diff --git a/src/app/Backends/PGP.php b/src/app/Backends/PGP.php index 88f0fd55..6e6e799f 100644 --- a/src/app/Backends/PGP.php +++ b/src/app/Backends/PGP.php @@ -1,208 +1,254 @@ deleteDirectory($homedir); } else { Storage::disk('pgp')->delete(Storage::disk('pgp')->files($homedir)); foreach (Storage::disk('pgp')->files($homedir) as $subdir) { Storage::disk('pgp')->deleteDirectory($subdir); } } // Remove all files from the Enigma database // Note: This will cause existing files in the Roundcube filesystem // to be removed, but only if the user used the Enigma functionality Roundcube::enigmaCleanup($user->email); } /** * Generate a keypair. * This will also initialize the user GPG homedir content. * * @param \App\User $user User object * @param string $email Email address to use for the key * * @throws \Exception */ public static function keypairCreate(User $user, string $email): void { self::initGPG($user, true); if ($user->email === $email) { // Make sure the homedir is empty for a new user self::homedirCleanup($user); } $keygen = new \Crypt_GPG_KeyGenerator(self::$config); $key = $keygen // ->setPassphrase() // ->setExpirationDate(0) ->setKeyParams(\Crypt_GPG_SubKey::ALGORITHM_RSA, \config('pgp.length')) ->setSubKeyParams(\Crypt_GPG_SubKey::ALGORITHM_RSA, \config('pgp.length')) ->generateKey(null, $email); // Store the keypair in Roundcube Enigma storage self::dbSave(true); // Get the ASCII armored data of the public key $armor = self::$gpg->exportPublicKey((string) $key, true); // Register the public key in DNS self::keyRegister($email, $armor); // FIXME: Should we remove the files from the worker filesystem? // They are still in database and Roundcube hosts' filesystem } + /** + * Deleta a keypair from DNS and Enigma keyring. + * + * @param \App\User $user User object + * @param string $email Email address of the key + * + * @throws \Exception + */ + public static function keyDelete(User $user, string $email): void + { + // Start with the DNS, it's more important + self::keyUnregister($email); + + // Remove the whole Enigma keyring (if it's a delete user account) + if ($user->email === $email) { + self::homedirCleanup($user); + } else { + // TODO: remove only the alias key from Enigma keyring + } + } + /** * List (public and private) keys from a user keyring. * * @param \App\User $user User object * * @returns \Crypt_GPG_Key[] List of keys * @throws \Exception */ public static function listKeys(User $user): array { self::initGPG($user); return self::$gpg->getKeys(''); } /** * Debug logging callback */ public static function logDebug($msg): void { \Log::debug("[GPG] $msg"); } /** * Register the key in the WOAT DNS system * * @param string $email Email address * @param string $key The ASCII-armored key content */ - public static function keyRegister(string $email, string $key) + private static function keyRegister(string $email, string $key): void { - // TODO + list($local, $domain) = \App\Utils::normalizeAddress($email, true); + + DB::beginTransaction(); + + $domain = \App\PowerDNS\Domain::firstOrCreate([ + 'name' => '_woat.' . $domain, + ]); + + \App\PowerDNS\Record::create([ + 'domain_id' => $domain->id, + 'name' => sha1($local) . '.' . $domain->name, + 'type' => 'TXT', + 'content' => 'v=woat1,public_key=' . $key + ]); + + DB::commit(); } /** * Remove the key from the WOAT DNS system * * @param string $email Email address */ - public static function keyUnregister(string $email) + private static function keyUnregister(string $email): void { - // TODO + list($local, $domain) = \App\Utils::normalizeAddress($email, true); + + $domain = \App\PowerDNS\Domain::where('name', '_woat.' . $domain)->first(); + + if ($domain) { + $fqdn = sha1($local) . '.' . $domain->name; + + // For now we support only one WOAT key record + $domain->records()->where('name', $fqdn)->delete(); + } } /** * Prepare Crypt_GPG configuration */ private static function initConfig(User $user, $nosync = false): void { if (!empty(self::$config) && self::$config['email'] == $user->email) { return; } $debug = \config('app.debug'); $binary = \config('pgp.binary'); $agent = \config('pgp.agent'); $gpgconf = \config('pgp.gpgconf'); $dir = self::setHomedir($user); $options = [ 'email' => $user->email, // this one is not a Crypt_GPG option 'dir' => $dir, // this one is not a Crypt_GPG option 'homedir' => \config('filesystems.disks.pgp.root') . '/' . $dir, 'debug' => $debug ? 'App\Backends\PGP::logDebug' : null, ]; if ($binary) { $options['binary'] = $binary; } if ($agent) { $options['agent'] = $agent; } if ($gpgconf) { $options['gpgconf'] = $gpgconf; } self::$config = $options; // Sync the homedir directory content with the Enigma storage if (!$nosync) { self::dbSync(); } } /** * Initialize Crypt_GPG */ private static function initGPG(User $user, $nosync = false): void { self::initConfig($user, $nosync); self::$gpg = new \Crypt_GPG(self::$config); } /** * Prepare a homedir for the user */ private static function setHomedir(User $user): string { // Create a subfolder using two first digits of the user ID $dir = sprintf('%02d', substr((string) $user->id, 0, 2)) . '/' . $user->email; Storage::disk('pgp')->makeDirectory($dir); return $dir; } /** * Synchronize keys database of a user */ private static function dbSync(): void { Roundcube::enigmaSync(self::$config['email'], self::$config['dir']); } /** * Save the keys database */ private static function dbSave($is_empty = false): void { Roundcube::enigmaSave(self::$config['email'], self::$config['dir'], $is_empty); } } diff --git a/src/app/Console/Commands/OwnerSwapCommand.php b/src/app/Console/Commands/OwnerSwapCommand.php new file mode 100644 index 00000000..bd59c3fb --- /dev/null +++ b/src/app/Console/Commands/OwnerSwapCommand.php @@ -0,0 +1,115 @@ +argument('current-user') == $this->argument('target-user')) { + $this->error('Users cannot be the same.'); + return 1; + } + + $user = $this->getUser($this->argument('current-user')); + + if (!$user) { + $this->error('User not found.'); + return 1; + } + + $target = $this->getUser($this->argument('target-user')); + + if (!$target) { + $this->error('User not found.'); + return 1; + } + + $wallet = $user->wallets->first(); + $target_wallet = $target->wallets->first(); + + if ($wallet->id != $target->wallet()->id) { + $this->error('The target user does not belong to the same account.'); + return 1; + } + + Queue::fake(); + + DB::beginTransaction(); + + // Switch wallet for existing entitlements + $wallet->entitlements()->withTrashed()->update(['wallet_id' => $target_wallet->id]); + + // Update target user's created_at timestamp to the source user's created_at. + // This is needed because we use this date when charging entitlements, + // i.e. the first month is free. + $dt = \now()->subMonthsWithoutOverflow(1); + if ($target->created_at > $dt && $target->created_at > $user->created_at) { + $target->created_at = $user->created_at; + $target->save(); + } + + // Migrate wallet properties + $target_wallet->balance = $wallet->balance; + $target_wallet->currency = $wallet->currency; + $target_wallet->save(); + + $wallet->balance = 0; + $wallet->save(); + + // Migrate wallet settings + $settings = $wallet->settings()->get(); + + \App\WalletSetting::where('wallet_id', $wallet->id)->delete(); + \App\WalletSetting::where('wallet_id', $target_wallet->id)->delete(); + + foreach ($settings as $setting) { + $target_wallet->setSetting($setting->key, $setting->value); + } + + DB::commit(); + + // Update mollie/stripe customer email (which point to the old wallet id) + $this->updatePaymentCustomer($target_wallet); + } + + /** + * Update user/wallet metadata at payment provider + * + * @param \App\Wallet $wallet The wallet + */ + private function updatePaymentCustomer(\App\Wallet $wallet): void + { + if ($mollie_id = $wallet->getSetting('mollie_id')) { + mollie()->customers()->update($mollie_id, [ + 'name' => $wallet->owner->name(), + 'email' => $wallet->id . '@private.' . \config('app.domain'), + ]); + } + + // TODO: Stripe + } +} diff --git a/src/app/Console/Commands/UserAddAlias.php b/src/app/Console/Commands/User/AddAliasCommand.php similarity index 91% rename from src/app/Console/Commands/UserAddAlias.php rename to src/app/Console/Commands/User/AddAliasCommand.php index 59c4d62a..d891ec86 100644 --- a/src/app/Console/Commands/UserAddAlias.php +++ b/src/app/Console/Commands/User/AddAliasCommand.php @@ -1,59 +1,60 @@ getUser($this->argument('user')); if (!$user) { + $this->error("User not found."); return 1; } $alias = \strtolower($this->argument('alias')); // Check if the alias already exists if ($user->aliases()->where('alias', $alias)->first()) { $this->error("Address is already assigned to the user."); return 1; } $controller = $user->wallet()->owner; // Validate the alias $error = UsersController::validateAlias($alias, $controller); if ($error) { if (!$this->option('force')) { $this->error($error); return 1; } } $user->aliases()->create(['alias' => $alias]); } } diff --git a/src/app/Console/Commands/UserAssignSku.php b/src/app/Console/Commands/User/AssignSkuCommand.php similarity index 88% rename from src/app/Console/Commands/UserAssignSku.php rename to src/app/Console/Commands/User/AssignSkuCommand.php index e36d6188..6b316098 100644 --- a/src/app/Console/Commands/UserAssignSku.php +++ b/src/app/Console/Commands/User/AssignSkuCommand.php @@ -1,56 +1,56 @@ getUser($this->argument('user')); if (!$user) { - $this->error("Unable to find the user {$this->argument('user')}."); + $this->error("User not found."); return 1; } $sku = $this->getObject(\App\Sku::class, $this->argument('sku'), 'title'); if (!$sku) { $this->error("Unable to find the SKU {$this->argument('sku')}."); return 1; } $quantity = (int) $this->option('qty'); // Check if the entitlement already exists if (empty($quantity)) { if ($user->entitlements()->where('sku_id', $sku->id)->first()) { $this->error("The entitlement already exists. Maybe try with --qty=X?"); return 1; } } $user->assignSku($sku, $quantity ?: 1); } } diff --git a/src/app/Console/Commands/UserDomains.php b/src/app/Console/Commands/User/DomainsCommand.php similarity index 58% rename from src/app/Console/Commands/UserDomains.php rename to src/app/Console/Commands/User/DomainsCommand.php index ba2a0fc3..681a879c 100644 --- a/src/app/Console/Commands/UserDomains.php +++ b/src/app/Console/Commands/User/DomainsCommand.php @@ -1,40 +1,41 @@ getUser($this->argument('userid')); + $user = $this->getUser($this->argument('user')); if (!$user) { + $this->error("User not found."); return 1; } foreach ($user->domains() as $domain) { - $this->info("{$domain->namespace}"); + $this->info($domain->namespace); } } } diff --git a/src/app/Console/Commands/UserEntitlements.php b/src/app/Console/Commands/User/EntitlementsCommand.php similarity index 84% rename from src/app/Console/Commands/UserEntitlements.php rename to src/app/Console/Commands/User/EntitlementsCommand.php index e3253b88..331fe3ca 100644 --- a/src/app/Console/Commands/UserEntitlements.php +++ b/src/app/Console/Commands/User/EntitlementsCommand.php @@ -1,53 +1,52 @@ getUser($this->argument('userid')); if (!$user) { + $this->error("User not found."); return 1; } - $this->info("Found user: {$user->id}"); - $skus_counted = []; foreach ($user->entitlements as $entitlement) { if (!array_key_exists($entitlement->sku_id, $skus_counted)) { $skus_counted[$entitlement->sku_id] = 1; } else { $skus_counted[$entitlement->sku_id] += 1; } } foreach ($skus_counted as $id => $qty) { $sku = \App\Sku::find($id); - $this->info("SKU: {$sku->title} ({$qty})"); + $this->info("{$sku->title}: {$qty}"); } } } diff --git a/src/app/Console/Commands/UserForceDelete.php b/src/app/Console/Commands/User/ForceDeleteCommand.php similarity index 80% rename from src/app/Console/Commands/UserForceDelete.php rename to src/app/Console/Commands/User/ForceDeleteCommand.php index eb818d2a..03fba65b 100644 --- a/src/app/Console/Commands/UserForceDelete.php +++ b/src/app/Console/Commands/User/ForceDeleteCommand.php @@ -1,46 +1,47 @@ getUser($this->argument('user'), true); if (!$user) { + $this->error("User not found."); return 1; } if (!$user->trashed()) { - $this->error('The user is not yet deleted'); + $this->error("The user is not yet deleted."); return 1; } DB::beginTransaction(); $user->forceDelete(); DB::commit(); } } diff --git a/src/app/Console/Commands/User/GreylistCommand.php b/src/app/Console/Commands/User/GreylistCommand.php index 76710b44..59ecab6f 100644 --- a/src/app/Console/Commands/User/GreylistCommand.php +++ b/src/app/Console/Commands/User/GreylistCommand.php @@ -1,73 +1,63 @@ argument('user'); $recipientHash = hash('sha256', $recipientAddress); $lastConnect = \App\Policy\Greylist\Connect::where('recipient_hash', $recipientHash) ->orderBy('updated_at', 'desc') ->first(); if ($lastConnect) { $timestamp = $lastConnect->updated_at->copy(); $this->info("Going from timestamp (last connect) {$timestamp}"); } else { $timestamp = \Carbon\Carbon::now(); $this->info("Going from timestamp (now) {$timestamp}"); } \App\Policy\Greylist\Connect::where('recipient_hash', $recipientHash) ->where('greylisting', true) ->whereDate('updated_at', '>=', $timestamp->copy()->subDays(7)) ->orderBy('created_at')->each( function ($connect) { $this->info( sprintf( "From %s@%s since %s", $connect->sender_local, $connect->sender_domain, $connect->created_at ) ); } ); } } diff --git a/src/app/Console/Commands/UserRestore.php b/src/app/Console/Commands/User/RestoreCommand.php similarity index 81% rename from src/app/Console/Commands/UserRestore.php rename to src/app/Console/Commands/User/RestoreCommand.php index dbf8906b..f1fdac09 100644 --- a/src/app/Console/Commands/UserRestore.php +++ b/src/app/Console/Commands/User/RestoreCommand.php @@ -1,47 +1,47 @@ getUser($this->argument('user'), true); if (!$user) { - $this->error('User not found.'); + $this->error("User not found."); return 1; } if (!$user->trashed()) { - $this->error('The user is not yet deleted.'); + $this->error("The user is not deleted."); return 1; } DB::beginTransaction(); $user->restore(); DB::commit(); } } diff --git a/src/app/Console/Commands/UserDiscount.php b/src/app/Console/Commands/User/SetDiscountCommand.php similarity index 81% rename from src/app/Console/Commands/UserDiscount.php rename to src/app/Console/Commands/User/SetDiscountCommand.php index 5d85a822..2fae46dc 100644 --- a/src/app/Console/Commands/UserDiscount.php +++ b/src/app/Console/Commands/User/SetDiscountCommand.php @@ -1,58 +1,58 @@ getUser($this->argument('user')); if (!$user) { + $this->error("User not found."); return 1; } - $this->info("Found user {$user->id}"); - if ($this->argument('discount') === '0') { $discount = null; } else { $discount = $this->getObject(\App\Discount::class, $this->argument('discount')); if (!$discount) { + $this->error("Discount not found."); return 1; } } foreach ($user->wallets as $wallet) { if (!$discount) { $wallet->discount()->dissociate(); } else { $wallet->discount()->associate($discount); } $wallet->save(); } } } diff --git a/src/app/Console/Commands/User/StatusCommand.php b/src/app/Console/Commands/User/StatusCommand.php index 564d3944..402e6b27 100644 --- a/src/app/Console/Commands/User/StatusCommand.php +++ b/src/app/Console/Commands/User/StatusCommand.php @@ -1,71 +1,58 @@ withEnvTenantContext()->where('email', $this->argument('user'))->first(); - - if (!$user) { - $user = \App\User::withTrashed()->withEnvTenantContext()->where('id', $this->argument('user'))->first(); - } + $user = $this->getUser($this->argument('user'), true); if (!$user) { - $this->error("No such user '" . $this->argument('user') . "' within this tenant context."); - $this->info("Try ./artisan scalpel:user:read --attr=email --attr=tenant_id " . $this->argument('user')); + $this->error("User not found."); + $this->error("Try ./artisan scalpel:user:read --attr=email --attr=tenant_id " . $this->argument('user')); return 1; } $statuses = [ - 'active' => \App\User::STATUS_ACTIVE, - 'suspended' => \App\User::STATUS_SUSPENDED, - 'deleted' => \App\User::STATUS_DELETED, - 'ldapReady' => \App\User::STATUS_LDAP_READY, - 'imapReady' => \App\User::STATUS_IMAP_READY, + 'active' => User::STATUS_ACTIVE, + 'suspended' => User::STATUS_SUSPENDED, + 'deleted' => User::STATUS_DELETED, + 'ldapReady' => User::STATUS_LDAP_READY, + 'imapReady' => User::STATUS_IMAP_READY, ]; $user_state = []; foreach (\array_keys($statuses) as $state) { $func = 'is' . \ucfirst($state); if ($user->$func()) { $user_state[] = $state; } } $this->info("Status: " . \implode(',', $user_state)); } } diff --git a/src/app/Console/Commands/UserSuspend.php b/src/app/Console/Commands/User/SuspendCommand.php similarity index 81% rename from src/app/Console/Commands/UserSuspend.php rename to src/app/Console/Commands/User/SuspendCommand.php index c213d23b..6bcbfa63 100644 --- a/src/app/Console/Commands/UserSuspend.php +++ b/src/app/Console/Commands/User/SuspendCommand.php @@ -1,41 +1,39 @@ getUser($this->argument('user')); if (!$user) { + $this->error("User not found."); return 1; } - $this->info("Found user: {$user->id}"); - $user->suspend(); } } diff --git a/src/app/Console/Commands/UserUnsuspend.php b/src/app/Console/Commands/User/UnsuspendCommand.php similarity index 75% rename from src/app/Console/Commands/UserUnsuspend.php rename to src/app/Console/Commands/User/UnsuspendCommand.php index dbbd32e5..b18f157f 100644 --- a/src/app/Console/Commands/UserUnsuspend.php +++ b/src/app/Console/Commands/User/UnsuspendCommand.php @@ -1,40 +1,39 @@ getUser($this->argument('user')); if (!$user) { + $this->error("User not found."); return 1; } - $this->info("Found user {$user->id}"); - $user->unsuspend(); } } diff --git a/src/app/Console/Commands/UserVerify.php b/src/app/Console/Commands/User/VerifyCommand.php similarity index 76% rename from src/app/Console/Commands/UserVerify.php rename to src/app/Console/Commands/User/VerifyCommand.php index 5830bf3a..51ed99d5 100644 --- a/src/app/Console/Commands/UserVerify.php +++ b/src/app/Console/Commands/User/VerifyCommand.php @@ -1,41 +1,42 @@ getUser($this->argument('user')); if (!$user) { + $this->error("User not found."); return 1; } - $this->info("Found user: {$user->id}"); - $job = new \App\Jobs\User\VerifyJob($user->id); $job->handle(); + + // TODO: We should check the job result and print an error on failure } } diff --git a/src/app/Console/Commands/UserWallets.php b/src/app/Console/Commands/User/WalletsCommand.php similarity index 78% rename from src/app/Console/Commands/UserWallets.php rename to src/app/Console/Commands/User/WalletsCommand.php index f522efa8..065b5305 100644 --- a/src/app/Console/Commands/UserWallets.php +++ b/src/app/Console/Commands/User/WalletsCommand.php @@ -1,40 +1,41 @@ getUser($this->argument('user')); if (!$user) { + $this->error("User not found."); return 1; } foreach ($user->wallets as $wallet) { $this->info("{$wallet->id} {$wallet->description}"); } } } diff --git a/src/app/Console/Commands/UserDelete.php b/src/app/Console/Commands/UserDelete.php deleted file mode 100644 index eb74376f..00000000 --- a/src/app/Console/Commands/UserDelete.php +++ /dev/null @@ -1,38 +0,0 @@ -getUser($this->argument('user')); - - if (!$user) { - return 1; - } - - $user->delete(); - } -} diff --git a/src/app/Console/Commands/WalletAddTransaction.php b/src/app/Console/Commands/Wallet/AddTransactionCommand.php similarity index 76% rename from src/app/Console/Commands/WalletAddTransaction.php rename to src/app/Console/Commands/Wallet/AddTransactionCommand.php index 2c52a811..907d66c3 100644 --- a/src/app/Console/Commands/WalletAddTransaction.php +++ b/src/app/Console/Commands/Wallet/AddTransactionCommand.php @@ -1,46 +1,47 @@ getWallet($this->argument('wallet')); if (!$wallet) { + $this->error("Wallet not found."); return 1; } $qty = (int) $this->argument('qty'); - $message = $this->option('message'); + $message = (string) $this->option('message'); if ($qty < 0) { - $wallet->debit($qty, $message); + $wallet->debit(-$qty, $message); } else { $wallet->credit($qty, $message); } } } diff --git a/src/app/Console/Commands/WalletBalances.php b/src/app/Console/Commands/Wallet/BalancesCommand.php similarity index 81% rename from src/app/Console/Commands/WalletBalances.php rename to src/app/Console/Commands/Wallet/BalancesCommand.php index d0821f4b..12d0a48b 100644 --- a/src/app/Console/Commands/WalletBalances.php +++ b/src/app/Console/Commands/Wallet/BalancesCommand.php @@ -1,60 +1,54 @@ join('users', 'users.id', '=', 'wallets.user_id') ->withEnvTenantContext('users') - ->all(); + ->where('balance', '!=', '0') + ->whereNull('users.deleted_at') + ->orderBy('balance'); $wallets->each( function ($wallet) { - if ($wallet->balance == 0) { - return; - } - $user = $wallet->owner; - if (!$user) { - return; - } - $this->info( sprintf( "%s: %8s (account: %s/%s (%s))", $wallet->id, $wallet->balance, "https://kolabnow.com/cockpit/admin/accounts/show", $user->id, $user->email ) ); } ); } } diff --git a/src/app/Console/Commands/WalletCharge.php b/src/app/Console/Commands/Wallet/ChargeCommand.php similarity index 81% rename from src/app/Console/Commands/WalletCharge.php rename to src/app/Console/Commands/Wallet/ChargeCommand.php index 6ca62cb0..268ae556 100644 --- a/src/app/Console/Commands/WalletCharge.php +++ b/src/app/Console/Commands/Wallet/ChargeCommand.php @@ -1,67 +1,72 @@ argument('wallet')) { // Find specified wallet by ID $wallet = $this->getWallet($wallet); - if (!$wallet || !$wallet->owner) { + if (!$wallet) { + $this->error("Wallet not found."); + return 1; + } + + if (!$wallet->owner) { + $this->error("Wallet's owner is deleted."); return 1; } $wallets = [$wallet]; } else { // Get all wallets, excluding deleted accounts - $wallets = Wallet::select('wallets.*') + $wallets = \App\Wallet::select('wallets.*') ->join('users', 'users.id', '=', 'wallets.user_id') ->withEnvTenantContext('users') ->whereNull('users.deleted_at') ->cursor(); } foreach ($wallets as $wallet) { $charge = $wallet->chargeEntitlements(); if ($charge > 0) { $this->info( "Charged wallet {$wallet->id} for user {$wallet->owner->email} with {$charge}" ); // Top-up the wallet if auto-payment enabled for the wallet \App\Jobs\WalletCharge::dispatch($wallet); } if ($wallet->balance < 0) { // Check the account balance, send notifications, suspend, delete \App\Jobs\WalletCheck::dispatch($wallet); } } } } diff --git a/src/app/Console/Commands/WalletExpected.php b/src/app/Console/Commands/Wallet/ExpectedCommand.php similarity index 80% rename from src/app/Console/Commands/WalletExpected.php rename to src/app/Console/Commands/Wallet/ExpectedCommand.php index 8bd2a523..a799a0f4 100644 --- a/src/app/Console/Commands/WalletExpected.php +++ b/src/app/Console/Commands/Wallet/ExpectedCommand.php @@ -1,68 +1,64 @@ option('user')) { $user = $this->getUser($this->option('user')); if (!$user) { + $this->error("User not found."); return 1; } $wallets = $user->wallets; } else { $wallets = \App\Wallet::select('wallets.*') ->join('users', 'users.id', '=', 'wallets.user_id') ->withEnvTenantContext('users') - ->all(); + ->whereNull('users.deleted_at'); } - foreach ($wallets as $wallet) { + $wallets->each(function ($wallet) { $charge = 0; $expected = $wallet->expectedCharges(); - if (!$wallet->owner) { - \Log::debug("{$wallet->id} has no owner: {$wallet->user_id}"); - continue; - } - if ($this->option('non-zero') && $expected < 1) { - continue; + return; } $this->info( sprintf( "expect charging wallet %s for user %s with %d", $wallet->id, $wallet->owner->email, $expected ) ); - } + }); } } diff --git a/src/app/Console/Commands/WalletGetBalance.php b/src/app/Console/Commands/Wallet/GetBalanceCommand.php similarity index 83% rename from src/app/Console/Commands/WalletGetBalance.php rename to src/app/Console/Commands/Wallet/GetBalanceCommand.php index 661177dc..e459d604 100644 --- a/src/app/Console/Commands/WalletGetBalance.php +++ b/src/app/Console/Commands/Wallet/GetBalanceCommand.php @@ -1,38 +1,39 @@ getWallet($this->argument('wallet')); if (!$wallet) { + $this->error("Wallet not found."); return 1; } $this->info($wallet->balance); } } diff --git a/src/app/Console/Commands/WalletGetDiscount.php b/src/app/Console/Commands/Wallet/GetDiscountCommand.php similarity index 85% rename from src/app/Console/Commands/WalletGetDiscount.php rename to src/app/Console/Commands/Wallet/GetDiscountCommand.php index 6fdbf0ca..52378f67 100644 --- a/src/app/Console/Commands/WalletGetDiscount.php +++ b/src/app/Console/Commands/Wallet/GetDiscountCommand.php @@ -1,43 +1,44 @@ getWallet($this->argument('wallet')); if (!$wallet) { + $this->error("Wallet not found."); return 1; } if (!$wallet->discount) { $this->info("No discount on this wallet."); return 0; } $this->info($wallet->discount->discount); } } diff --git a/src/app/Console/Commands/WalletMandate.php b/src/app/Console/Commands/Wallet/MandateCommand.php similarity index 88% rename from src/app/Console/Commands/WalletMandate.php rename to src/app/Console/Commands/Wallet/MandateCommand.php index f13265c5..c81d5f7c 100644 --- a/src/app/Console/Commands/WalletMandate.php +++ b/src/app/Console/Commands/Wallet/MandateCommand.php @@ -1,60 +1,61 @@ getWallet($this->argument('wallet')); if (!$wallet) { + $this->error("Wallet not found."); return 1; } $mandate = PaymentsController::walletMandate($wallet); if (!empty($mandate['id'])) { $disabled = $mandate['isDisabled'] ? 'Yes' : 'No'; if ($this->option('disable') && $disabled == 'No') { $wallet->setSetting('mandate_disabled', 1); $disabled = 'Yes'; } elseif ($this->option('enable') && $disabled == 'Yes') { $wallet->setSetting('mandate_disabled', null); $disabled = 'No'; } $this->info("Auto-payment: {$mandate['method']}"); $this->info(" id: {$mandate['id']}"); $this->info(" status: " . ($mandate['isPending'] ? 'pending' : 'valid')); $this->info(" amount: {$mandate['amount']} {$wallet->currency}"); $this->info(" min-balance: {$mandate['balance']} {$wallet->currency}"); $this->info(" disabled: $disabled"); } else { $this->info("Auto-payment: none"); } } } diff --git a/src/app/Console/Commands/WalletSetBalance.php b/src/app/Console/Commands/Wallet/SetBalanceCommand.php similarity index 84% rename from src/app/Console/Commands/WalletSetBalance.php rename to src/app/Console/Commands/Wallet/SetBalanceCommand.php index d3a082bc..7ed57996 100644 --- a/src/app/Console/Commands/WalletSetBalance.php +++ b/src/app/Console/Commands/Wallet/SetBalanceCommand.php @@ -1,39 +1,40 @@ getWallet($this->argument('wallet')); if (!$wallet) { + $this->error("Wallet not found."); return 1; } $wallet->balance = (int) $this->argument('balance'); $wallet->save(); } } diff --git a/src/app/Console/Commands/WalletSetDiscount.php b/src/app/Console/Commands/Wallet/SetDiscountCommand.php similarity index 85% rename from src/app/Console/Commands/WalletSetDiscount.php rename to src/app/Console/Commands/Wallet/SetDiscountCommand.php index 50e4e9d6..ee48af92 100644 --- a/src/app/Console/Commands/WalletSetDiscount.php +++ b/src/app/Console/Commands/Wallet/SetDiscountCommand.php @@ -1,52 +1,54 @@ getWallet($this->argument('wallet')); if (!$wallet) { + $this->error("Wallet not found."); return 1; } // FIXME: Using '0' for delete might be not that obvious if ($this->argument('discount') === '0') { $wallet->discount()->dissociate(); } else { $discount = $this->getObject(\App\Discount::class, $this->argument('discount')); if (!$discount) { + $this->error("Discount not found."); return 1; } $wallet->discount()->associate($discount); } $wallet->save(); } } diff --git a/src/app/Console/Commands/WalletSettingsCommand.php b/src/app/Console/Commands/Wallet/SettingsCommand.php similarity index 68% rename from src/app/Console/Commands/WalletSettingsCommand.php rename to src/app/Console/Commands/Wallet/SettingsCommand.php index a6a7afc2..ac135df0 100644 --- a/src/app/Console/Commands/WalletSettingsCommand.php +++ b/src/app/Console/Commands/Wallet/SettingsCommand.php @@ -1,12 +1,12 @@ getWallet($this->argument('wallet')); if (!$wallet) { + $this->error("Wallet not found."); return 1; } $wallet->transactions()->orderBy('created_at')->each(function ($transaction) { $this->info( sprintf( "%s: %s %s", $transaction->id, $transaction->created_at, $transaction->toString() ) ); if ($this->option('detail')) { $elements = \App\Transaction::where('transaction_id', $transaction->id) ->orderBy('created_at')->get(); foreach ($elements as $element) { $this->info( sprintf( " + %s: %s", $element->id, $element->toString() ) ); } } }); } } diff --git a/src/app/Console/Commands/WalletUntil.php b/src/app/Console/Commands/Wallet/UntilCommand.php similarity index 85% rename from src/app/Console/Commands/WalletUntil.php rename to src/app/Console/Commands/Wallet/UntilCommand.php index 8d4d7eb2..69c97f14 100644 --- a/src/app/Console/Commands/WalletUntil.php +++ b/src/app/Console/Commands/Wallet/UntilCommand.php @@ -1,40 +1,41 @@ getWallet($this->argument('wallet')); if (!$wallet) { + $this->error("Wallet not found."); return 1; } $until = $wallet->balanceLastsUntil(); $this->info("Lasts until: " . ($until ? $until->toDateString() : 'unknown')); } } diff --git a/src/app/Console/Commands/WalletDiscount.php b/src/app/Console/Commands/WalletDiscount.php deleted file mode 100644 index 30327913..00000000 --- a/src/app/Console/Commands/WalletDiscount.php +++ /dev/null @@ -1,52 +0,0 @@ -getWallet($this->argument('wallet')); - - if (!$wallet) { - return 1; - } - - // FIXME: Using '0' for delete might be not that obvious - - if ($this->argument('discount') === '0') { - $wallet->discount()->dissociate(); - } else { - $discount = $this->getObject(\App\Discount::class, $this->argument('discount')); - - if (!$discount) { - return 1; - } - - $wallet->discount()->associate($discount); - } - - $wallet->save(); - } -} diff --git a/src/app/Domain.php b/src/app/Domain.php index 092ee7bf..c428ac96 100644 --- a/src/app/Domain.php +++ b/src/app/Domain.php @@ -1,505 +1,542 @@ 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(), 'fee' => $sku->pivot->fee(), 'entitleable_id' => $this->id, 'entitleable_type' => Domain::class ] ); } } return $this; } /** * The domain entitlement. * * @return \Illuminate\Database\Eloquent\Relations\MorphOne */ public function entitlement() { return $this->morphOne('App\Entitlement', 'entitleable'); } + /** + * Entitlements for this domain. + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function entitlements() + { + return $this->hasMany('App\Entitlement', 'entitleable_id', 'id') + ->where('entitleable_type', Domain::class); + } + /** * Return list of public+active domain names (for current tenant) */ public static function getPublicDomains(): array { return self::withEnvTenantContext() ->whereRaw(sprintf('(type & %s)', Domain::TYPE_PUBLIC)) ->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. */ public function setNamespaceAttribute($namespace) { $this->attributes['namespace'] = strtolower($namespace); } /** * Domain 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_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; } + /** + * Checks if there are any objects (users/aliases/groups) in a domain. + * Note: Public domains are always reported not empty. + * + * @return bool True if there are no objects assigned, False otherwise + */ + public function isEmpty(): bool + { + if ($this->isPublic()) { + return false; + } + + // FIXME: These queries will not use indexes, so maybe we should consider + // wallet/entitlements to search in objects that belong to this domain account? + + $suffix = '@' . $this->namespace; + $suffixLen = strlen($suffix); + + return !( + \App\User::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists() + || \App\UserAlias::whereRaw('substr(alias, ?) = ?', [-$suffixLen, $suffix])->exists() + || \App\Group::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists() + ); + } + /** * Any (additional) properties of this domain. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function settings() { return $this->hasMany('App\DomainSetting', 'domain_id'); } /** * 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(); } /** * List the users of a domain, so long as the domain is not a public registration domain. + * Note: It returns only users with a mailbox. * - * @return array + * @return \App\User[] A list of users */ public function users(): array { if ($this->isPublic()) { return []; } $wallet = $this->wallet(); if (!$wallet) { return []; } $mailboxSKU = \App\Sku::withObjectTenantContext($this)->where('title', 'mailbox')->first(); if (!$mailboxSKU) { \Log::error("No mailbox SKU available."); return []; } $entitlements = $wallet->entitlements() ->where('entitleable_type', \App\User::class) ->where('sku_id', $mailboxSKU->id)->get(); $users = []; foreach ($entitlements as $entitlement) { $users[] = $entitlement->entitleable; } return $users; } /** * 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()->orderBy('created_at', 'desc')->first(); return $entitlement ? $entitlement->wallet : null; } } diff --git a/src/app/Entitlement.php b/src/app/Entitlement.php index dd7570a0..22057172 100644 --- a/src/app/Entitlement.php +++ b/src/app/Entitlement.php @@ -1,148 +1,175 @@ 'integer', 'fee' => '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(); $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 object such as Domain, User, Group. * Note that it may be trashed (soft-deleted). * * @return mixed */ public function entitleable() { return $this->morphTo()->withTrashed(); // @phpstan-ignore-line } /** * 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\Domain) { return $this->entitleable->namespace; } return $this->entitleable->email; } + /** + * Simplified Entitlement/SKU information for a specified entitleable object + * + * @param object $object Entitleable object + * + * @return array Skus list with some metadata + */ + public static function objectEntitlementsSummary($object): array + { + $skus = []; + + // TODO: I agree this format may need to be extended in future + + foreach ($object->entitlements as $ent) { + $sku = $ent->sku; + + if (!isset($skus[$sku->id])) { + $skus[$sku->id] = ['costs' => [], 'count' => 0]; + } + + $skus[$sku->id]['count']++; + $skus[$sku->id]['costs'][] = $ent->cost; + } + + return $skus; + } + /** * 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/Handlers/Base.php b/src/app/Handlers/Base.php index 349641e2..f01487ba 100644 --- a/src/app/Handlers/Base.php +++ b/src/app/Handlers/Base.php @@ -1,107 +1,107 @@ active) { - if (!$user->entitlements()->where('sku_id', $sku->id)->first()) { + if (!$object->entitlements()->where('sku_id', $sku->id)->first()) { return false; } } return true; } /** * Metadata of this SKU handler. * * @param \App\Sku $sku The SKU object * * @return array */ public static function metadata(\App\Sku $sku): array { $handler = explode('\\', static::class); $handler = strtolower(end($handler)); $type = explode('\\', static::entitleableClass()); $type = strtolower(end($type)); return [ // entitleable type 'type' => $type, // handler (as a keyword) 'handler' => $handler, // readonly entitlement state cannot be changed 'readonly' => false, // is entitlement enabled by default? 'enabled' => false, // priority on the entitlements list 'prio' => static::priority(), ]; } /** * Prerequisites for the Entitlement to be applied to the object. * * @param \App\Entitlement $entitlement * @param mixed $object * * @return bool */ public static function preReq($entitlement, $object): bool { $type = static::entitleableClass(); if (empty($type) || empty($entitlement->entitleable_type)) { \Log::error("Entitleable class/type not specified"); return false; } if ($type !== $entitlement->entitleable_type) { \Log::error("Entitleable class mismatch"); return false; } if (!$entitlement->sku->active) { \Log::error("Sku not active"); return false; } return true; } /** * The priority that specifies the order of SKUs in UI. * Higher number means higher on the list. * * @return int */ public static function priority(): int { return 0; } } diff --git a/src/app/Handlers/Beta/Base.php b/src/app/Handlers/Beta/Base.php index ea2cc882..bbca315e 100644 --- a/src/app/Handlers/Beta/Base.php +++ b/src/app/Handlers/Beta/Base.php @@ -1,66 +1,70 @@ active) { - return $user->hasSku('beta'); + return $object->hasSku('beta'); } else { - if ($user->entitlements()->where('sku_id', $sku->id)->first()) { + if ($object->entitlements()->where('sku_id', $sku->id)->first()) { return true; } } return false; } /** * SKU handler metadata. * * @param \App\Sku $sku The SKU object * * @return array */ public static function metadata(\App\Sku $sku): array { $data = parent::metadata($sku); $data['required'] = ['beta']; return $data; } /** * Prerequisites for the Entitlement to be applied to the object. * * @param \App\Entitlement $entitlement * @param mixed $object * * @return bool */ public static function preReq($entitlement, $object): bool { if (!parent::preReq($entitlement, $object)) { return false; } // TODO: User has to have the "beta" entitlement return true; } } diff --git a/src/app/Handlers/Distlist.php b/src/app/Handlers/Distlist.php index 9cb9edc3..d4d19ab9 100644 --- a/src/app/Handlers/Distlist.php +++ b/src/app/Handlers/Distlist.php @@ -1,49 +1,49 @@ wallet()->entitlements() + if (parent::isAvailable($sku, $object)) { + return $object->wallet()->entitlements() ->where('entitleable_type', \App\Domain::class)->count() > 0; } return false; } /** * The priority that specifies the order of SKUs in UI. * Higher number means higher on the list. * * @return int */ public static function priority(): int { return 10; } } diff --git a/src/app/Http/Controllers/API/V4/Admin/DomainsController.php b/src/app/Http/Controllers/API/V4/Admin/DomainsController.php index f9049f46..7ad9c324 100644 --- a/src/app/Http/Controllers/API/V4/Admin/DomainsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/DomainsController.php @@ -1,104 +1,128 @@ errorResponse(404); + } + /** * Search for domains * * @return \Illuminate\Http\JsonResponse */ public function index() { $search = trim(request()->input('search')); $owner = trim(request()->input('owner')); $result = collect([]); if ($owner) { if ($owner = User::find($owner)) { foreach ($owner->wallets as $wallet) { $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get(); foreach ($entitlements as $entitlement) { $domain = $entitlement->entitleable; $result->push($domain); } } $result = $result->sortBy('namespace')->values(); } } elseif (!empty($search)) { if ($domain = Domain::where('namespace', $search)->first()) { $result->push($domain); } } // Process the result $result = $result->map(function ($domain) { $data = $domain->toArray(); $data = array_merge($data, self::domainStatuses($domain)); return $data; }); $result = [ 'list' => $result, 'count' => count($result), 'message' => \trans('app.search-foundxdomains', ['x' => count($result)]), ]; return response()->json($result); } + /** + * Create a domain. + * + * @param \Illuminate\Http\Request $request + * + * @return \Illuminate\Http\JsonResponse + */ + public function store(Request $request) + { + return $this->errorResponse(404); + } + /** * Suspend the domain * * @param \Illuminate\Http\Request $request The API request. * @param string $id Domain identifier * * @return \Illuminate\Http\JsonResponse The response */ public function suspend(Request $request, $id) { $domain = Domain::find($id); if (!$this->checkTenant($domain) || $domain->isPublic()) { return $this->errorResponse(404); } $domain->suspend(); return response()->json([ 'status' => 'success', 'message' => \trans('app.domain-suspend-success'), ]); } /** * Un-Suspend the domain * * @param \Illuminate\Http\Request $request The API request. * @param string $id Domain identifier * * @return \Illuminate\Http\JsonResponse The response */ public function unsuspend(Request $request, $id) { $domain = Domain::find($id); if (!$this->checkTenant($domain) || $domain->isPublic()) { return $this->errorResponse(404); } $domain->unsuspend(); return response()->json([ 'status' => 'success', 'message' => \trans('app.domain-unsuspend-success'), ]); } } diff --git a/src/app/Http/Controllers/API/V4/Admin/StatsController.php b/src/app/Http/Controllers/API/V4/Admin/StatsController.php index e00b6da8..ecc4a7c2 100644 --- a/src/app/Http/Controllers/API/V4/Admin/StatsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/StatsController.php @@ -1,390 +1,428 @@ errorResponse(404); } $method = 'chart' . implode('', array_map('ucfirst', explode('-', $chart))); if (!in_array($chart, $this->charts) || !method_exists($this, $method)) { return $this->errorResponse(404); } $result = $this->{$method}(); return response()->json($result); } /** * Get discounts chart */ protected function chartDiscounts(): array { $discounts = DB::table('wallets') ->selectRaw("discount, count(discount_id) as cnt") ->join('discounts', 'discounts.id', '=', 'wallets.discount_id') ->join('users', 'users.id', '=', 'wallets.user_id') ->where('discount', '>', 0) ->whereNull('users.deleted_at') ->groupBy('discounts.discount'); $addTenantScope = function ($builder, $tenantId) { return $builder->where('users.tenant_id', $tenantId); }; $discounts = $this->applyTenantScope($discounts, $addTenantScope) ->pluck('cnt', 'discount')->all(); $labels = array_keys($discounts); $discounts = array_values($discounts); // $labels = [10, 25, 30, 100]; // $discounts = [100, 120, 30, 50]; $labels = array_map(function ($item) { return $item . '%'; }, $labels); - // See https://frappe.io/charts/docs for format/options description - - return [ - 'title' => 'Discounts', - 'type' => 'donut', - 'colors' => [ - self::COLOR_BLUE, - self::COLOR_BLUE_DARK, - self::COLOR_GREEN, - self::COLOR_GREEN_DARK, - self::COLOR_ORANGE, - self::COLOR_RED, - self::COLOR_RED_DARK - ], - 'maxSlices' => 8, - 'tooltipOptions' => [], // does not work without it (https://github.com/frappe/charts/issues/314) - 'data' => [ - 'labels' => $labels, - 'datasets' => [ - [ - 'values' => $discounts - ] - ] - ] - ]; + return $this->donutChart(\trans('app.chart-discounts'), $labels, $discounts); } /** * Get income chart */ protected function chartIncome(): array { $weeks = 8; $start = Carbon::now(); $labels = []; while ($weeks > 0) { $labels[] = $start->format('Y-W'); $weeks--; if ($weeks) { $start->subWeeks(1); } } $labels = array_reverse($labels); $start->startOfWeek(Carbon::MONDAY); // FIXME: We're using wallets.currency instead of payments.currency and payments.currency_amount // as I believe this way we have more precise amounts for this use-case (and default currency) $query = DB::table('payments') ->selectRaw("date_format(updated_at, '%Y-%v') as period, sum(amount) as amount, wallets.currency") ->join('wallets', 'wallets.id', '=', 'wallet_id') ->where('updated_at', '>=', $start->toDateString()) ->where('status', PaymentProvider::STATUS_PAID) ->whereIn('type', [PaymentProvider::TYPE_ONEOFF, PaymentProvider::TYPE_RECURRING]) ->groupByRaw('period, wallets.currency'); $addTenantScope = function ($builder, $tenantId) { $where = sprintf( '`wallets`.`user_id` IN (select `id` from `users` where `tenant_id` = %d)', $tenantId ); return $builder->whereRaw($where); }; $currency = $this->currency(); $payments = []; $this->applyTenantScope($query, $addTenantScope) ->get() ->each(function ($record) use (&$payments, $currency) { $amount = $record->amount; if ($record->currency != $currency) { $amount = intval(round($amount * \App\Utils::exchangeRate($record->currency, $currency))); } if (isset($payments[$record->period])) { $payments[$record->period] += $amount / 100; } else { $payments[$record->period] = $amount / 100; } }); // TODO: exclude refunds/chargebacks $empty = array_fill_keys($labels, 0); $payments = array_values(array_merge($empty, $payments)); // $payments = [1000, 1200.25, 3000, 1897.50, 2000, 1900, 2134, 3330]; $avg = collect($payments)->slice(0, count($labels) - 1)->avg(); // See https://frappe.io/charts/docs for format/options description return [ - 'title' => "Income in {$currency} - last 8 weeks", + 'title' => \trans('app.chart-income', ['currency' => $currency]), 'type' => 'bar', 'colors' => [self::COLOR_BLUE], 'axisOptions' => [ 'xIsSeries' => true, ], 'data' => [ 'labels' => $labels, 'datasets' => [ [ // 'name' => 'Payments', 'values' => $payments ] ], 'yMarkers' => [ [ 'label' => sprintf('average = %.2f', $avg), 'value' => $avg, 'options' => [ 'labelPos' => 'left' ] // default: 'right' ] ] ] ]; } /** * Get created/deleted users chart */ protected function chartUsers(): array { $weeks = 8; $start = Carbon::now(); $labels = []; while ($weeks > 0) { $labels[] = $start->format('Y-W'); $weeks--; if ($weeks) { $start->subWeeks(1); } } $labels = array_reverse($labels); $start->startOfWeek(Carbon::MONDAY); $created = DB::table('users') ->selectRaw("date_format(created_at, '%Y-%v') as period, count(*) as cnt") ->where('created_at', '>=', $start->toDateString()) ->groupByRaw('1'); $deleted = DB::table('users') ->selectRaw("date_format(deleted_at, '%Y-%v') as period, count(*) as cnt") ->where('deleted_at', '>=', $start->toDateString()) ->groupByRaw('1'); $created = $this->applyTenantScope($created)->get(); $deleted = $this->applyTenantScope($deleted)->get(); $empty = array_fill_keys($labels, 0); $created = array_values(array_merge($empty, $created->pluck('cnt', 'period')->all())); $deleted = array_values(array_merge($empty, $deleted->pluck('cnt', 'period')->all())); // $created = [5, 2, 4, 2, 0, 5, 2, 4]; // $deleted = [1, 2, 3, 1, 2, 1, 2, 3]; $avg = collect($created)->slice(0, count($labels) - 1)->avg(); // See https://frappe.io/charts/docs for format/options description return [ - 'title' => 'Users - last 8 weeks', + 'title' => \trans('app.chart-users'), 'type' => 'bar', // Required to fix https://github.com/frappe/charts/issues/294 'colors' => [self::COLOR_GREEN, self::COLOR_RED], 'axisOptions' => [ 'xIsSeries' => true, ], 'data' => [ 'labels' => $labels, 'datasets' => [ [ - 'name' => 'Created', + 'name' => \trans('app.chart-created'), 'chartType' => 'bar', 'values' => $created ], [ - 'name' => 'Deleted', + 'name' => \trans('app.chart-deleted'), 'chartType' => 'line', 'values' => $deleted ] ], 'yMarkers' => [ [ - 'label' => sprintf('average = %.1f', $avg), + 'label' => sprintf('%s = %.1f', \trans('app.chart-average'), $avg), 'value' => collect($created)->avg(), 'options' => [ 'labelPos' => 'left' ] // default: 'right' ] ] ] ]; } /** * Get all users chart */ protected function chartUsersAll(): array { $weeks = 54; $start = Carbon::now(); $labels = []; while ($weeks > 0) { $labels[] = $start->format('Y-W'); $weeks--; if ($weeks) { $start->subWeeks(1); } } $labels = array_reverse($labels); $start->startOfWeek(Carbon::MONDAY); $created = DB::table('users') ->selectRaw("date_format(created_at, '%Y-%v') as period, count(*) as cnt") ->where('created_at', '>=', $start->toDateString()) ->groupByRaw('1'); $deleted = DB::table('users') ->selectRaw("date_format(deleted_at, '%Y-%v') as period, count(*) as cnt") ->where('deleted_at', '>=', $start->toDateString()) ->groupByRaw('1'); $created = $this->applyTenantScope($created)->get(); $deleted = $this->applyTenantScope($deleted)->get(); $count = $this->applyTenantScope(DB::table('users')->whereNull('deleted_at'))->count(); $empty = array_fill_keys($labels, 0); $created = array_merge($empty, $created->pluck('cnt', 'period')->all()); $deleted = array_merge($empty, $deleted->pluck('cnt', 'period')->all()); $all = []; foreach (array_reverse($labels) as $label) { $all[] = $count; $count -= $created[$label] - $deleted[$label]; } $all = array_reverse($all); // $start = 3000; // for ($i = 0; $i < count($labels); $i++) { // $all[$i] = $start + $i * 15; // } // See https://frappe.io/charts/docs for format/options description return [ - 'title' => 'All Users - last year', + 'title' => \trans('app.chart-allusers'), 'type' => 'line', 'colors' => [self::COLOR_GREEN], 'axisOptions' => [ 'xIsSeries' => true, 'xAxisMode' => 'tick', ], 'lineOptions' => [ 'hideDots' => true, 'regionFill' => true, ], 'data' => [ 'labels' => $labels, 'datasets' => [ [ // 'name' => 'Existing', 'values' => $all ] ] ] ]; } + /** + * Get vouchers chart + */ + protected function chartVouchers(): array + { + $vouchers = DB::table('wallets') + ->selectRaw("count(discount_id) as cnt, code") + ->join('discounts', 'discounts.id', '=', 'wallets.discount_id') + ->join('users', 'users.id', '=', 'wallets.user_id') + ->where('discount', '>', 0) + ->whereNotNull('code') + ->whereNull('users.deleted_at') + ->groupBy('discounts.code') + ->havingRaw("count(discount_id) > 0") + ->orderByRaw('1'); + + $addTenantScope = function ($builder, $tenantId) { + return $builder->where('users.tenant_id', $tenantId); + }; + + $vouchers = $this->applyTenantScope($vouchers, $addTenantScope) + ->pluck('cnt', 'code')->all(); + + $labels = array_keys($vouchers); + $vouchers = array_values($vouchers); + + // $labels = ["TEST", "NEW", "OTHER", "US"]; + // $vouchers = [100, 120, 30, 50]; + + return $this->donutChart(\trans('app.chart-vouchers'), $labels, $vouchers); + } + + protected static function donutChart($title, $labels, $data): array + { + // See https://frappe.io/charts/docs for format/options description + + return [ + 'title' => $title, + 'type' => 'donut', + 'colors' => [ + self::COLOR_BLUE, + self::COLOR_BLUE_DARK, + self::COLOR_GREEN, + self::COLOR_GREEN_DARK, + self::COLOR_ORANGE, + self::COLOR_RED, + self::COLOR_RED_DARK + ], + 'maxSlices' => 8, + 'tooltipOptions' => [], // does not work without it (https://github.com/frappe/charts/issues/314) + 'data' => [ + 'labels' => $labels, + 'datasets' => [ + [ + 'values' => $data + ] + ] + ] + ]; + } + /** * Add tenant scope to the queries when needed * * @param \Illuminate\Database\Query\Builder $query The query * @param callable $addQuery Additional tenant-scope query-modifier * * @return \Illuminate\Database\Query\Builder */ protected function applyTenantScope($query, $addQuery = null) { // TODO: Per-tenant stats for admins return $query; } /** * Get the currency for stats * * @return string Currency code */ protected function currency() { $user = $this->guard()->user(); // For resellers return their wallet currency if ($user->role == 'reseller') { $currency = $user->wallet()->currency; } // System currency for others return \config('app.currency'); } } diff --git a/src/app/Http/Controllers/API/V4/DomainsController.php b/src/app/Http/Controllers/API/V4/DomainsController.php index a288ffe0..2f1abfbd 100644 --- a/src/app/Http/Controllers/API/V4/DomainsController.php +++ b/src/app/Http/Controllers/API/V4/DomainsController.php @@ -1,425 +1,526 @@ guard()->user(); $list = []; foreach ($user->domains() as $domain) { if (!$domain->isPublic()) { $data = $domain->toArray(); $data = array_merge($data, self::domainStatuses($domain)); $list[] = $data; } } + usort($list, function ($a, $b) { + return strcmp($a['namespace'], $b['namespace']); + }); + return response()->json($list); } /** - * Show the form for creating a new resource. + * Show the form for creating a new domain. * * @return \Illuminate\Http\JsonResponse */ public function create() { return $this->errorResponse(404); } /** * Confirm ownership of the specified domain (via DNS check). * * @param int $id Domain identifier * * @return \Illuminate\Http\JsonResponse|void */ public function confirm($id) { $domain = Domain::find($id); if (!$this->checkTenant($domain)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($domain)) { return $this->errorResponse(403); } if (!$domain->confirm()) { return response()->json([ 'status' => 'error', 'message' => \trans('app.domain-verify-error'), ]); } return response()->json([ 'status' => 'success', 'statusInfo' => self::statusInfo($domain), 'message' => \trans('app.domain-verify-success'), ]); } /** - * Remove the specified resource from storage. + * Remove the specified domain. * - * @param int $id + * @param int $id Domain identifier * * @return \Illuminate\Http\JsonResponse */ public function destroy($id) { - return $this->errorResponse(404); + $domain = Domain::withEnvTenantContext()->find($id); + + if (empty($domain)) { + return $this->errorResponse(404); + } + + if (!$this->guard()->user()->canDelete($domain)) { + return $this->errorResponse(403); + } + + // It is possible to delete domain only if there are no users/aliases/groups using it. + if (!$domain->isEmpty()) { + $response = ['status' => 'error', 'message' => \trans('app.domain-notempty-error')]; + return response()->json($response, 422); + } + + $domain->delete(); + + return response()->json([ + 'status' => 'success', + 'message' => \trans('app.domain-delete-success'), + ]); } /** - * Show the form for editing the specified resource. + * Show the form for editing the specified domain. * - * @param int $id + * @param int $id Domain identifier * * @return \Illuminate\Http\JsonResponse */ public function edit($id) { return $this->errorResponse(404); } /** * Set the domain configuration. * * @param int $id Domain identifier * * @return \Illuminate\Http\JsonResponse|void */ public function setConfig($id) { $domain = Domain::find($id); if (empty($domain)) { return $this->errorResponse(404); } // Only owner (or admin) has access to the domain if (!$this->guard()->user()->canRead($domain)) { return $this->errorResponse(403); } $errors = $domain->setConfig(request()->input()); if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } return response()->json([ 'status' => 'success', 'message' => \trans('app.domain-setconfig-success'), ]); } - /** - * Store a newly created resource in storage. + * Create a domain. * * @param \Illuminate\Http\Request $request * * @return \Illuminate\Http\JsonResponse */ public function store(Request $request) { - return $this->errorResponse(404); + $current_user = $this->guard()->user(); + $owner = $current_user->wallet()->owner; + + if ($owner->id != $current_user->id) { + return $this->errorResponse(403); + } + + // Validate the input + $v = Validator::make( + $request->all(), + [ + 'namespace' => ['required', 'string', new UserEmailDomain()] + ] + ); + + if ($v->fails()) { + return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); + } + + $namespace = \strtolower(request()->input('namespace')); + + // Domain already exists + if ($domain = Domain::withTrashed()->where('namespace', $namespace)->first()) { + // Check if the domain is soft-deleted and belongs to the same user + $deleteBeforeCreate = $domain->trashed() && ($wallet = $domain->wallet()) + && $wallet->owner && $wallet->owner->id == $owner->id; + + if (!$deleteBeforeCreate) { + $errors = ['namespace' => \trans('validation.domainnotavailable')]; + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + } + + if (empty($request->package) || !($package = \App\Package::withEnvTenantContext()->find($request->package))) { + $errors = ['package' => \trans('validation.packagerequired')]; + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + + if (!$package->isDomain()) { + $errors = ['package' => \trans('validation.packageinvalid')]; + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + + DB::beginTransaction(); + + // Force-delete the existing domain if it is soft-deleted and belongs to the same user + if (!empty($deleteBeforeCreate)) { + $domain->forceDelete(); + } + + // Create the domain + $domain = Domain::create([ + 'namespace' => $namespace, + 'type' => \App\Domain::TYPE_EXTERNAL, + ]); + + $domain->assignPackage($package, $owner); + + DB::commit(); + + return response()->json([ + 'status' => 'success', + 'message' => __('app.domain-create-success'), + ]); } /** * Get the information about the specified domain. * * @param int $id Domain identifier * * @return \Illuminate\Http\JsonResponse|void */ public function show($id) { $domain = Domain::find($id); if (!$this->checkTenant($domain)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($domain)) { return $this->errorResponse(403); } $response = $domain->toArray(); // Add hash information to the response $response['hash_text'] = $domain->hash(Domain::HASH_TEXT); $response['hash_cname'] = $domain->hash(Domain::HASH_CNAME); $response['hash_code'] = $domain->hash(Domain::HASH_CODE); // Add DNS/MX configuration for the domain $response['dns'] = self::getDNSConfig($domain); $response['mx'] = self::getMXConfig($domain->namespace); // Domain configuration, e.g. spf whitelist $response['config'] = $domain->getConfig(); // Status info $response['statusInfo'] = self::statusInfo($domain); + // Entitlements info + $response['skus'] = \App\Entitlement::objectEntitlementsSummary($domain); + $response = array_merge($response, self::domainStatuses($domain)); + // Some basic information about the domain wallet + $wallet = $domain->wallet(); + $response['wallet'] = $wallet->toArray(); + if ($wallet->discount) { + $response['wallet']['discount'] = $wallet->discount->discount; + $response['wallet']['discount_description'] = $wallet->discount->description; + } + return response()->json($response); } /** * Fetch domain status (and reload setup process) * * @param int $id Domain identifier * * @return \Illuminate\Http\JsonResponse */ public function status($id) { $domain = Domain::find($id); if (!$this->checkTenant($domain)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($domain)) { return $this->errorResponse(403); } $response = self::statusInfo($domain); if (!empty(request()->input('refresh'))) { $updated = false; $last_step = 'none'; foreach ($response['process'] as $idx => $step) { $last_step = $step['label']; if (!$step['state']) { if (!$this->execProcessStep($domain, $step['label'])) { break; } $updated = true; } } if ($updated) { $response = self::statusInfo($domain); } $success = $response['isReady']; $suffix = $success ? 'success' : 'error-' . $last_step; $response['status'] = $success ? 'success' : 'error'; $response['message'] = \trans('app.process-' . $suffix); } $response = array_merge($response, self::domainStatuses($domain)); return response()->json($response); } /** - * Update the specified resource in storage. + * Update the specified domain. * * @param \Illuminate\Http\Request $request - * @param int $id + * @param int $id Domain identifier * * @return \Illuminate\Http\JsonResponse */ public function update(Request $request, $id) { return $this->errorResponse(404); } /** * Provide DNS MX information to configure specified domain for */ protected static function getMXConfig(string $namespace): array { $entries = []; // copy MX entries from an existing domain if ($master = \config('dns.copyfrom')) { // TODO: cache this lookup foreach ((array) dns_get_record($master, DNS_MX) as $entry) { $entries[] = sprintf( "@\t%s\t%s\tMX\t%d %s.", \config('dns.ttl', $entry['ttl']), $entry['class'], $entry['pri'], $entry['target'] ); } } elseif ($static = \config('dns.static')) { $entries[] = strtr($static, array('\n' => "\n", '%s' => $namespace)); } // display SPF settings if ($spf = \config('dns.spf')) { $entries[] = ';'; foreach (['TXT', 'SPF'] as $type) { $entries[] = sprintf( "@\t%s\tIN\t%s\t\"%s\"", \config('dns.ttl'), $type, $spf ); } } return $entries; } /** * Provide sample DNS config for domain confirmation */ protected static function getDNSConfig(Domain $domain): array { $serial = date('Ymd01'); $hash_txt = $domain->hash(Domain::HASH_TEXT); $hash_cname = $domain->hash(Domain::HASH_CNAME); $hash = $domain->hash(Domain::HASH_CODE); return [ "@ IN SOA ns1.dnsservice.com. hostmaster.{$domain->namespace}. (", " {$serial} 10800 3600 604800 86400 )", ";", "@ IN A ", "www IN A ", ";", "{$hash_cname}.{$domain->namespace}. IN CNAME {$hash}.{$domain->namespace}.", "@ 3600 TXT \"{$hash_txt}\"", ]; } /** * Prepare domain statuses for the UI * * @param \App\Domain $domain Domain object * * @return array Statuses array */ protected static function domainStatuses(Domain $domain): array { return [ 'isLdapReady' => $domain->isLdapReady(), 'isConfirmed' => $domain->isConfirmed(), 'isVerified' => $domain->isVerified(), 'isSuspended' => $domain->isSuspended(), 'isActive' => $domain->isActive(), 'isDeleted' => $domain->isDeleted() || $domain->trashed(), ]; } /** * Domain status (extended) information. * * @param \App\Domain $domain Domain object * * @return array Status information */ public static function statusInfo(Domain $domain): array { $process = []; // If that is not a public domain, add domain specific steps $steps = [ 'domain-new' => true, 'domain-ldap-ready' => $domain->isLdapReady(), 'domain-verified' => $domain->isVerified(), 'domain-confirmed' => $domain->isConfirmed(), ]; $count = count($steps); // Create a process check list foreach ($steps as $step_name => $state) { $step = [ 'label' => $step_name, 'title' => \trans("app.process-{$step_name}"), 'state' => $state, ]; if ($step_name == 'domain-confirmed' && !$state) { $step['link'] = "/domain/{$domain->id}"; } $process[] = $step; if ($state) { $count--; } } $state = $count === 0 ? 'done' : 'running'; // After 180 seconds assume the process is in failed state, // this should unlock the Refresh button in the UI if ($count > 0 && $domain->created_at->diffInSeconds(Carbon::now()) > 180) { $state = 'failed'; } return [ 'process' => $process, 'processState' => $state, 'isReady' => $count === 0, ]; } /** * Execute (synchronously) specified step in a domain setup process. * * @param \App\Domain $domain Domain object * @param string $step Step identifier (as in self::statusInfo()) * * @return bool True if the execution succeeded, False otherwise */ public static function execProcessStep(Domain $domain, string $step): bool { try { switch ($step) { case 'domain-ldap-ready': // Domain not in LDAP, create it if (!$domain->isLdapReady()) { LDAP::createDomain($domain); $domain->status |= Domain::STATUS_LDAP_READY; $domain->save(); } return $domain->isLdapReady(); case 'domain-verified': // Domain existence not verified $domain->verify(); return $domain->isVerified(); case 'domain-confirmed': // Domain ownership confirmation $domain->confirm(); return $domain->isConfirmed(); } } catch (\Exception $e) { \Log::error($e); } return false; } } diff --git a/src/app/Http/Controllers/API/V4/Reseller/StatsController.php b/src/app/Http/Controllers/API/V4/Reseller/StatsController.php index 334f6500..9bff1132 100644 --- a/src/app/Http/Controllers/API/V4/Reseller/StatsController.php +++ b/src/app/Http/Controllers/API/V4/Reseller/StatsController.php @@ -1,36 +1,37 @@ user(); $query = $addQuery($query, $user->tenant_id); } else { $query = $query->withSubjectTenantContext(); } return $query; } } diff --git a/src/app/Http/Controllers/API/V4/SkusController.php b/src/app/Http/Controllers/API/V4/SkusController.php index 0d49d694..3ea2360d 100644 --- a/src/app/Http/Controllers/API/V4/SkusController.php +++ b/src/app/Http/Controllers/API/V4/SkusController.php @@ -1,192 +1,226 @@ errorResponse(404); } /** * Remove the specified sku from storage. * * @param int $id SKU identifier * * @return \Illuminate\Http\JsonResponse */ public function destroy($id) { // TODO return $this->errorResponse(404); } + /** + * Get a list of SKUs available to the domain. + * + * @param int $id Domain identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function domainSkus($id) + { + $domain = \App\Domain::find($id); + + if (!$this->checkTenant($domain)) { + return $this->errorResponse(404); + } + + if (!$this->guard()->user()->canRead($domain)) { + return $this->errorResponse(403); + } + + return $this->objectSkus($domain); + } + /** * Show the form for editing the specified sku. * * @param int $id SKU identifier * * @return \Illuminate\Http\JsonResponse */ public function edit($id) { // TODO return $this->errorResponse(404); } /** * Get a list of active SKUs. * * @return \Illuminate\Http\JsonResponse */ public function index() { // Note: Order by title for consistent ordering in tests $skus = Sku::withSubjectTenantContext()->where('active', true)->orderBy('title')->get(); $response = []; foreach ($skus as $sku) { if ($data = $this->skuElement($sku)) { $response[] = $data; } } usort($response, function ($a, $b) { return ($b['prio'] <=> $a['prio']); }); return response()->json($response); } /** * Store a newly created sku in storage. * * @param \Illuminate\Http\Request $request * * @return \Illuminate\Http\JsonResponse */ public function store(Request $request) { // TODO return $this->errorResponse(404); } /** * Display the specified sku. * * @param int $id SKU identifier * * @return \Illuminate\Http\JsonResponse */ public function show($id) { // TODO return $this->errorResponse(404); } /** * Update the specified sku in storage. * * @param \Illuminate\Http\Request $request Request object * @param int $id SKU identifier * * @return \Illuminate\Http\JsonResponse */ public function update(Request $request, $id) { // TODO return $this->errorResponse(404); } /** * Get a list of SKUs available to the user. * * @param int $id User identifier * * @return \Illuminate\Http\JsonResponse */ public function userSkus($id) { $user = \App\User::find($id); if (!$this->checkTenant($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($user)) { return $this->errorResponse(403); } - $type = request()->input('type'); + return $this->objectSkus($user); + } + + /** + * Return SKUs available to the specified user/domain. + * + * @param object $object User or Domain object + * + * @return \Illuminate\Http\JsonResponse + */ + protected static function objectSkus($object) + { + $type = $object instanceof \App\Domain ? 'domain' : 'user'; $response = []; // Note: Order by title for consistent ordering in tests - $skus = Sku::withObjectTenantContext($user)->orderBy('title')->get(); + $skus = Sku::withObjectTenantContext($object)->orderBy('title')->get(); foreach ($skus as $sku) { if (!class_exists($sku->handler_class)) { continue; } - if (!$sku->handler_class::isAvailable($sku, $user)) { + if (!$sku->handler_class::isAvailable($sku, $object)) { continue; } - if ($data = $this->skuElement($sku)) { - if ($type && $type != $data['type']) { + if ($data = self::skuElement($sku)) { + if ($type != $data['type']) { continue; } $response[] = $data; } } usort($response, function ($a, $b) { return ($b['prio'] <=> $a['prio']); }); return response()->json($response); } /** * Convert SKU information to metadata used by UI to * display the form control * * @param \App\Sku $sku SKU object * * @return array|null Metadata */ - protected function skuElement($sku): ?array + protected static function skuElement($sku): ?array { if (!class_exists($sku->handler_class)) { return null; } $data = array_merge($sku->toArray(), $sku->handler_class::metadata($sku)); // ignore incomplete handlers if (empty($data['type'])) { return null; } // Use localized value, toArray() does not get them right $data['name'] = $sku->name; $data['description'] = $sku->description; unset($data['handler_class'], $data['created_at'], $data['updated_at'], $data['fee'], $data['tenant_id']); return $data; } } diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php index ff96a039..f87417da 100644 --- a/src/app/Http/Controllers/API/V4/UsersController.php +++ b/src/app/Http/Controllers/API/V4/UsersController.php @@ -1,846 +1,835 @@ find($id); if (empty($user)) { return $this->errorResponse(404); } // User can't remove himself until he's the controller if (!$this->guard()->user()->canDelete($user)) { return $this->errorResponse(403); } $user->delete(); return response()->json([ 'status' => 'success', 'message' => \trans('app.user-delete-success'), ]); } /** * Listing of users. * * The user-entitlements billed to the current user wallet(s) * * @return \Illuminate\Http\JsonResponse */ public function index() { $user = $this->guard()->user(); $result = $user->users()->orderBy('email')->get()->map(function ($user) { $data = $user->toArray(); $data = array_merge($data, self::userStatuses($user)); return $data; }); return response()->json($result); } /** * Set user config. * * @param int $id The user * * @return \Illuminate\Http\JsonResponse */ public function setConfig($id) { $user = User::find($id); if (empty($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($user)) { return $this->errorResponse(403); } $errors = $user->setConfig(request()->input()); if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } return response()->json([ 'status' => 'success', 'message' => \trans('app.user-setconfig-success'), ]); } /** * Display information on the user account specified by $id. * * @param int $id The account to show information for. * * @return \Illuminate\Http\JsonResponse */ public function show($id) { $user = User::withEnvTenantContext()->find($id); if (empty($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($user)) { return $this->errorResponse(403); } $response = $this->userResponse($user); - // Simplified Entitlement/SKU information, - // TODO: I agree this format may need to be extended in future - $response['skus'] = []; - foreach ($user->entitlements as $ent) { - $sku = $ent->sku; - if (!isset($response['skus'][$sku->id])) { - $response['skus'][$sku->id] = ['costs' => [], 'count' => 0]; - } - $response['skus'][$sku->id]['count']++; - $response['skus'][$sku->id]['costs'][] = $ent->cost; - } - + $response['skus'] = \App\Entitlement::objectEntitlementsSummary($user); $response['config'] = $user->getConfig(); return response()->json($response); } /** * Fetch user status (and reload setup process) * * @param int $id User identifier * * @return \Illuminate\Http\JsonResponse */ public function status($id) { $user = User::withEnvTenantContext()->find($id); if (empty($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($user)) { return $this->errorResponse(403); } $response = self::statusInfo($user); if (!empty(request()->input('refresh'))) { $updated = false; $async = false; $last_step = 'none'; foreach ($response['process'] as $idx => $step) { $last_step = $step['label']; if (!$step['state']) { $exec = $this->execProcessStep($user, $step['label']); if (!$exec) { if ($exec === null) { $async = true; } break; } $updated = true; } } if ($updated) { $response = self::statusInfo($user); } $success = $response['isReady']; $suffix = $success ? 'success' : 'error-' . $last_step; $response['status'] = $success ? 'success' : 'error'; $response['message'] = \trans('app.process-' . $suffix); if ($async && !$success) { $response['processState'] = 'waiting'; $response['status'] = 'success'; $response['message'] = \trans('app.process-async'); } } $response = array_merge($response, self::userStatuses($user)); return response()->json($response); } /** * User status (extended) information * * @param \App\User $user User object * * @return array Status information */ public static function statusInfo(User $user): array { $process = []; $steps = [ 'user-new' => true, 'user-ldap-ready' => $user->isLdapReady(), 'user-imap-ready' => $user->isImapReady(), ]; // Create a process check list foreach ($steps as $step_name => $state) { $step = [ 'label' => $step_name, 'title' => \trans("app.process-{$step_name}"), 'state' => $state, ]; $process[] = $step; } list ($local, $domain) = explode('@', $user->email); $domain = Domain::where('namespace', $domain)->first(); // If that is not a public domain, add domain specific steps if ($domain && !$domain->isPublic()) { $domain_status = DomainsController::statusInfo($domain); $process = array_merge($process, $domain_status['process']); } $all = count($process); $checked = count(array_filter($process, function ($v) { return $v['state']; })); $state = $all === $checked ? 'done' : 'running'; // After 180 seconds assume the process is in failed state, // this should unlock the Refresh button in the UI if ($all !== $checked && $user->created_at->diffInSeconds(Carbon::now()) > 180) { $state = 'failed'; } // Check if the user is a controller of his wallet $isController = $user->canDelete($user); $hasCustomDomain = $user->wallet()->entitlements() ->where('entitleable_type', Domain::class) ->count() > 0; // Get user's entitlements titles $skus = $user->entitlements()->select('skus.title') ->join('skus', 'skus.id', '=', 'entitlements.sku_id') ->get() ->pluck('title') ->sort() ->unique() ->values() ->all(); return [ 'skus' => $skus, // TODO: This will change when we enable all users to create domains 'enableDomains' => $isController && $hasCustomDomain, // TODO: Make 'enableDistlists' working for wallet controllers that aren't account owners 'enableDistlists' => $isController && $hasCustomDomain && in_array('distlist', $skus), 'enableUsers' => $isController, 'enableWallets' => $isController, 'process' => $process, 'processState' => $state, 'isReady' => $all === $checked, ]; } /** * Create a new user record. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { $current_user = $this->guard()->user(); $owner = $current_user->wallet()->owner; if ($owner->id != $current_user->id) { return $this->errorResponse(403); } $this->deleteBeforeCreate = null; if ($error_response = $this->validateUserRequest($request, null, $settings)) { return $error_response; } if (empty($request->package) || !($package = \App\Package::withEnvTenantContext()->find($request->package))) { $errors = ['package' => \trans('validation.packagerequired')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } if ($package->isDomain()) { $errors = ['package' => \trans('validation.packageinvalid')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } DB::beginTransaction(); // @phpstan-ignore-next-line if ($this->deleteBeforeCreate) { $this->deleteBeforeCreate->forceDelete(); } // Create user record $user = User::create([ 'email' => $request->email, 'password' => $request->password, ]); $owner->assignPackage($package, $user); if (!empty($settings)) { $user->setSettings($settings); } if (!empty($request->aliases)) { $user->setAliases($request->aliases); } DB::commit(); return response()->json([ 'status' => 'success', 'message' => \trans('app.user-create-success'), ]); } /** * Update user data. * * @param \Illuminate\Http\Request $request The API request. * @param string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { $user = User::withEnvTenantContext()->find($id); if (empty($user)) { return $this->errorResponse(404); } $current_user = $this->guard()->user(); // TODO: Decide what attributes a user can change on his own profile if (!$current_user->canUpdate($user)) { return $this->errorResponse(403); } if ($error_response = $this->validateUserRequest($request, $user, $settings)) { return $error_response; } // Entitlements, only controller can do that if ($request->skus !== null && !$current_user->canDelete($user)) { return $this->errorResponse(422, "You have no permission to change entitlements"); } DB::beginTransaction(); $this->updateEntitlements($user, $request->skus); if (!empty($settings)) { $user->setSettings($settings); } if (!empty($request->password)) { $user->password = $request->password; $user->save(); } if (isset($request->aliases)) { $user->setAliases($request->aliases); } // TODO: Make sure that UserUpdate job is created in case of entitlements update // and no password change. So, for example quota change is applied to LDAP // TODO: Review use of $user->save() in the above context DB::commit(); $response = [ 'status' => 'success', 'message' => \trans('app.user-update-success'), ]; // For self-update refresh the statusInfo in the UI if ($user->id == $current_user->id) { $response['statusInfo'] = self::statusInfo($user); } return response()->json($response); } /** * Update user entitlements. * * @param \App\User $user The user * @param array $rSkus List of SKU IDs requested for the user in the form [id=>qty] */ protected function updateEntitlements(User $user, $rSkus) { if (!is_array($rSkus)) { return; } // list of skus, [id=>obj] $skus = Sku::withEnvTenantContext()->get()->mapWithKeys( function ($sku) { return [$sku->id => $sku]; } ); // existing entitlement's SKUs $eSkus = []; $user->entitlements()->groupBy('sku_id') ->selectRaw('count(*) as total, sku_id')->each( function ($e) use (&$eSkus) { $eSkus[$e->sku_id] = $e->total; } ); foreach ($skus as $skuID => $sku) { $e = array_key_exists($skuID, $eSkus) ? $eSkus[$skuID] : 0; $r = array_key_exists($skuID, $rSkus) ? $rSkus[$skuID] : 0; if ($sku->handler_class == \App\Handlers\Mailbox::class) { if ($r != 1) { throw new \Exception("Invalid quantity of mailboxes"); } } if ($e > $r) { // remove those entitled more than existing $user->removeSku($sku, ($e - $r)); } elseif ($e < $r) { // add those requested more than entitled $user->assignSku($sku, ($r - $e)); } } } /** * Create a response data array for specified user. * * @param \App\User $user User object * * @return array Response data */ public static function userResponse(User $user): array { $response = $user->toArray(); // Settings $response['settings'] = []; foreach ($user->settings()->whereIn('key', self::USER_SETTINGS)->get() as $item) { $response['settings'][$item->key] = $item->value; } // Aliases $response['aliases'] = []; foreach ($user->aliases as $item) { $response['aliases'][] = $item->alias; } // Status info $response['statusInfo'] = self::statusInfo($user); $response = array_merge($response, self::userStatuses($user)); // Add more info to the wallet object output $map_func = function ($wallet) use ($user) { $result = $wallet->toArray(); if ($wallet->discount) { $result['discount'] = $wallet->discount->discount; $result['discount_description'] = $wallet->discount->description; } if ($wallet->user_id != $user->id) { $result['user_email'] = $wallet->owner->email; } $provider = \App\Providers\PaymentProvider::factory($wallet); $result['provider'] = $provider->name(); return $result; }; // Information about wallets and accounts for access checks $response['wallets'] = $user->wallets->map($map_func)->toArray(); $response['accounts'] = $user->accounts->map($map_func)->toArray(); $response['wallet'] = $map_func($user->wallet()); return $response; } /** * Prepare user statuses for the UI * * @param \App\User $user User object * * @return array Statuses array */ protected static function userStatuses(User $user): array { return [ 'isImapReady' => $user->isImapReady(), 'isLdapReady' => $user->isLdapReady(), 'isSuspended' => $user->isSuspended(), 'isActive' => $user->isActive(), 'isDeleted' => $user->isDeleted() || $user->trashed(), ]; } /** * Validate user input * * @param \Illuminate\Http\Request $request The API request. * @param \App\User|null $user User identifier * @param array $settings User settings (from the request) * * @return \Illuminate\Http\JsonResponse|null The error response on error */ protected function validateUserRequest(Request $request, $user, &$settings = []) { $rules = [ 'external_email' => 'nullable|email', 'phone' => 'string|nullable|max:64|regex:/^[0-9+() -]+$/', 'first_name' => 'string|nullable|max:128', 'last_name' => 'string|nullable|max:128', 'organization' => 'string|nullable|max:512', 'billing_address' => 'string|nullable|max:1024', 'country' => 'string|nullable|alpha|size:2', 'currency' => 'string|nullable|alpha|size:3', 'aliases' => 'array|nullable', ]; if (empty($user) || !empty($request->password) || !empty($request->password_confirmation)) { $rules['password'] = 'required|min:4|max:2048|confirmed'; } $errors = []; // Validate input $v = Validator::make($request->all(), $rules); if ($v->fails()) { $errors = $v->errors()->toArray(); } $controller = $user ? $user->wallet()->owner : $this->guard()->user(); // For new user validate email address if (empty($user)) { $email = $request->email; if (empty($email)) { $errors['email'] = \trans('validation.required', ['attribute' => 'email']); } elseif ($error = self::validateEmail($email, $controller, $this->deleteBeforeCreate)) { $errors['email'] = $error; } } // Validate aliases input if (isset($request->aliases)) { $aliases = []; $existing_aliases = $user ? $user->aliases()->get()->pluck('alias')->toArray() : []; foreach ($request->aliases as $idx => $alias) { if (is_string($alias) && !empty($alias)) { // Alias cannot be the same as the email address (new user) if (!empty($email) && Str::lower($alias) == Str::lower($email)) { continue; } // validate new aliases if ( !in_array($alias, $existing_aliases) && ($error = self::validateAlias($alias, $controller)) ) { if (!isset($errors['aliases'])) { $errors['aliases'] = []; } $errors['aliases'][$idx] = $error; continue; } $aliases[] = $alias; } } $request->aliases = $aliases; } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } // Update user settings $settings = $request->only(array_keys($rules)); unset($settings['password'], $settings['aliases'], $settings['email']); return null; } /** * Execute (synchronously) specified step in a user setup process. * * @param \App\User $user User object * @param string $step Step identifier (as in self::statusInfo()) * * @return bool|null True if the execution succeeded, False if not, Null when * the job has been sent to the worker (result unknown) */ public static function execProcessStep(User $user, string $step): ?bool { try { if (strpos($step, 'domain-') === 0) { list ($local, $domain) = explode('@', $user->email); $domain = Domain::where('namespace', $domain)->first(); return DomainsController::execProcessStep($domain, $step); } switch ($step) { case 'user-ldap-ready': // User not in LDAP, create it $job = new \App\Jobs\User\CreateJob($user->id); $job->handle(); $user->refresh(); return $user->isLdapReady(); case 'user-imap-ready': // User not in IMAP? Verify again // Do it synchronously if the imap admin credentials are available // otherwise let the worker do the job if (!\config('imap.admin_password')) { \App\Jobs\User\VerifyJob::dispatch($user->id); return null; } $job = new \App\Jobs\User\VerifyJob($user->id); $job->handle(); $user->refresh(); return $user->isImapReady(); } } catch (\Exception $e) { \Log::error($e); } return false; } /** * Email address validation for use as a user mailbox (login). * * @param string $email Email address * @param \App\User $user The account owner * @param null|\App\User|\App\Group $deleted Filled with an instance of a deleted user or group * with the specified email address, if exists * * @return ?string Error message on validation error */ public static function validateEmail(string $email, \App\User $user, &$deleted = null): ?string { $deleted = null; if (strpos($email, '@') === false) { return \trans('validation.entryinvalid', ['attribute' => 'email']); } list($login, $domain) = explode('@', Str::lower($email)); if (strlen($login) === 0 || strlen($domain) === 0) { return \trans('validation.entryinvalid', ['attribute' => 'email']); } // Check if domain exists $domain = Domain::withEnvTenantContext()->where('namespace', $domain)->first(); if (empty($domain)) { return \trans('validation.domaininvalid'); } // Validate login part alone $v = Validator::make( ['email' => $login], ['email' => ['required', new UserEmailLocal(!$domain->isPublic())]] ); if ($v->fails()) { return $v->errors()->toArray()['email'][0]; } // Check if it is one of domains available to the user $domains = \collect($user->domains())->pluck('namespace')->all(); if (!in_array($domain->namespace, $domains)) { return \trans('validation.entryexists', ['attribute' => 'domain']); } // Check if a user with specified address already exists if ($existing_user = User::emailExists($email, true)) { // If this is a deleted user in the same custom domain // we'll force delete him before if (!$domain->isPublic() && $existing_user->trashed()) { $deleted = $existing_user; } else { return \trans('validation.entryexists', ['attribute' => 'email']); } } // Check if an alias with specified address already exists. if (User::aliasExists($email)) { return \trans('validation.entryexists', ['attribute' => 'email']); } // Check if a group with specified address already exists if ($existing_group = Group::emailExists($email, true)) { // If this is a deleted group in the same custom domain // we'll force delete it before if (!$domain->isPublic() && $existing_group->trashed()) { $deleted = $existing_group; } else { return \trans('validation.entryexists', ['attribute' => 'email']); } } return null; } /** * Email address validation for use as an alias. * * @param string $email Email address * @param \App\User $user The account owner * * @return ?string Error message on validation error */ public static function validateAlias(string $email, \App\User $user): ?string { if (strpos($email, '@') === false) { return \trans('validation.entryinvalid', ['attribute' => 'alias']); } list($login, $domain) = explode('@', Str::lower($email)); if (strlen($login) === 0 || strlen($domain) === 0) { return \trans('validation.entryinvalid', ['attribute' => 'alias']); } // Check if domain exists $domain = Domain::withEnvTenantContext()->where('namespace', $domain)->first(); if (empty($domain)) { return \trans('validation.domaininvalid'); } // Validate login part alone $v = Validator::make( ['alias' => $login], ['alias' => ['required', new UserEmailLocal(!$domain->isPublic())]] ); if ($v->fails()) { return $v->errors()->toArray()['alias'][0]; } // Check if it is one of domains available to the user $domains = \collect($user->domains())->pluck('namespace')->all(); if (!in_array($domain->namespace, $domains)) { return \trans('validation.entryexists', ['attribute' => 'domain']); } // Check if a user with specified address already exists if ($existing_user = User::emailExists($email, true)) { // Allow an alias in a custom domain to an address that was a user before if ($domain->isPublic() || !$existing_user->trashed()) { return \trans('validation.entryexists', ['attribute' => 'alias']); } } // Check if an alias with specified address already exists if (User::aliasExists($email)) { // Allow assigning the same alias to a user in the same group account, // but only for non-public domains if ($domain->isPublic()) { return \trans('validation.entryexists', ['attribute' => 'alias']); } } // Check if a group with specified address already exists if (Group::emailExists($email)) { return \trans('validation.entryexists', ['attribute' => 'alias']); } return null; } } diff --git a/src/app/Jobs/PGP/KeyDeleteJob.php b/src/app/Jobs/PGP/KeyDeleteJob.php new file mode 100644 index 00000000..6b0a9a9b --- /dev/null +++ b/src/app/Jobs/PGP/KeyDeleteJob.php @@ -0,0 +1,43 @@ +userId = $userId; + $this->userEmail = $userEmail; + } + + /** + * Execute the job. + * + * @return void + * + * @throws \Exception + */ + public function handle() + { + $user = $this->getUser(); + + if (!$user) { + return; + } + + \App\Backends\PGP::keyDelete($user, $this->userEmail); + } +} diff --git a/src/app/Jobs/PGP/KeyUnregisterJob.php b/src/app/Jobs/PGP/KeyUnregisterJob.php deleted file mode 100644 index 4c17f477..00000000 --- a/src/app/Jobs/PGP/KeyUnregisterJob.php +++ /dev/null @@ -1,42 +0,0 @@ -email = $email; - } - - /** - * Execute the job. - * - * @return void - * - * @throws \Exception - */ - public function handle() - { - \App\Backends\PGP::keyUnregister($this->email); - } -} diff --git a/src/app/Observers/UserAliasObserver.php b/src/app/Observers/UserAliasObserver.php index 791e06e6..bd013cf6 100644 --- a/src/app/Observers/UserAliasObserver.php +++ b/src/app/Observers/UserAliasObserver.php @@ -1,93 +1,93 @@ alias = \strtolower($alias->alias); list($login, $domain) = explode('@', $alias->alias); $domain = Domain::where('namespace', $domain)->first(); if (!$domain) { \Log::error("Failed creating alias {$alias->alias}. Domain does not exist."); return false; } if ($alias->user) { if ($alias->user->tenant_id != $domain->tenant_id) { \Log::error("Reseller for user '{$alias->user->email}' and domain '{$domain->namespace}' differ."); return false; } } return true; } /** * Handle the user alias "created" event. * * @param \App\UserAlias $alias User email alias * * @return void */ public function created(UserAlias $alias) { if ($alias->user) { \App\Jobs\User\UpdateJob::dispatch($alias->user_id); if (Tenant::getConfig($alias->user->tenant_id, 'pgp.enable')) { \App\Jobs\PGP\KeyCreateJob::dispatch($alias->user_id, $alias->alias); } } } /** * Handle the user setting "updated" event. * * @param \App\UserAlias $alias User email alias * * @return void */ public function updated(UserAlias $alias) { if ($alias->user) { \App\Jobs\User\UpdateJob::dispatch($alias->user_id); } } /** * Handle the user setting "deleted" event. * * @param \App\UserAlias $alias User email alias * * @return void */ public function deleted(UserAlias $alias) { if ($alias->user) { \App\Jobs\User\UpdateJob::dispatch($alias->user_id); if (Tenant::getConfig($alias->user->tenant_id, 'pgp.enable')) { - \App\Jobs\PGP\KeyUnregisterJob::dispatch($alias->alias); + \App\Jobs\PGP\KeyDeleteJob::dispatch($alias->user_id, $alias->alias); } } } } diff --git a/src/app/Observers/UserObserver.php b/src/app/Observers/UserObserver.php index c8822688..9c846442 100644 --- a/src/app/Observers/UserObserver.php +++ b/src/app/Observers/UserObserver.php @@ -1,366 +1,370 @@ email = \strtolower($user->email); // only users that are not imported get the benefit of the doubt. $user->status |= User::STATUS_NEW | User::STATUS_ACTIVE; } /** * Handle the "created" event. * * Ensures the user has at least one wallet. * * Should ensure some basic settings are available as well. * * @param \App\User $user The user created. * * @return void */ public function created(User $user) { $settings = [ 'country' => \App\Utils::countryForRequest(), 'currency' => \config('app.currency'), /* 'first_name' => '', 'last_name' => '', 'billing_address' => '', 'organization' => '', 'phone' => '', 'external_email' => '', */ ]; foreach ($settings as $key => $value) { $settings[$key] = [ 'key' => $key, 'value' => $value, 'user_id' => $user->id, ]; } // Note: Don't use setSettings() here to bypass UserSetting observers // Note: This is a single multi-insert query $user->settings()->insert(array_values($settings)); $user->wallets()->create(); // Create user record in LDAP, then check if the account is created in IMAP $chain = [ new \App\Jobs\User\VerifyJob($user->id), ]; \App\Jobs\User\CreateJob::withChain($chain)->dispatch($user->id); if (\App\Tenant::getConfig($user->tenant_id, 'pgp.enable')) { \App\Jobs\PGP\KeyCreateJob::dispatch($user->id, $user->email); } } /** * Handle the "deleted" event. * * @param \App\User $user The user deleted. * * @return void */ public function deleted(User $user) { // Remove the user from existing groups $wallet = $user->wallet(); if ($wallet && $wallet->owner) { $wallet->owner->groups()->each(function ($group) use ($user) { if (in_array($user->email, $group->members)) { $group->members = array_diff($group->members, [$user->email]); $group->save(); } }); } // Debit the reseller's wallet with the user negative balance $balance = 0; foreach ($user->wallets as $wallet) { // Note: here we assume all user wallets are using the same currency. // It might get changed in the future $balance += $wallet->balance; } if ($balance < 0 && $user->tenant && ($wallet = $user->tenant->wallet())) { $wallet->debit($balance * -1, "Deleted user {$user->email}"); } } /** * Handle the "deleting" event. * * @param User $user The user that is being deleted. * * @return void */ public function deleting(User $user) { if ($user->isForceDeleting()) { $this->forceDeleting($user); return; } // TODO: Especially in tests we're doing delete() on a already deleted user. // Should we escape here - for performance reasons? // TODO: I think all of this should use database transactions // Entitlements do not have referential integrity on the entitled object, so this is our // way of doing an onDelete('cascade') without the foreign key. $entitlements = Entitlement::where('entitleable_id', $user->id) ->where('entitleable_type', User::class)->get(); foreach ($entitlements as $entitlement) { $entitlement->delete(); } // Remove owned users/domains $wallets = $user->wallets()->pluck('id')->all(); $assignments = Entitlement::whereIn('wallet_id', $wallets)->get(); $users = []; $domains = []; $groups = []; $entitlements = []; foreach ($assignments as $entitlement) { if ($entitlement->entitleable_type == Domain::class) { $domains[] = $entitlement->entitleable_id; } elseif ($entitlement->entitleable_type == User::class && $entitlement->entitleable_id != $user->id) { $users[] = $entitlement->entitleable_id; } elseif ($entitlement->entitleable_type == Group::class) { $groups[] = $entitlement->entitleable_id; } else { $entitlements[] = $entitlement; } } // Domains/users/entitlements need to be deleted one by one to make sure // events are fired and observers can do the proper cleanup. if (!empty($users)) { foreach (User::whereIn('id', array_unique($users))->get() as $_user) { $_user->delete(); } } if (!empty($domains)) { foreach (Domain::whereIn('id', array_unique($domains))->get() as $_domain) { $_domain->delete(); } } if (!empty($groups)) { foreach (Group::whereIn('id', array_unique($groups))->get() as $_group) { $_group->delete(); } } foreach ($entitlements as $entitlement) { $entitlement->delete(); } // FIXME: What do we do with user wallets? \App\Jobs\User\DeleteJob::dispatch($user->id); + + if (\App\Tenant::getConfig($user->tenant_id, 'pgp.enable')) { + \App\Jobs\PGP\KeyDeleteJob::dispatch($user->id, $user->email); + } } /** * Handle the "deleting" event on forceDelete() call. * * @param User $user The user that is being deleted. * * @return void */ public function forceDeleting(User $user) { // TODO: We assume that at this moment all belongings are already soft-deleted. // Remove owned users/domains $wallets = $user->wallets()->pluck('id')->all(); $assignments = Entitlement::withTrashed()->whereIn('wallet_id', $wallets)->get(); $entitlements = []; $domains = []; $groups = []; $users = []; foreach ($assignments as $entitlement) { $entitlements[] = $entitlement->id; if ($entitlement->entitleable_type == Domain::class) { $domains[] = $entitlement->entitleable_id; } elseif ( $entitlement->entitleable_type == User::class && $entitlement->entitleable_id != $user->id ) { $users[] = $entitlement->entitleable_id; } elseif ($entitlement->entitleable_type == Group::class) { $groups[] = $entitlement->entitleable_id; } } // Remove the user "direct" entitlements explicitely, if they belong to another // user's wallet they will not be removed by the wallets foreign key cascade Entitlement::withTrashed() ->where('entitleable_id', $user->id) ->where('entitleable_type', User::class) ->forceDelete(); // Users need to be deleted one by one to make sure observers can do the proper cleanup. if (!empty($users)) { foreach (User::withTrashed()->whereIn('id', array_unique($users))->get() as $_user) { $_user->forceDelete(); } } // Domains can be just removed if (!empty($domains)) { Domain::withTrashed()->whereIn('id', array_unique($domains))->forceDelete(); } // Groups can be just removed if (!empty($groups)) { Group::withTrashed()->whereIn('id', array_unique($groups))->forceDelete(); } // Remove transactions, they also have no foreign key constraint Transaction::where('object_type', Entitlement::class) ->whereIn('object_id', $entitlements) ->delete(); Transaction::where('object_type', Wallet::class) ->whereIn('object_id', $wallets) ->delete(); } /** * Handle the user "restoring" event. * * @param \App\User $user The user * * @return void */ public function restoring(User $user) { // Make sure it's not DELETED/LDAP_READY/IMAP_READY/SUSPENDED anymore if ($user->isDeleted()) { $user->status ^= User::STATUS_DELETED; } if ($user->isLdapReady()) { $user->status ^= User::STATUS_LDAP_READY; } if ($user->isImapReady()) { $user->status ^= User::STATUS_IMAP_READY; } if ($user->isSuspended()) { $user->status ^= User::STATUS_SUSPENDED; } $user->status |= User::STATUS_ACTIVE; // Note: $user->save() is invoked between 'restoring' and 'restored' events } /** * Handle the user "restored" event. * * @param \App\User $user The user * * @return void */ public function restored(User $user) { $wallets = $user->wallets()->pluck('id')->all(); // Restore user entitlements // We'll restore only these that were deleted last. So, first we get // the maximum deleted_at timestamp and then use it to select // entitlements for restore $deleted_at = \App\Entitlement::withTrashed() ->where('entitleable_id', $user->id) ->where('entitleable_type', User::class) ->max('deleted_at'); if ($deleted_at) { $threshold = (new \Carbon\Carbon($deleted_at))->subMinute(); // We need at least the user domain so it can be created in ldap. // FIXME: What if the domain is owned by someone else? $domain = $user->domain(); if ($domain->trashed() && !$domain->isPublic()) { // Note: Domain entitlements will be restored by the DomainObserver $domain->restore(); } // Restore user entitlements \App\Entitlement::withTrashed() ->where('entitleable_id', $user->id) ->where('entitleable_type', User::class) ->where('deleted_at', '>=', $threshold) ->update(['updated_at' => now(), 'deleted_at' => null]); // Note: We're assuming that cost of entitlements was correct // on user deletion, so we don't have to re-calculate it again. } // FIXME: Should we reset user aliases? or re-validate them in any way? // Create user record in LDAP, then run the verification process $chain = [ new \App\Jobs\User\VerifyJob($user->id), ]; \App\Jobs\User\CreateJob::withChain($chain)->dispatch($user->id); } /** * Handle the "retrieving" event. * * @param User $user The user that is being retrieved. * * @todo This is useful for audit. * * @return void */ public function retrieving(User $user) { // TODO \App\Jobs\User\ReadJob::dispatch($user->id); } /** * Handle the "updating" event. * * @param User $user The user that is being updated. * * @return void */ public function updating(User $user) { \App\Jobs\User\UpdateJob::dispatch($user->id); } } diff --git a/src/app/PowerDNS/Domain.php b/src/app/PowerDNS/Domain.php index 868a27de..0049a20e 100644 --- a/src/app/PowerDNS/Domain.php +++ b/src/app/PowerDNS/Domain.php @@ -1,51 +1,72 @@ records()->where('type', 'SOA')->first(); list($ns, $hm, $serial, $a, $b, $c, $d) = explode(" ", $soa->content); $today = \Carbon\Carbon::now()->format('Ymd'); $date = substr($serial, 0, 8); if ($date != $today) { $serial = $today . '01'; } else { $change = (int)(substr($serial, 8, 2)); $serial = sprintf("%s%02s", $date, ($change + 1)); } $soa->content = "{$ns} {$hm} {$serial} {$a} {$b} {$c} {$d}"; $soa->save(); } - public function getSerial() + /** + * Returns the SOA record serial + * + * @return string + */ + public function getSerial(): string { $soa = $this->records()->where('type', 'SOA')->first(); list($ns, $hm, $serial, $a, $b, $c, $d) = explode(" ", $soa->content); return $serial; } + /** + * Any DNS records assigned to this domain. + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ public function records() { - return $this->hasMany('App\PowerDNS\Record', 'domain_id'); + return $this->hasMany(Record::class, 'domain_id'); } - //public function setSerial() { } + /** + * Any (additional) properties of this domain. + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function settings() + { + return $this->hasMany(DomainSetting::class, 'domain_id'); + } } diff --git a/src/app/Rules/UserEmailDomain.php b/src/app/Rules/UserEmailDomain.php index 7dbecd3a..8153bd12 100644 --- a/src/app/Rules/UserEmailDomain.php +++ b/src/app/Rules/UserEmailDomain.php @@ -1,70 +1,81 @@ domains = $domains; } /** * Determine if the validation rule passes. * - * Validation of local part of an email address that's + * Validation of a domain part of an email address that's * going to be user's login. * * @param string $attribute Attribute name * @param mixed $domain Domain part of email address * * @return bool */ public function passes($attribute, $domain): bool { - // don't allow @localhost and other no-fqdn - if (empty($domain) || strpos($domain, '.') === false || stripos($domain, 'www.') === 0) { + // don't allow @localhost and other non-fqdn + if ( + empty($domain) + || !is_string($domain) + || strpos($domain, '.') === false + || stripos($domain, 'www.') === 0 + ) { + $this->message = \trans('validation.domaininvalid'); + return false; + } + + // Check the max length, according to the database column length + if (strlen($domain) > 191) { $this->message = \trans('validation.domaininvalid'); return false; } $domain = Str::lower($domain); // Use email validator to validate the domain part $v = Validator::make(['email' => 'user@' . $domain], ['email' => 'required|email']); if ($v->fails()) { $this->message = \trans('validation.domaininvalid'); return false; } // Check if specified domain is allowed for signup if (is_array($this->domains) && !in_array($domain, $this->domains)) { $this->message = \trans('validation.domaininvalid'); return false; } return true; } /** * Get the validation error message. * * @return string */ public function message(): ?string { return $this->message; } } diff --git a/src/app/Rules/UserEmailLocal.php b/src/app/Rules/UserEmailLocal.php index 894c04a6..d61bb90b 100644 --- a/src/app/Rules/UserEmailLocal.php +++ b/src/app/Rules/UserEmailLocal.php @@ -1,72 +1,76 @@ external = $external; } /** * Determine if the validation rule passes. * * Validation of local part of an email address that's * going to be user's login. * * @param string $attribute Attribute name * @param mixed $login Local part of email address * * @return bool */ public function passes($attribute, $login): bool { // Strict validation - if (!preg_match('/^[A-Za-z0-9_.-]+$/', $login)) { + if ( + empty($login) + || !is_string($login) + || !preg_match('/^[A-Za-z0-9_.-]+$/', $login) + ) { $this->message = \trans('validation.entryinvalid', ['attribute' => $attribute]); return false; } // Standard email address validation $v = Validator::make([$attribute => $login . '@test.com'], [$attribute => 'required|email']); if ($v->fails()) { $this->message = \trans('validation.entryinvalid', ['attribute' => $attribute]); return false; } // Check if the local part is not one of exceptions // (when signing up for an account in public domain if (!$this->external) { $exceptions = '/^(admin|administrator|sales|root)$/i'; if (preg_match($exceptions, $login)) { $this->message = \trans('validation.entryexists', ['attribute' => $attribute]); return false; } } return true; } /** * Get the validation error message. * * @return string */ public function message(): ?string { return $this->message; } } diff --git a/src/app/Utils.php b/src/app/Utils.php index 1ea99b6d..5f852fb2 100644 --- a/src/app/Utils.php +++ b/src/app/Utils.php @@ -1,556 +1,557 @@ country ? $net->country : 'CH'; } /** * Return the country ISO code for the current request. */ public static function countryForRequest() { $request = \request(); $ip = $request->ip(); return self::countryForIP($ip); } /** * Shortcut to creating a progress bar of a particular format with a particular message. * * @param \Illuminate\Console\OutputStyle $output Console output object * @param int $count Number of progress steps * @param string $message The description * * @return \Symfony\Component\Console\Helper\ProgressBar */ public static function createProgressBar($output, $count, $message = null) { $bar = $output->createProgressBar($count); $bar->setFormat( '%current:7s%/%max:7s% [%bar%] %percent:3s%% %elapsed:7s%/%estimated:-7s% %message% ' ); if ($message) { $bar->setMessage($message . " ..."); } $bar->start(); return $bar; } /** * Return the number of days in the month prior to this one. * * @return int */ public static function daysInLastMonth() { $start = new Carbon('first day of last month'); $end = new Carbon('last day of last month'); return $start->diffInDays($end) + 1; } /** * Download a file from the interwebz and store it locally. * * @param string $source The source location * @param string $target The target location * @param bool $force Force the download (and overwrite target) * * @return void */ public static function downloadFile($source, $target, $force = false) { if (is_file($target) && !$force) { return; } \Log::info("Retrieving {$source}"); $fp = fopen($target, 'w'); $curl = curl_init(); curl_setopt($curl, CURLOPT_URL, $source); curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1); curl_setopt($curl, CURLOPT_FILE, $fp); curl_exec($curl); if (curl_errno($curl)) { \Log::error("Request error on {$source}: " . curl_error($curl)); curl_close($curl); fclose($fp); unlink($target); return; } curl_close($curl); fclose($fp); } /** * Generate a passphrase. Not intended for use in production, so limited to environments that are not production. * * @return string */ public static function generatePassphrase() { if (\config('app.env') == 'production') { throw new \Exception("Thou shall not pass!"); } if (\config('app.passphrase')) { return \config('app.passphrase'); } $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; } /** * Find an object that is the recipient for the specified address. * * @param string $address * * @return array */ public static function findObjectsByRecipientAddress($address) { $address = \App\Utils::normalizeAddress($address); list($local, $domainName) = explode('@', $address); $domain = \App\Domain::where('namespace', $domainName)->first(); if (!$domain) { return []; } $user = \App\User::where('email', $address)->first(); if ($user) { return [$user]; } $userAliases = \App\UserAlias::where('alias', $address)->get(); if (count($userAliases) > 0) { $users = []; foreach ($userAliases as $userAlias) { $users[] = $userAlias->user; } return $users; } $userAliases = \App\UserAlias::where('alias', "catchall@{$domain->namespace}")->get(); if (count($userAliases) > 0) { $users = []; foreach ($userAliases as $userAlias) { $users[] = $userAlias->user; } return $users; } return []; } /** * Retrieve the network ID and Type from a client address * * @param string $clientAddress The IPv4 or IPv6 address. * * @return array An array of ID and class or null and null. */ public static function getNetFromAddress($clientAddress) { if (strpos($clientAddress, ':') === false) { $net = \App\IP4Net::getNet($clientAddress); if ($net) { return [$net->id, \App\IP4Net::class]; } } else { $net = \App\IP6Net::getNet($clientAddress); if ($net) { return [$net->id, \App\IP6Net::class]; } } return [null, null]; } /** * Calculate the broadcast address provided a net number and a prefix. * * @param string $net A valid IPv6 network number. * @param int $prefix The network prefix. * * @return string */ public static function ip6Broadcast($net, $prefix) { $netHex = bin2hex(inet_pton($net)); // Overwriting first address string to make sure notation is optimal $net = inet_ntop(hex2bin($netHex)); // Calculate the number of 'flexible' bits $flexbits = 128 - $prefix; // Build the hexadecimal string of the last address $lastAddrHex = $netHex; // We start at the end of the string (which is always 32 characters long) $pos = 31; while ($flexbits > 0) { // Get the character at this position $orig = substr($lastAddrHex, $pos, 1); // Convert it to an integer $origval = hexdec($orig); // OR it with (2^flexbits)-1, with flexbits limited to 4 at a time $newval = $origval | (pow(2, min(4, $flexbits)) - 1); // Convert it back to a hexadecimal character $new = dechex($newval); // And put that character back in the string $lastAddrHex = substr_replace($lastAddrHex, $new, $pos, 1); // We processed one nibble, move to previous position $flexbits -= 4; $pos -= 1; } // Convert the hexadecimal string to a binary string $lastaddrbin = hex2bin($lastAddrHex); // And create an IPv6 address from the binary string $lastaddrstr = inet_ntop($lastaddrbin); return $lastaddrstr; } /** * Normalize an email address. * * This means to lowercase and strip components separated with recipient delimiters. * - * @param string $address The address to normalize. + * @param ?string $address The address to normalize + * @param bool $asArray Return an array with local and domain part * - * @return string + * @return string|array Normalized email address as string or array */ - public static function normalizeAddress($address) + public static function normalizeAddress(?string $address, bool $asArray = false) { - $address = strtolower($address); + if ($address === null || $address === '') { + return $asArray ? ['', ''] : ''; + } + + $address = \strtolower($address); if (strpos($address, '@') === false) { - return $address; + return $asArray ? [$address, ''] : $address; } list($local, $domain) = explode('@', $address); - if (strpos($local, '+') === false) { - return "{$local}@{$domain}"; + if (strpos($local, '+') !== false) { + $local = explode('+', $local)[0]; } - $localComponents = explode('+', $local); - - $local = array_shift($localComponents); - - return "{$local}@{$domain}"; + return $asArray ? [$local, $domain] : "{$local}@{$domain}"; } /** * 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 random string consisting of a quantity of segments of a certain length joined. * * Example: * * ```php * $roomName = strtolower(\App\Utils::randStr(3, 3, '-'); * // $roomName == '3qb-7cs-cjj' * ``` * * @param int $length The length of each segment * @param int $qty The quantity of segments * @param string $join The string to use to join the segments * * @return string */ public static function randStr($length, $qty = 1, $join = '') { $chars = env('SHORTCODE_CHARS', self::CHARS); $randStrs = []; for ($x = 0; $x < $qty; $x++) { $randStrs[$x] = []; for ($y = 0; $y < $length; $y++) { $randStrs[$x][] = $chars[rand(0, strlen($chars) - 1)]; } shuffle($randStrs[$x]); $randStrs[$x] = implode('', $randStrs[$x]); } return implode($join, $randStrs); } /** * 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 * @param int|null $tenantId Current tenant * * @todo Move this to App\Http\Controllers\Controller * * @return string Full URL */ public static function serviceUrl(string $route, $tenantId = null): string { $url = \App\Tenant::getConfig($tenantId, 'app.public_url'); if (!$url) { $url = \App\Tenant::getConfig($tenantId, 'app.url'); } return rtrim(trim($url, '/') . '/' . ltrim($route, '/'), '/'); } /** * Create a configuration/environment data to be passed to * the UI * * @todo Move this to App\Http\Controllers\Controller * * @return array Configuration data */ public static function uiEnv(): array { $countries = include resource_path('countries.php'); $req_domain = preg_replace('/:[0-9]+$/', '', request()->getHttpHost()); $sys_domain = \config('app.domain'); $opts = [ 'app.name', 'app.url', 'app.domain', 'app.theme', 'app.webmail_url', 'app.support_email', 'mail.from.address' ]; $env = \app('config')->getMany($opts); $env['countries'] = $countries ?: []; $env['view'] = 'root'; $env['jsapp'] = 'user.js'; if ($req_domain == "admin.$sys_domain") { $env['jsapp'] = 'admin.js'; } elseif ($req_domain == "reseller.$sys_domain") { $env['jsapp'] = 'reseller.js'; } $env['paymentProvider'] = \config('services.payment_provider'); $env['stripePK'] = \config('services.stripe.public_key'); $env['languages'] = \App\Http\Controllers\ContentController::locales(); $env['menu'] = \App\Http\Controllers\ContentController::menu(); return $env; } /** * Retrieve an exchange rate. * * @param string $sourceCurrency: Currency from which to convert * @param string $targetCurrency: Currency to convert to * * @return float Exchange rate */ public static function exchangeRate(string $sourceCurrency, string $targetCurrency): float { if (strcasecmp($sourceCurrency, $targetCurrency) == 0) { return 1.0; } $currencyFile = resource_path("exchangerates-$sourceCurrency.php"); //Attempt to find the reverse exchange rate, if we don't have the file for the source currency if (!file_exists($currencyFile)) { $rates = include resource_path("exchangerates-$targetCurrency.php"); if (!isset($rates[$sourceCurrency])) { throw new \Exception("Failed to find the reverse exchange rate for " . $sourceCurrency); } return 1.0 / floatval($rates[$sourceCurrency]); } $rates = include $currencyFile; if (!isset($rates[$targetCurrency])) { throw new \Exception("Failed to find exchange rate for " . $targetCurrency); } return floatval($rates[$targetCurrency]); } } diff --git a/src/resources/js/app.js b/src/resources/js/app.js index b27fdb0c..aad37ace 100644 --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -1,525 +1,543 @@ /** * First we will load all of this project's JavaScript dependencies which * includes Vue and other libraries. It is a great starting point when * building robust, powerful web applications using Vue and Laravel. */ require('./bootstrap') import AppComponent from '../vue/App' import MenuComponent from '../vue/Widgets/Menu' import SupportForm from '../vue/Widgets/SupportForm' import store from './store' import { Tab } from 'bootstrap' import { loadLangAsync, i18n } from './locale' const loader = '
Loading
' let isLoading = 0 // Lock the UI with the 'loading...' element const startLoading = () => { isLoading++ let loading = $('#app > .app-loader').removeClass('fadeOut') if (!loading.length) { $('#app').append($(loader)) } } // Hide "loading" overlay const stopLoading = () => { if (isLoading > 0) { $('#app > .app-loader').addClass('fadeOut') isLoading--; } } let loadingRoute // Note: This has to be before the app is created // Note: You cannot use app inside of the function window.router.beforeEach((to, from, next) => { // check if the route requires authentication and user is not logged in if (to.meta.requiresAuth && !store.state.isLoggedIn) { // remember the original request, to use after login store.state.afterLogin = to; // redirect to login page next({ name: 'login' }) return } if (to.meta.loading) { startLoading() loadingRoute = to.name } next() }) window.router.afterEach((to, from) => { if (to.name && loadingRoute === to.name) { stopLoading() loadingRoute = null } // When changing a page remove old: // - error page // - modal backdrop $('#error-page,.modal-backdrop.show').remove() $('body').css('padding', 0) // remove padding added by unclosed modal }) const app = new Vue({ components: { AppComponent, MenuComponent, }, i18n, store, router: window.router, data() { return { isUser: !window.isAdmin && !window.isReseller, appName: window.config['app.name'], appUrl: window.config['app.url'], themeDir: '/themes/' + window.config['app.theme'] } }, methods: { // Clear (bootstrap) form validation state clearFormValidation(form) { $(form).find('.is-invalid').removeClass('is-invalid') $(form).find('.invalid-feedback').remove() }, hasPermission(type) { const authInfo = store.state.authInfo const key = 'enable' + type.charAt(0).toUpperCase() + type.slice(1) return !!(authInfo && authInfo.statusInfo[key]) }, hasRoute(name) { return this.$router.resolve({ name: name }).resolved.matched.length > 0 }, hasSKU(name) { const authInfo = store.state.authInfo return authInfo.statusInfo.skus && authInfo.statusInfo.skus.indexOf(name) != -1 }, isController(wallet_id) { if (wallet_id && store.state.authInfo) { let i for (i = 0; i < store.state.authInfo.wallets.length; i++) { if (wallet_id == store.state.authInfo.wallets[i].id) { return true } } for (i = 0; i < store.state.authInfo.accounts.length; i++) { if (wallet_id == store.state.authInfo.accounts[i].id) { return true } } } return false }, // Set user state to "logged in" loginUser(response, dashboard, update) { if (!update) { store.commit('logoutUser') // destroy old state data store.commit('loginUser') } localStorage.setItem('token', response.access_token) localStorage.setItem('refreshToken', response.refresh_token) axios.defaults.headers.common.Authorization = 'Bearer ' + response.access_token if (response.email) { store.state.authInfo = response } if (dashboard !== false) { this.$router.push(store.state.afterLogin || { name: 'dashboard' }) } store.state.afterLogin = null // Refresh the token before it expires let timeout = response.expires_in || 0 // We'll refresh 60 seconds before the token expires if (timeout > 60) { timeout -= 60 } // TODO: We probably should try a few times in case of an error // TODO: We probably should prevent axios from doing any requests // while the token is being refreshed this.refreshTimeout = setTimeout(() => { axios.post('/api/auth/refresh', {'refresh_token': response.refresh_token}).then(response => { this.loginUser(response.data, false, true) }) }, timeout * 1000) }, // Set user state to "not logged in" logoutUser(redirect) { store.commit('logoutUser') localStorage.setItem('token', '') localStorage.setItem('refreshToken', '') delete axios.defaults.headers.common.Authorization if (redirect !== false) { this.$router.push({ name: 'login' }) } clearTimeout(this.refreshTimeout) }, logo(mode) { let src = this.appUrl + this.themeDir + '/images/logo_' + (mode || 'header') + '.png' return `${this.appName}` }, // Display "loading" overlay inside of the specified element addLoader(elem, small = true, style = null) { if (style) { $(elem).css(style) } else { $(elem).css('position', 'relative') } $(elem).append(small ? $(loader).addClass('small') : $(loader)) }, // Remove loader element added in addLoader() removeLoader(elem) { $(elem).find('.app-loader').remove() }, startLoading, stopLoading, isLoading() { return isLoading > 0 }, tab(e) { e.preventDefault() new Tab(e.target).show() }, errorPage(code, msg, hint) { // Until https://github.com/vuejs/vue-router/issues/977 is implemented // we can't really use router to display error page as it has two side // effects: it changes the URL and adds the error page to browser history. // For now we'll be replacing current view with error page "manually". if (!msg) msg = this.$te('error.' + code) ? this.$t('error.' + code) : this.$t('error.unknown') if (!hint) hint = '' const error_page = '
' + `
${code}
${msg}
${hint}
` + '
' $('#error-page').remove() $('#app').append(error_page) app.updateBodyClass('error') }, errorHandler(error) { this.stopLoading() if (!error.response) { // TODO: probably network connection error } else if (error.response.status === 401) { // Remember requested route to come back to it after log in if (this.$route.meta.requiresAuth) { store.state.afterLogin = this.$route this.logoutUser() } else { this.logoutUser(false) } } else { this.errorPage(error.response.status, error.response.statusText) } }, downloadFile(url) { // TODO: This might not be a best way for big files as the content // will be stored (temporarily) in browser memory // TODO: This method does not show the download progress in the browser // but it could be implemented in the UI, axios has 'progress' property axios.get(url, { responseType: 'blob' }) .then(response => { const link = document.createElement('a') const contentDisposition = response.headers['content-disposition'] let filename = 'unknown' if (contentDisposition) { const match = contentDisposition.match(/filename="(.+)"/); if (match.length === 2) { filename = match[1]; } } link.href = window.URL.createObjectURL(response.data) link.download = filename link.click() }) }, price(price, currency) { // TODO: Set locale argument according to the currently used locale return ((price || 0) / 100).toLocaleString('de-DE', { style: 'currency', currency: currency || 'CHF' }) }, priceLabel(cost, discount, currency) { let index = '' if (discount) { cost = Math.floor(cost * ((100 - discount) / 100)) index = '\u00B9' } return this.price(cost, currency) + '/' + this.$t('wallet.month') + index }, clickRecord(event) { if (!/^(a|button|svg|path)$/i.test(event.target.nodeName)) { $(event.target).closest('tr').find('a').trigger('click') } }, domainStatusClass(domain) { if (domain.isDeleted) { return 'text-muted' } if (domain.isSuspended) { return 'text-warning' } if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) { return 'text-danger' } return 'text-success' }, domainStatusText(domain) { if (domain.isDeleted) { return this.$t('status.deleted') } if (domain.isSuspended) { return this.$t('status.suspended') } if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) { return this.$t('status.notready') } return this.$t('status.active') }, distlistStatusClass(list) { if (list.isDeleted) { return 'text-muted' } if (list.isSuspended) { return 'text-warning' } if (!list.isLdapReady) { return 'text-danger' } return 'text-success' }, distlistStatusText(list) { if (list.isDeleted) { return this.$t('status.deleted') } if (list.isSuspended) { return this.$t('status.suspended') } if (!list.isLdapReady) { return this.$t('status.notready') } return this.$t('status.active') }, pageName(path) { let page = this.$route.path // check if it is a "menu page", find the page name // otherwise we'll use the real path as page name window.config.menu.every(item => { if (item.location == page && item.page) { page = item.page return false } }) page = page.replace(/^\//, '') return page ? page : '404' }, supportDialog(container) { let dialog = $('#support-dialog')[0] if (!dialog) { // FIXME: Find a nicer way of doing this SupportForm.i18n = i18n let form = new Vue(SupportForm) form.$mount($('
').appendTo(container)[0]) form.$root = this form.$toast = this.$toast dialog = form.$el } dialog.__vue__.showDialog() }, userStatusClass(user) { if (user.isDeleted) { return 'text-muted' } if (user.isSuspended) { return 'text-warning' } if (!user.isImapReady || !user.isLdapReady) { return 'text-danger' } return 'text-success' }, userStatusText(user) { if (user.isDeleted) { return this.$t('status.deleted') } if (user.isSuspended) { return this.$t('status.suspended') } if (!user.isImapReady || !user.isLdapReady) { return this.$t('status.notready') } return this.$t('status.active') }, + // Append some wallet properties to the object + userWalletProps(object) { + let wallet = store.state.authInfo.accounts[0] + + if (!wallet) { + wallet = store.state.authInfo.wallets[0] + } + + if (wallet) { + object.currency = wallet.currency + if (wallet.discount) { + object.discount = wallet.discount + object.discount_description = wallet.discount_description + } + } + }, updateBodyClass(name) { // Add 'class' attribute to the body, different for each page // so, we can apply page-specific styles document.body.className = 'page-' + (name || this.pageName()).replace(/\/.*$/, '') } } }) // Fetch the locale file and the start the app loadLangAsync().then(() => app.$mount('#app')) // Add a axios request interceptor window.axios.interceptors.request.use( config => { // This is the only way I found to change configuration options // on a running application. We need this for browser testing. config.headers['X-Test-Payment-Provider'] = window.config.paymentProvider return config }, error => { // Do something with request error return Promise.reject(error) } ) // Add a axios response interceptor for general/validation error handler window.axios.interceptors.response.use( response => { if (response.config.onFinish) { response.config.onFinish() } return response }, error => { - let error_msg - let status = error.response ? error.response.status : 200 - // Do not display the error in a toast message, pass the error as-is if (error.config.ignoreErrors) { return Promise.reject(error) } if (error.config.onFinish) { error.config.onFinish() } - if (error.response && status == 422) { - error_msg = "Form validation error" + let error_msg + + const status = error.response ? error.response.status : 200 + const data = error.response ? error.response.data : {} + + if (status == 422 && data.errors) { + error_msg = app.$t('error.form') const modal = $('div.modal.show') $(modal.length ? modal : 'form').each((i, form) => { form = $(form) - $.each(error.response.data.errors || {}, (idx, msg) => { + $.each(data.errors, (idx, msg) => { const input_name = (form.data('validation-prefix') || form.find('form').first().data('validation-prefix') || '') + idx let input = form.find('#' + input_name) if (!input.length) { input = form.find('[name="' + input_name + '"]'); } if (input.length) { // Create an error message // API responses can use a string, array or object let msg_text = '' if (typeof(msg) !== 'string') { $.each(msg, (index, str) => { msg_text += str + ' ' }) } else { msg_text = msg } let feedback = $('
').text(msg_text) if (input.is('.list-input')) { // List input widget let controls = input.children(':not(:first-child)') if (!controls.length && typeof msg == 'string') { // this is an empty list (the main input only) // and the error message is not an array input.find('.main-input').addClass('is-invalid') } else { controls.each((index, element) => { if (msg[index]) { $(element).find('input').addClass('is-invalid') } }) } input.addClass('is-invalid').next('.invalid-feedback').remove() input.after(feedback) } else { // Standard form element input.addClass('is-invalid') input.parent().find('.invalid-feedback').remove() input.parent().append(feedback) } } }) form.find('.is-invalid:not(.listinput-widget)').first().focus() }) } - else if (error.response && error.response.data) { - error_msg = error.response.data.message + else if (data.status == 'error') { + error_msg = data.message } else { error_msg = error.request ? error.request.statusText : error.message } app.$toast.error(error_msg || app.$t('error.server')) // Pass the error as-is return Promise.reject(error) } ) diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php index 664faa71..566723fc 100644 --- a/src/resources/lang/en/app.php +++ b/src/resources/lang/en/app.php @@ -1,84 +1,96 @@ 'Created', + 'chart-deleted' => 'Deleted', + 'chart-average' => 'average', + 'chart-allusers' => 'All Users - last year', + 'chart-discounts' => 'Discounts', + 'chart-vouchers' => 'Vouchers', + 'chart-income' => 'Income in :currency - last 8 weeks', + 'chart-users' => 'Users - last 8 weeks', + 'mandate-delete-success' => 'The auto-payment has been removed.', 'mandate-update-success' => 'The auto-payment has been updated.', 'planbutton' => 'Choose :plan', 'process-async' => 'Setup process has been pushed. Please wait.', 'process-user-new' => 'Registering a user...', 'process-user-ldap-ready' => 'Creating a user...', 'process-user-imap-ready' => 'Creating a mailbox...', 'process-distlist-new' => 'Registering a distribution list...', 'process-distlist-ldap-ready' => 'Creating a distribution list...', 'process-domain-new' => 'Registering a custom domain...', 'process-domain-ldap-ready' => 'Creating a custom domain...', 'process-domain-verified' => 'Verifying a custom domain...', 'process-domain-confirmed' => 'Verifying an ownership of a custom domain...', 'process-success' => 'Setup process finished successfully.', 'process-error-user-ldap-ready' => 'Failed to create a user.', 'process-error-user-imap-ready' => 'Failed to verify that a mailbox exists.', 'process-error-domain-ldap-ready' => 'Failed to create a domain.', 'process-error-domain-verified' => 'Failed to verify a domain.', 'process-error-domain-confirmed' => 'Failed to verify an ownership of a domain.', 'process-distlist-new' => 'Registering a distribution list...', 'process-distlist-ldap-ready' => 'Creating a distribution list...', 'process-error-distlist-ldap-ready' => 'Failed to create a distribution list.', 'distlist-update-success' => 'Distribution list updated successfully.', 'distlist-create-success' => 'Distribution list created successfully.', 'distlist-delete-success' => 'Distribution list deleted successfully.', 'distlist-suspend-success' => 'Distribution list suspended successfully.', 'distlist-unsuspend-success' => 'Distribution list unsuspended successfully.', + 'domain-create-success' => 'Domain created successfully.', + 'domain-delete-success' => 'Domain deleted successfully.', + 'domain-notempty-error' => 'Unable to delete a domain with assigned users or other objects.', 'domain-verify-success' => 'Domain verified successfully.', 'domain-verify-error' => 'Domain ownership verification failed.', 'domain-suspend-success' => 'Domain suspended successfully.', 'domain-unsuspend-success' => 'Domain unsuspended successfully.', 'domain-setconfig-success' => 'Domain settings updated successfully.', 'user-update-success' => 'User data updated successfully.', 'user-create-success' => 'User created successfully.', 'user-delete-success' => 'User deleted successfully.', 'user-suspend-success' => 'User suspended successfully.', 'user-unsuspend-success' => 'User unsuspended successfully.', 'user-reset-2fa-success' => '2-Factor authentication reset successfully.', 'user-setconfig-success' => 'User settings updated successfully.', 'user-set-sku-success' => 'The subscription added successfully.', 'user-set-sku-already-exists' => 'The subscription already exists.', 'search-foundxdomains' => ':x domains have been found.', 'search-foundxgroups' => ':x distribution lists have been found.', 'search-foundxusers' => ':x user accounts have been found.', 'signup-invitations-created' => 'The invitation has been created.|:count invitations has been created.', 'signup-invitations-csv-empty' => 'Failed to find any valid email addresses in the uploaded file.', 'signup-invitations-csv-invalid-email' => 'Found an invalid email address (:email) on line :line.', 'signup-invitation-delete-success' => 'Invitation deleted successfully.', 'signup-invitation-resend-success' => 'Invitation added to the sending queue successfully.', 'support-request-success' => 'Support request submitted successfully.', 'support-request-error' => 'Failed to submit the support request.', 'siteuser' => ':site User', 'wallet-award-success' => 'The bonus has been added to the wallet successfully.', 'wallet-penalty-success' => 'The penalty has been added to the wallet successfully.', 'wallet-update-success' => 'User wallet updated successfully.', 'wallet-notice-date' => 'With your current subscriptions your account balance will last until about :date (:days).', 'wallet-notice-nocredit' => 'You are out of credit, top up your balance now.', 'wallet-notice-today' => 'You will run out of credit today, top up your balance now.', 'wallet-notice-trial' => 'You are in your free trial period.', 'wallet-notice-trial-end' => 'Your free trial is about to end, top up to continue.', ]; diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php index 243aabf2..8c13a1f2 100644 --- a/src/resources/lang/en/ui.php +++ b/src/resources/lang/en/ui.php @@ -1,422 +1,430 @@ [ 'faq' => "FAQ", ], 'btn' => [ 'add' => "Add", 'accept' => "Accept", 'back' => "Back", 'cancel' => "Cancel", 'close' => "Close", 'continue' => "Continue", 'delete' => "Delete", 'deny' => "Deny", 'download' => "Download", 'edit' => "Edit", 'file' => "Choose file...", 'moreinfo' => "More information", 'refresh' => "Refresh", 'reset' => "Reset", 'resend' => "Resend", 'save' => "Save", 'search' => "Search", 'signup' => "Sign Up", 'submit' => "Submit", 'suspend' => "Suspend", 'unsuspend' => "Unsuspend", 'verify' => "Verify", ], 'dashboard' => [ 'beta' => "beta", 'distlists' => "Distribution lists", 'chat' => "Video chat", 'domains' => "Domains", 'invitations' => "Invitations", 'profile' => "Your profile", 'users' => "User accounts", 'wallet' => "Wallet", 'webmail' => "Webmail", 'stats' => "Stats", ], 'distlist' => [ 'list-title' => "Distribution list | Distribution lists", 'create' => "Create list", 'delete' => "Delete list", 'email' => "Email", 'list-empty' => "There are no distribution lists in this account.", 'new' => "New distribution list", 'recipients' => "Recipients", ], 'domain' => [ + 'delete' => "Delete domain", + 'delete-domain' => "Delete {domain}", + 'delete-text' => "Do you really want to delete this domain permanently?" + . " This is only possible if there are no users, aliases or other objects in this domain." + . " Please note that this action cannot be undone.", 'dns-verify' => "Domain DNS verification sample:", 'dns-config' => "Domain DNS configuration sample:", 'namespace' => "Namespace", 'spf-whitelist' => "SPF Whitelist", 'spf-whitelist-text' => "The Sender Policy Framework allows a sender domain to disclose, through DNS, " . "which systems are allowed to send emails with an envelope sender address within said domain.", 'spf-whitelist-ex' => "Here you can specify a list of allowed servers, for example: .ess.barracuda.com.", 'verify' => "Domain verification", 'verify-intro' => "In order to confirm that you're the actual holder of the domain, we need to run a verification process before finally activating it for email delivery.", 'verify-dns' => "The domain must have one of the following entries in DNS:", 'verify-dns-txt' => "TXT entry with value:", 'verify-dns-cname' => "or CNAME entry:", 'verify-outro' => "When this is done press the button below to start the verification.", 'verify-sample' => "Here's a sample zone file for your domain:", 'config' => "Domain configuration", 'config-intro' => "In order to let {app} receive email traffic for your domain you need to adjust the DNS settings, more precisely the MX entries, accordingly.", 'config-sample' => "Edit your domain's zone file and replace existing MX entries with the following values:", 'config-hint' => "If you don't know how to set DNS entries for your domain, please contact the registration service where you registered the domain or your web hosting provider.", + 'create' => "Create domain", + 'new' => "New domain", ], 'error' => [ '400' => "Bad request", '401' => "Unauthorized", '403' => "Access denied", '404' => "Not found", '405' => "Method not allowed", '500' => "Internal server error", 'unknown' => "Unknown Error", 'server' => "Server Error", + 'form' => "Form validation error", ], 'form' => [ 'amount' => "Amount", 'code' => "Confirmation Code", 'config' => "Configuration", 'date' => "Date", 'description' => "Description", 'details' => "Details", 'disabled' => "disabled", 'domain' => "Domain", 'email' => "Email Address", 'enabled' => "enabled", 'firstname' => "First Name", 'general' => "General", 'lastname' => "Last Name", 'none' => "none", 'or' => "or", 'password' => "Password", 'password-confirm' => "Confirm Password", 'phone' => "Phone", 'settings' => "Settings", 'status' => "Status", 'surname' => "Surname", 'user' => "User", 'primary-email' => "Primary Email", 'id' => "ID", 'created' => "Created", 'deleted' => "Deleted", ], 'invitation' => [ 'create' => "Create invite(s)", 'create-title' => "Invite for a signup", 'create-email' => "Enter an email address of the person you want to invite.", 'create-csv' => "To send multiple invitations at once, provide a CSV (comma separated) file, or alternatively a plain-text file, containing one email address per line.", 'empty-list' => "There are no invitations in the database.", 'title' => "Signup invitations", 'search' => "Email address or domain", 'send' => "Send invite(s)", 'status-completed' => "User signed up", 'status-failed' => "Sending failed", 'status-sent' => "Sent", 'status-new' => "Not sent yet", ], 'lang' => [ 'en' => "English", 'de' => "German", 'fr' => "French", 'it' => "Italian", ], 'login' => [ '2fa' => "Second factor code", '2fa_desc' => "Second factor code is optional for users with no 2-Factor Authentication setup.", 'forgot_password' => "Forgot password?", 'header' => "Please sign in", 'sign_in' => "Sign in", 'webmail' => "Webmail" ], 'meet' => [ 'title' => "Voice & Video Conferencing", 'welcome' => "Welcome to our beta program for Voice & Video Conferencing.", 'url' => "You have a room of your own at the URL below. This room is only open when you yourself are in attendance. Use this URL to invite people to join you.", 'notice' => "This is a work in progress and more features will be added over time. Current features include:", 'sharing' => "Screen Sharing", 'sharing-text' => "Share your screen for presentations or show-and-tell.", 'security' => "Room Security", 'security-text' => "Increase the room security by setting a password that attendees will need to know" . " before they can enter, or lock the door so attendees will have to knock, and a moderator can accept or deny those requests.", 'qa' => "Raise Hand (Q&A)", 'qa-text' => "Silent audience members can raise their hand to facilitate a Question & Answer session with the panel members.", 'moderation' => "Moderator Delegation", 'moderation-text' => "Delegate moderator authority for the session, so that a speaker is not needlessly" . " interrupted with attendees knocking and other moderator duties.", 'eject' => "Eject Attendees", 'eject-text' => "Eject attendees from the session in order to force them to reconnect, or address policy" . " violations. Click the user icon for effective dismissal.", 'silent' => "Silent Audience Members", 'silent-text' => "For a webinar-style session, configure the room to force all new attendees to be silent audience members.", 'interpreters' => "Language Specific Audio Channels", 'interpreters-text' => "Designate a participant to interpret the original audio to a target language, for sessions" . " with multi-lingual attendees. The interpreter is expected to be able to relay the original audio, and override it.", 'beta-notice' => "Keep in mind that this is still in beta and might come with some issues." . " Should you encounter any on your way, let us know by contacting support.", // Room options dialog 'options' => "Room options", 'password' => "Password", 'password-none' => "none", 'password-clear' => "Clear password", 'password-set' => "Set password", 'password-text' => "You can add a password to your meeting. Participants will have to provide the password before they are allowed to join the meeting.", 'lock' => "Locked room", 'lock-text' => "When the room is locked participants have to be approved by a moderator before they could join the meeting.", 'nomedia' => "Subscribers only", 'nomedia-text' => "Forces all participants to join as subscribers (with camera and microphone turned off)." . " Moderators will be able to promote them to publishers throughout the session.", // Room menu 'partcnt' => "Number of participants", 'menu-audio-mute' => "Mute audio", 'menu-audio-unmute' => "Unmute audio", 'menu-video-mute' => "Mute video", 'menu-video-unmute' => "Unmute video", 'menu-screen' => "Share screen", 'menu-hand-lower' => "Lower hand", 'menu-hand-raise' => "Raise hand", 'menu-channel' => "Interpreted language channel", 'menu-chat' => "Chat", 'menu-fullscreen' => "Full screen", 'menu-fullscreen-exit' => "Exit full screen", 'menu-leave' => "Leave session", // Room setup screen 'setup-title' => "Set up your session", 'mic' => "Microphone", 'cam' => "Camera", 'nick' => "Nickname", 'nick-placeholder' => "Your name", 'join' => "JOIN", 'joinnow' => "JOIN NOW", 'imaowner' => "I'm the owner", // Room 'qa' => "Q & A", 'leave-title' => "Room closed", 'leave-body' => "The session has been closed by the room owner.", 'media-title' => "Media setup", 'join-request' => "Join request", 'join-requested' => "{user} requested to join.", // Status messages 'status-init' => "Checking the room...", 'status-323' => "The room is closed. Please, wait for the owner to start the session.", 'status-324' => "The room is closed. It will be open for others after you join.", 'status-325' => "The room is ready. Please, provide a valid password.", 'status-326' => "The room is locked. Please, enter your name and try again.", 'status-327' => "Waiting for permission to join the room.", 'status-404' => "The room does not exist.", 'status-429' => "Too many requests. Please, wait.", 'status-500' => "Failed to connect to the room. Server error.", // Other menus 'media-setup' => "Media setup", 'perm' => "Permissions", 'perm-av' => "Audio & Video publishing", 'perm-mod' => "Moderation", 'lang-int' => "Language interpreter", 'menu-options' => "Options", ], 'menu' => [ 'cockpit' => "Cockpit", 'login' => "Login", 'logout' => "Logout", 'signup' => "Signup", 'toggle' => "Toggle navigation", ], 'msg' => [ 'initializing' => "Initializing...", 'loading' => "Loading...", 'loading-failed' => "Failed to load data.", 'notfound' => "Resource not found.", 'info' => "Information", 'error' => "Error", 'warning' => "Warning", 'success' => "Success", ], 'nav' => [ 'more' => "Load more", 'step' => "Step {i}/{n}", ], 'password' => [ 'reset' => "Password Reset", 'reset-step1' => "Enter your email address to reset your password.", 'reset-step1-hint' => "You may need to check your spam folder or unblock {email}.", 'reset-step2' => "We sent out a confirmation code to your external email address." . " Enter the code we sent you, or click the link in the message.", ], 'signup' => [ 'email' => "Existing Email Address", 'login' => "Login", 'title' => "Sign Up", 'step1' => "Sign up to start your free month.", 'step2' => "We sent out a confirmation code to your email address. Enter the code we sent you, or click the link in the message.", 'step3' => "Create your Kolab identity (you can choose additional addresses later).", 'voucher' => "Voucher Code", ], 'status' => [ 'prepare-account' => "We are preparing your account.", 'prepare-domain' => "We are preparing the domain.", 'prepare-distlist' => "We are preparing the distribution list.", 'prepare-user' => "We are preparing the user account.", 'prepare-hint' => "Some features may be missing or readonly at the moment.", 'prepare-refresh' => "The process never ends? Press the \"Refresh\" button, please.", 'ready-account' => "Your account is almost ready.", 'ready-domain' => "The domain is almost ready.", 'ready-distlist' => "The distribution list is almost ready.", 'ready-user' => "The user account is almost ready.", 'verify' => "Verify your domain to finish the setup process.", 'verify-domain' => "Verify domain", 'deleted' => "Deleted", 'suspended' => "Suspended", 'notready' => "Not Ready", 'active' => "Active", ], 'support' => [ 'title' => "Contact Support", 'id' => "Customer number or email address you have with us", 'id-pl' => "e.g. 12345678 or john@kolab.org", 'id-hint' => "Leave blank if you are not a customer yet", 'name' => "Name", 'name-pl' => "how we should call you in our reply", 'email' => "Working email address", 'email-pl' => "make sure we can reach you at this address", 'summary' => "Issue Summary", 'summary-pl' => "one sentence that summarizes your issue", 'expl' => "Issue Explanation", ], 'user' => [ '2fa-hint1' => "This will remove 2-Factor Authentication entitlement as well as the user-configured factors.", '2fa-hint2' => "Please, make sure to confirm the user identity properly.", 'add-beta' => "Enable beta program", 'address' => "Address", 'aliases' => "Aliases", 'aliases-email' => "Email Aliases", 'aliases-none' => "This user has no email aliases.", 'add-bonus' => "Add bonus", 'add-bonus-title' => "Add a bonus to the wallet", 'add-penalty' => "Add penalty", 'add-penalty-title' => "Add a penalty to the wallet", 'auto-payment' => "Auto-payment", 'auto-payment-text' => "Fill up by {amount} when under {balance} using {method}", 'country' => "Country", 'create' => "Create user", 'custno' => "Customer No.", 'delete' => "Delete user", 'delete-account' => "Delete this account?", 'delete-email' => "Delete {email}", 'delete-text' => "Do you really want to delete this user permanently?" . " This will delete all account data and withdraw the permission to access the email account." . " Please note that this action cannot be undone.", 'discount' => "Discount", 'discount-hint' => "applied discount", 'discount-title' => "Account discount", 'distlists' => "Distribution lists", 'distlists-none' => "There are no distribution lists in this account.", 'domains' => "Domains", 'domains-none' => "There are no domains in this account.", 'ext-email' => "External Email", 'finances' => "Finances", 'greylisting' => "Greylisting", 'greylisting-text' => "Greylisting is a method of defending users against spam. Any incoming mail from an unrecognized sender " . "is temporarily rejected. The originating server should try again after a delay. " . "This time the email will be accepted. Spammers usually do not reattempt mail delivery.", 'list-title' => "User accounts", 'managed-by' => "Managed by", 'new' => "New user account", 'org' => "Organization", 'package' => "Package", 'price' => "Price", 'profile-title' => "Your profile", 'profile-delete' => "Delete account", 'profile-delete-title' => "Delete this account?", 'profile-delete-text1' => "This will delete the account as well as all domains, users and aliases associated with this account.", 'profile-delete-warning' => "This operation is irreversible", 'profile-delete-text2' => "As you will not be able to recover anything after this point, please make sure that you have migrated all data before proceeding.", 'profile-delete-support' => "As we always strive to improve, we would like to ask for 2 minutes of your time. " . "The best tool for improvement is feedback from users, and we would like to ask " . "for a few words about your reasons for leaving our service. Please send your feedback to {email}.", 'profile-delete-contact' => "Also feel free to contact {app} Support with any questions or concerns that you may have in this context.", 'reset-2fa' => "Reset 2-Factor Auth", 'reset-2fa-title' => "2-Factor Authentication Reset", 'title' => "User account", 'search-pl' => "User ID, email or domain", 'skureq' => "{sku} requires {list}.", 'subscription' => "Subscription", 'subscriptions' => "Subscriptions", 'subscriptions-none' => "This user has no subscriptions.", 'users' => "Users", 'users-none' => "There are no users in this account.", ], 'wallet' => [ 'add-credit' => "Add credit", 'auto-payment-cancel' => "Cancel auto-payment", 'auto-payment-change' => "Change auto-payment", 'auto-payment-failed' => "The setup of automatic payments failed. Restart the process to enable automatic top-ups.", 'auto-payment-hint' => "Here is how it works: Every time your account runs low, we will charge your preferred payment method for an amount you choose." . " You can cancel or change the auto-payment option at any time.", 'auto-payment-setup' => "Set up auto-payment", 'auto-payment-disabled' => "The configured auto-payment has been disabled. Top up your wallet or raise the auto-payment amount.", 'auto-payment-info' => "Auto-payment is set to fill up your account by {amount} every time your account balance gets under {balance}.", 'auto-payment-inprogress' => "The setup of the automatic payment is still in progress.", 'auto-payment-next' => "Next, you will be redirected to the checkout page, where you can provide your credit card details.", 'auto-payment-disabled-next' => "The auto-payment is disabled. Immediately after you submit new settings we'll enable it and attempt to top up your wallet.", 'auto-payment-update' => "Update auto-payment", 'banktransfer-hint' => "Please note that a bank transfer can take several days to complete.", 'currency-conv' => "Here is how it works: You specify the amount by which you want to top up your wallet in {wc}." . " We will then convert this to {pc}, and on the next page you will be provided with the bank-details to transfer the amount in {pc}.", 'fill-up' => "Fill up by", 'history' => "History", 'month' => "month", 'noperm' => "Only account owners can access a wallet.", 'payment-amount-hint' => "Choose the amount by which you want to top up your wallet.", 'payment-method' => "Method of payment: {method}", 'payment-warning' => "You will be charged for {price}.", 'pending-payments' => "Pending Payments", 'pending-payments-warning' => "You have payments that are still in progress. See the \"Pending Payments\" tab below.", 'pending-payments-none' => "There are no pending payments for this account.", 'receipts' => "Receipts", 'receipts-hint' => "Here you can download receipts (in PDF format) for payments in specified period. Select the period and press the Download button.", 'receipts-none' => "There are no receipts for payments in this account. Please, note that you can download receipts after the month ends.", 'title' => "Account balance", 'top-up' => "Top up your wallet", 'transactions' => "Transactions", 'transactions-none' => "There are no transactions for this account.", 'when-below' => "when account balance is below", ], ]; diff --git a/src/resources/lang/fr/app.php b/src/resources/lang/fr/app.php index f6a03494..21c9f9de 100644 --- a/src/resources/lang/fr/app.php +++ b/src/resources/lang/fr/app.php @@ -1,83 +1,94 @@ "Crée", + 'chart-deleted' => "Supprimé", + 'chart-average' => "moyenne", + 'chart-allusers' => "Tous Utilisateurs - l'année derniere", + 'chart-discounts' => "Rabais", + 'chart-vouchers' => "Coupons", + 'chart-income' => "Revenus en :currency - 8 dernières semaines", + 'chart-users' => "Utilisateurs - 8 dernières semaines", + 'mandate-delete-success' => "L'auto-paiement a été supprimé.", 'mandate-update-success' => "L'auto-paiement a été mis-à-jour.", 'planbutton' => "Choisir :plan", 'siteuser' => "Utilisateur du :site", 'domain-setconfig-success' => "Les paramètres du domaine sont mis à jour avec succès.", 'user-setconfig-success' => "Les paramètres d'utilisateur sont mis à jour avec succès.", 'process-async' => "Le processus d'installation a été poussé. Veuillez patienter.", 'process-user-new' => "Enregistrement d'un utilisateur...", 'process-user-ldap-ready' => "Création d'un utilisateur...", 'process-user-imap-ready' => "Création d'une boîte aux lettres...", 'process-distlist-new' => "Enregistrement d'une liste de distribution...", 'process-distlist-ldap-ready' => "Création d'une liste de distribution...", 'process-domain-new' => "Enregistrement d'un domaine personnalisé...", 'process-domain-ldap-ready' => "Création d'un domaine personnalisé...", 'process-domain-verified' => "Vérification d'un domaine personnalisé...", 'process-domain-confirmed' => "vérification de la propriété d'un domaine personnalisé...", 'process-success' => "Le processus d'installation s'est terminé avec succès.", 'process-error-user-ldap-ready' => "Échec de créar un utilisateur.", 'process-error-user-imap-ready' => "Échec de la vérification de l'existence d'une boîte aux lettres.", 'process-error-domain-ldap-ready' => "Échec de créer un domaine.", 'process-error-domain-verified' => "Échec de vérifier un domaine.", 'process-error-domain-confirmed' => "Échec de la vérification de la propriété d'un domaine.", 'process-distlist-new' => "Enregistrement d'une liste de distribution...", 'process-distlist-ldap-ready' => "Création d'une liste de distribution...", 'process-error-distlist-ldap-ready' => "Échec de créer une liste de distrubion.", 'distlist-update-success' => "Liste de distribution mis-à-jour avec succès.", 'distlist-create-success' => "Liste de distribution créer avec succès.", 'distlist-delete-success' => "Liste de distribution suppriméee avec succès.", 'distlist-suspend-success' => "Liste de distribution à été suspendue avec succès.", 'distlist-unsuspend-success' => "Liste de distribution à été débloquée avec succès.", + 'domain-create-success' => "Domaine a été crée avec succès.", + 'domain-delete-success' => "Domaine supprimé avec succès.", 'domain-verify-success' => "Domaine vérifié avec succès.", 'domain-verify-error' => "Vérification de propriété de domaine à échoué.", 'domain-suspend-success' => "Domaine suspendue avec succès.", 'domain-unsuspend-success' => "Domaine debloqué avec succès.", 'user-update-success' => "Mis-à-jour des données de l'utilsateur effectué avec succès.", 'user-create-success' => "Utilisateur a été crée avec succès.", 'user-delete-success' => "Utilisateur a été supprimé avec succès.", 'user-suspend-success' => "Utilisateur a été suspendu avec succès.", 'user-unsuspend-success' => "Utilisateur a été debloqué avec succès.", 'user-reset-2fa-success' => "Réinstallation de l'authentification à 2-Facteur avec succès.", 'user-set-sku-success' => "Souscription ajoutée avec succès.", 'user-set-sku-already-exists' => "La souscription existe déjà.", 'search-foundxdomains' => "Les domaines :x ont été trouvés.", 'search-foundxgroups' => "Les listes de distribution :x ont été trouvées.", 'search-foundxusers' => "Les comptes d'utilisateurs :x ont été trouvés.", 'signup-invitations-created' => "L'invitation à été crée.|:count nombre d'invitations ont été crée.", 'signup-invitations-csv-empty' => "Aucune adresses email valides ont été trouvées dans le fichier téléchargé.", 'signup-invitations-csv-invalid-email' => "Une adresse email invalide a été trouvée (:email) on line :line.", 'signup-invitation-delete-success' => "Invitation supprimée avec succès.", 'signup-invitation-resend-success' => "Invitation ajoutée à la file d'attente d'envoi avec succès.", 'support-request-success' => "Demande de soutien soumise avec succès.", 'support-request-error' => "La soumission de demande de soutien a échoué.", 'wallet-award-success' => "Le bonus a été ajouté au portefeuille avec succès.", 'wallet-penalty-success' => "La pénalité a été ajoutée au portefeuille avec succès.", 'wallet-update-success' => "Portefeuille d'utilisateur a été mis-à-jour avec succès.", 'wallet-notice-date' => "Avec vos abonnements actuels, le solde de votre compte durera jusqu'à environ :date (:days).", 'wallet-notice-nocredit' => "Votre crédit a été epuisé, veuillez recharger immédiatement votre solde.", 'wallet-notice-today' => "Votre reste crédit sera épuisé aujourd'hui, veuillez recharger immédiatement.", 'wallet-notice-trial' => "Vous êtes dans votre période d'essai gratuite.", 'wallet-notice-trial-end' => "Vous approchez de la fin de votre période d'essai gratuite, veuillez recharger pour continuer.", ]; diff --git a/src/resources/lang/fr/ui.php b/src/resources/lang/fr/ui.php index b8cc2270..c3cf5a65 100644 --- a/src/resources/lang/fr/ui.php +++ b/src/resources/lang/fr/ui.php @@ -1,422 +1,430 @@ [ 'faq' => "FAQ", ], 'btn' => [ 'add' => "Ajouter", 'accept' => "Accepter", 'back' => "Back", 'cancel' => "Annuler", 'close' => "Fermer", 'continue' => "Continuer", 'delete' => "Supprimer", 'deny' => "Refuser", 'download' => "Télécharger", 'edit' => "Modifier", 'file' => "Choisir le ficher...", 'moreinfo' => "Plus d'information", 'refresh' => "Actualiser", 'reset' => "Réinitialiser", 'resend' => "Envoyer à nouveau", 'save' => "Sauvegarder", 'search' => "Chercher", 'signup' => "S'inscrire", 'submit' => "Soumettre", 'suspend' => "Suspendre", 'unsuspend' => "Débloquer", 'verify' => "Vérifier", ], 'dashboard' => [ 'beta' => "bêta", 'distlists' => "Listes de distribution", 'chat' => "Chat Vidéo", 'domains' => "Domaines", 'invitations' => "Invitations", 'profile' => "Votre profil", 'users' => "D'utilisateurs", 'wallet' => "Portefeuille", 'webmail' => "Webmail", 'stats' => "Statistiques", ], 'distlist' => [ 'list-title' => "Liste de distribution | Listes de Distribution", 'create' => "Créer une liste", 'delete' => "Suprimmer une list", 'email' => "Courriel", 'list-empty' => "il n'y a pas de listes de distribution dans ce compte.", 'new' => "Nouvelle liste de distribution", 'recipients' => "Destinataires", ], 'domain' => [ 'dns-verify' => "Exemple de vérification du DNS d'un domaine:", 'dns-config' => "Exemple de configuration du DNS d'un domaine:", 'namespace' => "Espace de noms", 'verify' => "Vérification du domaine", 'verify-intro' => "Afin de confirmer que vous êtes bien le titulaire du domaine, nous devons exécuter un processus de vérification avant de l'activer définitivement pour la livraison d'e-mails.", 'verify-dns' => "Le domaine doit avoir l'une des entrées suivantes dans le DNS:", 'verify-dns-txt' => "Entrée TXT avec valeur:", 'verify-dns-cname' => "ou entrée CNAME:", 'verify-outro' => "Lorsque cela est fait, appuyez sur le bouton ci-dessous pour lancer la vérification.", 'verify-sample' => "Voici un fichier de zone simple pour votre domaine:", 'config' => "Configuration du domaine", 'config-intro' => "Afin de permettre à {app} de recevoir le trafic de messagerie pour votre domaine, vous devez ajuster les paramètres DNS, plus précisément les entrées MX, en conséquence.", 'config-sample' => "Modifiez le fichier de zone de votre domaine et remplacez les entrées MX existantes par les valeurs suivantes:", 'config-hint' => "Si vous ne savez pas comment définir les entrées DNS pour votre domaine, veuillez contacter le service d'enregistrement auprès duquel vous avez enregistré le domaine ou votre fournisseur d'hébergement Web.", 'spf-whitelist' => "SPF Whitelist", 'spf-whitelist-text' => "Le Sender Policy Framework permet à un domaine expéditeur de dévoiler, par le biais de DNS," . " quels systèmes sont autorisés à envoyer des e-mails avec une adresse d'expéditeur d'enveloppe dans le domaine en question.", 'spf-whitelist-ex' => "Vous pouvez ici spécifier une liste de serveurs autorisés, par exemple: .ess.barracuda.com.", + 'create' => "Créer domaine", + 'new' => "Nouveau domaine", + 'delete' => "Supprimer domaine", + 'delete-domain' => "Supprimer {domain}", + 'delete-text' => "Voulez-vous vraiment supprimer ce domaine de façon permanente?" + . " Ceci n'est possible que s'il n'y a pas d'utilisateurs, d'alias ou d'autres objets dans ce domaine." + . " Veuillez noter que cette action ne peut pas être inversée.", ], 'error' => [ '400' => "Mauvaide demande", '401' => "Non autorisé", '403' => "Accès refusé", '404' => "Pas trouvé", '405' => "Méthode non autorisée", '500' => "Erreur de serveur interne", 'unknown' => "Erreur inconnu", 'server' => "Erreur de serveur", + 'form' => "Erreur de validation du formulaire", ], 'form' => [ 'amount' => "Montant", 'code' => "Le code de confirmation", 'config' => "Configuration", 'date' => "Date", 'description' => "Description", 'details' => "Détails", 'domain' => "Domaine", 'email' => "Adresse e-mail", 'firstname' => "Prénom", 'lastname' => "Nom de famille", 'none' => "aucun", 'or' => "ou", 'password' => "Mot de passe", 'password-confirm' => "Confirmer le mot de passe", 'phone' => "Téléphone", 'status' => "État", 'surname' => "Nom de famille", 'user' => "Utilisateur", 'primary-email' => "Email principal", 'id' => "ID", 'created' => "Créé", 'deleted' => "Supprimé", 'disabled' => "Désactivé", 'enabled' => "Activé", 'general' => "Général", 'settings' => "Paramètres", ], 'invitation' => [ 'create' => "Créez des invitation(s)", 'create-title' => "Invitation à une inscription", 'create-email' => "Saisissez l'adresse électronique de la personne que vous souhaitez inviter.", 'create-csv' => "Pour envoyer plusieurs invitations à la fois, fournissez un fichier CSV (séparé par des virgules) ou un fichier en texte brut, contenant une adresse e-mail par ligne.", 'empty-list' => "Il y a aucune invitation dans la mémoire de données.", 'title' => "Invitation d'inscription", 'search' => "Adresse E-mail ou domaine", 'send' => "Envoyer invitation(s)", 'status-completed' => "Utilisateur s'est inscrit", 'status-failed' => "L'envoi a échoué", 'status-sent' => "Envoyé", 'status-new' => "Pas encore envoyé", ], 'lang' => [ 'en' => "Anglais", 'de' => "Allemand", 'fr' => "Français", 'it' => "Italien", ], 'login' => [ '2fa' => "Code du 2ème facteur", '2fa_desc' => "Le code du 2ème facteur est facultatif pour les utilisateurs qui n'ont pas configuré l'authentification à deux facteurs.", 'forgot_password' => "Mot de passe oublié?", 'header' => "Veuillez vous connecter", 'sign_in' => "Se connecter", 'webmail' => "Webmail" ], 'meet' => [ 'title' => "Voix et vidéo-conférence", 'welcome' => "Bienvenue dans notre programme bêta pour les conférences vocales et vidéo.", 'url' => "Vous disposez d'une salle avec l'URL ci-dessous. Cette salle ouvre uniquement quand vous y êtes vous-même. Utilisez cette URL pour inviter des personnes à vous rejoindre.", 'notice' => "Il s'agit d'un travail en évolution et d'autres fonctions seront ajoutées au fil du temps. Les fonctions actuelles sont les suivantes:", 'sharing' => "Partage d'écran", 'sharing-text' => "Partagez votre écran pour des présentations ou des exposés.", 'security' => "sécurité de chambre", 'security-text' => "Renforcez la sécurité de la salle en définissant un mot de passe que les participants devront connaître." . " avant de pouvoir entrer, ou verrouiller la porte afin que les participants doivent frapper, et un modérateur peut accepter ou refuser ces demandes.", 'qa' => "Lever la main (Q&A)", 'qa-text' => "Les membres du public silencieux peuvent lever la main pour animer une séance de questions-réponses avec les membres du panel.", 'moderation' => "Délégation des Modérateurs", 'moderation-text' => "Déléguer l'autorité du modérateur pour la séance, afin qu'un orateur ne soit pas inutilement" . " interrompu par l'arrivée des participants et d'autres tâches du modérateur.", 'eject' => "Éjecter les participants", 'eject-text' => "Éjectez les participants de la session afin de les obliger à se reconnecter ou de remédier aux violations des règles." . " Cliquez sur l'icône de l'utilisateur pour un renvoi effectif.", 'silent' => "Membres du Public en Silence", 'silent-text' => "Pour une séance de type webinaire, configurez la salle pour obliger tous les nouveaux participants à être des spectateurs silencieux.", 'interpreters' => "Canaux d'Audio Spécifiques de Langues", 'interpreters-text' => "Désignez un participant pour interpréter l'audio original dans une langue cible, pour les sessions avec des participants multilingues." . " L'interprète doit être capable de relayer l'audio original et de le remplacer.", 'beta-notice' => "Rappelez-vous qu'il s'agit d'une version bêta et pourrait entraîner des problèmes." . " Au cas où vous rencontreriez des problèmes, n'hésitez pas à nous en faire part en contactant le support.", // Room options dialog 'options' => "Options de salle", 'password' => "Mot de passe", 'password-none' => "aucun", 'password-clear' => "Effacer mot de passe", 'password-set' => "Définir le mot de passe", 'password-text' => "Vous pouvez ajouter un mot de passe à votre session. Les participants devront fournir le mot de passe avant d'être autorisés à rejoindre la session.", 'lock' => "Salle verrouillée", 'lock-text' => "Lorsque la salle est verrouillée, les participants doivent être approuvés par un modérateur avant de pouvoir rejoindre la réunion.", 'nomedia' => "Réservé aux abonnés", 'nomedia-text' => "Force tous les participants à se joindre en tant qu'abonnés (avec caméra et microphone désactivés)" . "Les modérateurs pourront les promouvoir en tant qu'éditeurs tout au long de la session.", // Room menu 'partcnt' => "Nombres de participants", 'menu-audio-mute' => "Désactiver le son", 'menu-audio-unmute' => "Activer le son", 'menu-video-mute' => "Désactiver la vidéo", 'menu-video-unmute' => "Activer la vidéo", 'menu-screen' => "Partager l'écran", 'menu-hand-lower' => "Baisser la main", 'menu-hand-raise' => "Lever la main", 'menu-channel' => "Canal de langue interprétée", 'menu-chat' => "Le Chat", 'menu-fullscreen' => "Plein écran", 'menu-fullscreen-exit' => "Sortir en plein écran", 'menu-leave' => "Quitter la session", // Room setup screen 'setup-title' => "Préparez votre session", 'mic' => "Microphone", 'cam' => "Caméra", 'nick' => "Surnom", 'nick-placeholder' => "Votre nom", 'join' => "JOINDRE", 'joinnow' => "JOINDRE MAINTENANT", 'imaowner' => "Je suis le propriétaire", // Room 'qa' => "Q & A", 'leave-title' => "Salle fermée", 'leave-body' => "La session a été fermée par le propriétaire de la salle.", 'media-title' => "Configuration des médias", 'join-request' => "Demande de rejoindre", 'join-requested' => "{user} demandé à rejoindre.", // Status messages 'status-init' => "Vérification de la salle...", 'status-323' => "La salle est fermée. Veuillez attendre le démarrage de la session par le propriétaire.", 'status-324' => "La salle est fermée. Elle sera ouverte aux autres participants après votre adhésion.", 'status-325' => "La salle est prête. Veuillez entrer un mot de passe valide.", 'status-326' => "La salle est fermée. Veuillez entrer votre nom et réessayer.", 'status-327' => "En attendant la permission de joindre la salle.", 'status-404' => "La salle n'existe pas.", 'status-429' => "Trop de demande. Veuillez, patienter.", 'status-500' => "La connexion à la salle a échoué. Erreur de serveur.", // Other menus 'media-setup' => "configuration des médias", 'perm' => "Permissions", 'perm-av' => "Publication d'audio et vidéo", 'perm-mod' => "Modération", 'lang-int' => "Interprète de langue", 'menu-options' => "Options", ], 'menu' => [ 'cockpit' => "Cockpit", 'login' => "Connecter", 'logout' => "Deconnecter", 'signup' => "S'inscrire", 'toggle' => "Basculer la navigation", ], 'msg' => [ 'initializing' => "Initialisation...", 'loading' => "Chargement...", 'loading-failed' => "Échec du chargement des données.", 'notfound' => "Resource introuvable.", 'info' => "Information", 'error' => "Erreur", 'warning' => "Avertissement", 'success' => "Succès", ], 'nav' => [ 'more' => "Charger plus", 'step' => "Étape {i}/{n}", ], 'password' => [ 'reset' => "Réinitialiser le mot de passe", 'reset-step1' => "Entrez votre adresse e-mail pour réinitialiser votre mot de passe.", 'reset-step1-hint' => "Veuillez vérifier votre dossier de spam ou débloquer {email}.", 'reset-step2' => "Nous avons envoyé un code de confirmation à votre adresse e-mail externe." . " Entrez le code que nous vous avons envoyé, ou cliquez sur le lien dans le message.", ], 'signup' => [ 'email' => "Adresse e-mail actuelle", 'login' => "connecter", 'title' => "S'inscrire", 'step1' => "Inscrivez-vous pour commencer votre mois gratuit.", 'step2' => "Nous avons envoyé un code de confirmation à votre adresse e-mail. Entrez le code que nous vous avons envoyé, ou cliquez sur le lien dans le message.", 'step3' => "Créez votre identité Kolab (vous pourrez choisir des adresses supplémentaires plus tard).", 'voucher' => "Coupon Code", ], 'status' => [ 'prepare-account' => "Votre compte est en cours de préparation.", 'prepare-domain' => "Le domain est en cours de préparation.", 'prepare-distlist' => "La liste de distribution est en cours de préparation.", 'prepare-user' => "Le compte d'utilisateur est en cours de préparation.", 'prepare-hint' => "Certaines fonctionnalités peuvent être manquantes ou en lecture seule pour le moment.", 'prepare-refresh' => "Le processus ne se termine jamais? Appuyez sur le bouton \"Refresh\", s'il vous plaît.", 'ready-account' => "Votre compte est presque prêt.", 'ready-domain' => "Le domaine est presque prêt.", 'ready-distlist' => "La liste de distribution est presque prête.", 'ready-user' => "Le compte d'utilisateur est presque prêt.", 'verify' => "Veuillez vérifier votre domaine pour terminer le processus de configuration.", 'verify-domain' => "Vérifier domaine", 'deleted' => "Supprimé", 'suspended' => "Suspendu", 'notready' => "Pas Prêt", 'active' => "Actif", ], 'support' => [ 'title' => "Contacter Support", 'id' => "Numéro de client ou adresse é-mail que vous avez chez nous.", 'id-pl' => "e.g. 12345678 ou john@kolab.org", 'id-hint' => "Laissez vide si vous n'êtes pas encore client", 'name' => "Nom", 'name-pl' => "comment nous devons vous adresser dans notre réponse", 'email' => "adresse e-mail qui fonctionne", 'email-pl' => "assurez-vous que nous pouvons vous atteindre à cette adresse", 'summary' => "Résumé du problème", 'summary-pl' => "une phrase qui résume votre situation", 'expl' => "Analyse du problème", ], 'user' => [ '2fa-hint1' => "Cela éliminera le droit à l'authentification à 2-Facteurs ainsi que les éléments configurés par l'utilisateur.", '2fa-hint2' => "Veuillez vous assurer que l'identité de l'utilisateur est correctement confirmée.", 'add-beta' => "Activer le programme bêta", 'address' => "Adresse", 'aliases' => "Alias", 'aliases-email' => "Alias E-mail", 'aliases-none' => "Cet utilisateur n'aucune alias e-mail.", 'add-bonus' => "Ajouter un bonus", 'add-bonus-title' => "Ajouter un bonus au portefeuille", 'add-penalty' => "Ajouter une pénalité", 'add-penalty-title' => "Ajouter une pénalité au portefeuille", 'auto-payment' => "Auto-paiement", 'auto-payment-text' => "Recharger par {amount} quand le montant est inférieur à {balance} utilisant {method}", 'country' => "Pays", 'create' => "Créer un utilisateur", 'custno' => "No. de Client.", 'delete' => "Supprimer Utilisateur", 'delete-email' => "Supprimer {email}", 'delete-text' => "Voulez-vous vraiment supprimer cet utilisateur de façon permanente?" . " Cela supprimera toutes les données du compte et retirera la permission d'accéder au compte d'e-email." . " Veuillez noter que cette action ne peut pas être révoquée.", 'discount' => "Rabais", 'discount-hint' => "rabais appliqué", 'discount-title' => "Rabais de compte", 'distlists' => "Listes de Distribution", 'distlists-none' => "Il y a aucune liste de distribution dans ce compte.", 'domains' => "Domaines", 'domains-none' => "Il y a pas de domaines dans ce compte.", 'ext-email' => "E-mail externe", 'finances' => "Finances", 'greylisting' => "Greylisting", 'greylisting-text' => "La greylisting est une méthode de défense des utilisateurs contre le spam." . " Tout e-mail entrant provenant d'un expéditeur non reconnu est temporairement rejeté." . " Le serveur d'origine doit réessayer après un délai cette fois-ci, le mail sera accepté." . " Les spammeurs ne réessayent généralement pas de remettre le mail.", 'list-title' => "Comptes d'utilisateur", 'managed-by' => "Géré par", 'new' => "Nouveau compte d'utilisateur", 'org' => "Organisation", 'package' => "Paquet", 'price' => "Prix", 'profile-title' => "Votre profile", 'profile-delete' => "Supprimer compte", 'profile-delete-title' => "Supprimer ce compte?", 'profile-delete-text1' => "Cela supprimera le compte ainsi que tous les domaines, utilisateurs et alias associés à ce compte.", 'profile-delete-warning' => "Cette opération est irrévocable", 'profile-delete-text2' => "Comme vous ne pourrez plus rien récupérer après ce point, assurez-vous d'avoir migré toutes les données avant de poursuivre.", 'profile-delete-support' => "Étant donné que nous nous attachons à toujours nous améliorer, nous aimerions vous demander 2 minutes de votre temps. " . "Le meilleur moyen de nous améliorer est le feedback des utilisateurs, et nous voudrions vous demander" . "quelques mots sur les raisons pour lesquelles vous avez quitté notre service. Veuillez envoyer vos commentaires au {email}.", 'profile-delete-contact' => "Par ailleurs, n'hésitez pas à contacter le support de {app} pour toute question ou souci que vous pourriez avoir dans ce contexte.", 'reset-2fa' => "Réinitialiser l'authentification à 2-Facteurs.", 'reset-2fa-title' => "Réinitialisation de l'Authentification à 2-Facteurs", 'title' => "Compte d'utilisateur", 'search-pl' => "ID utilisateur,e-mail ou domamine", 'skureq' => "{sku} demande {list}.", 'subscription' => "Subscription", 'subscriptions' => "Subscriptions", 'subscriptions-none' => "Cet utilisateur n'a pas de subscriptions.", 'users' => "Utilisateurs", 'users-none' => "Il n'y a aucun utilisateur dans ce compte.", ], 'wallet' => [ 'add-credit' => "Ajouter un crédit", 'auto-payment-cancel' => "Annuler l'auto-paiement", 'auto-payment-change' => "Changer l'auto-paiement", 'auto-payment-failed' => "La configuration des paiements automatiques a échoué. Redémarrer le processus pour activer les top-ups automatiques.", 'auto-payment-hint' => "Cela fonctionne de la manière suivante: Chaque fois que votre compte est épuisé, nous débiterons votre méthode de paiement préférée d'un montant que vous aurez défini." . " Vous pouvez annuler ou modifier l'option de paiement automatique à tout moment.", 'auto-payment-setup' => "configurer l'auto-paiement", 'auto-payment-disabled' => "L'auto-paiement configuré a été désactivé. Rechargez votre porte-monnaie ou augmentez le montant d'auto-paiement.", 'auto-payment-info' => "L'auto-paiement est set pour recharger votre compte par {amount} lorsque le solde de votre compte devient inférieur à {balance}.", 'auto-payment-inprogress' => "La configuration d'auto-paiement est toujours en cours.", 'auto-payment-next' => "Ensuite, vous serez redirigé vers la page de paiement, où vous pourrez fournir les coordonnées de votre carte de crédit.", 'auto-payment-disabled-next' => "L'auto-paiement est désactivé. Dès que vous aurez soumis de nouveaux paramètres, nous l'activerons et essaierons de recharger votre portefeuille.", 'auto-payment-update' => "Mise à jour de l'auto-paiement.", 'banktransfer-hint' => "Veuillez noter qu'un virement bancaire peut nécessiter plusieurs jours avant d'être effectué.", 'currency-conv' => "Le principe est le suivant: Vous spécifiez le montant dont vous voulez recharger votre portefeuille en {wc}." . " Nous convertirons ensuite ce montant en {pc}, et sur la page suivante, vous obtiendrez les coordonnées bancaires pour transférer le montant en {pc}.", 'fill-up' => "Recharger par", 'history' => "Histoire", 'month' => "mois", 'noperm' => "Seuls les propriétaires de compte peuvent accéder à un portefeuille.", 'payment-amount-hint' => "Choisissez le montant dont vous voulez recharger votre portefeuille.", 'payment-method' => "Mode de paiement: {method}", 'payment-warning' => "Vous serez facturé pour {price}.", 'pending-payments' => "Paiements en attente", 'pending-payments-warning' => "Vous avez des paiements qui sont encore en cours. Voir l'onglet \"Paiements en attente\" ci-dessous.", 'pending-payments-none' => "Il y a aucun paiement en attente pour ce compte.", 'receipts' => "Reçus", 'receipts-hint' => "Vous pouvez télécharger ici les reçus (au format PDF) pour les paiements de la période spécifiée. Sélectionnez la période et appuyez sur le bouton Télécharger.", 'receipts-none' => "Il y a aucun reçu pour les paiements de ce compte. Veuillez noter que vous pouvez télécharger les reçus après la fin du mois.", 'title' => "Solde du compte", 'top-up' => "Rechargez votre portefeuille", 'transactions' => "Transactions", 'transactions-none' => "Il y a aucun transaction pour ce compte.", 'when-below' => "lorsque le solde du compte est inférieur à", ], ]; diff --git a/src/resources/themes/forms.scss b/src/resources/themes/forms.scss index 52bf9860..62177ed4 100644 --- a/src/resources/themes/forms.scss +++ b/src/resources/themes/forms.scss @@ -1,130 +1,127 @@ .list-input { & > div { &:not(:last-child) { margin-bottom: -1px; input, a.btn { border-bottom-right-radius: 0; border-bottom-left-radius: 0; } } &:not(:first-child) { input, a.btn { border-top-right-radius: 0; border-top-left-radius: 0; } } } input.is-invalid { z-index: 2; } .btn svg { vertical-align: middle; } } .range-input { display: flex; label { margin-right: 0.5em; min-width: 4em; text-align: right; line-height: 1.7; } } .input-group-activable { &.active { :not(.activable) { display: none; } } &:not(.active) { .activable { display: none; } } // Label is always visible .label { color: $body-color; display: initial !important; } .input-group-text { border-color: transparent; background: transparent; padding-left: 0; &:not(.label) { flex: 1; } } } .form-control-plaintext .btn-sm { margin-top: -0.25rem; } // Various improvements for mobile @include media-breakpoint-down(sm) { .row.mb-3 { margin-bottom: 0.5rem !important; } .nav-tabs { - flex-wrap: nowrap; - overflow-x: auto; - .nav-link { white-space: nowrap; padding: 0.5rem 0.75rem; } } .tab-content { margin-top: 0.5rem; } .col-form-label { color: #666; font-size: 95%; } .row.plaintext .col-form-label { padding-bottom: 0; } form.read-only.short label { width: 35%; & + * { width: 65%; } } .row.checkbox { position: relative; & > div { padding-top: 0 !important; input { position: absolute; top: 0.5rem; right: 1rem; } } label { padding-right: 2.5rem; } } } diff --git a/src/resources/vue/Admin/Stats.vue b/src/resources/vue/Admin/Stats.vue index 420ea3b1..bffdc8cb 100644 --- a/src/resources/vue/Admin/Stats.vue +++ b/src/resources/vue/Admin/Stats.vue @@ -1,46 +1,46 @@ diff --git a/src/resources/vue/Domain/Info.vue b/src/resources/vue/Domain/Info.vue index 29ffdd08..87127731 100644 --- a/src/resources/vue/Domain/Info.vue +++ b/src/resources/vue/Domain/Info.vue @@ -1,142 +1,231 @@ diff --git a/src/resources/vue/Domain/List.vue b/src/resources/vue/Domain/List.vue index 1ecd3fd8..3f7ac0ab 100644 --- a/src/resources/vue/Domain/List.vue +++ b/src/resources/vue/Domain/List.vue @@ -1,53 +1,58 @@ diff --git a/src/resources/vue/Reseller/Invitations.vue b/src/resources/vue/Reseller/Invitations.vue index d8a892d2..49be5ccd 100644 --- a/src/resources/vue/Reseller/Invitations.vue +++ b/src/resources/vue/Reseller/Invitations.vue @@ -1,279 +1,280 @@ diff --git a/src/resources/vue/Reseller/Stats.vue b/src/resources/vue/Reseller/Stats.vue index f7953506..88be2302 100644 --- a/src/resources/vue/Reseller/Stats.vue +++ b/src/resources/vue/Reseller/Stats.vue @@ -1,16 +1,16 @@ diff --git a/src/resources/vue/User/Info.vue b/src/resources/vue/User/Info.vue index f9023a57..bc014335 100644 --- a/src/resources/vue/User/Info.vue +++ b/src/resources/vue/User/Info.vue @@ -1,484 +1,243 @@ diff --git a/src/resources/vue/Widgets/PackageSelect.vue b/src/resources/vue/Widgets/PackageSelect.vue new file mode 100644 index 00000000..171f34d6 --- /dev/null +++ b/src/resources/vue/Widgets/PackageSelect.vue @@ -0,0 +1,87 @@ + + + diff --git a/src/resources/vue/Widgets/SubscriptionSelect.vue b/src/resources/vue/Widgets/SubscriptionSelect.vue new file mode 100644 index 00000000..e26fc1cd --- /dev/null +++ b/src/resources/vue/Widgets/SubscriptionSelect.vue @@ -0,0 +1,208 @@ + + + diff --git a/src/routes/api.php b/src/routes/api.php index 574b254a..a2962daa 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -1,234 +1,237 @@ 'api', 'prefix' => $prefix . 'api/auth' ], function ($router) { Route::post('login', 'API\AuthController@login'); Route::group( ['middleware' => 'auth:api'], function ($router) { Route::get('info', 'API\AuthController@info'); Route::post('info', 'API\AuthController@info'); Route::post('logout', 'API\AuthController@logout'); Route::post('refresh', 'API\AuthController@refresh'); } ); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'middleware' => 'api', 'prefix' => $prefix . 'api/auth' ], function ($router) { Route::post('password-reset/init', 'API\PasswordResetController@init'); Route::post('password-reset/verify', 'API\PasswordResetController@verify'); Route::post('password-reset', 'API\PasswordResetController@reset'); Route::post('signup/init', 'API\SignupController@init'); Route::get('signup/invitations/{id}', 'API\SignupController@invitation'); Route::get('signup/plans', 'API\SignupController@plans'); Route::post('signup/verify', 'API\SignupController@verify'); Route::post('signup', 'API\SignupController@signup'); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'middleware' => 'auth:api', 'prefix' => $prefix . 'api/v4' ], function () { Route::post('companion/register', 'API\V4\CompanionAppsController@register'); Route::post('auth-attempts/{id}/confirm', 'API\V4\AuthAttemptsController@confirm'); Route::post('auth-attempts/{id}/deny', 'API\V4\AuthAttemptsController@deny'); Route::get('auth-attempts/{id}/details', 'API\V4\AuthAttemptsController@details'); Route::get('auth-attempts', 'API\V4\AuthAttemptsController@index'); Route::apiResource('domains', API\V4\DomainsController::class); Route::get('domains/{id}/confirm', 'API\V4\DomainsController@confirm'); + Route::get('domains/{id}/skus', 'API\V4\SkusController@domainSkus'); Route::get('domains/{id}/status', 'API\V4\DomainsController@status'); Route::post('domains/{id}/config', 'API\V4\DomainsController@setConfig'); Route::apiResource('groups', API\V4\GroupsController::class); Route::get('groups/{id}/status', 'API\V4\GroupsController@status'); Route::get('meet/rooms', 'API\V4\MeetController@index'); Route::post('meet/rooms/{id}/config', 'API\V4\MeetController@setRoomConfig'); Route::apiResource('packages', API\V4\PackagesController::class); Route::apiResource('skus', API\V4\SkusController::class); Route::apiResource('users', API\V4\UsersController::class); Route::post('users/{id}/config', 'API\V4\UsersController@setConfig'); Route::get('users/{id}/skus', 'API\V4\SkusController@userSkus'); Route::get('users/{id}/status', 'API\V4\UsersController@status'); Route::apiResource('wallets', API\V4\WalletsController::class); Route::get('wallets/{id}/transactions', 'API\V4\WalletsController@transactions'); Route::get('wallets/{id}/receipts', 'API\V4\WalletsController@receipts'); Route::get('wallets/{id}/receipts/{receipt}', 'API\V4\WalletsController@receiptDownload'); Route::post('payments', 'API\V4\PaymentsController@store'); //Route::delete('payments', 'API\V4\PaymentsController@cancel'); Route::get('payments/mandate', 'API\V4\PaymentsController@mandate'); Route::post('payments/mandate', 'API\V4\PaymentsController@mandateCreate'); Route::put('payments/mandate', 'API\V4\PaymentsController@mandateUpdate'); Route::delete('payments/mandate', 'API\V4\PaymentsController@mandateDelete'); Route::get('payments/methods', 'API\V4\PaymentsController@paymentMethods'); Route::get('payments/pending', 'API\V4\PaymentsController@payments'); Route::get('payments/has-pending', 'API\V4\PaymentsController@hasPayments'); } ); // Note: In Laravel 7.x we could just use withoutMiddleware() instead of a separate group Route::group( [ 'domain' => \config('app.website_domain'), 'prefix' => $prefix . 'api/v4' ], function () { Route::post('meet/rooms/{id}', 'API\V4\MeetController@joinRoom'); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'middleware' => 'api', 'prefix' => $prefix . 'api/v4' ], function ($router) { Route::post('support/request', 'API\V4\SupportController@request'); } ); Route::group( [ 'domain' => \config('app.website_domain'), 'prefix' => $prefix . 'api/webhooks' ], function () { Route::post('payment/{provider}', 'API\V4\PaymentsController@webhook'); Route::post('meet', 'API\V4\MeetController@webhook'); Route::get('nginx', 'API\NGINXController@authenticate'); } ); if (\config('app.with_services')) { Route::group( [ 'domain' => 'services.' . \config('app.website_domain'), 'prefix' => $prefix . 'api/webhooks' ], function () { Route::get('nginx', 'API\V4\NGINXController@authenticate'); Route::post('policy/greylist', 'API\V4\PolicyController@greylist'); Route::post('policy/ratelimit', 'API\V4\PolicyController@ratelimit'); Route::post('policy/spf', 'API\V4\PolicyController@senderPolicyFramework'); } ); } if (\config('app.with_admin')) { Route::group( [ 'domain' => 'admin.' . \config('app.website_domain'), 'middleware' => ['auth:api', 'admin'], 'prefix' => $prefix . 'api/v4', ], function () { Route::apiResource('domains', API\V4\Admin\DomainsController::class); + Route::get('domains/{id}/skus', 'API\V4\Admin\SkusController@domainSkus'); Route::post('domains/{id}/suspend', 'API\V4\Admin\DomainsController@suspend'); Route::post('domains/{id}/unsuspend', 'API\V4\Admin\DomainsController@unsuspend'); Route::apiResource('groups', API\V4\Admin\GroupsController::class); Route::post('groups/{id}/suspend', 'API\V4\Admin\GroupsController@suspend'); Route::post('groups/{id}/unsuspend', 'API\V4\Admin\GroupsController@unsuspend'); Route::apiResource('skus', API\V4\Admin\SkusController::class); Route::apiResource('users', API\V4\Admin\UsersController::class); Route::get('users/{id}/discounts', 'API\V4\Reseller\DiscountsController@userDiscounts'); Route::post('users/{id}/reset2FA', 'API\V4\Admin\UsersController@reset2FA'); Route::get('users/{id}/skus', 'API\V4\Admin\SkusController@userSkus'); Route::post('users/{id}/skus/{sku}', 'API\V4\Admin\UsersController@setSku'); Route::post('users/{id}/suspend', 'API\V4\Admin\UsersController@suspend'); Route::post('users/{id}/unsuspend', 'API\V4\Admin\UsersController@unsuspend'); Route::apiResource('wallets', API\V4\Admin\WalletsController::class); Route::post('wallets/{id}/one-off', 'API\V4\Admin\WalletsController@oneOff'); Route::get('wallets/{id}/transactions', 'API\V4\Admin\WalletsController@transactions'); Route::get('stats/chart/{chart}', 'API\V4\Admin\StatsController@chart'); } ); } if (\config('app.with_reseller')) { Route::group( [ 'domain' => 'reseller.' . \config('app.website_domain'), 'middleware' => ['auth:api', 'reseller'], 'prefix' => $prefix . 'api/v4', ], function () { Route::apiResource('domains', API\V4\Reseller\DomainsController::class); + Route::get('domains/{id}/skus', 'API\V4\Reseller\SkusController@domainSkus'); Route::post('domains/{id}/suspend', 'API\V4\Reseller\DomainsController@suspend'); Route::post('domains/{id}/unsuspend', 'API\V4\Reseller\DomainsController@unsuspend'); Route::apiResource('groups', API\V4\Reseller\GroupsController::class); Route::post('groups/{id}/suspend', 'API\V4\Reseller\GroupsController@suspend'); Route::post('groups/{id}/unsuspend', 'API\V4\Reseller\GroupsController@unsuspend'); Route::apiResource('invitations', API\V4\Reseller\InvitationsController::class); Route::post('invitations/{id}/resend', 'API\V4\Reseller\InvitationsController@resend'); Route::post('payments', 'API\V4\Reseller\PaymentsController@store'); Route::get('payments/mandate', 'API\V4\Reseller\PaymentsController@mandate'); Route::post('payments/mandate', 'API\V4\Reseller\PaymentsController@mandateCreate'); Route::put('payments/mandate', 'API\V4\Reseller\PaymentsController@mandateUpdate'); Route::delete('payments/mandate', 'API\V4\Reseller\PaymentsController@mandateDelete'); Route::get('payments/methods', 'API\V4\Reseller\PaymentsController@paymentMethods'); Route::get('payments/pending', 'API\V4\Reseller\PaymentsController@payments'); Route::get('payments/has-pending', 'API\V4\Reseller\PaymentsController@hasPayments'); Route::apiResource('skus', API\V4\Reseller\SkusController::class); Route::apiResource('users', API\V4\Reseller\UsersController::class); Route::get('users/{id}/discounts', 'API\V4\Reseller\DiscountsController@userDiscounts'); Route::post('users/{id}/reset2FA', 'API\V4\Reseller\UsersController@reset2FA'); Route::get('users/{id}/skus', 'API\V4\Reseller\SkusController@userSkus'); Route::post('users/{id}/skus/{sku}', 'API\V4\Admin\UsersController@setSku'); Route::post('users/{id}/suspend', 'API\V4\Reseller\UsersController@suspend'); Route::post('users/{id}/unsuspend', 'API\V4\Reseller\UsersController@unsuspend'); Route::apiResource('wallets', API\V4\Reseller\WalletsController::class); Route::post('wallets/{id}/one-off', 'API\V4\Reseller\WalletsController@oneOff'); Route::get('wallets/{id}/receipts', 'API\V4\Reseller\WalletsController@receipts'); Route::get('wallets/{id}/receipts/{receipt}', 'API\V4\Reseller\WalletsController@receiptDownload'); Route::get('wallets/{id}/transactions', 'API\V4\Reseller\WalletsController@transactions'); Route::get('stats/chart/{chart}', 'API\V4\Reseller\StatsController@chart'); } ); } diff --git a/src/tests/Browser/Admin/DomainTest.php b/src/tests/Browser/Admin/DomainTest.php index c90a464b..dcd4aa51 100644 --- a/src/tests/Browser/Admin/DomainTest.php +++ b/src/tests/Browser/Admin/DomainTest.php @@ -1,143 +1,159 @@ deleteTestUser('test1@domainscontroller.com'); + $this->deleteTestDomain('domainscontroller.com'); + self::useAdminUrl(); } /** * {@inheritDoc} */ public function tearDown(): void { $domain = $this->getTestDomain('kolab.org'); $domain->setSetting('spf_whitelist', null); + $this->deleteTestUser('test1@domainscontroller.com'); + $this->deleteTestDomain('domainscontroller.com'); + parent::tearDown(); } /** * Test domain info page (unauthenticated) */ public function testDomainUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $domain = $this->getTestDomain('kolab.org'); $browser->visit('/domain/' . $domain->id)->on(new Home()); }); } /** * Test domain info page */ public function testDomainInfo(): void { $this->browse(function (Browser $browser) { $domain = $this->getTestDomain('kolab.org'); $domain_page = new DomainPage($domain->id); $john = $this->getTestUser('john@kolab.org'); $user_page = new UserPage($john->id); $domain->setSetting('spf_whitelist', null); // Goto the domain page $browser->visit(new Home()) ->submitLogon('jeroen@jeroen.jeroen', \App\Utils::generatePassphrase(), true) ->on(new Dashboard()) ->visit($user_page) ->on($user_page) ->click('@nav #tab-domains') ->pause(1000) ->click('@user-domains table tbody tr:first-child td a'); $browser->on($domain_page) ->assertSeeIn('@domain-info .card-title', 'kolab.org') ->with('@domain-info form', function (Browser $browser) use ($domain) { $browser->assertElementsCount('.row', 2) ->assertSeeIn('.row:nth-child(1) label', 'ID (Created)') ->assertSeeIn('.row:nth-child(1) #domainid', "{$domain->id} ({$domain->created_at})") ->assertSeeIn('.row:nth-child(2) label', 'Status') ->assertSeeIn('.row:nth-child(2) #status span.text-success', 'Active'); }); // Some tabs are loaded in background, wait a second $browser->pause(500) ->assertElementsCount('@nav a', 2); // Assert Configuration tab $browser->assertSeeIn('@nav #tab-config', 'Configuration') ->with('@domain-config', function (Browser $browser) { $browser->assertSeeIn('pre#dns-verify', 'kolab-verify.kolab.org.') ->assertSeeIn('pre#dns-config', 'kolab.org.'); }); // Assert Settings tab $browser->assertSeeIn('@nav #tab-settings', 'Settings') ->click('@nav #tab-settings') ->with('@domain-settings form', function (Browser $browser) { $browser->assertElementsCount('.row', 1) ->assertSeeIn('.row:first-child label', 'SPF Whitelist') ->assertSeeIn('.row:first-child .form-control-plaintext', 'none'); }); // Assert non-empty SPF whitelist $domain->setSetting('spf_whitelist', json_encode(['.test1.com', '.test2.com'])); $browser->refresh() ->waitFor('@nav #tab-settings') ->click('@nav #tab-settings') ->with('@domain-settings form', function (Browser $browser) { $browser->assertSeeIn('.row:first-child .form-control-plaintext', '.test1.com, .test2.com'); }); }); } /** * Test suspending/unsuspending a domain * * @depends testDomainInfo */ public function testSuspendAndUnsuspend(): void { $this->browse(function (Browser $browser) { + $sku_domain = \App\Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first(); + $user = $this->getTestUser('test1@domainscontroller.com'); $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW | Domain::STATUS_ACTIVE | Domain::STATUS_LDAP_READY | Domain::STATUS_CONFIRMED | Domain::STATUS_VERIFIED, 'type' => Domain::TYPE_EXTERNAL, ]); + \App\Entitlement::create([ + 'wallet_id' => $user->wallets()->first()->id, + 'sku_id' => $sku_domain->id, + 'entitleable_id' => $domain->id, + 'entitleable_type' => Domain::class + ]); + $browser->visit(new DomainPage($domain->id)) ->assertVisible('@domain-info #button-suspend') ->assertMissing('@domain-info #button-unsuspend') ->click('@domain-info #button-suspend') ->assertToast(Toast::TYPE_SUCCESS, 'Domain suspended successfully.') ->assertSeeIn('@domain-info #status span.text-warning', 'Suspended') ->assertMissing('@domain-info #button-suspend') ->click('@domain-info #button-unsuspend') ->assertToast(Toast::TYPE_SUCCESS, 'Domain unsuspended successfully.') ->assertSeeIn('@domain-info #status span.text-success', 'Active') ->assertVisible('@domain-info #button-suspend') ->assertMissing('@domain-info #button-unsuspend'); }); } } diff --git a/src/tests/Browser/Admin/StatsTest.php b/src/tests/Browser/Admin/StatsTest.php index b877cc0f..0cb3aec4 100644 --- a/src/tests/Browser/Admin/StatsTest.php +++ b/src/tests/Browser/Admin/StatsTest.php @@ -1,41 +1,42 @@ browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('jeroen@jeroen.jeroen', \App\Utils::generatePassphrase(), true) ->on(new Dashboard()) ->assertSeeIn('@links .link-stats', 'Stats') ->click('@links .link-stats') ->on(new Stats()) - ->assertElementsCount('@container > div', 4) + ->assertElementsCount('@container > div', 5) ->waitForTextIn('@container #chart-users svg .title', 'Users - last 8 weeks') ->waitForTextIn('@container #chart-users-all svg .title', 'All Users - last year') ->waitForTextIn('@container #chart-income svg .title', 'Income in CHF - last 8 weeks') - ->waitForTextIn('@container #chart-discounts svg .title', 'Discounts'); + ->waitForTextIn('@container #chart-discounts svg .title', 'Discounts') + ->waitForTextIn('@container #chart-vouchers svg .title', 'Vouchers'); }); } } diff --git a/src/tests/Browser/DomainTest.php b/src/tests/Browser/DomainTest.php index 9bdfed5c..136d3000 100644 --- a/src/tests/Browser/DomainTest.php +++ b/src/tests/Browser/DomainTest.php @@ -1,202 +1,353 @@ deleteTestDomain('testdomain.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestDomain('testdomain.com'); + parent::tearDown(); + } /** * Test domain info page (unauthenticated) */ public function testDomainInfoUnauth(): void { // Test that the page requires authentication $this->browse(function ($browser) { $browser->visit('/domain/123')->on(new Home()); }); } /** * Test domain info page (non-existing domain id) */ public function testDomainInfo404(): void { $this->browse(function ($browser) { // FIXME: I couldn't make loginAs() method working // Note: Here we're also testing that unauthenticated request // is passed to logon form and then "redirected" to the requested page $browser->visit('/domain/123') ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123') ->assertErrorPage(404); }); } /** * Test domain info page (existing domain) * * @depends testDomainInfo404 */ public function testDomainInfo(): void { $this->browse(function ($browser) { // Unconfirmed domain $domain = Domain::where('namespace', 'kolab.org')->first(); if ($domain->isConfirmed()) { $domain->status ^= Domain::STATUS_CONFIRMED; $domain->save(); } $domain->setSetting('spf_whitelist', \json_encode(['.test.com'])); $browser->visit('/domain/' . $domain->id) ->on(new DomainInfo()) + ->assertSeeIn('.card-title', 'Domain') + ->whenAvailable('@general', function ($browser) use ($domain) { + $browser->assertSeeIn('form div:nth-child(1) label', 'Status') + ->assertSeeIn('form div:nth-child(1) #status.text-danger', 'Not Ready') + ->assertSeeIn('form div:nth-child(2) label', 'Name') + ->assertValue('form div:nth-child(2) input:disabled', $domain->namespace) + ->assertSeeIn('form div:nth-child(3) label', 'Subscriptions'); + }) + ->whenAvailable('@general form div:nth-child(3) table', function ($browser) { + $browser->assertElementsCount('tbody tr', 1) + ->assertVisible('tbody tr td.selection input:checked:disabled') + ->assertSeeIn('tbody tr td.name', 'External Domain') + ->assertSeeIn('tbody tr td.price', '0,00 CHF/month') + ->assertTip( + 'tbody tr td.buttons button', + 'Host a domain that is externally registered' + ); + }) ->whenAvailable('@verify', function ($browser) use ($domain) { $browser->assertSeeIn('pre', $domain->namespace) ->assertSeeIn('pre', $domain->hash()) ->click('button') ->assertToast(Toast::TYPE_SUCCESS, 'Domain verified successfully.'); // TODO: Test scenario when a domain confirmation failed }) ->whenAvailable('@config', function ($browser) use ($domain) { $browser->assertSeeIn('pre', $domain->namespace); }) + ->assertMissing('@general button[type=submit]') ->assertMissing('@verify'); // Check that confirmed domain page contains only the config box $browser->visit('/domain/' . $domain->id) ->on(new DomainInfo()) ->assertMissing('@verify') ->assertPresent('@config'); }); } /** * Test domain settings */ public function testDomainSettings(): void { $this->browse(function ($browser) { $domain = Domain::where('namespace', 'kolab.org')->first(); $domain->setSetting('spf_whitelist', \json_encode(['.test.com'])); $browser->visit('/domain/' . $domain->id) ->on(new DomainInfo()) ->assertElementsCount('@nav a', 2) - ->assertSeeIn('@nav #tab-general', 'Domain configuration') + ->assertSeeIn('@nav #tab-general', 'General') ->assertSeeIn('@nav #tab-settings', 'Settings') ->click('@nav #tab-settings') ->with('#settings form', function (Browser $browser) { // Test whitelist widget $widget = new ListInput('#spf_whitelist'); $browser->assertSeeIn('div.row:nth-child(1) label', 'SPF Whitelist') ->assertVisible('div.row:nth-child(1) .list-input') ->with($widget, function (Browser $browser) { $browser->assertListInputValue(['.test.com']) ->assertValue('@input', '') ->addListEntry('invalid domain'); }) ->click('button[type=submit]') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->with($widget, function (Browser $browser) { $err = 'The entry format is invalid. Expected a domain name starting with a dot.'; $browser->assertFormError(2, $err, false) ->removeListEntry(2) ->removeListEntry(1) ->addListEntry('.new.domain.tld'); }) ->click('button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'Domain settings updated successfully.'); }); }); } /** * Test domains list page (unauthenticated) */ public function testDomainListUnauth(): void { // Test that the page requires authentication $this->browse(function ($browser) { $browser->visit('/logout') ->visit('/domains') ->on(new Home()); }); } /** * Test domains list page * * @depends testDomainListUnauth */ public function testDomainList(): void { $this->browse(function ($browser) { // Login the user $browser->visit('/login') ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) // On dashboard click the "Domains" link ->on(new Dashboard()) ->assertSeeIn('@links a.link-domains', 'Domains') ->click('@links a.link-domains') // On Domains List page click the domain entry ->on(new DomainList()) ->waitFor('@table tbody tr') ->assertVisible('@table tbody tr:first-child td:first-child svg.fa-globe.text-success') ->assertText('@table tbody tr:first-child td:first-child svg title', 'Active') ->assertSeeIn('@table tbody tr:first-child td:first-child', 'kolab.org') ->assertMissing('@table tfoot') ->click('@table tbody tr:first-child td:first-child a') // On Domain Info page verify that's the clicked domain ->on(new DomainInfo()) ->whenAvailable('@config', function ($browser) { $browser->assertSeeIn('pre', 'kolab.org'); }); }); // TODO: Test domains list acting as Ned (John's "delegatee") } /** * Test domains list page (user with no domains) */ public function testDomainListEmpty(): void { $this->browse(function ($browser) { // Login the user $browser->visit('/login') ->on(new Home()) ->submitLogon('jack@kolab.org', 'simple123', true) ->on(new Dashboard()) ->assertVisible('@links a.link-profile') ->assertMissing('@links a.link-domains') ->assertMissing('@links a.link-users') ->assertMissing('@links a.link-wallet'); /* // On dashboard click the "Domains" link ->assertSeeIn('@links a.link-domains', 'Domains') ->click('@links a.link-domains') // On Domains List page click the domain entry ->on(new DomainList()) ->assertMissing('@table tbody') ->assertSeeIn('tfoot td', 'There are no domains in this account.'); */ }); } + + /** + * Test domain creation page + */ + public function testDomainCreate(): void + { + $this->browse(function ($browser) { + $browser->visit('/login') + ->on(new Home()) + ->submitLogon('john@kolab.org', 'simple123') + ->visit('/domains') + ->on(new DomainList()) + ->assertSeeIn('.card-title button.btn-success', 'Create domain') + ->click('.card-title button.btn-success') + ->on(new DomainInfo()) + ->assertSeeIn('.card-title', 'New domain') + ->assertElementsCount('@nav li', 1) + ->assertSeeIn('@nav li:first-child', 'General') + ->whenAvailable('@general', function ($browser) { + $browser->assertSeeIn('form div:nth-child(1) label', 'Name') + ->assertValue('form div:nth-child(1) input:not(:disabled)', '') + ->assertFocused('form div:nth-child(1) input') + ->assertSeeIn('form div:nth-child(2) label', 'Package') + ->assertMissing('form div:nth-child(3)'); + }) + ->whenAvailable('@general form div:nth-child(2) table', function ($browser) { + $browser->assertElementsCount('tbody tr', 1) + ->assertVisible('tbody tr td.selection input:checked[readonly]') + ->assertSeeIn('tbody tr td.name', 'Domain Hosting') + ->assertSeeIn('tbody tr td.price', '0,00 CHF/month') + ->assertTip( + 'tbody tr td.buttons button', + 'Use your own, existing domain.' + ); + }) + ->assertSeeIn('@general button.btn-primary[type=submit]', 'Submit') + ->assertMissing('@config') + ->assertMissing('@verify') + ->assertMissing('@settings') + ->assertMissing('@status') + // Test error handling + ->click('button[type=submit]') + ->waitFor('#namespace + .invalid-feedback') + ->assertSeeIn('#namespace + .invalid-feedback', 'The namespace field is required.') + ->assertFocused('#namespace') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + ->type('@general form div:nth-child(1) input', 'testdomain..com') + ->click('button[type=submit]') + ->waitFor('#namespace + .invalid-feedback') + ->assertSeeIn('#namespace + .invalid-feedback', 'The specified domain is invalid.') + ->assertFocused('#namespace') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + // Test success + ->type('@general form div:nth-child(1) input', 'testdomain.com') + ->click('button[type=submit]') + ->assertToast(Toast::TYPE_SUCCESS, 'Domain created successfully.') + ->on(new DomainList()) + ->assertSeeIn('@table tr:nth-child(2) a', 'testdomain.com'); + }); + } + + /** + * Test domain deletion + */ + public function testDomainDelete(): void + { + // Create the domain to delete + $john = $this->getTestUser('john@kolab.org'); + $domain = $this->getTestDomain('testdomain.com', ['type' => Domain::TYPE_EXTERNAL]); + $packageDomain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); + $domain->assignPackage($packageDomain, $john); + + $this->browse(function ($browser) { + $browser->visit('/login') + ->on(new Home()) + ->submitLogon('john@kolab.org', 'simple123') + ->visit('/domains') + ->on(new DomainList()) + ->assertElementsCount('@table tbody tr', 2) + ->assertSeeIn('@table tr:nth-child(2) a', 'testdomain.com') + ->click('@table tbody tr:nth-child(2) a') + ->on(new DomainInfo()) + ->waitFor('button.button-delete') + ->assertSeeIn('button.button-delete', 'Delete domain') + ->click('button.button-delete') + ->with(new Dialog('#delete-warning'), function ($browser) { + $browser->assertSeeIn('@title', 'Delete testdomain.com') + ->assertFocused('@button-cancel') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@button-action', 'Delete') + ->click('@button-cancel'); + }) + ->waitUntilMissing('#delete-warning') + ->click('button.button-delete') + ->with(new Dialog('#delete-warning'), function (Browser $browser) { + $browser->click('@button-action'); + }) + ->waitUntilMissing('#delete-warning') + ->assertToast(Toast::TYPE_SUCCESS, 'Domain deleted successfully.') + ->on(new DomainList()) + ->assertElementsCount('@table tbody tr', 1); + + // Test error handling on deleting a non-empty domain + $err = 'Unable to delete a domain with assigned users or other objects.'; + $browser->click('@table tbody tr:nth-child(1) a') + ->on(new DomainInfo()) + ->waitFor('button.button-delete') + ->click('button.button-delete') + ->with(new Dialog('#delete-warning'), function ($browser) { + $browser->click('@button-action'); + }) + ->assertToast(Toast::TYPE_ERROR, $err); + }); + } } diff --git a/src/tests/Browser/Pages/DomainInfo.php b/src/tests/Browser/Pages/DomainInfo.php index 13820c3c..3448cd3d 100644 --- a/src/tests/Browser/Pages/DomainInfo.php +++ b/src/tests/Browser/Pages/DomainInfo.php @@ -1,47 +1,48 @@ waitUntilMissing('@app .app-loader'); } /** * Get the element shortcuts for the page. * * @return array */ public function elements(): array { return [ '@app' => '#app', '@config' => '#domain-config', + '@general' => '#general', '@nav' => 'ul.nav-tabs', '@settings' => '#settings', '@status' => '#status-box', '@verify' => '#domain-verify', ]; } } diff --git a/src/tests/Browser/Reseller/DomainTest.php b/src/tests/Browser/Reseller/DomainTest.php index 85b3ccf1..1ee3dd8b 100644 --- a/src/tests/Browser/Reseller/DomainTest.php +++ b/src/tests/Browser/Reseller/DomainTest.php @@ -1,120 +1,136 @@ deleteTestUser('test1@domainscontroller.com'); + $this->deleteTestDomain('domainscontroller.com'); + self::useResellerUrl(); } /** * {@inheritDoc} */ public function tearDown(): void { + $this->deleteTestUser('test1@domainscontroller.com'); + $this->deleteTestDomain('domainscontroller.com'); + parent::tearDown(); } /** * Test domain info page (unauthenticated) */ public function testDomainUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $domain = $this->getTestDomain('kolab.org'); $browser->visit('/domain/' . $domain->id)->on(new Home()); }); } /** * Test domain info page */ public function testDomainInfo(): void { $this->browse(function (Browser $browser) { $domain = $this->getTestDomain('kolab.org'); $domain_page = new DomainPage($domain->id); $reseller = $this->getTestUser('reseller@' . \config('app.domain')); $user = $this->getTestUser('john@kolab.org'); $user_page = new UserPage($user->id); // Goto the domain page $browser->visit(new Home()) ->submitLogon('reseller@' . \config('app.domain'), \App\Utils::generatePassphrase(), true) ->on(new Dashboard()) ->visit($user_page) ->on($user_page) ->click('@nav #tab-domains') ->pause(1000) ->click('@user-domains table tbody tr:first-child td a'); $browser->on($domain_page) ->assertSeeIn('@domain-info .card-title', 'kolab.org') ->with('@domain-info form', function (Browser $browser) use ($domain) { $browser->assertElementsCount('.row', 2) ->assertSeeIn('.row:nth-child(1) label', 'ID (Created)') ->assertSeeIn('.row:nth-child(1) #domainid', "{$domain->id} ({$domain->created_at})") ->assertSeeIn('.row:nth-child(2) label', 'Status') ->assertSeeIn('.row:nth-child(2) #status span.text-success', 'Active'); }); // Some tabs are loaded in background, wait a second $browser->pause(500) ->assertElementsCount('@nav a', 2); // Assert Configuration tab $browser->assertSeeIn('@nav #tab-config', 'Configuration') ->with('@domain-config', function (Browser $browser) { $browser->assertSeeIn('pre#dns-verify', 'kolab-verify.kolab.org.') ->assertSeeIn('pre#dns-config', 'kolab.org.'); }); }); } /** * Test suspending/unsuspending a domain * * @depends testDomainInfo */ public function testSuspendAndUnsuspend(): void { $this->browse(function (Browser $browser) { + $sku_domain = \App\Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first(); + $user = $this->getTestUser('test1@domainscontroller.com'); $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW | Domain::STATUS_ACTIVE | Domain::STATUS_LDAP_READY | Domain::STATUS_CONFIRMED | Domain::STATUS_VERIFIED, 'type' => Domain::TYPE_EXTERNAL, ]); + \App\Entitlement::create([ + 'wallet_id' => $user->wallets()->first()->id, + 'sku_id' => $sku_domain->id, + 'entitleable_id' => $domain->id, + 'entitleable_type' => Domain::class + ]); + $browser->visit(new DomainPage($domain->id)) ->assertVisible('@domain-info #button-suspend') ->assertMissing('@domain-info #button-unsuspend') ->click('@domain-info #button-suspend') ->assertToast(Toast::TYPE_SUCCESS, 'Domain suspended successfully.') ->assertSeeIn('@domain-info #status span.text-warning', 'Suspended') ->assertMissing('@domain-info #button-suspend') ->click('@domain-info #button-unsuspend') ->assertToast(Toast::TYPE_SUCCESS, 'Domain unsuspended successfully.') ->assertSeeIn('@domain-info #status span.text-success', 'Active') ->assertVisible('@domain-info #button-suspend') ->assertMissing('@domain-info #button-unsuspend'); }); } } diff --git a/src/tests/Browser/Reseller/InvitationsTest.php b/src/tests/Browser/Reseller/InvitationsTest.php index 7e24b764..5f96dc50 100644 --- a/src/tests/Browser/Reseller/InvitationsTest.php +++ b/src/tests/Browser/Reseller/InvitationsTest.php @@ -1,221 +1,225 @@ browse(function (Browser $browser) { $browser->visit('/invitations')->on(new Home()); }); } /** * Test Invitations creation */ public function testInvitationCreate(): void { $this->browse(function (Browser $browser) { $date_regexp = '/^20[0-9]{2}-/'; $browser->visit(new Home()) ->submitLogon('reseller@' . \config('app.domain'), \App\Utils::generatePassphrase(), true) ->on(new Dashboard()) ->assertSeeIn('@links .link-invitations', 'Invitations') ->click('@links .link-invitations') ->on(new Invitations()) ->assertElementsCount('@table tbody tr', 0) ->assertMissing('#more-loader') ->assertSeeIn('@table tfoot td', "There are no invitations in the database.") ->assertSeeIn('@create-button', 'Create invite(s)'); // Create a single invite with email address input $browser->click('@create-button') ->with(new Dialog('#invite-create'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Invite for a signup') ->assertFocused('@body input#email') ->assertValue('@body input#email', '') ->type('@body input#email', 'test') ->assertSeeIn('@button-action', 'Send invite(s)') ->click('@button-action') ->assertToast(Toast::TYPE_ERROR, "Form validation error") ->waitFor('@body input#email.is-invalid') ->assertSeeIn( '@body input#email.is-invalid + .invalid-feedback', "The email must be a valid email address." ) ->type('@body input#email', 'test@domain.tld') ->click('@button-action'); }) ->assertToast(Toast::TYPE_SUCCESS, "The invitation has been created.") ->waitUntilMissing('#invite-create') ->waitUntilMissing('@table .app-loader') ->assertElementsCount('@table tbody tr', 1) ->assertMissing('@table tfoot') ->assertSeeIn('@table tbody tr td.email', 'test@domain.tld') ->assertText('@table tbody tr td.email title', 'Not sent yet') ->assertTextRegExp('@table tbody tr td.datetime', $date_regexp) ->assertVisible('@table tbody tr td.buttons button.button-delete') ->assertVisible('@table tbody tr td.buttons button.button-resend:disabled'); sleep(1); // Create invites from a file $browser->click('@create-button') ->with(new Dialog('#invite-create'), function (Browser $browser) { $browser->assertFocused('@body input#email') ->assertValue('@body input#email', '') ->assertMissing('@body input#email.is-invalid') // Submit an empty file ->attach('@body input#file', __DIR__ . '/../../data/empty.csv') ->click('@button-action') ->assertToast(Toast::TYPE_ERROR, "Form validation error") // ->waitFor('input#file.is-invalid') ->assertSeeIn( '@body input#file.is-invalid + .invalid-feedback', "Failed to find any valid email addresses in the uploaded file." ) // Submit non-empty file ->attach('@body input#file', __DIR__ . '/../../data/email.csv') ->click('@button-action'); }) ->assertToast(Toast::TYPE_SUCCESS, "2 invitations has been created.") ->waitUntilMissing('#invite-create') ->waitUntilMissing('@table .app-loader') ->assertElementsCount('@table tbody tr', 3) ->assertTextRegExp('@table tbody tr:nth-child(1) td.email', '/email[12]@test\.com$/') ->assertTextRegExp('@table tbody tr:nth-child(2) td.email', '/email[12]@test\.com$/'); }); } /** * Test Invitations deletion and resending */ public function testInvitationDeleteAndResend(): void { $this->browse(function (Browser $browser) { Queue::fake(); $i1 = SignupInvitation::create(['email' => 'test1@domain.org']); $i2 = SignupInvitation::create(['email' => 'test2@domain.org']); - SignupInvitation::where('id', $i2->id) - ->update(['created_at' => now()->subHours('2'), 'status' => SignupInvitation::STATUS_FAILED]); + SignupInvitation::where('id', $i1->id)->update(['status' => SignupInvitation::STATUS_FAILED]); + SignupInvitation::where('id', $i2->id)->update(['created_at' => now()->subHours('2')]); - // Test deleting $browser->visit(new Invitations()) - // ->submitLogon('reseller@' . \config('app.domain'), \App\Utils::generatePassphrase(), true) - ->assertElementsCount('@table tbody tr', 2) - ->click('@table tbody tr:first-child button.button-delete') - ->assertToast(Toast::TYPE_SUCCESS, "Invitation deleted successfully.") - ->assertElementsCount('@table tbody tr', 1); + ->assertElementsCount('@table tbody tr', 2); // Test resending - $browser->click('@table tbody tr:first-child button.button-resend') + $browser->assertSeeIn('@table tbody tr:first-child td.email', 'test1@domain.org') + ->click('@table tbody tr:first-child button.button-resend') ->assertToast(Toast::TYPE_SUCCESS, "Invitation added to the sending queue successfully.") - ->assertElementsCount('@table tbody tr', 1); + ->assertVisible('@table tbody tr:first-child button.button-resend:disabled') + ->assertElementsCount('@table tbody tr', 2); + + // Test deleting + $browser->assertSeeIn('@table tbody tr:last-child td.email', 'test2@domain.org') + ->click('@table tbody tr:last-child button.button-delete') + ->assertToast(Toast::TYPE_SUCCESS, "Invitation deleted successfully.") + ->assertElementsCount('@table tbody tr', 1) + ->assertSeeIn('@table tbody tr:first-child td.email', 'test1@domain.org'); }); } /** * Test Invitations list (paging and searching) */ public function testInvitationsList(): void { $this->browse(function (Browser $browser) { Queue::fake(); $i1 = SignupInvitation::create(['email' => 'email1@ext.com']); $i2 = SignupInvitation::create(['email' => 'email2@ext.com']); $i3 = SignupInvitation::create(['email' => 'email3@ext.com']); $i4 = SignupInvitation::create(['email' => 'email4@other.com']); $i5 = SignupInvitation::create(['email' => 'email5@other.com']); $i6 = SignupInvitation::create(['email' => 'email6@other.com']); $i7 = SignupInvitation::create(['email' => 'email7@other.com']); $i8 = SignupInvitation::create(['email' => 'email8@other.com']); $i9 = SignupInvitation::create(['email' => 'email9@other.com']); $i10 = SignupInvitation::create(['email' => 'email10@other.com']); $i11 = SignupInvitation::create(['email' => 'email11@other.com']); SignupInvitation::query()->update(['created_at' => now()->subDays('1')]); SignupInvitation::where('id', $i1->id) ->update(['created_at' => now()->subHours('2'), 'status' => SignupInvitation::STATUS_FAILED]); SignupInvitation::where('id', $i2->id) ->update(['created_at' => now()->subHours('3'), 'status' => SignupInvitation::STATUS_SENT]); SignupInvitation::where('id', $i3->id) ->update(['created_at' => now()->subHours('4'), 'status' => SignupInvitation::STATUS_COMPLETED]); SignupInvitation::where('id', $i11->id)->update(['created_at' => now()->subDays('3')]); // Test paging (load more) feature $browser->visit(new Invitations()) // ->submitLogon('reseller@' . \config('app.domain'), \App\Utils::generatePassphrase(), true) ->assertElementsCount('@table tbody tr', 10) ->assertSeeIn('#more-loader button', 'Load more') ->with('@table tbody', function ($browser) use ($i1, $i2, $i3) { $browser->assertSeeIn('tr:nth-child(1) td.email', $i1->email) ->assertText('tr:nth-child(1) td.email svg.text-danger title', 'Sending failed') ->assertVisible('tr:nth-child(1) td.buttons button.button-delete') ->assertVisible('tr:nth-child(1) td.buttons button.button-resend:not(:disabled)') ->assertSeeIn('tr:nth-child(2) td.email', $i2->email) ->assertText('tr:nth-child(2) td.email svg.text-primary title', 'Sent') ->assertVisible('tr:nth-child(2) td.buttons button.button-delete') ->assertVisible('tr:nth-child(2) td.buttons button.button-resend:not(:disabled)') ->assertSeeIn('tr:nth-child(3) td.email', $i3->email) ->assertText('tr:nth-child(3) td.email svg.text-success title', 'User signed up') ->assertVisible('tr:nth-child(3) td.buttons button.button-delete') ->assertVisible('tr:nth-child(3) td.buttons button.button-resend:disabled') ->assertText('tr:nth-child(4) td.email svg title', 'Not sent yet') ->assertVisible('tr:nth-child(4) td.buttons button.button-delete') ->assertVisible('tr:nth-child(4) td.buttons button.button-resend:disabled'); }) ->click('#more-loader button') ->whenAvailable('@table tbody tr:nth-child(11)', function ($browser) use ($i11) { $browser->assertSeeIn('td.email', $i11->email); }) ->assertMissing('#more-loader button'); // Test searching (by domain) $browser->type('@search-input', 'ext.com') ->click('@search-button') ->waitUntilMissing('@table .app-loader') ->assertElementsCount('@table tbody tr', 3) ->assertMissing('#more-loader button') // search by full email ->type('@search-input', 'email7@other.com') ->keys('@search-input', '{enter}') ->waitUntilMissing('@table .app-loader') ->assertElementsCount('@table tbody tr', 1) ->assertSeeIn('@table tbody tr:nth-child(1) td.email', 'email7@other.com') ->assertMissing('#more-loader button') // reset search ->vueClear('#search-form input') ->keys('@search-input', '{enter}') ->waitUntilMissing('@table .app-loader') ->assertElementsCount('@table tbody tr', 10) ->assertVisible('#more-loader button'); }); } } diff --git a/src/tests/Browser/Reseller/StatsTest.php b/src/tests/Browser/Reseller/StatsTest.php index 27fe981d..29c3d961 100644 --- a/src/tests/Browser/Reseller/StatsTest.php +++ b/src/tests/Browser/Reseller/StatsTest.php @@ -1,51 +1,52 @@ browse(function (Browser $browser) { $browser->visit('/stats')->on(new Home()); }); } /** * Test Stats page */ public function testStats(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('reseller@' . \config('app.domain'), \App\Utils::generatePassphrase(), true) ->on(new Dashboard()) ->assertSeeIn('@links .link-stats', 'Stats') ->click('@links .link-stats') ->on(new Stats()) - ->assertElementsCount('@container > div', 3) + ->assertElementsCount('@container > div', 4) ->waitForTextIn('@container #chart-users svg .title', 'Users - last 8 weeks') ->waitForTextIn('@container #chart-users-all svg .title', 'All Users - last year') - ->waitForTextIn('@container #chart-discounts svg .title', 'Discounts'); + ->waitForTextIn('@container #chart-discounts svg .title', 'Discounts') + ->waitForTextIn('@container #chart-vouchers svg .title', 'Vouchers'); }); } } diff --git a/src/tests/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php index d64f1dd5..89ce6760 100644 --- a/src/tests/Browser/UsersTest.php +++ b/src/tests/Browser/UsersTest.php @@ -1,835 +1,835 @@ 'John', 'last_name' => 'Doe', 'organization' => 'Kolab Developers', ]; /** * {@inheritDoc} */ public function setUp(): void { parent::setUp(); $this->deleteTestUser('julia.roberts@kolab.org'); $john = User::where('email', 'john@kolab.org')->first(); $john->setSettings($this->profile); UserAlias::where('user_id', $john->id) ->where('alias', 'john.test@kolab.org')->delete(); $activesync_sku = Sku::withEnvTenantContext()->where('title', 'activesync')->first(); $storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first(); Entitlement::where('entitleable_id', $john->id)->where('sku_id', $activesync_sku->id)->delete(); Entitlement::where('cost', '>=', 5000)->delete(); Entitlement::where('cost', '=', 25)->where('sku_id', $storage_sku->id)->delete(); $wallet = $john->wallets()->first(); $wallet->discount()->dissociate(); $wallet->currency = 'CHF'; $wallet->save(); $this->clearBetaEntitlements(); $this->clearMeetEntitlements(); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('julia.roberts@kolab.org'); $john = User::where('email', 'john@kolab.org')->first(); $john->setSettings($this->profile); UserAlias::where('user_id', $john->id) ->where('alias', 'john.test@kolab.org')->delete(); $activesync_sku = Sku::withEnvTenantContext()->where('title', 'activesync')->first(); $storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first(); Entitlement::where('entitleable_id', $john->id)->where('sku_id', $activesync_sku->id)->delete(); Entitlement::where('cost', '>=', 5000)->delete(); Entitlement::where('cost', '=', 25)->where('sku_id', $storage_sku->id)->delete(); $wallet = $john->wallets()->first(); $wallet->discount()->dissociate(); $wallet->save(); $this->clearBetaEntitlements(); $this->clearMeetEntitlements(); parent::tearDown(); } /** * Test user info page (unauthenticated) */ public function testInfoUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $user = User::where('email', 'john@kolab.org')->first(); $browser->visit('/user/' . $user->id)->on(new Home()); }); } /** * Test users list page (unauthenticated) */ public function testListUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $browser->visit('/users')->on(new Home()); }); } /** * Test users list page */ public function testList(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->on(new Dashboard()) ->assertSeeIn('@links .link-users', 'User accounts') ->click('@links .link-users') ->on(new UserList()) ->whenAvailable('@table', function (Browser $browser) { $browser->waitFor('tbody tr') ->assertElementsCount('tbody tr', 4) ->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org') ->assertSeeIn('tbody tr:nth-child(2) a', 'joe@kolab.org') ->assertSeeIn('tbody tr:nth-child(3) a', 'john@kolab.org') ->assertSeeIn('tbody tr:nth-child(4) a', 'ned@kolab.org') ->assertMissing('tfoot'); }); }); } /** * Test user account editing page (not profile page) * * @depends testList */ public function testInfo(): void { $this->browse(function (Browser $browser) { $browser->on(new UserList()) ->click('@table tr:nth-child(3) a') ->on(new UserInfo()) ->assertSeeIn('#user-info .card-title', 'User account') ->with('@general', function (Browser $browser) { // Assert form content $browser->assertSeeIn('div.row:nth-child(1) label', 'Status') ->assertSeeIn('div.row:nth-child(1) #status', 'Active') ->assertFocused('div.row:nth-child(2) input') ->assertSeeIn('div.row:nth-child(2) label', 'First Name') ->assertValue('div.row:nth-child(2) input[type=text]', $this->profile['first_name']) ->assertSeeIn('div.row:nth-child(3) label', 'Last Name') ->assertValue('div.row:nth-child(3) input[type=text]', $this->profile['last_name']) ->assertSeeIn('div.row:nth-child(4) label', 'Organization') ->assertValue('div.row:nth-child(4) input[type=text]', $this->profile['organization']) ->assertSeeIn('div.row:nth-child(5) label', 'Email') ->assertValue('div.row:nth-child(5) input[type=text]', 'john@kolab.org') ->assertDisabled('div.row:nth-child(5) input[type=text]') ->assertSeeIn('div.row:nth-child(6) label', 'Email Aliases') ->assertVisible('div.row:nth-child(6) .list-input') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertListInputValue(['john.doe@kolab.org']) ->assertValue('@input', ''); }) ->assertSeeIn('div.row:nth-child(7) label', 'Password') ->assertValue('div.row:nth-child(7) input[type=password]', '') ->assertSeeIn('div.row:nth-child(8) label', 'Confirm Password') ->assertValue('div.row:nth-child(8) input[type=password]', '') ->assertSeeIn('button[type=submit]', 'Submit') // Clear some fields and submit ->vueClear('#first_name') ->vueClear('#last_name') ->click('button[type=submit]'); }) ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.') ->on(new UserList()) ->click('@table tr:nth-child(3) a') ->on(new UserInfo()) ->assertSeeIn('#user-info .card-title', 'User account') ->with('@general', function (Browser $browser) { // Test error handling (password) $browser->type('#password', 'aaaaaa') ->vueClear('#password_confirmation') ->click('button[type=submit]') ->waitFor('#password + .invalid-feedback') ->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.') ->assertFocused('#password') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); // TODO: Test password change // Test form error handling (aliases) $browser->vueClear('#password') ->vueClear('#password_confirmation') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->addListEntry('invalid address'); }) ->click('button[type=submit]') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); $browser->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertFormError(2, 'The specified alias is invalid.', false); }); // Test adding aliases $browser->with(new ListInput('#aliases'), function (Browser $browser) { $browser->removeListEntry(2) ->addListEntry('john.test@kolab.org'); }) ->click('button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); }) ->on(new UserList()) ->click('@table tr:nth-child(3) a') ->on(new UserInfo()); $john = User::where('email', 'john@kolab.org')->first(); $alias = UserAlias::where('user_id', $john->id)->where('alias', 'john.test@kolab.org')->first(); $this->assertTrue(!empty($alias)); // Test subscriptions $browser->with('@general', function (Browser $browser) { $browser->assertSeeIn('div.row:nth-child(9) label', 'Subscriptions') ->assertVisible('@skus.row:nth-child(9)') ->with('@skus', function ($browser) { $browser->assertElementsCount('tbody tr', 6) // Mailbox SKU ->assertSeeIn('tbody tr:nth-child(1) td.name', 'User Mailbox') ->assertSeeIn('tbody tr:nth-child(1) td.price', '5,00 CHF/month') ->assertChecked('tbody tr:nth-child(1) td.selection input') ->assertDisabled('tbody tr:nth-child(1) td.selection input') ->assertTip( 'tbody tr:nth-child(1) td.buttons button', 'Just a mailbox' ) // Storage SKU ->assertSeeIn('tbody tr:nth-child(2) td.name', 'Storage Quota') ->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month') ->assertChecked('tbody tr:nth-child(2) td.selection input') ->assertDisabled('tbody tr:nth-child(2) td.selection input') ->assertTip( 'tbody tr:nth-child(2) td.buttons button', 'Some wiggle room' ) ->with(new QuotaInput('tbody tr:nth-child(2) .range-input'), function ($browser) { $browser->assertQuotaValue(5)->setQuotaValue(6); }) ->assertSeeIn('tr:nth-child(2) td.price', '0,25 CHF/month') // groupware SKU ->assertSeeIn('tbody tr:nth-child(3) td.name', 'Groupware Features') ->assertSeeIn('tbody tr:nth-child(3) td.price', '4,90 CHF/month') ->assertChecked('tbody tr:nth-child(3) td.selection input') ->assertEnabled('tbody tr:nth-child(3) td.selection input') ->assertTip( 'tbody tr:nth-child(3) td.buttons button', 'Groupware functions like Calendar, Tasks, Notes, etc.' ) // ActiveSync SKU ->assertSeeIn('tbody tr:nth-child(4) td.name', 'Activesync') ->assertSeeIn('tbody tr:nth-child(4) td.price', '0,00 CHF/month') ->assertNotChecked('tbody tr:nth-child(4) td.selection input') ->assertEnabled('tbody tr:nth-child(4) td.selection input') ->assertTip( 'tbody tr:nth-child(4) td.buttons button', 'Mobile synchronization' ) // 2FA SKU ->assertSeeIn('tbody tr:nth-child(5) td.name', '2-Factor Authentication') ->assertSeeIn('tbody tr:nth-child(5) td.price', '0,00 CHF/month') ->assertNotChecked('tbody tr:nth-child(5) td.selection input') ->assertEnabled('tbody tr:nth-child(5) td.selection input') ->assertTip( 'tbody tr:nth-child(5) td.buttons button', 'Two factor authentication for webmail and administration panel' ) // Meet SKU ->assertSeeIn('tbody tr:nth-child(6) td.name', 'Voice & Video Conferencing (public beta)') ->assertSeeIn('tbody tr:nth-child(6) td.price', '0,00 CHF/month') ->assertNotChecked('tbody tr:nth-child(6) td.selection input') ->assertEnabled('tbody tr:nth-child(6) td.selection input') ->assertTip( 'tbody tr:nth-child(6) td.buttons button', 'Video conferencing tool' ) ->click('tbody tr:nth-child(4) td.selection input'); }) ->assertMissing('@skus table + .hint') ->click('button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); }) ->on(new UserList()) ->click('@table tr:nth-child(3) a') ->on(new UserInfo()); $expected = ['activesync', 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage']; - $this->assertUserEntitlements($john, $expected); + $this->assertEntitlements($john, $expected); // Test subscriptions interaction $browser->with('@general', function (Browser $browser) { $browser->with('@skus', function ($browser) { // Uncheck 'groupware', expect activesync unchecked $browser->click('#sku-input-groupware') ->assertNotChecked('#sku-input-groupware') ->assertNotChecked('#sku-input-activesync') ->assertEnabled('#sku-input-activesync') ->assertNotReadonly('#sku-input-activesync') // Check 'activesync', expect an alert ->click('#sku-input-activesync') ->assertDialogOpened('Activesync requires Groupware Features.') ->acceptDialog() ->assertNotChecked('#sku-input-activesync') // Check 'meet', expect an alert ->click('#sku-input-meet') ->assertDialogOpened('Voice & Video Conferencing (public beta) requires Groupware Features.') ->acceptDialog() ->assertNotChecked('#sku-input-meet') // Check '2FA', expect 'activesync' unchecked and readonly ->click('#sku-input-2fa') ->assertChecked('#sku-input-2fa') ->assertNotChecked('#sku-input-activesync') ->assertReadonly('#sku-input-activesync') // Uncheck '2FA' ->click('#sku-input-2fa') ->assertNotChecked('#sku-input-2fa') ->assertNotReadonly('#sku-input-activesync'); }); }); }); } /** * Test user settings tab * * @depends testInfo */ public function testUserSettings(): void { $john = $this->getTestUser('john@kolab.org'); $john->setSetting('greylist_enabled', null); $this->browse(function (Browser $browser) use ($john) { $browser->visit('/user/' . $john->id) ->on(new UserInfo()) ->assertElementsCount('@nav a', 2) ->assertSeeIn('@nav #tab-general', 'General') ->assertSeeIn('@nav #tab-settings', 'Settings') ->click('@nav #tab-settings') ->with('#settings form', function (Browser $browser) { $browser->assertSeeIn('div.row:nth-child(1) label', 'Greylisting') ->click('div.row:nth-child(1) input[type=checkbox]:checked') ->click('button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User settings updated successfully.'); }); }); $this->assertSame('false', $john->fresh()->getSetting('greylist_enabled')); } /** * Test user adding page * * @depends testList */ public function testNewUser(): void { $this->browse(function (Browser $browser) { $browser->visit(new UserList()) ->assertSeeIn('button.create-user', 'Create user') ->click('button.create-user') ->on(new UserInfo()) ->assertSeeIn('#user-info .card-title', 'New user account') ->with('@general', function (Browser $browser) { // Assert form content $browser->assertFocused('div.row:nth-child(1) input') ->assertSeeIn('div.row:nth-child(1) label', 'First Name') ->assertValue('div.row:nth-child(1) input[type=text]', '') ->assertSeeIn('div.row:nth-child(2) label', 'Last Name') ->assertValue('div.row:nth-child(2) input[type=text]', '') ->assertSeeIn('div.row:nth-child(3) label', 'Organization') ->assertValue('div.row:nth-child(3) input[type=text]', '') ->assertSeeIn('div.row:nth-child(4) label', 'Email') ->assertValue('div.row:nth-child(4) input[type=text]', '') ->assertEnabled('div.row:nth-child(4) input[type=text]') ->assertSeeIn('div.row:nth-child(5) label', 'Email Aliases') ->assertVisible('div.row:nth-child(5) .list-input') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertListInputValue([]) ->assertValue('@input', ''); }) ->assertSeeIn('div.row:nth-child(6) label', 'Password') ->assertValue('div.row:nth-child(6) input[type=password]', '') ->assertSeeIn('div.row:nth-child(7) label', 'Confirm Password') ->assertValue('div.row:nth-child(7) input[type=password]', '') ->assertSeeIn('div.row:nth-child(8) label', 'Package') // assert packages list widget, select "Lite Account" ->with('@packages', function ($browser) { $browser->assertElementsCount('tbody tr', 2) ->assertSeeIn('tbody tr:nth-child(1)', 'Groupware Account') ->assertSeeIn('tbody tr:nth-child(2)', 'Lite Account') ->assertSeeIn('tbody tr:nth-child(1) .price', '9,90 CHF/month') ->assertSeeIn('tbody tr:nth-child(2) .price', '5,00 CHF/month') ->assertChecked('tbody tr:nth-child(1) input') ->click('tbody tr:nth-child(2) input') ->assertNotChecked('tbody tr:nth-child(1) input') ->assertChecked('tbody tr:nth-child(2) input'); }) ->assertMissing('@packages table + .hint') ->assertSeeIn('button[type=submit]', 'Submit'); // Test browser-side required fields and error handling $browser->click('button[type=submit]') ->assertFocused('#email') ->type('#email', 'invalid email') ->click('button[type=submit]') ->assertFocused('#password') ->type('#password', 'simple123') ->click('button[type=submit]') ->assertFocused('#password_confirmation') ->type('#password_confirmation', 'simple') ->click('button[type=submit]') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->assertSeeIn('#email + .invalid-feedback', 'The specified email is invalid.') ->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.'); }); // Test form error handling (aliases) $browser->with('@general', function (Browser $browser) { $browser->type('#email', 'julia.roberts@kolab.org') ->type('#password_confirmation', 'simple123') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->addListEntry('invalid address'); }) ->click('button[type=submit]') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertFormError(1, 'The specified alias is invalid.', false); }); }); // Successful account creation $browser->with('@general', function (Browser $browser) { $browser->type('#first_name', 'Julia') ->type('#last_name', 'Roberts') ->type('#organization', 'Test Org') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->removeListEntry(1) ->addListEntry('julia.roberts2@kolab.org'); }) ->click('button[type=submit]'); }) ->assertToast(Toast::TYPE_SUCCESS, 'User created successfully.') // check redirection to users list ->on(new UserList()) ->whenAvailable('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 5) ->assertSeeIn('tbody tr:nth-child(4) a', 'julia.roberts@kolab.org'); }); $julia = User::where('email', 'julia.roberts@kolab.org')->first(); $alias = UserAlias::where('user_id', $julia->id)->where('alias', 'julia.roberts2@kolab.org')->first(); $this->assertTrue(!empty($alias)); - $this->assertUserEntitlements($julia, ['mailbox', 'storage', 'storage', 'storage', 'storage', 'storage']); + $this->assertEntitlements($julia, ['mailbox', 'storage', 'storage', 'storage', 'storage', 'storage']); $this->assertSame('Julia', $julia->getSetting('first_name')); $this->assertSame('Roberts', $julia->getSetting('last_name')); $this->assertSame('Test Org', $julia->getSetting('organization')); // Some additional tests for the list input widget $browser->click('@table tbody tr:nth-child(4) a') ->on(new UserInfo()) ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertListInputValue(['julia.roberts2@kolab.org']) ->addListEntry('invalid address') ->type('.input-group:nth-child(2) input', '@kolab.org') ->keys('.input-group:nth-child(2) input', '{enter}'); }) // TODO: Investigate why this click does not work, for now we // submit the form with Enter key above //->click('@general button[type=submit]') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertVisible('.input-group:nth-child(2) input.is-invalid') ->assertVisible('.input-group:nth-child(3) input.is-invalid') ->type('.input-group:nth-child(2) input', 'julia.roberts3@kolab.org') ->type('.input-group:nth-child(3) input', 'julia.roberts4@kolab.org') ->keys('.input-group:nth-child(3) input', '{enter}'); }) // TODO: Investigate why this click does not work, for now we // submit the form with Enter key above //->click('@general button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); $julia = User::where('email', 'julia.roberts@kolab.org')->first(); $aliases = $julia->aliases()->orderBy('alias')->get()->pluck('alias')->all(); $this->assertSame(['julia.roberts3@kolab.org', 'julia.roberts4@kolab.org'], $aliases); }); } /** * Test user delete * * @depends testNewUser */ public function testDeleteUser(): void { // First create a new user $john = $this->getTestUser('john@kolab.org'); $julia = $this->getTestUser('julia.roberts@kolab.org'); $package_kolab = \App\Package::where('title', 'kolab')->first(); $john->assignPackage($package_kolab, $julia); // Test deleting non-controller user $this->browse(function (Browser $browser) use ($julia) { $browser->visit('/user/' . $julia->id) ->on(new UserInfo()) ->assertSeeIn('button.button-delete', 'Delete user') ->click('button.button-delete') ->with(new Dialog('#delete-warning'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Delete julia.roberts@kolab.org') ->assertFocused('@button-cancel') ->assertSeeIn('@button-cancel', 'Cancel') ->assertSeeIn('@button-action', 'Delete') ->click('@button-cancel'); }) ->waitUntilMissing('#delete-warning') ->click('button.button-delete') ->with(new Dialog('#delete-warning'), function (Browser $browser) { $browser->click('@button-action'); }) ->waitUntilMissing('#delete-warning') ->assertToast(Toast::TYPE_SUCCESS, 'User deleted successfully.') ->on(new UserList()) ->with('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 4) ->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org') ->assertSeeIn('tbody tr:nth-child(2) a', 'joe@kolab.org') ->assertSeeIn('tbody tr:nth-child(3) a', 'john@kolab.org') ->assertSeeIn('tbody tr:nth-child(4) a', 'ned@kolab.org'); }); $julia = User::where('email', 'julia.roberts@kolab.org')->first(); $this->assertTrue(empty($julia)); }); // Test that non-controller user cannot see/delete himself on the users list $this->browse(function (Browser $browser) { $browser->visit('/logout') ->on(new Home()) ->submitLogon('jack@kolab.org', 'simple123', true) ->visit('/users') ->assertErrorPage(403); }); // Test that controller user (Ned) can see all the users $this->browse(function (Browser $browser) { $browser->visit('/logout') ->on(new Home()) ->submitLogon('ned@kolab.org', 'simple123', true) ->visit(new UserList()) ->whenAvailable('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 4); }); // TODO: Test the delete action in details }); // TODO: Test what happens with the logged in user session after he's been deleted by another user } /** * Test discounted sku/package prices in the UI */ public function testDiscountedPrices(): void { // Add 10% discount $discount = Discount::where('code', 'TEST')->first(); $john = User::where('email', 'john@kolab.org')->first(); $wallet = $john->wallet(); $wallet->discount()->associate($discount); $wallet->save(); // SKUs on user edit page $this->browse(function (Browser $browser) { $browser->visit('/logout') ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->visit(new UserList()) ->waitFor('@table tr:nth-child(2)') ->click('@table tr:nth-child(2) a') // joe@kolab.org ->on(new UserInfo()) ->with('@general', function (Browser $browser) { $browser->whenAvailable('@skus', function (Browser $browser) { $quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input'); $browser->waitFor('tbody tr') ->assertElementsCount('tbody tr', 6) // Mailbox SKU ->assertSeeIn('tbody tr:nth-child(1) td.price', '4,50 CHF/month¹') // Storage SKU ->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month¹') ->with($quota_input, function (Browser $browser) { $browser->setQuotaValue(100); }) ->assertSeeIn('tr:nth-child(2) td.price', '21,37 CHF/month¹') // groupware SKU ->assertSeeIn('tbody tr:nth-child(3) td.price', '4,41 CHF/month¹') // ActiveSync SKU ->assertSeeIn('tbody tr:nth-child(4) td.price', '0,00 CHF/month¹') // 2FA SKU ->assertSeeIn('tbody tr:nth-child(5) td.price', '0,00 CHF/month¹'); }) ->assertSeeIn('@skus table + .hint', '¹ applied discount: 10% - Test voucher'); }); }); // Packages on new user page $this->browse(function (Browser $browser) { $browser->visit(new UserList()) ->click('button.create-user') ->on(new UserInfo()) ->with('@general', function (Browser $browser) { $browser->whenAvailable('@packages', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 2) ->assertSeeIn('tbody tr:nth-child(1) .price', '8,91 CHF/month¹') // Groupware ->assertSeeIn('tbody tr:nth-child(2) .price', '4,50 CHF/month¹'); // Lite }) ->assertSeeIn('@packages table + .hint', '¹ applied discount: 10% - Test voucher'); }); }); // Test using entitlement cost instead of the SKU cost $this->browse(function (Browser $browser) use ($wallet) { $joe = User::where('email', 'joe@kolab.org')->first(); $beta_sku = Sku::withEnvTenantContext()->where('title', 'beta')->first(); $storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first(); // Add an extra storage and beta entitlement with different prices Entitlement::create([ 'wallet_id' => $wallet->id, 'sku_id' => $beta_sku->id, 'cost' => 5010, 'entitleable_id' => $joe->id, 'entitleable_type' => User::class ]); Entitlement::create([ 'wallet_id' => $wallet->id, 'sku_id' => $storage_sku->id, 'cost' => 5000, 'entitleable_id' => $joe->id, 'entitleable_type' => User::class ]); $browser->visit('/user/' . $joe->id) ->on(new UserInfo()) ->with('@general', function (Browser $browser) { $browser->whenAvailable('@skus', function (Browser $browser) { $quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input'); $browser->waitFor('tbody tr') // Beta SKU ->assertSeeIn('tbody tr:nth-child(7) td.price', '45,09 CHF/month¹') // Storage SKU ->assertSeeIn('tr:nth-child(2) td.price', '45,00 CHF/month¹') ->with($quota_input, function (Browser $browser) { $browser->setQuotaValue(7); }) ->assertSeeIn('tr:nth-child(2) td.price', '45,22 CHF/month¹') ->with($quota_input, function (Browser $browser) { $browser->setQuotaValue(5); }) ->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month¹'); }) ->assertSeeIn('@skus table + .hint', '¹ applied discount: 10% - Test voucher'); }); }); } /** * Test non-default currency in the UI */ public function testCurrency(): void { // Add 10% discount $john = User::where('email', 'john@kolab.org')->first(); $wallet = $john->wallet(); $wallet->balance = -1000; $wallet->currency = 'EUR'; $wallet->save(); // On Dashboard and the wallet page $this->browse(function (Browser $browser) { $browser->visit('/logout') ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->on(new Dashboard()) ->assertSeeIn('@links .link-wallet .badge', '-10,00 €') ->click('@links .link-wallet') ->on(new WalletPage()) ->assertSeeIn('#wallet .card-title', 'Account balance -10,00 €'); }); // SKUs on user edit page $this->browse(function (Browser $browser) { $browser->visit(new UserList()) ->waitFor('@table tr:nth-child(2)') ->click('@table tr:nth-child(2) a') // joe@kolab.org ->on(new UserInfo()) ->with('@general', function (Browser $browser) { $browser->whenAvailable('@skus', function (Browser $browser) { $quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input'); $browser->waitFor('tbody tr') ->assertElementsCount('tbody tr', 6) // Mailbox SKU ->assertSeeIn('tbody tr:nth-child(1) td.price', '5,00 €/month') // Storage SKU ->assertSeeIn('tr:nth-child(2) td.price', '0,00 €/month') ->with($quota_input, function (Browser $browser) { $browser->setQuotaValue(100); }) ->assertSeeIn('tr:nth-child(2) td.price', '23,75 €/month'); }); }); }); // Packages on new user page $this->browse(function (Browser $browser) { $browser->visit(new UserList()) ->click('button.create-user') ->on(new UserInfo()) ->with('@general', function (Browser $browser) { $browser->whenAvailable('@packages', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 2) ->assertSeeIn('tbody tr:nth-child(1) .price', '9,90 €/month') // Groupware ->assertSeeIn('tbody tr:nth-child(2) .price', '5,00 €/month'); // Lite }); }); }); } /** * Test beta entitlements * * @depends testList */ public function testBetaEntitlements(): void { $this->browse(function (Browser $browser) { $john = User::where('email', 'john@kolab.org')->first(); $sku = Sku::withEnvTenantContext()->where('title', 'beta')->first(); $john->assignSku($sku); $browser->visit('/user/' . $john->id) ->on(new UserInfo()) ->with('@skus', function ($browser) { $browser->assertElementsCount('tbody tr', 8) // Meet SKU ->assertSeeIn('tbody tr:nth-child(6) td.name', 'Voice & Video Conferencing (public beta)') ->assertSeeIn('tr:nth-child(6) td.price', '0,00 CHF/month') ->assertNotChecked('tbody tr:nth-child(6) td.selection input') ->assertEnabled('tbody tr:nth-child(6) td.selection input') ->assertTip( 'tbody tr:nth-child(6) td.buttons button', 'Video conferencing tool' ) // Beta SKU ->assertSeeIn('tbody tr:nth-child(7) td.name', 'Private Beta (invitation only)') ->assertSeeIn('tbody tr:nth-child(7) td.price', '0,00 CHF/month') ->assertChecked('tbody tr:nth-child(7) td.selection input') ->assertEnabled('tbody tr:nth-child(7) td.selection input') ->assertTip( 'tbody tr:nth-child(7) td.buttons button', 'Access to the private beta program subscriptions' ) // Distlist SKU ->assertSeeIn('tbody tr:nth-child(8) td.name', 'Distribution lists') ->assertSeeIn('tr:nth-child(8) td.price', '0,00 CHF/month') ->assertNotChecked('tbody tr:nth-child(8) td.selection input') ->assertEnabled('tbody tr:nth-child(8) td.selection input') ->assertTip( 'tbody tr:nth-child(8) td.buttons button', 'Access to mail distribution lists' ) // Check Distlist, Uncheck Beta, expect Distlist unchecked ->click('#sku-input-distlist') ->click('#sku-input-beta') ->assertNotChecked('#sku-input-beta') ->assertNotChecked('#sku-input-distlist') // Click Distlist expect an alert ->click('#sku-input-distlist') ->assertDialogOpened('Distribution lists requires Private Beta (invitation only).') ->acceptDialog() // Enable Beta and Distlist and submit ->click('#sku-input-beta') ->click('#sku-input-distlist'); }) ->click('@general button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); $expected = [ 'beta', 'distlist', 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage' ]; - $this->assertUserEntitlements($john, $expected); + $this->assertEntitlements($john, $expected); $browser->visit('/user/' . $john->id) ->on(new UserInfo()) ->waitFor('#sku-input-beta') ->click('#sku-input-beta') ->click('@general button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); $expected = [ 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage' ]; - $this->assertUserEntitlements($john, $expected); + $this->assertEntitlements($john, $expected); }); // TODO: Test that the Distlist SKU is not available for users that aren't a group account owners // TODO: Test that entitlements change has immediate effect on the available items in dashboard // i.e. does not require a page reload nor re-login. } } diff --git a/src/tests/Feature/Console/DomainsTest.php b/src/tests/Feature/Console/DomainsTest.php index 9a8cc4ce..e908f011 100644 --- a/src/tests/Feature/Console/DomainsTest.php +++ b/src/tests/Feature/Console/DomainsTest.php @@ -1,27 +1,28 @@ first(); // Existing domain $code = \Artisan::call("domains"); $output = trim(\Artisan::output()); $this->assertSame(0, $code); $this->assertTrue(strpos($output, (string) $domain->id) !== false); // TODO: Test --deleted argument // TODO: Test output format and other attributes // TODO: Test tenant context + $this->markTestIncomplete(); } } diff --git a/src/tests/Feature/Console/OwnerSwapTest.php b/src/tests/Feature/Console/OwnerSwapTest.php new file mode 100644 index 00000000..7b0b6bec --- /dev/null +++ b/src/tests/Feature/Console/OwnerSwapTest.php @@ -0,0 +1,142 @@ +deleteTestUser('user1@owner-swap.com'); + $this->deleteTestUser('user2@owner-swap.com'); + $this->deleteTestDomain('owner-swap.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('user1@owner-swap.com'); + $this->deleteTestUser('user2@owner-swap.com'); + $this->deleteTestDomain('owner-swap.com'); + + parent::tearDown(); + } + + /** + * Test the command + * + * @group mollie + */ + public function testHandle(): void + { + Queue::fake(); + + // Create some sample account + $owner = $this->getTestUser('user1@owner-swap.com'); + $user = $this->getTestUser('user2@owner-swap.com'); + $domain = $this->getTestDomain('owner-swap.com', [ + 'status' => \App\Domain::STATUS_NEW, + 'type' => \App\Domain::TYPE_HOSTED, + ]); + $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); + $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); + $owner->assignPackage($package_kolab); + $owner->assignPackage($package_kolab, $user); + $domain->assignPackage($package_domain, $owner); + $wallet = $owner->wallets()->first(); + $wallet->currency = 'USD'; + $wallet->balance = 100; + $wallet->save(); + $wallet->setSetting('test', 'test'); + $target_wallet = $user->wallets()->first(); + $owner->created_at = \now()->subMonths(1); + $owner->save(); + + $entitlements = $wallet->entitlements()->orderBy('id')->pluck('id')->all(); + $this->assertCount(15, $entitlements); + $this->assertSame(0, $target_wallet->entitlements()->count()); + + $customer = $this->createMollieCustomer($wallet); + + // Non-existing target user + $code = \Artisan::call("owner:swap user1@owner-swap.com unknown@unknown.org"); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("User not found.", $output); + + // The same user + $code = \Artisan::call("owner:swap user1@owner-swap.com user1@owner-swap.com"); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("Users cannot be the same.", $output); + + // Success + $code = \Artisan::call("owner:swap user1@owner-swap.com user2@owner-swap.com"); + $output = trim(\Artisan::output()); + $this->assertSame(0, $code); + $this->assertSame("", $output); + + $user->refresh(); + $target_wallet->refresh(); + $target_entitlements = $target_wallet->entitlements()->orderBy('id')->pluck('id')->all(); + + $this->assertSame($target_entitlements, $entitlements); + $this->assertSame(0, $wallet->entitlements()->count()); + $this->assertSame($wallet->balance, $target_wallet->balance); + $this->assertSame($wallet->currency, $target_wallet->currency); + $this->assertTrue($user->created_at->toDateTimeString() === $owner->created_at->toDateTimeString()); + $this->assertSame('test', $target_wallet->getSetting('test')); + + $wallet->refresh(); + $this->assertSame(null, $wallet->getSetting('test')); + $this->assertSame(0, $wallet->balance); + + $target_customer = $this->getMollieCustomer($target_wallet->getSetting('mollie_id')); + $this->assertSame($customer->id, $target_customer->id); + $this->assertTrue($customer->email != $target_customer->email); + $this->assertSame($target_wallet->id . '@private.' . \config('app.domain'), $target_customer->email); + + // Test case when the target user does not belong to the same account + $john = $this->getTestUser('john@kolab.org'); + $owner->entitlement()->update(['wallet_id' => $john->wallets->first()->id]); + + $code = \Artisan::call("owner:swap user2@owner-swap.com user1@owner-swap.com"); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("The target user does not belong to the same account.", $output); + } + + /** + * Create a Mollie customer + */ + private function createMollieCustomer($wallet) + { + $customer = mollie()->customers()->create([ + 'name' => $wallet->owner->name(), + 'email' => $wallet->id . '@private.' . \config('app.domain'), + ]); + + $customer_id = $customer->id; + + $wallet->setSetting('mollie_id', $customer->id); + + return $customer; + } + + /** + * Get a Mollie customer + */ + private function getMollieCustomer(string $mollie_id) + { + return mollie()->customers()->get($mollie_id); + } +} diff --git a/src/tests/Feature/Console/UserRestoreTest.php b/src/tests/Feature/Console/User/AddAliasTest.php similarity index 62% copy from src/tests/Feature/Console/UserRestoreTest.php copy to src/tests/Feature/Console/User/AddAliasTest.php index 662b0fbd..0b6857b6 100644 --- a/src/tests/Feature/Console/UserRestoreTest.php +++ b/src/tests/Feature/Console/User/AddAliasTest.php @@ -1,80 +1,77 @@ deleteTestUser('user@force-delete.com'); $this->deleteTestDomain('force-delete.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('user@force-delete.com'); $this->deleteTestDomain('force-delete.com'); parent::tearDown(); } /** * Test the command */ public function testHandle(): void { - Queue::fake(); - // Non-existing user - $code = \Artisan::call("user:restore unknown@unknown.org"); + $code = \Artisan::call("user:add-alias unknown unknown"); $output = trim(\Artisan::output()); + $this->assertSame(1, $code); $this->assertSame("User not found.", $output); - // Create a user account for delete $user = $this->getTestUser('user@force-delete.com'); $domain = $this->getTestDomain('force-delete.com', [ 'status' => \App\Domain::STATUS_NEW, 'type' => \App\Domain::TYPE_HOSTED, ]); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $user->assignPackage($package_kolab); $domain->assignPackage($package_domain, $user); - $wallet = $user->wallets()->first(); - $entitlements = $wallet->entitlements->pluck('id')->all(); - - $this->assertCount(8, $entitlements); - // Non-deleted user - $code = \Artisan::call("user:restore {$user->email}"); + // Invalid alias + $code = \Artisan::call("user:add-alias {$user->email} invalid"); $output = trim(\Artisan::output()); - $this->assertSame(1, $code); - $this->assertSame("The user is not yet deleted.", $output); - - $user->delete(); - $this->assertTrue($user->trashed()); - $this->assertTrue($domain->fresh()->trashed()); + $this->assertSame(1, $code); + $this->assertSame("The specified alias is invalid.", $output); - // Deleted user - $code = \Artisan::call("user:restore {$user->email}"); + // Test success + $code = \Artisan::call("user:add-alias {$user->email} test@force-delete.com"); $output = trim(\Artisan::output()); + $this->assertSame(0, $code); $this->assertSame("", $output); + $this->assertCount(1, $user->aliases()->where('alias', 'test@force-delete.com')->get()); + + // Alias already exists + $code = \Artisan::call("user:add-alias {$user->email} test@force-delete.com"); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + $this->assertSame("Address is already assigned to the user.", $output); - $this->assertFalse($user->fresh()->trashed()); - $this->assertFalse($domain->fresh()->trashed()); + // TODO: test --force option } } diff --git a/src/tests/Feature/Console/UserAssignSkuTest.php b/src/tests/Feature/Console/User/AssignSkuTest.php similarity index 91% rename from src/tests/Feature/Console/UserAssignSkuTest.php rename to src/tests/Feature/Console/User/AssignSkuTest.php index 9905fe72..0ff6a5ee 100644 --- a/src/tests/Feature/Console/UserAssignSkuTest.php +++ b/src/tests/Feature/Console/User/AssignSkuTest.php @@ -1,63 +1,63 @@ deleteTestUser('add-entitlement@kolabnow.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('add-entitlement@kolabnow.com'); parent::tearDown(); } /** * Test command runs */ public function testHandle(): void { $sku = \App\Sku::where('title', 'meet')->first(); $user = $this->getTestUser('add-entitlement@kolabnow.com'); $this->artisan('user:assign-sku unknown@unknown.org ' . $sku->id) ->assertExitCode(1) - ->expectsOutput("Unable to find the user unknown@unknown.org."); + ->expectsOutput("User not found."); $this->artisan('user:assign-sku ' . $user->email . ' unknownsku') ->assertExitCode(1) ->expectsOutput("Unable to find the SKU unknownsku."); $this->artisan('user:assign-sku ' . $user->email . ' ' . $sku->id) ->assertExitCode(0); $this->assertCount(1, $user->entitlements()->where('sku_id', $sku->id)->get()); // Try again (also test sku by title) $this->artisan('user:assign-sku ' . $user->email . ' ' . $sku->title) ->assertExitCode(1) ->expectsOutput("The entitlement already exists. Maybe try with --qty=X?"); $this->assertCount(1, $user->entitlements()->where('sku_id', $sku->id)->get()); // Try again with --qty option, to force the assignment $this->artisan('user:assign-sku ' . $user->email . ' ' . $sku->title . ' --qty=1') ->assertExitCode(0); $this->assertCount(2, $user->entitlements()->where('sku_id', $sku->id)->get()); } } diff --git a/src/tests/Feature/Console/User/DeleteTest.php b/src/tests/Feature/Console/User/DeleteTest.php new file mode 100644 index 00000000..c48cd3e1 --- /dev/null +++ b/src/tests/Feature/Console/User/DeleteTest.php @@ -0,0 +1,58 @@ +deleteTestUser('user@force-delete.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('user@force-delete.com'); + + parent::tearDown(); + } + + /** + * Test the command + */ + public function testHandle(): void + { + // Non-existing user + $code = \Artisan::call("user:delete unknown"); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + $this->assertSame("No such user unknown", $output); + + $user = $this->getTestUser('user@force-delete.com'); + + // Test success + $code = \Artisan::call("user:delete {$user->email}"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertSame("", $output); + $this->assertTrue($user->fresh()->trashed()); + + // User already deleted + $code = \Artisan::call("user:delete {$user->email}"); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + $this->assertSame("No such user user@force-delete.com", $output); + } +} diff --git a/src/tests/Feature/Console/User/DomainsTest.php b/src/tests/Feature/Console/User/DomainsTest.php new file mode 100644 index 00000000..2ca6d3ad --- /dev/null +++ b/src/tests/Feature/Console/User/DomainsTest.php @@ -0,0 +1,27 @@ +assertSame(1, $code); + $this->assertSame("User not found.", $output); + + $code = \Artisan::call("user:domains john@kolab.org"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertTrue(strpos($output, "kolab.org") !== false); + $this->assertTrue(strpos($output, \config('app.domain')) !== false); + } +} diff --git a/src/tests/Feature/Console/User/EntitlementsTest.php b/src/tests/Feature/Console/User/EntitlementsTest.php new file mode 100644 index 00000000..fdb0c1de --- /dev/null +++ b/src/tests/Feature/Console/User/EntitlementsTest.php @@ -0,0 +1,28 @@ +assertSame(1, $code); + $this->assertSame("User not found.", $output); + + $code = \Artisan::call("user:entitlements john@kolab.org"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertTrue(strpos($output, "storage: 5") !== false); + $this->assertTrue(strpos($output, "mailbox: 1") !== false); + $this->assertTrue(strpos($output, "groupware: 1") !== false); + } +} diff --git a/src/tests/Feature/Console/UserForceDeleteTest.php b/src/tests/Feature/Console/User/ForceDeleteTest.php similarity index 97% rename from src/tests/Feature/Console/UserForceDeleteTest.php rename to src/tests/Feature/Console/User/ForceDeleteTest.php index 4b57ea5e..795bc3b7 100644 --- a/src/tests/Feature/Console/UserForceDeleteTest.php +++ b/src/tests/Feature/Console/User/ForceDeleteTest.php @@ -1,98 +1,98 @@ deleteTestUser('user@force-delete.com'); $this->deleteTestDomain('force-delete.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('user@force-delete.com'); $this->deleteTestDomain('force-delete.com'); parent::tearDown(); } /** * Test the command */ public function testHandle(): void { // Non-existing user $this->artisan('user:force-delete unknown@unknown.org') ->assertExitCode(1); Queue::fake(); $user = $this->getTestUser('user@force-delete.com'); $domain = $this->getTestDomain('force-delete.com', [ 'status' => \App\Domain::STATUS_NEW, 'type' => \App\Domain::TYPE_HOSTED, ]); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $user->assignPackage($package_kolab); $domain->assignPackage($package_domain, $user); $wallet = $user->wallets()->first(); $entitlements = $wallet->entitlements->pluck('id')->all(); $this->assertCount(8, $entitlements); // Non-deleted user $this->artisan('user:force-delete user@force-delete.com') ->assertExitCode(1); $user->delete(); $this->assertTrue($user->trashed()); $this->assertTrue($domain->fresh()->trashed()); // Deleted user $this->artisan('user:force-delete user@force-delete.com') ->assertExitCode(0); $this->assertCount( 0, \App\User::withTrashed()->where('email', 'user@force-delete.com')->get() ); $this->assertCount( 0, \App\Domain::withTrashed()->where('namespace', 'force-delete.com')->get() ); $this->assertCount( 0, \App\Wallet::where('id', $wallet->id)->get() ); $this->assertCount( 0, \App\Entitlement::withTrashed()->where('wallet_id', $wallet->id)->get() ); $this->assertCount( 0, \App\Entitlement::withTrashed()->where('entitleable_id', $user->id)->get() ); $this->assertCount( 0, \App\Transaction::whereIn('object_id', $entitlements) ->where('object_type', \App\Entitlement::class) ->get() ); // TODO: Test that it also deletes users in a group account } } diff --git a/src/tests/Feature/Console/WalletDiscountTest.php b/src/tests/Feature/Console/User/GreylistTest.php similarity index 51% copy from src/tests/Feature/Console/WalletDiscountTest.php copy to src/tests/Feature/Console/User/GreylistTest.php index bb87edfc..8b1c6fef 100644 --- a/src/tests/Feature/Console/WalletDiscountTest.php +++ b/src/tests/Feature/Console/User/GreylistTest.php @@ -1,13 +1,16 @@ markTestIncomplete(); } } diff --git a/src/tests/Feature/Console/UserRestoreTest.php b/src/tests/Feature/Console/User/RestoreTest.php similarity index 94% rename from src/tests/Feature/Console/UserRestoreTest.php rename to src/tests/Feature/Console/User/RestoreTest.php index 662b0fbd..7f36dae4 100644 --- a/src/tests/Feature/Console/UserRestoreTest.php +++ b/src/tests/Feature/Console/User/RestoreTest.php @@ -1,80 +1,80 @@ deleteTestUser('user@force-delete.com'); $this->deleteTestDomain('force-delete.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('user@force-delete.com'); $this->deleteTestDomain('force-delete.com'); parent::tearDown(); } /** * Test the command */ public function testHandle(): void { Queue::fake(); // Non-existing user $code = \Artisan::call("user:restore unknown@unknown.org"); $output = trim(\Artisan::output()); $this->assertSame(1, $code); $this->assertSame("User not found.", $output); // Create a user account for delete $user = $this->getTestUser('user@force-delete.com'); $domain = $this->getTestDomain('force-delete.com', [ 'status' => \App\Domain::STATUS_NEW, 'type' => \App\Domain::TYPE_HOSTED, ]); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $user->assignPackage($package_kolab); $domain->assignPackage($package_domain, $user); $wallet = $user->wallets()->first(); $entitlements = $wallet->entitlements->pluck('id')->all(); $this->assertCount(8, $entitlements); // Non-deleted user $code = \Artisan::call("user:restore {$user->email}"); $output = trim(\Artisan::output()); $this->assertSame(1, $code); - $this->assertSame("The user is not yet deleted.", $output); + $this->assertSame("The user is not deleted.", $output); $user->delete(); $this->assertTrue($user->trashed()); $this->assertTrue($domain->fresh()->trashed()); // Deleted user $code = \Artisan::call("user:restore {$user->email}"); $output = trim(\Artisan::output()); $this->assertSame(0, $code); $this->assertSame("", $output); $this->assertFalse($user->fresh()->trashed()); $this->assertFalse($domain->fresh()->trashed()); } } diff --git a/src/tests/Feature/Console/User/SetDiscountTest.php b/src/tests/Feature/Console/User/SetDiscountTest.php new file mode 100644 index 00000000..90e173fb --- /dev/null +++ b/src/tests/Feature/Console/User/SetDiscountTest.php @@ -0,0 +1,64 @@ +deleteTestUser('wallets-controller@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('wallets-controller@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test command runs + */ + public function testHandle(): void + { + $user = $this->getTestUser('wallets-controller@kolabnow.com'); + $wallet = $user->wallets()->first(); + $discount = \App\Discount::withObjectTenantContext($user)->where('discount', 100)->first(); + + // Invalid user id + $code = \Artisan::call("user:set-discount 123 123"); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("User not found.", $output); + + // Invalid discount id + $code = \Artisan::call("user:set-discount {$user->id} 123"); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("Discount not found.", $output); + + // Assign a discount + $code = \Artisan::call("user:set-discount {$user->id} {$discount->id}"); + $output = trim(\Artisan::output()); + $this->assertSame(0, $code); + $this->assertSame("", $output); + $this->assertSame($discount->id, $wallet->fresh()->discount_id); + + // Remove the discount + $code = \Artisan::call("user:set-discount {$user->id} 0"); + $output = trim(\Artisan::output()); + $this->assertSame(0, $code); + $this->assertSame("", $output); + $this->assertNull($wallet->fresh()->discount_id); + } +} diff --git a/src/tests/Feature/Console/User/StatusTest.php b/src/tests/Feature/Console/User/StatusTest.php new file mode 100644 index 00000000..b3f88d23 --- /dev/null +++ b/src/tests/Feature/Console/User/StatusTest.php @@ -0,0 +1,29 @@ +assertSame(1, $code); + $this->assertSame( + "User not found.\nTry ./artisan scalpel:user:read --attr=email --attr=tenant_id unknown", + $output + ); + + $code = \Artisan::call("user:status john@kolab.org"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertSame("Status: active,ldapReady,imapReady", $output); + } +} diff --git a/src/tests/Feature/Console/User/SuspendTest.php b/src/tests/Feature/Console/User/SuspendTest.php new file mode 100644 index 00000000..dd9d2586 --- /dev/null +++ b/src/tests/Feature/Console/User/SuspendTest.php @@ -0,0 +1,54 @@ +deleteTestUser('user@force-delete.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('user@force-delete.com'); + + parent::tearDown(); + } + + /** + * Test the command + */ + public function testHandle(): void + { + Queue::fake(); + + // Non-existing user + $code = \Artisan::call("user:suspend unknown"); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + $this->assertSame("User not found.", $output); + + $user = $this->getTestUser('user@force-delete.com'); + + // Test success + $code = \Artisan::call("user:suspend {$user->email}"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertSame("", $output); + $this->assertTrue($user->fresh()->isSuspended()); + } +} diff --git a/src/tests/Feature/Console/User/UnsuspendTest.php b/src/tests/Feature/Console/User/UnsuspendTest.php new file mode 100644 index 00000000..d55ee736 --- /dev/null +++ b/src/tests/Feature/Console/User/UnsuspendTest.php @@ -0,0 +1,55 @@ +deleteTestUser('user@force-delete.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('user@force-delete.com'); + + parent::tearDown(); + } + + /** + * Test the command + */ + public function testHandle(): void + { + Queue::fake(); + + // Non-existing user + $code = \Artisan::call("user:unsuspend unknown"); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + $this->assertSame("User not found.", $output); + + $user = $this->getTestUser('user@force-delete.com'); + $user->suspend(); + + // Test success + $code = \Artisan::call("user:unsuspend {$user->email}"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertSame("", $output); + $this->assertFalse($user->fresh()->isSuspended()); + } +} diff --git a/src/tests/Feature/Console/User/VerifyTest.php b/src/tests/Feature/Console/User/VerifyTest.php new file mode 100644 index 00000000..6cdb36ab --- /dev/null +++ b/src/tests/Feature/Console/User/VerifyTest.php @@ -0,0 +1,55 @@ +deleteTestUser('user@force-delete.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('user@force-delete.com'); + + parent::tearDown(); + } + + /** + * Test the command + */ + public function testHandle(): void + { + Queue::fake(); + + // Non-existing user + $code = \Artisan::call("user:verify unknown"); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + $this->assertSame("User not found.", $output); + + $user = $this->getTestUser('user@force-delete.com'); + + // Test success + $code = \Artisan::call("user:verify {$user->email}"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertSame("", $output); + + // TODO: Test the verification utility error conditions + } +} diff --git a/src/tests/Feature/Console/User/WalletsTest.php b/src/tests/Feature/Console/User/WalletsTest.php new file mode 100644 index 00000000..c3c6f29b --- /dev/null +++ b/src/tests/Feature/Console/User/WalletsTest.php @@ -0,0 +1,29 @@ +assertSame(1, $code); + $this->assertSame("User not found.", $output); + + $user = $this->getTestUser('john@kolab.org'); + $wallet = $user->wallets()->first(); + + $code = \Artisan::call("user:wallets john@kolab.org"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertSame(trim("{$wallet->id} {$wallet->description}"), $output); + } +} diff --git a/src/tests/Feature/Console/UserDomainsTest.php b/src/tests/Feature/Console/UserDomainsTest.php deleted file mode 100644 index 6e9bdafe..00000000 --- a/src/tests/Feature/Console/UserDomainsTest.php +++ /dev/null @@ -1,16 +0,0 @@ -artisan('user:domains john@kolab.org') - ->assertExitCode(0); - - $this->markTestIncomplete(); - } -} diff --git a/src/tests/Feature/Console/UserEntitlementsTest.php b/src/tests/Feature/Console/UserEntitlementsTest.php deleted file mode 100644 index 1a421886..00000000 --- a/src/tests/Feature/Console/UserEntitlementsTest.php +++ /dev/null @@ -1,16 +0,0 @@ -artisan('user:entitlements john@kolab.org') - ->assertExitCode(0); - - $this->markTestIncomplete(); - } -} diff --git a/src/tests/Feature/Console/UserSettingsTest.php b/src/tests/Feature/Console/UserSettingsTest.php new file mode 100644 index 00000000..a9aa0e0d --- /dev/null +++ b/src/tests/Feature/Console/UserSettingsTest.php @@ -0,0 +1,22 @@ +assertSame(0, $code); + + // Test the output and extra arguments + $this->markTestIncomplete(); + } +} diff --git a/src/tests/Feature/Console/UserWalletsTest.php b/src/tests/Feature/Console/UserWalletsTest.php deleted file mode 100644 index 7e3e723a..00000000 --- a/src/tests/Feature/Console/UserWalletsTest.php +++ /dev/null @@ -1,16 +0,0 @@ -artisan('user:wallets john@kolab.org') - ->assertExitCode(0); - - $this->markTestIncomplete(); - } -} diff --git a/src/tests/Feature/Console/DomainsTest.php b/src/tests/Feature/Console/UsersTest.php similarity index 57% copy from src/tests/Feature/Console/DomainsTest.php copy to src/tests/Feature/Console/UsersTest.php index 9a8cc4ce..7a966d9f 100644 --- a/src/tests/Feature/Console/DomainsTest.php +++ b/src/tests/Feature/Console/UsersTest.php @@ -1,27 +1,28 @@ first(); + $john = $this->getTestUser('john@kolab.org'); // Existing domain - $code = \Artisan::call("domains"); + $code = \Artisan::call("users"); $output = trim(\Artisan::output()); $this->assertSame(0, $code); - $this->assertTrue(strpos($output, (string) $domain->id) !== false); + $this->assertTrue(strpos($output, (string) $john->id) !== false); // TODO: Test --deleted argument // TODO: Test output format and other attributes // TODO: Test tenant context + $this->markTestIncomplete(); } } diff --git a/src/tests/Feature/Console/Wallet/AddTransactionTest.php b/src/tests/Feature/Console/Wallet/AddTransactionTest.php new file mode 100644 index 00000000..08d29b8d --- /dev/null +++ b/src/tests/Feature/Console/Wallet/AddTransactionTest.php @@ -0,0 +1,61 @@ +deleteTestUser('wallets-controller@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('wallets-controller@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test command run for a specified wallet + */ + public function testHandle(): void + { + $user = $this->getTestUser('wallets-controller@kolabnow.com'); + $wallet = $user->wallets()->first(); + + // Invalid wallet id + $code = \Artisan::call("wallet:add-transaction 123 100"); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("Wallet not found.", $output); + + // Add credit + $code = \Artisan::call("wallet:add-transaction {$wallet->id} 100"); + $output = trim(\Artisan::output()); + $this->assertSame(0, $code); + $this->assertSame("", $output); + $wallet->refresh(); + $this->assertSame(100, $wallet->balance); + + // Add debit with a transaction description + // Note: The double-dash trick to make it working with a negative number input + $code = \Artisan::call("wallet:add-transaction --message=debit -- {$wallet->id} -100"); + $output = trim(\Artisan::output()); + $this->assertSame(0, $code); + $this->assertSame("", $output); + $wallet->refresh(); + $this->assertSame(0, $wallet->balance); + $this->assertCount(1, $wallet->transactions()->where('description', 'debit')->get()); + } +} diff --git a/src/tests/Feature/Console/Wallet/BalancesTest.php b/src/tests/Feature/Console/Wallet/BalancesTest.php new file mode 100644 index 00000000..6f3bb5e4 --- /dev/null +++ b/src/tests/Feature/Console/Wallet/BalancesTest.php @@ -0,0 +1,70 @@ +deleteTestUser('wallets-controller@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('wallets-controller@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test command run for a specified wallet + */ + public function testHandle(): void + { + Queue::fake(); + + $user = $this->getTestUser('wallets-controller@kolabnow.com'); + $wallet = $user->wallets()->first(); + + // Expect no wallets with balance=0 + $code = \Artisan::call("wallet:balances"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertTrue(strpos($output, $wallet->id) === false); + + $wallet->balance = -100; + $wallet->save(); + + // Expect the wallet with a negative balance in output + $code = \Artisan::call("wallet:balances"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertMatchesRegularExpression( + '|' . preg_quote($wallet->id, '|') . ': {5}-100 \(account: https://.*/admin/accounts/show/' + . $user->id . ' \(' . preg_quote($user->email, '|') . '\)\)|', + $output + ); + + $user->delete(); + + // Expect no wallet with deleted owner in output + $code = \Artisan::call("wallet:balances"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertTrue(strpos($output, $wallet->id) === false); + } +} diff --git a/src/tests/Feature/Console/WalletChargeTest.php b/src/tests/Feature/Console/Wallet/ChargeTest.php similarity index 96% rename from src/tests/Feature/Console/WalletChargeTest.php rename to src/tests/Feature/Console/Wallet/ChargeTest.php index ec681731..628114eb 100644 --- a/src/tests/Feature/Console/WalletChargeTest.php +++ b/src/tests/Feature/Console/Wallet/ChargeTest.php @@ -1,146 +1,147 @@ deleteTestUser('wallet-charge@kolabnow.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('wallet-charge@kolabnow.com'); parent::tearDown(); } /** * Test command run for a specified wallet */ public function testHandleSingle(): void { $user = $this->getTestUser('wallet-charge@kolabnow.com'); $wallet = $user->wallets()->first(); $wallet->balance = 0; $wallet->save(); Queue::fake(); // Non-existing wallet ID $this->artisan('wallet:charge 123') - ->assertExitCode(1); + ->assertExitCode(1) + ->expectsOutput("Wallet not found."); Queue::assertNothingPushed(); // The wallet has no entitlements, expect no charge and no check $this->artisan('wallet:charge ' . $wallet->id) ->assertExitCode(0); Queue::assertNothingPushed(); // The wallet has no entitlements, but has negative balance $wallet->balance = -100; $wallet->save(); $this->artisan('wallet:charge ' . $wallet->id) ->assertExitCode(0); Queue::assertPushed(\App\Jobs\WalletCharge::class, 0); Queue::assertPushed(\App\Jobs\WalletCheck::class, 1); Queue::assertPushed(\App\Jobs\WalletCheck::class, function ($job) use ($wallet) { $job_wallet = TestCase::getObjectProperty($job, 'wallet'); return $job_wallet->id === $wallet->id; }); Queue::fake(); // The wallet has entitlements to charge, and negative balance $sku = \App\Sku::where('title', 'mailbox')->first(); $entitlement = \App\Entitlement::create([ 'wallet_id' => $wallet->id, 'sku_id' => $sku->id, 'cost' => 100, 'entitleable_id' => $user->id, 'entitleable_type' => \App\User::class, ]); \App\Entitlement::where('id', $entitlement->id)->update([ 'created_at' => \Carbon\Carbon::now()->subMonths(1), 'updated_at' => \Carbon\Carbon::now()->subMonths(1), ]); \App\User::where('id', $user->id)->update([ 'created_at' => \Carbon\Carbon::now()->subMonths(1), 'updated_at' => \Carbon\Carbon::now()->subMonths(1), ]); $this->assertSame(100, $wallet->fresh()->chargeEntitlements(false)); $this->artisan('wallet:charge ' . $wallet->id) ->assertExitCode(0); Queue::assertPushed(\App\Jobs\WalletCharge::class, 1); Queue::assertPushed(\App\Jobs\WalletCharge::class, function ($job) use ($wallet) { $job_wallet = TestCase::getObjectProperty($job, 'wallet'); return $job_wallet->id === $wallet->id; }); Queue::assertPushed(\App\Jobs\WalletCheck::class, 1); Queue::assertPushed(\App\Jobs\WalletCheck::class, function ($job) use ($wallet) { $job_wallet = TestCase::getObjectProperty($job, 'wallet'); return $job_wallet->id === $wallet->id; }); } /** * Test command run for all wallets */ public function testHandleAll(): void { $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->balance = 0; $wallet->save(); // backdate john's entitlements and set balance=0 for all wallets $this->backdateEntitlements($user->entitlements, \Carbon\Carbon::now()->subWeeks(5)); \App\Wallet::where('balance', '<', '0')->update(['balance' => 0]); $user2 = $this->getTestUser('wallet-charge@kolabnow.com'); $wallet2 = $user2->wallets()->first(); $wallet2->balance = -100; $wallet2->save(); Queue::fake(); // Non-existing wallet ID $this->artisan('wallet:charge')->assertExitCode(0); Queue::assertPushed(\App\Jobs\WalletCheck::class, 2); Queue::assertPushed(\App\Jobs\WalletCheck::class, function ($job) use ($wallet) { $job_wallet = TestCase::getObjectProperty($job, 'wallet'); return $job_wallet->id === $wallet->id; }); Queue::assertPushed(\App\Jobs\WalletCheck::class, function ($job) use ($wallet2) { $job_wallet = TestCase::getObjectProperty($job, 'wallet'); return $job_wallet->id === $wallet2->id; }); Queue::assertPushed(\App\Jobs\WalletCharge::class, 1); Queue::assertPushed(\App\Jobs\WalletCharge::class, function ($job) use ($wallet) { $job_wallet = TestCase::getObjectProperty($job, 'wallet'); return $job_wallet->id === $wallet->id; }); } } diff --git a/src/tests/Feature/Console/Wallet/ExpectedTest.php b/src/tests/Feature/Console/Wallet/ExpectedTest.php new file mode 100644 index 00000000..ebbdba6f --- /dev/null +++ b/src/tests/Feature/Console/Wallet/ExpectedTest.php @@ -0,0 +1,74 @@ +deleteTestUser('wallets-controller@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('wallets-controller@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test command run for a specified wallet + */ + public function testHandle(): void + { + Queue::fake(); + + $user = $this->getTestUser('wallets-controller@kolabnow.com'); + $wallet = $user->wallets()->first(); + + // Non-existing user + $code = \Artisan::call("wallet:expected --user=123"); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + $this->assertSame("User not found.", $output); + + // Expected charges for a specified user + $code = \Artisan::call("wallet:expected --user={$user->id}"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertMatchesRegularExpression( + "|expect charging wallet {$wallet->id} for user {$user->email} with 0|", + $output + ); + + // Test --non-zero argument + $code = \Artisan::call("wallet:expected --user={$user->id} --non-zero"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertTrue(strpos($output, $wallet->id) === false); + + // Expected charges for all wallets + $code = \Artisan::call("wallet:expected"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertMatchesRegularExpression( + "|expect charging wallet {$wallet->id} for user {$user->email} with 0|", + $output + ); + } +} diff --git a/src/tests/Feature/Console/Wallet/GetBalanceTest.php b/src/tests/Feature/Console/Wallet/GetBalanceTest.php new file mode 100644 index 00000000..da8197ea --- /dev/null +++ b/src/tests/Feature/Console/Wallet/GetBalanceTest.php @@ -0,0 +1,57 @@ +deleteTestUser('wallets-controller@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('wallets-controller@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test command run for a specified wallet + */ + public function testHandle(): void + { + Queue::fake(); + + $user = $this->getTestUser('wallets-controller@kolabnow.com'); + $wallet = $user->wallets()->first(); + + // Non-existing wallet + $code = \Artisan::call("wallet:get-balance 123"); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + $this->assertSame("Wallet not found.", $output); + + $wallet->balance = -100; + $wallet->save(); + + // Existing wallet + $code = \Artisan::call("wallet:get-balance {$wallet->id}"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertSame('-100', $output); + } +} diff --git a/src/tests/Feature/Console/Wallet/GetDiscountTest.php b/src/tests/Feature/Console/Wallet/GetDiscountTest.php new file mode 100644 index 00000000..a5ac029a --- /dev/null +++ b/src/tests/Feature/Console/Wallet/GetDiscountTest.php @@ -0,0 +1,65 @@ +deleteTestUser('wallets-controller@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('wallets-controller@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test command run for a specified wallet + */ + public function testHandle(): void + { + Queue::fake(); + + $user = $this->getTestUser('wallets-controller@kolabnow.com'); + $wallet = $user->wallets()->first(); + + // Non-existing wallet + $code = \Artisan::call("wallet:get-discount 123"); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + $this->assertSame("Wallet not found.", $output); + + // No discount + $code = \Artisan::call("wallet:get-discount {$wallet->id}"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertSame("No discount on this wallet.", $output); + + $discount = \App\Discount::withObjectTenantContext($user)->where('discount', 100)->first(); + $wallet->discount()->associate($discount); + $wallet->save(); + + // With discount + $code = \Artisan::call("wallet:get-discount {$wallet->id}"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertSame("100", $output); + } +} diff --git a/src/tests/Feature/Console/Wallet/MandateTest.php b/src/tests/Feature/Console/Wallet/MandateTest.php new file mode 100644 index 00000000..dca2449f --- /dev/null +++ b/src/tests/Feature/Console/Wallet/MandateTest.php @@ -0,0 +1,57 @@ +deleteTestUser('wallets-controller@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('wallets-controller@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test command run for a specified wallet + */ + public function testHandle(): void + { + Queue::fake(); + + $user = $this->getTestUser('wallets-controller@kolabnow.com'); + $wallet = $user->wallets()->first(); + + // Non-existing wallet + $code = \Artisan::call("wallet:mandate 123"); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + $this->assertSame("Wallet not found.", $output); + + // No mandate + $code = \Artisan::call("wallet:mandate {$wallet->id}"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertSame("Auto-payment: none", $output); + + // TODO: Test an existing mandate + $this->markTestIncomplete(); + } +} diff --git a/src/tests/Feature/Console/Wallet/SetBalanceTest.php b/src/tests/Feature/Console/Wallet/SetBalanceTest.php new file mode 100644 index 00000000..e3b7af08 --- /dev/null +++ b/src/tests/Feature/Console/Wallet/SetBalanceTest.php @@ -0,0 +1,56 @@ +deleteTestUser('wallets-controller@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('wallets-controller@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test command run for a specified wallet + */ + public function testHandle(): void + { + Queue::fake(); + + $user = $this->getTestUser('wallets-controller@kolabnow.com'); + $wallet = $user->wallets()->first(); + + // Non-existing wallet + $code = \Artisan::call("wallet:set-balance 123 123"); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + $this->assertSame("Wallet not found.", $output); + + // Existing wallet + // Note: The double-dash trick to make it working with a negative number input + $code = \Artisan::call("wallet:set-balance -- {$wallet->id} -123"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertSame('', $output); + $this->assertSame(-123, $wallet->fresh()->balance); + } +} diff --git a/src/tests/Feature/Console/Wallet/SetDiscountTest.php b/src/tests/Feature/Console/Wallet/SetDiscountTest.php new file mode 100644 index 00000000..ef554f3d --- /dev/null +++ b/src/tests/Feature/Console/Wallet/SetDiscountTest.php @@ -0,0 +1,68 @@ +deleteTestUser('wallets-controller@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('wallets-controller@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test command run for a specified wallet + */ + public function testHandle(): void + { + $user = $this->getTestUser('wallets-controller@kolabnow.com'); + $package = \App\Package::where('title', 'kolab')->first(); + $user->assignPackage($package); + $wallet = $user->wallets()->first(); + $discount = \App\Discount::withObjectTenantContext($user)->where('discount', 100)->first(); + + // Invalid wallet id + $code = \Artisan::call("wallet:set-discount 123 123"); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("Wallet not found.", $output); + + // Invalid discount id + $code = \Artisan::call("wallet:set-discount {$wallet->id} 123"); + $output = trim(\Artisan::output()); + $this->assertSame(1, $code); + $this->assertSame("Discount not found.", $output); + + // Assign a discount + $code = \Artisan::call("wallet:set-discount {$wallet->id} {$discount->id}"); + $output = trim(\Artisan::output()); + $this->assertSame(0, $code); + $this->assertSame("", $output); + $wallet->refresh(); + $this->assertSame($discount->id, $wallet->discount_id); + + // Remove the discount + $code = \Artisan::call("wallet:set-discount {$wallet->id} 0"); + $output = trim(\Artisan::output()); + $this->assertSame(0, $code); + $this->assertSame("", $output); + $wallet->refresh(); + $this->assertNull($wallet->discount_id); + } +} diff --git a/src/tests/Feature/Console/WalletDiscountTest.php b/src/tests/Feature/Console/Wallet/SettingsTest.php similarity index 61% copy from src/tests/Feature/Console/WalletDiscountTest.php copy to src/tests/Feature/Console/Wallet/SettingsTest.php index bb87edfc..7dfb608d 100644 --- a/src/tests/Feature/Console/WalletDiscountTest.php +++ b/src/tests/Feature/Console/Wallet/SettingsTest.php @@ -1,13 +1,13 @@ markTestIncomplete(); } } diff --git a/src/tests/Feature/Console/WalletDiscountTest.php b/src/tests/Feature/Console/Wallet/TransactionsTest.php similarity index 60% rename from src/tests/Feature/Console/WalletDiscountTest.php rename to src/tests/Feature/Console/Wallet/TransactionsTest.php index bb87edfc..a837ed9d 100644 --- a/src/tests/Feature/Console/WalletDiscountTest.php +++ b/src/tests/Feature/Console/Wallet/TransactionsTest.php @@ -1,13 +1,13 @@ markTestIncomplete(); } } diff --git a/src/tests/Feature/Console/Wallet/UntilTest.php b/src/tests/Feature/Console/Wallet/UntilTest.php new file mode 100644 index 00000000..b948d3fc --- /dev/null +++ b/src/tests/Feature/Console/Wallet/UntilTest.php @@ -0,0 +1,68 @@ +deleteTestUser('wallets-controller@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('wallets-controller@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test command run for a specified wallet + */ + public function testHandle(): void + { + Queue::fake(); + + $user = $this->getTestUser('wallets-controller@kolabnow.com'); + $wallet = $user->wallets()->first(); + + // Non-existing wallet + $code = \Artisan::call("wallet:until 123"); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + $this->assertSame("Wallet not found.", $output); + + // Existing wallet + $code = \Artisan::call("wallet:until {$wallet->id}"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertSame("Lasts until: unknown", $output); + + $package = \App\Package::withObjectTenantContext($user)->where('title', 'kolab')->first(); + $user->assignPackage($package); + $wallet->balance = 1000; + $wallet->save(); + + $expected = \now()->addMonths(2)->toDateString(); + + // Existing wallet + $code = \Artisan::call("wallet:until {$wallet->id}"); + $output = trim(\Artisan::output()); + + $this->assertSame(0, $code); + $this->assertSame("Lasts until: $expected", $output); + } +} diff --git a/src/tests/Feature/Console/UserDiscountTest.php b/src/tests/Feature/Console/WalletsTest.php similarity index 79% rename from src/tests/Feature/Console/UserDiscountTest.php rename to src/tests/Feature/Console/WalletsTest.php index 3e99362b..7348fc25 100644 --- a/src/tests/Feature/Console/UserDiscountTest.php +++ b/src/tests/Feature/Console/WalletsTest.php @@ -1,13 +1,13 @@ markTestIncomplete(); } } diff --git a/src/tests/Feature/Controller/Admin/DiscountsTest.php b/src/tests/Feature/Controller/Admin/DiscountsTest.php index dad9e3a6..434b2944 100644 --- a/src/tests/Feature/Controller/Admin/DiscountsTest.php +++ b/src/tests/Feature/Controller/Admin/DiscountsTest.php @@ -1,77 +1,77 @@ getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // Non-admin user $response = $this->actingAs($user)->get("api/v4/users/{$user->id}/discounts"); $response->assertStatus(403); // Admin user $response = $this->actingAs($admin)->get("api/v4/users/{$user->id}/discounts"); $response->assertStatus(200); $json = $response->json(); - $discount_test = Discount::where('code', 'TEST')->first(); - $discount_free = Discount::where('discount', 100)->first(); + $discount_test = Discount::withObjectTenantContext($user)->where('code', 'TEST')->first(); + $discount_free = Discount::withObjectTenantContext($user)->where('discount', 100)->first(); $this->assertSame(3, $json['count']); $this->assertSame($discount_test->id, $json['list'][0]['id']); $this->assertSame($discount_test->discount, $json['list'][0]['discount']); $this->assertSame($discount_test->code, $json['list'][0]['code']); $this->assertSame($discount_test->description, $json['list'][0]['description']); $this->assertSame('10% - Test voucher [TEST]', $json['list'][0]['label']); $this->assertSame($discount_free->id, $json['list'][2]['id']); $this->assertSame($discount_free->discount, $json['list'][2]['discount']); $this->assertSame($discount_free->code, $json['list'][2]['code']); $this->assertSame($discount_free->description, $json['list'][2]['description']); $this->assertSame('100% - Free Account', $json['list'][2]['label']); // A user in another tenant $user = $this->getTestUser('user@sample-tenant.dev-local'); $response = $this->actingAs($admin)->get("api/v4/users/{$user->id}/discounts"); $response->assertStatus(200); $json = $response->json(); $discount = Discount::withObjectTenantContext($user)->where('discount', 10)->first(); $this->assertSame(1, $json['count']); $this->assertSame($discount->id, $json['list'][0]['id']); $this->assertSame($discount->discount, $json['list'][0]['discount']); $this->assertSame($discount->code, $json['list'][0]['code']); $this->assertSame($discount->description, $json['list'][0]['description']); $this->assertSame('10% - ' . $discount->description, $json['list'][0]['label']); } } diff --git a/src/tests/Feature/Controller/Admin/DomainsTest.php b/src/tests/Feature/Controller/Admin/DomainsTest.php index 6437a5c5..cbec81fa 100644 --- a/src/tests/Feature/Controller/Admin/DomainsTest.php +++ b/src/tests/Feature/Controller/Admin/DomainsTest.php @@ -1,223 +1,249 @@ deleteTestDomain('domainscontroller.com'); } /** * {@inheritDoc} */ public function tearDown(): void { + $this->deleteTestUser('test1@domainscontroller.com'); $this->deleteTestDomain('domainscontroller.com'); parent::tearDown(); } /** * Test domains confirming (not implemented) */ public function testConfirm(): void { $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $domain = $this->getTestDomain('kolab.org'); // This end-point does not exist for admins $response = $this->actingAs($admin)->get("api/v4/domains/{$domain->id}/confirm"); $response->assertStatus(404); } + /** + * Test deleting a domain (DELETE /api/v4/domains/) + */ + public function testDestroy(): void + { + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $domain = $this->getTestDomain('kolab.org'); + + // This end-point does not exist for admins + $response = $this->actingAs($admin)->delete("api/v4/domains/{$domain->id}"); + $response->assertStatus(404); + } + /** * Test domains searching (/api/v4/domains) */ public function testIndex(): void { $john = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // Non-admin user $response = $this->actingAs($john)->get("api/v4/domains"); $response->assertStatus(403); // Search with no search criteria $response = $this->actingAs($admin)->get("api/v4/domains"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search with no matches expected $response = $this->actingAs($admin)->get("api/v4/domains?search=abcd12.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search by a domain name $response = $this->actingAs($admin)->get("api/v4/domains?search=kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame('kolab.org', $json['list'][0]['namespace']); // Search by owner $response = $this->actingAs($admin)->get("api/v4/domains?owner={$john->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame('kolab.org', $json['list'][0]['namespace']); // Search by owner (Ned is a controller on John's wallets, // here we expect only domains assigned to Ned's wallet(s)) $ned = $this->getTestUser('ned@kolab.org'); $response = $this->actingAs($admin)->get("api/v4/domains?owner={$ned->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertCount(0, $json['list']); } /** * Test fetching domain info */ public function testShow(): void { $sku_domain = Sku::where('title', 'domain-hosting')->first(); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $user = $this->getTestUser('test1@domainscontroller.com'); $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); Entitlement::create([ 'wallet_id' => $user->wallets()->first()->id, 'sku_id' => $sku_domain->id, 'entitleable_id' => $domain->id, 'entitleable_type' => Domain::class ]); // Only admins can access it $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}"); $response->assertStatus(403); $response = $this->actingAs($admin)->get("api/v4/domains/{$domain->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals($domain->id, $json['id']); $this->assertEquals($domain->namespace, $json['namespace']); $this->assertEquals($domain->status, $json['status']); $this->assertEquals($domain->type, $json['type']); // Note: Other properties are being tested in the user controller tests } /** * Test fetching domain status (GET /api/v4/domains//status) */ public function testStatus(): void { $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $domain = $this->getTestDomain('kolab.org'); // This end-point does not exist for admins $response = $this->actingAs($admin)->get("/api/v4/domains/{$domain->id}/status"); $response->assertStatus(404); } + /** + * Test creeating a domain (POST /api/v4/domains) + */ + public function testStore(): void + { + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + + // Admins can't create domains + $response = $this->actingAs($admin)->post("api/v4/domains", []); + $response->assertStatus(404); + } + /** * Test domain suspending (POST /api/v4/domains//suspend) */ public function testSuspend(): void { Queue::fake(); // disable jobs $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); - $user = $this->getTestUser('test@domainscontroller.com'); + $user = $this->getTestUser('test1@domainscontroller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // Test unauthorized access to admin API $response = $this->actingAs($user)->post("/api/v4/domains/{$domain->id}/suspend", []); $response->assertStatus(403); $this->assertFalse($domain->fresh()->isSuspended()); // Test suspending the user $response = $this->actingAs($admin)->post("/api/v4/domains/{$domain->id}/suspend", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("Domain suspended successfully.", $json['message']); $this->assertCount(2, $json); $this->assertTrue($domain->fresh()->isSuspended()); } /** * Test user un-suspending (POST /api/v4/users//unsuspend) */ public function testUnsuspend(): void { Queue::fake(); // disable jobs $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW | Domain::STATUS_SUSPENDED, 'type' => Domain::TYPE_EXTERNAL, ]); - $user = $this->getTestUser('test@domainscontroller.com'); + $user = $this->getTestUser('test1@domainscontroller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // Test unauthorized access to admin API $response = $this->actingAs($user)->post("/api/v4/domains/{$domain->id}/unsuspend", []); $response->assertStatus(403); $this->assertTrue($domain->fresh()->isSuspended()); // Test suspending the user $response = $this->actingAs($admin)->post("/api/v4/domains/{$domain->id}/unsuspend", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("Domain unsuspended successfully.", $json['message']); $this->assertCount(2, $json); $this->assertFalse($domain->fresh()->isSuspended()); } } diff --git a/src/tests/Feature/Controller/Admin/GroupsTest.php b/src/tests/Feature/Controller/Admin/GroupsTest.php index febdb014..369aa68a 100644 --- a/src/tests/Feature/Controller/Admin/GroupsTest.php +++ b/src/tests/Feature/Controller/Admin/GroupsTest.php @@ -1,225 +1,225 @@ deleteTestGroup('group-test@kolab.org'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestGroup('group-test@kolab.org'); parent::tearDown(); } /** * Test groups searching (/api/v4/groups) */ public function testIndex(): void { $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($user->wallets->first()); // Non-admin user $response = $this->actingAs($user)->get("api/v4/groups"); $response->assertStatus(403); // Search with no search criteria $response = $this->actingAs($admin)->get("api/v4/groups"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search with no matches expected $response = $this->actingAs($admin)->get("api/v4/groups?search=john@kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search by email $response = $this->actingAs($admin)->get("api/v4/groups?search={$group->email}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($group->email, $json['list'][0]['email']); // Search by owner $response = $this->actingAs($admin)->get("api/v4/groups?owner={$user->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($group->email, $json['list'][0]['email']); // Search by owner (Ned is a controller on John's wallets, // here we expect only domains assigned to Ned's wallet(s)) $ned = $this->getTestUser('ned@kolab.org'); $response = $this->actingAs($admin)->get("api/v4/groups?owner={$ned->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertCount(0, $json['list']); } /** * Test fetching group info */ public function testShow(): void { $admin = $this->getTestUser('jeroen@jeroen.jeroen'); - $user = $this->getTestUser('test1@domainscontroller.com'); + $user = $this->getTestUser('john@kolab.org'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($user->wallets->first()); // Only admins can access it $response = $this->actingAs($user)->get("api/v4/groups/{$group->id}"); $response->assertStatus(403); $response = $this->actingAs($admin)->get("api/v4/groups/{$group->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals($group->id, $json['id']); $this->assertEquals($group->email, $json['email']); $this->assertEquals($group->status, $json['status']); } /** * Test fetching domain status (GET /api/v4/domains//status) */ public function testStatus(): void { Queue::fake(); // disable jobs $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($user->wallets->first()); // This end-point does not exist for admins $response = $this->actingAs($admin)->get("/api/v4/groups/{$group->id}/status"); $response->assertStatus(404); } /** * Test group creating (POST /api/v4/groups) */ public function testStore(): void { $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // Test unauthorized access to admin API $response = $this->actingAs($user)->post("/api/v4/groups", []); $response->assertStatus(403); // Admin can't create groups $response = $this->actingAs($admin)->post("/api/v4/groups", []); $response->assertStatus(404); } /** * Test group suspending (POST /api/v4/groups//suspend) */ public function testSuspend(): void { Queue::fake(); // disable jobs $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($user->wallets->first()); // Test unauthorized access to admin API $response = $this->actingAs($user)->post("/api/v4/groups/{$group->id}/suspend", []); $response->assertStatus(403); // Test non-existing group ID $response = $this->actingAs($admin)->post("/api/v4/groups/abc/suspend", []); $response->assertStatus(404); $this->assertFalse($group->fresh()->isSuspended()); // Test suspending the group $response = $this->actingAs($admin)->post("/api/v4/groups/{$group->id}/suspend", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("Distribution list suspended successfully.", $json['message']); $this->assertCount(2, $json); $this->assertTrue($group->fresh()->isSuspended()); } /** * Test user un-suspending (POST /api/v4/users//unsuspend) */ public function testUnsuspend(): void { Queue::fake(); // disable jobs $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($user->wallets->first()); $group->status |= Group::STATUS_SUSPENDED; $group->save(); // Test unauthorized access to admin API $response = $this->actingAs($user)->post("/api/v4/groups/{$group->id}/unsuspend", []); $response->assertStatus(403); // Invalid group ID $response = $this->actingAs($admin)->post("/api/v4/groups/abc/unsuspend", []); $response->assertStatus(404); $this->assertTrue($group->fresh()->isSuspended()); // Test suspending the group $response = $this->actingAs($admin)->post("/api/v4/groups/{$group->id}/unsuspend", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("Distribution list unsuspended successfully.", $json['message']); $this->assertCount(2, $json); $this->assertFalse($group->fresh()->isSuspended()); } } diff --git a/src/tests/Feature/Controller/Admin/SkusTest.php b/src/tests/Feature/Controller/Admin/SkusTest.php index ce3344c2..962d6158 100644 --- a/src/tests/Feature/Controller/Admin/SkusTest.php +++ b/src/tests/Feature/Controller/Admin/SkusTest.php @@ -1,98 +1,124 @@ delete(); $this->clearBetaEntitlements(); $this->clearMeetEntitlements(); } /** * {@inheritDoc} */ public function tearDown(): void { Sku::where('title', 'test')->delete(); $this->clearBetaEntitlements(); $this->clearMeetEntitlements(); parent::tearDown(); } + /** + * Test fetching SKUs list for a domain (GET /domains//skus) + */ + public function testDomainSkus(): void + { + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $user = $this->getTestUser('john@kolab.org'); + $domain = $this->getTestDOmain('kolab.org'); + + // Unauth access not allowed + $response = $this->get("api/v4/domains/{$domain->id}/skus"); + $response->assertStatus(401); + + // Non-admin access not allowed + $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}/skus"); + $response->assertStatus(403); + + $response = $this->actingAs($admin)->get("api/v4/domains/{$domain->id}/skus"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(1, $json); + // Note: Details are tested where we test API\V4\SkusController + } + /** * Test fetching SKUs list */ public function testIndex(): void { $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $user = $this->getTestUser('john@kolab.org'); $sku = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); // Unauth access not allowed $response = $this->get("api/v4/skus"); $response->assertStatus(401); // User access not allowed on admin API $response = $this->actingAs($user)->get("api/v4/skus"); $response->assertStatus(403); $response = $this->actingAs($admin)->get("api/v4/skus"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(9, $json); $this->assertSame(100, $json[0]['prio']); $this->assertSame($sku->id, $json[0]['id']); $this->assertSame($sku->title, $json[0]['title']); $this->assertSame($sku->name, $json[0]['name']); $this->assertSame($sku->description, $json[0]['description']); $this->assertSame($sku->cost, $json[0]['cost']); $this->assertSame($sku->units_free, $json[0]['units_free']); $this->assertSame($sku->period, $json[0]['period']); $this->assertSame($sku->active, $json[0]['active']); $this->assertSame('user', $json[0]['type']); $this->assertSame('mailbox', $json[0]['handler']); } /** * Test fetching SKUs list for a user (GET /users//skus) */ public function testUserSkus(): void { $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $user = $this->getTestUser('john@kolab.org'); // Unauth access not allowed $response = $this->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(401); // Non-admin access not allowed $response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(403); $response = $this->actingAs($admin)->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(200); $json = $response->json(); - $this->assertCount(8, $json); + $this->assertCount(6, $json); // Note: Details are tested where we test API\V4\SkusController } } diff --git a/src/tests/Feature/Controller/Admin/StatsTest.php b/src/tests/Feature/Controller/Admin/StatsTest.php index 14416b51..77d575c4 100644 --- a/src/tests/Feature/Controller/Admin/StatsTest.php +++ b/src/tests/Feature/Controller/Admin/StatsTest.php @@ -1,193 +1,211 @@ update(['discount_id' => null]); } /** * {@inheritDoc} */ public function tearDown(): void { Payment::truncate(); + DB::table('wallets')->update(['discount_id' => null]); parent::tearDown(); } /** * Test charts (GET /api/v4/stats/chart/) */ public function testChart(): void { $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // Non-admin user $response = $this->actingAs($user)->get("api/v4/stats/chart/discounts"); $response->assertStatus(403); // Unknown chart name $response = $this->actingAs($admin)->get("api/v4/stats/chart/unknown"); $response->assertStatus(404); // 'discounts' chart $response = $this->actingAs($admin)->get("api/v4/stats/chart/discounts"); $response->assertStatus(200); $json = $response->json(); $this->assertSame('Discounts', $json['title']); $this->assertSame('donut', $json['type']); $this->assertSame([], $json['data']['labels']); $this->assertSame([['values' => []]], $json['data']['datasets']); // 'income' chart $response = $this->actingAs($admin)->get("api/v4/stats/chart/income"); $response->assertStatus(200); $json = $response->json(); $this->assertSame('Income in CHF - last 8 weeks', $json['title']); $this->assertSame('bar', $json['type']); $this->assertCount(8, $json['data']['labels']); $this->assertSame(date('Y-W'), $json['data']['labels'][7]); $this->assertSame([['values' => [0,0,0,0,0,0,0,0]]], $json['data']['datasets']); // 'users' chart $response = $this->actingAs($admin)->get("api/v4/stats/chart/users"); $response->assertStatus(200); $json = $response->json(); $this->assertSame('Users - last 8 weeks', $json['title']); $this->assertCount(8, $json['data']['labels']); $this->assertSame(date('Y-W'), $json['data']['labels'][7]); $this->assertCount(2, $json['data']['datasets']); $this->assertSame('Created', $json['data']['datasets'][0]['name']); $this->assertSame('Deleted', $json['data']['datasets'][1]['name']); // 'users-all' chart $response = $this->actingAs($admin)->get("api/v4/stats/chart/users-all"); $response->assertStatus(200); $json = $response->json(); $this->assertSame('All Users - last year', $json['title']); $this->assertCount(54, $json['data']['labels']); $this->assertCount(1, $json['data']['datasets']); + + // 'vouchers' chart + $discount = \App\Discount::withObjectTenantContext($user)->where('code', 'TEST')->first(); + $wallet = $user->wallets->first(); + $wallet->discount()->associate($discount); + $wallet->save(); + + $response = $this->actingAs($admin)->get("api/v4/stats/chart/vouchers"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('Vouchers', $json['title']); + $this->assertSame(['TEST'], $json['data']['labels']); + $this->assertSame([['values' => [1]]], $json['data']['datasets']); } /** * Test income chart currency handling */ public function testChartIncomeCurrency(): void { $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $john = $this->getTestUser('john@kolab.org'); $user = $this->getTestUser('test-stats@' . \config('app.domain')); $wallet = $user->wallets()->first(); $wallet->currency = 'EUR'; $wallet->save(); $johns_wallet = $john->wallets()->first(); // Create some test payments Payment::create([ 'id' => 'test1', 'description' => '', 'status' => PaymentProvider::STATUS_PAID, 'amount' => 1000, // EUR 'type' => PaymentProvider::TYPE_ONEOFF, 'wallet_id' => $wallet->id, 'provider' => 'mollie', 'currency' => 'EUR', 'currency_amount' => 1000, ]); Payment::create([ 'id' => 'test2', 'description' => '', 'status' => PaymentProvider::STATUS_PAID, 'amount' => 2000, // EUR 'type' => PaymentProvider::TYPE_RECURRING, 'wallet_id' => $wallet->id, 'provider' => 'mollie', 'currency' => 'EUR', 'currency_amount' => 2000, ]); Payment::create([ 'id' => 'test3', 'description' => '', 'status' => PaymentProvider::STATUS_PAID, 'amount' => 3000, // CHF 'type' => PaymentProvider::TYPE_ONEOFF, 'wallet_id' => $johns_wallet->id, 'provider' => 'mollie', 'currency' => 'EUR', 'currency_amount' => 2800, ]); Payment::create([ 'id' => 'test4', 'description' => '', 'status' => PaymentProvider::STATUS_PAID, 'amount' => 4000, // CHF 'type' => PaymentProvider::TYPE_RECURRING, 'wallet_id' => $johns_wallet->id, 'provider' => 'mollie', 'currency' => 'CHF', 'currency_amount' => 4000, ]); Payment::create([ 'id' => 'test5', 'description' => '', 'status' => PaymentProvider::STATUS_OPEN, 'amount' => 5000, // CHF 'type' => PaymentProvider::TYPE_ONEOFF, 'wallet_id' => $johns_wallet->id, 'provider' => 'mollie', 'currency' => 'CHF', 'currency_amount' => 5000, ]); Payment::create([ 'id' => 'test6', 'description' => '', 'status' => PaymentProvider::STATUS_FAILED, 'amount' => 6000, // CHF 'type' => PaymentProvider::TYPE_ONEOFF, 'wallet_id' => $johns_wallet->id, 'provider' => 'mollie', 'currency' => 'CHF', 'currency_amount' => 6000, ]); // 'income' chart $response = $this->actingAs($admin)->get("api/v4/stats/chart/income"); $response->assertStatus(200); $json = $response->json(); $this->assertSame('Income in CHF - last 8 weeks', $json['title']); $this->assertSame('bar', $json['type']); $this->assertCount(8, $json['data']['labels']); $this->assertSame(date('Y-W'), $json['data']['labels'][7]); // 7000 CHF + 3000 EUR = $expected = 7000 + intval(round(3000 * \App\Utils::exchangeRate('EUR', 'CHF'))); $this->assertCount(1, $json['data']['datasets']); $this->assertSame($expected / 100, $json['data']['datasets'][0]['values'][7]); } } diff --git a/src/tests/Feature/Controller/DomainsTest.php b/src/tests/Feature/Controller/DomainsTest.php index ae8fbc27..bfc0ab10 100644 --- a/src/tests/Feature/Controller/DomainsTest.php +++ b/src/tests/Feature/Controller/DomainsTest.php @@ -1,321 +1,555 @@ deleteTestUser('test1@' . \config('app.domain')); + $this->deleteTestUser('test2@' . \config('app.domain')); $this->deleteTestUser('test1@domainscontroller.com'); $this->deleteTestDomain('domainscontroller.com'); } public function tearDown(): void { + $this->deleteTestUser('test1@' . \config('app.domain')); + $this->deleteTestUser('test2@' . \config('app.domain')); $this->deleteTestUser('test1@domainscontroller.com'); $this->deleteTestDomain('domainscontroller.com'); $domain = $this->getTestDomain('kolab.org'); $domain->settings()->whereIn('key', ['spf_whitelist'])->delete(); parent::tearDown(); } /** * Test domain confirm request */ public function testConfirm(): void { + Queue::fake(); + $sku_domain = Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $user = $this->getTestUser('test1@domainscontroller.com'); $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); Entitlement::create([ 'wallet_id' => $user->wallets()->first()->id, 'sku_id' => $sku_domain->id, 'entitleable_id' => $domain->id, 'entitleable_type' => Domain::class ]); $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}/confirm"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(2, $json); $this->assertEquals('error', $json['status']); $this->assertEquals('Domain ownership verification failed.', $json['message']); $domain->status |= Domain::STATUS_CONFIRMED; $domain->save(); $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}/confirm"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('success', $json['status']); $this->assertEquals('Domain verified successfully.', $json['message']); $this->assertTrue(is_array($json['statusInfo'])); // Not authorized access $response = $this->actingAs($john)->get("api/v4/domains/{$domain->id}/confirm"); $response->assertStatus(403); // Authorized access by additional account controller $domain = $this->getTestDomain('kolab.org'); $response = $this->actingAs($ned)->get("api/v4/domains/{$domain->id}/confirm"); $response->assertStatus(200); } + /** + * Test domain delete request (DELETE /api/v4/domains/) + */ + public function testDestroy(): void + { + Queue::fake(); + + $sku_domain = Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first(); + $john = $this->getTestUser('john@kolab.org'); + $johns_domain = $this->getTestDomain('kolab.org'); + $user1 = $this->getTestUser('test1@' . \config('app.domain')); + $user2 = $this->getTestUser('test2@' . \config('app.domain')); + $domain = $this->getTestDomain('domainscontroller.com', [ + 'status' => Domain::STATUS_NEW, + 'type' => Domain::TYPE_EXTERNAL, + ]); + + Entitlement::create([ + 'wallet_id' => $user1->wallets()->first()->id, + 'sku_id' => $sku_domain->id, + 'entitleable_id' => $domain->id, + 'entitleable_type' => Domain::class + ]); + + // Not authorized access + $response = $this->actingAs($john)->delete("api/v4/domains/{$domain->id}"); + $response->assertStatus(403); + + // Can't delete non-empty domain + $response = $this->actingAs($john)->delete("api/v4/domains/{$johns_domain->id}"); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertCount(2, $json); + $this->assertEquals('error', $json['status']); + $this->assertEquals('Unable to delete a domain with assigned users or other objects.', $json['message']); + + // Successful deletion + $response = $this->actingAs($user1)->delete("api/v4/domains/{$domain->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(2, $json); + $this->assertEquals('success', $json['status']); + $this->assertEquals('Domain deleted successfully.', $json['message']); + $this->assertTrue($domain->fresh()->trashed()); + + // Authorized access by additional account controller + $this->deleteTestDomain('domainscontroller.com'); + $domain = $this->getTestDomain('domainscontroller.com', [ + 'status' => Domain::STATUS_NEW, + 'type' => Domain::TYPE_EXTERNAL, + ]); + + Entitlement::create([ + 'wallet_id' => $user1->wallets()->first()->id, + 'sku_id' => $sku_domain->id, + 'entitleable_id' => $domain->id, + 'entitleable_type' => Domain::class + ]); + + $user1->wallets()->first()->addController($user2); + + $response = $this->actingAs($user2)->delete("api/v4/domains/{$domain->id}"); + $response->assertStatus(200); + + $json = $response->json(); + $this->assertCount(2, $json); + $this->assertEquals('success', $json['status']); + $this->assertEquals('Domain deleted successfully.', $json['message']); + $this->assertTrue($domain->fresh()->trashed()); + } + /** * Test fetching domains list */ public function testIndex(): void { // User with no domains $user = $this->getTestUser('test1@domainscontroller.com'); $response = $this->actingAs($user)->get("api/v4/domains"); $response->assertStatus(200); $json = $response->json(); $this->assertSame([], $json); // User with custom domain(s) $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $response = $this->actingAs($john)->get("api/v4/domains"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(1, $json); $this->assertSame('kolab.org', $json[0]['namespace']); // Values below are tested by Unit tests $this->assertArrayHasKey('isConfirmed', $json[0]); $this->assertArrayHasKey('isDeleted', $json[0]); $this->assertArrayHasKey('isVerified', $json[0]); $this->assertArrayHasKey('isSuspended', $json[0]); $this->assertArrayHasKey('isActive', $json[0]); $this->assertArrayHasKey('isLdapReady', $json[0]); $response = $this->actingAs($ned)->get("api/v4/domains"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(1, $json); $this->assertSame('kolab.org', $json[0]['namespace']); } /** * Test domain config update (POST /api/v4/domains//config) */ public function testSetConfig(): void { $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $domain = $this->getTestDomain('kolab.org'); $domain->setSetting('spf_whitelist', null); // Test unknown domain id $post = ['spf_whitelist' => []]; $response = $this->actingAs($john)->post("/api/v4/domains/123/config", $post); $json = $response->json(); $response->assertStatus(404); // Test access by user not being a wallet controller $post = ['spf_whitelist' => []]; $response = $this->actingAs($jack)->post("/api/v4/domains/{$domain->id}/config", $post); $json = $response->json(); $response->assertStatus(403); $this->assertSame('error', $json['status']); $this->assertSame("Access denied", $json['message']); $this->assertCount(2, $json); // Test some invalid data $post = ['grey' => 1]; $response = $this->actingAs($john)->post("/api/v4/domains/{$domain->id}/config", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertCount(1, $json['errors']); $this->assertSame('The requested configuration parameter is not supported.', $json['errors']['grey']); $this->assertNull($domain->fresh()->getSetting('spf_whitelist')); // Test some valid data $post = ['spf_whitelist' => ['.test.domain.com']]; $response = $this->actingAs($john)->post("/api/v4/domains/{$domain->id}/config", $post); $response->assertStatus(200); $json = $response->json(); $this->assertCount(2, $json); $this->assertSame('success', $json['status']); $this->assertSame('Domain settings updated successfully.', $json['message']); $expected = \json_encode($post['spf_whitelist']); $this->assertSame($expected, $domain->fresh()->getSetting('spf_whitelist')); // Test input validation $post = ['spf_whitelist' => ['aaa']]; $response = $this->actingAs($john)->post("/api/v4/domains/{$domain->id}/config", $post); $response->assertStatus(422); $json = $response->json(); $this->assertCount(2, $json); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame( 'The entry format is invalid. Expected a domain name starting with a dot.', $json['errors']['spf_whitelist'][0] ); $this->assertSame($expected, $domain->fresh()->getSetting('spf_whitelist')); } /** * Test fetching domain info */ public function testShow(): void { $sku_domain = Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $user = $this->getTestUser('test1@domainscontroller.com'); $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); + $discount = \App\Discount::withEnvTenantContext()->where('code', 'TEST')->first(); + $wallet = $user->wallet(); + $wallet->discount()->associate($discount); + $wallet->save(); + Entitlement::create([ 'wallet_id' => $user->wallets()->first()->id, 'sku_id' => $sku_domain->id, 'entitleable_id' => $domain->id, 'entitleable_type' => Domain::class ]); $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals($domain->id, $json['id']); $this->assertEquals($domain->namespace, $json['namespace']); $this->assertEquals($domain->status, $json['status']); $this->assertEquals($domain->type, $json['type']); $this->assertSame($domain->hash(Domain::HASH_TEXT), $json['hash_text']); $this->assertSame($domain->hash(Domain::HASH_CNAME), $json['hash_cname']); $this->assertSame($domain->hash(Domain::HASH_CODE), $json['hash_code']); $this->assertSame([], $json['config']['spf_whitelist']); $this->assertCount(4, $json['mx']); $this->assertTrue(strpos(implode("\n", $json['mx']), $domain->namespace) !== false); $this->assertCount(8, $json['dns']); $this->assertTrue(strpos(implode("\n", $json['dns']), $domain->namespace) !== false); $this->assertTrue(strpos(implode("\n", $json['dns']), $domain->hash()) !== false); $this->assertTrue(is_array($json['statusInfo'])); // Values below are tested by Unit tests $this->assertArrayHasKey('isConfirmed', $json); $this->assertArrayHasKey('isDeleted', $json); $this->assertArrayHasKey('isVerified', $json); $this->assertArrayHasKey('isSuspended', $json); $this->assertArrayHasKey('isActive', $json); $this->assertArrayHasKey('isLdapReady', $json); + $this->assertCount(1, $json['skus']); + $this->assertSame(1, $json['skus'][$sku_domain->id]['count']); + $this->assertSame([0], $json['skus'][$sku_domain->id]['costs']); + $this->assertSame($wallet->id, $json['wallet']['id']); + $this->assertSame($wallet->balance, $json['wallet']['balance']); + $this->assertSame($wallet->currency, $json['wallet']['currency']); + $this->assertSame($discount->discount, $json['wallet']['discount']); + $this->assertSame($discount->description, $json['wallet']['discount_description']); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); // Not authorized - Other account domain $response = $this->actingAs($john)->get("api/v4/domains/{$domain->id}"); $response->assertStatus(403); $domain = $this->getTestDomain('kolab.org'); // Ned is an additional controller on kolab.org's wallet $response = $this->actingAs($ned)->get("api/v4/domains/{$domain->id}"); $response->assertStatus(200); // Jack has no entitlement/control over kolab.org $response = $this->actingAs($jack)->get("api/v4/domains/{$domain->id}"); $response->assertStatus(403); } /** * Test fetching domain status (GET /api/v4/domains//status) * and forcing setup process update (?refresh=1) * * @group dns */ public function testStatus(): void { $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $domain = $this->getTestDomain('kolab.org'); // Test unauthorized access $response = $this->actingAs($jack)->get("/api/v4/domains/{$domain->id}/status"); $response->assertStatus(403); $domain->status = Domain::STATUS_NEW | Domain::STATUS_ACTIVE | Domain::STATUS_LDAP_READY; $domain->save(); // Get domain status $response = $this->actingAs($john)->get("/api/v4/domains/{$domain->id}/status"); $response->assertStatus(200); $json = $response->json(); $this->assertFalse($json['isVerified']); $this->assertFalse($json['isReady']); $this->assertCount(4, $json['process']); $this->assertSame('domain-verified', $json['process'][2]['label']); $this->assertSame(false, $json['process'][2]['state']); $this->assertTrue(empty($json['status'])); $this->assertTrue(empty($json['message'])); // Now "reboot" the process and verify the domain $response = $this->actingAs($john)->get("/api/v4/domains/{$domain->id}/status?refresh=1"); $response->assertStatus(200); $json = $response->json(); $this->assertTrue($json['isVerified']); $this->assertTrue($json['isReady']); $this->assertCount(4, $json['process']); $this->assertSame('domain-verified', $json['process'][2]['label']); $this->assertSame(true, $json['process'][2]['state']); $this->assertSame('domain-confirmed', $json['process'][3]['label']); $this->assertSame(true, $json['process'][3]['state']); $this->assertSame('success', $json['status']); $this->assertSame('Setup process finished successfully.', $json['message']); // TODO: Test completing all process steps } + + /** + * Test domain creation (POST /api/v4/domains) + */ + public function testStore(): void + { + Queue::fake(); + + $jack = $this->getTestUser('jack@kolab.org'); + $john = $this->getTestUser('john@kolab.org'); + + // Test empty request + $response = $this->actingAs($john)->post("/api/v4/domains", []); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertSame("The namespace field is required.", $json['errors']['namespace'][0]); + $this->assertCount(1, $json['errors']); + $this->assertCount(1, $json['errors']['namespace']); + $this->assertCount(2, $json); + + // Test access by user not being a wallet controller + $post = ['namespace' => 'domainscontroller.com']; + $response = $this->actingAs($jack)->post("/api/v4/domains", $post); + $json = $response->json(); + + $response->assertStatus(403); + + $this->assertSame('error', $json['status']); + $this->assertSame("Access denied", $json['message']); + $this->assertCount(2, $json); + + // Test some invalid data + $post = ['namespace' => '--']; + $response = $this->actingAs($john)->post("/api/v4/domains", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(2, $json); + $this->assertSame('The specified domain is invalid.', $json['errors']['namespace'][0]); + $this->assertCount(1, $json['errors']); + $this->assertCount(1, $json['errors']['namespace']); + + // Test an existing domain + $post = ['namespace' => 'kolab.org']; + $response = $this->actingAs($john)->post("/api/v4/domains", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(2, $json); + $this->assertSame('The specified domain is not available.', $json['errors']['namespace']); + + $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); + $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); + + // Missing package + $post = ['namespace' => 'domainscontroller.com']; + $response = $this->actingAs($john)->post("/api/v4/domains", $post); + $json = $response->json(); + + $response->assertStatus(422); + + $this->assertSame('error', $json['status']); + $this->assertSame("Package is required.", $json['errors']['package']); + $this->assertCount(2, $json); + + // Invalid package + $post['package'] = $package_kolab->id; + $response = $this->actingAs($john)->post("/api/v4/domains", $post); + $json = $response->json(); + + $response->assertStatus(422); + + $this->assertSame('error', $json['status']); + $this->assertSame("Invalid package selected.", $json['errors']['package']); + $this->assertCount(2, $json); + + // Test full and valid data + $post['package'] = $package_domain->id; + $response = $this->actingAs($john)->post("/api/v4/domains", $post); + $json = $response->json(); + + $response->assertStatus(200); + + $this->assertSame('success', $json['status']); + $this->assertSame("Domain created successfully.", $json['message']); + $this->assertCount(2, $json); + + $domain = Domain::where('namespace', $post['namespace'])->first(); + $this->assertInstanceOf(Domain::class, $domain); + + // Assert the new domain entitlements + $this->assertEntitlements($domain, ['domain-hosting']); + + // Assert the wallet to which the new domain should be assigned to + $wallet = $domain->wallet(); + $this->assertSame($john->wallets->first()->id, $wallet->id); + + // Test re-creating a domain + $domain->delete(); + + $response = $this->actingAs($john)->post("/api/v4/domains", $post); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertSame("Domain created successfully.", $json['message']); + $this->assertCount(2, $json); + + $domain = Domain::where('namespace', $post['namespace'])->first(); + $this->assertInstanceOf(Domain::class, $domain); + $this->assertEntitlements($domain, ['domain-hosting']); + $wallet = $domain->wallet(); + $this->assertSame($john->wallets->first()->id, $wallet->id); + + // Test creating a domain that is soft-deleted and belongs to another user + $domain->delete(); + $domain->entitlement()->withTrashed()->update(['wallet_id' => $jack->wallets->first()->id]); + + $response = $this->actingAs($john)->post("/api/v4/domains", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(2, $json); + $this->assertSame('The specified domain is not available.', $json['errors']['namespace']); + + // Test acting as account controller (not owner) + + $this->markTestIncomplete(); + } } diff --git a/src/tests/Feature/Controller/Reseller/DomainsTest.php b/src/tests/Feature/Controller/Reseller/DomainsTest.php index 78066c18..626a9014 100644 --- a/src/tests/Feature/Controller/Reseller/DomainsTest.php +++ b/src/tests/Feature/Controller/Reseller/DomainsTest.php @@ -1,286 +1,312 @@ deleteTestDomain('domainscontroller.com'); } /** * {@inheritDoc} */ public function tearDown(): void { + $this->deleteTestUser('test1@domainscontroller.com'); $this->deleteTestDomain('domainscontroller.com'); parent::tearDown(); } /** * Test domain confirm request */ public function testConfirm(): void { $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); // THe end-point exists on the users controller, but not reseller's $response = $this->actingAs($reseller1)->get("api/v4/domains/{$domain->id}/confirm"); $response->assertStatus(404); } + /** + * Test deleting a domain (DELETE /api/v4/domains/) + */ + public function testDestroy(): void + { + $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); + $domain = $this->getTestDomain('kolab.org'); + + // This end-point does not exist for resellers + $response = $this->actingAs($reseller1)->delete("api/v4/domains/{$domain->id}"); + $response->assertStatus(404); + } + /** * Test domains searching (/api/v4/domains) */ public function testIndex(): void { $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); // Non-admin user $response = $this->actingAs($user)->get("api/v4/domains"); $response->assertStatus(403); // Admin user $response = $this->actingAs($admin)->get("api/v4/domains"); $response->assertStatus(403); // Search with no matches expected $response = $this->actingAs($reseller1)->get("api/v4/domains?search=abcd12.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search by a domain name $response = $this->actingAs($reseller1)->get("api/v4/domains?search=kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame('kolab.org', $json['list'][0]['namespace']); // Search by owner $response = $this->actingAs($reseller1)->get("api/v4/domains?owner={$user->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame('kolab.org', $json['list'][0]['namespace']); // Search by owner (Ned is a controller on John's wallets, // here we expect only domains assigned to Ned's wallet(s)) $ned = $this->getTestUser('ned@kolab.org'); $response = $this->actingAs($reseller1)->get("api/v4/domains?owner={$ned->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertCount(0, $json['list']); // Test unauth access to other tenant's domains $response = $this->actingAs($reseller2)->get("api/v4/domains?search=kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); $response = $this->actingAs($reseller2)->get("api/v4/domains?owner={$user->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); } /** * Test fetching domain info */ public function testShow(): void { $sku_domain = Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $user = $this->getTestUser('test1@domainscontroller.com'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); Entitlement::create([ 'wallet_id' => $user->wallets()->first()->id, 'sku_id' => $sku_domain->id, 'entitleable_id' => $domain->id, 'entitleable_type' => Domain::class ]); // Unauthorized access (user) $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}"); $response->assertStatus(403); // Unauthorized access (admin) $response = $this->actingAs($admin)->get("api/v4/domains/{$domain->id}"); $response->assertStatus(403); // Unauthorized access (tenant != env-tenant) $response = $this->actingAs($reseller2)->get("api/v4/domains/{$domain->id}"); $response->assertStatus(404); $response = $this->actingAs($reseller1)->get("api/v4/domains/{$domain->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals($domain->id, $json['id']); $this->assertEquals($domain->namespace, $json['namespace']); $this->assertEquals($domain->status, $json['status']); $this->assertEquals($domain->type, $json['type']); // Note: Other properties are being tested in the user controller tests } /** * Test fetching domain status (GET /api/v4/domains//status) */ public function testStatus(): void { $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $domain = $this->getTestDomain('kolab.org'); // This end-point does not exist for resellers $response = $this->actingAs($reseller1)->get("/api/v4/domains/{$domain->id}/status"); $response->assertStatus(404); } + /** + * Test creeating a domain (POST /api/v4/domains) + */ + public function testStore(): void + { + $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); + + // Resellers can't create domains + $response = $this->actingAs($reseller1)->post("api/v4/domains", []); + $response->assertStatus(404); + } + /** * Test domain suspending (POST /api/v4/domains//suspend) */ public function testSuspend(): void { Queue::fake(); // disable jobs $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); \config(['app.tenant_id' => $reseller2->tenant_id]); $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); - $user = $this->getTestUser('test@domainscontroller.com'); + $user = $this->getTestUser('test1@domainscontroller.com'); // Test unauthorized access to the reseller API (user) $response = $this->actingAs($user)->post("/api/v4/domains/{$domain->id}/suspend", []); $response->assertStatus(403); $this->assertFalse($domain->fresh()->isSuspended()); // Test unauthorized access to the reseller API (admin) $response = $this->actingAs($admin)->post("/api/v4/domains/{$domain->id}/suspend", []); $response->assertStatus(403); $this->assertFalse($domain->fresh()->isSuspended()); // Test unauthorized access to the reseller API (reseller in another tenant) $response = $this->actingAs($reseller1)->post("/api/v4/domains/{$domain->id}/suspend", []); $response->assertStatus(404); $this->assertFalse($domain->fresh()->isSuspended()); // Test suspending the domain $response = $this->actingAs($reseller2)->post("/api/v4/domains/{$domain->id}/suspend", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("Domain suspended successfully.", $json['message']); $this->assertCount(2, $json); $this->assertTrue($domain->fresh()->isSuspended()); } /** * Test user un-suspending (POST /api/v4/users//unsuspend) */ public function testUnsuspend(): void { Queue::fake(); // disable jobs $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); \config(['app.tenant_id' => $reseller2->tenant_id]); $domain = $this->getTestDomain('domainscontroller.com', [ 'status' => Domain::STATUS_NEW | Domain::STATUS_SUSPENDED, 'type' => Domain::TYPE_EXTERNAL, ]); - $user = $this->getTestUser('test@domainscontroller.com'); + $user = $this->getTestUser('test1@domainscontroller.com'); // Test unauthorized access to reseller API (user) $response = $this->actingAs($user)->post("/api/v4/domains/{$domain->id}/unsuspend", []); $response->assertStatus(403); $this->assertTrue($domain->fresh()->isSuspended()); // Test unauthorized access to reseller API (admin) $response = $this->actingAs($admin)->post("/api/v4/domains/{$domain->id}/unsuspend", []); $response->assertStatus(403); $this->assertTrue($domain->fresh()->isSuspended()); // Test unauthorized access to reseller API (another tenant) $response = $this->actingAs($reseller1)->post("/api/v4/domains/{$domain->id}/unsuspend", []); $response->assertStatus(404); $this->assertTrue($domain->fresh()->isSuspended()); // Test suspending the user $response = $this->actingAs($reseller2)->post("/api/v4/domains/{$domain->id}/unsuspend", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("Domain unsuspended successfully.", $json['message']); $this->assertCount(2, $json); $this->assertFalse($domain->fresh()->isSuspended()); } } diff --git a/src/tests/Feature/Controller/Reseller/GroupsTest.php b/src/tests/Feature/Controller/Reseller/GroupsTest.php index 981967f5..22b213c3 100644 --- a/src/tests/Feature/Controller/Reseller/GroupsTest.php +++ b/src/tests/Feature/Controller/Reseller/GroupsTest.php @@ -1,278 +1,278 @@ deleteTestGroup('group-test@kolab.org'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestGroup('group-test@kolab.org'); parent::tearDown(); } /** * Test groups searching (/api/v4/groups) */ public function testIndex(): void { $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($user->wallets->first()); // Non-admin user $response = $this->actingAs($user)->get("api/v4/groups"); $response->assertStatus(403); // Admin user $response = $this->actingAs($admin)->get("api/v4/groups"); $response->assertStatus(403); // Search with no search criteria $response = $this->actingAs($reseller1)->get("api/v4/groups"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search with no matches expected $response = $this->actingAs($reseller1)->get("api/v4/groups?search=john@kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search by email $response = $this->actingAs($reseller1)->get("api/v4/groups?search={$group->email}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($group->email, $json['list'][0]['email']); // Search by owner $response = $this->actingAs($reseller1)->get("api/v4/groups?owner={$user->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($group->email, $json['list'][0]['email']); // Search by owner (Ned is a controller on John's wallets, // here we expect only domains assigned to Ned's wallet(s)) $ned = $this->getTestUser('ned@kolab.org'); $response = $this->actingAs($reseller1)->get("api/v4/groups?owner={$ned->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertCount(0, $json['list']); $response = $this->actingAs($reseller2)->get("api/v4/groups?search=kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); $response = $this->actingAs($reseller2)->get("api/v4/groups?owner={$user->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); } /** * Test fetching group info */ public function testShow(): void { $admin = $this->getTestUser('jeroen@jeroen.jeroen'); - $user = $this->getTestUser('test1@domainscontroller.com'); + $user = $this->getTestUser('john@kolab.org'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($user->wallets->first()); // Only resellers can access it $response = $this->actingAs($user)->get("api/v4/groups/{$group->id}"); $response->assertStatus(403); $response = $this->actingAs($admin)->get("api/v4/groups/{$group->id}"); $response->assertStatus(403); $response = $this->actingAs($reseller2)->get("api/v4/groups/{$group->id}"); $response->assertStatus(404); $response = $this->actingAs($reseller1)->get("api/v4/groups/{$group->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals($group->id, $json['id']); $this->assertEquals($group->email, $json['email']); $this->assertEquals($group->status, $json['status']); } /** * Test fetching group status (GET /api/v4/domains//status) */ public function testStatus(): void { Queue::fake(); // disable jobs $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($user->wallets->first()); // This end-point does not exist for admins $response = $this->actingAs($reseller1)->get("/api/v4/groups/{$group->id}/status"); $response->assertStatus(404); } /** * Test group creating (POST /api/v4/groups) */ public function testStore(): void { $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); // Test unauthorized access to reseller API $response = $this->actingAs($user)->post("/api/v4/groups", []); $response->assertStatus(403); // Reseller or admin can't create groups $response = $this->actingAs($admin)->post("/api/v4/groups", []); $response->assertStatus(403); $response = $this->actingAs($reseller1)->post("/api/v4/groups", []); $response->assertStatus(404); } /** * Test group suspending (POST /api/v4/groups//suspend) */ public function testSuspend(): void { Queue::fake(); // disable jobs $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($user->wallets->first()); // Test unauthorized access to reseller API $response = $this->actingAs($user)->post("/api/v4/groups/{$group->id}/suspend", []); $response->assertStatus(403); // Test unauthorized access to reseller API $response = $this->actingAs($admin)->post("/api/v4/groups/{$group->id}/suspend", []); $response->assertStatus(403); // Test non-existing group ID $response = $this->actingAs($reseller1)->post("/api/v4/groups/abc/suspend", []); $response->assertStatus(404); $this->assertFalse($group->fresh()->isSuspended()); // Test suspending the group $response = $this->actingAs($reseller1)->post("/api/v4/groups/{$group->id}/suspend", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("Distribution list suspended successfully.", $json['message']); $this->assertCount(2, $json); $this->assertTrue($group->fresh()->isSuspended()); $response = $this->actingAs($reseller2)->post("/api/v4/groups/{$group->id}/suspend", []); $response->assertStatus(404); } /** * Test user un-suspending (POST /api/v4/users//unsuspend) */ public function testUnsuspend(): void { Queue::fake(); // disable jobs $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($user->wallets->first()); $group->status |= Group::STATUS_SUSPENDED; $group->save(); // Test unauthorized access to reseller API $response = $this->actingAs($user)->post("/api/v4/groups/{$group->id}/unsuspend", []); $response->assertStatus(403); // Test unauthorized access to reseller API $response = $this->actingAs($admin)->post("/api/v4/groups/{$group->id}/unsuspend", []); $response->assertStatus(403); // Invalid group ID $response = $this->actingAs($reseller1)->post("/api/v4/groups/abc/unsuspend", []); $response->assertStatus(404); $this->assertTrue($group->fresh()->isSuspended()); // Test suspending the group $response = $this->actingAs($reseller1)->post("/api/v4/groups/{$group->id}/unsuspend", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("Distribution list unsuspended successfully.", $json['message']); $this->assertCount(2, $json); $this->assertFalse($group->fresh()->isSuspended()); $response = $this->actingAs($reseller2)->post("/api/v4/groups/{$group->id}/unsuspend", []); $response->assertStatus(404); } } diff --git a/src/tests/Feature/Controller/Reseller/SkusTest.php b/src/tests/Feature/Controller/Reseller/SkusTest.php index f841c64e..392c5d4e 100644 --- a/src/tests/Feature/Controller/Reseller/SkusTest.php +++ b/src/tests/Feature/Controller/Reseller/SkusTest.php @@ -1,136 +1,173 @@ delete(); $this->clearBetaEntitlements(); $this->clearMeetEntitlements(); } /** * {@inheritDoc} */ public function tearDown(): void { Sku::where('title', 'test')->delete(); $this->clearBetaEntitlements(); $this->clearMeetEntitlements(); parent::tearDown(); } + /** + * Test fetching SKUs list for a domain (GET /domains//skus) + */ + public function testDomainSkus(): void + { + $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); + $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $user = $this->getTestUser('john@kolab.org'); + $domain = $this->getTestDomain('kolab.org'); + + // Unauth access not allowed + $response = $this->get("api/v4/domains/{$domain->id}/skus"); + $response->assertStatus(401); + + // User access not allowed + $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}/skus"); + $response->assertStatus(403); + + // Admin access not allowed + $response = $this->actingAs($admin)->get("api/v4/domains/{$domain->id}/skus"); + $response->assertStatus(403); + + // Reseller from another tenant + $response = $this->actingAs($reseller2)->get("api/v4/domains/{$domain->id}/skus"); + $response->assertStatus(404); + + // Reseller access + $response = $this->actingAs($reseller1)->get("api/v4/domains/{$domain->id}/skus"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(1, $json); + // Note: Details are tested where we test API\V4\SkusController + } + /** * Test fetching SKUs list */ public function testIndex(): void { $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $user = $this->getTestUser('john@kolab.org'); $sku = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); // Unauth access not allowed $response = $this->get("api/v4/skus"); $response->assertStatus(401); // User access not allowed $response = $this->actingAs($user)->get("api/v4/skus"); $response->assertStatus(403); // Admin access not allowed $response = $this->actingAs($admin)->get("api/v4/skus"); $response->assertStatus(403); $response = $this->actingAs($reseller1)->get("api/v4/skus"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(9, $json); $this->assertSame(100, $json[0]['prio']); $this->assertSame($sku->id, $json[0]['id']); $this->assertSame($sku->title, $json[0]['title']); $this->assertSame($sku->name, $json[0]['name']); $this->assertSame($sku->description, $json[0]['description']); $this->assertSame($sku->cost, $json[0]['cost']); $this->assertSame($sku->units_free, $json[0]['units_free']); $this->assertSame($sku->period, $json[0]['period']); $this->assertSame($sku->active, $json[0]['active']); $this->assertSame('user', $json[0]['type']); $this->assertSame('mailbox', $json[0]['handler']); // Test with another tenant $sku = Sku::where('title', 'mailbox')->where('tenant_id', $reseller2->tenant_id)->first(); $response = $this->actingAs($reseller2)->get("api/v4/skus"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(6, $json); $this->assertSame(100, $json[0]['prio']); $this->assertSame($sku->id, $json[0]['id']); $this->assertSame($sku->title, $json[0]['title']); $this->assertSame($sku->name, $json[0]['name']); $this->assertSame($sku->description, $json[0]['description']); $this->assertSame($sku->cost, $json[0]['cost']); $this->assertSame($sku->units_free, $json[0]['units_free']); $this->assertSame($sku->period, $json[0]['period']); $this->assertSame($sku->active, $json[0]['active']); $this->assertSame('user', $json[0]['type']); $this->assertSame('mailbox', $json[0]['handler']); } /** * Test fetching SKUs list for a user (GET /users//skus) */ public function testUserSkus(): void { $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $user = $this->getTestUser('john@kolab.org'); // Unauth access not allowed $response = $this->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(401); // User access not allowed $response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(403); // Admin access not allowed $response = $this->actingAs($admin)->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(403); // Reseller from another tenant $response = $this->actingAs($reseller2)->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(404); // Reseller access $response = $this->actingAs($reseller1)->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(200); $json = $response->json(); - $this->assertCount(8, $json); + $this->assertCount(6, $json); // Note: Details are tested where we test API\V4\SkusController } } diff --git a/src/tests/Feature/Controller/Reseller/StatsTest.php b/src/tests/Feature/Controller/Reseller/StatsTest.php index 0dbd9e4b..e4655faf 100644 --- a/src/tests/Feature/Controller/Reseller/StatsTest.php +++ b/src/tests/Feature/Controller/Reseller/StatsTest.php @@ -1,89 +1,109 @@ update(['discount_id' => null]); } /** * {@inheritDoc} */ public function tearDown(): void { + DB::table('wallets')->update(['discount_id' => null]); + parent::tearDown(); } /** * Test charts (GET /api/v4/stats/chart/) */ public function testChart(): void { $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $reseller = $this->getTestUser('reseller@' . \config('app.domain')); // Unauth access $response = $this->get("api/v4/stats/chart/discounts"); $response->assertStatus(401); // Normal user $response = $this->actingAs($user)->get("api/v4/stats/chart/discounts"); $response->assertStatus(403); // Admin user $response = $this->actingAs($admin)->get("api/v4/stats/chart/discounts"); $response->assertStatus(403); // Unknown chart name $response = $this->actingAs($reseller)->get("api/v4/stats/chart/unknown"); $response->assertStatus(404); // 'income' chart $response = $this->actingAs($reseller)->get("api/v4/stats/chart/income"); $response->assertStatus(404); // 'discounts' chart $response = $this->actingAs($reseller)->get("api/v4/stats/chart/discounts"); $response->assertStatus(200); $json = $response->json(); $this->assertSame('Discounts', $json['title']); $this->assertSame('donut', $json['type']); $this->assertSame([], $json['data']['labels']); $this->assertSame([['values' => []]], $json['data']['datasets']); // 'users' chart $response = $this->actingAs($reseller)->get("api/v4/stats/chart/users"); $response->assertStatus(200); $json = $response->json(); $this->assertSame('Users - last 8 weeks', $json['title']); $this->assertCount(8, $json['data']['labels']); $this->assertSame(date('Y-W'), $json['data']['labels'][7]); $this->assertCount(2, $json['data']['datasets']); $this->assertSame('Created', $json['data']['datasets'][0]['name']); $this->assertSame('Deleted', $json['data']['datasets'][1]['name']); // 'users-all' chart $response = $this->actingAs($reseller)->get("api/v4/stats/chart/users-all"); $response->assertStatus(200); $json = $response->json(); $this->assertSame('All Users - last year', $json['title']); $this->assertCount(54, $json['data']['labels']); $this->assertCount(1, $json['data']['datasets']); + + // 'vouchers' chart + $discount = \App\Discount::withObjectTenantContext($user)->where('code', 'TEST')->first(); + $wallet = $user->wallets->first(); + $wallet->discount()->associate($discount); + $wallet->save(); + + $response = $this->actingAs($reseller)->get("api/v4/stats/chart/vouchers"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('Vouchers', $json['title']); + $this->assertSame(['TEST'], $json['data']['labels']); + $this->assertSame([['values' => [1]]], $json['data']['datasets']); } } diff --git a/src/tests/Feature/Controller/SkusTest.php b/src/tests/Feature/Controller/SkusTest.php index 441783a2..1fcb7894 100644 --- a/src/tests/Feature/Controller/SkusTest.php +++ b/src/tests/Feature/Controller/SkusTest.php @@ -1,249 +1,264 @@ clearBetaEntitlements(); $this->clearMeetEntitlements(); Sku::where('title', 'test')->delete(); } /** * {@inheritDoc} */ public function tearDown(): void { $this->clearBetaEntitlements(); $this->clearMeetEntitlements(); Sku::where('title', 'test')->delete(); parent::tearDown(); } + /** + * Test fetching SKUs list for a domain (GET /domains//skus) + */ + public function testDomainSkus(): void + { + $user = $this->getTestUser('john@kolab.org'); + $domain = $this->getTestDomain('kolab.org'); + + // Unauth access not allowed + $response = $this->get("api/v4/domains/{$domain->id}/skus"); + $response->assertStatus(401); + + // Create an sku for another tenant, to make sure it is not included in the result + $nsku = Sku::create([ + 'title' => 'test', + 'name' => 'Test', + 'description' => '', + 'active' => true, + 'cost' => 100, + 'handler_class' => 'App\Handlers\Domain', + ]); + $tenant = Tenant::whereNotIn('id', [\config('app.tenant_id')])->first(); + $nsku->tenant_id = $tenant->id; + $nsku->save(); + + $response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}/skus"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(1, $json); + + $this->assertSkuElement('domain-hosting', $json[0], [ + 'prio' => 0, + 'type' => 'domain', + 'handler' => 'domainhosting', + 'enabled' => false, + 'readonly' => false, + ]); + } /** * Test fetching SKUs list */ public function testIndex(): void { // Unauth access not allowed $response = $this->get("api/v4/skus"); $response->assertStatus(401); $user = $this->getTestUser('john@kolab.org'); $sku = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); // Create an sku for another tenant, to make sure it is not included in the result $nsku = Sku::create([ 'title' => 'test', 'name' => 'Test', 'description' => '', 'active' => true, 'cost' => 100, 'handler_class' => 'App\Handlers\Mailbox', ]); $tenant = Tenant::whereNotIn('id', [\config('app.tenant_id')])->first(); $nsku->tenant_id = $tenant->id; $nsku->save(); $response = $this->actingAs($user)->get("api/v4/skus"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(9, $json); $this->assertSame(100, $json[0]['prio']); $this->assertSame($sku->id, $json[0]['id']); $this->assertSame($sku->title, $json[0]['title']); $this->assertSame($sku->name, $json[0]['name']); $this->assertSame($sku->description, $json[0]['description']); $this->assertSame($sku->cost, $json[0]['cost']); $this->assertSame($sku->units_free, $json[0]['units_free']); $this->assertSame($sku->period, $json[0]['period']); $this->assertSame($sku->active, $json[0]['active']); $this->assertSame('user', $json[0]['type']); $this->assertSame('mailbox', $json[0]['handler']); } /** * Test fetching SKUs list for a user (GET /users//skus) */ public function testUserSkus(): void { $user = $this->getTestUser('john@kolab.org'); // Unauth access not allowed $response = $this->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(401); // Create an sku for another tenant, to make sure it is not included in the result $nsku = Sku::create([ 'title' => 'test', 'name' => 'Test', 'description' => '', 'active' => true, 'cost' => 100, 'handler_class' => 'App\Handlers\Mailbox', ]); $tenant = Tenant::whereNotIn('id', [\config('app.tenant_id')])->first(); $nsku->tenant_id = $tenant->id; $nsku->save(); $response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(200); $json = $response->json(); - $this->assertCount(8, $json); + $this->assertCount(6, $json); $this->assertSkuElement('mailbox', $json[0], [ 'prio' => 100, 'type' => 'user', 'handler' => 'mailbox', 'enabled' => true, 'readonly' => true, ]); $this->assertSkuElement('storage', $json[1], [ 'prio' => 90, 'type' => 'user', 'handler' => 'storage', 'enabled' => true, 'readonly' => true, 'range' => [ 'min' => 5, 'max' => 100, 'unit' => 'GB', ] ]); $this->assertSkuElement('groupware', $json[2], [ 'prio' => 80, 'type' => 'user', 'handler' => 'groupware', 'enabled' => false, 'readonly' => false, ]); $this->assertSkuElement('activesync', $json[3], [ 'prio' => 70, 'type' => 'user', 'handler' => 'activesync', 'enabled' => false, 'readonly' => false, 'required' => ['groupware'], ]); $this->assertSkuElement('2fa', $json[4], [ 'prio' => 60, 'type' => 'user', 'handler' => 'auth2f', 'enabled' => false, 'readonly' => false, 'forbidden' => ['activesync'], ]); $this->assertSkuElement('meet', $json[5], [ 'prio' => 50, 'type' => 'user', 'handler' => 'meet', 'enabled' => false, 'readonly' => false, 'required' => ['groupware'], ]); - $this->assertSkuElement('domain-hosting', $json[6], [ - 'prio' => 0, - 'type' => 'domain', - 'handler' => 'domainhosting', - 'enabled' => false, - 'readonly' => false, - ]); - - $this->assertSkuElement('group', $json[7], [ - 'prio' => 0, - 'type' => 'group', - 'handler' => 'group', - 'enabled' => false, - 'readonly' => false, - ]); - - // Test filter by type - $response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus?type=domain"); - $response->assertStatus(200); - - $json = $response->json(); - - $this->assertCount(1, $json); - $this->assertSame('domain', $json[0]['type']); - // Test inclusion of beta SKUs $sku = Sku::withEnvTenantContext()->where('title', 'beta')->first(); $user->assignSku($sku); - $response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus?type=user"); + $response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(8, $json); - $this->assertSkuElement('meet', $json[5], [ - 'prio' => 50, + $this->assertSkuElement('beta', $json[6], [ + 'prio' => 10, 'type' => 'user', - 'handler' => 'meet', + 'handler' => 'beta', 'enabled' => false, 'readonly' => false, - 'required' => ['groupware'], ]); - $this->assertSkuElement('beta', $json[6], [ + $this->assertSkuElement('distlist', $json[7], [ 'prio' => 10, 'type' => 'user', - 'handler' => 'beta', + 'handler' => 'distlist', 'enabled' => false, 'readonly' => false, + 'required' => ['beta'], ]); } /** * Assert content of the SKU element in an API response * * @param string $sku_title The SKU title * @param array $result The result to assert * @param array $other Other items the SKU itself does not include */ protected function assertSkuElement($sku_title, $result, $other = []): void { $sku = Sku::withEnvTenantContext()->where('title', $sku_title)->first(); $this->assertSame($sku->id, $result['id']); $this->assertSame($sku->title, $result['title']); $this->assertSame($sku->name, $result['name']); $this->assertSame($sku->description, $result['description']); $this->assertSame($sku->cost, $result['cost']); $this->assertSame($sku->units_free, $result['units_free']); $this->assertSame($sku->period, $result['period']); $this->assertSame($sku->active, $result['active']); foreach ($other as $key => $value) { $this->assertSame($value, $result[$key]); } $this->assertCount(8 + count($other), $result); } } diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php index 13ee461a..e00019b6 100644 --- a/src/tests/Feature/Controller/UsersTest.php +++ b/src/tests/Feature/Controller/UsersTest.php @@ -1,1357 +1,1357 @@ deleteTestUser('jane@kolabnow.com'); $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestUser('UsersControllerTest2@userscontroller.com'); $this->deleteTestUser('UsersControllerTest3@userscontroller.com'); $this->deleteTestUser('UserEntitlement2A@UserEntitlement.com'); $this->deleteTestUser('john2.doe2@kolab.org'); $this->deleteTestUser('deleted@kolab.org'); $this->deleteTestUser('deleted@kolabnow.com'); $this->deleteTestDomain('userscontroller.com'); $this->deleteTestGroup('group-test@kolabnow.com'); $this->deleteTestGroup('group-test@kolab.org'); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->discount()->dissociate(); $wallet->settings()->whereIn('key', ['mollie_id', 'stripe_id'])->delete(); $wallet->save(); $user->status |= User::STATUS_IMAP_READY; $user->save(); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('jane@kolabnow.com'); $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestUser('UsersControllerTest2@userscontroller.com'); $this->deleteTestUser('UsersControllerTest3@userscontroller.com'); $this->deleteTestUser('UserEntitlement2A@UserEntitlement.com'); $this->deleteTestUser('john2.doe2@kolab.org'); $this->deleteTestUser('deleted@kolab.org'); $this->deleteTestUser('deleted@kolabnow.com'); $this->deleteTestDomain('userscontroller.com'); $this->deleteTestGroup('group-test@kolabnow.com'); $this->deleteTestGroup('group-test@kolab.org'); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->discount()->dissociate(); $wallet->settings()->whereIn('key', ['mollie_id', 'stripe_id'])->delete(); $wallet->save(); $user->settings()->whereIn('key', ['greylist_enabled'])->delete(); $user->status |= User::STATUS_IMAP_READY; $user->save(); parent::tearDown(); } /** * Test user deleting (DELETE /api/v4/users/) */ public function testDestroy(): void { // First create some users/accounts to delete $package_kolab = \App\Package::where('title', 'kolab')->first(); $package_domain = \App\Package::where('title', 'domain-hosting')->first(); $john = $this->getTestUser('john@kolab.org'); $user1 = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $user2 = $this->getTestUser('UsersControllerTest2@userscontroller.com'); $user3 = $this->getTestUser('UsersControllerTest3@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $user1->assignPackage($package_kolab); $domain->assignPackage($package_domain, $user1); $user1->assignPackage($package_kolab, $user2); $user1->assignPackage($package_kolab, $user3); // Test unauth access $response = $this->delete("api/v4/users/{$user2->id}"); $response->assertStatus(401); // Test access to other user/account $response = $this->actingAs($john)->delete("api/v4/users/{$user2->id}"); $response->assertStatus(403); $response = $this->actingAs($john)->delete("api/v4/users/{$user1->id}"); $response->assertStatus(403); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame("Access denied", $json['message']); $this->assertCount(2, $json); // Test that non-controller cannot remove himself $response = $this->actingAs($user3)->delete("api/v4/users/{$user3->id}"); $response->assertStatus(403); // Test removing a non-controller user $response = $this->actingAs($user1)->delete("api/v4/users/{$user3->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('success', $json['status']); $this->assertEquals('User deleted successfully.', $json['message']); // Test removing self (an account with users) $response = $this->actingAs($user1)->delete("api/v4/users/{$user1->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('success', $json['status']); $this->assertEquals('User deleted successfully.', $json['message']); } /** * Test user deleting (DELETE /api/v4/users/) */ public function testDestroyByController(): void { // Create an account with additional controller - $user2 $package_kolab = \App\Package::where('title', 'kolab')->first(); $package_domain = \App\Package::where('title', 'domain-hosting')->first(); $user1 = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $user2 = $this->getTestUser('UsersControllerTest2@userscontroller.com'); $user3 = $this->getTestUser('UsersControllerTest3@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $user1->assignPackage($package_kolab); $domain->assignPackage($package_domain, $user1); $user1->assignPackage($package_kolab, $user2); $user1->assignPackage($package_kolab, $user3); $user1->wallets()->first()->addController($user2); // TODO/FIXME: // For now controller can delete himself, as well as // the whole account he has control to, including the owner // Probably he should not be able to do none of those // However, this is not 0-regression scenario as we // do not fully support additional controllers. //$response = $this->actingAs($user2)->delete("api/v4/users/{$user2->id}"); //$response->assertStatus(403); $response = $this->actingAs($user2)->delete("api/v4/users/{$user3->id}"); $response->assertStatus(200); $response = $this->actingAs($user2)->delete("api/v4/users/{$user1->id}"); $response->assertStatus(200); // Note: More detailed assertions in testDestroy() above $this->assertTrue($user1->fresh()->trashed()); $this->assertTrue($user2->fresh()->trashed()); $this->assertTrue($user3->fresh()->trashed()); } /** * Test user listing (GET /api/v4/users) */ public function testIndex(): void { // Test unauth access $response = $this->get("api/v4/users"); $response->assertStatus(401); $jack = $this->getTestUser('jack@kolab.org'); $joe = $this->getTestUser('joe@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $response = $this->actingAs($jack)->get("/api/v4/users"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(0, $json); $response = $this->actingAs($john)->get("/api/v4/users"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(4, $json); $this->assertSame($jack->email, $json[0]['email']); $this->assertSame($joe->email, $json[1]['email']); $this->assertSame($john->email, $json[2]['email']); $this->assertSame($ned->email, $json[3]['email']); // Values below are tested by Unit tests $this->assertArrayHasKey('isDeleted', $json[0]); $this->assertArrayHasKey('isSuspended', $json[0]); $this->assertArrayHasKey('isActive', $json[0]); $this->assertArrayHasKey('isLdapReady', $json[0]); $this->assertArrayHasKey('isImapReady', $json[0]); $response = $this->actingAs($ned)->get("/api/v4/users"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(4, $json); $this->assertSame($jack->email, $json[0]['email']); $this->assertSame($joe->email, $json[1]['email']); $this->assertSame($john->email, $json[2]['email']); $this->assertSame($ned->email, $json[3]['email']); } /** * Test fetching user data/profile (GET /api/v4/users/) */ public function testShow(): void { $userA = $this->getTestUser('UserEntitlement2A@UserEntitlement.com'); // Test getting profile of self $response = $this->actingAs($userA)->get("/api/v4/users/{$userA->id}"); $json = $response->json(); $response->assertStatus(200); $this->assertEquals($userA->id, $json['id']); $this->assertEquals($userA->email, $json['email']); $this->assertTrue(is_array($json['statusInfo'])); $this->assertTrue(is_array($json['settings'])); $this->assertTrue(is_array($json['aliases'])); $this->assertTrue($json['config']['greylist_enabled']); $this->assertSame([], $json['skus']); // Values below are tested by Unit tests $this->assertArrayHasKey('isDeleted', $json); $this->assertArrayHasKey('isSuspended', $json); $this->assertArrayHasKey('isActive', $json); $this->assertArrayHasKey('isLdapReady', $json); $this->assertArrayHasKey('isImapReady', $json); $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); // Test unauthorized access to a profile of other user $response = $this->actingAs($jack)->get("/api/v4/users/{$userA->id}"); $response->assertStatus(403); // Test authorized access to a profile of other user // Ned: Additional account controller $response = $this->actingAs($ned)->get("/api/v4/users/{$john->id}"); $response->assertStatus(200); $response = $this->actingAs($ned)->get("/api/v4/users/{$jack->id}"); $response->assertStatus(200); // John: Account owner $response = $this->actingAs($john)->get("/api/v4/users/{$jack->id}"); $response->assertStatus(200); $response = $this->actingAs($john)->get("/api/v4/users/{$ned->id}"); $response->assertStatus(200); $json = $response->json(); $storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first(); $groupware_sku = Sku::withEnvTenantContext()->where('title', 'groupware')->first(); $mailbox_sku = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); $secondfactor_sku = Sku::withEnvTenantContext()->where('title', '2fa')->first(); $this->assertCount(5, $json['skus']); $this->assertSame(5, $json['skus'][$storage_sku->id]['count']); $this->assertSame([0,0,0,0,0], $json['skus'][$storage_sku->id]['costs']); $this->assertSame(1, $json['skus'][$groupware_sku->id]['count']); $this->assertSame([490], $json['skus'][$groupware_sku->id]['costs']); $this->assertSame(1, $json['skus'][$mailbox_sku->id]['count']); $this->assertSame([500], $json['skus'][$mailbox_sku->id]['costs']); $this->assertSame(1, $json['skus'][$secondfactor_sku->id]['count']); $this->assertSame([0], $json['skus'][$secondfactor_sku->id]['costs']); } /** * Test fetching user status (GET /api/v4/users//status) * and forcing setup process update (?refresh=1) * * @group imap * @group dns */ public function testStatus(): void { Queue::fake(); $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); // Test unauthorized access $response = $this->actingAs($jack)->get("/api/v4/users/{$john->id}/status"); $response->assertStatus(403); if ($john->isImapReady()) { $john->status ^= User::STATUS_IMAP_READY; $john->save(); } // Get user status $response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status"); $response->assertStatus(200); $json = $response->json(); $this->assertFalse($json['isImapReady']); $this->assertFalse($json['isReady']); $this->assertCount(7, $json['process']); $this->assertSame('user-imap-ready', $json['process'][2]['label']); $this->assertSame(false, $json['process'][2]['state']); $this->assertTrue(empty($json['status'])); $this->assertTrue(empty($json['message'])); // Make sure the domain is confirmed (other test might unset that status) $domain = $this->getTestDomain('kolab.org'); $domain->status |= Domain::STATUS_CONFIRMED; $domain->save(); // Now "reboot" the process and verify the user in imap synchronously $response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status?refresh=1"); $response->assertStatus(200); $json = $response->json(); $this->assertTrue($json['isImapReady']); $this->assertTrue($json['isReady']); $this->assertCount(7, $json['process']); $this->assertSame('user-imap-ready', $json['process'][2]['label']); $this->assertSame(true, $json['process'][2]['state']); $this->assertSame('success', $json['status']); $this->assertSame('Setup process finished successfully.', $json['message']); Queue::size(1); // Test case for when the verify job is dispatched to the worker $john->refresh(); $john->status ^= User::STATUS_IMAP_READY; $john->save(); \config(['imap.admin_password' => null]); $response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status?refresh=1"); $response->assertStatus(200); $json = $response->json(); $this->assertFalse($json['isImapReady']); $this->assertFalse($json['isReady']); $this->assertSame('success', $json['status']); $this->assertSame('waiting', $json['processState']); $this->assertSame('Setup process has been pushed. Please wait.', $json['message']); Queue::assertPushed(\App\Jobs\User\VerifyJob::class, 1); } /** * Test UsersController::statusInfo() */ public function testStatusInfo(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $user->created_at = Carbon::now(); $user->status = User::STATUS_NEW; $user->save(); $result = UsersController::statusInfo($user); $this->assertFalse($result['isReady']); $this->assertSame([], $result['skus']); $this->assertCount(3, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('user-ldap-ready', $result['process'][1]['label']); $this->assertSame(false, $result['process'][1]['state']); $this->assertSame('user-imap-ready', $result['process'][2]['label']); $this->assertSame(false, $result['process'][2]['state']); $this->assertSame('running', $result['processState']); $user->created_at = Carbon::now()->subSeconds(181); $user->save(); $result = UsersController::statusInfo($user); $this->assertSame('failed', $result['processState']); $user->status |= User::STATUS_LDAP_READY | User::STATUS_IMAP_READY; $user->save(); $result = UsersController::statusInfo($user); $this->assertTrue($result['isReady']); $this->assertCount(3, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('user-ldap-ready', $result['process'][1]['label']); $this->assertSame(true, $result['process'][1]['state']); $this->assertSame('user-imap-ready', $result['process'][2]['label']); $this->assertSame(true, $result['process'][2]['state']); $this->assertSame('done', $result['processState']); $domain->status |= Domain::STATUS_VERIFIED; $domain->type = Domain::TYPE_EXTERNAL; $domain->save(); $result = UsersController::statusInfo($user); $this->assertFalse($result['isReady']); $this->assertSame([], $result['skus']); $this->assertCount(7, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('user-ldap-ready', $result['process'][1]['label']); $this->assertSame(true, $result['process'][1]['state']); $this->assertSame('user-imap-ready', $result['process'][2]['label']); $this->assertSame(true, $result['process'][2]['state']); $this->assertSame('domain-new', $result['process'][3]['label']); $this->assertSame(true, $result['process'][3]['state']); $this->assertSame('domain-ldap-ready', $result['process'][4]['label']); $this->assertSame(false, $result['process'][4]['state']); $this->assertSame('domain-verified', $result['process'][5]['label']); $this->assertSame(true, $result['process'][5]['state']); $this->assertSame('domain-confirmed', $result['process'][6]['label']); $this->assertSame(false, $result['process'][6]['state']); // Test 'skus' property $user->assignSku(Sku::withEnvTenantContext()->where('title', 'beta')->first()); $result = UsersController::statusInfo($user); $this->assertSame(['beta'], $result['skus']); $user->assignSku(Sku::withEnvTenantContext()->where('title', 'meet')->first()); $result = UsersController::statusInfo($user); $this->assertSame(['beta', 'meet'], $result['skus']); $user->assignSku(Sku::withEnvTenantContext()->where('title', 'meet')->first()); $result = UsersController::statusInfo($user); $this->assertSame(['beta', 'meet'], $result['skus']); } /** * Test user config update (POST /api/v4/users//config) */ public function testSetConfig(): void { $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $john->setSetting('greylist_enabled', null); // Test unknown user id $post = ['greylist_enabled' => 1]; $response = $this->actingAs($john)->post("/api/v4/users/123/config", $post); $json = $response->json(); $response->assertStatus(404); // Test access by user not being a wallet controller $post = ['greylist_enabled' => 1]; $response = $this->actingAs($jack)->post("/api/v4/users/{$john->id}/config", $post); $json = $response->json(); $response->assertStatus(403); $this->assertSame('error', $json['status']); $this->assertSame("Access denied", $json['message']); $this->assertCount(2, $json); // Test some invalid data $post = ['grey' => 1]; $response = $this->actingAs($john)->post("/api/v4/users/{$john->id}/config", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertCount(1, $json['errors']); $this->assertSame('The requested configuration parameter is not supported.', $json['errors']['grey']); $this->assertNull($john->fresh()->getSetting('greylist_enabled')); // Test some valid data $post = ['greylist_enabled' => 1]; $response = $this->actingAs($john)->post("/api/v4/users/{$john->id}/config", $post); $response->assertStatus(200); $json = $response->json(); $this->assertCount(2, $json); $this->assertSame('success', $json['status']); $this->assertSame('User settings updated successfully.', $json['message']); $this->assertSame('true', $john->fresh()->getSetting('greylist_enabled')); // Test some valid data $post = ['greylist_enabled' => 0]; $response = $this->actingAs($john)->post("/api/v4/users/{$john->id}/config", $post); $response->assertStatus(200); $json = $response->json(); $this->assertCount(2, $json); $this->assertSame('success', $json['status']); $this->assertSame('User settings updated successfully.', $json['message']); $this->assertSame('false', $john->fresh()->getSetting('greylist_enabled')); } /** * Test user creation (POST /api/v4/users) */ public function testStore(): void { Queue::fake(); $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $deleted_priv = $this->getTestUser('deleted@kolab.org'); $deleted_priv->delete(); // Test empty request $response = $this->actingAs($john)->post("/api/v4/users", []); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame("The email field is required.", $json['errors']['email']); $this->assertSame("The password field is required.", $json['errors']['password'][0]); $this->assertCount(2, $json); // Test access by user not being a wallet controller $post = ['first_name' => 'Test']; $response = $this->actingAs($jack)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(403); $this->assertSame('error', $json['status']); $this->assertSame("Access denied", $json['message']); $this->assertCount(2, $json); // Test some invalid data $post = ['password' => '12345678', 'email' => 'invalid']; $response = $this->actingAs($john)->post("/api/v4/users", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertSame('The password confirmation does not match.', $json['errors']['password'][0]); $this->assertSame('The specified email is invalid.', $json['errors']['email']); // Test existing user email $post = [ 'password' => 'simple', 'password_confirmation' => 'simple', 'first_name' => 'John2', 'last_name' => 'Doe2', 'email' => 'jack.daniels@kolab.org', ]; $response = $this->actingAs($john)->post("/api/v4/users", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertSame('The specified email is not available.', $json['errors']['email']); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $post = [ 'password' => 'simple', 'password_confirmation' => 'simple', 'first_name' => 'John2', 'last_name' => 'Doe2', 'email' => 'john2.doe2@kolab.org', 'organization' => 'TestOrg', 'aliases' => ['useralias1@kolab.org', 'deleted@kolab.org'], ]; // Missing package $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertSame("Package is required.", $json['errors']['package']); $this->assertCount(2, $json); // Invalid package $post['package'] = $package_domain->id; $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertSame("Invalid package selected.", $json['errors']['package']); $this->assertCount(2, $json); // Test full and valid data $post['package'] = $package_kolab->id; $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("User created successfully.", $json['message']); $this->assertCount(2, $json); $user = User::where('email', 'john2.doe2@kolab.org')->first(); $this->assertInstanceOf(User::class, $user); $this->assertSame('John2', $user->getSetting('first_name')); $this->assertSame('Doe2', $user->getSetting('last_name')); $this->assertSame('TestOrg', $user->getSetting('organization')); $aliases = $user->aliases()->orderBy('alias')->get(); $this->assertCount(2, $aliases); $this->assertSame('deleted@kolab.org', $aliases[0]->alias); $this->assertSame('useralias1@kolab.org', $aliases[1]->alias); // Assert the new user entitlements - $this->assertUserEntitlements($user, ['groupware', 'mailbox', + $this->assertEntitlements($user, ['groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage']); // Assert the wallet to which the new user should be assigned to $wallet = $user->wallet(); $this->assertSame($john->wallets()->first()->id, $wallet->id); // Attempt to create a user previously deleted $user->delete(); $post['package'] = $package_kolab->id; $post['aliases'] = []; $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("User created successfully.", $json['message']); $this->assertCount(2, $json); $user = User::where('email', 'john2.doe2@kolab.org')->first(); $this->assertInstanceOf(User::class, $user); $this->assertSame('John2', $user->getSetting('first_name')); $this->assertSame('Doe2', $user->getSetting('last_name')); $this->assertSame('TestOrg', $user->getSetting('organization')); $this->assertCount(0, $user->aliases()->get()); - $this->assertUserEntitlements($user, ['groupware', 'mailbox', + $this->assertEntitlements($user, ['groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage']); // Test acting as account controller (not owner) $this->markTestIncomplete(); } /** * Test user update (PUT /api/v4/users/) */ public function testUpdate(): void { $userA = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $domain = $this->getTestDomain( 'userscontroller.com', ['status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL] ); // Test unauthorized update of other user profile $response = $this->actingAs($jack)->get("/api/v4/users/{$userA->id}", []); $response->assertStatus(403); // Test authorized update of account owner by account controller $response = $this->actingAs($ned)->get("/api/v4/users/{$john->id}", []); $response->assertStatus(200); // Test updating of self (empty request) $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully.", $json['message']); $this->assertTrue(!empty($json['statusInfo'])); $this->assertCount(3, $json); // Test some invalid data $post = ['password' => '12345678', 'currency' => 'invalid']; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertSame('The password confirmation does not match.', $json['errors']['password'][0]); $this->assertSame('The currency must be 3 characters.', $json['errors']['currency'][0]); // Test full profile update including password $post = [ 'password' => 'simple', 'password_confirmation' => 'simple', 'first_name' => 'John2', 'last_name' => 'Doe2', 'organization' => 'TestOrg', 'phone' => '+123 123 123', 'external_email' => 'external@gmail.com', 'billing_address' => 'billing', 'country' => 'CH', 'currency' => 'CHF', 'aliases' => ['useralias1@' . \config('app.domain'), 'useralias2@' . \config('app.domain')] ]; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully.", $json['message']); $this->assertTrue(!empty($json['statusInfo'])); $this->assertCount(3, $json); $this->assertTrue($userA->password != $userA->fresh()->password); unset($post['password'], $post['password_confirmation'], $post['aliases']); foreach ($post as $key => $value) { $this->assertSame($value, $userA->getSetting($key)); } $aliases = $userA->aliases()->orderBy('alias')->get(); $this->assertCount(2, $aliases); $this->assertSame('useralias1@' . \config('app.domain'), $aliases[0]->alias); $this->assertSame('useralias2@' . \config('app.domain'), $aliases[1]->alias); // Test unsetting values $post = [ 'first_name' => '', 'last_name' => '', 'organization' => '', 'phone' => '', 'external_email' => '', 'billing_address' => '', 'country' => '', 'currency' => '', 'aliases' => ['useralias2@' . \config('app.domain')] ]; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully.", $json['message']); $this->assertTrue(!empty($json['statusInfo'])); $this->assertCount(3, $json); unset($post['aliases']); foreach ($post as $key => $value) { $this->assertNull($userA->getSetting($key)); } $aliases = $userA->aliases()->get(); $this->assertCount(1, $aliases); $this->assertSame('useralias2@' . \config('app.domain'), $aliases[0]->alias); // Test error on some invalid aliases missing password confirmation $post = [ 'password' => 'simple123', 'aliases' => [ 'useralias2@' . \config('app.domain'), 'useralias1@kolab.org', '@kolab.org', ] ]; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertCount(2, $json['errors']['aliases']); $this->assertSame("The specified domain is not available.", $json['errors']['aliases'][1]); $this->assertSame("The specified alias is invalid.", $json['errors']['aliases'][2]); $this->assertSame("The password confirmation does not match.", $json['errors']['password'][0]); // Test authorized update of other user $response = $this->actingAs($ned)->put("/api/v4/users/{$jack->id}", []); $response->assertStatus(200); $json = $response->json(); $this->assertTrue(empty($json['statusInfo'])); // TODO: Test error on aliases with invalid/non-existing/other-user's domain // Create entitlements and additional user for following tests $owner = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $user = $this->getTestUser('UsersControllerTest2@userscontroller.com'); $package_domain = Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $package_kolab = Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_lite = Package::withEnvTenantContext()->where('title', 'lite')->first(); $sku_mailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); $sku_storage = Sku::withEnvTenantContext()->where('title', 'storage')->first(); $sku_groupware = Sku::withEnvTenantContext()->where('title', 'groupware')->first(); $domain = $this->getTestDomain( 'userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ] ); $domain->assignPackage($package_domain, $owner); $owner->assignPackage($package_kolab); $owner->assignPackage($package_lite, $user); // Non-controller cannot update his own entitlements $post = ['skus' => []]; $response = $this->actingAs($user)->put("/api/v4/users/{$user->id}", $post); $response->assertStatus(422); // Test updating entitlements $post = [ 'skus' => [ $sku_mailbox->id => 1, $sku_storage->id => 6, $sku_groupware->id => 1, ], ]; $response = $this->actingAs($owner)->put("/api/v4/users/{$user->id}", $post); $response->assertStatus(200); $json = $response->json(); $storage_cost = $user->entitlements() ->where('sku_id', $sku_storage->id) ->orderBy('cost') ->pluck('cost')->all(); - $this->assertUserEntitlements( + $this->assertEntitlements( $user, ['groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage'] ); $this->assertSame([0, 0, 0, 0, 0, 25], $storage_cost); $this->assertTrue(empty($json['statusInfo'])); } /** * Test UsersController::updateEntitlements() */ public function testUpdateEntitlements(): void { $jane = $this->getTestUser('jane@kolabnow.com'); $kolab = Package::withEnvTenantContext()->where('title', 'kolab')->first(); $storage = Sku::withEnvTenantContext()->where('title', 'storage')->first(); $activesync = Sku::withEnvTenantContext()->where('title', 'activesync')->first(); $groupware = Sku::withEnvTenantContext()->where('title', 'groupware')->first(); $mailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); // standard package, 1 mailbox, 1 groupware, 2 storage $jane->assignPackage($kolab); // add 2 storage, 1 activesync $post = [ 'skus' => [ $mailbox->id => 1, $groupware->id => 1, $storage->id => 7, $activesync->id => 1 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(200); - $this->assertUserEntitlements( + $this->assertEntitlements( $jane, [ 'activesync', 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage' ] ); // add 2 storage, remove 1 activesync $post = [ 'skus' => [ $mailbox->id => 1, $groupware->id => 1, $storage->id => 9, $activesync->id => 0 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(200); - $this->assertUserEntitlements( + $this->assertEntitlements( $jane, [ 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage' ] ); // add mailbox $post = [ 'skus' => [ $mailbox->id => 2, $groupware->id => 1, $storage->id => 9, $activesync->id => 0 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(500); - $this->assertUserEntitlements( + $this->assertEntitlements( $jane, [ 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage' ] ); // remove mailbox $post = [ 'skus' => [ $mailbox->id => 0, $groupware->id => 1, $storage->id => 9, $activesync->id => 0 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(500); - $this->assertUserEntitlements( + $this->assertEntitlements( $jane, [ 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage' ] ); // less than free storage $post = [ 'skus' => [ $mailbox->id => 1, $groupware->id => 1, $storage->id => 1, $activesync->id => 0 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(200); - $this->assertUserEntitlements( + $this->assertEntitlements( $jane, [ 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage' ] ); } /** * Test user data response used in show and info actions */ public function testUserResponse(): void { $provider = \config('services.payment_provider') ?: 'mollie'; $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->setSettings(['mollie_id' => null, 'stripe_id' => null]); $result = $this->invokeMethod(new UsersController(), 'userResponse', [$user]); $this->assertEquals($user->id, $result['id']); $this->assertEquals($user->email, $result['email']); $this->assertEquals($user->status, $result['status']); $this->assertTrue(is_array($result['statusInfo'])); $this->assertTrue(is_array($result['aliases'])); $this->assertCount(1, $result['aliases']); $this->assertSame('john.doe@kolab.org', $result['aliases'][0]); $this->assertTrue(is_array($result['settings'])); $this->assertSame('US', $result['settings']['country']); $this->assertSame('USD', $result['settings']['currency']); $this->assertTrue(is_array($result['accounts'])); $this->assertTrue(is_array($result['wallets'])); $this->assertCount(0, $result['accounts']); $this->assertCount(1, $result['wallets']); $this->assertSame($wallet->id, $result['wallet']['id']); $this->assertArrayNotHasKey('discount', $result['wallet']); $this->assertTrue($result['statusInfo']['enableDomains']); $this->assertTrue($result['statusInfo']['enableWallets']); $this->assertTrue($result['statusInfo']['enableUsers']); // Ned is John's wallet controller $ned = $this->getTestUser('ned@kolab.org'); $ned_wallet = $ned->wallets()->first(); $result = $this->invokeMethod(new UsersController(), 'userResponse', [$ned]); $this->assertEquals($ned->id, $result['id']); $this->assertEquals($ned->email, $result['email']); $this->assertTrue(is_array($result['accounts'])); $this->assertTrue(is_array($result['wallets'])); $this->assertCount(1, $result['accounts']); $this->assertCount(1, $result['wallets']); $this->assertSame($wallet->id, $result['wallet']['id']); $this->assertSame($wallet->id, $result['accounts'][0]['id']); $this->assertSame($ned_wallet->id, $result['wallets'][0]['id']); $this->assertSame($provider, $result['wallet']['provider']); $this->assertSame($provider, $result['wallets'][0]['provider']); $this->assertTrue($result['statusInfo']['enableDomains']); $this->assertTrue($result['statusInfo']['enableWallets']); $this->assertTrue($result['statusInfo']['enableUsers']); // Test discount in a response $discount = Discount::where('code', 'TEST')->first(); $wallet->discount()->associate($discount); $wallet->save(); $mod_provider = $provider == 'mollie' ? 'stripe' : 'mollie'; $wallet->setSetting($mod_provider . '_id', 123); $user->refresh(); $result = $this->invokeMethod(new UsersController(), 'userResponse', [$user]); $this->assertEquals($user->id, $result['id']); $this->assertSame($discount->id, $result['wallet']['discount_id']); $this->assertSame($discount->discount, $result['wallet']['discount']); $this->assertSame($discount->description, $result['wallet']['discount_description']); $this->assertSame($mod_provider, $result['wallet']['provider']); $this->assertSame($discount->id, $result['wallets'][0]['discount_id']); $this->assertSame($discount->discount, $result['wallets'][0]['discount']); $this->assertSame($discount->description, $result['wallets'][0]['discount_description']); $this->assertSame($mod_provider, $result['wallets'][0]['provider']); // Jack is not a John's wallet controller $jack = $this->getTestUser('jack@kolab.org'); $result = $this->invokeMethod(new UsersController(), 'userResponse', [$jack]); $this->assertFalse($result['statusInfo']['enableDomains']); $this->assertFalse($result['statusInfo']['enableWallets']); $this->assertFalse($result['statusInfo']['enableUsers']); } /** * List of email address validation cases for testValidateEmail() * * @return array Arguments for testValidateEmail() */ public function dataValidateEmail(): array { $this->refreshApplication(); $public_domains = Domain::getPublicDomains(); $domain = reset($public_domains); $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); return [ // Invalid format ["$domain", $john, 'The specified email is invalid.'], [".@$domain", $john, 'The specified email is invalid.'], ["test123456@localhost", $john, 'The specified domain is invalid.'], ["test123456@unknown-domain.org", $john, 'The specified domain is invalid.'], ["$domain", $john, 'The specified email is invalid.'], [".@$domain", $john, 'The specified email is invalid.'], // forbidden local part on public domains ["admin@$domain", $john, 'The specified email is not available.'], ["administrator@$domain", $john, 'The specified email is not available.'], // forbidden (other user's domain) ["testtest@kolab.org", $user, 'The specified domain is not available.'], // existing alias of other user, to be a user email ["jack.daniels@kolab.org", $john, 'The specified email is not available.'], // valid (user domain) ["admin@kolab.org", $john, null], // valid (public domain) ["test.test@$domain", $john, null], ]; } /** * User email address validation. * * Note: Technically these include unit tests, but let's keep it here for now. * FIXME: Shall we do a http request for each case? * * @dataProvider dataValidateEmail */ public function testValidateEmail($email, $user, $expected_result): void { $result = UsersController::validateEmail($email, $user); $this->assertSame($expected_result, $result); } /** * User email validation - tests for $deleted argument * * Note: Technically these include unit tests, but let's keep it here for now. * FIXME: Shall we do a http request for each case? */ public function testValidateEmailDeleted(): void { Queue::fake(); $john = $this->getTestUser('john@kolab.org'); $deleted_priv = $this->getTestUser('deleted@kolab.org'); $deleted_priv->delete(); $deleted_pub = $this->getTestUser('deleted@kolabnow.com'); $deleted_pub->delete(); $result = UsersController::validateEmail('deleted@kolab.org', $john, $deleted); $this->assertSame(null, $result); $this->assertSame($deleted_priv->id, $deleted->id); $result = UsersController::validateEmail('deleted@kolabnow.com', $john, $deleted); $this->assertSame('The specified email is not available.', $result); $this->assertSame(null, $deleted); $result = UsersController::validateEmail('jack@kolab.org', $john, $deleted); $this->assertSame('The specified email is not available.', $result); $this->assertSame(null, $deleted); } /** * User email validation - tests for an address being a group email address * * Note: Technically these include unit tests, but let's keep it here for now. * FIXME: Shall we do a http request for each case? */ public function testValidateEmailGroup(): void { Queue::fake(); $john = $this->getTestUser('john@kolab.org'); $pub_group = $this->getTestGroup('group-test@kolabnow.com'); $priv_group = $this->getTestGroup('group-test@kolab.org'); // A group in a public domain, existing $result = UsersController::validateEmail($pub_group->email, $john, $deleted); $this->assertSame('The specified email is not available.', $result); $this->assertNull($deleted); $pub_group->delete(); // A group in a public domain, deleted $result = UsersController::validateEmail($pub_group->email, $john, $deleted); $this->assertSame('The specified email is not available.', $result); $this->assertNull($deleted); // A group in a private domain, existing $result = UsersController::validateEmail($priv_group->email, $john, $deleted); $this->assertSame('The specified email is not available.', $result); $this->assertNull($deleted); $priv_group->delete(); // A group in a private domain, deleted $result = UsersController::validateEmail($priv_group->email, $john, $deleted); $this->assertSame(null, $result); $this->assertSame($priv_group->id, $deleted->id); } /** * List of alias validation cases for testValidateAlias() * * @return array Arguments for testValidateAlias() */ public function dataValidateAlias(): array { $this->refreshApplication(); $public_domains = Domain::getPublicDomains(); $domain = reset($public_domains); $john = $this->getTestUser('john@kolab.org'); $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); return [ // Invalid format ["$domain", $john, 'The specified alias is invalid.'], [".@$domain", $john, 'The specified alias is invalid.'], ["test123456@localhost", $john, 'The specified domain is invalid.'], ["test123456@unknown-domain.org", $john, 'The specified domain is invalid.'], ["$domain", $john, 'The specified alias is invalid.'], [".@$domain", $john, 'The specified alias is invalid.'], // forbidden local part on public domains ["admin@$domain", $john, 'The specified alias is not available.'], ["administrator@$domain", $john, 'The specified alias is not available.'], // forbidden (other user's domain) ["testtest@kolab.org", $user, 'The specified domain is not available.'], // existing alias of other user, to be an alias, user in the same group account ["jack.daniels@kolab.org", $john, null], // existing user ["jack@kolab.org", $john, 'The specified alias is not available.'], // valid (user domain) ["admin@kolab.org", $john, null], // valid (public domain) ["test.test@$domain", $john, null], ]; } /** * User email alias validation. * * Note: Technically these include unit tests, but let's keep it here for now. * FIXME: Shall we do a http request for each case? * * @dataProvider dataValidateAlias */ public function testValidateAlias($alias, $user, $expected_result): void { $result = UsersController::validateAlias($alias, $user); $this->assertSame($expected_result, $result); } /** * User alias validation - more cases. * * Note: Technically these include unit tests, but let's keep it here for now. * FIXME: Shall we do a http request for each case? */ public function testValidateAlias2(): void { Queue::fake(); $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $deleted_priv = $this->getTestUser('deleted@kolab.org'); $deleted_priv->setAliases(['deleted-alias@kolab.org']); $deleted_priv->delete(); $deleted_pub = $this->getTestUser('deleted@kolabnow.com'); $deleted_pub->setAliases(['deleted-alias@kolabnow.com']); $deleted_pub->delete(); $group = $this->getTestGroup('group-test@kolabnow.com'); // An alias that was a user email before is allowed, but only for custom domains $result = UsersController::validateAlias('deleted@kolab.org', $john); $this->assertSame(null, $result); $result = UsersController::validateAlias('deleted-alias@kolab.org', $john); $this->assertSame(null, $result); $result = UsersController::validateAlias('deleted@kolabnow.com', $john); $this->assertSame('The specified alias is not available.', $result); $result = UsersController::validateAlias('deleted-alias@kolabnow.com', $john); $this->assertSame('The specified alias is not available.', $result); // A grpoup with the same email address exists $result = UsersController::validateAlias($group->email, $john); $this->assertSame('The specified alias is not available.', $result); } } diff --git a/src/tests/Feature/Controller/WalletsTest.php b/src/tests/Feature/Controller/WalletsTest.php index b03f84e3..72785b3e 100644 --- a/src/tests/Feature/Controller/WalletsTest.php +++ b/src/tests/Feature/Controller/WalletsTest.php @@ -1,356 +1,356 @@ deleteTestUser('wallets-controller@kolabnow.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('wallets-controller@kolabnow.com'); parent::tearDown(); } /** * Test for getWalletNotice() method */ public function testGetWalletNotice(): void { $user = $this->getTestUser('wallets-controller@kolabnow.com'); - $package = \App\Package::where('title', 'kolab')->first(); + $package = \App\Package::withObjectTenantContext($user)->where('title', 'kolab')->first(); $user->assignPackage($package); $wallet = $user->wallets()->first(); $controller = new WalletsController(); $method = new \ReflectionMethod($controller, 'getWalletNotice'); $method->setAccessible(true); // User/entitlements created today, balance=0 $notice = $method->invoke($controller, $wallet); $this->assertSame('You are in your free trial period.', $notice); $wallet->owner->created_at = Carbon::now()->subDays(15); $wallet->owner->save(); $notice = $method->invoke($controller, $wallet); $this->assertSame('Your free trial is about to end, top up to continue.', $notice); // User/entitlements created today, balance=-10 CHF $wallet->balance = -1000; $notice = $method->invoke($controller, $wallet); $this->assertSame('You are out of credit, top up your balance now.', $notice); // User/entitlements created slightly more than a month ago, balance=9,99 CHF (monthly) $wallet->owner->created_at = Carbon::now()->subMonthsWithoutOverflow(1)->subDays(1); $wallet->owner->save(); // test "1 month" $wallet->balance = 990; $notice = $method->invoke($controller, $wallet); $this->assertMatchesRegularExpression('/\((1 month|4 weeks)\)/', $notice); // test "2 months" $wallet->balance = 990 * 2.6; $notice = $method->invoke($controller, $wallet); $this->assertMatchesRegularExpression('/\(2 months 2 weeks\)/', $notice); // Change locale to make sure the text is localized by Carbon \app()->setLocale('de'); // test "almost 2 years" $wallet->balance = 990 * 23.5; $notice = $method->invoke($controller, $wallet); $this->assertMatchesRegularExpression('/\(1 Jahr 11 Monate\)/', $notice); // Old entitlements, 100% discount $this->backdateEntitlements($wallet->entitlements, Carbon::now()->subDays(40)); - $discount = \App\Discount::where('discount', 100)->first(); + $discount = \App\Discount::withObjectTenantContext($user)->where('discount', 100)->first(); $wallet->discount()->associate($discount); $notice = $method->invoke($controller, $wallet->refresh()); $this->assertSame(null, $notice); } /** * Test fetching pdf receipt */ public function testReceiptDownload(): void { $user = $this->getTestUser('wallets-controller@kolabnow.com'); $john = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); // Unauth access not allowed $response = $this->get("api/v4/wallets/{$wallet->id}/receipts/2020-05"); $response->assertStatus(401); $response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/receipts/2020-05"); $response->assertStatus(403); // Invalid receipt id (current month) $receiptId = date('Y-m'); $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts/{$receiptId}"); $response->assertStatus(404); // Invalid receipt id $receiptId = '1000-03'; $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts/{$receiptId}"); $response->assertStatus(404); // Valid receipt id $year = intval(date('Y')) - 1; $receiptId = "$year-12"; $filename = \config('app.name') . " Receipt for $year-12"; $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts/{$receiptId}"); $response->assertStatus(200); $response->assertHeader('content-type', 'application/pdf'); $response->assertHeader('content-disposition', 'attachment; filename="' . $filename . '"'); $response->assertHeader('content-length'); $length = $response->headers->get('content-length'); $content = $response->content(); $this->assertStringStartsWith("%PDF-1.", $content); $this->assertEquals(strlen($content), $length); } /** * Test fetching list of receipts */ public function testReceipts(): void { $user = $this->getTestUser('wallets-controller@kolabnow.com'); $john = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->payments()->delete(); // Unauth access not allowed $response = $this->get("api/v4/wallets/{$wallet->id}/receipts"); $response->assertStatus(401); $response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/receipts"); $response->assertStatus(403); // Empty list expected $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame([], $json['list']); $this->assertSame(1, $json['page']); $this->assertSame(0, $json['count']); $this->assertSame(false, $json['hasMore']); // Insert a payment to the database $date = Carbon::create(intval(date('Y')) - 1, 4, 30); $payment = Payment::create([ 'id' => 'AAA1', 'status' => PaymentProvider::STATUS_PAID, 'type' => PaymentProvider::TYPE_ONEOFF, 'description' => 'Paid in April', 'wallet_id' => $wallet->id, 'provider' => 'stripe', 'amount' => 1111, 'currency' => 'CHF', 'currency_amount' => 1111, ]); $payment->updated_at = $date; $payment->save(); $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame([$date->format('Y-m')], $json['list']); $this->assertSame(1, $json['page']); $this->assertSame(1, $json['count']); $this->assertSame(false, $json['hasMore']); } /** * Test fetching a wallet (GET /api/v4/wallets/:id) */ public function testShow(): void { $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $wallet = $john->wallets()->first(); $wallet->balance = -100; $wallet->save(); // Accessing a wallet of someone else $response = $this->actingAs($jack)->get("api/v4/wallets/{$wallet->id}"); $response->assertStatus(403); // Accessing non-existing wallet $response = $this->actingAs($jack)->get("api/v4/wallets/aaa"); $response->assertStatus(404); // Wallet owner $response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame($wallet->id, $json['id']); $this->assertSame('CHF', $json['currency']); $this->assertSame($wallet->balance, $json['balance']); $this->assertTrue(empty($json['description'])); $this->assertTrue(!empty($json['notice'])); } /** * Test fetching wallet transactions */ public function testTransactions(): void { $package_kolab = \App\Package::where('title', 'kolab')->first(); $user = $this->getTestUser('wallets-controller@kolabnow.com'); $user->assignPackage($package_kolab); $john = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); // Unauth access not allowed $response = $this->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(401); $response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(403); // Expect empty list $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame([], $json['list']); $this->assertSame(1, $json['page']); $this->assertSame(0, $json['count']); $this->assertSame(false, $json['hasMore']); // Create some sample transactions $transactions = $this->createTestTransactions($wallet); $transactions = array_reverse($transactions); $pages = array_chunk($transactions, 10 /* page size*/); // Get the first page $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame(1, $json['page']); $this->assertSame(10, $json['count']); $this->assertSame(true, $json['hasMore']); $this->assertCount(10, $json['list']); foreach ($pages[0] as $idx => $transaction) { $this->assertSame($transaction->id, $json['list'][$idx]['id']); $this->assertSame($transaction->type, $json['list'][$idx]['type']); $this->assertSame(\config('app.currency'), $json['list'][$idx]['currency']); $this->assertSame($transaction->shortDescription(), $json['list'][$idx]['description']); $this->assertFalse($json['list'][$idx]['hasDetails']); $this->assertFalse(array_key_exists('user', $json['list'][$idx])); } $search = null; // Get the second page $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?page=2"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame(2, $json['page']); $this->assertSame(2, $json['count']); $this->assertSame(false, $json['hasMore']); $this->assertCount(2, $json['list']); foreach ($pages[1] as $idx => $transaction) { $this->assertSame($transaction->id, $json['list'][$idx]['id']); $this->assertSame($transaction->type, $json['list'][$idx]['type']); $this->assertSame($transaction->shortDescription(), $json['list'][$idx]['description']); $this->assertSame( $transaction->type == Transaction::WALLET_DEBIT, $json['list'][$idx]['hasDetails'] ); $this->assertFalse(array_key_exists('user', $json['list'][$idx])); if ($transaction->type == Transaction::WALLET_DEBIT) { $search = $transaction->id; } } // Get a non-existing page $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?page=3"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame(3, $json['page']); $this->assertSame(0, $json['count']); $this->assertSame(false, $json['hasMore']); $this->assertCount(0, $json['list']); // Sub-transaction searching $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?transaction=123"); $response->assertStatus(404); $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?transaction={$search}"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame(1, $json['page']); $this->assertSame(2, $json['count']); $this->assertSame(false, $json['hasMore']); $this->assertCount(2, $json['list']); $this->assertSame(Transaction::ENTITLEMENT_BILLED, $json['list'][0]['type']); $this->assertSame(Transaction::ENTITLEMENT_BILLED, $json['list'][1]['type']); // Test that John gets 404 if he tries to access // someone else's transaction ID on his wallet's endpoint $wallet = $john->wallets()->first(); $response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/transactions?transaction={$search}"); $response->assertStatus(404); } } diff --git a/src/tests/Feature/DomainTest.php b/src/tests/Feature/DomainTest.php index 5ffa4a7d..7f71fb62 100644 --- a/src/tests/Feature/DomainTest.php +++ b/src/tests/Feature/DomainTest.php @@ -1,308 +1,336 @@ domains as $domain) { $this->deleteTestDomain($domain); } $this->deleteTestUser('user@gmail.com'); } /** * {@inheritDoc} */ public function tearDown(): void { foreach ($this->domains as $domain) { $this->deleteTestDomain($domain); } $this->deleteTestUser('user@gmail.com'); parent::tearDown(); } /** * Test domain create/creating observer */ public function testCreate(): void { Queue::fake(); $domain = Domain::create([ 'namespace' => 'GMAIL.COM', 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); $result = Domain::where('namespace', 'gmail.com')->first(); $this->assertSame('gmail.com', $result->namespace); $this->assertSame($domain->id, $result->id); $this->assertSame($domain->type, $result->type); $this->assertSame(Domain::STATUS_NEW, $result->status); } /** * Test domain creating jobs */ public function testCreateJobs(): void { // Fake the queue, assert that no jobs were pushed... Queue::fake(); Queue::assertNothingPushed(); $domain = Domain::create([ 'namespace' => 'gmail.com', 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); Queue::assertPushed(\App\Jobs\Domain\CreateJob::class, 1); Queue::assertPushed( \App\Jobs\Domain\CreateJob::class, function ($job) use ($domain) { $domainId = TestCase::getObjectProperty($job, 'domainId'); $domainNamespace = TestCase::getObjectProperty($job, 'domainNamespace'); return $domainId === $domain->id && $domainNamespace === $domain->namespace; } ); $job = new \App\Jobs\Domain\CreateJob($domain->id); $job->handle(); } /** * Tests getPublicDomains() method */ public function testGetPublicDomains(): void { $public_domains = Domain::getPublicDomains(); $this->assertNotContains('public-active.com', $public_domains); $queue = Queue::fake(); $domain = Domain::create([ 'namespace' => 'public-active.com', 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); // External domains should not be returned $public_domains = Domain::getPublicDomains(); $this->assertNotContains('public-active.com', $public_domains); $domain->type = Domain::TYPE_PUBLIC; $domain->save(); $public_domains = Domain::getPublicDomains(); $this->assertContains('public-active.com', $public_domains); // Domains of other tenants should not be returned $tenant = Tenant::whereNotIn('id', [\config('app.tenant_id')])->first(); $domain->tenant_id = $tenant->id; $domain->save(); $public_domains = Domain::getPublicDomains(); $this->assertNotContains('public-active.com', $public_domains); } /** * Test domain (ownership) confirmation * * @group dns */ public function testConfirm(): void { /* DNS records for positive and negative tests - kolab.org: ci-success-cname A 212.103.80.148 ci-success-cname MX 10 mx01.kolabnow.com. ci-success-cname TXT "v=spf1 mx -all" kolab-verify.ci-success-cname CNAME 2b719cfa4e1033b1e1e132977ed4fe3e.ci-success-cname ci-failure-cname A 212.103.80.148 ci-failure-cname MX 10 mx01.kolabnow.com. kolab-verify.ci-failure-cname CNAME 2b719cfa4e1033b1e1e132977ed4fe3e.ci-failure-cname ci-success-txt A 212.103.80.148 ci-success-txt MX 10 mx01.kolabnow.com. ci-success-txt TXT "v=spf1 mx -all" ci-success-txt TXT "kolab-verify=de5d04ababb52d52e2519a2f16d11422" ci-failure-txt A 212.103.80.148 ci-failure-txt MX 10 mx01.kolabnow.com. kolab-verify.ci-failure-txt TXT "kolab-verify=de5d04ababb52d52e2519a2f16d11422" ci-failure-none A 212.103.80.148 ci-failure-none MX 10 mx01.kolabnow.com. */ $queue = Queue::fake(); $domain_props = ['status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL]; $domain = $this->getTestDomain('ci-failure-none.kolab.org', $domain_props); $this->assertTrue($domain->confirm() === false); $this->assertFalse($domain->isConfirmed()); $domain = $this->getTestDomain('ci-failure-txt.kolab.org', $domain_props); $this->assertTrue($domain->confirm() === false); $this->assertFalse($domain->isConfirmed()); $domain = $this->getTestDomain('ci-failure-cname.kolab.org', $domain_props); $this->assertTrue($domain->confirm() === false); $this->assertFalse($domain->isConfirmed()); $domain = $this->getTestDomain('ci-success-txt.kolab.org', $domain_props); $this->assertTrue($domain->confirm()); $this->assertTrue($domain->isConfirmed()); $domain = $this->getTestDomain('ci-success-cname.kolab.org', $domain_props); $this->assertTrue($domain->confirm()); $this->assertTrue($domain->isConfirmed()); } /** * Test domain deletion */ public function testDelete(): void { Queue::fake(); $domain = $this->getTestDomain('gmail.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $domain->delete(); $this->assertTrue($domain->fresh()->trashed()); $this->assertFalse($domain->fresh()->isDeleted()); // Delete the domain for real $job = new \App\Jobs\Domain\DeleteJob($domain->id); $job->handle(); $this->assertTrue(Domain::withTrashed()->where('id', $domain->id)->first()->isDeleted()); $domain->forceDelete(); $this->assertCount(0, Domain::withTrashed()->where('id', $domain->id)->get()); } + /** + * Test isEmpty() method + */ + public function testIsEmpty(): void + { + Queue::fake(); + + // Empty domain + $domain = $this->getTestDomain('gmail.com', [ + 'status' => Domain::STATUS_NEW, + 'type' => Domain::TYPE_EXTERNAL, + ]); + + $this->assertTrue($domain->isEmpty()); + + // TODO: Test with adding a group/alias/user, each separately + + // Empty public domain + $domain = Domain::where('namespace', 'libertymail.net')->first(); + + $this->assertFalse($domain->isEmpty()); + + // Non-empty private domain + $domain = Domain::where('namespace', 'kolab.org')->first(); + + $this->assertFalse($domain->isEmpty()); + } + /** * Test domain restoring */ public function testRestore(): void { Queue::fake(); $domain = $this->getTestDomain('gmail.com', [ 'status' => Domain::STATUS_NEW | Domain::STATUS_SUSPENDED | Domain::STATUS_LDAP_READY | Domain::STATUS_CONFIRMED, 'type' => Domain::TYPE_PUBLIC, ]); $user = $this->getTestUser('user@gmail.com'); $sku = \App\Sku::where('title', 'domain-hosting')->first(); $now = \Carbon\Carbon::now(); // Assign two entitlements to the domain, so we can assert that only the // ones deleted last will be restored $ent1 = \App\Entitlement::create([ 'wallet_id' => $user->wallets->first()->id, 'sku_id' => $sku->id, 'cost' => 0, 'entitleable_id' => $domain->id, 'entitleable_type' => Domain::class, ]); $ent2 = \App\Entitlement::create([ 'wallet_id' => $user->wallets->first()->id, 'sku_id' => $sku->id, 'cost' => 0, 'entitleable_id' => $domain->id, 'entitleable_type' => Domain::class, ]); $domain->delete(); $this->assertTrue($domain->fresh()->trashed()); $this->assertFalse($domain->fresh()->isDeleted()); $this->assertTrue($ent1->fresh()->trashed()); $this->assertTrue($ent2->fresh()->trashed()); // Backdate some properties \App\Entitlement::withTrashed()->where('id', $ent2->id)->update(['deleted_at' => $now->subMinutes(2)]); \App\Entitlement::withTrashed()->where('id', $ent1->id)->update(['updated_at' => $now->subMinutes(10)]); Queue::fake(); $domain->restore(); $domain->refresh(); $this->assertFalse($domain->trashed()); $this->assertFalse($domain->isDeleted()); $this->assertFalse($domain->isSuspended()); $this->assertFalse($domain->isLdapReady()); $this->assertTrue($domain->isActive()); $this->assertTrue($domain->isConfirmed()); // Assert entitlements $this->assertTrue($ent2->fresh()->trashed()); $this->assertFalse($ent1->fresh()->trashed()); $this->assertTrue($ent1->updated_at->greaterThan(\Carbon\Carbon::now()->subSeconds(5))); // We expect only one CreateJob and one UpdateJob // Because how Illuminate/Database/Eloquent/SoftDeletes::restore() method // is implemented we cannot skip the UpdateJob in any way. // I don't want to overwrite this method, the extra job shouldn't do any harm. $this->assertCount(2, Queue::pushedJobs()); // @phpstan-ignore-line Queue::assertPushed(\App\Jobs\Domain\UpdateJob::class, 1); Queue::assertPushed(\App\Jobs\Domain\CreateJob::class, 1); Queue::assertPushed( \App\Jobs\Domain\CreateJob::class, function ($job) use ($domain) { return $domain->id === TestCase::getObjectProperty($job, 'domainId'); } ); } } diff --git a/src/tests/Feature/Jobs/PGP/KeyCreateTest.php b/src/tests/Feature/Jobs/PGP/KeyCreateTest.php index 2f3e6c86..2fcdda96 100644 --- a/src/tests/Feature/Jobs/PGP/KeyCreateTest.php +++ b/src/tests/Feature/Jobs/PGP/KeyCreateTest.php @@ -1,123 +1,141 @@ getTestUser('john@kolab.org'); UserAlias::where('alias', 'test-alias@kolab.org')->delete(); PGP::homedirCleanup($user); + \App\PowerDNS\Domain::where('name', '_woat.kolab.org')->delete(); } /** * {@inheritDoc} */ public function tearDown(): void { $user = $this->getTestUser('john@kolab.org'); UserAlias::where('alias', 'test-alias@kolab.org')->delete(); PGP::homedirCleanup($user); + \App\PowerDNS\Domain::where('name', '_woat.kolab.org')->delete(); parent::tearDown(); } /** * Test job handle * * @group pgp */ public function testHandle(): void { $user = $this->getTestUser('john@kolab.org'); $job = new \App\Jobs\PGP\KeyCreateJob($user->id, $user->email); $job->handle(); // Assert the Enigma storage has been initialized and contains the key $files = Roundcube::enigmaList($user->email); // TODO: More detailed asserts on the filestore content, but it's specific to GPG version $this->assertTrue(count($files) > 1); // Assert the created keypair parameters $keys = PGP::listKeys($user); $this->assertCount(1, $keys); $userIds = $keys[0]->getUserIds(); $this->assertCount(1, $userIds); $this->assertSame($user->email, $userIds[0]->getEmail()); $this->assertSame('', $userIds[0]->getName()); $this->assertSame('', $userIds[0]->getComment()); $this->assertSame(true, $userIds[0]->isValid()); $this->assertSame(false, $userIds[0]->isRevoked()); $key = $keys[0]->getPrimaryKey(); $this->assertSame(\Crypt_GPG_SubKey::ALGORITHM_RSA, $key->getAlgorithm()); $this->assertSame(0, $key->getExpirationDate()); $this->assertSame((int) \config('pgp.length'), $key->getLength()); $this->assertSame(true, $key->hasPrivate()); $this->assertSame(true, $key->canSign()); $this->assertSame(false, $key->canEncrypt()); $this->assertSame(false, $key->isRevoked()); $key = $keys[0]->getSubKeys()[1]; $this->assertSame(\Crypt_GPG_SubKey::ALGORITHM_RSA, $key->getAlgorithm()); $this->assertSame(0, $key->getExpirationDate()); $this->assertSame((int) \config('pgp.length'), $key->getLength()); $this->assertSame(false, $key->canSign()); $this->assertSame(true, $key->canEncrypt()); $this->assertSame(false, $key->isRevoked()); - // TODO: Assert the public key in DNS? + // Assert the public key in DNS + $dns_domain = \App\PowerDNS\Domain::where('name', '_woat.kolab.org')->first(); + $this->assertNotNull($dns_domain); + $dns_record = $dns_domain->records()->where('type', 'TXT')->first(); + $this->assertNotNull($dns_record); + $this->assertSame('TXT', $dns_record->type); + $this->assertSame(sha1('john') . '._woat.kolab.org', $dns_record->name); + $this->assertMatchesRegularExpression( + '/^v=woat1,public_key=' + . '-----BEGIN PGP PUBLIC KEY BLOCK-----' + . '[a-zA-Z0-9\n\/+=]+' + . '-----END PGP PUBLIC KEY BLOCK-----' + . '$/', + $dns_record->content + ); // Test an alias Queue::fake(); UserAlias::create(['user_id' => $user->id, 'alias' => 'test-alias@kolab.org']); $job = new \App\Jobs\PGP\KeyCreateJob($user->id, 'test-alias@kolab.org'); $job->handle(); // Assert the created keypair parameters $keys = PGP::listKeys($user); $this->assertCount(2, $keys); $userIds = $keys[1]->getUserIds(); $this->assertCount(1, $userIds); $this->assertSame('test-alias@kolab.org', $userIds[0]->getEmail()); $this->assertSame('', $userIds[0]->getName()); $this->assertSame('', $userIds[0]->getComment()); $this->assertSame(true, $userIds[0]->isValid()); $this->assertSame(false, $userIds[0]->isRevoked()); $key = $keys[1]->getPrimaryKey(); $this->assertSame(\Crypt_GPG_SubKey::ALGORITHM_RSA, $key->getAlgorithm()); $this->assertSame(0, $key->getExpirationDate()); $this->assertSame((int) \config('pgp.length'), $key->getLength()); $this->assertSame(true, $key->hasPrivate()); $this->assertSame(true, $key->canSign()); $this->assertSame(false, $key->canEncrypt()); $this->assertSame(false, $key->isRevoked()); $key = $keys[1]->getSubKeys()[1]; $this->assertSame(\Crypt_GPG_SubKey::ALGORITHM_RSA, $key->getAlgorithm()); $this->assertSame(0, $key->getExpirationDate()); $this->assertSame((int) \config('pgp.length'), $key->getLength()); $this->assertSame(false, $key->canSign()); $this->assertSame(true, $key->canEncrypt()); $this->assertSame(false, $key->isRevoked()); + + $this->assertSame(2, $dns_domain->records()->where('type', 'TXT')->count()); } } diff --git a/src/tests/Feature/Jobs/PGP/KeyDeleteTest.php b/src/tests/Feature/Jobs/PGP/KeyDeleteTest.php new file mode 100644 index 00000000..5b835811 --- /dev/null +++ b/src/tests/Feature/Jobs/PGP/KeyDeleteTest.php @@ -0,0 +1,71 @@ +getTestUser('john@kolab.org'); + UserAlias::where('alias', 'test-alias@kolab.org')->delete(); + PGP::homedirCleanup($user); + \App\PowerDNS\Domain::where('name', '_woat.kolab.org')->delete(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $user = $this->getTestUser('john@kolab.org'); + UserAlias::where('alias', 'test-alias@kolab.org')->delete(); + PGP::homedirCleanup($user); + \App\PowerDNS\Domain::where('name', '_woat.kolab.org')->delete(); + + parent::tearDown(); + } + + /** + * Test job handle + * + * @group pgp + */ + public function testHandle(): void + { + Queue::fake(); + + $user = $this->getTestUser('john@kolab.org'); + + // First run the key create job + $job = new \App\Jobs\PGP\KeyCreateJob($user->id, $user->email); + $job->handle(); + + // Assert the public key in DNS exist at this point + $dns_domain = \App\PowerDNS\Domain::where('name', '_woat.kolab.org')->first(); + $this->assertNotNull($dns_domain); + $this->assertSame(1, $dns_domain->records()->where('type', 'TXT')->count()); + + // Run the job + $job = new \App\Jobs\PGP\KeyDeleteJob($user->id, $user->email); + $job->handle(); + + $this->assertSame(0, $dns_domain->records()->where('type', 'TXT')->count()); + + // Assert the created keypair parameters + $keys = PGP::listKeys($user); + + $this->assertCount(0, $keys); + } +} diff --git a/src/tests/Feature/PowerDNS/DomainTest.php b/src/tests/Feature/PowerDNS/DomainTest.php index 3ae6a9cc..a4663d30 100644 --- a/src/tests/Feature/PowerDNS/DomainTest.php +++ b/src/tests/Feature/PowerDNS/DomainTest.php @@ -1,70 +1,48 @@ domain = Domain::firstOrCreate( - [ - 'name' => 'test-domain.com' - ] - ); + $this->domain = Domain::firstOrCreate(['name' => 'test-domain.com']); } /** * {@inheritDoc} */ public function tearDown(): void { $this->domain->delete(); parent::tearDown(); } + /** + * Test domain record creation (observer) + */ public function testDomainCreate(): void { $this->assertCount(1, $this->domain->records()->where('type', 'SOA')->get()); $this->assertCount(2, $this->domain->records()->where('type', 'NS')->get()); - } - - public function testCreateRecord(): void - { - $before = $this->domain->getSerial(); - - Record::create( - [ - 'domain_id' => $this->domain->id, - 'name' => $this->domain->{'name'}, - 'type' => "MX", - 'content' => '10 mx01.' . $this->domain->{'name'} . '.' - ] - ); - - Record::create( - [ - 'domain_id' => $this->domain->id, - 'name' => 'mx01.' . $this->domain->{'name'}, - 'type' => "A", - 'content' => '127.0.0.1' - ] - ); + $this->assertCount(2, $this->domain->records()->where('type', 'A')->get()); + $this->assertCount(5, $this->domain->records()->get()); - $after = $this->domain->getSerial(); + $this->assertCount(1, $this->domain->settings()->get()); - $this->assertTrue($before < $after); + // TODO: Test content of every domain record/setting } } diff --git a/src/tests/Feature/PowerDNS/RecordTest.php b/src/tests/Feature/PowerDNS/RecordTest.php new file mode 100644 index 00000000..58dacb1e --- /dev/null +++ b/src/tests/Feature/PowerDNS/RecordTest.php @@ -0,0 +1,95 @@ +domain = Domain::firstOrCreate(['name' => 'test-domain.com']); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->domain->delete(); + + parent::tearDown(); + } + + /** + * Test creating DNS records + */ + public function testCreateRecord(): void + { + $before = $this->domain->getSerial(); + + Record::create([ + 'domain_id' => $this->domain->id, + 'name' => $this->domain->{'name'}, + 'type' => "MX", + 'content' => '10 mx01.' . $this->domain->{'name'} . '.' + ]); + + $after = $this->domain->getSerial(); + + $this->assertTrue($before < $after); + } + + /** + * Test updating DNS records + */ + public function testUpdateRecord(): void + { + $record = Record::create([ + 'domain_id' => $this->domain->id, + 'name' => $this->domain->{'name'}, + 'type' => "MX", + 'content' => '10 mx01.' . $this->domain->{'name'} . '.' + ]); + + $before = $this->domain->getSerial(); + + $record->content = 'test'; + $record->save(); + + $after = $this->domain->getSerial(); + + $this->assertTrue($before < $after); + } + + /** + * Test deleting DNS records + */ + public function testDeleteRecord(): void + { + $record = Record::create([ + 'domain_id' => $this->domain->id, + 'name' => $this->domain->{'name'}, + 'type' => "MX", + 'content' => '10 mx01.' . $this->domain->{'name'} . '.' + ]); + + $before = $this->domain->getSerial(); + + $record->delete(); + + $after = $this->domain->getSerial(); + + $this->assertTrue($before < $after); + } +} diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php index eaa61f92..271b4d11 100644 --- a/src/tests/Feature/UserTest.php +++ b/src/tests/Feature/UserTest.php @@ -1,970 +1,1012 @@ deleteTestUser('user-test@' . \config('app.domain')); $this->deleteTestUser('UserAccountA@UserAccount.com'); $this->deleteTestUser('UserAccountB@UserAccount.com'); $this->deleteTestUser('UserAccountC@UserAccount.com'); $this->deleteTestGroup('test-group@UserAccount.com'); $this->deleteTestDomain('UserAccount.com'); $this->deleteTestDomain('UserAccountAdd.com'); } public function tearDown(): void { \App\TenantSetting::truncate(); $this->deleteTestUser('user-test@' . \config('app.domain')); $this->deleteTestUser('UserAccountA@UserAccount.com'); $this->deleteTestUser('UserAccountB@UserAccount.com'); $this->deleteTestUser('UserAccountC@UserAccount.com'); $this->deleteTestGroup('test-group@UserAccount.com'); $this->deleteTestDomain('UserAccount.com'); $this->deleteTestDomain('UserAccountAdd.com'); parent::tearDown(); } /** * Tests for User::assignPackage() */ public function testAssignPackage(): void { $this->markTestIncomplete(); } /** * Tests for User::assignPlan() */ public function testAssignPlan(): void { $this->markTestIncomplete(); } /** * Tests for User::assignSku() */ public function testAssignSku(): void { $this->markTestIncomplete(); } /** * Verify a wallet assigned a controller is among the accounts of the assignee. */ public function testAccounts(): void { $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $this->assertTrue($userA->wallets()->count() == 1); $userA->wallets()->each( function ($wallet) use ($userB) { $wallet->addController($userB); } ); $this->assertTrue($userB->accounts()->get()[0]->id === $userA->wallets()->get()[0]->id); } public function testCanDelete(): void { $this->markTestIncomplete(); } /** * Test User::canRead() method */ public function testCanRead(): void { $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $domain = $this->getTestDomain('kolab.org'); // Admin $this->assertTrue($admin->canRead($admin)); $this->assertTrue($admin->canRead($john)); $this->assertTrue($admin->canRead($jack)); $this->assertTrue($admin->canRead($reseller1)); $this->assertTrue($admin->canRead($reseller2)); $this->assertTrue($admin->canRead($domain)); $this->assertTrue($admin->canRead($domain->wallet())); // Reseller - kolabnow $this->assertTrue($reseller1->canRead($john)); $this->assertTrue($reseller1->canRead($jack)); $this->assertTrue($reseller1->canRead($reseller1)); $this->assertTrue($reseller1->canRead($domain)); $this->assertTrue($reseller1->canRead($domain->wallet())); $this->assertFalse($reseller1->canRead($reseller2)); $this->assertFalse($reseller1->canRead($admin)); // Reseller - different tenant $this->assertTrue($reseller2->canRead($reseller2)); $this->assertFalse($reseller2->canRead($john)); $this->assertFalse($reseller2->canRead($jack)); $this->assertFalse($reseller2->canRead($reseller1)); $this->assertFalse($reseller2->canRead($domain)); $this->assertFalse($reseller2->canRead($domain->wallet())); $this->assertFalse($reseller2->canRead($admin)); // Normal user - account owner $this->assertTrue($john->canRead($john)); $this->assertTrue($john->canRead($ned)); $this->assertTrue($john->canRead($jack)); $this->assertTrue($john->canRead($domain)); $this->assertTrue($john->canRead($domain->wallet())); $this->assertFalse($john->canRead($reseller1)); $this->assertFalse($john->canRead($reseller2)); $this->assertFalse($john->canRead($admin)); // Normal user - a non-owner and non-controller $this->assertTrue($jack->canRead($jack)); $this->assertFalse($jack->canRead($john)); $this->assertFalse($jack->canRead($domain)); $this->assertFalse($jack->canRead($domain->wallet())); $this->assertFalse($jack->canRead($reseller1)); $this->assertFalse($jack->canRead($reseller2)); $this->assertFalse($jack->canRead($admin)); // Normal user - John's wallet controller $this->assertTrue($ned->canRead($ned)); $this->assertTrue($ned->canRead($john)); $this->assertTrue($ned->canRead($jack)); $this->assertTrue($ned->canRead($domain)); $this->assertTrue($ned->canRead($domain->wallet())); $this->assertFalse($ned->canRead($reseller1)); $this->assertFalse($ned->canRead($reseller2)); $this->assertFalse($ned->canRead($admin)); } /** * Test User::canUpdate() method */ public function testCanUpdate(): void { $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $domain = $this->getTestDomain('kolab.org'); // Admin $this->assertTrue($admin->canUpdate($admin)); $this->assertTrue($admin->canUpdate($john)); $this->assertTrue($admin->canUpdate($jack)); $this->assertTrue($admin->canUpdate($reseller1)); $this->assertTrue($admin->canUpdate($reseller2)); $this->assertTrue($admin->canUpdate($domain)); $this->assertTrue($admin->canUpdate($domain->wallet())); // Reseller - kolabnow $this->assertTrue($reseller1->canUpdate($john)); $this->assertTrue($reseller1->canUpdate($jack)); $this->assertTrue($reseller1->canUpdate($reseller1)); $this->assertTrue($reseller1->canUpdate($domain)); $this->assertTrue($reseller1->canUpdate($domain->wallet())); $this->assertFalse($reseller1->canUpdate($reseller2)); $this->assertFalse($reseller1->canUpdate($admin)); // Reseller - different tenant $this->assertTrue($reseller2->canUpdate($reseller2)); $this->assertFalse($reseller2->canUpdate($john)); $this->assertFalse($reseller2->canUpdate($jack)); $this->assertFalse($reseller2->canUpdate($reseller1)); $this->assertFalse($reseller2->canUpdate($domain)); $this->assertFalse($reseller2->canUpdate($domain->wallet())); $this->assertFalse($reseller2->canUpdate($admin)); // Normal user - account owner $this->assertTrue($john->canUpdate($john)); $this->assertTrue($john->canUpdate($ned)); $this->assertTrue($john->canUpdate($jack)); $this->assertTrue($john->canUpdate($domain)); $this->assertFalse($john->canUpdate($domain->wallet())); $this->assertFalse($john->canUpdate($reseller1)); $this->assertFalse($john->canUpdate($reseller2)); $this->assertFalse($john->canUpdate($admin)); // Normal user - a non-owner and non-controller $this->assertTrue($jack->canUpdate($jack)); $this->assertFalse($jack->canUpdate($john)); $this->assertFalse($jack->canUpdate($domain)); $this->assertFalse($jack->canUpdate($domain->wallet())); $this->assertFalse($jack->canUpdate($reseller1)); $this->assertFalse($jack->canUpdate($reseller2)); $this->assertFalse($jack->canUpdate($admin)); // Normal user - John's wallet controller $this->assertTrue($ned->canUpdate($ned)); $this->assertTrue($ned->canUpdate($john)); $this->assertTrue($ned->canUpdate($jack)); $this->assertTrue($ned->canUpdate($domain)); $this->assertFalse($ned->canUpdate($domain->wallet())); $this->assertFalse($ned->canUpdate($reseller1)); $this->assertFalse($ned->canUpdate($reseller2)); $this->assertFalse($ned->canUpdate($admin)); } /** * Test user create/creating observer */ public function testCreate(): void { Queue::fake(); $domain = \config('app.domain'); $user = User::create(['email' => 'USER-test@' . \strtoupper($domain)]); $result = User::where('email', 'user-test@' . $domain)->first(); $this->assertSame('user-test@' . $domain, $result->email); $this->assertSame($user->id, $result->id); $this->assertSame(User::STATUS_NEW | User::STATUS_ACTIVE, $result->status); } /** * Verify user creation process */ public function testCreateJobs(): void { Queue::fake(); $user = User::create([ 'email' => 'user-test@' . \config('app.domain') ]); Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1); Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 0); Queue::assertPushed( \App\Jobs\User\CreateJob::class, function ($job) use ($user) { $userEmail = TestCase::getObjectProperty($job, 'userEmail'); $userId = TestCase::getObjectProperty($job, 'userId'); return $userEmail === $user->email && $userId === $user->id; } ); Queue::assertPushedWithChain( \App\Jobs\User\CreateJob::class, [ \App\Jobs\User\VerifyJob::class, ] ); /* FIXME: Looks like we can't really do detailed assertions on chained jobs Another thing to consider is if we maybe should run these jobs independently (not chained) and make sure there's no race-condition in status update Queue::assertPushed(\App\Jobs\User\VerifyJob::class, 1); Queue::assertPushed(\App\Jobs\User\VerifyJob::class, function ($job) use ($user) { $userEmail = TestCase::getObjectProperty($job, 'userEmail'); $userId = TestCase::getObjectProperty($job, 'userId'); return $userEmail === $user->email && $userId === $user->id; }); */ } /** * Verify user creation process invokes the PGP keys creation job (if configured) */ public function testCreatePGPJob(): void { Queue::fake(); \App\Tenant::find(\config('app.tenant_id'))->setSetting('pgp.enable', 1); $user = User::create([ 'email' => 'user-test@' . \config('app.domain') ]); Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1); Queue::assertPushed( \App\Jobs\PGP\KeyCreateJob::class, function ($job) use ($user) { $userEmail = TestCase::getObjectProperty($job, 'userEmail'); $userId = TestCase::getObjectProperty($job, 'userId'); return $userEmail === $user->email && $userId === $user->id; } ); } /** * Tests for User::domains() */ public function testDomains(): void { $user = $this->getTestUser('john@kolab.org'); $domain = $this->getTestDomain('useraccount.com', [ 'status' => Domain::STATUS_NEW | Domain::STATUS_ACTIVE, 'type' => Domain::TYPE_PUBLIC, ]); $domains = collect($user->domains())->pluck('namespace')->all(); $this->assertContains($domain->namespace, $domains); $this->assertContains('kolab.org', $domains); // Jack is not the wallet controller, so for him the list should not // include John's domains, kolab.org specifically $user = $this->getTestUser('jack@kolab.org'); $domains = collect($user->domains())->pluck('namespace')->all(); $this->assertContains($domain->namespace, $domains); $this->assertNotContains('kolab.org', $domains); // Public domains of other tenants should not be returned $tenant = \App\Tenant::where('id', '!=', \config('app.tenant_id'))->first(); $domain->tenant_id = $tenant->id; $domain->save(); $domains = collect($user->domains())->pluck('namespace')->all(); $this->assertNotContains($domain->namespace, $domains); } /** * Test User::getConfig() and setConfig() methods */ public function testConfigTrait(): void { $john = $this->getTestUser('john@kolab.org'); $john->setSetting('greylist_enabled', null); $this->assertSame(['greylist_enabled' => true], $john->getConfig()); $result = $john->setConfig(['greylist_enabled' => false, 'unknown' => false]); $this->assertSame(['greylist_enabled' => false], $john->getConfig()); $this->assertSame('false', $john->getSetting('greylist_enabled')); $result = $john->setConfig(['greylist_enabled' => true]); $this->assertSame(['greylist_enabled' => true], $john->getConfig()); $this->assertSame('true', $john->getSetting('greylist_enabled')); } /** * Test User::hasSku() method */ public function testHasSku(): void { $john = $this->getTestUser('john@kolab.org'); $this->assertTrue($john->hasSku('mailbox')); $this->assertTrue($john->hasSku('storage')); $this->assertFalse($john->hasSku('beta')); $this->assertFalse($john->hasSku('unknown')); } public function testUserQuota(): void { // TODO: This test does not test much, probably could be removed // or moved to somewhere else, or extended with // other entitlements() related cases. $user = $this->getTestUser('john@kolab.org'); $storage_sku = \App\Sku::withEnvTenantContext()->where('title', 'storage')->first(); $count = 0; foreach ($user->entitlements()->get() as $entitlement) { if ($entitlement->sku_id == $storage_sku->id) { $count += 1; } } $this->assertTrue($count == 5); } /** * Test user deletion */ public function testDelete(): void { Queue::fake(); $user = $this->getTestUser('user-test@' . \config('app.domain')); $package = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $user->assignPackage($package); $id = $user->id; $this->assertCount(7, $user->entitlements()->get()); $user->delete(); $this->assertCount(0, $user->entitlements()->get()); $this->assertTrue($user->fresh()->trashed()); $this->assertFalse($user->fresh()->isDeleted()); // Delete the user for real $job = new \App\Jobs\User\DeleteJob($id); $job->handle(); $this->assertTrue(User::withTrashed()->where('id', $id)->first()->isDeleted()); $user->forceDelete(); $this->assertCount(0, User::withTrashed()->where('id', $id)->get()); // Test an account with users, domain, and group $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $userC = $this->getTestUser('UserAccountC@UserAccount.com'); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $domain = $this->getTestDomain('UserAccount.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $userA->assignPackage($package_kolab); $domain->assignPackage($package_domain, $userA); $userA->assignPackage($package_kolab, $userB); $userA->assignPackage($package_kolab, $userC); $group = $this->getTestGroup('test-group@UserAccount.com'); $group->assignToWallet($userA->wallets->first()); $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); $entitlementsC = \App\Entitlement::where('entitleable_id', $userC->id); $entitlementsDomain = \App\Entitlement::where('entitleable_id', $domain->id); $entitlementsGroup = \App\Entitlement::where('entitleable_id', $group->id); $this->assertSame(7, $entitlementsA->count()); $this->assertSame(7, $entitlementsB->count()); $this->assertSame(7, $entitlementsC->count()); $this->assertSame(1, $entitlementsDomain->count()); $this->assertSame(1, $entitlementsGroup->count()); // Delete non-controller user $userC->delete(); $this->assertTrue($userC->fresh()->trashed()); $this->assertFalse($userC->fresh()->isDeleted()); $this->assertSame(0, $entitlementsC->count()); // Delete the controller (and expect "sub"-users to be deleted too) $userA->delete(); $this->assertSame(0, $entitlementsA->count()); $this->assertSame(0, $entitlementsB->count()); $this->assertSame(0, $entitlementsDomain->count()); $this->assertSame(0, $entitlementsGroup->count()); $this->assertTrue($userA->fresh()->trashed()); $this->assertTrue($userB->fresh()->trashed()); $this->assertTrue($domain->fresh()->trashed()); $this->assertTrue($group->fresh()->trashed()); $this->assertFalse($userA->isDeleted()); $this->assertFalse($userB->isDeleted()); $this->assertFalse($domain->isDeleted()); $this->assertFalse($group->isDeleted()); $userA->forceDelete(); $all_entitlements = \App\Entitlement::where('wallet_id', $userA->wallets->first()->id); $this->assertSame(0, $all_entitlements->withTrashed()->count()); $this->assertCount(0, User::withTrashed()->where('id', $userA->id)->get()); $this->assertCount(0, User::withTrashed()->where('id', $userB->id)->get()); $this->assertCount(0, User::withTrashed()->where('id', $userC->id)->get()); $this->assertCount(0, Domain::withTrashed()->where('id', $domain->id)->get()); $this->assertCount(0, Group::withTrashed()->where('id', $group->id)->get()); } /** * Test user deletion vs. group membership */ public function testDeleteAndGroups(): void { Queue::fake(); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $userA->assignPackage($package_kolab, $userB); $group = $this->getTestGroup('test-group@UserAccount.com'); $group->members = ['test@gmail.com', $userB->email]; $group->assignToWallet($userA->wallets->first()); $group->save(); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1); $userGroups = $userA->groups()->get(); $this->assertSame(1, $userGroups->count()); $this->assertSame($group->id, $userGroups->first()->id); $userB->delete(); $this->assertSame(['test@gmail.com'], $group->fresh()->members); // Twice, one for save() and one for delete() above Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 2); } /** * Test handling negative balance on user deletion */ public function testDeleteWithNegativeBalance(): void { $user = $this->getTestUser('user-test@' . \config('app.domain')); $wallet = $user->wallets()->first(); $wallet->balance = -1000; $wallet->save(); $reseller_wallet = $user->tenant->wallet(); $reseller_wallet->balance = 0; $reseller_wallet->save(); \App\Transaction::where('object_id', $reseller_wallet->id)->where('object_type', \App\Wallet::class)->delete(); $user->delete(); $reseller_transactions = \App\Transaction::where('object_id', $reseller_wallet->id) ->where('object_type', \App\Wallet::class)->get(); $this->assertSame(-1000, $reseller_wallet->fresh()->balance); $this->assertCount(1, $reseller_transactions); $trans = $reseller_transactions[0]; $this->assertSame("Deleted user {$user->email}", $trans->description); $this->assertSame(-1000, $trans->amount); $this->assertSame(\App\Transaction::WALLET_DEBIT, $trans->type); } /** * Test handling positive balance on user deletion */ public function testDeleteWithPositiveBalance(): void { $user = $this->getTestUser('user-test@' . \config('app.domain')); $wallet = $user->wallets()->first(); $wallet->balance = 1000; $wallet->save(); $reseller_wallet = $user->tenant->wallet(); $reseller_wallet->balance = 0; $reseller_wallet->save(); $user->delete(); $this->assertSame(0, $reseller_wallet->fresh()->balance); } + /** + * Test user deletion with PGP/WOAT enabled + */ + public function testDeleteWithPGP(): void + { + Queue::fake(); + + // Test with PGP disabled + $user = $this->getTestUser('user-test@' . \config('app.domain')); + + $user->tenant->setSetting('pgp.enable', 0); + $user->delete(); + + Queue::assertPushed(\App\Jobs\PGP\KeyDeleteJob::class, 0); + + // Test with PGP enabled + $this->deleteTestUser('user-test@' . \config('app.domain')); + $user = $this->getTestUser('user-test@' . \config('app.domain')); + + $user->tenant->setSetting('pgp.enable', 1); + $user->delete(); + $user->tenant->setSetting('pgp.enable', 0); + + Queue::assertPushed(\App\Jobs\PGP\KeyDeleteJob::class, 1); + Queue::assertPushed( + \App\Jobs\PGP\KeyDeleteJob::class, + function ($job) use ($user) { + $userId = TestCase::getObjectProperty($job, 'userId'); + $userEmail = TestCase::getObjectProperty($job, 'userEmail'); + return $userId == $user->id && $userEmail === $user->email; + } + ); + } + /** * Tests for User::aliasExists() */ public function testAliasExists(): void { $this->assertTrue(User::aliasExists('jack.daniels@kolab.org')); $this->assertFalse(User::aliasExists('j.daniels@kolab.org')); $this->assertFalse(User::aliasExists('john@kolab.org')); } /** * Tests for User::emailExists() */ public function testEmailExists(): void { $this->assertFalse(User::emailExists('jack.daniels@kolab.org')); $this->assertFalse(User::emailExists('j.daniels@kolab.org')); $this->assertTrue(User::emailExists('john@kolab.org')); $user = User::emailExists('john@kolab.org', true); $this->assertSame('john@kolab.org', $user->email); } /** * Tests for User::findByEmail() */ public function testFindByEmail(): void { $user = $this->getTestUser('john@kolab.org'); $result = User::findByEmail('john'); $this->assertNull($result); $result = User::findByEmail('non-existing@email.com'); $this->assertNull($result); $result = User::findByEmail('john@kolab.org'); $this->assertInstanceOf(User::class, $result); $this->assertSame($user->id, $result->id); // Use an alias $result = User::findByEmail('john.doe@kolab.org'); $this->assertInstanceOf(User::class, $result); $this->assertSame($user->id, $result->id); Queue::fake(); // A case where two users have the same alias $ned = $this->getTestUser('ned@kolab.org'); $ned->setAliases(['joe.monster@kolab.org']); $result = User::findByEmail('joe.monster@kolab.org'); $this->assertNull($result); $ned->setAliases([]); // TODO: searching by external email (setting) $this->markTestIncomplete(); } /** * Test User::name() */ public function testName(): void { Queue::fake(); $user = $this->getTestUser('user-test@' . \config('app.domain')); $this->assertSame('', $user->name()); $this->assertSame($user->tenant->title . ' User', $user->name(true)); $user->setSetting('first_name', 'First'); $this->assertSame('First', $user->name()); $this->assertSame('First', $user->name(true)); $user->setSetting('last_name', 'Last'); $this->assertSame('First Last', $user->name()); $this->assertSame('First Last', $user->name(true)); } /** * Test user restoring */ public function testRestore(): void { Queue::fake(); // Test an account with users and domain $userA = $this->getTestUser('UserAccountA@UserAccount.com', [ 'status' => User::STATUS_LDAP_READY | User::STATUS_IMAP_READY | User::STATUS_SUSPENDED, ]); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $domainA = $this->getTestDomain('UserAccount.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $domainB = $this->getTestDomain('UserAccountAdd.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $userA->assignPackage($package_kolab); $domainA->assignPackage($package_domain, $userA); $domainB->assignPackage($package_domain, $userA); $userA->assignPackage($package_kolab, $userB); $storage_sku = \App\Sku::withEnvTenantContext()->where('title', 'storage')->first(); $now = \Carbon\Carbon::now(); $wallet_id = $userA->wallets->first()->id; // add an extra storage entitlement $ent1 = \App\Entitlement::create([ 'wallet_id' => $wallet_id, 'sku_id' => $storage_sku->id, 'cost' => 0, 'entitleable_id' => $userA->id, 'entitleable_type' => User::class, ]); $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); $entitlementsDomain = \App\Entitlement::where('entitleable_id', $domainA->id); // First delete the user $userA->delete(); $this->assertSame(0, $entitlementsA->count()); $this->assertSame(0, $entitlementsB->count()); $this->assertSame(0, $entitlementsDomain->count()); $this->assertTrue($userA->fresh()->trashed()); $this->assertTrue($userB->fresh()->trashed()); $this->assertTrue($domainA->fresh()->trashed()); $this->assertTrue($domainB->fresh()->trashed()); $this->assertFalse($userA->isDeleted()); $this->assertFalse($userB->isDeleted()); $this->assertFalse($domainA->isDeleted()); // Backdate one storage entitlement (it's not expected to be restored) \App\Entitlement::withTrashed()->where('id', $ent1->id) ->update(['deleted_at' => $now->copy()->subMinutes(2)]); // Backdate entitlements to assert that they were restored with proper updated_at timestamp \App\Entitlement::withTrashed()->where('wallet_id', $wallet_id) ->update(['updated_at' => $now->subMinutes(10)]); Queue::fake(); // Then restore it $userA->restore(); $userA->refresh(); $this->assertFalse($userA->trashed()); $this->assertFalse($userA->isDeleted()); $this->assertFalse($userA->isSuspended()); $this->assertFalse($userA->isLdapReady()); $this->assertFalse($userA->isImapReady()); $this->assertTrue($userA->isActive()); $this->assertTrue($userB->fresh()->trashed()); $this->assertTrue($domainB->fresh()->trashed()); $this->assertFalse($domainA->fresh()->trashed()); // Assert entitlements $this->assertSame(7, $entitlementsA->count()); // mailbox + groupware + 5 x storage $this->assertTrue($ent1->fresh()->trashed()); $entitlementsA->get()->each(function ($ent) { $this->assertTrue($ent->updated_at->greaterThan(\Carbon\Carbon::now()->subSeconds(5))); }); // We expect only CreateJob + UpdateJob pair for both user and domain. // Because how Illuminate/Database/Eloquent/SoftDeletes::restore() method // is implemented we cannot skip the UpdateJob in any way. // I don't want to overwrite this method, the extra job shouldn't do any harm. $this->assertCount(4, Queue::pushedJobs()); // @phpstan-ignore-line Queue::assertPushed(\App\Jobs\Domain\UpdateJob::class, 1); Queue::assertPushed(\App\Jobs\Domain\CreateJob::class, 1); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1); Queue::assertPushed( \App\Jobs\User\CreateJob::class, function ($job) use ($userA) { return $userA->id === TestCase::getObjectProperty($job, 'userId'); } ); Queue::assertPushedWithChain( \App\Jobs\User\CreateJob::class, [ \App\Jobs\User\VerifyJob::class, ] ); } /** * Tests for UserAliasesTrait::setAliases() */ public function testSetAliases(): void { Queue::fake(); Queue::assertNothingPushed(); $user = $this->getTestUser('UserAccountA@UserAccount.com'); $domain = $this->getTestDomain('UserAccount.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $this->assertCount(0, $user->aliases->all()); $user->tenant->setSetting('pgp.enable', 1); // Add an alias $user->setAliases(['UserAlias1@UserAccount.com']); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1); $user->tenant->setSetting('pgp.enable', 0); $aliases = $user->aliases()->get(); $this->assertCount(1, $aliases); $this->assertSame('useralias1@useraccount.com', $aliases[0]['alias']); // Add another alias $user->setAliases(['UserAlias1@UserAccount.com', 'UserAlias2@UserAccount.com']); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 2); Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1); $aliases = $user->aliases()->orderBy('alias')->get(); $this->assertCount(2, $aliases); $this->assertSame('useralias1@useraccount.com', $aliases[0]->alias); $this->assertSame('useralias2@useraccount.com', $aliases[1]->alias); $user->tenant->setSetting('pgp.enable', 1); // Remove an alias $user->setAliases(['UserAlias1@UserAccount.com']); $user->tenant->setSetting('pgp.enable', 0); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 3); - Queue::assertPushed(\App\Jobs\PGP\KeyUnregisterJob::class, 1); + Queue::assertPushed(\App\Jobs\PGP\KeyDeleteJob::class, 1); + Queue::assertPushed( + \App\Jobs\PGP\KeyDeleteJob::class, + function ($job) use ($user) { + $userId = TestCase::getObjectProperty($job, 'userId'); + $userEmail = TestCase::getObjectProperty($job, 'userEmail'); + return $userId == $user->id && $userEmail === 'useralias2@useraccount.com'; + } + ); $aliases = $user->aliases()->get(); $this->assertCount(1, $aliases); $this->assertSame('useralias1@useraccount.com', $aliases[0]['alias']); // Remove all aliases $user->setAliases([]); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 4); $this->assertCount(0, $user->aliases()->get()); } /** * Tests for UserSettingsTrait::setSettings() and getSetting() and getSettings() */ public function testUserSettings(): void { Queue::fake(); Queue::assertNothingPushed(); $user = $this->getTestUser('UserAccountA@UserAccount.com'); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 0); // Test default settings // Note: Technicly this tests UserObserver::created() behavior $all_settings = $user->settings()->orderBy('key')->get(); $this->assertCount(2, $all_settings); $this->assertSame('country', $all_settings[0]->key); $this->assertSame('CH', $all_settings[0]->value); $this->assertSame('currency', $all_settings[1]->key); $this->assertSame('CHF', $all_settings[1]->value); // Add a setting $user->setSetting('first_name', 'Firstname'); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame('Firstname', $user->getSetting('first_name')); $this->assertSame('Firstname', $user->fresh()->getSetting('first_name')); // Update a setting $user->setSetting('first_name', 'Firstname1'); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 2); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame('Firstname1', $user->getSetting('first_name')); $this->assertSame('Firstname1', $user->fresh()->getSetting('first_name')); // Delete a setting (null) $user->setSetting('first_name', null); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 3); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame(null, $user->getSetting('first_name')); $this->assertSame(null, $user->fresh()->getSetting('first_name')); // Delete a setting (empty string) $user->setSetting('first_name', 'Firstname1'); $user->setSetting('first_name', ''); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 5); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame(null, $user->getSetting('first_name')); $this->assertSame(null, $user->fresh()->getSetting('first_name')); // Set multiple settings at once $user->setSettings([ 'first_name' => 'Firstname2', 'last_name' => 'Lastname2', 'country' => null, ]); // TODO: This really should create a single UserUpdate job, not 3 Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 7); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame('Firstname2', $user->getSetting('first_name')); $this->assertSame('Firstname2', $user->fresh()->getSetting('first_name')); $this->assertSame('Lastname2', $user->getSetting('last_name')); $this->assertSame('Lastname2', $user->fresh()->getSetting('last_name')); $this->assertSame(null, $user->getSetting('country')); $this->assertSame(null, $user->fresh()->getSetting('country')); $all_settings = $user->settings()->orderBy('key')->get(); $this->assertCount(3, $all_settings); // Test getSettings() method $this->assertSame( [ 'first_name' => 'Firstname2', 'last_name' => 'Lastname2', 'unknown' => null, ], $user->getSettings(['first_name', 'last_name', 'unknown']) ); } /** * Tests for User::users() */ public function testUsers(): void { $jack = $this->getTestUser('jack@kolab.org'); $joe = $this->getTestUser('joe@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $wallet = $john->wallets()->first(); $users = $john->users()->orderBy('email')->get(); $this->assertCount(4, $users); $this->assertEquals($jack->id, $users[0]->id); $this->assertEquals($joe->id, $users[1]->id); $this->assertEquals($john->id, $users[2]->id); $this->assertEquals($ned->id, $users[3]->id); $this->assertSame($wallet->id, $users[0]->wallet_id); $this->assertSame($wallet->id, $users[1]->wallet_id); $this->assertSame($wallet->id, $users[2]->wallet_id); $this->assertSame($wallet->id, $users[3]->wallet_id); $users = $jack->users()->orderBy('email')->get(); $this->assertCount(0, $users); $users = $ned->users()->orderBy('email')->get(); $this->assertCount(4, $users); } public function testWallets(): void { $this->markTestIncomplete(); } } diff --git a/src/tests/TestCaseTrait.php b/src/tests/TestCaseTrait.php index 147b0953..7817f2ca 100644 --- a/src/tests/TestCaseTrait.php +++ b/src/tests/TestCaseTrait.php @@ -1,479 +1,479 @@ 'John', 'last_name' => 'Doe', 'organization' => 'Test Domain Owner', ]; /** * Some users for the hosted domain, ultimately including the owner. * * @var \App\User[] */ protected $domainUsers = []; /** * A specific user that is a regular user in the hosted domain. * * @var ?\App\User */ protected $jack; /** * A specific user that is a controller on the wallet to which the hosted domain is charged. * * @var ?\App\User */ protected $jane; /** * A specific user that has a second factor configured. * * @var ?\App\User */ protected $joe; /** * One of the domains that is available for public registration. * * @var ?\App\Domain */ protected $publicDomain; /** * A newly generated user in a public domain. * * @var ?\App\User */ protected $publicDomainUser; /** * A placeholder for a password that can be generated. * * Should be generated with `\App\Utils::generatePassphrase()`. * * @var ?string */ protected $userPassword; /** * 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. + * @param \App\User|\App\Domain $object The object for which the entitlements need to be pulled. + * @param array $expected An array of expected \App\SKU titles. */ - protected function assertUserEntitlements($user, $expected) + protected function assertEntitlements($object, $expected) { // Assert the user entitlements - $skus = $user->entitlements()->get() + $skus = $object->entitlements()->get() ->map(function ($ent) { return $ent->sku->title; }) ->toArray(); sort($skus); Assert::assertSame($expected, $skus); } protected function backdateEntitlements($entitlements, $targetDate) { $wallets = []; $ids = []; foreach ($entitlements as $entitlement) { $ids[] = $entitlement->id; $wallets[] = $entitlement->wallet_id; } \App\Entitlement::whereIn('id', $ids)->update([ 'created_at' => $targetDate, 'updated_at' => $targetDate, ]); if (!empty($wallets)) { $wallets = array_unique($wallets); $owners = \App\Wallet::whereIn('id', $wallets)->pluck('user_id')->all(); \App\User::whereIn('id', $owners)->update(['created_at' => $targetDate]); } } /** * Removes all beta entitlements from the database */ protected function clearBetaEntitlements(): void { $beta_handlers = [ 'App\Handlers\Beta', 'App\Handlers\Distlist', ]; $betas = \App\Sku::whereIn('handler_class', $beta_handlers)->pluck('id')->all(); \App\Entitlement::whereIn('sku_id', $betas)->delete(); } /** * Creates the application. * * @return \Illuminate\Foundation\Application */ public function createApplication() { $app = require __DIR__ . '/../bootstrap/app.php'; $app->make(Kernel::class)->bootstrap(); return $app; } /** * Create a set of transaction log entries for a wallet */ protected function createTestTransactions($wallet) { $result = []; $date = Carbon::now(); $debit = 0; $entitlementTransactions = []; foreach ($wallet->entitlements as $entitlement) { if ($entitlement->cost) { $debit += $entitlement->cost; $entitlementTransactions[] = $entitlement->createTransaction( Transaction::ENTITLEMENT_BILLED, $entitlement->cost ); } } $transaction = Transaction::create( [ 'user_email' => 'jeroen@jeroen.jeroen', 'object_id' => $wallet->id, 'object_type' => \App\Wallet::class, 'type' => Transaction::WALLET_DEBIT, 'amount' => $debit * -1, 'description' => 'Payment', ] ); $result[] = $transaction; Transaction::whereIn('id', $entitlementTransactions)->update(['transaction_id' => $transaction->id]); $transaction = Transaction::create( [ 'user_email' => null, 'object_id' => $wallet->id, 'object_type' => \App\Wallet::class, 'type' => Transaction::WALLET_CREDIT, 'amount' => 2000, 'description' => 'Payment', ] ); $transaction->created_at = $date->next(Carbon::MONDAY); $transaction->save(); $result[] = $transaction; $types = [ Transaction::WALLET_AWARD, Transaction::WALLET_PENALTY, ]; // The page size is 10, so we generate so many to have at least two pages $loops = 10; while ($loops-- > 0) { $type = $types[count($result) % count($types)]; $transaction = Transaction::create([ 'user_email' => 'jeroen.@jeroen.jeroen', 'object_id' => $wallet->id, 'object_type' => \App\Wallet::class, 'type' => $type, 'amount' => 11 * (count($result) + 1) * ($type == Transaction::WALLET_PENALTY ? -1 : 1), 'description' => 'TRANS' . $loops, ]); $transaction->created_at = $date->next(Carbon::MONDAY); $transaction->save(); $result[] = $transaction; } return $result; } /** * Delete a test domain whatever it takes. * * @coversNothing */ protected function deleteTestDomain($name) { Queue::fake(); $domain = Domain::withTrashed()->where('namespace', $name)->first(); if (!$domain) { return; } $job = new \App\Jobs\Domain\DeleteJob($domain->id); $job->handle(); $domain->forceDelete(); } /** * Delete a test group whatever it takes. * * @coversNothing */ protected function deleteTestGroup($email) { Queue::fake(); $group = Group::withTrashed()->where('email', $email)->first(); if (!$group) { return; } $job = new \App\Jobs\Group\DeleteJob($group->id); $job->handle(); $group->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(); } /** * Helper to access protected property of an object */ protected static function getObjectProperty($object, $property_name) { $reflection = new \ReflectionClass($object); $property = $reflection->getProperty($property_name); $property->setAccessible(true); return $property->getValue($object); } /** * Get Domain object by namespace, create it if needed. * Skip LDAP jobs. * * @coversNothing */ protected function getTestDomain($name, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); return Domain::firstOrCreate(['namespace' => $name], $attrib); } /** * Get Group object by email, create it if needed. * Skip LDAP jobs. */ protected function getTestGroup($email, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); return Group::firstOrCreate(['email' => $email], $attrib); } /** * Get 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::firstOrCreate(['email' => $email], $attrib); if ($user->trashed()) { // Note: we do not want to use user restore here User::where('id', $user->id)->forceDelete(); $user = User::create(['email' => $email] + $attrib); } return $user; } /** * 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); } protected function setUpTest() { $this->userPassword = \App\Utils::generatePassphrase(); $this->domainHosted = $this->getTestDomain( 'test.domain', [ 'type' => \App\Domain::TYPE_EXTERNAL, 'status' => \App\Domain::STATUS_ACTIVE | \App\Domain::STATUS_CONFIRMED | \App\Domain::STATUS_VERIFIED ] ); $this->getTestDomain( 'test2.domain2', [ 'type' => \App\Domain::TYPE_EXTERNAL, 'status' => \App\Domain::STATUS_ACTIVE | \App\Domain::STATUS_CONFIRMED | \App\Domain::STATUS_VERIFIED ] ); $packageKolab = \App\Package::where('title', 'kolab')->first(); $this->domainOwner = $this->getTestUser('john@test.domain', ['password' => $this->userPassword]); $this->domainOwner->assignPackage($packageKolab); $this->domainOwner->setSettings($this->domainOwnerSettings); $this->domainOwner->setAliases(['alias1@test2.domain2']); // separate for regular user $this->jack = $this->getTestUser('jack@test.domain', ['password' => $this->userPassword]); // separate for wallet controller $this->jane = $this->getTestUser('jane@test.domain', ['password' => $this->userPassword]); $this->joe = $this->getTestUser('joe@test.domain', ['password' => $this->userPassword]); $this->domainUsers[] = $this->jack; $this->domainUsers[] = $this->jane; $this->domainUsers[] = $this->joe; $this->domainUsers[] = $this->getTestUser('jill@test.domain', ['password' => $this->userPassword]); foreach ($this->domainUsers as $user) { $this->domainOwner->assignPackage($packageKolab, $user); } $this->domainUsers[] = $this->domainOwner; // assign second factor to joe $this->joe->assignSku(\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 ); $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); } if ($this->domainOwner) { $this->deleteTestUser($this->domainOwner->email); } if ($this->domainHosted) { $this->deleteTestDomain($this->domainHosted->namespace); } if ($this->publicDomainUser) { $this->deleteTestUser($this->publicDomainUser->email); } parent::tearDown(); } } diff --git a/src/tests/Unit/Rules/UserEmailDomainTest.php b/src/tests/Unit/Rules/UserEmailDomainTest.php index 559d337c..42d6c9a3 100644 --- a/src/tests/Unit/Rules/UserEmailDomainTest.php +++ b/src/tests/Unit/Rules/UserEmailDomainTest.php @@ -1,18 +1,68 @@ markTestIncomplete(); + $rules = ['domain' => [new UserEmailDomain()]]; + + // Non-string input + $v = Validator::make(['domain' => ['domain.tld']], $rules); + + $this->assertTrue($v->fails()); + $this->assertSame(['domain' => ['The specified domain is invalid.']], $v->errors()->toArray()); + + // Non-fqdn name + $v = Validator::make(['domain' => 'local'], $rules); + + $this->assertTrue($v->fails()); + $this->assertSame(['domain' => ['The specified domain is invalid.']], $v->errors()->toArray()); + + // www. prefix not allowed + $v = Validator::make(['domain' => 'www.local.tld'], $rules); + + $this->assertTrue($v->fails()); + $this->assertSame(['domain' => ['The specified domain is invalid.']], $v->errors()->toArray()); + + // invalid domain + $v = Validator::make(['domain' => 'local..tld'], $rules); + + $this->assertTrue($v->fails()); + $this->assertSame(['domain' => ['The specified domain is invalid.']], $v->errors()->toArray()); + + // Valid domain + $domain = str_repeat('abcdefghi.', 18) . 'abcdefgh.pl'; // 191 chars + $v = Validator::make(['domain' => $domain], $rules); + + $this->assertFalse($v->fails()); + + // Domain too long + $domain = str_repeat('abcdefghi.', 18) . 'abcdefghi.pl'; // too long domain, 192 chars + $v = Validator::make(['domain' => $domain], $rules); + + $this->assertTrue($v->fails()); + $this->assertSame(['domain' => ['The specified domain is invalid.']], $v->errors()->toArray()); + + $rules = ['domain' => [new UserEmailDomain(['kolabnow.com'])]]; + + // Domain not belongs to a set of allowed domains + $v = Validator::make(['domain' => 'domain.tld'], $rules); + + $this->assertTrue($v->fails()); + $this->assertSame(['domain' => ['The specified domain is invalid.']], $v->errors()->toArray()); + + // Domain on the allowed domains list + $v = Validator::make(['domain' => 'kolabNow.com'], $rules); + + $this->assertFalse($v->fails()); } } diff --git a/src/tests/Unit/Rules/UserEmailLocalTest.php b/src/tests/Unit/Rules/UserEmailLocalTest.php index 8476a8fb..ef732b02 100644 --- a/src/tests/Unit/Rules/UserEmailLocalTest.php +++ b/src/tests/Unit/Rules/UserEmailLocalTest.php @@ -1,18 +1,58 @@ markTestIncomplete(); + $rules = ['user' => [new UserEmailLocal($external)]]; + + $v = Validator::make(['user' => $user], $rules); + + if ($error) { + $this->assertTrue($v->fails()); + $this->assertSame(['user' => [$error]], $v->errors()->toArray()); + } else { + $this->assertFalse($v->fails()); + } } } diff --git a/src/tests/Unit/UtilsTest.php b/src/tests/Unit/UtilsTest.php index 21acde70..85b0a619 100644 --- a/src/tests/Unit/UtilsTest.php +++ b/src/tests/Unit/UtilsTest.php @@ -1,125 +1,143 @@ assertSame('', \App\Utils::normalizeAddress('')); + $this->assertSame('', \App\Utils::normalizeAddress(null)); + $this->assertSame('test', \App\Utils::normalizeAddress('TEST')); + $this->assertSame('test@domain.tld', \App\Utils::normalizeAddress('Test@Domain.TLD')); + $this->assertSame('test@domain.tld', \App\Utils::normalizeAddress('Test+Trash@Domain.TLD')); + + $this->assertSame(['', ''], \App\Utils::normalizeAddress('', true)); + $this->assertSame(['', ''], \App\Utils::normalizeAddress(null, true)); + $this->assertSame(['test', ''], \App\Utils::normalizeAddress('TEST', true)); + $this->assertSame(['test', 'domain.tld'], \App\Utils::normalizeAddress('Test@Domain.TLD', true)); + $this->assertSame(['test', 'domain.tld'], \App\Utils::normalizeAddress('Test+Trash@Domain.TLD', true)); + } + /** * Test for Utils::powerSet() */ public function testPowerSet(): void { $set = []; $result = \App\Utils::powerSet($set); $this->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); } /** * Test for Utils::exchangeRate() */ public function testExchangeRate(): void { $this->assertSame(1.0, Utils::exchangeRate("DUMMY", "dummy")); // Exchange rates are volatile, can't test with high accuracy. $this->assertTrue(Utils::exchangeRate("CHF", "EUR") >= 0.88); //$this->assertEqualsWithDelta(0.90503424978382, Utils::exchangeRate("CHF", "EUR"), PHP_FLOAT_EPSILON); $this->assertTrue(Utils::exchangeRate("EUR", "CHF") <= 1.12); //$this->assertEqualsWithDelta(1.1049305595217682, Utils::exchangeRate("EUR", "CHF"), PHP_FLOAT_EPSILON); $this->expectException(\Exception::class); $this->assertSame(1.0, Utils::exchangeRate("CHF", "FOO")); $this->expectException(\Exception::class); $this->assertSame(1.0, Utils::exchangeRate("FOO", "CHF")); } }