diff --git a/bin/phpstan b/bin/phpstan index 3143812f..d7df8160 100755 --- a/bin/phpstan +++ b/bin/phpstan @@ -1,11 +1,11 @@ #!/bin/bash cwd=$(dirname $0) pushd ${cwd}/../src/ -php -dmemory_limit=400M \ +php -dmemory_limit=500M \ vendor/bin/phpstan \ analyse popd diff --git a/docker-compose.yml b/docker-compose.yml index ff7a43ad..5f6fd4f1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,150 +1,151 @@ version: '3' services: coturn: container_name: kolab-coturn environment: - DB_NAME=${OPENVIDU_COTURN_REDIS_DATABASE} - DB_PASSWORD=${OPENVIDU_COTURN_REDIS_PASSWORD} - REDIS_IP=${OPENVIDU_COTURN_REDIS_IP} - TURN_PUBLIC_IP=${OPENVIDU_COTURN_IP} - TURN_LISTEN_PORT=3478 hostname: sturn.mgmt.com image: openvidu/openvidu-coturn:1.0.0 network_mode: host restart: on-failure tty: true kolab: build: context: ./docker/kolab/ container_name: kolab depends_on: - mariadb extra_hosts: - "kolab.mgmt.com:127.0.0.1" healthcheck: interval: 10s test: test -f /tmp/kolab-init.done timeout: 5s retries: 30 hostname: kolab.mgmt.com image: kolab network_mode: host tmpfs: - /run - /tmp - /var/run - /var/tmp tty: true volumes: - /etc/letsencrypt/:/etc/letsencrypt/:ro - ./docker/certs/ca.cert:/etc/pki/tls/certs/ca.cert:ro - ./docker/certs/ca.cert:/etc/pki/ca-trust/source/anchors/ca.cert:ro - ./docker/certs/kolab.hosted.com.cert:/etc/pki/tls/certs/kolab.hosted.com.cert - ./docker/certs/kolab.hosted.com.key:/etc/pki/tls/certs/kolab.hosted.com.key - ./docker/certs/kolab.mgmt.com.cert:/etc/pki/tls/certs/kolab.mgmt.com.cert - ./docker/certs/kolab.mgmt.com.key:/etc/pki/tls/certs/kolab.mgmt.com.key - ./docker/kolab/utils:/root/utils:ro - ./src/.env:/.dockerenv:ro - /sys/fs/cgroup:/sys/fs/cgroup:ro kurento-media-server: build: context: ./docker/kurento-media-server/ container_name: kolab-kurento-media-server environment: - GST_DEBUG=3,Kurento*:4,kms*:4,sdp*:4,webrtc*:4,*rtpendpoint:4,rtp*handler:4,rtpsynchronizer:4,agnosticbin:4 hostname: kurento-media-server.hosted.com image: apheleia/kurento-media-server:6.15.0 network_mode: host mariadb: container_name: kolab-mariadb environment: MYSQL_ROOT_PASSWORD: Welcome2KolabSystems + TZ: "+02:00" healthcheck: interval: 10s test: test -e /var/run/mysqld/mysqld.sock timeout: 5s retries: 30 image: mariadb network_mode: host openvidu: build: context: ./docker/openvidu/ container_name: kolab-openvidu depends_on: - kurento-media-server environment: - APP_DOMAIN=${APP_DOMAIN} - CERTIFICATE_TYPE=letsencrypt - COTURN_IP=${OPENVIDU_COTURN_IP} - COTURN_REDIS_DBNAME=${OPENVIDU_COTURN_REDIS_DATABASE} - COTURN_REDIS_PASSWORD=${OPENVIDU_COTURN_REDIS_PASSWORD} - COTURN_REDIS_IP=${OPENVIDU_COTURN_REDIS_IP} - DOMAIN_OR_PUBLIC_IP=${OPENVIDU_PUBLIC_IP} - SERVER_PORT=${OPENVIDU_SERVER_PORT} - KMS_STUN_IP=${OPENVIDU_COTURN_IP} - KMS_STUN_PORT=3478 - KMS_URIS=["ws://localhost:8888/kurento", "ws://localhost:8889/kurento"] - OPENVIDU_SECRET=${OPENVIDU_API_PASSWORD} - OPENVIDU_WEBHOOK=${OPENVIDU_WEBHOOK} - OPENVIDU_WEBHOOK_ENDPOINT=${OPENVIDU_WEBHOOK_ENDPOINT} - SERVER_SSL_ENABLED=false hostname: openvidu.hosted.com image: apheleia/openvidu:2.18.0 network_mode: host tmpfs: - /run - /tmp - /var/run - /var/tmp tty: true volumes: - /etc/letsencrypt/:/etc/letsencrypt/:ro proxy: build: context: ./docker/proxy/ container_name: kolab-proxy hostname: kanarip.internet-box.ch image: kolab-proxy network_mode: host tmpfs: - /run - /tmp - /var/run - /var/tmp tty: true volumes: - ./docker/certs/:/etc/certs/:ro - /etc/letsencrypt/:/etc/letsencrypt/:ro - /sys/fs/cgroup:/sys/fs/cgroup:ro redis: build: context: ./docker/redis/ container_name: kolab-redis hostname: redis image: redis network_mode: host volumes: - ./docker/redis/redis.conf:/usr/local/etc/redis/redis.conf:ro swoole: build: context: ./docker/swoole/ container_name: kolab-swoole image: apheleia/swoole:4.6.x worker: build: context: ./docker/worker/ container_name: kolab-worker depends_on: - kolab hostname: worker image: kolab-worker network_mode: host tmpfs: - /run - /tmp - /var/run - /var/tmp tty: true volumes: - ./src:/home/worker/src.orig:ro - /sys/fs/cgroup:/sys/fs/cgroup:ro diff --git a/src/.env.example b/src/.env.example index d084b377..3f41a803 100644 --- a/src/.env.example +++ b/src/.env.example @@ -1,148 +1,149 @@ APP_NAME=Kolab APP_ENV=local APP_KEY= APP_DEBUG=true APP_URL=http://127.0.0.1:8000 APP_PUBLIC_URL= APP_DOMAIN=kolabnow.com APP_THEME=default +APP_TENANT_ID=1 APP_LOCALE=en APP_LOCALES=en,de ASSET_URL=http://127.0.0.1:8000 WEBMAIL_URL=/apps SUPPORT_URL=/support SUPPORT_EMAIL= LOG_CHANNEL=stack LOG_SLOW_REQUESTS=5 DB_CONNECTION=mysql DB_DATABASE=kolabdev DB_HOST=127.0.0.1 DB_PASSWORD=kolab DB_PORT=3306 DB_USERNAME=kolabdev BROADCAST_DRIVER=redis CACHE_DRIVER=redis QUEUE_CONNECTION=redis SESSION_DRIVER=file SESSION_LIFETIME=120 MFA_DSN=mysql://roundcube:Welcome2KolabSystems@127.0.0.1/roundcube MFA_TOTP_DIGITS=6 MFA_TOTP_INTERVAL=30 MFA_TOTP_DIGEST=sha1 IMAP_URI=ssl://127.0.0.1:993 IMAP_ADMIN_LOGIN=cyrus-admin IMAP_ADMIN_PASSWORD=Welcome2KolabSystems IMAP_VERIFY_HOST=false IMAP_VERIFY_PEER=false LDAP_BASE_DN="dc=mgmt,dc=com" LDAP_DOMAIN_BASE_DN="ou=Domains,dc=mgmt,dc=com" LDAP_HOSTS=127.0.0.1 LDAP_PORT=389 LDAP_SERVICE_BIND_DN="uid=kolab-service,ou=Special Users,dc=mgmt,dc=com" LDAP_SERVICE_BIND_PW="Welcome2KolabSystems" LDAP_USE_SSL=false LDAP_USE_TLS=false # Administrative LDAP_ADMIN_BIND_DN="cn=Directory Manager" LDAP_ADMIN_BIND_PW="Welcome2KolabSystems" LDAP_ADMIN_ROOT_DN="dc=mgmt,dc=com" # Hosted (public registration) LDAP_HOSTED_BIND_DN="uid=hosted-kolab-service,ou=Special Users,dc=mgmt,dc=com" LDAP_HOSTED_BIND_PW="Welcome2KolabSystems" LDAP_HOSTED_ROOT_DN="dc=hosted,dc=com" OPENVIDU_API_PASSWORD=MY_SECRET OPENVIDU_API_URL=http://localhost:8080/api/ OPENVIDU_API_USERNAME=OPENVIDUAPP OPENVIDU_API_VERIFY_TLS=true OPENVIDU_COTURN_IP=127.0.0.1 OPENVIDU_COTURN_REDIS_DATABASE=2 OPENVIDU_COTURN_REDIS_IP=127.0.0.1 OPENVIDU_COTURN_REDIS_PASSWORD=turn # Used as COTURN_IP, TURN_PUBLIC_IP, for KMS_TURN_URL OPENVIDU_PUBLIC_IP=127.0.0.1 OPENVIDU_PUBLIC_PORT=3478 OPENVIDU_SERVER_PORT=8080 OPENVIDU_WEBHOOK=true OPENVIDU_WEBHOOK_ENDPOINT=http://127.0.0.1:8000/webhooks/meet/openvidu # "CDR" events, see https://docs.openvidu.io/en/2.13.0/reference-docs/openvidu-server-cdr/ #OPENVIDU_WEBHOOK_EVENTS=[sessionCreated,sessionDestroyed,participantJoined,participantLeft,webrtcConnectionCreated,webrtcConnectionDestroyed,recordingStatusChanged,filterEventDispatched,mediaNodeStatusChanged] #OPENVIDU_WEBHOOK_HEADERS=[\"Authorization:\ Basic\ SOMETHING\"] REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null REDIS_PORT=6379 SWOOLE_HOT_RELOAD_ENABLE=true SWOOLE_HTTP_ACCESS_LOG=true SWOOLE_HTTP_HOST=127.0.0.1 SWOOLE_HTTP_PORT=8000 SWOOLE_HTTP_REACTOR_NUM=1 SWOOLE_HTTP_WEBSOCKET=true SWOOLE_HTTP_WORKER_NUM=1 SWOOLE_OB_OUTPUT=true PAYMENT_PROVIDER= MOLLIE_KEY= STRIPE_KEY= STRIPE_PUBLIC_KEY= STRIPE_WEBHOOK_SECRET= MAIL_DRIVER=smtp MAIL_HOST=smtp.mailtrap.io MAIL_PORT=2525 MAIL_USERNAME=null MAIL_PASSWORD=null MAIL_ENCRYPTION=null MAIL_FROM_ADDRESS="noreply@example.com" MAIL_FROM_NAME="Example.com" MAIL_REPLYTO_ADDRESS=null MAIL_REPLYTO_NAME=null DNS_TTL=3600 DNS_SPF="v=spf1 mx -all" DNS_STATIC="%s. MX 10 ext-mx01.mykolab.com." DNS_COPY_FROM=null AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_DEFAULT_REGION=us-east-1 AWS_BUCKET= PUSHER_APP_ID= PUSHER_APP_KEY= PUSHER_APP_SECRET= PUSHER_APP_CLUSTER=mt1 MIX_ASSET_PATH='/' MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}" MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" JWT_SECRET= JWT_TTL=60 COMPANY_NAME= COMPANY_ADDRESS= COMPANY_DETAILS= COMPANY_EMAIL= COMPANY_LOGO= COMPANY_FOOTER= VAT_COUNTRIES=CH,LI VAT_RATE=7.7 KB_ACCOUNT_DELETE= KB_ACCOUNT_SUSPENDED= diff --git a/src/app/Console/Command.php b/src/app/Console/Command.php index 63808a8e..89c6513c 100644 --- a/src/app/Console/Command.php +++ b/src/app/Console/Command.php @@ -1,112 +1,199 @@ getObject(\App\Domain::class, $domain, 'namespace'); + return $this->getObject(\App\Domain::class, $domain, 'namespace', $withDeleted); } /** * Find an object. * * @param string $objectClass The name of the class * @param string $objectIdOrTitle The name of a database field to match. * @param string|null $objectTitle An additional database field to match. + * @param bool $withDeleted Act as if --with-deleted was used * * @return mixed */ - public function getObject($objectClass, $objectIdOrTitle, $objectTitle) + public function getObject($objectClass, $objectIdOrTitle, $objectTitle = null, $withDeleted = false) { - if ($this->hasOption('with-deleted') && $this->option('with-deleted')) { - $object = $objectClass::withTrashed()->find($objectIdOrTitle); - } else { - $object = $objectClass::find($objectIdOrTitle); + if (!$withDeleted) { + $withDeleted = $this->hasOption('with-deleted') && $this->option('with-deleted'); } + $object = $this->getObjectModel($objectClass, $withDeleted)->find($objectIdOrTitle); + if (!$object && !empty($objectTitle)) { - if ($this->hasOption('with-deleted') && $this->option('with-deleted')) { - $object = $objectClass::withTrashed()->where($objectTitle, $objectIdOrTitle)->first(); - } else { - $object = $objectClass::where($objectTitle, $objectIdOrTitle)->first(); - } + $object = $this->getObjectModel($objectClass, $withDeleted) + ->where($objectTitle, $objectIdOrTitle)->first(); } return $object; } + /** + * Returns a preconfigured Model object for a specified class. + * + * @param string $objectClass The name of the class + * @param bool $withDeleted Include withTrashed() query + * + * @return mixed + */ + protected function getObjectModel($objectClass, $withDeleted = false) + { + if ($withDeleted) { + $model = $objectClass::withTrashed(); + } else { + $model = new $objectClass(); + } + + $modelsWithTenant = [ + \App\Discount::class, + \App\Domain::class, + \App\Group::class, + \App\Package::class, + \App\Plan::class, + \App\Sku::class, + \App\User::class, + ]; + + $modelsWithOwner = [ + \App\Wallet::class, + ]; + + $tenant_id = \config('app.tenant_id'); + + // Add tenant filter + if (in_array($objectClass, $modelsWithTenant)) { + $model = $model->withEnvTenant(); + } elseif (in_array($objectClass, $modelsWithOwner)) { + $model = $model->whereExists(function ($query) use ($tenant_id) { + $query->select(DB::raw(1)) + ->from('users') + ->whereRaw('wallets.user_id = users.id') + ->whereRaw('users.tenant_id ' . ($tenant_id ? "= $tenant_id" : 'is null')); + }); + } + + // TODO: tenant check for Entitlement, Transaction, etc. + + return $model; + } + /** * Find the user. * - * @param string $user User ID or email + * @param string $user User ID or email + * @param bool $withDeleted Include deleted * * @return \App\User|null */ - public function getUser($user) + public function getUser($user, $withDeleted = false) { - return $this->getObject(\App\User::class, $user, 'email'); + return $this->getObject(\App\User::class, $user, 'email', $withDeleted); } /** * Find the wallet. * * @param string $wallet Wallet ID * * @return \App\Wallet|null */ public function getWallet($wallet) { return $this->getObject(\App\Wallet::class, $wallet, null); } + public function handle() + { + if ($this->dangerous) { + $this->warn( + "This command is a dangerous scalpel command with potentially significant unintended consequences" + ); + + $confirmation = $this->confirm("Are you sure you understand what's about to happen?"); + + if (!$confirmation) { + $this->info("Better safe than sorry."); + return false; + } + + $this->info("VĂ¡monos!"); + } + + return true; + } + /** * Return a string for output, with any additional attributes specified as well. * * @param mixed $entry An object * * @return string */ protected function toString($entry) { /** * Haven't figured out yet, how to test if this command implements an option for additional * attributes. if (!in_array('attr', $this->options())) { return $entry->{$entry->getKeyName()}; } */ $str = [ $entry->{$entry->getKeyName()} ]; foreach ($this->option('attr') as $attr) { if ($attr == $entry->getKeyName()) { $this->warn("Specifying {$attr} is not useful."); continue; } if (!array_key_exists($attr, $entry->toArray())) { $this->error("Attribute {$attr} isn't available"); continue; } if (is_numeric($entry->{$attr})) { $str[] = $entry->{$attr}; } else { $str[] = !empty($entry->{$attr}) ? $entry->{$attr} : "null"; } } return implode(" ", $str); } } diff --git a/src/app/Console/Commands/DBPing.php b/src/app/Console/Commands/DB/PingCommand.php similarity index 94% rename from src/app/Console/Commands/DBPing.php rename to src/app/Console/Commands/DB/PingCommand.php index 43f807a6..bf524bf1 100644 --- a/src/app/Console/Commands/DBPing.php +++ b/src/app/Console/Commands/DB/PingCommand.php @@ -1,62 +1,62 @@ option('wait')) { while (true) { try { $result = DB::select("SELECT 1"); if (sizeof($result) > 0) { break; } } catch (\Exception $exception) { sleep(1); } } } else { try { $result = DB::select("SELECT 1"); return 0; } catch (\Exception $exception) { return 1; } } } } diff --git a/src/app/Console/Commands/DB/VerifyTimezoneCommand.php b/src/app/Console/Commands/DB/VerifyTimezoneCommand.php new file mode 100644 index 00000000..0a78d3ff --- /dev/null +++ b/src/app/Console/Commands/DB/VerifyTimezoneCommand.php @@ -0,0 +1,57 @@ +error("The application timezone is not configured to be UTC"); + return 1; + } + + if ($result[0]->{'Value'} != '+00:00' && $result[0]->{'Value'} != 'UTC') { + $this->error("The database timezone is neither configured as '+00:00' nor 'UTC'"); + return 1; + } + + return 0; + } +} diff --git a/src/app/Console/Commands/Discount/MergeCommand.php b/src/app/Console/Commands/Discount/MergeCommand.php new file mode 100644 index 00000000..561fc658 --- /dev/null +++ b/src/app/Console/Commands/Discount/MergeCommand.php @@ -0,0 +1,85 @@ + 158f660b-e992-4fb9-ac12-5173b5f33807 \ + * > 62af659f-17d8-4527-87c1-c69eaa26653c \ + * > --description="Employee discount" + * ``` + */ +class MergeCommand extends Command +{ + /** + * The name and signature of the console command. + * + * @var string + */ + protected $signature = 'discount:merge {source} {target} {--description*}'; + + /** + * The console command description. + * + * @var string + */ + protected $description = 'Merge one discount in to another discount, ' . + 'optionally set the description, and delete the source discount'; + + /** + * Execute the console command. + * + * @return mixed + */ + public function handle() + { + $source = $this->getObject(\App\Discount::class, $this->argument('source')); + + if (!$source) { + $this->error("No such source discount: {$source}"); + return 1; + } + + $target = $this->getObject(\App\Discount::class, $this->argument('target')); + + if (!$target) { + $this->error("No such target discount: {$target}"); + return 1; + } + + if ($source->discount !== $target->discount) { + $this->error("Can't merge two discounts that have different rates"); + return 1; + } + + foreach ($source->wallets as $wallet) { + $wallet->discount_id = $target->id; + $wallet->timestamps = false; + $wallet->save(); + } + + if ($this->option('description')) { + $target->description = $this->option('description'); + $target->save(); + } + + $source->delete(); + } +} diff --git a/src/app/Console/Commands/DiscountList.php b/src/app/Console/Commands/DiscountList.php deleted file mode 100644 index 76ab30fe..00000000 --- a/src/app/Console/Commands/DiscountList.php +++ /dev/null @@ -1,61 +0,0 @@ -orderBy('discount')->get()->each( - function ($discount) { - $name = $discount->description; - - if ($discount->code) { - $name .= " [{$discount->code}]"; - } - - $this->info( - sprintf( - "%s %3d%% %s", - $discount->id, - $discount->discount, - $name - ) - ); - } - ); - } -} diff --git a/src/app/Console/Commands/DiscountsCommand.php b/src/app/Console/Commands/DiscountsCommand.php index ad469186..8fc87112 100644 --- a/src/app/Console/Commands/DiscountsCommand.php +++ b/src/app/Console/Commands/DiscountsCommand.php @@ -1,12 +1,35 @@ argument('domain')); // must use withTrashed(), because unique constraint - $domain = Domain::withTrashed()->where('namespace', $namespace)->first(); + $domain = \App\Domain::withTrashed()->where('namespace', $namespace)->first(); if ($domain && !$this->option('force')) { $this->error("Domain {$namespace} already exists."); return 1; } - Queue::fake(); // ignore LDAP for now - if ($domain) { if ($domain->deleted_at) { - // revive domain - $domain->deleted_at = null; - $domain->status = 0; + // set the status back to new + $domain->status = \App\Domain::STATUS_NEW; $domain->save(); // remove existing entitlement - $entitlement = Entitlement::withTrashed()->where( + $entitlement = \App\Entitlement::withTrashed()->where( [ 'entitleable_id' => $domain->id, 'entitleable_type' => \App\Domain::class ] )->first(); if ($entitlement) { $entitlement->forceDelete(); } + + // restore the domain to allow for the observer to handle the create job + $domain->restore(); + + $this->info( + sprintf( + "Domain %s with ID %d revived. Remember to assign it to a wallet with 'domain:set-wallet'", + $domain->namespace, + $domain->id + ) + ); } else { $this->error("Domain {$namespace} not marked as deleted... examine more closely"); return 1; } } else { - $domain = Domain::create([ + $domain = \App\Domain::create( + [ 'namespace' => $namespace, - 'type' => Domain::TYPE_EXTERNAL, - ]); - } + 'type' => \App\Domain::TYPE_EXTERNAL, + ] + ); - $this->info($domain->id); + $this->info( + sprintf( + "Domain %s created with ID %d. Remember to assign it to a wallet with 'domain:set-wallet'", + $domain->namespace, + $domain->id + ) + ); + } } } diff --git a/src/app/Console/Commands/DomainDelete.php b/src/app/Console/Commands/DomainDelete.php index a1d67a89..4f9af503 100644 --- a/src/app/Console/Commands/DomainDelete.php +++ b/src/app/Console/Commands/DomainDelete.php @@ -1,48 +1,38 @@ argument('domain'))->first(); + $domain = $this->getDomain($this->argument('domain')); if (!$domain) { return 1; } $domain->delete(); } } diff --git a/src/app/Console/Commands/DomainList.php b/src/app/Console/Commands/DomainList.php index 4ca0ecf3..9356d45a 100644 --- a/src/app/Console/Commands/DomainList.php +++ b/src/app/Console/Commands/DomainList.php @@ -1,59 +1,49 @@ option('deleted')) { $domains = Domain::withTrashed()->orderBy('namespace'); } else { $domains = Domain::orderBy('namespace'); } - $domains->each( + $domains->withEnvTenant()->each( function ($domain) { $msg = $domain->namespace; if ($domain->deleted_at) { $msg .= " (deleted at {$domain->deleted_at})"; } $this->info($msg); } ); } } diff --git a/src/app/Console/Commands/DomainListUsers.php b/src/app/Console/Commands/DomainListUsers.php index 833dcd1b..717cee68 100644 --- a/src/app/Console/Commands/DomainListUsers.php +++ b/src/app/Console/Commands/DomainListUsers.php @@ -1,83 +1,83 @@ argument('domain'))->first(); + $domain = $this->getDomain($this->argument('domain')); if (!$domain) { return 1; } if ($domain->isPublic()) { $this->error("This domain is a public registration domain."); return 1; } // TODO: actually implement listing users $wallet = $domain->wallet(); if (!$wallet) { $this->error("This domain isn't billed to a wallet."); return 1; } $mailboxSKU = \App\Sku::where('title', 'mailbox')->first(); if (!$mailboxSKU) { $this->error("No mailbox SKU available."); } $entitlements = $wallet->entitlements() ->where('entitleable_type', \App\User::class) ->where('sku_id', $mailboxSKU->id)->get(); $users = []; foreach ($entitlements as $entitlement) { $users[] = $entitlement->entitleable; } usort($users, function ($a, $b) { return $a->email > $b->email; }); foreach ($users as $user) { $this->info($user->email); } } } diff --git a/src/app/Console/Commands/DomainRestore.php b/src/app/Console/Commands/DomainRestore.php index c20a7ef4..79f39569 100644 --- a/src/app/Console/Commands/DomainRestore.php +++ b/src/app/Console/Commands/DomainRestore.php @@ -1,54 +1,54 @@ where('namespace', $this->argument('domain'))->first(); + $domain = $this->getDomain($this->argument('domain'), true); if (!$domain) { $this->error("Domain not found."); return 1; } if (!$domain->trashed()) { $this->error("The domain is not yet deleted."); return 1; } $wallet = $domain->wallet(); if ($wallet && !$wallet->owner) { $this->error("The domain owner is deleted."); return 1; } DB::beginTransaction(); $domain->restore(); DB::commit(); } } diff --git a/src/app/Console/Commands/DomainSetStatus.php b/src/app/Console/Commands/DomainSetStatus.php index c97078d9..b9013439 100644 --- a/src/app/Console/Commands/DomainSetStatus.php +++ b/src/app/Console/Commands/DomainSetStatus.php @@ -1,45 +1,45 @@ argument('domain'))->first(); + $domain = $this->getDomain($this->argument('domain')); if (!$domain) { return 1; } Queue::fake(); // ignore LDAP for now $domain->status = (int) $this->argument('status'); $domain->save(); $this->info($domain->status); } } diff --git a/src/app/Console/Commands/DomainSetWallet.php b/src/app/Console/Commands/DomainSetWallet.php index 9c528411..c4111384 100644 --- a/src/app/Console/Commands/DomainSetWallet.php +++ b/src/app/Console/Commands/DomainSetWallet.php @@ -1,68 +1,69 @@ argument('domain'))->first(); + $domain = $this->getDomain($this->argument('domain')); if (!$domain) { $this->error("Domain not found."); return 1; } - $wallet = Wallet::find($this->argument('wallet')); + $wallet = $this->getWallet($this->argument('wallet')); if (!$wallet) { $this->error("Wallet not found."); return 1; } if ($domain->entitlement) { $this->error("Domain already assigned to a wallet: {$domain->entitlement->wallet->id}."); return 1; } $sku = Sku::where('title', 'domain-hosting')->first(); Queue::fake(); // ignore LDAP for now (note: adding entitlements updates the domain) Entitlement::create( [ 'wallet_id' => $wallet->id, 'sku_id' => $sku->id, 'cost' => 0, + 'fee' => 0, 'entitleable_id' => $domain->id, 'entitleable_type' => Domain::class, ] ); } } diff --git a/src/app/Console/Commands/DomainStatus.php b/src/app/Console/Commands/DomainStatus.php index 80a36067..2197adf2 100644 --- a/src/app/Console/Commands/DomainStatus.php +++ b/src/app/Console/Commands/DomainStatus.php @@ -1,64 +1,54 @@ argument('domain'))->first(); + $domain = $this->getDomain($this->argument('domain')); if (!$domain) { return 1; } $statuses = [ 'active' => Domain::STATUS_ACTIVE, 'suspended' => Domain::STATUS_SUSPENDED, 'deleted' => Domain::STATUS_DELETED, 'ldapReady' => Domain::STATUS_LDAP_READY, 'verified' => Domain::STATUS_VERIFIED, 'confirmed' => Domain::STATUS_CONFIRMED, ]; foreach ($statuses as $text => $bit) { $func = 'is' . \ucfirst($text); $this->info(sprintf("%d %s: %s", $bit, $text, $domain->$func())); } $this->info("In total: {$domain->status}"); } } diff --git a/src/app/Console/Commands/DomainSuspend.php b/src/app/Console/Commands/DomainSuspend.php index 681dd20e..85c3c7db 100644 --- a/src/app/Console/Commands/DomainSuspend.php +++ b/src/app/Console/Commands/DomainSuspend.php @@ -1,51 +1,41 @@ argument('domain'))->first(); + $domain = $this->getDomain($this->argument('domain')); if (!$domain) { return 1; } $this->info("Found domain: {$domain->id}"); $domain->suspend(); } } diff --git a/src/app/Console/Commands/DomainUnsuspend.php b/src/app/Console/Commands/DomainUnsuspend.php index ba92a818..0a17b547 100644 --- a/src/app/Console/Commands/DomainUnsuspend.php +++ b/src/app/Console/Commands/DomainUnsuspend.php @@ -1,51 +1,41 @@ argument('domain'))->first(); + $domain = $this->getDomain($this->argument('domain')); if (!$domain) { return 1; } $this->info("Found domain {$domain->id}"); $domain->unsuspend(); } } diff --git a/src/app/Console/Commands/Group/CreateCommand.php b/src/app/Console/Commands/Group/CreateCommand.php index 6b3109eb..4b1cbc64 100644 --- a/src/app/Console/Commands/Group/CreateCommand.php +++ b/src/app/Console/Commands/Group/CreateCommand.php @@ -1,84 +1,89 @@ argument('email'); $members = $this->option('member'); list($local, $domainName) = explode('@', $email, 2); $domain = $this->getDomain($domainName); if (!$domain) { $this->error("No such domain {$domainName}."); return 1; } if ($domain->isPublic()) { $this->error("Domain {$domainName} is public."); return 1; } $owner = $domain->wallet()->owner; // Validate members addresses foreach ($members as $i => $member) { if ($error = GroupsController::validateMemberEmail($member, $owner)) { $this->error("{$member}: $error"); return 1; } if (\strtolower($member) === \strtolower($email)) { $this->error("{$member}: Cannot be the same as the group address."); return 1; } } // Validate group email address if ($error = GroupsController::validateGroupEmail($email, $owner)) { $this->error("{$email}: {$error}"); return 1; } DB::beginTransaction(); // Create the group $group = new Group(); $group->email = $email; $group->members = $members; $group->save(); $group->assignToWallet($owner->wallets->first()); DB::commit(); $this->info($group->id); } } diff --git a/src/app/Console/Commands/Job/DomainCreate.php b/src/app/Console/Commands/Job/DomainCreate.php index 812feef2..9c6f5716 100644 --- a/src/app/Console/Commands/Job/DomainCreate.php +++ b/src/app/Console/Commands/Job/DomainCreate.php @@ -1,40 +1,40 @@ argument('domain'))->first(); + $domain = $this->getDomain($this->argument('domain')); if (!$domain) { return 1; } $job = new \App\Jobs\Domain\CreateJob($domain->id); $job->handle(); } } diff --git a/src/app/Console/Commands/Job/DomainUpdate.php b/src/app/Console/Commands/Job/DomainUpdate.php index 35f92570..4b207e42 100644 --- a/src/app/Console/Commands/Job/DomainUpdate.php +++ b/src/app/Console/Commands/Job/DomainUpdate.php @@ -1,40 +1,40 @@ argument('domain'))->first(); + $domain = $this->getDomain($this->argument('domain')); if (!$domain) { return 1; } $job = new \App\Jobs\Domain\UpdateJob($domain->id); $job->handle(); } } diff --git a/src/app/Console/Commands/Job/UserCreate.php b/src/app/Console/Commands/Job/UserCreate.php index ce40e45b..d6c4f750 100644 --- a/src/app/Console/Commands/Job/UserCreate.php +++ b/src/app/Console/Commands/Job/UserCreate.php @@ -1,40 +1,40 @@ argument('user'))->first(); + $user = $this->getUser($this->argument('user')); if (!$user) { return 1; } $job = new \App\Jobs\User\CreateJob($user->id); $job->handle(); } } diff --git a/src/app/Console/Commands/Job/UserUpdate.php b/src/app/Console/Commands/Job/UserUpdate.php index b0adf1b4..2b8fe851 100644 --- a/src/app/Console/Commands/Job/UserUpdate.php +++ b/src/app/Console/Commands/Job/UserUpdate.php @@ -1,40 +1,40 @@ argument('user'))->first(); + $user = $this->getUser($this->argument('user')); if (!$user) { return 1; } $job = new \App\Jobs\User\UpdateJob($user->id); $job->handle(); } } diff --git a/src/app/Console/Commands/Job/WalletCheck.php b/src/app/Console/Commands/Job/WalletCheck.php index 5b80f707..f24c8d5e 100644 --- a/src/app/Console/Commands/Job/WalletCheck.php +++ b/src/app/Console/Commands/Job/WalletCheck.php @@ -1,40 +1,40 @@ argument('wallet')); + $wallet = $this->getWallet($this->argument('wallet')); if (!$wallet) { return 1; } $job = new \App\Jobs\WalletCheck($wallet); $job->handle(); } } diff --git a/src/app/Console/Commands/MollieInfo.php b/src/app/Console/Commands/MollieInfo.php index 098ecd3a..f3e013a2 100644 --- a/src/app/Console/Commands/MollieInfo.php +++ b/src/app/Console/Commands/MollieInfo.php @@ -1,89 +1,89 @@ argument('user')) { - $user = User::where('email', $this->argument('user'))->first(); + $user = $this->getUser($this->argument('user')); if (!$user) { return 1; } $this->info("Found user: {$user->id}"); $wallet = $user->wallets->first(); $provider = new \App\Providers\Payment\Mollie(); if ($mandate = $provider->getMandate($wallet)) { $amount = $wallet->getSetting('mandate_amount'); $balance = $wallet->getSetting('mandate_balance') ?: 0; $status = 'invalid'; if ($mandate['isPending']) { $status = 'pending'; } elseif ($mandate['isValid']) { $status = 'valid'; } if ($wallet->getSetting('mandate_disabled')) { $status .= ' (disabled)'; } $this->info("Auto-payment: {$mandate['method']}"); $this->info(" id: {$mandate['id']}"); $this->info(" status: {$status}"); $this->info(" amount: {$amount} {$wallet->currency}"); $this->info(" min-balance: {$balance} {$wallet->currency}"); } else { $this->info("Auto-payment: none"); } // TODO: List user payments history } else { $this->info("Available payment methods:"); foreach (mollie()->methods()->all() as $method) { $this->info("- {$method->description} ({$method->id}):"); $this->info(" status: {$method->status}"); $this->info(sprintf( " min: %s %s", $method->minimumAmount->value, $method->minimumAmount->currency )); if (!empty($method->maximumAmount)) { $this->info(sprintf( " max: %s %s", $method->maximumAmount->value, $method->maximumAmount->currency )); } } } } } diff --git a/src/app/Console/Commands/OpenVidu/RoomCreate.php b/src/app/Console/Commands/OpenVidu/RoomCreate.php index b9343780..a88b5578 100644 --- a/src/app/Console/Commands/OpenVidu/RoomCreate.php +++ b/src/app/Console/Commands/OpenVidu/RoomCreate.php @@ -1,67 +1,57 @@ argument('user'))->first(); + $user = $this->getUser($this->argument('user')); if (!$user) { return 1; } $roomName = $this->argument('room'); if (!preg_match('/^[a-zA-Z0-9_-]{1,16}$/', $roomName)) { $this->error("Invalid room name. Should be up to 16 characters ([a-zA-Z0-9_-])."); return 1; } $room = \App\OpenVidu\Room::where('name', $roomName)->first(); if ($room) { $this->error("Room already exists."); return 1; } \App\OpenVidu\Room::create( [ 'name' => $roomName, 'user_id' => $user->id ] ); } } diff --git a/src/app/Console/Commands/OpenVidu/Rooms.php b/src/app/Console/Commands/OpenVidu/Rooms.php index 77beabc0..597c553f 100644 --- a/src/app/Console/Commands/OpenVidu/Rooms.php +++ b/src/app/Console/Commands/OpenVidu/Rooms.php @@ -1,46 +1,36 @@ info("{$room->name}"); } } } diff --git a/src/app/Console/Commands/OpenVidu/Sessions.php b/src/app/Console/Commands/OpenVidu/Sessions.php index 6b6d8d8f..8894ec61 100644 --- a/src/app/Console/Commands/OpenVidu/Sessions.php +++ b/src/app/Console/Commands/OpenVidu/Sessions.php @@ -1,82 +1,72 @@ \config('openvidu.api_url'), 'verify' => \config('openvidu.api_verify_tls') ] ); $response = $client->request( 'GET', 'sessions', ['auth' => [\config('openvidu.api_username'), \config('openvidu.api_password')]] ); if ($response->getStatusCode() !== 200) { return 1; } $sessionResponse = json_decode($response->getBody(), true); foreach ($sessionResponse['content'] as $session) { $room = \App\OpenVidu\Room::where('session_id', $session['sessionId'])->first(); if ($room) { $owner = $room->owner->email; $roomName = $room->name; } else { $owner = '(none)'; $roomName = '(none)'; } $this->info( sprintf( "Session: %s for %s since %s (by %s)", $session['sessionId'], $roomName, \Carbon\Carbon::parse((int)substr($session['createdAt'], 0, 10), 'UTC'), $owner ) ); } } } diff --git a/src/app/Console/Commands/PackageSkus.php b/src/app/Console/Commands/PackageSkus.php index d04ad369..26ea06bb 100644 --- a/src/app/Console/Commands/PackageSkus.php +++ b/src/app/Console/Commands/PackageSkus.php @@ -1,51 +1,41 @@ get(); foreach ($packages as $package) { $this->info(sprintf("Package: %s", $package->title)); foreach ($package->skus as $sku) { $this->info(sprintf(" SKU: %s (%d)", $sku->title, $sku->pivot->qty)); } } } } diff --git a/src/app/Console/Commands/PlanPackages.php b/src/app/Console/Commands/PlanPackages.php index e406d5f3..b827c4d1 100644 --- a/src/app/Console/Commands/PlanPackages.php +++ b/src/app/Console/Commands/PlanPackages.php @@ -1,87 +1,87 @@ get(); foreach ($plans as $plan) { $this->info(sprintf("Plan: %s", $plan->title)); $plan_costs = 0; foreach ($plan->packages as $package) { $qtyMin = $package->pivot->qty_min; $qtyMax = $package->pivot->qty_max; $discountQty = $package->pivot->discount_qty; $discountRate = (100 - $package->pivot->discount_rate) / 100; $this->info( sprintf( " Package: %s (min: %d, max: %d, discount %d%% after the first %d, base cost: %d)", $package->title, $package->pivot->qty_min, $package->pivot->qty_max, $package->pivot->discount_rate, $package->pivot->discount_qty, $package->cost() ) ); foreach ($package->skus as $sku) { $this->info(sprintf(" SKU: %s (%d)", $sku->title, $sku->pivot->qty)); } if ($qtyMin < $discountQty) { $plan_costs += $qtyMin * $package->cost(); } elseif ($qtyMin == $discountQty) { $plan_costs += $package->cost(); } else { // base rate $plan_costs += $discountQty * $package->cost(); // discounted rate $plan_costs += ($qtyMin - $discountQty) * $package->cost() * $discountRate; } } $this->info(sprintf(" Plan costs per month: %d", $plan_costs)); } } } diff --git a/src/app/Console/Commands/Scalpel/Discount/CreateCommand.php b/src/app/Console/Commands/Scalpel/Discount/CreateCommand.php index 37c5fef9..6ed889b4 100644 --- a/src/app/Console/Commands/Scalpel/Discount/CreateCommand.php +++ b/src/app/Console/Commands/Scalpel/Discount/CreateCommand.php @@ -1,13 +1,15 @@ properties = $this->getProperties(); $entitleable = call_user_func_array( [$this->properties['entitleable_type'], 'find'], [$this->properties['entitleable_id']] ); if (!$entitleable) { $this->error("No such {$this->properties['entitleable_type']}"); return 1; } if (!array_key_exists('entitleable_id', $this->properties)) { $this->error("Specify --entitleable_id"); } if (array_key_exists('sku_id', $this->properties)) { $sku = \App\Sku::find($this->properties['sku_id']); if (!$sku) { $this->error("No such SKU {$this->properties['sku_id']}"); return 1; } if ($this->properties['cost'] == null) { $this->properties['cost'] = $sku->cost; } } if (array_key_exists('wallet_id', $this->properties)) { $wallet = \App\Wallet::find($this->properties['wallet_id']); if (!$wallet) { $this->error("No such wallet {$this->properties['wallet_id']}"); return 1; } } parent::handle(); } } diff --git a/src/app/Console/Commands/Scalpel/Entitlement/ReadCommand.php b/src/app/Console/Commands/Scalpel/Entitlement/ReadCommand.php index fd6437a7..65e33fce 100644 --- a/src/app/Console/Commands/Scalpel/Entitlement/ReadCommand.php +++ b/src/app/Console/Commands/Scalpel/Entitlement/ReadCommand.php @@ -1,13 +1,15 @@ argument('sku')); - - if (!$sku) { - $sku = \App\Sku::where('title', $this->argument('sku'))->first(); - } + $sku = $this->getObject(\App\Sku::class, $this->argument('sku'), 'title'); if (!$sku) { $this->error("Unable to find the SKU."); return 1; } $fn = function ($entitlement) { $user_id = $entitlement->user_id; if ($entitlement->entitleable_type == \App\User::class) { $user_id = $entitlement->entitleable_id; } return $user_id; }; $users = \App\Entitlement::select('user_id', 'entitleable_id', 'entitleable_type') ->join('wallets', 'wallets.id', '=', 'wallet_id') ->where('sku_id', $sku->id) ->get() ->map($fn) ->unique(); // TODO: This wereIn() might not scale \App\User::whereIn('id', $users)->orderBy('email')->get() ->pluck('email') ->each(function ($email, $key) { $this->info($email); }); } } diff --git a/src/app/Console/Commands/StripeInfo.php b/src/app/Console/Commands/StripeInfo.php index de67c474..0d0c0888 100644 --- a/src/app/Console/Commands/StripeInfo.php +++ b/src/app/Console/Commands/StripeInfo.php @@ -1,74 +1,74 @@ argument('user')) { - $user = User::where('email', $this->argument('user'))->first(); + $user = $this->getUser($this->argument('user')); if (!$user) { return 1; } $this->info("Found user: {$user->id}"); $wallet = $user->wallets->first(); $provider = PaymentProvider::factory('stripe'); if ($mandate = $provider->getMandate($wallet)) { $amount = $wallet->getSetting('mandate_amount'); $balance = $wallet->getSetting('mandate_balance') ?: 0; $status = 'invalid'; if ($mandate['isPending']) { $status = 'pending'; } elseif ($mandate['isValid']) { $status = 'valid'; } if ($wallet->getSetting('mandate_disabled')) { $status .= ' (disabled)'; } $this->info("Auto-payment: {$mandate['method']}"); $this->info(" id: {$mandate['id']}"); $this->info(" status: {$status}"); $this->info(" amount: {$amount} {$wallet->currency}"); $this->info(" min-balance: {$balance} {$wallet->currency}"); } else { $this->info("Auto-payment: none"); } // TODO: List user payments history } else { // TODO: Fetch some info/stats from Stripe } } } diff --git a/src/app/Console/Commands/UserAddAlias.php b/src/app/Console/Commands/UserAddAlias.php index 6b87aa7b..59c4d62a 100644 --- a/src/app/Console/Commands/UserAddAlias.php +++ b/src/app/Console/Commands/UserAddAlias.php @@ -1,69 +1,59 @@ argument('user'))->first(); + $user = $this->getUser($this->argument('user')); if (!$user) { 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/UserAssignSku.php index bdc1dd60..e36d6188 100644 --- a/src/app/Console/Commands/UserAssignSku.php +++ b/src/app/Console/Commands/UserAssignSku.php @@ -1,60 +1,56 @@ argument('user'))->first(); + $user = $this->getUser($this->argument('user')); if (!$user) { $this->error("Unable to find the user {$this->argument('user')}."); return 1; } - $sku = \App\Sku::find($this->argument('sku')); - - if (!$sku) { - $sku = \App\Sku::where('title', $this->argument('sku'))->first(); - } + $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/UserDelete.php b/src/app/Console/Commands/UserDelete.php index 780a01fc..eb74376f 100644 --- a/src/app/Console/Commands/UserDelete.php +++ b/src/app/Console/Commands/UserDelete.php @@ -1,48 +1,38 @@ argument('user'))->first(); + $user = $this->getUser($this->argument('user')); if (!$user) { return 1; } $user->delete(); } } diff --git a/src/app/Console/Commands/UserDiscount.php b/src/app/Console/Commands/UserDiscount.php index c363f8a8..5d85a822 100644 --- a/src/app/Console/Commands/UserDiscount.php +++ b/src/app/Console/Commands/UserDiscount.php @@ -1,68 +1,58 @@ argument('user'))->first(); + $user = $this->getUser($this->argument('user')); if (!$user) { return 1; } $this->info("Found user {$user->id}"); if ($this->argument('discount') === '0') { $discount = null; } else { - $discount = \App\Discount::find($this->argument('discount')); + $discount = $this->getObject(\App\Discount::class, $this->argument('discount')); if (!$discount) { 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/UserDomains.php b/src/app/Console/Commands/UserDomains.php index 17ebf5f0..ba2a0fc3 100644 --- a/src/app/Console/Commands/UserDomains.php +++ b/src/app/Console/Commands/UserDomains.php @@ -1,53 +1,40 @@ argument('userid'))->first(); + $user = $this->getUser($this->argument('userid')); if (!$user) { return 1; } foreach ($user->domains() as $domain) { $this->info("{$domain->namespace}"); } } } diff --git a/src/app/Console/Commands/UserEntitlements.php b/src/app/Console/Commands/UserEntitlements.php index 8d8d34ed..e3253b88 100644 --- a/src/app/Console/Commands/UserEntitlements.php +++ b/src/app/Console/Commands/UserEntitlements.php @@ -1,65 +1,53 @@ argument('userid'))->first(); + $user = $this->getUser($this->argument('userid')); if (!$user) { 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 = Sku::find($id); + $sku = \App\Sku::find($id); $this->info("SKU: {$sku->title} ({$qty})"); } } } diff --git a/src/app/Console/Commands/UserForceDelete.php b/src/app/Console/Commands/UserForceDelete.php index 1336ad26..eb818d2a 100644 --- a/src/app/Console/Commands/UserForceDelete.php +++ b/src/app/Console/Commands/UserForceDelete.php @@ -1,46 +1,46 @@ where('email', $this->argument('user'))->first(); + $user = $this->getUser($this->argument('user'), true); if (!$user) { return 1; } if (!$user->trashed()) { $this->error('The user is not yet deleted'); return 1; } DB::beginTransaction(); $user->forceDelete(); DB::commit(); } } diff --git a/src/app/Console/Commands/UserRestore.php b/src/app/Console/Commands/UserRestore.php index 0bd82cff..dbf8906b 100644 --- a/src/app/Console/Commands/UserRestore.php +++ b/src/app/Console/Commands/UserRestore.php @@ -1,47 +1,47 @@ where('email', $this->argument('user'))->first(); + $user = $this->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.'); return 1; } DB::beginTransaction(); $user->restore(); DB::commit(); } } diff --git a/src/app/Console/Commands/UserStatus.php b/src/app/Console/Commands/UserStatus.php index 70b7c885..0a6d26e4 100644 --- a/src/app/Console/Commands/UserStatus.php +++ b/src/app/Console/Commands/UserStatus.php @@ -1,63 +1,53 @@ argument('user'))->first(); + $user = $this->getUser($this->argument('user')); if (!$user) { return 1; } $statuses = [ 'active' => User::STATUS_ACTIVE, 'suspended' => User::STATUS_SUSPENDED, 'deleted' => User::STATUS_DELETED, 'ldapReady' => User::STATUS_LDAP_READY, 'imapReady' => User::STATUS_IMAP_READY, ]; foreach ($statuses as $text => $bit) { $func = 'is' . \ucfirst($text); $this->info(sprintf("%d %s: %s", $bit, $text, $user->$func())); } $this->info("In total: {$user->status}"); } } diff --git a/src/app/Console/Commands/UserSuspend.php b/src/app/Console/Commands/UserSuspend.php index d0f90ede..c213d23b 100644 --- a/src/app/Console/Commands/UserSuspend.php +++ b/src/app/Console/Commands/UserSuspend.php @@ -1,51 +1,41 @@ argument('user'))->first(); + $user = $this->getUser($this->argument('user')); if (!$user) { return 1; } $this->info("Found user: {$user->id}"); $user->suspend(); } } diff --git a/src/app/Console/Commands/UserUnsuspend.php b/src/app/Console/Commands/UserUnsuspend.php index 8497de6a..dbbd32e5 100644 --- a/src/app/Console/Commands/UserUnsuspend.php +++ b/src/app/Console/Commands/UserUnsuspend.php @@ -1,51 +1,40 @@ argument('user'))->first(); + $user = $this->getUser($this->argument('user')); if (!$user) { return 1; } $this->info("Found user {$user->id}"); $user->unsuspend(); } } diff --git a/src/app/Console/Commands/UserVerify.php b/src/app/Console/Commands/UserVerify.php index 366a10a7..5830bf3a 100644 --- a/src/app/Console/Commands/UserVerify.php +++ b/src/app/Console/Commands/UserVerify.php @@ -1,51 +1,41 @@ argument('user'))->first(); + $user = $this->getUser($this->argument('user')); if (!$user) { return 1; } $this->info("Found user: {$user->id}"); $job = new \App\Jobs\User\VerifyJob($user->id); $job->handle(); } } diff --git a/src/app/Console/Commands/UserWallets.php b/src/app/Console/Commands/UserWallets.php index d5424331..f522efa8 100644 --- a/src/app/Console/Commands/UserWallets.php +++ b/src/app/Console/Commands/UserWallets.php @@ -1,50 +1,40 @@ argument('user'))->first(); + $user = $this->getUser($this->argument('user')); if (!$user) { return 1; } foreach ($user->wallets as $wallet) { $this->info("{$wallet->id} {$wallet->description}"); } } } diff --git a/src/app/Console/Commands/WalletAddTransaction.php b/src/app/Console/Commands/WalletAddTransaction.php index 445c9652..2c52a811 100644 --- a/src/app/Console/Commands/WalletAddTransaction.php +++ b/src/app/Console/Commands/WalletAddTransaction.php @@ -1,56 +1,46 @@ argument('wallet')); + $wallet = $this->getWallet($this->argument('wallet')); if (!$wallet) { return 1; } $qty = (int) $this->argument('qty'); $message = $this->option('message'); if ($qty < 0) { $wallet->debit($qty, $message); } else { $wallet->credit($qty, $message); } } } diff --git a/src/app/Console/Commands/WalletBalances.php b/src/app/Console/Commands/WalletBalances.php index 8264fd3a..9eccfabc 100644 --- a/src/app/Console/Commands/WalletBalances.php +++ b/src/app/Console/Commands/WalletBalances.php @@ -1,65 +1,60 @@ each( + $wallets = \App\Wallet::select('wallets.*') + ->join('users', 'users.id', '=', 'wallets.user_id') + ->withEnvTenant('users') + ->all(); + + $wallets->each( function ($wallet) { if ($wallet->balance == 0) { return; } - $user = \App\User::where('id', $wallet->user_id)->first(); + $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/WalletCharge.php index 55637a20..c407e8c9 100644 --- a/src/app/Console/Commands/WalletCharge.php +++ b/src/app/Console/Commands/WalletCharge.php @@ -1,76 +1,67 @@ argument('wallet')) { // Find specified wallet by ID - $wallet = Wallet::find($wallet); + $wallet = $this->getWallet($wallet); if (!$wallet || !$wallet->owner) { return 1; } $wallets = [$wallet]; } else { // Get all wallets, excluding deleted accounts $wallets = Wallet::select('wallets.*') ->join('users', 'users.id', '=', 'wallets.user_id') + ->withEnvTenant('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/WalletDiscount.php b/src/app/Console/Commands/WalletDiscount.php index c0e2327b..30327913 100644 --- a/src/app/Console/Commands/WalletDiscount.php +++ b/src/app/Console/Commands/WalletDiscount.php @@ -1,62 +1,52 @@ argument('wallet'))->first(); + $wallet = $this->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 = \App\Discount::find($this->argument('discount')); + $discount = $this->getObject(\App\Discount::class, $this->argument('discount')); if (!$discount) { return 1; } $wallet->discount()->associate($discount); } $wallet->save(); } } diff --git a/src/app/Console/Commands/WalletExpected.php b/src/app/Console/Commands/WalletExpected.php index 74e7e926..fdeecb38 100644 --- a/src/app/Console/Commands/WalletExpected.php +++ b/src/app/Console/Commands/WalletExpected.php @@ -1,76 +1,68 @@ option('user')) { - $user = \App\User::where('email', $this->option('user')) - ->orWhere('id', $this->option('user'))->first(); + $user = $this->getUser($this->option('user')); if (!$user) { return 1; } $wallets = $user->wallets; } else { - $wallets = \App\Wallet::all(); + $wallets = \App\Wallet::select('wallets.*') + ->join('users', 'users.id', '=', 'wallets.user_id') + ->withEnvTenant('users') + ->all(); } foreach ($wallets as $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; } $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/WalletGetBalance.php index 27f2fc25..661177dc 100644 --- a/src/app/Console/Commands/WalletGetBalance.php +++ b/src/app/Console/Commands/WalletGetBalance.php @@ -1,48 +1,38 @@ argument('wallet')); + $wallet = $this->getWallet($this->argument('wallet')); if (!$wallet) { return 1; } $this->info($wallet->balance); } } diff --git a/src/app/Console/Commands/WalletGetDiscount.php b/src/app/Console/Commands/WalletGetDiscount.php index 6058f84a..6fdbf0ca 100644 --- a/src/app/Console/Commands/WalletGetDiscount.php +++ b/src/app/Console/Commands/WalletGetDiscount.php @@ -1,53 +1,43 @@ argument('wallet')); + $wallet = $this->getWallet($this->argument('wallet')); if (!$wallet) { 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/WalletMandate.php index e3193b59..f13265c5 100644 --- a/src/app/Console/Commands/WalletMandate.php +++ b/src/app/Console/Commands/WalletMandate.php @@ -1,70 +1,60 @@ argument('wallet')); + $wallet = $this->getWallet($this->argument('wallet')); if (!$wallet) { 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/WalletSetBalance.php index 62106119..d3a082bc 100644 --- a/src/app/Console/Commands/WalletSetBalance.php +++ b/src/app/Console/Commands/WalletSetBalance.php @@ -1,49 +1,39 @@ argument('wallet')); + $wallet = $this->getWallet($this->argument('wallet')); if (!$wallet) { return 1; } - $wallet->balance = (int)($this->argument('balance')); + $wallet->balance = (int) $this->argument('balance'); $wallet->save(); } } diff --git a/src/app/Console/Commands/WalletSetDiscount.php b/src/app/Console/Commands/WalletSetDiscount.php index 79e0b45d..50e4e9d6 100644 --- a/src/app/Console/Commands/WalletSetDiscount.php +++ b/src/app/Console/Commands/WalletSetDiscount.php @@ -1,62 +1,52 @@ argument('wallet'))->first(); + $wallet = $this->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 = \App\Discount::find($this->argument('discount')); + $discount = $this->getObject(\App\Discount::class, $this->argument('discount')); if (!$discount) { return 1; } $wallet->discount()->associate($discount); } $wallet->save(); } } diff --git a/src/app/Console/Commands/WalletTransactions.php b/src/app/Console/Commands/WalletTransactions.php index 3be8872d..ed36d08b 100644 --- a/src/app/Console/Commands/WalletTransactions.php +++ b/src/app/Console/Commands/WalletTransactions.php @@ -1,72 +1,62 @@ argument('wallet'))->first(); + $wallet = $this->getWallet($this->argument('wallet')); if (!$wallet) { return 1; } - foreach ($wallet->transactions()->orderBy('created_at')->get() as $transaction) { + $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/WalletUntil.php index 6e544e1d..8d4d7eb2 100644 --- a/src/app/Console/Commands/WalletUntil.php +++ b/src/app/Console/Commands/WalletUntil.php @@ -1,50 +1,40 @@ argument('wallet')); + $wallet = $this->getWallet($this->argument('wallet')); if (!$wallet) { return 1; } $until = $wallet->balanceLastsUntil(); $this->info("Lasts until: " . ($until ? $until->toDateString() : 'unknown')); } } diff --git a/src/app/Console/ObjectUpdateCommand.php b/src/app/Console/ObjectDeleteCommand.php similarity index 74% copy from src/app/Console/ObjectUpdateCommand.php copy to src/app/Console/ObjectDeleteCommand.php index 5633b753..098d2097 100644 --- a/src/app/Console/ObjectUpdateCommand.php +++ b/src/app/Console/ObjectDeleteCommand.php @@ -1,99 +1,95 @@ description = "Update a {$this->objectName}"; + $this->description = "Delete a {$this->objectName}"; $this->signature = sprintf( - "%s%s:update {%s}", + "%s%s:delete {%s}", $this->commandPrefix ? $this->commandPrefix . ":" : "", $this->objectName, $this->objectName ); $class = new $this->objectClass(); try { foreach (Schema::getColumnListing($class->getTable()) as $column) { if ($column == "id") { continue; } $this->signature .= " {--{$column}=}"; } } catch (\Exception $e) { \Log::error("Could not extract options: {$e->getMessage()}"); } $classes = class_uses_recursive($this->objectClass); - if (in_array(SoftDeletes::class, $classes)) { - $this->signature .= " {--with-deleted : Include deleted {$this->objectName}s}"; - } - parent::__construct(); } public function getProperties() { if (!empty($this->properties)) { return $this->properties; } $class = new $this->objectClass(); $this->properties = []; foreach (Schema::getColumnListing($class->getTable()) as $column) { if ($column == "id") { continue; } if (($value = $this->option($column)) !== null) { $this->properties[$column] = $value; } } return $this->properties; } /** * Execute the console command. * * @return mixed */ public function handle() { + $result = parent::handle(); + + if (!$result) { + return 1; + } + $argument = $this->argument($this->objectName); $object = $this->getObject($this->objectClass, $argument, $this->objectTitle); if (!$object) { $this->error("No such {$this->objectName} {$argument}"); return 1; } - foreach ($this->getProperties() as $property => $value) { - if ($property == "deleted_at" && $value == "null") { - $value = null; - } - - $object->{$property} = $value; + if ($this->commandPrefix == 'scalpel') { + $this->objectClass::withoutEvents( + function () use ($object) { + $object->delete(); + } + ); } - - $object->timestamps = false; - - $object->save(['timestamps' => false]); - - $this->cacheRefresh($object); } } diff --git a/src/app/Console/ObjectRelationListCommand.php b/src/app/Console/ObjectRelationListCommand.php index 786dede6..fd9049a9 100644 --- a/src/app/Console/ObjectRelationListCommand.php +++ b/src/app/Console/ObjectRelationListCommand.php @@ -1,90 +1,87 @@ description = "List {$this->objectRelation} for a {$this->objectName}"; $this->signature = sprintf( "%s%s:%s {%s}", $this->commandPrefix ? $this->commandPrefix . ":" : "", $this->objectName, $this->objectRelation, $this->objectName ); $this->signature .= " {--attr=* : Attributes other than the primary unique key to include}"; parent::__construct(); } /** * Execute the console command. * * @return mixed */ public function handle() { $argument = $this->argument($this->objectName); $object = $this->getObject( $this->objectClass, $argument, $this->objectTitle ); if (!$object) { $this->error("No such {$this->objectName} {$argument}"); return 1; } if (method_exists($object, $this->objectRelation)) { $result = call_user_func([$object, $this->objectRelation]); } elseif (property_exists($object, $this->objectRelation)) { $result = $object->{"{$this->objectRelation}"}; } else { $this->error("No such relation {$this->objectRelation}"); return 1; } - if ($result instanceof \Illuminate\Database\Eloquent\Collection) { - $result->each( - function ($entry) { - $this->info($this->toString($entry)); - } - ); - } elseif ($result instanceof \Illuminate\Database\Eloquent\Relations\Relation) { - $result->each( - function ($entry) { - $this->info($this->toString($entry)); - } - ); - } elseif (is_array($result)) { + // Convert query builder into a collection + if ($result instanceof \Illuminate\Database\Eloquent\Relations\Relation) { + $result = $result->get(); + } + + // Print the result + if ( + ($result instanceof \Illuminate\Database\Eloquent\Collection) + || is_array($result) + ) { foreach ($result as $entry) { $this->info($this->toString($entry)); } } else { $this->info($this->toString($result)); } } } diff --git a/src/app/Console/ObjectUpdateCommand.php b/src/app/Console/ObjectUpdateCommand.php index 5633b753..c33cb6ba 100644 --- a/src/app/Console/ObjectUpdateCommand.php +++ b/src/app/Console/ObjectUpdateCommand.php @@ -1,99 +1,107 @@ description = "Update a {$this->objectName}"; $this->signature = sprintf( "%s%s:update {%s}", $this->commandPrefix ? $this->commandPrefix . ":" : "", $this->objectName, $this->objectName ); $class = new $this->objectClass(); try { foreach (Schema::getColumnListing($class->getTable()) as $column) { if ($column == "id") { continue; } $this->signature .= " {--{$column}=}"; } } catch (\Exception $e) { \Log::error("Could not extract options: {$e->getMessage()}"); } $classes = class_uses_recursive($this->objectClass); if (in_array(SoftDeletes::class, $classes)) { $this->signature .= " {--with-deleted : Include deleted {$this->objectName}s}"; } parent::__construct(); } public function getProperties() { if (!empty($this->properties)) { return $this->properties; } $class = new $this->objectClass(); $this->properties = []; foreach (Schema::getColumnListing($class->getTable()) as $column) { if ($column == "id") { continue; } if (($value = $this->option($column)) !== null) { $this->properties[$column] = $value; } } return $this->properties; } /** * Execute the console command. * * @return mixed */ public function handle() { $argument = $this->argument($this->objectName); $object = $this->getObject($this->objectClass, $argument, $this->objectTitle); if (!$object) { $this->error("No such {$this->objectName} {$argument}"); return 1; } foreach ($this->getProperties() as $property => $value) { if ($property == "deleted_at" && $value == "null") { $value = null; } $object->{$property} = $value; } $object->timestamps = false; - $object->save(['timestamps' => false]); + if ($this->commandPrefix == 'scalpel') { + $this->objectClass::withoutEvents( + function () use ($object) { + $object->save(); + } + ); + } else { + $object->save(); + } $this->cacheRefresh($object); } } diff --git a/src/app/Discount.php b/src/app/Discount.php index 32c6da87..3917961e 100644 --- a/src/app/Discount.php +++ b/src/app/Discount.php @@ -1,65 +1,81 @@ 'integer', ]; protected $fillable = [ 'active', 'code', 'description', 'discount', ]; /** @var array Translatable properties */ public $translatable = [ 'description', ]; /** * Discount value mutator * * @throws \Exception */ public function setDiscountAttribute($discount) { $discount = (int) $discount; if ($discount < 0) { \Log::warning("Expecting a discount rate >= 0"); $discount = 0; } if ($discount > 100) { \Log::warning("Expecting a discount rate <= 100"); $discount = 100; } $this->attributes['discount'] = $discount; } + /** + * The tenant for this discount. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function tenant() + { + return $this->belongsTo('App\Tenant', 'tenant_id', 'id'); + } + /** * List of wallets with this discount assigned. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function wallets() { return $this->hasMany('App\Wallet'); } } diff --git a/src/app/Domain.php b/src/app/Domain.php index 21e292ec..2da10979 100644 --- a/src/app/Domain.php +++ b/src/app/Domain.php @@ -1,450 +1,464 @@ 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'); } /** - * Return list of public+active domain names + * Return list of public+active domain names (for current tenant) */ public static function getPublicDomains(): array { - $where = sprintf('(type & %s)', Domain::TYPE_PUBLIC); - - return self::whereRaw($where)->get(['namespace'])->pluck('namespace')->toArray(); + return self::withEnvTenant() + ->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; } /** * Suspend this domain. * * @return void */ public function suspend(): void { if ($this->isSuspended()) { return; } $this->status |= Domain::STATUS_SUSPENDED; $this->save(); } + /** + * The tenant for this domain. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function tenant() + { + return $this->belongsTo('App\Tenant', 'tenant_id', 'id'); + } + /** * Unsuspend this domain. * * The domain is unsuspended through either of the following courses of actions; * * * The account balance has been topped up, or * * a suspected spammer has resolved their issues, or * * the command-line is triggered. * * Therefore, we can also confidently set the domain status to 'active' should the ownership of or management * access to have been confirmed before. * * @return void */ public function unsuspend(): void { if (!$this->isSuspended()) { return; } $this->status ^= Domain::STATUS_SUSPENDED; if ($this->isConfirmed() && $this->isVerified()) { $this->status |= Domain::STATUS_ACTIVE; } $this->save(); } /** * Verify if a domain exists in DNS * * @return bool True if registered, False otherwise * @throws \Exception Throws exception on DNS or DB errors */ public function verify(): bool { if ($this->isVerified()) { return true; } $records = \dns_get_record($this->namespace, DNS_ANY); if ($records === false) { throw new \Exception("Failed to get DNS record for {$this->namespace}"); } // It may happen that result contains other domains depending on the host DNS setup // that's why in_array() and not just !empty() if (in_array($this->namespace, array_column($records, 'host'))) { $this->status |= Domain::STATUS_VERIFIED; $this->save(); return true; } return false; } /** * Returns the wallet by which the domain is controlled * * @return \App\Wallet A wallet object */ public function wallet(): ?Wallet { // Note: Not all domains have a entitlement/wallet $entitlement = $this->entitlement()->withTrashed()->orderBy('created_at', 'desc')->first(); return $entitlement ? $entitlement->wallet : null; } } diff --git a/src/app/Entitlement.php b/src/app/Entitlement.php index 3ab2834b..494b7f2f 100644 --- a/src/app/Entitlement.php +++ b/src/app/Entitlement.php @@ -1,151 +1,161 @@ '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 objects such as 'Domain' or 'User'. * * @return mixed */ public function entitleable() { return $this->morphTo(); } /** * Returns entitleable object title (e.g. email or domain name). * * @return string|null An object title/name */ public function entitleableTitle(): ?string { if ($this->entitleable instanceof \App\User) { return $this->entitleable->email; } if ($this->entitleable instanceof \App\Domain) { return $this->entitleable->namespace; } } /** * 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/Group.php b/src/app/Group.php index 872fce3c..06db042e 100644 --- a/src/app/Group.php +++ b/src/app/Group.php @@ -1,280 +1,292 @@ id)) { throw new \Exception("Group not yet exists"); } if ($this->entitlement()->count()) { throw new \Exception("Group already assigned to a wallet"); } $sku = \App\Sku::where('title', 'group')->first(); $exists = $wallet->entitlements()->where('sku_id', $sku->id)->count(); \App\Entitlement::create([ 'wallet_id' => $wallet->id, 'sku_id' => $sku->id, 'cost' => $exists >= $sku->units_free ? $sku->cost : 0, + 'fee' => $exists >= $sku->units_free ? $sku->fee : 0, 'entitleable_id' => $this->id, 'entitleable_type' => Group::class ]); return $this; } /** * Returns group domain. * * @return ?\App\Domain The domain group belongs to, NULL if it does not exist */ public function domain(): ?Domain { list($local, $domainName) = explode('@', $this->email); return Domain::where('namespace', $domainName)->first(); } /** * Find whether an email address exists as a group (including deleted groups). * * @param string $email Email address * @param bool $return_group Return Group instance instead of boolean * * @return \App\Group|bool True or Group model object if found, False otherwise */ public static function emailExists(string $email, bool $return_group = false) { if (strpos($email, '@') === false) { return false; } $email = \strtolower($email); $group = self::withTrashed()->where('email', $email)->first(); if ($group) { return $return_group ? $group : true; } return false; } /** * The group entitlement. * * @return \Illuminate\Database\Eloquent\Relations\MorphOne */ public function entitlement() { return $this->morphOne('App\Entitlement', 'entitleable'); } /** * Group members propert accessor. Converts internal comma-separated list into an array * * @param string $members Comma-separated list of email addresses * * @return array Email addresses of the group members, as an array */ public function getMembersAttribute($members): array { return $members ? explode(',', $members) : []; } /** * Returns whether this domain is active. * * @return bool */ public function isActive(): bool { return ($this->status & self::STATUS_ACTIVE) > 0; } /** * Returns whether this domain is deleted. * * @return bool */ public function isDeleted(): bool { return ($this->status & self::STATUS_DELETED) > 0; } /** * Returns whether this domain is new. * * @return bool */ public function isNew(): bool { return ($this->status & self::STATUS_NEW) > 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; } /** * Ensure the email is appropriately cased. * * @param string $email Group email address */ public function setEmailAttribute(string $email) { $this->attributes['email'] = strtolower($email); } /** * Ensure the members are appropriately formatted. * * @param array $members Email addresses of the group members */ public function setMembersAttribute(array $members): void { $members = array_unique(array_filter(array_map('strtolower', $members))); sort($members); $this->attributes['members'] = implode(',', $members); } /** * Group status mutator * * @throws \Exception */ public function setStatusAttribute($status) { $new_status = 0; $allowed_values = [ self::STATUS_NEW, self::STATUS_ACTIVE, self::STATUS_SUSPENDED, self::STATUS_DELETED, self::STATUS_LDAP_READY, ]; foreach ($allowed_values as $value) { if ($status & $value) { $new_status |= $value; $status ^= $value; } } if ($status > 0) { throw new \Exception("Invalid group status: {$status}"); } $this->attributes['status'] = $new_status; } /** * Suspend this group. * * @return void */ public function suspend(): void { if ($this->isSuspended()) { return; } $this->status |= Group::STATUS_SUSPENDED; $this->save(); } + /** + * The tenant for this group. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function tenant() + { + return $this->belongsTo('App\Tenant', 'tenant_id', 'id'); + } + /** * Unsuspend this group. * * @return void */ public function unsuspend(): void { if (!$this->isSuspended()) { return; } $this->status ^= Group::STATUS_SUSPENDED; $this->save(); } /** * Returns the wallet by which the group 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/Http/Controllers/API/SignupController.php b/src/app/Http/Controllers/API/SignupController.php index 754e393c..faa4fe11 100644 --- a/src/app/Http/Controllers/API/SignupController.php +++ b/src/app/Http/Controllers/API/SignupController.php @@ -1,378 +1,447 @@ orderByDesc('title')->get()->map(function ($plan) use (&$plans) { $plans[] = [ 'title' => $plan->title, 'name' => $plan->name, 'button' => __('app.planbutton', ['plan' => $plan->name]), 'description' => $plan->description, ]; }); return response()->json(['status' => 'success', 'plans' => $plans]); } /** * Starts signup process. * * Verifies user name and email/phone, sends verification email/sms message. * Returns the verification code. * * @param \Illuminate\Http\Request $request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function init(Request $request) { // Check required fields $v = Validator::make( $request->all(), [ 'email' => 'required', 'first_name' => 'max:128', 'last_name' => 'max:128', 'plan' => 'nullable|alpha_num|max:128', 'voucher' => 'max:32', ] ); $is_phone = false; $errors = $v->fails() ? $v->errors()->toArray() : []; // Validate user email (or phone) if (empty($errors['email'])) { if ($error = $this->validatePhoneOrEmail($request->email, $is_phone)) { $errors['email'] = $error; } } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } // Generate the verification code $code = SignupCode::create([ 'email' => $request->email, 'first_name' => $request->first_name, 'last_name' => $request->last_name, 'plan' => $request->plan, 'voucher' => $request->voucher, ]); // Send email/sms message if ($is_phone) { SignupVerificationSMS::dispatch($code); } else { SignupVerificationEmail::dispatch($code); } return response()->json(['status' => 'success', 'code' => $code->code]); } + /** + * Returns signup invitation information. + * + * @param string $id Signup invitation identifier + * + * @return \Illuminate\Http\JsonResponse|void + */ + public function invitation($id) + { + $invitation = SignupInvitation::withEnvTenant()->find($id); + + if (empty($invitation) || $invitation->isCompleted()) { + return $this->errorResponse(404); + } + + $has_domain = $this->getPlan()->hasDomain(); + + $result = [ + 'id' => $id, + 'is_domain' => $has_domain, + 'domains' => $has_domain ? [] : Domain::getPublicDomains(), + ]; + + return response()->json($result); + } + + /** * Validation of the verification code. * * @param \Illuminate\Http\Request $request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function verify(Request $request) { // Validate the request args $v = Validator::make( $request->all(), [ 'code' => 'required', 'short_code' => 'required', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } // Validate the verification code $code = SignupCode::find($request->code); if ( empty($code) || $code->isExpired() || Str::upper($request->short_code) !== Str::upper($code->short_code) ) { $errors = ['short_code' => "The code is invalid or expired."]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } // For signup last-step mode remember the code object, so we can delete it // with single SQL query (->delete()) instead of two (::destroy()) $this->code = $code; $has_domain = $this->getPlan()->hasDomain(); // Return user name and email/phone/voucher from the codes database, // domains list for selection and "plan type" flag return response()->json([ 'status' => 'success', 'email' => $code->email, 'first_name' => $code->first_name, 'last_name' => $code->last_name, 'voucher' => $code->voucher, 'is_domain' => $has_domain, 'domains' => $has_domain ? [] : Domain::getPublicDomains(), ]); } /** * Finishes the signup process by creating the user account. * * @param \Illuminate\Http\Request $request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function signup(Request $request) { // Validate input $v = Validator::make( $request->all(), [ 'login' => 'required|min:2', 'password' => 'required|min:4|confirmed', 'domain' => 'required', 'voucher' => 'max:32', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } - // Validate verification codes (again) - $v = $this->verify($request); - if ($v->status() !== 200) { - return $v; + // Signup via invitation + if ($request->invitation) { + $invitation = SignupInvitation::withEnvTenant()->find($request->invitation); + + if (empty($invitation) || $invitation->isCompleted()) { + return $this->errorResponse(404); + } + + // Check required fields + $v = Validator::make( + $request->all(), + [ + 'first_name' => 'max:128', + 'last_name' => 'max:128', + 'voucher' => 'max:32', + ] + ); + + $errors = $v->fails() ? $v->errors()->toArray() : []; + + if (!empty($errors)) { + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + + $settings = [ + 'external_email' => $invitation->email, + 'first_name' => $request->first_name, + 'last_name' => $request->last_name, + ]; + } else { + // Validate verification codes (again) + $v = $this->verify($request); + if ($v->status() !== 200) { + return $v; + } + + // Get user name/email from the verification code database + $code_data = $v->getData(); + + $settings = [ + 'external_email' => $code_data->email, + 'first_name' => $code_data->first_name, + 'last_name' => $code_data->last_name, + ]; } // Find the voucher discount if ($request->voucher) { $discount = Discount::where('code', \strtoupper($request->voucher)) ->where('active', true)->first(); if (!$discount) { $errors = ['voucher' => \trans('validation.voucherinvalid')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } } // Get the plan $plan = $this->getPlan(); $is_domain = $plan->hasDomain(); $login = $request->login; $domain_name = $request->domain; // Validate login if ($errors = self::validateLogin($login, $domain_name, $is_domain)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } - // Get user name/email from the verification code database - $code_data = $v->getData(); - $user_email = $code_data->email; - // We allow only ASCII, so we can safely lower-case the email address $login = Str::lower($login); $domain_name = Str::lower($domain_name); $domain = null; DB::beginTransaction(); // Create domain record if ($is_domain) { $domain = Domain::create([ 'namespace' => $domain_name, 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ]); } // Create user record $user = User::create([ 'email' => $login . '@' . $domain_name, 'password' => $request->password, ]); if (!empty($discount)) { $wallet = $user->wallets()->first(); $wallet->discount()->associate($discount); $wallet->save(); } $user->assignPlan($plan, $domain); // Save the external email and plan in user settings - $user->setSettings([ - 'external_email' => $user_email, - 'first_name' => $code_data->first_name, - 'last_name' => $code_data->last_name, - ]); + $user->setSettings($settings); + + // Update the invitation + if (!empty($invitation)) { + $invitation->status = SignupInvitation::STATUS_COMPLETED; + $invitation->user_id = $user->id; + $invitation->save(); + } // Remove the verification code - $this->code->delete(); + if ($this->code) { + $this->code->delete(); + } DB::commit(); return AuthController::logonResponse($user); } /** * Returns plan for the signup process * * @returns \App\Plan Plan object selected for current signup process */ protected function getPlan() { if (!$this->plan) { // Get the plan if specified and exists... if ($this->code && $this->code->plan) { $plan = Plan::where('title', $this->code->plan)->first(); } // ...otherwise use the default plan if (empty($plan)) { // TODO: Get default plan title from config $plan = Plan::where('title', 'individual')->first(); } $this->plan = $plan; } return $this->plan; } /** * Checks if the input string is a valid email address or a phone number * * @param string $input Email address or phone number * @param bool $is_phone Will have been set to True if the string is valid phone number * * @return string Error message on validation error */ protected static function validatePhoneOrEmail($input, &$is_phone = false): ?string { $is_phone = false; $v = Validator::make( ['email' => $input], ['email' => ['required', 'string', new ExternalEmail()]] ); if ($v->fails()) { return $v->errors()->toArray()['email'][0]; } // TODO: Phone number support /* $input = str_replace(array('-', ' '), '', $input); if (!preg_match('/^\+?[0-9]{9,12}$/', $input)) { return \trans('validation.noemailorphone'); } $is_phone = true; */ return null; } /** * Login (kolab identity) validation * * @param string $login Login (local part of an email address) * @param string $domain Domain name * @param bool $external Enables additional checks for domain part * * @return array Error messages on validation error */ protected static function validateLogin($login, $domain, $external = false): ?array { // Validate login part alone $v = Validator::make( ['login' => $login], ['login' => ['required', 'string', new UserEmailLocal($external)]] ); if ($v->fails()) { return ['login' => $v->errors()->toArray()['login'][0]]; } $domains = $external ? null : Domain::getPublicDomains(); // Validate the domain $v = Validator::make( ['domain' => $domain], ['domain' => ['required', 'string', new UserEmailDomain($domains)]] ); if ($v->fails()) { return ['domain' => $v->errors()->toArray()['domain'][0]]; } $domain = Str::lower($domain); // Check if domain is already registered with us if ($external) { if (Domain::where('namespace', $domain)->first()) { return ['domain' => \trans('validation.domainexists')]; } } // Check if user with specified login already exists $email = $login . '@' . $domain; if (User::emailExists($email) || User::aliasExists($email) || \App\Group::emailExists($email)) { return ['login' => \trans('validation.loginexists')]; } return null; } } diff --git a/src/app/Http/Controllers/API/V4/Admin/DiscountsController.php b/src/app/Http/Controllers/API/V4/Admin/DiscountsController.php index e6487db4..072d4981 100644 --- a/src/app/Http/Controllers/API/V4/Admin/DiscountsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/DiscountsController.php @@ -1,42 +1,45 @@ orderBy('discount')->get() + Discount::withEnvTenant() + ->where('active', true) + ->orderBy('discount') + ->get() ->map(function ($discount) use (&$discounts) { $label = $discount->discount . '% - ' . $discount->description; if ($discount->code) { $label .= " [{$discount->code}]"; } $discounts[] = [ 'id' => $discount->id, 'discount' => $discount->discount, 'code' => $discount->code, 'description' => $discount->description, 'label' => $label, ]; }); return response()->json([ 'status' => 'success', 'list' => $discounts, 'count' => count($discounts), ]); } } diff --git a/src/app/Http/Controllers/API/V4/Admin/DomainsController.php b/src/app/Http/Controllers/API/V4/Admin/DomainsController.php index e83d929a..aead2568 100644 --- a/src/app/Http/Controllers/API/V4/Admin/DomainsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/DomainsController.php @@ -1,104 +1,104 @@ input('search')); $owner = trim(request()->input('owner')); $result = collect([]); if ($owner) { - if ($owner = User::find($owner)) { + if ($owner = User::withEnvTenant()->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()) { + if ($domain = Domain::withEnvTenant()->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); } /** * 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); + $domain = Domain::withEnvTenant()->find($id); if (empty($domain) || $domain->isPublic()) { return $this->errorResponse(404); } $domain->suspend(); return response()->json([ 'status' => 'success', 'message' => __('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); + $domain = Domain::withEnvTenant()->find($id); if (empty($domain) || $domain->isPublic()) { return $this->errorResponse(404); } $domain->unsuspend(); return response()->json([ 'status' => 'success', 'message' => __('app.domain-unsuspend-success'), ]); } } diff --git a/src/app/Http/Controllers/API/V4/Admin/EntitlementsController.php b/src/app/Http/Controllers/API/V4/Admin/EntitlementsController.php deleted file mode 100644 index 1e7b3952..00000000 --- a/src/app/Http/Controllers/API/V4/Admin/EntitlementsController.php +++ /dev/null @@ -1,7 +0,0 @@ -input('search')); $owner = trim(request()->input('owner')); $result = collect([]); if ($owner) { - if ($owner = User::find($owner)) { + if ($owner = User::withEnvTenant()->find($owner)) { foreach ($owner->wallets as $wallet) { $wallet->entitlements()->where('entitleable_type', Group::class)->get() ->each(function ($entitlement) use ($result) { $result->push($entitlement->entitleable); }); } $result = $result->sortBy('namespace')->values(); } } elseif (!empty($search)) { - if ($group = Group::where('email', $search)->first()) { + if ($group = Group::withEnvTenant()->where('email', $search)->first()) { $result->push($group); } } // Process the result $result = $result->map(function ($group) { $data = [ 'id' => $group->id, 'email' => $group->email, ]; $data = array_merge($data, self::groupStatuses($group)); return $data; }); $result = [ 'list' => $result, 'count' => count($result), 'message' => \trans('app.search-foundxdistlists', ['x' => count($result)]), ]; return response()->json($result); } /** * Create a new group. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { return $this->errorResponse(404); } /** * Suspend a group * * @param \Illuminate\Http\Request $request The API request. * @param string $id Group identifier * * @return \Illuminate\Http\JsonResponse The response */ public function suspend(Request $request, $id) { - $group = Group::find($id); + $group = Group::withEnvTenant()->find($id); if (empty($group)) { return $this->errorResponse(404); } $group->suspend(); return response()->json([ 'status' => 'success', 'message' => __('app.distlist-suspend-success'), ]); } /** * Un-Suspend a group * * @param \Illuminate\Http\Request $request The API request. * @param string $id Group identifier * * @return \Illuminate\Http\JsonResponse The response */ public function unsuspend(Request $request, $id) { - $group = Group::find($id); + $group = Group::withEnvTenant()->find($id); if (empty($group)) { return $this->errorResponse(404); } $group->unsuspend(); return response()->json([ 'status' => 'success', 'message' => __('app.distlist-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 c7655f00..bf5dccde 100644 --- a/src/app/Http/Controllers/API/V4/Admin/StatsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/StatsController.php @@ -1,316 +1,369 @@ errorResponse(404); } $method = 'chart' . implode('', array_map('ucfirst', explode('-', $chart))); - if (!method_exists($this, $method)) { + 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') - ->pluck('cnt', 'discount') - ->all(); + ->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 ] ] ] ]; } /** * 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); $payments = DB::table('payments') ->selectRaw("date_format(updated_at, '%Y-%v') as period, sum(amount) as amount") ->where('updated_at', '>=', $start->toDateString()) ->where('status', PaymentProvider::STATUS_PAID) ->whereIn('type', [PaymentProvider::TYPE_ONEOFF, PaymentProvider::TYPE_RECURRING]) - ->groupByRaw('1') + ->groupByRaw('1'); + + $addTenantScope = function ($builder, $tenantId) { + $where = '`wallet_id` IN (' + . 'select `id` from `wallets` ' + . 'join `users` on (`wallets`.`user_id` = `users`.`id`) ' + . 'where `payments`.`wallet_id` = `wallets`.`id` ' + . 'and `users`.`tenant_id` = ' . intval($tenantId) + . ')'; + + return $builder->whereRaw($where); + }; + + $payments = $this->applyTenantScope($payments, $addTenantScope) ->pluck('amount', 'period') ->map(function ($amount) { return $amount / 100; }); // TODO: exclude refunds/chargebacks $empty = array_fill_keys($labels, 0); $payments = array_values(array_merge($empty, $payments->all())); // $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 CHF - last 8 weeks', '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') - ->get(); + ->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') - ->get(); + ->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', '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', 'chartType' => 'bar', 'values' => $created ], [ 'name' => 'Deleted', 'chartType' => 'line', 'values' => $deleted ] ], 'yMarkers' => [ [ 'label' => sprintf('average = %.1f', $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') - ->get(); + ->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') - ->get(); + ->groupByRaw('1'); - $count = DB::table('users')->whereNull('deleted_at')->count(); + $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', 'type' => 'line', 'colors' => [self::COLOR_GREEN], 'axisOptions' => [ 'xIsSeries' => true, 'xAxisMode' => 'tick', ], 'lineOptions' => [ 'hideDots' => true, 'regionFill' => true, ], 'data' => [ 'labels' => $labels, 'datasets' => [ [ // 'name' => 'Existing', 'values' => $all ] ] ] ]; } + + /** + * 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) + { + $user = Auth::guard()->user(); + + if ($user->role == 'reseller') { + if ($addQuery) { + $query = $addQuery($query, \config('app.tenant_id')); + } else { + $query = $query->withEnvTenant(); + } + } + + // TODO: Tenant selector for admins + + return $query; + } } diff --git a/src/app/Http/Controllers/API/V4/Admin/UsersController.php b/src/app/Http/Controllers/API/V4/Admin/UsersController.php index 867574f7..a430b675 100644 --- a/src/app/Http/Controllers/API/V4/Admin/UsersController.php +++ b/src/app/Http/Controllers/API/V4/Admin/UsersController.php @@ -1,207 +1,257 @@ errorResponse(404); + } + /** * Searching of user accounts. * * @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)) { - $result = $owner->users(false)->orderBy('email')->get(); + $owner = User::where('id', $owner) + ->withEnvTenant() + ->whereNull('role') + ->first(); + + if ($owner) { + $result = $owner->users(false)->whereNull('role')->orderBy('email')->get(); } } elseif (strpos($search, '@')) { // Search by email $result = User::withTrashed()->where('email', $search) - ->orderBy('email')->get(); + ->withEnvTenant() + ->whereNull('role') + ->orderBy('email') + ->get(); if ($result->isEmpty()) { // Search by an alias $user_ids = UserAlias::where('alias', $search)->get()->pluck('user_id'); // Search by an external email $ext_user_ids = UserSetting::where('key', 'external_email') - ->where('value', $search)->get()->pluck('user_id'); + ->where('value', $search) + ->get() + ->pluck('user_id'); $user_ids = $user_ids->merge($ext_user_ids)->unique(); // Search by a distribution list email if ($group = Group::withTrashed()->where('email', $search)->first()) { $user_ids = $user_ids->merge([$group->wallet()->user_id])->unique(); } if (!$user_ids->isEmpty()) { $result = User::withTrashed()->whereIn('id', $user_ids) - ->orderBy('email')->get(); + ->withEnvTenant() + ->whereNull('role') + ->orderBy('email') + ->get(); } } } elseif (is_numeric($search)) { // Search by user ID - if ($user = User::withTrashed()->find($search)) { + $user = User::withTrashed()->where('id', $search) + ->withEnvTenant() + ->whereNull('role') + ->first(); + + if ($user) { $result->push($user); } } elseif (!empty($search)) { // Search by domain - if ($domain = Domain::withTrashed()->where('namespace', $search)->first()) { - if ($wallet = $domain->wallet()) { - $result->push($wallet->owner()->withTrashed()->first()); + $domain = Domain::withTrashed()->where('namespace', $search) + ->withEnvTenant() + ->first(); + + if ($domain) { + if ( + ($wallet = $domain->wallet()) + && ($owner = $wallet->owner()->withTrashed()->withEnvTenant()->first()) + && empty($owner->role) + ) { + $result->push($owner); } } } // Process the result $result = $result->map(function ($user) { $data = $user->toArray(); $data = array_merge($data, self::userStatuses($user)); return $data; }); $result = [ 'list' => $result, 'count' => count($result), 'message' => \trans('app.search-foundxusers', ['x' => count($result)]), ]; return response()->json($result); } /** * Reset 2-Factor Authentication for the user * * @param \Illuminate\Http\Request $request The API request. * @param string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function reset2FA(Request $request, $id) { - $user = User::find($id); + $user = User::withEnvTenant()->find($id); - if (empty($user)) { + if (empty($user) || !$this->guard()->user()->canUpdate($user)) { return $this->errorResponse(404); } $sku = Sku::where('title', '2fa')->first(); // Note: we do select first, so the observer can delete // 2FA preferences from Roundcube database, so don't // be tempted to replace first() with delete() below $entitlement = $user->entitlements()->where('sku_id', $sku->id)->first(); $entitlement->delete(); return response()->json([ 'status' => 'success', 'message' => __('app.user-reset-2fa-success'), ]); } + /** + * Create a new user record. + * + * @param \Illuminate\Http\Request $request The API request. + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function store(Request $request) + { + return $this->errorResponse(404); + } + /** * Suspend the user * * @param \Illuminate\Http\Request $request The API request. * @param string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function suspend(Request $request, $id) { - $user = User::find($id); + $user = User::withEnvTenant()->find($id); - if (empty($user)) { + if (empty($user) || !$this->guard()->user()->canUpdate($user)) { return $this->errorResponse(404); } $user->suspend(); return response()->json([ 'status' => 'success', 'message' => __('app.user-suspend-success'), ]); } /** * Un-Suspend the user * * @param \Illuminate\Http\Request $request The API request. * @param string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function unsuspend(Request $request, $id) { - $user = User::find($id); + $user = User::withEnvTenant()->find($id); - if (empty($user)) { + if (empty($user) || !$this->guard()->user()->canUpdate($user)) { return $this->errorResponse(404); } $user->unsuspend(); return response()->json([ 'status' => 'success', 'message' => __('app.user-unsuspend-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::find($id); + $user = User::withEnvTenant()->find($id); - if (empty($user)) { + if (empty($user) || !$this->guard()->user()->canUpdate($user)) { return $this->errorResponse(404); } // For now admins can change only user external email address $rules = []; if (array_key_exists('external_email', $request->input())) { $rules['external_email'] = 'email'; } // Validate input $v = Validator::make($request->all(), $rules); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } // Update user settings $settings = $request->only(array_keys($rules)); if (!empty($settings)) { $user->setSettings($settings); } return response()->json([ 'status' => 'success', 'message' => __('app.user-update-success'), ]); } } diff --git a/src/app/Http/Controllers/API/V4/Admin/WalletsController.php b/src/app/Http/Controllers/API/V4/Admin/WalletsController.php index f03e0563..586cc146 100644 --- a/src/app/Http/Controllers/API/V4/Admin/WalletsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/WalletsController.php @@ -1,148 +1,159 @@ user()->canRead($wallet)) { return $this->errorResponse(404); } $result = $wallet->toArray(); $result['discount'] = 0; $result['discount_description'] = ''; if ($wallet->discount) { $result['discount'] = $wallet->discount->discount; $result['discount_description'] = $wallet->discount->description; } $result['mandate'] = PaymentsController::walletMandate($wallet); $provider = PaymentProvider::factory($wallet); $result['provider'] = $provider->name(); $result['providerLink'] = $provider->customerLink($wallet); + $result['notice'] = $this->getWalletNotice($wallet); // for resellers return response()->json($result); } /** * Award/penalize a wallet. * * @param \Illuminate\Http\Request $request The API request. * @param string $id Wallet identifier * * @return \Illuminate\Http\JsonResponse The response */ public function oneOff(Request $request, $id) { $wallet = Wallet::find($id); + $user = Auth::guard()->user(); - if (empty($wallet)) { + if (empty($wallet) || !$user->canRead($wallet)) { return $this->errorResponse(404); } // Check required fields $v = Validator::make( $request->all(), [ 'amount' => 'required|numeric', 'description' => 'required|string|max:1024', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } $amount = (int) ($request->amount * 100); $type = $amount > 0 ? Transaction::WALLET_AWARD : Transaction::WALLET_PENALTY; DB::beginTransaction(); $wallet->balance += $amount; $wallet->save(); Transaction::create( [ 'user_email' => \App\Utils::userEmailOrNull(), 'object_id' => $wallet->id, 'object_type' => Wallet::class, 'type' => $type, 'amount' => $amount, 'description' => $request->description ] ); + if ($user->role == 'reseller') { + if ($user->tenant && ($tenant_wallet = $user->tenant->wallet())) { + $desc = ($amount > 0 ? 'Awarded' : 'Penalized') . " user {$wallet->owner->email}"; + $method = $amount > 0 ? 'debit' : 'credit'; + $tenant_wallet->{$method}(abs($amount), $desc); + } + } + DB::commit(); $response = [ 'status' => 'success', 'message' => \trans("app.wallet-{$type}-success"), 'balance' => $wallet->balance ]; return response()->json($response); } /** * Update wallet data. * * @param \Illuminate\Http\Request $request The API request. * @param string $id Wallet identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { $wallet = Wallet::find($id); - if (empty($wallet)) { + if (empty($wallet) || !Auth::guard()->user()->canRead($wallet)) { return $this->errorResponse(404); } if (array_key_exists('discount', $request->input())) { if (empty($request->discount)) { $wallet->discount()->dissociate(); $wallet->save(); - } elseif ($discount = Discount::find($request->discount)) { + } elseif ($discount = Discount::withEnvTenant()->find($request->discount)) { $wallet->discount()->associate($discount); $wallet->save(); } } $response = $wallet->toArray(); if ($wallet->discount) { $response['discount'] = $wallet->discount->discount; $response['discount_description'] = $wallet->discount->description; } $response['status'] = 'success'; $response['message'] = \trans('app.wallet-update-success'); return response()->json($response); } } diff --git a/src/app/Http/Controllers/API/V4/DomainsController.php b/src/app/Http/Controllers/API/V4/DomainsController.php index 631891ff..c33308ce 100644 --- a/src/app/Http/Controllers/API/V4/DomainsController.php +++ b/src/app/Http/Controllers/API/V4/DomainsController.php @@ -1,381 +1,381 @@ user(); $list = []; foreach ($user->domains() as $domain) { if (!$domain->isPublic()) { $data = $domain->toArray(); $data = array_merge($data, self::domainStatuses($domain)); $list[] = $data; } } return response()->json($list); } /** * Show the form for creating a new resource. * * @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::findOrFail($id); // Only owner (or admin) has access to the domain if (!Auth::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. * * @param int $id * * @return \Illuminate\Http\JsonResponse */ public function destroy($id) { return $this->errorResponse(404); } /** * Show the form for editing the specified resource. * * @param int $id * * @return \Illuminate\Http\JsonResponse */ public function edit($id) { return $this->errorResponse(404); } /** * Store a newly created resource in storage. * * @param \Illuminate\Http\Request $request * * @return \Illuminate\Http\JsonResponse */ public function store(Request $request) { return $this->errorResponse(404); } /** * Get the information about the specified domain. * * @param int $id Domain identifier * * @return \Illuminate\Http\JsonResponse|void */ public function show($id) { - $domain = Domain::findOrFail($id); + $domain = Domain::withEnvTenant()->findOrFail($id); // Only owner (or admin) has access to the domain if (!Auth::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['config'] = self::getMXConfig($domain->namespace); // Status info $response['statusInfo'] = self::statusInfo($domain); $response = array_merge($response, self::domainStatuses($domain)); 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); + $domain = Domain::withEnvTenant()->findOrFail($id); // Only owner (or admin) has access to the domain if (!Auth::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. * * @param \Illuminate\Http\Request $request * @param int $id * * @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/EntitlementsController.php b/src/app/Http/Controllers/API/V4/EntitlementsController.php deleted file mode 100644 index 8fc4ffa2..00000000 --- a/src/app/Http/Controllers/API/V4/EntitlementsController.php +++ /dev/null @@ -1,97 +0,0 @@ -errorResponse(404); - } - - /** - * Remove the specified resource from storage. - * - * @param int $id - * - * @return \Illuminate\Http\JsonResponse - */ - public function destroy($id) - { - // TODO - return $this->errorResponse(404); - } - - /** - * Show the form for editing the specified resource. - * - * @param int $id - * - * @return \Illuminate\Http\JsonResponse - */ - public function edit($id) - { - // TODO - return $this->errorResponse(404); - } - - /** - * Display a listing of the resource. - * - * @return \Illuminate\Http\JsonResponse - */ - public function index() - { - // TODO - return $this->errorResponse(404); - } - - /** - * Store a newly created resource in storage. - * - * @param \Illuminate\Http\Request $request - * - * @return \Illuminate\Http\JsonResponse - */ - public function store(Request $request) - { - // TODO - return $this->errorResponse(404); - } - - /** - * Display the specified resource. - * - * @param int $id - * - * @return \Illuminate\Http\JsonResponse - */ - public function show($id) - { - // TODO - return $this->errorResponse(404); - } - - /** - * Update the specified resource in storage. - * - * @param \Illuminate\Http\Request $request - * @param int $id - * - * @return \Illuminate\Http\JsonResponse - */ - public function update(Request $request, $id) - { - // TODO - return $this->errorResponse(404); - } -} diff --git a/src/app/Http/Controllers/API/V4/GroupsController.php b/src/app/Http/Controllers/API/V4/GroupsController.php index e8c5265a..0c2e3daa 100644 --- a/src/app/Http/Controllers/API/V4/GroupsController.php +++ b/src/app/Http/Controllers/API/V4/GroupsController.php @@ -1,507 +1,507 @@ errorResponse(404); } /** * Delete a group. * * @param int $id Group identifier * * @return \Illuminate\Http\JsonResponse The response */ public function destroy($id) { - $group = Group::find($id); + $group = Group::withEnvTenant()->find($id); if (empty($group)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canDelete($group)) { return $this->errorResponse(403); } $group->delete(); return response()->json([ 'status' => 'success', 'message' => __('app.distlist-delete-success'), ]); } /** * Show the form for editing the specified group. * * @param int $id Group identifier * * @return \Illuminate\Http\JsonResponse */ public function edit($id) { return $this->errorResponse(404); } /** * Listing of groups belonging to the authenticated user. * * The group-entitlements billed to the current user wallet(s) * * @return \Illuminate\Http\JsonResponse */ public function index() { $user = $this->guard()->user(); $result = $user->groups()->orderBy('email')->get() ->map(function (Group $group) { $data = [ 'id' => $group->id, 'email' => $group->email, ]; $data = array_merge($data, self::groupStatuses($group)); return $data; }); return response()->json($result); } /** * Display information of a group specified by $id. * * @param int $id The group to show information for. * * @return \Illuminate\Http\JsonResponse */ public function show($id) { - $group = Group::find($id); + $group = Group::withEnvTenant()->find($id); if (empty($group)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($group)) { return $this->errorResponse(403); } $response = $group->toArray(); $response = array_merge($response, self::groupStatuses($group)); $response['statusInfo'] = self::statusInfo($group); return response()->json($response); } /** * Fetch group status (and reload setup process) * * @param int $id Group identifier * * @return \Illuminate\Http\JsonResponse */ public function status($id) { - $group = Group::find($id); + $group = Group::withEnvTenant()->find($id); if (empty($group)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($group)) { return $this->errorResponse(403); } $response = self::statusInfo($group); 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($group, $step['label']); if (!$exec) { if ($exec === null) { $async = true; } break; } $updated = true; } } if ($updated) { $response = self::statusInfo($group); } $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::groupStatuses($group)); return response()->json($response); } /** * Group status (extended) information * * @param \App\Group $group Group object * * @return array Status information */ public static function statusInfo(Group $group): array { $process = []; $steps = [ 'distlist-new' => true, 'distlist-ldap-ready' => $group->isLdapReady(), ]; // 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; } $domain = $group->domain(); // 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 && $group->created_at->diffInSeconds(Carbon::now()) > 180) { $state = 'failed'; } return [ 'process' => $process, 'processState' => $state, 'isReady' => $all === $checked, ]; } /** * Create a new group 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); } $email = request()->input('email'); $members = request()->input('members'); $errors = []; // Validate group address if ($error = GroupsController::validateGroupEmail($email, $owner)) { $errors['email'] = $error; } // Validate members' email addresses if (empty($members) || !is_array($members)) { $errors['members'] = \trans('validation.listmembersrequired'); } else { foreach ($members as $i => $member) { if (is_string($member) && !empty($member)) { if ($error = GroupsController::validateMemberEmail($member, $owner)) { $errors['members'][$i] = $error; } elseif (\strtolower($member) === \strtolower($email)) { $errors['members'][$i] = \trans('validation.memberislist'); } } else { unset($members[$i]); } } } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } DB::beginTransaction(); // Create the group $group = new Group(); $group->email = $email; $group->members = $members; $group->save(); $group->assignToWallet($owner->wallets->first()); DB::commit(); return response()->json([ 'status' => 'success', 'message' => __('app.distlist-create-success'), ]); } /** * Update a group. * * @param \Illuminate\Http\Request $request The API request. * @param string $id Group identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { - $group = Group::find($id); + $group = Group::withEnvTenant()->find($id); if (empty($group)) { return $this->errorResponse(404); } $current_user = $this->guard()->user(); if (!$current_user->canUpdate($group)) { return $this->errorResponse(403); } $owner = $group->wallet()->owner; // It is possible to update members property only for now $members = request()->input('members'); $errors = []; // Validate members' email addresses if (empty($members) || !is_array($members)) { $errors['members'] = \trans('validation.listmembersrequired'); } else { foreach ((array) $members as $i => $member) { if (is_string($member) && !empty($member)) { if ($error = GroupsController::validateMemberEmail($member, $owner)) { $errors['members'][$i] = $error; } elseif (\strtolower($member) === $group->email) { $errors['members'][$i] = \trans('validation.memberislist'); } } else { unset($members[$i]); } } } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } $group->members = $members; $group->save(); return response()->json([ 'status' => 'success', 'message' => __('app.distlist-update-success'), ]); } /** * Execute (synchronously) specified step in a group setup process. * * @param \App\Group $group Group 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(Group $group, string $step): ?bool { try { if (strpos($step, 'domain-') === 0) { return DomainsController::execProcessStep($group->domain(), $step); } switch ($step) { case 'distlist-ldap-ready': // Group not in LDAP, create it $job = new \App\Jobs\Group\CreateJob($group->id); $job->handle(); $group->refresh(); return $group->isLdapReady(); } } catch (\Exception $e) { \Log::error($e); } return false; } /** * Prepare group statuses for the UI * * @param \App\Group $group Group object * * @return array Statuses array */ protected static function groupStatuses(Group $group): array { return [ 'isLdapReady' => $group->isLdapReady(), 'isSuspended' => $group->isSuspended(), 'isActive' => $group->isActive(), 'isDeleted' => $group->isDeleted() || $group->trashed(), ]; } /** * Validate an email address for use as a group email * * @param string $email Email address * @param \App\User $user The group owner * * @return ?string Error message on validation error */ public static function validateGroupEmail($email, \App\User $user): ?string { if (empty($email)) { return \trans('validation.required', ['attribute' => 'email']); } if (strpos($email, '@') === false) { return \trans('validation.entryinvalid', ['attribute' => 'email']); } list($login, $domain) = explode('@', \strtolower($email)); if (strlen($login) === 0 || strlen($domain) === 0) { return \trans('validation.entryinvalid', ['attribute' => 'email']); } // Check if domain exists $domain = Domain::where('namespace', $domain)->first(); if (empty($domain)) { return \trans('validation.domaininvalid'); } $wallet = $domain->wallet(); // The domain must be owned by the user if (!$wallet || !$user->wallets()->find($wallet->id)) { return \trans('validation.domainnotavailable'); } // Validate login part alone $v = Validator::make( ['email' => $login], ['email' => [new \App\Rules\UserEmailLocal(true)]] ); if ($v->fails()) { return $v->errors()->toArray()['email'][0]; } // Check if a user with specified address already exists if (User::emailExists($email)) { 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']); } if (Group::emailExists($email)) { return \trans('validation.entryexists', ['attribute' => 'email']); } return null; } /** * Validate an email address for use as a group member * * @param string $email Email address * @param \App\User $user The group owner * * @return ?string Error message on validation error */ public static function validateMemberEmail($email, \App\User $user): ?string { $v = Validator::make( ['email' => $email], ['email' => [new \App\Rules\ExternalEmail()]] ); if ($v->fails()) { return $v->errors()->toArray()['email'][0]; } // A local domain user must exist if (!User::where('email', \strtolower($email))->first()) { list($login, $domain) = explode('@', \strtolower($email)); $domain = Domain::where('namespace', $domain)->first(); // We return an error only if the domain belongs to the group owner if ($domain && ($wallet = $domain->wallet()) && $user->wallets()->find($wallet->id)) { return \trans('validation.notalocaluser'); } } return null; } } diff --git a/src/app/Http/Controllers/API/V4/Reseller/DiscountsController.php b/src/app/Http/Controllers/API/V4/Reseller/DiscountsController.php new file mode 100644 index 00000000..6edddbc8 --- /dev/null +++ b/src/app/Http/Controllers/API/V4/Reseller/DiscountsController.php @@ -0,0 +1,7 @@ +errorResponse(404); + } + + /** + * Remove the specified invitation. + * + * @param int $id Invitation identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function destroy($id) + { + $invitation = SignupInvitation::withUserTenant()->find($id); + + if (empty($invitation)) { + return $this->errorResponse(404); + } + + $invitation->delete(); + + return response()->json([ + 'status' => 'success', + 'message' => trans('app.signup-invitation-delete-success'), + ]); + } + + /** + * Show the form for editing the specified resource. + * + * @param int $id Invitation identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function edit($id) + { + return $this->errorResponse(404); + } + + /** + * Display a listing of the resource. + * + * @return \Illuminate\Http\JsonResponse + */ + public function index() + { + $pageSize = 10; + $search = request()->input('search'); + $page = intval(request()->input('page')) ?: 1; + $hasMore = false; + + $result = SignupInvitation::withUserTenant() + ->latest() + ->limit($pageSize + 1) + ->offset($pageSize * ($page - 1)); + + if ($search) { + if (strpos($search, '@')) { + $result->where('email', $search); + } else { + $result->whereLike('email', $search); + } + } + + $result = $result->get(); + + if (count($result) > $pageSize) { + $result->pop(); + $hasMore = true; + } + + $result = $result->map(function ($invitation) { + return $this->invitationToArray($invitation); + }); + + return response()->json([ + 'status' => 'success', + 'list' => $result, + 'count' => count($result), + 'hasMore' => $hasMore, + 'page' => $page, + ]); + } + + /** + * Resend the specified invitation. + * + * @param int $id Invitation identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function resend($id) + { + $invitation = SignupInvitation::withUserTenant()->find($id); + + if (empty($invitation)) { + return $this->errorResponse(404); + } + + if ($invitation->isFailed() || $invitation->isSent()) { + // Note: The email sending job will be dispatched by the observer + $invitation->status = SignupInvitation::STATUS_NEW; + $invitation->save(); + } + + return response()->json([ + 'status' => 'success', + 'message' => trans('app.signup-invitation-resend-success'), + 'invitation' => $this->invitationToArray($invitation), + ]); + } + + /** + * Store a newly created resource in storage. + * + * @param \Illuminate\Http\Request $request + * + * @return \Illuminate\Http\JsonResponse + */ + public function store(Request $request) + { + $errors = []; + $invitations = []; + + if (!empty($request->file) && is_object($request->file)) { + // Expected a text/csv file with multiple email addresses + if (!$request->file->isValid()) { + $errors = ['file' => [$request->file->getErrorMessage()]]; + } else { + $fh = fopen($request->file->getPathname(), 'r'); + $line_number = 0; + $error = null; + + while ($line = fgetcsv($fh)) { + $line_number++; + + // @phpstan-ignore-next-line + if (count($line) >= 1 && $line[0]) { + $email = trim($line[0]); + + if (strpos($email, '@')) { + $v = Validator::make(['email' => $email], ['email' => 'email:filter|required']); + + if ($v->fails()) { + $args = ['email' => $email, 'line' => $line_number]; + $error = trans('app.signup-invitations-csv-invalid-email', $args); + break; + } + + $invitations[] = ['email' => $email]; + } + } + } + + fclose($fh); + + if ($error) { + $errors = ['file' => $error]; + } elseif (empty($invitations)) { + $errors = ['file' => trans('app.signup-invitations-csv-empty')]; + } + } + } else { + // Expected 'email' field with an email address + $v = Validator::make($request->all(), ['email' => 'email|required']); + + if ($v->fails()) { + $errors = $v->errors()->toArray(); + } else { + $invitations[] = ['email' => $request->email]; + } + } + + if (!empty($errors)) { + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + + $count = 0; + foreach ($invitations as $idx => $invitation) { + SignupInvitation::create($invitation); + $count++; + } + + return response()->json([ + 'status' => 'success', + 'message' => \trans_choice('app.signup-invitations-created', $count, ['count' => $count]), + 'count' => $count, + ]); + } + + /** + * Display the specified resource. + * + * @param int $id Invitation identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function show($id) + { + return $this->errorResponse(404); + } + + /** + * Update the specified resource in storage. + * + * @param \Illuminate\Http\Request $request + * @param int $id + * + * @return \Illuminate\Http\JsonResponse + */ + public function update(Request $request, $id) + { + return $this->errorResponse(404); + } + + /** + * Convert an invitation object to an array for output + * + * @param \App\SignupInvitation $invitation The signup invitation object + * + * @return array + */ + protected static function invitationToArray(SignupInvitation $invitation): array + { + return [ + 'id' => $invitation->id, + 'email' => $invitation->email, + 'isNew' => $invitation->isNew(), + 'isSent' => $invitation->isSent(), + 'isFailed' => $invitation->isFailed(), + 'isCompleted' => $invitation->isCompleted(), + 'created' => $invitation->created_at->toDateTimeString(), + ]; + } +} diff --git a/src/app/Http/Controllers/API/V4/Reseller/PackagesController.php b/src/app/Http/Controllers/API/V4/Reseller/PackagesController.php new file mode 100644 index 00000000..ba5742df --- /dev/null +++ b/src/app/Http/Controllers/API/V4/Reseller/PackagesController.php @@ -0,0 +1,7 @@ +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); } /** * 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::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); + $user = \App\User::withEnvTenant()->find($id); if (empty($user)) { return $this->errorResponse(404); } if (!Auth::guard()->user()->canRead($user)) { return $this->errorResponse(403); } $type = request()->input('type'); $response = []; // Note: Order by title for consistent ordering in tests $skus = Sku::orderBy('title')->get(); foreach ($skus as $sku) { if (!class_exists($sku->handler_class)) { continue; } if (!$sku->handler_class::isAvailable($sku, $user)) { continue; } if ($data = $this->skuElement($sku)) { if ($type && $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 { 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']); + unset($data['handler_class'], $data['created_at'], $data['updated_at'], $data['fee'], $data['tenant_id']); return $data; } } diff --git a/src/app/Http/Controllers/Controller.php b/src/app/Http/Controllers/Controller.php index 0c80b9b6..bbb21465 100644 --- a/src/app/Http/Controllers/Controller.php +++ b/src/app/Http/Controllers/Controller.php @@ -1,61 +1,61 @@ "Bad request", 401 => "Unauthorized", 403 => "Access denied", 404 => "Not found", 405 => "Method not allowed", 422 => "Input validation error", 429 => "Too many requests", 500 => "Internal server error", ]; $response = [ 'status' => 'error', 'message' => $message ?: (isset($errors[$code]) ? $errors[$code] : "Server error"), ]; if (!empty($data)) { $response = $response + $data; } return response()->json($response, $code); } /** * Get the guard to be used during authentication. * * @return \Illuminate\Contracts\Auth\Guard */ protected function guard() { return Auth::guard(); } } diff --git a/src/app/Http/Kernel.php b/src/app/Http/Kernel.php index 9e45be35..9d4a5085 100644 --- a/src/app/Http/Kernel.php +++ b/src/app/Http/Kernel.php @@ -1,102 +1,103 @@ [ // \App\Http\Middleware\EncryptCookies::class, // \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, // \Illuminate\Session\Middleware\StartSession::class, // \Illuminate\Session\Middleware\AuthenticateSession::class, // \Illuminate\View\Middleware\ShareErrorsFromSession::class, // \App\Http\Middleware\VerifyCsrfToken::class, // \Illuminate\Routing\Middleware\SubstituteBindings::class, ], 'api' => [ 'throttle:120,1', 'bindings', ], ]; /** * The application's route middleware. * * These middleware may be assigned to groups or used individually. * * @var array */ protected $routeMiddleware = [ 'admin' => \App\Http\Middleware\AuthenticateAdmin::class, 'auth' => \App\Http\Middleware\Authenticate::class, 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, 'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class, 'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class, 'can' => \Illuminate\Auth\Middleware\Authorize::class, 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, + 'reseller' => \App\Http\Middleware\AuthenticateReseller::class, 'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class, 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, 'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class, ]; /** * The priority-sorted list of middleware. * * This forces non-global middleware to always be in the given order. * * @var array */ protected $middlewarePriority = [ \Illuminate\Session\Middleware\StartSession::class, \Illuminate\View\Middleware\ShareErrorsFromSession::class, \App\Http\Middleware\AuthenticateAdmin::class, + \App\Http\Middleware\AuthenticateReseller::class, \App\Http\Middleware\Authenticate::class, \Illuminate\Session\Middleware\AuthenticateSession::class, \Illuminate\Routing\Middleware\SubstituteBindings::class, \Illuminate\Auth\Middleware\Authorize::class, - \App\Http\Middleware\AuthenticateAdmin::class, ]; /** * Handle an incoming HTTP request. * * @param \Illuminate\Http\Request $request HTTP Request object * * @return \Illuminate\Http\Response */ public function handle($request) { // Overwrite the http request object return parent::handle(Request::createFrom($request)); } } diff --git a/src/app/Http/Middleware/AuthenticateAdmin.php b/src/app/Http/Middleware/AuthenticateAdmin.php index dfbff382..f7f6b85a 100644 --- a/src/app/Http/Middleware/AuthenticateAdmin.php +++ b/src/app/Http/Middleware/AuthenticateAdmin.php @@ -1,30 +1,30 @@ user(); if (!$user) { - abort(403, "Unauthorized"); + abort(401, "Unauthorized"); } if ($user->role !== "admin") { abort(403, "Unauthorized"); } return $next($request); } } diff --git a/src/app/Http/Middleware/AuthenticateAdmin.php b/src/app/Http/Middleware/AuthenticateReseller.php similarity index 72% copy from src/app/Http/Middleware/AuthenticateAdmin.php copy to src/app/Http/Middleware/AuthenticateReseller.php index dfbff382..9d4308ea 100644 --- a/src/app/Http/Middleware/AuthenticateAdmin.php +++ b/src/app/Http/Middleware/AuthenticateReseller.php @@ -1,30 +1,34 @@ user(); if (!$user) { + abort(401, "Unauthorized"); + } + + if ($user->role !== "reseller") { abort(403, "Unauthorized"); } - if ($user->role !== "admin") { + if ($user->tenant_id != \config('app.tenant_id')) { abort(403, "Unauthorized"); } return $next($request); } } diff --git a/src/app/Jobs/SignupInvitationEmail.php b/src/app/Jobs/SignupInvitationEmail.php new file mode 100644 index 00000000..e23a9590 --- /dev/null +++ b/src/app/Jobs/SignupInvitationEmail.php @@ -0,0 +1,75 @@ +invitation = $invitation; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + Mail::to($this->invitation->email)->send(new SignupInvitationMail($this->invitation)); + + // Update invitation status + $this->invitation->status = SignupInvitation::STATUS_SENT; + $this->invitation->save(); + } + + /** + * The job failed to process. + * + * @param \Exception $exception + * + * @return void + */ + public function failed(\Exception $exception) + { + if ($this->attempts() >= $this->tries) { + // Update invitation status + $this->invitation->status = SignupInvitation::STATUS_FAILED; + $this->invitation->save(); + } + } +} diff --git a/src/app/Mail/SignupInvitation.php b/src/app/Mail/SignupInvitation.php new file mode 100644 index 00000000..1bda7b2f --- /dev/null +++ b/src/app/Mail/SignupInvitation.php @@ -0,0 +1,72 @@ +invitation = $invitation; + } + + /** + * Build the message. + * + * @return $this + */ + public function build() + { + $href = Utils::serviceUrl('/signup/invite/' . $this->invitation->id); + + $this->view('emails.html.signup_invitation') + ->text('emails.plain.signup_invitation') + ->subject(__('mail.signupinvitation-subject', ['site' => \config('app.name')])) + ->with([ + 'site' => \config('app.name'), + 'href' => $href, + ]); + + return $this; + } + + /** + * Render the mail template with fake data + * + * @param string $type Output format ('html' or 'text') + * + * @return string HTML or Plain Text output + */ + public static function fakeRender(string $type = 'html'): string + { + $invitation = new SI([ + 'email' => 'test@external.org', + ]); + + $invitation->id = Utils::uuidStr(); + + $mail = new self($invitation); + + return Helper::render($mail, $type); + } +} diff --git a/src/app/Observers/DiscountObserver.php b/src/app/Observers/DiscountObserver.php index 1f7a1999..7d9bd037 100644 --- a/src/app/Observers/DiscountObserver.php +++ b/src/app/Observers/DiscountObserver.php @@ -1,29 +1,31 @@ {$discount->getKeyName()} = $allegedly_unique; break; } } + + $discount->tenant_id = \config('app.tenant_id'); } } diff --git a/src/app/Observers/DomainObserver.php b/src/app/Observers/DomainObserver.php index 57cfc858..e9003af0 100644 --- a/src/app/Observers/DomainObserver.php +++ b/src/app/Observers/DomainObserver.php @@ -1,157 +1,159 @@ find($allegedly_unique)) { $domain->{$domain->getKeyName()} = $allegedly_unique; break; } } $domain->namespace = \strtolower($domain->namespace); $domain->status |= Domain::STATUS_NEW; + + $domain->tenant_id = \config('app.tenant_id'); } /** * Handle the domain "created" event. * * @param \App\Domain $domain The domain. * * @return void */ public function created(Domain $domain) { // Create domain record in LDAP // Note: DomainCreate job will dispatch DomainVerify job \App\Jobs\Domain\CreateJob::dispatch($domain->id); } /** * Handle the domain "deleting" event. * * @param \App\Domain $domain The domain. * * @return void */ public function deleting(Domain $domain) { // Entitlements do not have referential integrity on the entitled object, so this is our // way of doing an onDelete('cascade') without the foreign key. \App\Entitlement::where('entitleable_id', $domain->id) ->where('entitleable_type', Domain::class) ->delete(); } /** * Handle the domain "deleted" event. * * @param \App\Domain $domain The domain. * * @return void */ public function deleted(Domain $domain) { if ($domain->isForceDeleting()) { return; } \App\Jobs\Domain\DeleteJob::dispatch($domain->id); } /** * Handle the domain "updated" event. * * @param \App\Domain $domain The domain. * * @return void */ public function updated(Domain $domain) { \App\Jobs\Domain\UpdateJob::dispatch($domain->id); } /** * Handle the domain "restoring" event. * * @param \App\Domain $domain The domain. * * @return void */ public function restoring(Domain $domain) { // Make sure it's not DELETED/LDAP_READY/SUSPENDED if ($domain->isDeleted()) { $domain->status ^= Domain::STATUS_DELETED; } if ($domain->isLdapReady()) { $domain->status ^= Domain::STATUS_LDAP_READY; } if ($domain->isSuspended()) { $domain->status ^= Domain::STATUS_SUSPENDED; } if ($domain->isConfirmed() && $domain->isVerified()) { $domain->status |= Domain::STATUS_ACTIVE; } // Note: $domain->save() is invoked between 'restoring' and 'restored' events } /** * Handle the domain "restored" event. * * @param \App\Domain $domain The domain. * * @return void */ public function restored(Domain $domain) { // Restore domain entitlements // We'll restore only these that were deleted last. So, first we get // the maximum deleted_at timestamp and then use it to select // domain entitlements for restore $deleted_at = \App\Entitlement::withTrashed() ->where('entitleable_id', $domain->id) ->where('entitleable_type', Domain::class) ->max('deleted_at'); if ($deleted_at) { \App\Entitlement::withTrashed() ->where('entitleable_id', $domain->id) ->where('entitleable_type', Domain::class) ->where('deleted_at', '>=', (new \Carbon\Carbon($deleted_at))->subMinute()) ->update(['updated_at' => now(), 'deleted_at' => null]); } // Create the domain in LDAP again \App\Jobs\Domain\CreateJob::dispatch($domain->id); } /** * Handle the domain "force deleted" event. * * @param \App\Domain $domain The domain. * * @return void */ public function forceDeleted(Domain $domain) { // } } diff --git a/src/app/Observers/EntitlementObserver.php b/src/app/Observers/EntitlementObserver.php index 6b62498a..6f8cf0e7 100644 --- a/src/app/Observers/EntitlementObserver.php +++ b/src/app/Observers/EntitlementObserver.php @@ -1,165 +1,175 @@ wallet_id); if (!$wallet || !$wallet->owner) { return false; } $sku = \App\Sku::find($entitlement->sku_id); if (!$sku) { return false; } $result = $sku->handler_class::preReq($entitlement, $wallet->owner); if (!$result) { return false; } while (true) { $allegedly_unique = \App\Utils::uuidStr(); if (!Entitlement::withTrashed()->find($allegedly_unique)) { $entitlement->{$entitlement->getKeyName()} = $allegedly_unique; break; } } return true; } /** * Handle the entitlement "created" event. * * @param \App\Entitlement $entitlement The entitlement. * * @return void */ public function created(Entitlement $entitlement) { $entitlement->entitleable->updated_at = Carbon::now(); $entitlement->entitleable->save(); $entitlement->createTransaction(\App\Transaction::ENTITLEMENT_CREATED); } /** * Handle the entitlement "deleted" event. * * @param \App\Entitlement $entitlement The entitlement. * * @return void */ public function deleted(Entitlement $entitlement) { // Remove all configured 2FA methods from Roundcube database if ($entitlement->sku->title == '2fa') { // FIXME: Should that be an async job? $sf = new \App\Auth\SecondFactor($entitlement->entitleable); $sf->removeFactors(); } $entitlement->entitleable->updated_at = Carbon::now(); $entitlement->entitleable->save(); $entitlement->createTransaction(\App\Transaction::ENTITLEMENT_DELETED); } /** * Handle the entitlement "deleting" event. * * @param \App\Entitlement $entitlement The entitlement. * * @return void */ public function deleting(Entitlement $entitlement) { if ($entitlement->trashed()) { return; } // Start calculating the costs for the consumption of this entitlement if the // existing consumption spans >= 14 days. // // Effect is that anything's free for the first 14 days if ($entitlement->created_at >= Carbon::now()->subDays(14)) { return; } $owner = $entitlement->wallet->owner; // Determine if we're still within the free first month $freeMonthEnds = $owner->created_at->copy()->addMonthsWithoutOverflow(1); if ($freeMonthEnds >= Carbon::now()) { return; } - $cost = 0; $now = Carbon::now(); // get the discount rate applied to the wallet. $discount = $entitlement->wallet->getDiscountRate(); // just in case this had not been billed yet, ever $diffInMonths = $entitlement->updated_at->diffInMonths($now); - $cost += (int) ($entitlement->cost * $discount * $diffInMonths); + $cost = (int) ($entitlement->cost * $discount * $diffInMonths); + $fee = (int) ($entitlement->fee * $diffInMonths); // this moves the hypothetical updated at forward to however many months past the original $updatedAt = $entitlement->updated_at->copy()->addMonthsWithoutOverflow($diffInMonths); // now we have the diff in days since the last "billed" period end. // This may be an entitlement paid up until February 28th, 2020, with today being March // 12th 2020. Calculating the costs for the entitlement is based on the daily price // the price per day is based on the number of days in the last month // or the current month if the period does not overlap with the previous month // FIXME: This really should be simplified to $daysInMonth=30 $diffInDays = $updatedAt->diffInDays($now); if ($now->day >= $diffInDays) { $daysInMonth = $now->daysInMonth; } else { $daysInMonth = \App\Utils::daysInLastMonth(); } $pricePerDay = $entitlement->cost / $daysInMonth; + $feePerDay = $entitlement->fee / $daysInMonth; $cost += (int) (round($pricePerDay * $discount * $diffInDays, 0)); + $fee += (int) (round($feePerDay * $diffInDays, 0)); + + $profit = $cost - $fee; + + if ($profit != 0 && $owner->tenant && ($wallet = $owner->tenant->wallet())) { + $desc = "Charged user {$owner->email}"; + $method = $profit > 0 ? 'credit' : 'debit'; + $wallet->{$method}(abs($profit), $desc); + } if ($cost == 0) { return; } $entitlement->wallet->debit($cost); } } diff --git a/src/app/Observers/GroupObserver.php b/src/app/Observers/GroupObserver.php index eaf0a543..7f133f01 100644 --- a/src/app/Observers/GroupObserver.php +++ b/src/app/Observers/GroupObserver.php @@ -1,113 +1,115 @@ find($allegedly_unique)) { $group->{$group->getKeyName()} = $allegedly_unique; break; } } $group->status |= Group::STATUS_NEW | Group::STATUS_ACTIVE; + + $group->tenant_id = \config('app.tenant_id'); } /** * Handle the group "created" event. * * @param \App\Group $group The group * * @return void */ public function created(Group $group) { \App\Jobs\Group\CreateJob::dispatch($group->id); } /** * Handle the group "deleting" event. * * @param \App\Group $group The group * * @return void */ public function deleting(Group $group) { // Entitlements do not have referential integrity on the entitled object, so this is our // way of doing an onDelete('cascade') without the foreign key. \App\Entitlement::where('entitleable_id', $group->id) ->where('entitleable_type', Group::class) ->delete(); } /** * Handle the group "deleted" event. * * @param \App\Group $group The group * * @return void */ public function deleted(Group $group) { if ($group->isForceDeleting()) { return; } \App\Jobs\Group\DeleteJob::dispatch($group->id); } /** * Handle the group "updated" event. * * @param \App\Group $group The group * * @return void */ public function updated(Group $group) { \App\Jobs\Group\UpdateJob::dispatch($group->id); } /** * Handle the group "restored" event. * * @param \App\Group $group The group * * @return void */ public function restored(Group $group) { // } /** * Handle the group "force deleting" event. * * @param \App\Group $group The group * * @return void */ public function forceDeleted(Group $group) { // A group can be force-deleted separately from the owner // we have to force-delete entitlements \App\Entitlement::where('entitleable_id', $group->id) ->where('entitleable_type', Group::class) ->forceDelete(); } } diff --git a/src/app/Observers/PackageObserver.php b/src/app/Observers/PackageObserver.php index b967ba2d..e567a44d 100644 --- a/src/app/Observers/PackageObserver.php +++ b/src/app/Observers/PackageObserver.php @@ -1,31 +1,33 @@ {$package->getKeyName()} = $allegedly_unique; break; } } + + $package->tenant_id = \config('app.tenant_id'); } } diff --git a/src/app/Observers/PackageSkuObserver.php b/src/app/Observers/PackageSkuObserver.php index e15a5862..eecc2925 100644 --- a/src/app/Observers/PackageSkuObserver.php +++ b/src/app/Observers/PackageSkuObserver.php @@ -1,28 +1,47 @@ package; + $sku = $packageSku->sku; + + if ($package->tenant_id != $sku->tenant_id) { + throw new \Exception("Package and SKU owned by different tenants"); + } + } + /** * Handle the "created" event on an PackageSku relation * * @param \App\PackageSku $packageSku The package-sku relation * * @return void */ public function created(PackageSku $packageSku) { // TODO: free units... $package = $packageSku->package; $sku = $packageSku->sku; $package->skus()->updateExistingPivot( $sku, ['cost' => ($sku->cost * (100 - $package->discount_rate)) / 100], false ); } } diff --git a/src/app/Observers/PlanObserver.php b/src/app/Observers/PlanObserver.php index 47b56ed2..aa501f62 100644 --- a/src/app/Observers/PlanObserver.php +++ b/src/app/Observers/PlanObserver.php @@ -1,31 +1,33 @@ {$plan->getKeyName()} = $allegedly_unique; break; } } + + $plan->tenant_id = \config('app.tenant_id'); } } diff --git a/src/app/Observers/PlanPackageObserver.php b/src/app/Observers/PlanPackageObserver.php new file mode 100644 index 00000000..9140abdd --- /dev/null +++ b/src/app/Observers/PlanPackageObserver.php @@ -0,0 +1,27 @@ +package; + $plan = $planPackage->plan; + + if ($package->tenant_id != $plan->tenant_id) { + throw new \Exception("Package and Plan owned by different tenants"); + } + } +} diff --git a/src/app/Observers/SignupInvitationObserver.php b/src/app/Observers/SignupInvitationObserver.php new file mode 100644 index 00000000..f598bf0c --- /dev/null +++ b/src/app/Observers/SignupInvitationObserver.php @@ -0,0 +1,65 @@ +{$invitation->getKeyName()} = $allegedly_unique; + break; + } + } + + $invitation->status = SI::STATUS_NEW; + + $invitation->tenant_id = \config('app.tenant_id'); + } + + /** + * Handle the invitation "created" event. + * + * @param \App\SignupInvitation $invitation The invitation object + * + * @return void + */ + public function created(SI $invitation) + { + \App\Jobs\SignupInvitationEmail::dispatch($invitation); + } + + /** + * Handle the invitation "updated" event. + * + * @param \App\SignupInvitation $invitation The invitation object + * + * @return void + */ + public function updated(SI $invitation) + { + $oldStatus = $invitation->getOriginal('status'); + + // Resend the invitation + if ( + $invitation->status == SI::STATUS_NEW + && ($oldStatus == SI::STATUS_FAILED || $oldStatus == SI::STATUS_SENT) + ) { + \App\Jobs\SignupInvitationEmail::dispatch($invitation); + } + } +} diff --git a/src/app/Observers/SkuObserver.php b/src/app/Observers/SkuObserver.php index e41d5750..dca338f8 100644 --- a/src/app/Observers/SkuObserver.php +++ b/src/app/Observers/SkuObserver.php @@ -1,26 +1,30 @@ {$sku->getKeyName()} = $allegedly_unique; break; } } + + $sku->tenant_id = \config('app.tenant_id'); + + // TODO: We should make sure that tenant_id + title is unique } } diff --git a/src/app/Observers/UserObserver.php b/src/app/Observers/UserObserver.php index ac254a6a..7e3c86f9 100644 --- a/src/app/Observers/UserObserver.php +++ b/src/app/Observers/UserObserver.php @@ -1,362 +1,374 @@ id) { while (true) { $allegedly_unique = \App\Utils::uuidInt(); if (!User::withTrashed()->find($allegedly_unique)) { $user->{$user->getKeyName()} = $allegedly_unique; break; } } } $user->email = \strtolower($user->email); // only users that are not imported get the benefit of the doubt. $user->status |= User::STATUS_NEW | User::STATUS_ACTIVE; - // can't dispatch job here because it'll fail serialization + $user->tenant_id = \config('app.tenant_id'); } /** * 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' => 'CHF', /* '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); } /** * 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); } /** * 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/Package.php b/src/app/Package.php index c7b5775b..1d58ab88 100644 --- a/src/app/Package.php +++ b/src/app/Package.php @@ -1,97 +1,117 @@ skus as $sku) { $units = $sku->pivot->qty - $sku->units_free; if ($units < 0) { \Log::debug("Package {$this->id} is misconfigured for more free units than qty."); $units = 0; } $ppu = $sku->cost * ((100 - $this->discount_rate) / 100); $costs += $units * $ppu; } return $costs; } - public function isDomain() + /** + * Checks whether the package contains a domain SKU. + */ + public function isDomain(): bool { foreach ($this->skus as $sku) { if ($sku->handler_class::entitleableClass() == \App\Domain::class) { return true; } } return false; } /** * SKUs of this package. * * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany */ public function skus() { return $this->belongsToMany( 'App\Sku', 'package_skus' )->using('App\PackageSku')->withPivot( ['qty'] ); } + + /** + * The tenant for this package. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function tenant() + { + return $this->belongsTo('App\Tenant', 'tenant_id', 'id'); + } } diff --git a/src/app/PackageSku.php b/src/app/PackageSku.php index 57d03c94..f52c1ecd 100644 --- a/src/app/PackageSku.php +++ b/src/app/PackageSku.php @@ -1,66 +1,86 @@ 'integer', 'qty' => 'integer' ]; /** * Under this package, how much does this SKU cost? * * @return int The costs of this SKU under this package in cents. */ public function cost() { - $costs = 0; - $units = $this->qty - $this->sku->units_free; if ($units < 0) { - \Log::debug( - "Package {$this->package_id} is misconfigured for more free units than qty." - ); - $units = 0; } + // FIXME: Why package_skus.cost value is not used anywhere? + $ppu = $this->sku->cost * ((100 - $this->package->discount_rate) / 100); - $costs += $units * $ppu; + return $units * $ppu; + } + + /** + * Under this package, what fee this SKU has? + * + * @return int The fee for this SKU under this package in cents. + */ + public function fee() + { + $units = $this->qty - $this->sku->units_free; + + if ($units < 0) { + $units = 0; + } - return $costs; + return $this->sku->fee * $units; } + /** + * The package for this relation. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ public function package() { return $this->belongsTo('App\Package'); } + /** + * The SKU for this relation. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ public function sku() { return $this->belongsTo('App\Sku'); } } diff --git a/src/app/Plan.php b/src/app/Plan.php index 4bc0caf3..c6b222de 100644 --- a/src/app/Plan.php +++ b/src/app/Plan.php @@ -1,108 +1,127 @@ 'datetime', 'promo_to' => 'datetime', 'discount_qty' => 'integer', 'discount_rate' => 'integer' ]; /** @var array Translatable properties */ public $translatable = [ 'name', 'description', ]; /** * The list price for this package at the minimum configuration. * * @return int The costs in cents. */ public function cost() { $costs = 0; foreach ($this->packages as $package) { $costs += $package->pivot->cost(); } return $costs; } /** * The relationship to packages. * * The plan contains one or more packages. Each package may have its minimum number (for * billing) or its maximum (to allow topping out "enterprise" customers on a "small business" * plan). * * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany */ public function packages() { return $this->belongsToMany( 'App\Package', 'plan_packages' )->using('App\PlanPackage')->withPivot( [ 'qty', 'qty_min', 'qty_max', 'discount_qty', 'discount_rate' ] ); } /** * Checks if the plan has any type of domain SKU assigned. * * @return bool */ public function hasDomain(): bool { foreach ($this->packages as $package) { if ($package->isDomain()) { return true; } } return false; } + + /** + * The tenant for this plan. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function tenant() + { + return $this->belongsTo('App\Tenant', 'tenant_id', 'id'); + } } diff --git a/src/app/PlanPackage.php b/src/app/PlanPackage.php index 0b8a1aba..484a48b5 100644 --- a/src/app/PlanPackage.php +++ b/src/app/PlanPackage.php @@ -1,61 +1,77 @@ 'integer', 'qty_max' => 'integer', 'qty_min' => 'integer', 'discount_qty' => 'integer', 'discount_rate' => 'integer' ]; /** * Calculate the costs for this plan. * * @return integer */ public function cost() { $costs = 0; if ($this->qty_min > 0) { $costs += $this->package->cost() * $this->qty_min; } elseif ($this->qty > 0) { $costs += $this->package->cost() * $this->qty; } return $costs; } + /** + * The package in this relation. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ public function package() { return $this->belongsTo('App\Package'); } + + /** + * The plan in this relation. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function plan() + { + return $this->belongsTo('App\Plan'); + } } diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php index 40437600..0c010ec2 100644 --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -1,61 +1,109 @@ sql, implode(', ', $query->bindings))); }); } // Register some template helpers Blade::directive('theme_asset', function ($path) { $path = trim($path, '/\'"'); return ""; }); + + // Query builder 'withEnvTenant' macro + Builder::macro('withEnvTenant', function (string $table = null) { + $tenant_id = \config('app.tenant_id'); + + if ($tenant_id) { + /** @var Builder $this */ + return $this->where(($table ? "$table." : '') . 'tenant_id', $tenant_id); + } + + /** @var Builder $this */ + return $this->whereNull(($table ? "$table." : '') . 'tenant_id'); + }); + + // Query builder 'withUserTenant' macro + Builder::macro('withUserTenant', function (string $table = null) { + $tenant_id = auth()->user()->tenant_id; + + if ($tenant_id) { + /** @var Builder $this */ + return $this->where(($table ? "$table." : '') . 'tenant_id', $tenant_id); + } + + /** @var Builder $this */ + return $this->whereNull(($table ? "$table." : '') . 'tenant_id'); + }); + + // Query builder 'whereLike' mocro + Builder::macro('whereLike', function (string $column, string $search, int $mode = 0) { + $search = addcslashes($search, '%_'); + + switch ($mode) { + case 2: + $search .= '%'; + break; + case 1: + $search = '%' . $search; + break; + default: + $search = '%' . $search . '%'; + } + + /** @var Builder $this */ + return $this->where($column, 'like', $search); + }); } } diff --git a/src/app/Providers/Payment/Mollie.php b/src/app/Providers/Payment/Mollie.php index bdbf867a..42cc779e 100644 --- a/src/app/Providers/Payment/Mollie.php +++ b/src/app/Providers/Payment/Mollie.php @@ -1,622 +1,622 @@ tag */ public function customerLink(Wallet $wallet): ?string { $customer_id = self::mollieCustomerId($wallet, false); if (!$customer_id) { return null; } return sprintf( '%s', $customer_id, $customer_id ); } /** * Create a new auto-payment mandate for a wallet. * * @param \App\Wallet $wallet The wallet * @param array $payment Payment data: * - amount: Value in cents (optional) * - currency: The operation currency * - description: Operation desc. * - methodId: Payment method * * @return array Provider payment data: * - id: Operation identifier * - redirectUrl: the location to redirect to */ public function createMandate(Wallet $wallet, array $payment): ?array { // Register the user in Mollie, if not yet done $customer_id = self::mollieCustomerId($wallet, true); if (!isset($payment['amount'])) { $payment['amount'] = 0; } $amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']); $payment['currency_amount'] = $amount; $request = [ 'amount' => [ 'currency' => $payment['currency'], 'value' => sprintf('%.2f', $amount / 100), ], 'customerId' => $customer_id, 'sequenceType' => 'first', 'description' => $payment['description'], 'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'), - 'redirectUrl' => Utils::serviceUrl('/wallet'), + 'redirectUrl' => self::redirectUrl(), 'locale' => 'en_US', 'method' => $payment['methodId'] ]; // Create the payment in Mollie $response = mollie()->payments()->create($request); if ($response->mandateId) { $wallet->setSetting('mollie_mandate_id', $response->mandateId); } // Store the payment reference in database $payment['status'] = $response->status; $payment['id'] = $response->id; $payment['type'] = self::TYPE_MANDATE; $this->storePayment($payment, $wallet->id); return [ 'id' => $response->id, 'redirectUrl' => $response->getCheckoutUrl(), ]; } /** * Revoke the auto-payment mandate for the wallet. * * @param \App\Wallet $wallet The wallet * * @return bool True on success, False on failure */ public function deleteMandate(Wallet $wallet): bool { // Get the Mandate info $mandate = self::mollieMandate($wallet); // Revoke the mandate on Mollie if ($mandate) { $mandate->revoke(); $wallet->setSetting('mollie_mandate_id', null); } return true; } /** * Get a auto-payment mandate for the wallet. * * @param \App\Wallet $wallet The wallet * * @return array|null Mandate information: * - id: Mandate identifier * - method: user-friendly payment method desc. * - methodId: Payment method * - isPending: the process didn't complete yet * - isValid: the mandate is valid */ public function getMandate(Wallet $wallet): ?array { // Get the Mandate info $mandate = self::mollieMandate($wallet); if (empty($mandate)) { return null; } $result = [ 'id' => $mandate->id, 'isPending' => $mandate->isPending(), 'isValid' => $mandate->isValid(), 'method' => self::paymentMethod($mandate, 'Unknown method'), 'methodId' => $mandate->method ]; return $result; } /** * Get a provider name * * @return string Provider name */ public function name(): string { return 'mollie'; } /** * Create a new payment. * * @param \App\Wallet $wallet The wallet * @param array $payment Payment data: * - amount: Value in cents * - currency: The operation currency * - type: oneoff/recurring * - description: Operation desc. * - methodId: Payment method * * @return array Provider payment data: * - id: Operation identifier * - redirectUrl: the location to redirect to */ public function payment(Wallet $wallet, array $payment): ?array { if ($payment['type'] == self::TYPE_RECURRING) { return $this->paymentRecurring($wallet, $payment); } // Register the user in Mollie, if not yet done $customer_id = self::mollieCustomerId($wallet, true); $amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']); $payment['currency_amount'] = $amount; // Note: Required fields: description, amount/currency, amount/value $request = [ 'amount' => [ 'currency' => $payment['currency'], // a number with two decimals is required (note that JPK and ISK don't require decimals, // but we're not using them currently) 'value' => sprintf('%.2f', $amount / 100), ], 'customerId' => $customer_id, 'sequenceType' => $payment['type'], 'description' => $payment['description'], 'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'), 'locale' => 'en_US', 'method' => $payment['methodId'], - 'redirectUrl' => Utils::serviceUrl('/wallet') // required for non-recurring payments + 'redirectUrl' => self::redirectUrl() // required for non-recurring payments ]; // TODO: Additional payment parameters for better fraud protection: // billingEmail - for bank transfers, Przelewy24, but not creditcard // billingAddress (it is a structured field not just text) // Create the payment in Mollie $response = mollie()->payments()->create($request); // Store the payment reference in database $payment['status'] = $response->status; $payment['id'] = $response->id; $this->storePayment($payment, $wallet->id); return [ 'id' => $payment['id'], 'redirectUrl' => $response->getCheckoutUrl(), ]; } /** * Cancel a pending payment. * * @param \App\Wallet $wallet The wallet * @param string $paymentId Payment Id * * @return bool True on success, False on failure */ public function cancel(Wallet $wallet, $paymentId): bool { $response = mollie()->payments()->delete($paymentId); $db_payment = Payment::find($paymentId); $db_payment->status = $response->status; $db_payment->save(); return true; } /** * Create a new automatic payment operation. * * @param \App\Wallet $wallet The wallet * @param array $payment Payment data (see self::payment()) * * @return array Provider payment/session data: * - id: Operation identifier */ protected function paymentRecurring(Wallet $wallet, array $payment): ?array { // Check if there's a valid mandate $mandate = self::mollieMandate($wallet); if (empty($mandate) || !$mandate->isValid() || $mandate->isPending()) { return null; } $customer_id = self::mollieCustomerId($wallet, true); // Note: Required fields: description, amount/currency, amount/value $amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']); $payment['currency_amount'] = $amount; $request = [ 'amount' => [ 'currency' => $payment['currency'], // a number with two decimals is required 'value' => sprintf('%.2f', $amount / 100), ], 'customerId' => $customer_id, 'sequenceType' => $payment['type'], 'description' => $payment['description'], 'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'), 'locale' => 'en_US', 'method' => $payment['methodId'], 'mandateId' => $mandate->id ]; // Create the payment in Mollie $response = mollie()->payments()->create($request); // Store the payment reference in database $payment['status'] = $response->status; $payment['id'] = $response->id; DB::beginTransaction(); $payment = $this->storePayment($payment, $wallet->id); // Mollie can return 'paid' status immediately, so we don't // have to wait for the webhook. What's more, the webhook would ignore // the payment because it will be marked as paid before the webhook. // Let's handle paid status here too. if ($response->isPaid()) { self::creditPayment($payment, $response); $notify = true; } elseif ($response->isFailed()) { // Note: I didn't find a way to get any description of the problem with a payment \Log::info(sprintf('Mollie payment failed (%s)', $response->id)); // Disable the mandate $wallet->setSetting('mandate_disabled', 1); $notify = true; } DB::commit(); if (!empty($notify)) { \App\Jobs\PaymentEmail::dispatch($payment); } return [ 'id' => $payment['id'], ]; } /** * Update payment status (and balance). * * @return int HTTP response code */ public function webhook(): int { $payment_id = \request()->input('id'); if (empty($payment_id)) { return 200; } $payment = Payment::find($payment_id); if (empty($payment)) { // Mollie recommends to return "200 OK" even if the payment does not exist return 200; } // Get the payment details from Mollie // TODO: Consider https://github.com/mollie/mollie-api-php/issues/502 when it's fixed $mollie_payment = mollie()->payments()->get($payment_id); if (empty($mollie_payment)) { // Mollie recommends to return "200 OK" even if the payment does not exist return 200; } $refunds = []; if ($mollie_payment->isPaid()) { // The payment is paid. Update the balance, and notify the user if ($payment->status != self::STATUS_PAID && $payment->amount > 0) { $credit = true; $notify = $payment->type == self::TYPE_RECURRING; } // The payment has been (partially) refunded. // Let's process refunds with status "refunded". if ($mollie_payment->hasRefunds()) { foreach ($mollie_payment->refunds() as $refund) { if ($refund->isTransferred() && $refund->amount->value) { $refunds[] = [ 'id' => $refund->id, 'description' => $refund->description, 'amount' => round(floatval($refund->amount->value) * 100), 'type' => self::TYPE_REFUND, 'currency' => $refund->amount->currency ]; } } } // The payment has been (partially) charged back. // Let's process chargebacks (they have no states as refunds) if ($mollie_payment->hasChargebacks()) { foreach ($mollie_payment->chargebacks() as $chargeback) { if ($chargeback->amount->value) { $refunds[] = [ 'id' => $chargeback->id, 'amount' => round(floatval($chargeback->amount->value) * 100), 'type' => self::TYPE_CHARGEBACK, 'currency' => $chargeback->amount->currency ]; } } } // In case there were multiple auto-payment setup requests (e.g. caused by a double // form submission) we end up with multiple payment records and mollie_mandate_id // pointing to the one from the last payment not the successful one. // We make sure to use mandate id from the successful "first" payment. if ( $payment->type == self::TYPE_MANDATE && $mollie_payment->mandateId && $mollie_payment->sequenceType == Types\SequenceType::SEQUENCETYPE_FIRST ) { $payment->wallet->setSetting('mollie_mandate_id', $mollie_payment->mandateId); } } elseif ($mollie_payment->isFailed()) { // Note: I didn't find a way to get any description of the problem with a payment \Log::info(sprintf('Mollie payment failed (%s)', $payment->id)); // Disable the mandate if ($payment->type == self::TYPE_RECURRING) { $notify = true; $payment->wallet->setSetting('mandate_disabled', 1); } } DB::beginTransaction(); // This is a sanity check, just in case the payment provider api // sent us open -> paid -> open -> paid. So, we lock the payment after // recivied a "final" state. $pending_states = [self::STATUS_OPEN, self::STATUS_PENDING, self::STATUS_AUTHORIZED]; if (in_array($payment->status, $pending_states)) { $payment->status = $mollie_payment->status; $payment->save(); } if (!empty($credit)) { self::creditPayment($payment, $mollie_payment); } foreach ($refunds as $refund) { $this->storeRefund($payment->wallet, $refund); } DB::commit(); if (!empty($notify)) { \App\Jobs\PaymentEmail::dispatch($payment); } return 200; } /** * Get Mollie customer identifier for specified wallet. * Create one if does not exist yet. * * @param \App\Wallet $wallet The wallet * @param bool $create Create the customer if does not exist yet * * @return ?string Mollie customer identifier */ protected static function mollieCustomerId(Wallet $wallet, bool $create = false): ?string { $customer_id = $wallet->getSetting('mollie_id'); // Register the user in Mollie if (empty($customer_id) && $create) { $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_id; } /** * Get the active Mollie auto-payment mandate */ protected static function mollieMandate(Wallet $wallet) { $customer_id = $wallet->getSetting('mollie_id'); $mandate_id = $wallet->getSetting('mollie_mandate_id'); // Get the manadate reference we already have if ($customer_id && $mandate_id) { try { return mollie()->mandates()->getForId($customer_id, $mandate_id); } catch (ApiException $e) { // FIXME: What about 404? if ($e->getCode() == 410) { // The mandate is gone, remove the reference $wallet->setSetting('mollie_mandate_id', null); return null; } // TODO: Maybe we shouldn't always throw? It make sense in the job // but for example when we're just fetching wallet info... throw $e; } } } /** * Apply the successful payment's pecunia to the wallet */ protected static function creditPayment($payment, $mollie_payment) { // Extract the payment method for transaction description $method = self::paymentMethod($mollie_payment, 'Mollie'); // TODO: Localization? $description = $payment->type == self::TYPE_RECURRING ? 'Auto-payment' : 'Payment'; $description .= " transaction {$payment->id} using {$method}"; $payment->wallet->credit($payment->amount, $description); // Unlock the disabled auto-payment mandate if ($payment->wallet->balance >= 0) { $payment->wallet->setSetting('mandate_disabled', null); } } /** * Extract payment method description from Mollie payment/mandate details */ protected static function paymentMethod($object, $default = ''): string { $details = $object->details; // Mollie supports 3 methods here switch ($object->method) { case self::METHOD_CREDITCARD: // If the customer started, but never finished the 'first' payment // card details will be empty, and mandate will be 'pending'. if (empty($details->cardNumber)) { return 'Credit Card'; } return sprintf( '%s (**** **** **** %s)', $details->cardLabel ?: 'Card', // @phpstan-ignore-line $details->cardNumber ); case self::METHOD_DIRECTDEBIT: return sprintf('Direct Debit (%s)', $details->customerAccount); case self::METHOD_PAYPAL: return sprintf('PayPal (%s)', $details->consumerAccount); } return $default; } /** * List supported payment methods. * * @param string $type The payment type for which we require a method (oneoff/recurring). * * @return array Array of array with available payment methods: * - id: id of the method * - name: User readable name of the payment method * - minimumAmount: Minimum amount to be charged in cents * - currency: Currency used for the method * - exchangeRate: The projected exchange rate (actual rate is determined during payment) * - icon: An icon (icon name) representing the method */ public function providerPaymentMethods($type): array { $providerMethods = array_merge( // Fallback to EUR methods (later provider methods will override earlier ones) //mollie()->methods()->allActive( // [ // 'sequenceType' => $type, // 'amount' => [ // 'value' => '1.00', // 'currency' => 'EUR' // ] // ] //), // Prefer CHF methods (array)mollie()->methods()->allActive( [ 'sequenceType' => $type, 'amount' => [ 'value' => '1.00', 'currency' => 'CHF' ] ] ) ); $availableMethods = []; foreach ($providerMethods as $method) { $availableMethods[$method->id] = [ 'id' => $method->id, 'name' => $method->description, 'minimumAmount' => round(floatval($method->minimumAmount->value) * 100), // Converted to cents 'currency' => $method->minimumAmount->currency, 'exchangeRate' => $this->exchangeRate('CHF', $method->minimumAmount->currency) ]; } return $availableMethods; } /** * Get a payment. * * @param string $paymentId Payment identifier * * @return array Payment information: * - id: Payment identifier * - status: Payment status * - isCancelable: The payment can be canceled * - checkoutUrl: The checkout url to complete the payment or null if none */ public function getPayment($paymentId): array { $payment = mollie()->payments()->get($paymentId); return [ 'id' => $payment->id, 'status' => $payment->status, 'isCancelable' => $payment->isCancelable, 'checkoutUrl' => $payment->getCheckoutUrl() ]; } } diff --git a/src/app/Providers/Payment/Stripe.php b/src/app/Providers/Payment/Stripe.php index 6c20642d..d96f2af6 100644 --- a/src/app/Providers/Payment/Stripe.php +++ b/src/app/Providers/Payment/Stripe.php @@ -1,558 +1,558 @@ tag */ public function customerLink(Wallet $wallet): ?string { $customer_id = self::stripeCustomerId($wallet, false); if (!$customer_id) { return null; } $location = 'https://dashboard.stripe.com'; $key = \config('services.stripe.key'); if (strpos($key, 'sk_test_') === 0) { $location .= '/test'; } return sprintf( '%s', $location, $customer_id, $customer_id ); } /** * Create a new auto-payment mandate for a wallet. * * @param \App\Wallet $wallet The wallet * @param array $payment Payment data: * - amount: Value in cents (not used) * - currency: The operation currency * - description: Operation desc. * * @return array Provider payment/session data: * - id: Session identifier */ public function createMandate(Wallet $wallet, array $payment): ?array { // Register the user in Stripe, if not yet done $customer_id = self::stripeCustomerId($wallet, true); $request = [ 'customer' => $customer_id, - 'cancel_url' => Utils::serviceUrl('/wallet'), // required - 'success_url' => Utils::serviceUrl('/wallet'), // required + 'cancel_url' => self::redirectUrl(), // required + 'success_url' => self::redirectUrl(), // required 'payment_method_types' => ['card'], // required 'locale' => 'en', 'mode' => 'setup', ]; // Note: Stripe does not allow to set amount for 'setup' operation // We'll dispatch WalletCharge job when we receive a webhook request $session = StripeAPI\Checkout\Session::create($request); $payment['amount'] = 0; $payment['currency_amount'] = 0; $payment['id'] = $session->setup_intent; $payment['type'] = self::TYPE_MANDATE; $this->storePayment($payment, $wallet->id); return [ 'id' => $session->id, ]; } /** * Revoke the auto-payment mandate. * * @param \App\Wallet $wallet The wallet * * @return bool True on success, False on failure */ public function deleteMandate(Wallet $wallet): bool { // Get the Mandate info $mandate = self::stripeMandate($wallet); if ($mandate) { // Remove the reference $wallet->setSetting('stripe_mandate_id', null); // Detach the payment method on Stripe $pm = StripeAPI\PaymentMethod::retrieve($mandate->payment_method); $pm->detach(); } return true; } /** * Get a auto-payment mandate for a wallet. * * @param \App\Wallet $wallet The wallet * * @return array|null Mandate information: * - id: Mandate identifier * - method: user-friendly payment method desc. * - isPending: the process didn't complete yet * - isValid: the mandate is valid */ public function getMandate(Wallet $wallet): ?array { // Get the Mandate info $mandate = self::stripeMandate($wallet); if (empty($mandate)) { return null; } $pm = StripeAPI\PaymentMethod::retrieve($mandate->payment_method); $result = [ 'id' => $mandate->id, 'isPending' => $mandate->status != 'succeeded' && $mandate->status != 'canceled', 'isValid' => $mandate->status == 'succeeded', 'method' => self::paymentMethod($pm, 'Unknown method') ]; return $result; } /** * Get a provider name * * @return string Provider name */ public function name(): string { return 'stripe'; } /** * Create a new payment. * * @param \App\Wallet $wallet The wallet * @param array $payment Payment data: * - amount: Value in cents * - currency: The operation currency * - type: first/oneoff/recurring * - description: Operation desc. * * @return array Provider payment/session data: * - id: Session identifier */ public function payment(Wallet $wallet, array $payment): ?array { if ($payment['type'] == self::TYPE_RECURRING) { return $this->paymentRecurring($wallet, $payment); } // Register the user in Stripe, if not yet done $customer_id = self::stripeCustomerId($wallet, true); $amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']); $payment['currency_amount'] = $amount; $request = [ 'customer' => $customer_id, - 'cancel_url' => Utils::serviceUrl('/wallet'), // required - 'success_url' => Utils::serviceUrl('/wallet'), // required + 'cancel_url' => self::redirectUrl(), // required + 'success_url' => self::redirectUrl(), // required 'payment_method_types' => ['card'], // required 'locale' => 'en', 'line_items' => [ [ 'name' => $payment['description'], 'amount' => $amount, 'currency' => \strtolower($payment['currency']), 'quantity' => 1, ] ] ]; $session = StripeAPI\Checkout\Session::create($request); // Store the payment reference in database $payment['id'] = $session->payment_intent; $this->storePayment($payment, $wallet->id); return [ 'id' => $session->id, ]; } /** * Create a new automatic payment operation. * * @param \App\Wallet $wallet The wallet * @param array $payment Payment data (see self::payment()) * * @return array Provider payment/session data: * - id: Session identifier */ protected function paymentRecurring(Wallet $wallet, array $payment): ?array { // Check if there's a valid mandate $mandate = self::stripeMandate($wallet); if (empty($mandate)) { return null; } $amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']); $payment['currency_amount'] = $amount; $request = [ 'amount' => $amount, 'currency' => \strtolower($payment['currency']), 'description' => $payment['description'], 'receipt_email' => $wallet->owner->email, 'customer' => $mandate->customer, 'payment_method' => $mandate->payment_method, 'off_session' => true, 'confirm' => true, ]; $intent = StripeAPI\PaymentIntent::create($request); // Store the payment reference in database $payment['id'] = $intent->id; $this->storePayment($payment, $wallet->id); return [ 'id' => $payment['id'], ]; } /** * Update payment status (and balance). * * @return int HTTP response code */ public function webhook(): int { // We cannot just use php://input as it's already "emptied" by the framework // $payload = file_get_contents('php://input'); $request = Request::instance(); $payload = $request->getContent(); $sig_header = $request->header('Stripe-Signature'); // Parse and validate the input try { $event = StripeAPI\Webhook::constructEvent( $payload, $sig_header, \config('services.stripe.webhook_secret') ); } catch (\Exception $e) { \Log::error("Invalid payload: " . $e->getMessage()); // Invalid payload return 400; } switch ($event->type) { case StripeAPI\Event::PAYMENT_INTENT_CANCELED: case StripeAPI\Event::PAYMENT_INTENT_PAYMENT_FAILED: case StripeAPI\Event::PAYMENT_INTENT_SUCCEEDED: $intent = $event->data->object; // @phpstan-ignore-line $payment = Payment::find($intent->id); if (empty($payment) || $payment->type == self::TYPE_MANDATE) { return 404; } switch ($intent->status) { case StripeAPI\PaymentIntent::STATUS_CANCELED: $status = self::STATUS_CANCELED; break; case StripeAPI\PaymentIntent::STATUS_SUCCEEDED: $status = self::STATUS_PAID; break; default: $status = self::STATUS_FAILED; } DB::beginTransaction(); if ($status == self::STATUS_PAID) { // Update the balance, if it wasn't already if ($payment->status != self::STATUS_PAID) { $this->creditPayment($payment, $intent); } } else { if (!empty($intent->last_payment_error)) { // See https://stripe.com/docs/error-codes for more info \Log::info(sprintf( 'Stripe payment failed (%s): %s', $payment->id, json_encode($intent->last_payment_error) )); } } if ($payment->status != self::STATUS_PAID) { $payment->status = $status; $payment->save(); if ($status != self::STATUS_CANCELED && $payment->type == self::TYPE_RECURRING) { // Disable the mandate if ($status == self::STATUS_FAILED) { $payment->wallet->setSetting('mandate_disabled', 1); } // Notify the user \App\Jobs\PaymentEmail::dispatch($payment); } } DB::commit(); break; case StripeAPI\Event::SETUP_INTENT_SUCCEEDED: case StripeAPI\Event::SETUP_INTENT_SETUP_FAILED: case StripeAPI\Event::SETUP_INTENT_CANCELED: $intent = $event->data->object; // @phpstan-ignore-line $payment = Payment::find($intent->id); if (empty($payment) || $payment->type != self::TYPE_MANDATE) { return 404; } switch ($intent->status) { case StripeAPI\SetupIntent::STATUS_CANCELED: $status = self::STATUS_CANCELED; break; case StripeAPI\SetupIntent::STATUS_SUCCEEDED: $status = self::STATUS_PAID; break; default: $status = self::STATUS_FAILED; } if ($status == self::STATUS_PAID) { $payment->wallet->setSetting('stripe_mandate_id', $intent->id); $threshold = intval((float) $payment->wallet->getSetting('mandate_balance') * 100); // Top-up the wallet if balance is below the threshold if ($payment->wallet->balance < $threshold && $payment->status != self::STATUS_PAID) { \App\Jobs\WalletCharge::dispatch($payment->wallet); } } $payment->status = $status; $payment->save(); break; default: \Log::debug("Unhandled Stripe event: " . var_export($payload, true)); break; } return 200; } /** * Get Stripe customer identifier for specified wallet. * Create one if does not exist yet. * * @param \App\Wallet $wallet The wallet * @param bool $create Create the customer if does not exist yet * * @return string|null Stripe customer identifier */ protected static function stripeCustomerId(Wallet $wallet, bool $create = false): ?string { $customer_id = $wallet->getSetting('stripe_id'); // Register the user in Stripe if (empty($customer_id) && $create) { $customer = StripeAPI\Customer::create([ 'name' => $wallet->owner->name(), // Stripe will display the email on Checkout page, editable, // and use it to send the receipt (?), use the user email here // 'email' => $wallet->id . '@private.' . \config('app.domain'), 'email' => $wallet->owner->email, ]); $customer_id = $customer->id; $wallet->setSetting('stripe_id', $customer->id); } return $customer_id; } /** * Get the active Stripe auto-payment mandate (Setup Intent) */ protected static function stripeMandate(Wallet $wallet) { // Note: Stripe also has 'Mandate' objects, but we do not use these if ($mandate_id = $wallet->getSetting('stripe_mandate_id')) { $mandate = StripeAPI\SetupIntent::retrieve($mandate_id); // @phpstan-ignore-next-line if ($mandate && $mandate->status != 'canceled') { return $mandate; } } } /** * Apply the successful payment's pecunia to the wallet */ protected static function creditPayment(Payment $payment, $intent) { $method = 'Stripe'; // Extract the payment method for transaction description if ( !empty($intent->charges) && ($charge = $intent->charges->data[0]) && ($pm = $charge->payment_method_details) ) { $method = self::paymentMethod($pm); } // TODO: Localization? $description = $payment->type == self::TYPE_RECURRING ? 'Auto-payment' : 'Payment'; $description .= " transaction {$payment->id} using {$method}"; $payment->wallet->credit($payment->amount, $description); // Unlock the disabled auto-payment mandate if ($payment->wallet->balance >= 0) { $payment->wallet->setSetting('mandate_disabled', null); } } /** * Extract payment method description from Stripe payment details */ protected static function paymentMethod($details, $default = ''): string { switch ($details->type) { case 'card': // TODO: card number return \sprintf( '%s (**** **** **** %s)', \ucfirst($details->card->brand) ?: 'Card', $details->card->last4 ); } return $default; } /** * List supported payment methods. * * @param string $type The payment type for which we require a method (oneoff/recurring). * * @return array Array of array with available payment methods: * - id: id of the method * - name: User readable name of the payment method * - minimumAmount: Minimum amount to be charged in cents * - currency: Currency used for the method * - exchangeRate: The projected exchange rate (actual rate is determined during payment) * - icon: An icon (icon name) representing the method */ public function providerPaymentMethods($type): array { //TODO get this from the stripe API? $availableMethods = []; switch ($type) { case self::TYPE_ONEOFF: $availableMethods = [ self::METHOD_CREDITCARD => [ 'id' => self::METHOD_CREDITCARD, 'name' => "Credit Card", 'minimumAmount' => self::MIN_AMOUNT, 'currency' => 'CHF', 'exchangeRate' => 1.0 ], self::METHOD_PAYPAL => [ 'id' => self::METHOD_PAYPAL, 'name' => "PayPal", 'minimumAmount' => self::MIN_AMOUNT, 'currency' => 'CHF', 'exchangeRate' => 1.0 ] ]; break; case self::TYPE_RECURRING: $availableMethods = [ self::METHOD_CREDITCARD => [ 'id' => self::METHOD_CREDITCARD, 'name' => "Credit Card", 'minimumAmount' => self::MIN_AMOUNT, // Converted to cents, 'currency' => 'CHF', 'exchangeRate' => 1.0 ] ]; break; } return $availableMethods; } /** * Get a payment. * * @param string $paymentId Payment identifier * * @return array Payment information: * - id: Payment identifier * - status: Payment status * - isCancelable: The payment can be canceled * - checkoutUrl: The checkout url to complete the payment or null if none */ public function getPayment($paymentId): array { \Log::info("Stripe::getPayment does not yet retrieve a checkoutUrl."); $payment = StripeAPI\PaymentIntent::retrieve($paymentId); return [ 'id' => $payment->id, 'status' => $payment->status, 'isCancelable' => false, 'checkoutUrl' => null ]; } } diff --git a/src/app/Providers/PaymentProvider.php b/src/app/Providers/PaymentProvider.php index 30d41a8e..b4f1558e 100644 --- a/src/app/Providers/PaymentProvider.php +++ b/src/app/Providers/PaymentProvider.php @@ -1,388 +1,408 @@ ['prefix' => 'far', 'name' => 'credit-card'], self::METHOD_PAYPAL => ['prefix' => 'fab', 'name' => 'paypal'], self::METHOD_BANKTRANSFER => ['prefix' => 'fas', 'name' => 'university'] ]; /** * Detect the name of the provider * * @param \App\Wallet|string|null $provider_or_wallet * @return string The name of the provider */ private static function providerName($provider_or_wallet = null): string { if ($provider_or_wallet instanceof Wallet) { if ($provider_or_wallet->getSetting('stripe_id')) { $provider = self::PROVIDER_STRIPE; } elseif ($provider_or_wallet->getSetting('mollie_id')) { $provider = self::PROVIDER_MOLLIE; } } else { $provider = $provider_or_wallet; } if (empty($provider)) { $provider = \config('services.payment_provider') ?: self::PROVIDER_MOLLIE; } return \strtolower($provider); } /** * Factory method * * @param \App\Wallet|string|null $provider_or_wallet */ public static function factory($provider_or_wallet = null) { switch (self::providerName($provider_or_wallet)) { case self::PROVIDER_STRIPE: return new \App\Providers\Payment\Stripe(); case self::PROVIDER_MOLLIE: return new \App\Providers\Payment\Mollie(); default: throw new \Exception("Invalid payment provider: {$provider_or_wallet}"); } } /** * Create a new auto-payment mandate for a wallet. * * @param \App\Wallet $wallet The wallet * @param array $payment Payment data: * - amount: Value in cents * - currency: The operation currency * - description: Operation desc. * - methodId: Payment method * * @return array Provider payment data: * - id: Operation identifier * - redirectUrl: the location to redirect to */ abstract public function createMandate(Wallet $wallet, array $payment): ?array; /** * Revoke the auto-payment mandate for a wallet. * * @param \App\Wallet $wallet The wallet * * @return bool True on success, False on failure */ abstract public function deleteMandate(Wallet $wallet): bool; /** * Get a auto-payment mandate for a wallet. * * @param \App\Wallet $wallet The wallet * * @return array|null Mandate information: * - id: Mandate identifier * - method: user-friendly payment method desc. * - methodId: Payment method * - isPending: the process didn't complete yet * - isValid: the mandate is valid */ abstract public function getMandate(Wallet $wallet): ?array; /** * Get a link to the customer in the provider's control panel * * @param \App\Wallet $wallet The wallet * * @return string|null The string representing tag */ abstract public function customerLink(Wallet $wallet): ?string; /** * Get a provider name * * @return string Provider name */ abstract public function name(): string; /** * Create a new payment. * * @param \App\Wallet $wallet The wallet * @param array $payment Payment data: * - amount: Value in cents * - currency: The operation currency * - type: first/oneoff/recurring * - description: Operation description * - methodId: Payment method * * @return array Provider payment/session data: * - id: Operation identifier * - redirectUrl */ abstract public function payment(Wallet $wallet, array $payment): ?array; /** * Update payment status (and balance). * * @return int HTTP response code */ abstract public function webhook(): int; /** * Create a payment record in DB * * @param array $payment Payment information * @param string $wallet_id Wallet ID * * @return \App\Payment Payment object */ protected function storePayment(array $payment, $wallet_id): Payment { $db_payment = new Payment(); $db_payment->id = $payment['id']; $db_payment->description = $payment['description'] ?? ''; $db_payment->status = $payment['status'] ?? self::STATUS_OPEN; $db_payment->amount = $payment['amount'] ?? 0; $db_payment->type = $payment['type']; $db_payment->wallet_id = $wallet_id; $db_payment->provider = $this->name(); $db_payment->currency = $payment['currency']; $db_payment->currency_amount = $payment['currency_amount']; $db_payment->save(); return $db_payment; } /** * Retrieve an exchange rate. * * @param string $sourceCurrency Currency from which to convert * @param string $targetCurrency Currency to convert to * * @return float Exchange rate */ protected function exchangeRate(string $sourceCurrency, string $targetCurrency): float { if (strcasecmp($sourceCurrency, $targetCurrency)) { throw new \Exception("Currency conversion is not yet implemented."); //FIXME Not yet implemented } return 1.0; } /** * Convert a value from $sourceCurrency to $targetCurrency * * @param int $amount Amount in cents of $sourceCurrency * @param string $sourceCurrency Currency from which to convert * @param string $targetCurrency Currency to convert to * * @return int Exchanged amount in cents of $targetCurrency */ protected function exchange(int $amount, string $sourceCurrency, string $targetCurrency): int { return intval(round($amount * $this->exchangeRate($sourceCurrency, $targetCurrency))); } /** * Deduct an amount of pecunia from the wallet. * Creates a payment and transaction records for the refund/chargeback operation. * * @param \App\Wallet $wallet A wallet object * @param array $refund A refund or chargeback data (id, type, amount, description) * * @return void */ protected function storeRefund(Wallet $wallet, array $refund): void { if (empty($refund) || empty($refund['amount'])) { return; } // Preserve originally refunded amount $refund['currency_amount'] = $refund['amount']; // Convert amount to wallet currency // TODO We should possibly be using the same exchange rate as for the original payment? $amount = $this->exchange($refund['amount'], $refund['currency'], $wallet->currency); $wallet->balance -= $amount; $wallet->save(); if ($refund['type'] == self::TYPE_CHARGEBACK) { $transaction_type = Transaction::WALLET_CHARGEBACK; } else { $transaction_type = Transaction::WALLET_REFUND; } Transaction::create([ 'object_id' => $wallet->id, 'object_type' => Wallet::class, 'type' => $transaction_type, 'amount' => $amount * -1, 'description' => $refund['description'] ?? '', ]); $refund['status'] = self::STATUS_PAID; $refund['amount'] = -1 * $amount; + // FIXME: Refunds/chargebacks are out of the reseller comissioning for now + $this->storePayment($refund, $wallet->id); } /** * List supported payment methods from this provider * * @param string $type The payment type for which we require a method (oneoff/recurring). * * @return array Array of array with available payment methods: * - id: id of the method * - name: User readable name of the payment method * - minimumAmount: Minimum amount to be charged in cents * - currency: Currency used for the method * - exchangeRate: The projected exchange rate (actual rate is determined during payment) * - icon: An icon (icon name) representing the method */ abstract public function providerPaymentMethods($type): array; /** * Get a payment. * * @param string $paymentId Payment identifier * * @return array Payment information: * - id: Payment identifier * - status: Payment status * - isCancelable: The payment can be canceled * - checkoutUrl: The checkout url to complete the payment or null if none */ abstract public function getPayment($paymentId): array; /** * Return an array of whitelisted payment methods with override values. * * @param string $type The payment type for which we require a method. * * @return array Array of methods */ protected static function paymentMethodsWhitelist($type): array { switch ($type) { case self::TYPE_ONEOFF: return [ self::METHOD_CREDITCARD => [ 'id' => self::METHOD_CREDITCARD, 'icon' => self::$paymentMethodIcons[self::METHOD_CREDITCARD] ], self::METHOD_PAYPAL => [ 'id' => self::METHOD_PAYPAL, 'icon' => self::$paymentMethodIcons[self::METHOD_PAYPAL] ], // TODO Enable once we're ready to offer them // self::METHOD_BANKTRANSFER => [ // 'id' => self::METHOD_BANKTRANSFER, // 'icon' => self::$paymentMethodIcons[self::METHOD_BANKTRANSFER] // ] ]; case PaymentProvider::TYPE_RECURRING: return [ self::METHOD_CREDITCARD => [ 'id' => self::METHOD_CREDITCARD, 'icon' => self::$paymentMethodIcons[self::METHOD_CREDITCARD] ] ]; } \Log::error("Unknown payment type: " . $type); return []; } /** * Return an array of whitelisted payment methods with override values. * * @param string $type The payment type for which we require a method. * * @return array Array of methods */ private static function applyMethodWhitelist($type, $availableMethods): array { $methods = []; // Use only whitelisted methods, and apply values from whitelist (overriding the backend) $whitelistMethods = self::paymentMethodsWhitelist($type); foreach ($whitelistMethods as $id => $whitelistMethod) { if (array_key_exists($id, $availableMethods)) { $methods[] = array_merge($availableMethods[$id], $whitelistMethod); } } return $methods; } /** * List supported payment methods for $wallet * * @param \App\Wallet $wallet The wallet * @param string $type The payment type for which we require a method (oneoff/recurring). * * @return array Array of array with available payment methods: * - id: id of the method * - name: User readable name of the payment method * - minimumAmount: Minimum amount to be charged in cents * - currency: Currency used for the method * - exchangeRate: The projected exchange rate (actual rate is determined during payment) * - icon: An icon (icon name) representing the method */ public static function paymentMethods(Wallet $wallet, $type): array { $providerName = self::providerName($wallet); $cacheKey = "methods-" . $providerName . '-' . $type; if ($methods = Cache::get($cacheKey)) { \Log::debug("Using payment method cache" . var_export($methods, true)); return $methods; } $provider = PaymentProvider::factory($providerName); $methods = self::applyMethodWhitelist($type, $provider->providerPaymentMethods($type)); Cache::put($cacheKey, $methods, now()->addHours(1)); return $methods; } + + /** + * Returns the full URL for the wallet page, used when returning from an external payment page. + * Depending on the request origin it will return a URL for the User or Reseller UI. + * + * @return string The redirect URL + */ + public static function redirectUrl(): string + { + $url = \App\Utils::serviceUrl('/wallet'); + $domain = preg_replace('/:[0-9]+$/', '', request()->getHttpHost()); + + if (strpos($domain, 'reseller') === 0) { + $url = preg_replace('|^(https?://)([^/]+)|', '\\1' . $domain, $url); + } + + return $url; + } } diff --git a/src/app/SignupInvitation.php b/src/app/SignupInvitation.php new file mode 100644 index 00000000..f8053463 --- /dev/null +++ b/src/app/SignupInvitation.php @@ -0,0 +1,109 @@ +status & self::STATUS_COMPLETED) > 0; + } + + /** + * Returns whether this invitation sending failed. + * + * @return bool + */ + public function isFailed(): bool + { + return ($this->status & self::STATUS_FAILED) > 0; + } + + /** + * Returns whether this invitation is new. + * + * @return bool + */ + public function isNew(): bool + { + return ($this->status & self::STATUS_NEW) > 0; + } + + /** + * Returns whether this invitation has been sent. + * + * @return bool + */ + public function isSent(): bool + { + return ($this->status & self::STATUS_SENT) > 0; + } + + /** + * The tenant for this invitation. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function tenant() + { + return $this->belongsTo('App\Tenant', 'tenant_id', 'id'); + } + + /** + * The account to which the invitation was used for. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function user() + { + return $this->belongsTo('App\User', 'user_id', 'id'); + } +} diff --git a/src/app/Sku.php b/src/app/Sku.php index ee451e86..2e1a90ee 100644 --- a/src/app/Sku.php +++ b/src/app/Sku.php @@ -1,62 +1,85 @@ 'integer' ]; protected $fillable = [ 'active', 'cost', 'description', + 'fee', 'handler_class', 'name', // persist for annual domain registration 'period', 'title', 'units_free', ]; /** @var array Translatable properties */ public $translatable = [ 'name', 'description', ]; /** * List the entitlements that consume this SKU. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function entitlements() { return $this->hasMany('App\Entitlement'); } /** * List of packages that use this SKU. * * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany */ public function packages() { return $this->belongsToMany( 'App\Package', 'package_skus' )->using('App\PackageSku')->withPivot(['cost', 'qty']); } + + /** + * The tenant for this SKU. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function tenant() + { + return $this->belongsTo('App\Tenant', 'tenant_id', 'id'); + } } diff --git a/src/app/Tenant.php b/src/app/Tenant.php new file mode 100644 index 00000000..608b1897 --- /dev/null +++ b/src/app/Tenant.php @@ -0,0 +1,52 @@ +hasMany('App\Discount'); + } + + /** + * SignupInvitations assigned to this tenant. + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function signupInvitations() + { + return $this->hasMany('App\SignupInvitation'); + } + + /* + * Returns the wallet of the tanant (reseller's wallet). + * + * @return ?\App\Wallet A wallet object + */ + public function wallet(): ?Wallet + { + $user = \App\User::where('role', 'reseller')->where('tenant_id', $this->id)->first(); + + return $user ? $user->wallets->first() : null; + } +} diff --git a/src/app/User.php b/src/app/User.php index dc4605ed..55379903 100644 --- a/src/app/User.php +++ b/src/app/User.php @@ -1,714 +1,758 @@ belongsToMany( 'App\Wallet', // The foreign object definition 'user_accounts', // The table name 'user_id', // The local foreign key 'wallet_id' // The remote foreign key ); } /** * Email aliases of this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function aliases() { return $this->hasMany('App\UserAlias', 'user_id'); } /** * Assign a package to a user. The user should not have any existing entitlements. * * @param \App\Package $package The package to assign. * @param \App\User|null $user Assign the package to another user. * * @return \App\User */ public function assignPackage($package, $user = null) { if (!$user) { $user = $this; } $wallet_id = $this->wallets()->first()->id; foreach ($package->skus as $sku) { for ($i = $sku->pivot->qty; $i > 0; $i--) { \App\Entitlement::create( [ 'wallet_id' => $wallet_id, 'sku_id' => $sku->id, 'cost' => $sku->pivot->cost(), + 'fee' => $sku->pivot->fee(), 'entitleable_id' => $user->id, 'entitleable_type' => User::class ] ); } } return $user; } /** * Assign a package plan to a user. * * @param \App\Plan $plan The plan to assign * @param \App\Domain $domain Optional domain object * * @return \App\User Self */ public function assignPlan($plan, $domain = null): User { $this->setSetting('plan_id', $plan->id); foreach ($plan->packages as $package) { if ($package->isDomain()) { $domain->assignPackage($package, $this); } else { $this->assignPackage($package); } } return $this; } /** * Assign a Sku to a user. * * @param \App\Sku $sku The sku to assign. * @param int $count Count of entitlements to add * * @return \App\User Self * @throws \Exception */ public function assignSku(Sku $sku, int $count = 1): User { // TODO: I guess wallet could be parametrized in future $wallet = $this->wallet(); $exists = $this->entitlements()->where('sku_id', $sku->id)->count(); while ($count > 0) { \App\Entitlement::create([ 'wallet_id' => $wallet->id, 'sku_id' => $sku->id, 'cost' => $exists >= $sku->units_free ? $sku->cost : 0, + 'fee' => $exists >= $sku->units_free ? $sku->fee : 0, 'entitleable_id' => $this->id, 'entitleable_type' => User::class ]); $exists++; $count--; } return $this; } /** * Check if current user can delete another object. * - * @param \App\User|\App\Domain $object A user|domain object + * @param mixed $object A user|domain|wallet|group object * * @return bool True if he can, False otherwise */ public function canDelete($object): bool { if (!method_exists($object, 'wallet')) { return false; } $wallet = $object->wallet(); // TODO: For now controller can delete/update the account owner, // this may change in future, controllers are not 0-regression feature return $this->wallets->contains($wallet) || $this->accounts->contains($wallet); } /** * Check if current user can read data of another object. * - * @param \App\User|\App\Domain|\App\Wallet $object A user|domain|wallet object + * @param mixed $object A user|domain|wallet|group object * * @return bool True if he can, False otherwise */ public function canRead($object): bool { - if ($this->role == "admin") { + if ($this->role == 'admin') { return true; } if ($object instanceof User && $this->id == $object->id) { return true; } + if ($this->role == 'reseller') { + if ($object instanceof User && $object->role == 'admin') { + return false; + } + + if ($object instanceof Wallet && !empty($object->owner)) { + $object = $object->owner; + } + + return isset($object->tenant_id) && $object->tenant_id == $this->tenant_id; + } + if ($object instanceof Wallet) { return $object->user_id == $this->id || $object->controllers->contains($this); } if (!method_exists($object, 'wallet')) { return false; } $wallet = $object->wallet(); - return $this->wallets->contains($wallet) || $this->accounts->contains($wallet); + return $wallet && ($this->wallets->contains($wallet) || $this->accounts->contains($wallet)); } /** * Check if current user can update data of another object. * - * @param \App\User|\App\Domain $object A user|domain object + * @param mixed $object A user|domain|wallet|group object * * @return bool True if he can, False otherwise */ public function canUpdate($object): bool { - if (!method_exists($object, 'wallet')) { - return false; + if ($object instanceof User && $this->id == $object->id) { + return true; } - if ($object instanceof User && $this->id == $object->id) { + if ($this->role == 'admin') { return true; } + if ($this->role == 'reseller') { + if ($object instanceof User && $object->role == 'admin') { + return false; + } + + if ($object instanceof Wallet && !empty($object->owner)) { + $object = $object->owner; + } + + return isset($object->tenant_id) && $object->tenant_id == $this->tenant_id; + } + return $this->canDelete($object); } /** * Return the \App\Domain for this user. * * @return \App\Domain|null */ public function domain() { list($local, $domainName) = explode('@', $this->email); $domain = \App\Domain::withTrashed()->where('namespace', $domainName)->first(); return $domain; } /** * List the domains to which this user is entitled. + * Note: Active public domains are also returned (for the user tenant). * - * @return Domain[] + * @return Domain[] List of Domain objects */ - public function domains() + public function domains(): array { - $domains = Domain::whereRaw(sprintf('(type & %s)', Domain::TYPE_PUBLIC)) + if ($this->tenant_id) { + $domains = Domain::where('tenant_id', $this->tenant_id); + } else { + $domains = Domain::withEnvTenant(); + } + + $domains = $domains->whereRaw(sprintf('(type & %s)', Domain::TYPE_PUBLIC)) ->whereRaw(sprintf('(status & %s)', Domain::STATUS_ACTIVE)) ->get() ->all(); foreach ($this->wallets as $wallet) { $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get(); foreach ($entitlements as $entitlement) { $domains[] = $entitlement->entitleable; } } foreach ($this->accounts as $wallet) { $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get(); foreach ($entitlements as $entitlement) { $domains[] = $entitlement->entitleable; } } return $domains; } /** * The user entitlement. * * @return \Illuminate\Database\Eloquent\Relations\MorphOne */ public function entitlement() { return $this->morphOne('App\Entitlement', 'entitleable'); } /** * Entitlements for this user. * * Note that these are entitlements that apply to the user account, and not entitlements that * this user owns. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function entitlements() { return $this->hasMany('App\Entitlement', 'entitleable_id', 'id') ->where('entitleable_type', User::class); } /** * Find whether an email address exists as a user (including deleted users). * * @param string $email Email address * @param bool $return_user Return User instance instead of boolean * * @return \App\User|bool True or User model object if found, False otherwise */ public static function emailExists(string $email, bool $return_user = false) { if (strpos($email, '@') === false) { return false; } $email = \strtolower($email); $user = self::withTrashed()->where('email', $email)->first(); if ($user) { return $return_user ? $user : true; } return false; } /** * Helper to find user by email address, whether it is * main email address, alias or an external email. * * If there's more than one alias NULL will be returned. * * @param string $email Email address * @param bool $external Search also for an external email * * @return \App\User User model object if found */ public static function findByEmail(string $email, bool $external = false): ?User { if (strpos($email, '@') === false) { return null; } $email = \strtolower($email); $user = self::where('email', $email)->first(); if ($user) { return $user; } $aliases = UserAlias::where('alias', $email)->get(); if (count($aliases) == 1) { return $aliases->first()->user; } // TODO: External email return null; } public function getJWTIdentifier() { return $this->getKey(); } public function getJWTCustomClaims() { return []; } /** * Return groups controlled by the current user. * * @param bool $with_accounts Include groups assigned to wallets * the current user controls but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function groups($with_accounts = true) { $wallets = $this->wallets()->pluck('id')->all(); if ($with_accounts) { $wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all()); } return Group::select(['groups.*', 'entitlements.wallet_id']) ->distinct() ->join('entitlements', 'entitlements.entitleable_id', '=', 'groups.id') ->whereIn('entitlements.wallet_id', $wallets) ->where('entitlements.entitleable_type', Group::class); } /** * Check if user has an entitlement for the specified SKU. * * @param string $title The SKU title * * @return bool True if specified SKU entitlement exists */ public function hasSku($title): bool { $sku = Sku::where('title', $title)->first(); if (!$sku) { return false; } return $this->entitlements()->where('sku_id', $sku->id)->count() > 0; } /** * Returns whether this domain is active. * * @return bool */ public function isActive(): bool { return ($this->status & self::STATUS_ACTIVE) > 0; } /** * Returns whether this domain is deleted. * * @return bool */ public function isDeleted(): bool { return ($this->status & self::STATUS_DELETED) > 0; } /** * Returns whether this (external) domain has been verified * to exist in DNS. * * @return bool */ public function isImapReady(): bool { return ($this->status & self::STATUS_IMAP_READY) > 0; } /** * Returns whether this user is registered in LDAP. * * @return bool */ public function isLdapReady(): bool { return ($this->status & self::STATUS_LDAP_READY) > 0; } /** * Returns whether this user is new. * * @return bool */ public function isNew(): bool { return ($this->status & self::STATUS_NEW) > 0; } /** * Returns whether this domain is suspended. * * @return bool */ public function isSuspended(): bool { return ($this->status & self::STATUS_SUSPENDED) > 0; } /** * A shortcut to get the user name. * * @param bool $fallback Return " User" if there's no name * * @return string Full user name */ public function name(bool $fallback = false): string { $firstname = $this->getSetting('first_name'); $lastname = $this->getSetting('last_name'); $name = trim($firstname . ' ' . $lastname); if (empty($name) && $fallback) { return \config('app.name') . ' User'; } return $name; } /** * Remove a number of entitlements for the SKU. * * @param \App\Sku $sku The SKU * @param int $count The number of entitlements to remove * * @return User Self */ public function removeSku(Sku $sku, int $count = 1): User { $entitlements = $this->entitlements() ->where('sku_id', $sku->id) ->orderBy('cost', 'desc') ->orderBy('created_at') ->get(); $entitlements_count = count($entitlements); foreach ($entitlements as $entitlement) { if ($entitlements_count <= $sku->units_free) { continue; } if ($count > 0) { $entitlement->delete(); $entitlements_count--; $count--; } } return $this; } /** * Any (additional) properties of this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function settings() { return $this->hasMany('App\UserSetting', 'user_id'); } /** * Suspend this domain. * * @return void */ public function suspend(): void { if ($this->isSuspended()) { return; } $this->status |= User::STATUS_SUSPENDED; $this->save(); } + /** + * The tenant for this user account. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function tenant() + { + return $this->belongsTo('App\Tenant', 'tenant_id', 'id'); + } + /** * Unsuspend this domain. * * @return void */ public function unsuspend(): void { if (!$this->isSuspended()) { return; } $this->status ^= User::STATUS_SUSPENDED; $this->save(); } /** * Return users controlled by the current user. * * @param bool $with_accounts Include users assigned to wallets * the current user controls but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function users($with_accounts = true) { $wallets = $this->wallets()->pluck('id')->all(); if ($with_accounts) { $wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all()); } return $this->select(['users.*', 'entitlements.wallet_id']) ->distinct() ->leftJoin('entitlements', 'entitlements.entitleable_id', '=', 'users.id') ->whereIn('entitlements.wallet_id', $wallets) ->where('entitlements.entitleable_type', User::class); } /** * Verification codes for this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function verificationcodes() { return $this->hasMany('App\VerificationCode', 'user_id', 'id'); } /** * Returns the wallet by which the user is controlled * * @return ?\App\Wallet A wallet object */ public function wallet(): ?Wallet { $entitlement = $this->entitlement()->withTrashed()->orderBy('created_at', 'desc')->first(); // TODO: No entitlement should not happen, but in tests we have // such cases, so we fallback to the user's wallet in this case return $entitlement ? $entitlement->wallet : $this->wallets()->first(); } /** * Wallets this user owns. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function wallets() { return $this->hasMany('App\Wallet'); } /** * User password mutator * * @param string $password The password in plain text. * * @return void */ public function setPasswordAttribute($password) { if (!empty($password)) { $this->attributes['password'] = bcrypt($password, [ "rounds" => 12 ]); $this->attributes['password_ldap'] = '{SSHA512}' . base64_encode( pack('H*', hash('sha512', $password)) ); } } /** * User LDAP password mutator * * @param string $password The password in plain text. * * @return void */ public function setPasswordLdapAttribute($password) { $this->setPasswordAttribute($password); } /** * User status mutator * * @throws \Exception */ public function setStatusAttribute($status) { $new_status = 0; $allowed_values = [ self::STATUS_NEW, self::STATUS_ACTIVE, self::STATUS_SUSPENDED, self::STATUS_DELETED, self::STATUS_LDAP_READY, self::STATUS_IMAP_READY, ]; foreach ($allowed_values as $value) { if ($status & $value) { $new_status |= $value; $status ^= $value; } } if ($status > 0) { throw new \Exception("Invalid user status: {$status}"); } $this->attributes['status'] = $new_status; } } diff --git a/src/app/Utils.php b/src/app/Utils.php index e6799475..86cffb74 100644 --- a/src/app/Utils.php +++ b/src/app/Utils.php @@ -1,395 +1,396 @@ = INET_ATON(?) ORDER BY INET_ATON(net_number), net_mask DESC LIMIT 1 "; } else { $query = " SELECT id FROM ip6nets WHERE INET6_ATON(net_number) <= INET6_ATON(?) AND INET6_ATON(net_broadcast) >= INET6_ATON(?) ORDER BY INET6_ATON(net_number), net_mask DESC LIMIT 1 "; } $nets = \Illuminate\Support\Facades\DB::select($query, [$ip, $ip]); if (sizeof($nets) > 0) { return $nets[0]->country; } return '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); } /** * 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; } /** * 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 * @todo Move this to App\Http\Controllers\Controller * * @return string Full URL */ public static function serviceUrl(string $route): string { $url = \config('app.public_url'); if (!$url) { $url = \config('app.url'); } return rtrim(trim($url, '/') . '/' . ltrim($route, '/'), '/'); } /** * Create a configuration/environment data to be passed to * the UI * * @todo 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'); - $path = request()->path(); $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; } } diff --git a/src/app/Wallet.php b/src/app/Wallet.php index 8c0a77ec..882dedec 100644 --- a/src/app/Wallet.php +++ b/src/app/Wallet.php @@ -1,400 +1,417 @@ 0, 'currency' => 'CHF' ]; protected $fillable = [ 'currency' ]; protected $nullable = [ 'description', ]; protected $casts = [ 'balance' => 'integer', ]; /** * Add a controller to this wallet. * * @param \App\User $user The user to add as a controller to this wallet. * * @return void */ public function addController(User $user) { if (!$this->controllers->contains($user)) { $this->controllers()->save($user); } } public function chargeEntitlements($apply = true) { // This wallet has been created less than a month ago, this is the trial period if ($this->owner->created_at >= Carbon::now()->subMonthsWithoutOverflow(1)) { // Move all the current entitlement's updated_at timestamps forward to one month after // this wallet was created. $freeMonthEnds = $this->owner->created_at->copy()->addMonthsWithoutOverflow(1); foreach ($this->entitlements()->get()->fresh() as $entitlement) { if ($entitlement->updated_at < $freeMonthEnds) { $entitlement->updated_at = $freeMonthEnds; $entitlement->save(); } } return 0; } + $profit = 0; $charges = 0; $discount = $this->getDiscountRate(); DB::beginTransaction(); // used to parent individual entitlement billings to the wallet debit. $entitlementTransactions = []; foreach ($this->entitlements()->get()->fresh() as $entitlement) { // This entitlement has been created less than or equal to 14 days ago (this is at // maximum the fourteenth 24-hour period). if ($entitlement->created_at > Carbon::now()->subDays(14)) { continue; } // This entitlement was created, or billed last, less than a month ago. if ($entitlement->updated_at > Carbon::now()->subMonthsWithoutOverflow(1)) { continue; } // updated last more than a month ago -- was it billed? if ($entitlement->updated_at <= Carbon::now()->subMonthsWithoutOverflow(1)) { $diff = $entitlement->updated_at->diffInMonths(Carbon::now()); $cost = (int) ($entitlement->cost * $discount * $diff); + $fee = (int) ($entitlement->fee * $diff); $charges += $cost; + $profit += $cost - $fee; // if we're in dry-run, you know... if (!$apply) { continue; } $entitlement->updated_at = $entitlement->updated_at->copy() ->addMonthsWithoutOverflow($diff); $entitlement->save(); if ($cost == 0) { continue; } $entitlementTransactions[] = $entitlement->createTransaction( \App\Transaction::ENTITLEMENT_BILLED, $cost ); } } if ($apply) { - $this->debit($charges, $entitlementTransactions); + $this->debit($charges, '', $entitlementTransactions); + + // Credit/debit the reseller + if ($profit != 0 && $this->owner->tenant) { + // FIXME: Should we have a simpler way to skip this for non-reseller tenant(s) + if ($wallet = $this->owner->tenant->wallet()) { + $desc = "Charged user {$this->owner->email}"; + $method = $profit > 0 ? 'credit' : 'debit'; + $wallet->{$method}(abs($profit), $desc); + } + } } DB::commit(); return $charges; } /** * Calculate for how long the current balance will last. * * Returns NULL for balance < 0 or discount = 100% or on a fresh account * * @return \Carbon\Carbon|null Date */ public function balanceLastsUntil() { if ($this->balance < 0 || $this->getDiscount() == 100) { return null; } // retrieve any expected charges $expectedCharge = $this->expectedCharges(); // get the costs per day for all entitlements billed against this wallet $costsPerDay = $this->costsPerDay(); if (!$costsPerDay) { return null; } // the number of days this balance, minus the expected charges, would last $daysDelta = ($this->balance - $expectedCharge) / $costsPerDay; // calculate from the last entitlement billed $entitlement = $this->entitlements()->orderBy('updated_at', 'desc')->first(); $until = $entitlement->updated_at->copy()->addDays($daysDelta); // Don't return dates from the past if ($until < Carbon::now() && !$until->isToday()) { return null; } return $until; } /** * Controllers of this wallet. * * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany */ public function controllers() { return $this->belongsToMany( 'App\User', // The foreign object definition 'user_accounts', // The table name 'wallet_id', // The local foreign key 'user_id' // The remote foreign key ); } /** * Retrieve the costs per day of everything charged to this wallet. * * @return float */ public function costsPerDay() { $costs = (float) 0; foreach ($this->entitlements as $entitlement) { $costs += $entitlement->costsPerDay(); } return $costs; } /** * Add an amount of pecunia to this wallet's balance. * * @param int $amount The amount of pecunia to add (in cents). * @param string $description The transaction description * * @return Wallet Self */ public function credit(int $amount, string $description = ''): Wallet { $this->balance += $amount; $this->save(); \App\Transaction::create( [ 'object_id' => $this->id, 'object_type' => \App\Wallet::class, 'type' => \App\Transaction::WALLET_CREDIT, 'amount' => $amount, 'description' => $description ] ); return $this; } /** * Deduct an amount of pecunia from this wallet's balance. * - * @param int $amount The amount of pecunia to deduct (in cents). - * @param array $eTIDs List of transaction IDs for the individual entitlements that make up - * this debit record, if any. + * @param int $amount The amount of pecunia to deduct (in cents). + * @param string $description The transaction description + * @param array $eTIDs List of transaction IDs for the individual entitlements + * that make up this debit record, if any. * @return Wallet Self */ - public function debit(int $amount, array $eTIDs = []): Wallet + public function debit(int $amount, string $description = '', array $eTIDs = []): Wallet { if ($amount == 0) { return $this; } $this->balance -= $amount; $this->save(); $transaction = \App\Transaction::create( [ 'object_id' => $this->id, 'object_type' => \App\Wallet::class, 'type' => \App\Transaction::WALLET_DEBIT, - 'amount' => $amount * -1 + 'amount' => $amount * -1, + 'description' => $description ] ); - \App\Transaction::whereIn('id', $eTIDs)->update(['transaction_id' => $transaction->id]); + if (!empty($eTIDs)) { + \App\Transaction::whereIn('id', $eTIDs)->update(['transaction_id' => $transaction->id]); + } return $this; } /** * The discount assigned to the wallet. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function discount() { return $this->belongsTo('App\Discount', 'discount_id', 'id'); } /** * Entitlements billed to this wallet. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function entitlements() { return $this->hasMany('App\Entitlement'); } /** * Calculate the expected charges to this wallet. * * @return int */ public function expectedCharges() { return $this->chargeEntitlements(false); } /** * Return the exact, numeric version of the discount to be applied. * * Ranges from 0 - 100. * * @return int */ public function getDiscount() { return $this->discount ? $this->discount->discount : 0; } /** * The actual discount rate for use in multiplication * * Ranges from 0.00 to 1.00. */ public function getDiscountRate() { return (100 - $this->getDiscount()) / 100; } /** * A helper to display human-readable amount of money using * the wallet currency and specified locale. * * @param int $amount A amount of money (in cents) * @param string $locale A locale for the output * * @return string String representation, e.g. "9.99 CHF" */ public function money(int $amount, $locale = 'de_DE') { $amount = round($amount / 100, 2); // Prefer intl extension's number formatter if (class_exists('NumberFormatter')) { $nf = new \NumberFormatter($locale, \NumberFormatter::CURRENCY); $result = $nf->formatCurrency($amount, $this->currency); // Replace non-breaking space return str_replace("\xC2\xA0", " ", $result); } return sprintf('%.2f %s', $amount, $this->currency); } /** * The owner of the wallet -- the wallet is in his/her back pocket. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function owner() { return $this->belongsTo('App\User', 'user_id', 'id'); } /** * Payments on this wallet. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function payments() { return $this->hasMany('App\Payment'); } /** * Remove a controller from this wallet. * * @param \App\User $user The user to remove as a controller from this wallet. * * @return void */ public function removeController(User $user) { if ($this->controllers->contains($user)) { $this->controllers()->detach($user); } } /** * Any (additional) properties of this wallet. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function settings() { return $this->hasMany('App\WalletSetting'); } /** * Retrieve the transactions against this wallet. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function transactions() { return \App\Transaction::where( [ 'object_id' => $this->id, 'object_type' => \App\Wallet::class ] ); } } diff --git a/src/config/app.php b/src/config/app.php index dd4b08e5..6179e447 100644 --- a/src/config/app.php +++ b/src/config/app.php @@ -1,269 +1,271 @@ env('APP_NAME', 'Laravel'), /* |-------------------------------------------------------------------------- | Application Environment |-------------------------------------------------------------------------- | | This value determines the "environment" your application is currently | running in. This may determine how you prefer to configure various | services the application utilizes. Set this in your ".env" file. | */ 'env' => env('APP_ENV', 'production'), /* |-------------------------------------------------------------------------- | Application Debug Mode |-------------------------------------------------------------------------- | | When your application is in debug mode, detailed error messages with | stack traces will be shown on every error that occurs within your | application. If disabled, a simple generic error page is shown. | */ 'debug' => env('APP_DEBUG', false), /* |-------------------------------------------------------------------------- | Application URL |-------------------------------------------------------------------------- | | This URL is used by the console to properly generate URLs when using | the Artisan command line tool. You should set this to the root of | your application so that it is used when running Artisan tasks. */ 'url' => env('APP_URL', 'http://localhost'), 'public_url' => env('APP_PUBLIC_URL', env('APP_URL', 'http://localhost')), 'asset_url' => env('ASSET_URL', null), 'support_url' => env('SUPPORT_URL', null), 'support_email' => env('SUPPORT_EMAIL', null), 'webmail_url' => env('WEBMAIL_URL', null), 'theme' => env('APP_THEME', 'default'), + 'tenant_id' => env('APP_TENANT_ID', null), + /* |-------------------------------------------------------------------------- | Application Domain |-------------------------------------------------------------------------- | | System domain used for user signup (kolab identity) */ 'domain' => env('APP_DOMAIN', 'domain.tld'), /* |-------------------------------------------------------------------------- | Application Timezone |-------------------------------------------------------------------------- | | Here you may specify the default timezone for your application, which | will be used by the PHP date and date-time functions. We have gone | ahead and set this to a sensible default for you out of the box. | */ 'timezone' => 'UTC', /* |-------------------------------------------------------------------------- | Application Locale Configuration |-------------------------------------------------------------------------- | | The application locale determines the default locale that will be used | by the translation service provider. You are free to set this value | to any of the locales which will be supported by the application. | */ 'locale' => env('APP_LOCALE', 'en'), /* |-------------------------------------------------------------------------- | Application Fallback Locale |-------------------------------------------------------------------------- | | The fallback locale determines the locale to use when the current one | is not available. You may change the value to correspond to any of | the language folders that are provided through your application. | */ 'fallback_locale' => 'en', /* |-------------------------------------------------------------------------- | Faker Locale |-------------------------------------------------------------------------- | | This locale will be used by the Faker PHP library when generating fake | data for your database seeds. For example, this will be used to get | localized telephone numbers, street address information and more. | */ 'faker_locale' => 'en_US', /* |-------------------------------------------------------------------------- | Encryption Key |-------------------------------------------------------------------------- | | This key is used by the Illuminate encrypter service and should be set | to a random, 32 character string, otherwise these encrypted strings | will not be safe. Please do this before deploying an application! | */ 'key' => env('APP_KEY'), 'cipher' => 'AES-256-CBC', /* |-------------------------------------------------------------------------- | Autoloaded Service Providers |-------------------------------------------------------------------------- | | The service providers listed here will be automatically loaded on the | request to your application. Feel free to add your own services to | this array to grant expanded functionality to your applications. | */ 'providers' => [ /* * Laravel Framework Service Providers... */ Illuminate\Auth\AuthServiceProvider::class, Illuminate\Broadcasting\BroadcastServiceProvider::class, Illuminate\Bus\BusServiceProvider::class, Illuminate\Cache\CacheServiceProvider::class, Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class, Illuminate\Cookie\CookieServiceProvider::class, Illuminate\Database\DatabaseServiceProvider::class, Illuminate\Encryption\EncryptionServiceProvider::class, Illuminate\Filesystem\FilesystemServiceProvider::class, Illuminate\Foundation\Providers\FoundationServiceProvider::class, Illuminate\Hashing\HashServiceProvider::class, Illuminate\Mail\MailServiceProvider::class, Illuminate\Notifications\NotificationServiceProvider::class, Illuminate\Pagination\PaginationServiceProvider::class, Illuminate\Pipeline\PipelineServiceProvider::class, Illuminate\Queue\QueueServiceProvider::class, Illuminate\Redis\RedisServiceProvider::class, Illuminate\Auth\Passwords\PasswordResetServiceProvider::class, Illuminate\Session\SessionServiceProvider::class, Illuminate\Translation\TranslationServiceProvider::class, Illuminate\Validation\ValidationServiceProvider::class, Illuminate\View\ViewServiceProvider::class, /* * Package Service Providers... */ Barryvdh\DomPDF\ServiceProvider::class, /* * Application Service Providers... */ App\Providers\AppServiceProvider::class, App\Providers\AuthServiceProvider::class, // App\Providers\BroadcastServiceProvider::class, App\Providers\EventServiceProvider::class, App\Providers\HorizonServiceProvider::class, App\Providers\RouteServiceProvider::class, ], /* |-------------------------------------------------------------------------- | Class Aliases |-------------------------------------------------------------------------- | | This array of class aliases will be registered when this application | is started. However, feel free to register as many as you wish as | the aliases are "lazy" loaded so they don't hinder performance. | */ 'aliases' => [ 'App' => Illuminate\Support\Facades\App::class, 'Arr' => Illuminate\Support\Arr::class, 'Artisan' => Illuminate\Support\Facades\Artisan::class, 'Auth' => Illuminate\Support\Facades\Auth::class, 'Blade' => Illuminate\Support\Facades\Blade::class, 'Broadcast' => Illuminate\Support\Facades\Broadcast::class, 'Bus' => Illuminate\Support\Facades\Bus::class, 'Cache' => Illuminate\Support\Facades\Cache::class, 'Config' => Illuminate\Support\Facades\Config::class, 'Cookie' => Illuminate\Support\Facades\Cookie::class, 'Crypt' => Illuminate\Support\Facades\Crypt::class, 'DB' => Illuminate\Support\Facades\DB::class, 'Eloquent' => Illuminate\Database\Eloquent\Model::class, 'Event' => Illuminate\Support\Facades\Event::class, 'File' => Illuminate\Support\Facades\File::class, 'Gate' => Illuminate\Support\Facades\Gate::class, 'Hash' => Illuminate\Support\Facades\Hash::class, 'Lang' => Illuminate\Support\Facades\Lang::class, 'Log' => Illuminate\Support\Facades\Log::class, 'Mail' => Illuminate\Support\Facades\Mail::class, 'Notification' => Illuminate\Support\Facades\Notification::class, 'Password' => Illuminate\Support\Facades\Password::class, 'PDF' => Barryvdh\DomPDF\Facade::class, 'Queue' => Illuminate\Support\Facades\Queue::class, 'Redirect' => Illuminate\Support\Facades\Redirect::class, 'Redis' => Illuminate\Support\Facades\Redis::class, 'Request' => Illuminate\Support\Facades\Request::class, 'Response' => Illuminate\Support\Facades\Response::class, 'Route' => Illuminate\Support\Facades\Route::class, 'Schema' => Illuminate\Support\Facades\Schema::class, 'Session' => Illuminate\Support\Facades\Session::class, 'Storage' => Illuminate\Support\Facades\Storage::class, 'Str' => Illuminate\Support\Str::class, 'URL' => Illuminate\Support\Facades\URL::class, 'Validator' => Illuminate\Support\Facades\Validator::class, 'View' => Illuminate\Support\Facades\View::class, ], // Locations of knowledge base articles 'kb' => [ // An article about suspended accounts 'account_suspended' => env('KB_ACCOUNT_SUSPENDED'), // An article about a way to delete an owned account 'account_delete' => env('KB_ACCOUNT_DELETE'), ], 'company' => [ 'name' => env('COMPANY_NAME'), 'address' => env('COMPANY_ADDRESS'), 'details' => env('COMPANY_DETAILS'), 'email' => env('COMPANY_EMAIL'), 'logo' => env('COMPANY_LOGO'), 'footer' => env('COMPANY_FOOTER', env('COMPANY_DETAILS')), ], 'vat' => [ 'countries' => env('VAT_COUNTRIES'), 'rate' => (float) env('VAT_RATE'), ], ]; diff --git a/src/config/database.php b/src/config/database.php index 2eaa9bf6..59b3a1ca 100644 --- a/src/config/database.php +++ b/src/config/database.php @@ -1,151 +1,152 @@ env('DB_CONNECTION', 'mysql'), /* |-------------------------------------------------------------------------- | Database Connections |-------------------------------------------------------------------------- | | Here are each of the database connections setup for your application. | Of course, examples of configuring each database platform that is | supported by Laravel is shown below to make development simple. | | | All database work in Laravel is done through the PHP PDO facilities | so make sure you have the driver for your particular database of | choice installed on your machine before you begin development. | */ 'connections' => [ 'sqlite' => [ 'driver' => 'sqlite', 'url' => env('DATABASE_URL'), 'database' => env('DB_DATABASE', database_path('database.sqlite')), 'prefix' => '', 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), ], '2fa' => [ 'driver' => 'mysql', 'url' => env('MFA_DSN') ], 'mysql' => [ 'driver' => 'mysql', 'url' => env('DATABASE_URL'), 'host' => env('DB_HOST', '127.0.0.1'), 'port' => env('DB_PORT', '3306'), 'database' => env('DB_DATABASE', 'forge'), 'username' => env('DB_USERNAME', 'forge'), 'password' => env('DB_PASSWORD', ''), 'unix_socket' => env('DB_SOCKET', ''), 'charset' => 'utf8', 'collation' => 'utf8_unicode_ci', 'prefix' => '', 'prefix_indexes' => true, 'strict' => true, + 'timezone' => '+00:00', 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), ]) : [], ], 'pgsql' => [ 'driver' => 'pgsql', 'url' => env('DATABASE_URL'), 'host' => env('DB_HOST', '127.0.0.1'), 'port' => env('DB_PORT', '5432'), 'database' => env('DB_DATABASE', 'forge'), 'username' => env('DB_USERNAME', 'forge'), 'password' => env('DB_PASSWORD', ''), 'charset' => 'utf8', 'prefix' => '', 'prefix_indexes' => true, 'schema' => 'public', 'sslmode' => 'prefer', ], 'sqlsrv' => [ 'driver' => 'sqlsrv', 'url' => env('DATABASE_URL'), 'host' => env('DB_HOST', 'localhost'), 'port' => env('DB_PORT', '1433'), 'database' => env('DB_DATABASE', 'forge'), 'username' => env('DB_USERNAME', 'forge'), 'password' => env('DB_PASSWORD', ''), 'charset' => 'utf8', 'prefix' => '', 'prefix_indexes' => true, ], ], /* |-------------------------------------------------------------------------- | Migration Repository Table |-------------------------------------------------------------------------- | | This table keeps track of all the migrations that have already run for | your application. Using this information, we can determine which of | the migrations on disk haven't actually been run in the database. | */ 'migrations' => 'migrations', /* |-------------------------------------------------------------------------- | Redis Databases |-------------------------------------------------------------------------- | | Redis is an open source, fast, and advanced key-value store that also | provides a richer body of commands than a typical key-value system | such as APC or Memcached. Laravel makes it easy to dig right in. | */ 'redis' => [ 'client' => env('REDIS_CLIENT', 'predis'), 'options' => [ 'cluster' => env('REDIS_CLUSTER', 'predis'), 'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_database_'), ], 'default' => [ 'url' => env('REDIS_URL'), 'host' => env('REDIS_HOST', '127.0.0.1'), 'password' => env('REDIS_PASSWORD', null), 'port' => env('REDIS_PORT', 6379), 'database' => env('REDIS_DB', 0), ], 'cache' => [ 'url' => env('REDIS_URL'), 'host' => env('REDIS_HOST', '127.0.0.1'), 'password' => env('REDIS_PASSWORD', null), 'port' => env('REDIS_PORT', 6379), 'database' => env('REDIS_CACHE_DB', 1), ], ], ]; diff --git a/src/database/migrations/2019_12_10_100355_create_package_skus_table.php b/src/database/migrations/2019_12_10_100355_create_package_skus_table.php index 1dee1e15..cdb96ef9 100644 --- a/src/database/migrations/2019_12_10_100355_create_package_skus_table.php +++ b/src/database/migrations/2019_12_10_100355_create_package_skus_table.php @@ -1,45 +1,44 @@ bigIncrements('id'); $table->string('package_id', 36); $table->string('sku_id', 36); $table->integer('qty')->default(1); - $table->integer('cost')->default(0)->nullable(); $table->foreign('package_id')->references('id')->on('packages') ->onDelete('cascade')->onUpdate('cascade'); $table->foreign('sku_id')->references('id')->on('skus') ->onDelete('cascade')->onUpdate('cascade'); } ); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::dropIfExists('package_skus'); } } diff --git a/src/database/migrations/2020_05_05_095212_create_tenants_table.php b/src/database/migrations/2020_05_05_095212_create_tenants_table.php new file mode 100644 index 00000000..8a1a463a --- /dev/null +++ b/src/database/migrations/2020_05_05_095212_create_tenants_table.php @@ -0,0 +1,84 @@ +bigIncrements('id'); + $table->string('title', 32); + $table->timestamps(); + } + ); + + \App\Tenant::create(['title' => 'Kolab Now']); + + foreach (['users', 'discounts', 'domains', 'plans', 'packages', 'skus'] as $table_name) { + Schema::table( + $table_name, + function (Blueprint $table) { + $table->bigInteger('tenant_id')->unsigned()->nullable(); + $table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('set null'); + } + ); + + if ($tenant_id = \config('app.tenant_id')) { + DB::statement("UPDATE `{$table_name}` SET `tenant_id` = {$tenant_id}"); + } + } + + // Add fee column + foreach (['entitlements', 'skus'] as $table) { + Schema::table( + $table, + function (Blueprint $table) { + $table->integer('fee')->nullable(); + } + ); + } + + // FIXME: Should we also have package_skus.fee ? + // We have package_skus.cost, but I think it is not used anywhere. + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + foreach (['users', 'discounts', 'domains', 'plans', 'packages', 'skus'] as $table_name) { + Schema::table( + $table_name, + function (Blueprint $table) { + $table->dropForeign(['tenant_id']); + $table->dropColumn('tenant_id'); + } + ); + } + + foreach (['entitlements', 'skus'] as $table) { + Schema::table( + $table, + function (Blueprint $table) { + $table->dropColumn('fee'); + } + ); + } + + Schema::dropIfExists('tenants'); + } +} diff --git a/src/database/migrations/2021_01_26_150000_change_sku_descriptions.php b/src/database/migrations/2021_01_26_150000_change_sku_descriptions.php index 7af011e5..4896bd01 100644 --- a/src/database/migrations/2021_01_26_150000_change_sku_descriptions.php +++ b/src/database/migrations/2021_01_26_150000_change_sku_descriptions.php @@ -1,36 +1,42 @@ first(); - $beta_sku->name = 'Private Beta (invitation only)'; - $beta_sku->description = 'Access to the private beta program subscriptions'; - $beta_sku->save(); + + if ($beta_sku) { + $beta_sku->name = 'Private Beta (invitation only)'; + $beta_sku->description = 'Access to the private beta program subscriptions'; + $beta_sku->save(); + } $meet_sku = \App\Sku::where('title', 'meet')->first(); - $meet_sku->name = 'Voice & Video Conferencing (public beta)'; - $meet_sku->handler_class = 'App\Handlers\Meet'; - $meet_sku->save(); + + if ($meet_sku) { + $meet_sku->name = 'Voice & Video Conferencing (public beta)'; + $meet_sku->handler_class = 'App\Handlers\Meet'; + $meet_sku->save(); + } } /** * Reverse the migrations. * * @return void */ public function down() { } } diff --git a/src/database/migrations/2021_03_26_080000_create_signup_invitations_table.php b/src/database/migrations/2021_03_26_080000_create_signup_invitations_table.php new file mode 100644 index 00000000..b9948b83 --- /dev/null +++ b/src/database/migrations/2021_03_26_080000_create_signup_invitations_table.php @@ -0,0 +1,49 @@ +string('id', 36); + $table->string('email'); + $table->smallInteger('status'); + $table->bigInteger('user_id')->nullable(); + $table->bigInteger('tenant_id')->unsigned()->nullable(); + $table->timestamps(); + + $table->primary('id'); + + $table->index('email'); + $table->index('created_at'); + + $table->foreign('tenant_id')->references('id')->on('tenants') + ->onUpdate('cascade')->onDelete('set null'); + $table->foreign('user_id')->references('id')->on('users') + ->onUpdate('cascade')->onDelete('set null'); + } + ); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('signup_invitations'); + } +} diff --git a/src/database/migrations/2021_05_12_150000_groups_add_tenant_id.php b/src/database/migrations/2021_05_12_150000_groups_add_tenant_id.php new file mode 100644 index 00000000..07882992 --- /dev/null +++ b/src/database/migrations/2021_05_12_150000_groups_add_tenant_id.php @@ -0,0 +1,46 @@ +bigInteger('tenant_id')->unsigned()->nullable(); + $table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('set null'); + } + ); + + if ($tenant_id = \config('app.tenant_id')) { + DB::statement("UPDATE `groups` SET `tenant_id` = {$tenant_id}"); + } + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table( + 'groups', + function (Blueprint $table) { + $table->dropForeign(['tenant_id']); + $table->dropColumn('tenant_id'); + } + ); + } +} diff --git a/src/database/seeds/DatabaseSeeder.php b/src/database/seeds/DatabaseSeeder.php index 306a95ab..b8aab0e9 100644 --- a/src/database/seeds/DatabaseSeeder.php +++ b/src/database/seeds/DatabaseSeeder.php @@ -1,38 +1,39 @@ $name) { $class = "Database\\Seeds\\$env\\$name"; $seeders[$idx] = class_exists($class) ? $class : null; } $seeders = array_filter($seeders); $this->call($seeders); } } diff --git a/src/database/seeds/local/DomainSeeder.php b/src/database/seeds/local/DomainSeeder.php index 4e02e668..3b2bf128 100644 --- a/src/database/seeds/local/DomainSeeder.php +++ b/src/database/seeds/local/DomainSeeder.php @@ -1,67 +1,81 @@ $domain, 'status' => Domain::STATUS_CONFIRMED + Domain::STATUS_ACTIVE, 'type' => Domain::TYPE_PUBLIC ] ); } if (!in_array(\config('app.domain'), $domains)) { Domain::create( [ 'namespace' => \config('app.domain'), 'status' => DOMAIN::STATUS_CONFIRMED + Domain::STATUS_ACTIVE, 'type' => Domain::TYPE_PUBLIC ] ); } $domains = [ 'example.com', 'example.net', 'example.org' ]; foreach ($domains as $domain) { Domain::create( [ 'namespace' => $domain, 'status' => Domain::STATUS_CONFIRMED + Domain::STATUS_ACTIVE, 'type' => Domain::TYPE_EXTERNAL ] ); } + + // example tenant domain, note that 'tenant_id' is not a fillable. + $domain = Domain::create( + [ + 'namespace' => 'example-tenant.dev-local', + 'status' => Domain::STATUS_CONFIRMED + Domain::STATUS_ACTIVE, + 'type' => Domain::TYPE_PUBLIC + ] + ); + + $tenant = \App\Tenant::where('title', 'Sample Tenant')->first(); + + $domain->tenant_id = $tenant->id; + $domain->save(); } } diff --git a/src/database/seeds/local/TenantSeeder.php b/src/database/seeds/local/TenantSeeder.php new file mode 100644 index 00000000..1f6c10c5 --- /dev/null +++ b/src/database/seeds/local/TenantSeeder.php @@ -0,0 +1,29 @@ + 'Kolab Now' + ]); + } + + if (!Tenant::find(2)) { + Tenant::create([ + 'title' => 'Sample Tenant' + ]); + } + } +} diff --git a/src/database/seeds/local/UserSeeder.php b/src/database/seeds/local/UserSeeder.php index 1733d4e1..44fa220d 100644 --- a/src/database/seeds/local/UserSeeder.php +++ b/src/database/seeds/local/UserSeeder.php @@ -1,149 +1,174 @@ 'kolab.org', 'status' => Domain::STATUS_NEW + Domain::STATUS_ACTIVE + Domain::STATUS_CONFIRMED + Domain::STATUS_VERIFIED, 'type' => Domain::TYPE_EXTERNAL ] ); $john = User::create( [ 'email' => 'john@kolab.org', 'password' => 'simple123', ] ); $john->setSettings( [ 'first_name' => 'John', 'last_name' => 'Doe', 'currency' => 'USD', 'country' => 'US', 'billing_address' => "601 13th Street NW\nSuite 900 South\nWashington, DC 20005", 'external_email' => 'john.doe.external@gmail.com', 'organization' => 'Kolab Developers', 'phone' => '+1 509-248-1111', ] ); $john->setAliases(['john.doe@kolab.org']); $wallet = $john->wallets->first(); $package_domain = \App\Package::where('title', 'domain-hosting')->first(); $package_kolab = \App\Package::where('title', 'kolab')->first(); $package_lite = \App\Package::where('title', 'lite')->first(); $domain->assignPackage($package_domain, $john); $john->assignPackage($package_kolab); $jack = User::create( [ 'email' => 'jack@kolab.org', 'password' => 'simple123', ] ); $jack->setSettings( [ 'first_name' => 'Jack', 'last_name' => 'Daniels', 'currency' => 'USD', 'country' => 'US' ] ); $jack->setAliases(['jack.daniels@kolab.org']); $john->assignPackage($package_kolab, $jack); foreach ($john->entitlements as $entitlement) { $entitlement->created_at = Carbon::now()->subMonthsWithoutOverflow(1); $entitlement->updated_at = Carbon::now()->subMonthsWithoutOverflow(1); $entitlement->save(); } $ned = User::create( [ 'email' => 'ned@kolab.org', 'password' => 'simple123', ] ); $ned->setSettings( [ 'first_name' => 'Edward', 'last_name' => 'Flanders', 'currency' => 'USD', 'country' => 'US' ] ); $john->assignPackage($package_kolab, $ned); $ned->assignSku(\App\Sku::where('title', 'activesync')->first(), 1); // Ned is a controller on Jack's wallet $john->wallets()->first()->addController($ned); // Ned is also our 2FA test user $sku2fa = Sku::firstOrCreate(['title' => '2fa']); $ned->assignSku($sku2fa); try { SecondFactor::seed('ned@kolab.org'); } catch (\Exception $e) { // meh } $joe = User::create( [ 'email' => 'joe@kolab.org', 'password' => 'simple123', ] ); $john->assignPackage($package_lite, $joe); //$john->assignSku(Sku::firstOrCreate(['title' => 'beta'])); //$john->assignSku(Sku::firstOrCreate(['title' => 'meet'])); $joe->setAliases(['joe.monster@kolab.org']); // factory(User::class, 10)->create(); $jeroen = User::create( [ 'email' => 'jeroen@jeroen.jeroen', 'password' => 'jeroen', ] ); $jeroen->role = 'admin'; $jeroen->save(); + + $tenant1 = \App\Tenant::where('title', 'Kolab Now')->first(); + $tenant2 = \App\Tenant::where('title', 'Sample Tenant')->first(); + + $reseller1 = User::create( + [ + 'email' => 'reseller@kolabnow.com', + 'password' => 'reseller', + ] + ); + + $reseller1->tenant_id = $tenant1->id; + $reseller1->role = 'reseller'; + $reseller1->save(); + + $reseller2 = User::create( + [ + 'email' => 'reseller@reseller.com', + 'password' => 'reseller', + ] + ); + + $reseller2->tenant_id = $tenant2->id; + $reseller2->role = 'reseller'; + $reseller2->save(); } } diff --git a/src/phpstan.neon b/src/phpstan.neon index 8e854b01..97d6cf14 100644 --- a/src/phpstan.neon +++ b/src/phpstan.neon @@ -1,11 +1,15 @@ includes: - ./vendor/nunomaduro/larastan/extension.neon parameters: ignoreErrors: - '#Access to an undefined property Illuminate\\Contracts\\Auth\\Authenticatable#' - '#Access to an undefined property [a-zA-Z\\]+::\$pivot#' + - '#Call to an undefined [a-zA-Z0-9<>\\ ]+::withEnvTenant\(\)#' + - '#Call to an undefined [a-zA-Z0-9<>\\ ]+::withUserTenant\(\)#' - '#Call to an undefined method Tests\\Browser::#' level: 4 + parallel: + processTimeout: 300.0 paths: - app/ - tests/ diff --git a/src/resources/js/admin.js b/src/resources/js/admin/app.js similarity index 62% rename from src/resources/js/admin.js rename to src/resources/js/admin/app.js index 2ef6047d..ad9eab93 100644 --- a/src/resources/js/admin.js +++ b/src/resources/js/admin/app.js @@ -1,10 +1,10 @@ /** * Application code for the admin UI */ -import routes from './routes-admin.js' +import routes from './routes.js' window.routes = routes window.isAdmin = true -require('./app') +require('../app') diff --git a/src/resources/js/routes-admin.js b/src/resources/js/admin/routes.js similarity index 72% copy from src/resources/js/routes-admin.js copy to src/resources/js/admin/routes.js index 73287517..cb2a6848 100644 --- a/src/resources/js/routes-admin.js +++ b/src/resources/js/admin/routes.js @@ -1,62 +1,62 @@ -import DashboardComponent from '../vue/Admin/Dashboard' -import DistlistComponent from '../vue/Admin/Distlist' -import DomainComponent from '../vue/Admin/Domain' -import LoginComponent from '../vue/Login' -import LogoutComponent from '../vue/Logout' -import PageComponent from '../vue/Page' -import StatsComponent from '../vue/Admin/Stats' -import UserComponent from '../vue/Admin/User' +import DashboardComponent from '../../vue/Admin/Dashboard' +import DistlistComponent from '../../vue/Admin/Distlist' +import DomainComponent from '../../vue/Admin/Domain' +import LoginComponent from '../../vue/Login' +import LogoutComponent from '../../vue/Logout' +import PageComponent from '../../vue/Page' +import StatsComponent from '../../vue/Admin/Stats' +import UserComponent from '../../vue/Admin/User' const routes = [ { path: '/', redirect: { name: 'dashboard' } }, { path: '/dashboard', name: 'dashboard', component: DashboardComponent, meta: { requiresAuth: true } }, { path: '/distlist/:list', name: 'distlist', component: DistlistComponent, meta: { requiresAuth: true } }, { path: '/domain/:domain', name: 'domain', component: DomainComponent, meta: { requiresAuth: true } }, { path: '/login', name: 'login', component: LoginComponent }, { path: '/logout', name: 'logout', component: LogoutComponent }, { path: '/stats', name: 'stats', component: StatsComponent, meta: { requiresAuth: true } }, { path: '/user/:user', name: 'user', component: UserComponent, meta: { requiresAuth: true } }, { name: '404', path: '*', component: PageComponent } ] export default routes diff --git a/src/resources/js/app.js b/src/resources/js/app.js index 14abd0eb..09aa686a 100644 --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -1,522 +1,522 @@ /** * 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 { 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() }) const app = new Vue({ components: { AppComponent, MenuComponent, }, i18n, store, router: window.router, data() { return { - isAdmin: window.isAdmin, + 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) 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').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', '') 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) { $(elem).css({position: 'relative'}).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 }, 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". const map = { 400: "Bad request", 401: "Unauthorized", 403: "Access denied", 404: "Not found", 405: "Method not allowed", 500: "Internal server error" } if (!msg) msg = map[code] || "Unknown Error" 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) { return ((price || 0) / 100).toLocaleString('de-DE', { style: 'currency', currency: currency || 'CHF' }) }, priceLabel(cost, discount) { let index = '' if (discount) { cost = Math.floor(cost * ((100 - discount) / 100)) index = '\u00B9' } return this.price(cost) + '/month' + index }, clickRecord(event) { if (!/^(a|button|svg|path)$/i.test(event.target.nodeName)) { let link = $(event.target).closest('tr').find('a')[0] if (link) { link.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 'Deleted' } if (domain.isSuspended) { return 'Suspended' } if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) { return 'Not Ready' } return '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 'Deleted' } if (list.isSuspended) { return 'Suspended' } if (!list.isLdapReady) { return 'Not Ready' } return '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') // FIXME: Find a nicer way of doing this if (!dialog.length) { let form = new Vue(SupportForm) form.$mount($('
').appendTo(container)[0]) form.$root = this form.$toast = this.$toast dialog = $(form.$el) } dialog.on('shown.bs.modal', () => { dialog.find('input').first().focus() }).modal() }, 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 'Deleted' } if (user.isSuspended) { return 'Suspended' } if (!user.isImapReady || !user.isLdapReady) { return 'Not Ready' } return 'Active' }, 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" const modal = $('div.modal.show') $(modal.length ? modal : 'form').each((i, form) => { form = $(form) $.each(error.response.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\ + // Create an error message // API responses can use a string, array or object let msg_text = '' if ($.type(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 { error_msg = error.request ? error.request.statusText : error.message } app.$toast.error(error_msg || "Server Error") // Pass the error as-is return Promise.reject(error) } ) diff --git a/src/resources/js/reseller/app.js b/src/resources/js/reseller/app.js new file mode 100644 index 00000000..442a0902 --- /dev/null +++ b/src/resources/js/reseller/app.js @@ -0,0 +1,10 @@ +/** + * Application code for the reseller UI + */ + +import routes from './routes.js' + +window.routes = routes +window.isReseller = true + +require('../app') diff --git a/src/resources/js/routes-admin.js b/src/resources/js/reseller/routes.js similarity index 57% rename from src/resources/js/routes-admin.js rename to src/resources/js/reseller/routes.js index 73287517..d0296f0b 100644 --- a/src/resources/js/routes-admin.js +++ b/src/resources/js/reseller/routes.js @@ -1,62 +1,76 @@ -import DashboardComponent from '../vue/Admin/Dashboard' -import DistlistComponent from '../vue/Admin/Distlist' -import DomainComponent from '../vue/Admin/Domain' -import LoginComponent from '../vue/Login' -import LogoutComponent from '../vue/Logout' -import PageComponent from '../vue/Page' -import StatsComponent from '../vue/Admin/Stats' -import UserComponent from '../vue/Admin/User' +import DashboardComponent from '../../vue/Reseller/Dashboard' +import DistlistComponent from '../../vue/Admin/Distlist' +import DomainComponent from '../../vue/Admin/Domain' +import InvitationsComponent from '../../vue/Reseller/Invitations' +import LoginComponent from '../../vue/Login' +import LogoutComponent from '../../vue/Logout' +import PageComponent from '../../vue/Page' +import StatsComponent from '../../vue/Reseller/Stats' +import UserComponent from '../../vue/Admin/User' +import WalletComponent from '../../vue/Wallet' const routes = [ { path: '/', redirect: { name: 'dashboard' } }, { path: '/dashboard', name: 'dashboard', component: DashboardComponent, meta: { requiresAuth: true } }, { path: '/distlist/:list', name: 'distlist', component: DistlistComponent, meta: { requiresAuth: true } }, { path: '/domain/:domain', name: 'domain', component: DomainComponent, meta: { requiresAuth: true } }, { path: '/login', name: 'login', component: LoginComponent }, { path: '/logout', name: 'logout', component: LogoutComponent }, + { + path: '/invitations', + name: 'invitations', + component: InvitationsComponent, + meta: { requiresAuth: true } + }, { path: '/stats', name: 'stats', component: StatsComponent, meta: { requiresAuth: true } }, { path: '/user/:user', name: 'user', component: UserComponent, meta: { requiresAuth: true } }, + { + path: '/wallet', + name: 'wallet', + component: WalletComponent, + meta: { requiresAuth: true } + }, { name: '404', path: '*', component: PageComponent } ] export default routes diff --git a/src/resources/js/user.js b/src/resources/js/user/app.js similarity index 54% rename from src/resources/js/user.js rename to src/resources/js/user/app.js index 99bf191a..dc7c6682 100644 --- a/src/resources/js/user.js +++ b/src/resources/js/user/app.js @@ -1,10 +1,11 @@ /** * Application code for the user UI */ -import routes from './routes-user.js' +import routes from './routes.js' window.routes = routes window.isAdmin = false +window.isReseller = false -require('./app') +require('../app') diff --git a/src/resources/js/routes-user.js b/src/resources/js/user/routes.js similarity index 72% rename from src/resources/js/routes-user.js rename to src/resources/js/user/routes.js index 4585d61d..3c7c5f9c 100644 --- a/src/resources/js/routes-user.js +++ b/src/resources/js/user/routes.js @@ -1,124 +1,129 @@ -import DashboardComponent from '../vue/Dashboard' -import DistlistInfoComponent from '../vue/Distlist/Info' -import DistlistListComponent from '../vue/Distlist/List' -import DomainInfoComponent from '../vue/Domain/Info' -import DomainListComponent from '../vue/Domain/List' -import LoginComponent from '../vue/Login' -import LogoutComponent from '../vue/Logout' -import MeetComponent from '../vue/Rooms' -import PageComponent from '../vue/Page' -import PasswordResetComponent from '../vue/PasswordReset' -import SignupComponent from '../vue/Signup' -import UserInfoComponent from '../vue/User/Info' -import UserListComponent from '../vue/User/List' -import UserProfileComponent from '../vue/User/Profile' -import UserProfileDeleteComponent from '../vue/User/ProfileDelete' -import WalletComponent from '../vue/Wallet' +import DashboardComponent from '../../vue/Dashboard' +import DistlistInfoComponent from '../../vue/Distlist/Info' +import DistlistListComponent from '../../vue/Distlist/List' +import DomainInfoComponent from '../../vue/Domain/Info' +import DomainListComponent from '../../vue/Domain/List' +import LoginComponent from '../../vue/Login' +import LogoutComponent from '../../vue/Logout' +import MeetComponent from '../../vue/Rooms' +import PageComponent from '../../vue/Page' +import PasswordResetComponent from '../../vue/PasswordReset' +import SignupComponent from '../../vue/Signup' +import UserInfoComponent from '../../vue/User/Info' +import UserListComponent from '../../vue/User/List' +import UserProfileComponent from '../../vue/User/Profile' +import UserProfileDeleteComponent from '../../vue/User/ProfileDelete' +import WalletComponent from '../../vue/Wallet' // Here's a list of lazy-loaded components // Note: you can pack multiple components into the same chunk, webpackChunkName // is also used to get a sensible file name instead of numbers -const RoomComponent = () => import(/* webpackChunkName: "room" */ '../vue/Meet/Room.vue') +const RoomComponent = () => import(/* webpackChunkName: "room" */ '../../vue/Meet/Room.vue') const routes = [ { path: '/dashboard', name: 'dashboard', component: DashboardComponent, meta: { requiresAuth: true } }, { path: '/distlist/:list', name: 'distlist', component: DistlistInfoComponent, meta: { requiresAuth: true, perm: 'distlists' } }, { path: '/distlists', name: 'distlists', component: DistlistListComponent, meta: { requiresAuth: true, perm: 'distlists' } }, { path: '/domain/:domain', name: 'domain', component: DomainInfoComponent, meta: { requiresAuth: true, perm: 'domains' } }, { path: '/domains', name: 'domains', component: DomainListComponent, meta: { requiresAuth: true, perm: 'domains' } }, { path: '/login', name: 'login', component: LoginComponent }, { path: '/logout', name: 'logout', component: LogoutComponent }, { path: '/password-reset/:code?', name: 'password-reset', component: PasswordResetComponent }, { path: '/profile', name: 'profile', component: UserProfileComponent, meta: { requiresAuth: true } }, { path: '/profile/delete', name: 'profile-delete', component: UserProfileDeleteComponent, meta: { requiresAuth: true } }, { component: RoomComponent, name: 'room', path: '/meet/:room', meta: { loading: true } }, { path: '/rooms', name: 'rooms', component: MeetComponent, meta: { requiresAuth: true } }, + { + path: '/signup/invite/:param', + name: 'signup-invite', + component: SignupComponent + }, { path: '/signup/:param?', alias: '/signup/voucher/:param', name: 'signup', component: SignupComponent }, { path: '/user/:user', name: 'user', component: UserInfoComponent, meta: { requiresAuth: true, perm: 'users' } }, { path: '/users', name: 'users', component: UserListComponent, meta: { requiresAuth: true, perm: 'users' } }, { path: '/wallet', name: 'wallet', component: WalletComponent, meta: { requiresAuth: true, perm: 'wallets' } }, { name: '404', path: '*', component: PageComponent } ] export default routes diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php index 48637e49..57aac0ba 100644 --- a/src/resources/lang/en/app.php +++ b/src/resources/lang/en/app.php @@ -1,72 +1,78 @@ '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-verify-success' => 'Domain verified successfully.', 'domain-verify-error' => 'Domain ownership verification failed.', 'domain-suspend-success' => 'Domain suspended successfully.', 'domain-unsuspend-success' => 'Domain unsuspended 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.', '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.', '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/mail.php b/src/resources/lang/en/mail.php index 687be129..187b2a2c 100644 --- a/src/resources/lang/en/mail.php +++ b/src/resources/lang/en/mail.php @@ -1,84 +1,89 @@ "Dear :name,", 'footer1' => "Best regards,", 'footer2' => "Your :site Team", 'more-info-html' => "See here for more information.", 'more-info-text' => "See :href for more information.", 'negativebalance-subject' => ":site Payment Required", 'negativebalance-body' => "This is a notification to let you know that your :site account balance has run into the negative and requires your attention. " . "Consider setting up an automatic payment to avoid messages like this in the future.", 'negativebalance-body-ext' => "Settle up to keep your account running:", 'negativebalancereminder-subject' => ":site Payment Reminder", 'negativebalancereminder-body' => "It has probably skipped your attention that you are behind on paying for your :site account. " . "Consider setting up an automatic payment to avoid messages like this in the future.", 'negativebalancereminder-body-ext' => "Settle up to keep your account running:", 'negativebalancereminder-body-warning' => "Please, be aware that your account will be suspended " . "if your account balance is not settled by :date.", 'negativebalancesuspended-subject' => ":site Account Suspended", 'negativebalancesuspended-body' => "Your :site account has been suspended for having a negative balance for too long. " . "Consider setting up an automatic payment to avoid messages like this in the future.", 'negativebalancesuspended-body-ext' => "Settle up now to unsuspend your account:", 'negativebalancesuspended-body-warning' => "Please, be aware that your account and all its data will be deleted " . "if your account balance is not settled by :date.", 'negativebalancebeforedelete-subject' => ":site Final Warning", 'negativebalancebeforedelete-body' => "This is a final reminder to settle your :site account balance. " . "Your account and all its data will be deleted if your account balance is not settled by :date.", 'negativebalancebeforedelete-body-ext' => "Settle up now to keep your account:", 'passwordreset-subject' => ":site Password Reset", 'passwordreset-body1' => "Someone recently asked to change your :site password.", 'passwordreset-body2' => "If this was you, use this verification code to complete the process:", 'passwordreset-body3' => "You can also click the link below:", 'passwordreset-body4' => "If you did not make such a request, you can either ignore this message or get in touch with us about this incident.", 'paymentmandatedisabled-subject' => ":site Auto-payment Problem", 'paymentmandatedisabled-body' => "Your :site account balance is negative " . "and the configured amount for automatically topping up the balance does not cover " . "the costs of subscriptions consumed.", 'paymentmandatedisabled-body-ext' => "Charging you multiple times for the same amount in short succession " . "could lead to issues with the payment provider. " . "In order to not cause any problems, we suspended auto-payment for your account. " . "To resolve this issue, login to your account settings and adjust your auto-payment amount.", 'paymentfailure-subject' => ":site Payment Failed", 'paymentfailure-body' => "Something went wrong with auto-payment for your :site account.\n" . "We tried to charge you via your preferred payment method, but the charge did not go through.", 'paymentfailure-body-ext' => "In order to not cause any further issues, we suspended auto-payment for your account. " . "To resolve this issue, login to your account settings at", 'paymentfailure-body-rest' => "There you can pay manually for your account and " . "change your auto-payment settings.", 'paymentsuccess-subject' => ":site Payment Succeeded", 'paymentsuccess-body' => "The auto-payment for your :site account went through without issues. " . "You can check your new account balance and more details here:", 'support' => "Special circumstances? Something is wrong with a charge?\n" . ":site Support is here to help.", 'signupcode-subject' => ":site Registration", 'signupcode-body1' => "This is your verification code for the :site registration process:", 'signupcode-body2' => "You can also click the link below to continue the registration process:", + 'signupinvitation-subject' => ":site Invitation", + 'signupinvitation-header' => "Hi,", + 'signupinvitation-body1' => "You have been invited to join :site. Click the link below to sign up.", + 'signupinvitation-body2' => "", + 'suspendeddebtor-subject' => ":site Account Suspended", 'suspendeddebtor-body' => "You have been behind on paying for your :site account " ."for over :days days. Your account has been suspended.", 'suspendeddebtor-middle' => "Settle up now to reactivate your account.", 'suspendeddebtor-cancel' => "Don't want to be our customer anymore? " . "Here is how you can cancel your account:", ]; diff --git a/src/resources/themes/app.scss b/src/resources/themes/app.scss index 96ce40c3..61bfaaf6 100644 --- a/src/resources/themes/app.scss +++ b/src/resources/themes/app.scss @@ -1,460 +1,471 @@ html, body, body > .outer-container { height: 100%; } #app { display: flex; flex-direction: column; min-height: 100%; overflow: hidden; & > nav { flex-shrink: 0; z-index: 12; } & > div.container { flex-grow: 1; margin-top: 2rem; margin-bottom: 2rem; } & > .filler { flex-grow: 1; } & > div.container + .filler { display: none; } } .error-page { position: absolute; top: 0; height: 100%; width: 100%; align-content: center; align-items: center; display: flex; flex-wrap: wrap; justify-content: center; color: #636b6f; z-index: 10; background: white; .code { text-align: right; border-right: 2px solid; font-size: 26px; padding: 0 15px; } .message { font-size: 18px; padding: 0 15px; } .hint { margin-top: 3em; text-align: center; width: 100%; } } .app-loader { background-color: $body-bg; height: 100%; width: 100%; position: absolute; top: 0; left: 0; display: flex; align-items: center; justify-content: center; z-index: 8; .spinner-border { width: 120px; height: 120px; border-width: 15px; color: #b2aa99; } &.small .spinner-border { width: 25px; height: 25px; border-width: 3px; } &.fadeOut { visibility: hidden; opacity: 0; transition: visibility 300ms linear, opacity 300ms linear; } } pre { margin: 1rem 0; padding: 1rem; background-color: $menu-bg-color; } .card-title { font-size: 1.2rem; font-weight: bold; } tfoot.table-fake-body { background-color: #f8f8f8; color: grey; text-align: center; td { vertical-align: middle; height: 8em; border: 0; } tbody:not(:empty) + & { display: none; } } table { - td.buttons, + th { + white-space: nowrap; + } + td.email, td.price, td.datetime, td.selection { width: 1%; white-space: nowrap; } + td.buttons, th.price, td.price { width: 1%; text-align: right; white-space: nowrap; } &.form-list { margin: 0; td { border: 0; &:first-child { padding-left: 0; } &:last-child { padding-right: 0; } } button { line-height: 1; } } .btn-action { line-height: 1; padding: 0; } } .list-details { min-height: 1em; & > ul { margin: 0; padding-left: 1.2em; } } .plan-selector { .plan-header { display: flex; } .plan-ico { margin:auto; font-size: 3.8rem; color: #f1a539; border: 3px solid #f1a539; width: 6rem; height: 6rem; border-radius: 50%; } } .status-message { display: flex; align-items: center; justify-content: center; .app-loader { width: auto; position: initial; .spinner-border { color: $body-color; } } svg { font-size: 1.5em; } :first-child { margin-right: 0.4em; } } .form-separator { position: relative; margin: 1em 0; display: flex; justify-content: center; hr { border-color: #999; margin: 0; position: absolute; top: 0.75em; width: 100%; } span { background: #fff; padding: 0 1em; z-index: 1; } } #status-box { background-color: lighten($green, 35); .progress { background-color: #fff; height: 10px; } .progress-label { font-size: 0.9em; } .progress-bar { background-color: $green; } &.process-failed { background-color: lighten($orange, 30); .progress-bar { background-color: $red; } } } @keyframes blinker { 50% { opacity: 0; } } .blinker { animation: blinker 750ms step-start infinite; } #dashboard-nav { display: flex; flex-wrap: wrap; justify-content: center; & > a { padding: 1rem; text-align: center; white-space: nowrap; margin: 0.25rem; text-decoration: none; width: 150px; &.disabled { pointer-events: none; opacity: 0.6; } + // Some icons are too big, scale them down + &.link-invitations { + svg { + transform: scale(0.9); + } + } + .badge { position: absolute; top: 0.5rem; right: 0.5rem; } } svg { width: 6rem; height: 6rem; margin: auto; } } #payment-method-selection { display: flex; flex-wrap: wrap; justify-content: center; & > a { padding: 1rem; text-align: center; white-space: nowrap; margin: 0.25rem; text-decoration: none; width: 150px; } svg { width: 6rem; height: 6rem; margin: auto; } } #logon-form { flex-basis: auto; // Bootstrap issue? See logon page with width < 992 } #logon-form-footer { a:not(:first-child) { margin-left: 2em; } } // Various improvements for mobile @include media-breakpoint-down(sm) { .card, .card-footer { border: 0; } .card-body { padding: 0.5rem 0; } .form-group { margin-bottom: 0.5rem; } .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%; } .form-group.plaintext .col-form-label { padding-bottom: 0; } form.read-only.short label { width: 35%; & + * { width: 65%; } } #app > div.container { margin-bottom: 1rem; margin-top: 1rem; max-width: 100%; } #header-menu-navbar { padding: 0; } #dashboard-nav > a { width: 135px; } .table-sm:not(.form-list) { tbody td { padding: 0.75rem 0.5rem; svg { vertical-align: -0.175em; } & > svg { font-size: 125%; margin-right: 0.25rem; } } } .table.transactions { thead { display: none; } tbody { tr { position: relative; display: flex; flex-wrap: wrap; } td { width: auto; border: 0; padding: 0.5rem; &.datetime { width: 50%; padding-left: 0; } &.description { order: 3; width: 100%; border-bottom: 1px solid $border-color; color: $secondary; padding: 0 1.5em 0.5rem 0; margin-top: -0.25em; } &.selection { position: absolute; right: 0; border: 0; top: 1.7em; padding-right: 0; } &.price { width: 50%; padding-right: 0; } &.email { display: none; } } } } } diff --git a/src/resources/themes/default/theme.json b/src/resources/themes/default/theme.json index a6a79acb..21b4ceb1 100644 --- a/src/resources/themes/default/theme.json +++ b/src/resources/themes/default/theme.json @@ -1,41 +1,45 @@ { "menu": [ { "label": "explore", "location": "https://kolabnow.com/", - "admin": true + "admin": true, + "reseller": true }, { "label": "blog", "location": "https://blogs.kolabnow.com/", - "admin": true + "admin": true, + "reseller": true }, { "label": "support", "location": "/support", "page": "support", - "admin": true + "admin": true, + "reseller": true }, { "label": "tos", "location": "https://kolabnow.com/tos", - "footer": true + "footer": true, + "reseller": true } ], "faq": { "signup": [ { "href": "https://kolabnow.com/tos", "label": "tos" }, { "href": "https://kb.kolabnow.com/faq/can-i-upgrade-an-individual-account-to-a-group-account", "label": "account-upgrade" }, { "href": "https://kb.kolabnow.com/faq/how-much-storage-comes-with-my-account", "label": "storage" } ] } } diff --git a/src/resources/views/emails/html/signup_invitation.blade.php b/src/resources/views/emails/html/signup_invitation.blade.php new file mode 100644 index 00000000..7b2a6596 --- /dev/null +++ b/src/resources/views/emails/html/signup_invitation.blade.php @@ -0,0 +1,18 @@ + + + + + + +

{{ __('mail.signupinvitation-header') }}

+ +

{{ __('mail.signupinvitation-body1', ['site' => $site]) }}

+ +

{!! $href !!}

+ +

{{ __('mail.signupinvitation-body2') }}

+ +

{{ __('mail.footer1') }}

+

{{ __('mail.footer2', ['site' => $site]) }}

+ + diff --git a/src/resources/views/emails/plain/signup_invitation.blade.php b/src/resources/views/emails/plain/signup_invitation.blade.php new file mode 100644 index 00000000..1788b4a4 --- /dev/null +++ b/src/resources/views/emails/plain/signup_invitation.blade.php @@ -0,0 +1,11 @@ +{!! __('mail.signupinvitation-header') !!} + +{!! __('mail.signupinvitation-body1', ['site' => $site]) !!} + +{!! $href !!} + +{!! __('mail.signupinvitation-body2') !!} + +-- +{!! __('mail.footer1') !!} +{!! __('mail.footer2', ['site' => $site]) !!} diff --git a/src/resources/vue/Admin/Dashboard.vue b/src/resources/vue/Admin/Dashboard.vue index 6a239de7..39bf9208 100644 --- a/src/resources/vue/Admin/Dashboard.vue +++ b/src/resources/vue/Admin/Dashboard.vue @@ -1,89 +1,24 @@ diff --git a/src/resources/vue/Admin/Stats.vue b/src/resources/vue/Admin/Stats.vue index 0c60c7f3..a3eabae1 100644 --- a/src/resources/vue/Admin/Stats.vue +++ b/src/resources/vue/Admin/Stats.vue @@ -1,46 +1,47 @@ diff --git a/src/resources/vue/Login.vue b/src/resources/vue/Login.vue index dc6a5e8b..0bbd0279 100644 --- a/src/resources/vue/Login.vue +++ b/src/resources/vue/Login.vue @@ -1,81 +1,81 @@ diff --git a/src/resources/vue/Reseller/Dashboard.vue b/src/resources/vue/Reseller/Dashboard.vue new file mode 100644 index 00000000..95791e1c --- /dev/null +++ b/src/resources/vue/Reseller/Dashboard.vue @@ -0,0 +1,51 @@ + + + diff --git a/src/resources/vue/Reseller/Invitations.vue b/src/resources/vue/Reseller/Invitations.vue new file mode 100644 index 00000000..c2c89312 --- /dev/null +++ b/src/resources/vue/Reseller/Invitations.vue @@ -0,0 +1,283 @@ + + + diff --git a/src/resources/vue/Reseller/Stats.vue b/src/resources/vue/Reseller/Stats.vue new file mode 100644 index 00000000..1f20ad3d --- /dev/null +++ b/src/resources/vue/Reseller/Stats.vue @@ -0,0 +1,18 @@ + + + diff --git a/src/resources/vue/Signup.vue b/src/resources/vue/Signup.vue index fa02dc76..059efe44 100644 --- a/src/resources/vue/Signup.vue +++ b/src/resources/vue/Signup.vue @@ -1,264 +1,305 @@ diff --git a/src/resources/vue/Widgets/Menu.vue b/src/resources/vue/Widgets/Menu.vue index 4037561e..a0a3823b 100644 --- a/src/resources/vue/Widgets/Menu.vue +++ b/src/resources/vue/Widgets/Menu.vue @@ -1,122 +1,122 @@ diff --git a/src/resources/vue/Widgets/UserSearch.vue b/src/resources/vue/Widgets/UserSearch.vue new file mode 100644 index 00000000..e3135442 --- /dev/null +++ b/src/resources/vue/Widgets/UserSearch.vue @@ -0,0 +1,78 @@ + + + diff --git a/src/routes/api.php b/src/routes/api.php index 0357a28b..f23ff8d1 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -1,175 +1,217 @@ '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('logout', 'API\AuthController@logout'); Route::post('refresh', 'API\AuthController@refresh'); } ); } ); Route::group( [ 'domain' => \config('app.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::get('signup/plans', 'API\SignupController@plans'); 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.domain'), 'middleware' => 'auth:api', 'prefix' => $prefix . 'api/v4' ], function () { Route::apiResource('domains', API\V4\DomainsController::class); Route::get('domains/{id}/confirm', 'API\V4\DomainsController@confirm'); Route::get('domains/{id}/status', 'API\V4\DomainsController@status'); Route::apiResource('groups', API\V4\GroupsController::class); Route::get('groups/{id}/status', 'API\V4\GroupsController@status'); - Route::apiResource('entitlements', API\V4\EntitlementsController::class); Route::apiResource('packages', API\V4\PackagesController::class); Route::apiResource('skus', API\V4\SkusController::class); Route::apiResource('users', API\V4\UsersController::class); 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'); Route::get('openvidu/rooms', 'API\V4\OpenViduController@index'); Route::post('openvidu/rooms/{id}/close', 'API\V4\OpenViduController@closeRoom'); Route::post('openvidu/rooms/{id}/config', 'API\V4\OpenViduController@setRoomConfig'); // FIXME: I'm not sure about this one, should we use DELETE request maybe? Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection'); Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection'); Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest'); Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest'); } ); // Note: In Laravel 7.x we could just use withoutMiddleware() instead of a separate group Route::group( [ 'domain' => \config('app.domain'), 'prefix' => $prefix . 'api/v4' ], function () { Route::post('openvidu/rooms/{id}', 'API\V4\OpenViduController@joinRoom'); Route::post('openvidu/rooms/{id}/connections', 'API\V4\OpenViduController@createConnection'); // FIXME: I'm not sure about this one, should we use DELETE request maybe? Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection'); Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection'); Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest'); Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest'); } ); Route::group( [ 'domain' => \config('app.domain'), 'middleware' => 'api', 'prefix' => $prefix . 'api/v4' ], function ($router) { Route::post('support/request', 'API\V4\SupportController@request'); } ); Route::group( [ 'domain' => \config('app.domain'), 'prefix' => $prefix . 'api/webhooks', ], function () { Route::post('payment/{provider}', 'API\V4\PaymentsController@webhook'); Route::post('meet/openvidu', 'API\V4\OpenViduController@webhook'); } ); Route::group( [ 'domain' => 'admin.' . \config('app.domain'), 'middleware' => ['auth:api', 'admin'], 'prefix' => $prefix . 'api/v4', ], function () { Route::apiResource('domains', API\V4\Admin\DomainsController::class); - Route::get('domains/{id}/confirm', 'API\V4\Admin\DomainsController@confirm'); Route::post('domains/{id}/suspend', 'API\V4\Admin\DomainsController@suspend'); Route::post('domains/{id}/unsuspend', 'API\V4\Admin\DomainsController@unsuspend'); - Route::apiResource('entitlements', API\V4\Admin\EntitlementsController::class); - 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('packages', API\V4\Admin\PackagesController::class); Route::apiResource('skus', API\V4\Admin\SkusController::class); Route::apiResource('users', API\V4\Admin\UsersController::class); Route::post('users/{id}/reset2FA', 'API\V4\Admin\UsersController@reset2FA'); Route::get('users/{id}/skus', 'API\V4\Admin\SkusController@userSkus'); 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::apiResource('discounts', API\V4\Admin\DiscountsController::class); Route::get('stats/chart/{chart}', 'API\V4\Admin\StatsController@chart'); } ); + +Route::group( + [ + 'domain' => 'reseller.' . \config('app.domain'), + 'middleware' => ['auth:api', 'reseller'], + 'prefix' => $prefix . 'api/v4', + ], + function () { + Route::apiResource('domains', API\V4\Reseller\DomainsController::class); + 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::apiResource('packages', API\V4\Reseller\PackagesController::class); + + 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::post('users/{id}/reset2FA', 'API\V4\Reseller\UsersController@reset2FA'); + Route::get('users/{id}/skus', 'API\V4\Reseller\SkusController@userSkus'); + 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::apiResource('discounts', API\V4\Reseller\DiscountsController::class); + + Route::get('stats/chart/{chart}', 'API\V4\Reseller\StatsController@chart'); + } +); diff --git a/src/routes/web.php b/src/routes/web.php index 560a4160..b51fc1cc 100644 --- a/src/routes/web.php +++ b/src/routes/web.php @@ -1,23 +1,28 @@ \config('app.domain'), ], function () { Route::get('content/page/{page}', 'ContentController@pageContent') ->where('page', '(.*)'); Route::get('content/faq/{page}', 'ContentController@faqContent') ->where('page', '(.*)'); Route::fallback( function () { + // Return 404 for requests to the API end-points that do not exist + if (strpos(request()->path(), 'api/') === 0) { + return \App\Http\Controllers\Controller::errorResponse(404); + } + $env = \App\Utils::uiEnv(); return view($env['view'])->with('env', $env); } ); } ); diff --git a/src/tests/Browser/Meet/RoomsTest.php b/src/tests/Browser/Meet/RoomsTest.php index 0e554c4b..8e76517b 100644 --- a/src/tests/Browser/Meet/RoomsTest.php +++ b/src/tests/Browser/Meet/RoomsTest.php @@ -1,110 +1,110 @@ clearMeetEntitlements(); } /** * {@inheritDoc} */ public function tearDown(): void { $this->clearMeetEntitlements(); parent::tearDown(); } /** * Test rooms page (unauthenticated and unauthorized) * * @group openvidu */ public function testRoomsUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $browser->visit('/rooms') ->on(new Home()) // User has no 'meet' entitlement yet, expect redirect to error page ->submitLogon('john@kolab.org', 'simple123', false) ->waitFor('#app > #error-page') ->assertSeeIn('#error-page .code', '403') ->assertSeeIn('#error-page .message', 'Access denied'); }); } /** * Test rooms page * * @group openvidu */ public function testRooms(): void { $this->browse(function (Browser $browser) { $href = \config('app.url') . '/meet/john'; $john = $this->getTestUser('john@kolab.org'); // User has no 'meet' entitlement yet $browser->visit('/login') ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->on(new Dashboard()) ->assertMissing('@links a.link-chat'); // Goto user subscriptions, and enable 'meet' subscription $browser->visit('/user/' . $john->id) ->on(new UserInfo()) ->whenAvailable('@skus', function ($browser) { $browser->click('#sku-input-meet'); }) ->click('button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.') ->click('.navbar-brand') ->on(new Dashboard()) ->assertSeeIn('@links a.link-chat', 'Video chat') // Make sure the element also exists on Dashboard page load ->refresh() ->on(new Dashboard()) ->assertSeeIn('@links a.link-chat', 'Video chat'); // Test Video chat page $browser->click('@links a.link-chat') ->waitFor('#meet-rooms') ->waitFor('.card-text a') ->assertSeeIn('.card-title', 'Voice & Video Conferencing') ->assertSeeIn('.card-text a', $href) - ->assertAttribute('.card-text a', 'href', $href) + ->assertAttribute('.card-text a', 'href', '/meet/john') ->click('.card-text a') ->on(new RoomPage('john')) // check that entering the room skips the logon form ->assertMissing('@toolbar') ->assertMissing('@menu') ->assertMissing('@session') ->assertMissing('@chat') ->assertMissing('@login-form') ->assertVisible('@setup-form') ->assertSeeIn('@setup-status-message', "The room is closed. It will be open for others after you join.") ->assertSeeIn('@setup-button', "JOIN") ->click('@setup-button') ->waitFor('@session') ->assertMissing('@setup-form'); }); } } diff --git a/src/tests/Browser/Pages/Home.php b/src/tests/Browser/Pages/Home.php index 6bffbd17..2a600fd4 100644 --- a/src/tests/Browser/Pages/Home.php +++ b/src/tests/Browser/Pages/Home.php @@ -1,88 +1,88 @@ waitForLocation($this->url()) ->waitUntilMissing('.app-loader') ->assertVisible('form.form-signin'); } /** * Get the element shortcuts for the page. * * @return array */ public function elements() { return [ '@app' => '#app', '@email-input' => '#inputEmail', '@password-input' => '#inputPassword', '@second-factor-input' => '#secondfactor', '@logon-button' => '#logon-form button.btn-primary' ]; } /** * Submit logon form. * - * @param \Laravel\Dusk\Browser $browser The browser object - * @param string $username User name - * @param string $password User password - * @param bool $wait_for_dashboard - * @param array $config Client-site config + * @param \Tests\Browser $browser The browser object + * @param string $username User name + * @param string $password User password + * @param bool $wait_for_dashboard + * @param array $config Client-site config * * @return void */ public function submitLogon( $browser, $username, $password, $wait_for_dashboard = false, $config = [] ) { $browser->clearToasts() ->type('@email-input', $username) ->type('@password-input', $password); if ($username == 'ned@kolab.org') { $code = \App\Auth\SecondFactor::code('ned@kolab.org'); $browser->type('@second-factor-input', $code); } if (!empty($config)) { $browser->script( sprintf('Object.assign(window.config, %s)', \json_encode($config)) ); } $browser->press('form button'); if ($wait_for_dashboard) { $browser->waitForLocation('/dashboard'); } } } diff --git a/src/tests/Browser/Pages/Reseller/Invitations.php b/src/tests/Browser/Pages/Reseller/Invitations.php new file mode 100644 index 00000000..8837c21c --- /dev/null +++ b/src/tests/Browser/Pages/Reseller/Invitations.php @@ -0,0 +1,49 @@ +assertPathIs($this->url()) + ->waitUntilMissing('@app .app-loader') + ->assertSeeIn('#invitations .card-title', 'Signup Invitations'); + } + + /** + * Get the element shortcuts for the page. + * + * @return array + */ + public function elements(): array + { + return [ + '@app' => '#app', + '@create-button' => '.card-text button.create-invite', + '@create-dialog' => '#invite-create', + '@search-button' => '#search-form button', + '@search-input' => '#search-form input', + '@table' => '#invitations-list', + ]; + } +} diff --git a/src/tests/Browser/Reseller/DashboardTest.php b/src/tests/Browser/Reseller/DashboardTest.php new file mode 100644 index 00000000..4493c81e --- /dev/null +++ b/src/tests/Browser/Reseller/DashboardTest.php @@ -0,0 +1,140 @@ +getTestUser('jack@kolab.org'); + $jack->setSetting('external_email', null); + + $this->deleteTestUser('test@testsearch.com'); + $this->deleteTestDomain('testsearch.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $jack = $this->getTestUser('jack@kolab.org'); + $jack->setSetting('external_email', null); + + $this->deleteTestUser('test@testsearch.com'); + $this->deleteTestDomain('testsearch.com'); + + parent::tearDown(); + } + + /** + * Test user search + */ + public function testSearch(): void + { + $this->browse(function (Browser $browser) { + $browser->visit(new Home()) + ->submitLogon('reseller@kolabnow.com', 'reseller', true) + ->on(new Dashboard()) + ->assertFocused('@search input') + ->assertMissing('@search table'); + + // Test search with no results + $browser->type('@search input', 'unknown') + ->click('@search form button') + ->assertToast(Toast::TYPE_INFO, '0 user accounts have been found.') + ->assertMissing('@search table'); + + $john = $this->getTestUser('john@kolab.org'); + $jack = $this->getTestUser('jack@kolab.org'); + $jack->setSetting('external_email', 'john.doe.external@gmail.com'); + + // Test search with multiple results + $browser->type('@search input', 'john.doe.external@gmail.com') + ->click('@search form button') + ->assertToast(Toast::TYPE_INFO, '2 user accounts have been found.') + ->whenAvailable('@search table', function (Browser $browser) use ($john, $jack) { + $browser->assertElementsCount('tbody tr', 2) + ->with('tbody tr:first-child', function (Browser $browser) use ($jack) { + $browser->assertSeeIn('td:nth-child(1) a', $jack->email) + ->assertSeeIn('td:nth-child(2) a', $jack->id) + ->assertVisible('td:nth-child(3)') + ->assertTextRegExp('td:nth-child(3)', '/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/') + ->assertVisible('td:nth-child(4)') + ->assertText('td:nth-child(4)', ''); + }) + ->with('tbody tr:last-child', function (Browser $browser) use ($john) { + $browser->assertSeeIn('td:nth-child(1) a', $john->email) + ->assertSeeIn('td:nth-child(2) a', $john->id) + ->assertVisible('td:nth-child(3)') + ->assertTextRegExp('td:nth-child(3)', '/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/') + ->assertVisible('td:nth-child(4)') + ->assertText('td:nth-child(4)', ''); + }); + }); + + // Test search with single record result -> redirect to user page + $browser->type('@search input', 'kolab.org') + ->click('@search form button') + ->assertMissing('@search table') + ->waitForLocation('/user/' . $john->id) + ->waitUntilMissing('.app-loader') + ->whenAvailable('#user-info', function (Browser $browser) use ($john) { + $browser->assertSeeIn('.card-title', $john->email); + }); + }); + } + + /** + * Test user search deleted user/domain + */ + public function testSearchDeleted(): void + { + $this->browse(function (Browser $browser) { + $browser->visit(new Home()) + ->submitLogon('reseller@kolabnow.com', 'reseller', true) + ->on(new Dashboard()) + ->assertFocused('@search input') + ->assertMissing('@search table'); + + // Deleted users/domains + $domain = $this->getTestDomain('testsearch.com', ['type' => \App\Domain::TYPE_EXTERNAL]); + $user = $this->getTestUser('test@testsearch.com'); + $plan = \App\Plan::where('title', 'group')->first(); + $user->assignPlan($plan, $domain); + $user->setAliases(['alias@testsearch.com']); + Queue::fake(); + $user->delete(); + + // Test search with multiple results + $browser->type('@search input', 'testsearch.com') + ->click('@search form button') + ->assertToast(Toast::TYPE_INFO, '1 user accounts have been found.') + ->whenAvailable('@search table', function (Browser $browser) use ($user) { + $browser->assertElementsCount('tbody tr', 1) + ->assertVisible('tbody tr:first-child.text-secondary') + ->with('tbody tr:first-child', function (Browser $browser) use ($user) { + $browser->assertSeeIn('td:nth-child(1) span', $user->email) + ->assertSeeIn('td:nth-child(2) span', $user->id) + ->assertVisible('td:nth-child(3)') + ->assertTextRegExp('td:nth-child(3)', '/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/') + ->assertVisible('td:nth-child(4)') + ->assertTextRegExp('td:nth-child(4)', '/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/'); + }); + }); + }); + } +} diff --git a/src/tests/Browser/Reseller/DistlistTest.php b/src/tests/Browser/Reseller/DistlistTest.php new file mode 100644 index 00000000..b45f8c89 --- /dev/null +++ b/src/tests/Browser/Reseller/DistlistTest.php @@ -0,0 +1,128 @@ +deleteTestGroup('group-test@kolab.org'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestGroup('group-test@kolab.org'); + + parent::tearDown(); + } + + /** + * Test distlist info page (unauthenticated) + */ + public function testDistlistUnauth(): void + { + // Test that the page requires authentication + $this->browse(function (Browser $browser) { + $user = $this->getTestUser('john@kolab.org'); + $group = $this->getTestGroup('group-test@kolab.org'); + $group->assignToWallet($user->wallets->first()); + + $browser->visit('/distlist/' . $group->id)->on(new Home()); + }); + } + + /** + * Test distribution list info page + */ + public function testInfo(): void + { + Queue::fake(); + + $this->browse(function (Browser $browser) { + $user = $this->getTestUser('john@kolab.org'); + $group = $this->getTestGroup('group-test@kolab.org'); + $group->assignToWallet($user->wallets->first()); + $group->members = ['test1@gmail.com', 'test2@gmail.com']; + $group->save(); + + $distlist_page = new DistlistPage($group->id); + $user_page = new UserPage($user->id); + + // Goto the distlist page + $browser->visit(new Home()) + ->submitLogon('reseller@kolabnow.com', 'reseller', true) + ->on(new Dashboard()) + ->visit($user_page) + ->on($user_page) + ->click('@nav #tab-distlists') + ->pause(1000) + ->click('@user-distlists table tbody tr:first-child td a') + ->on($distlist_page) + ->assertSeeIn('@distlist-info .card-title', $group->email) + ->with('@distlist-info form', function (Browser $browser) use ($group) { + $browser->assertElementsCount('.row', 3) + ->assertSeeIn('.row:nth-child(1) label', 'ID (Created at)') + ->assertSeeIn('.row:nth-child(1) #distlistid', "{$group->id} ({$group->created_at})") + ->assertSeeIn('.row:nth-child(2) label', 'Status') + ->assertSeeIn('.row:nth-child(2) #status.text-danger', 'Not Ready') + ->assertSeeIn('.row:nth-child(3) label', 'Recipients') + ->assertSeeIn('.row:nth-child(3) #members', $group->members[0]) + ->assertSeeIn('.row:nth-child(3) #members', $group->members[1]); + }); + + // Test invalid group identifier + $browser->visit('/distlist/abc')->assertErrorPage(404); + }); + } + + /** + * Test suspending/unsuspending a distribution list + * + * @depends testInfo + */ + public function testSuspendAndUnsuspend(): void + { + Queue::fake(); + + $this->browse(function (Browser $browser) { + $user = $this->getTestUser('john@kolab.org'); + $group = $this->getTestGroup('group-test@kolab.org'); + $group->assignToWallet($user->wallets->first()); + $group->status = Group::STATUS_ACTIVE | Group::STATUS_LDAP_READY; + $group->save(); + + $browser->visit(new DistlistPage($group->id)) + ->assertVisible('@distlist-info #button-suspend') + ->assertMissing('@distlist-info #button-unsuspend') + ->assertSeeIn('@distlist-info #status.text-success', 'Active') + ->click('@distlist-info #button-suspend') + ->assertToast(Toast::TYPE_SUCCESS, 'Distribution list suspended successfully.') + ->assertSeeIn('@distlist-info #status.text-warning', 'Suspended') + ->assertMissing('@distlist-info #button-suspend') + ->click('@distlist-info #button-unsuspend') + ->assertToast(Toast::TYPE_SUCCESS, 'Distribution list unsuspended successfully.') + ->assertSeeIn('@distlist-info #status.text-success', 'Active') + ->assertVisible('@distlist-info #button-suspend') + ->assertMissing('@distlist-info #button-unsuspend'); + }); + } +} diff --git a/src/tests/Browser/Reseller/DomainTest.php b/src/tests/Browser/Reseller/DomainTest.php new file mode 100644 index 00000000..e57bcfc9 --- /dev/null +++ b/src/tests/Browser/Reseller/DomainTest.php @@ -0,0 +1,120 @@ +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@kolabnow.com'); + $user = $this->getTestUser('john@kolab.org'); + $user_page = new UserPage($user->id); + + // Goto the domain page + $browser->visit(new Home()) + ->submitLogon('reseller@kolabnow.com', 'reseller', 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 at)') + ->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', 1); + + // 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) { + $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, + ]); + + $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 new file mode 100644 index 00000000..8d43bb90 --- /dev/null +++ b/src/tests/Browser/Reseller/InvitationsTest.php @@ -0,0 +1,222 @@ +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@kolabnow.com', 'reseller', 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') + ->assertSeeIn('@body input#file + label', 'empty.csv') + ->click('@button-action') + ->assertToast(Toast::TYPE_ERROR, "Form validation error") + // ->waitFor('input#file.is-invalid') + ->assertSeeIn( + '@body input#file.is-invalid + label + .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]); + + // Test deleting + $browser->visit(new Invitations()) + // ->submitLogon('reseller@kolabnow.com', 'reseller', 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); + + // Test resending + $browser->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); + }); + } + + /** + * 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@kolabnow.com', 'reseller', 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/LogonTest.php b/src/tests/Browser/Reseller/LogonTest.php new file mode 100644 index 00000000..656904e7 --- /dev/null +++ b/src/tests/Browser/Reseller/LogonTest.php @@ -0,0 +1,144 @@ +browse(function (Browser $browser) { + $browser->visit(new Home()) + ->with(new Menu(), function ($browser) { + $browser->assertMenuItems(['explore', 'blog', 'support', 'login', 'lang']); + }) + ->assertMissing('@second-factor-input') + ->assertMissing('@forgot-password'); + }); + } + + /** + * Test redirect to /login if user is unauthenticated + */ + public function testLogonRedirect(): void + { + $this->browse(function (Browser $browser) { + $browser->visit('/dashboard'); + + // Checks if we're really on the login page + $browser->waitForLocation('/login') + ->on(new Home()); + }); + } + + /** + * Logon with wrong password/user test + */ + public function testLogonWrongCredentials(): void + { + $this->browse(function (Browser $browser) { + $browser->visit(new Home()) + ->submitLogon('reseller@reseller.com', 'wrong') + // Error message + ->assertToast(Toast::TYPE_ERROR, 'Invalid username or password.') + // Checks if we're still on the logon page + ->on(new Home()); + }); + } + + /** + * Successful logon test + */ + public function testLogonSuccessful(): void + { + $this->browse(function (Browser $browser) { + $browser->visit(new Home()) + ->submitLogon('reseller@reseller.com', 'reseller', true); + + // Checks if we're really on Dashboard page + $browser->on(new Dashboard()) + ->within(new Menu(), function ($browser) { + $browser->assertMenuItems(['explore', 'blog', 'support', 'dashboard', 'logout', 'lang']); + }) + ->assertUser('reseller@reseller.com'); + + // Test that visiting '/' with logged in user does not open logon form + // but "redirects" to the dashboard + $browser->visit('/')->on(new Dashboard()); + }); + } + + /** + * Logout test + * + * @depends testLogonSuccessful + */ + public function testLogout(): void + { + $this->browse(function (Browser $browser) { + $browser->on(new Dashboard()); + + // Click the Logout button + $browser->within(new Menu(), function ($browser) { + $browser->clickMenuItem('logout'); + }); + + // We expect the logon page + $browser->waitForLocation('/login') + ->on(new Home()); + + // with default menu + $browser->within(new Menu(), function ($browser) { + $browser->assertMenuItems(['explore', 'blog', 'support', 'login', 'lang']); + }); + + // Success toast message + $browser->assertToast(Toast::TYPE_SUCCESS, 'Successfully logged out'); + }); + } + + /** + * Logout by URL test + */ + public function testLogoutByURL(): void + { + $this->browse(function (Browser $browser) { + $browser->visit(new Home()) + ->submitLogon('reseller@reseller.com', 'reseller', true); + + // Checks if we're really on Dashboard page + $browser->on(new Dashboard()); + + // Use /logout url, and expect the logon page + $browser->visit('/logout') + ->waitForLocation('/login') + ->on(new Home()); + + // with default menu + $browser->within(new Menu(), function ($browser) { + $browser->assertMenuItems(['explore', 'blog', 'support', 'login', 'lang']); + }); + + // Success toast message + $browser->assertToast(Toast::TYPE_SUCCESS, 'Successfully logged out'); + }); + } +} diff --git a/src/tests/Browser/Reseller/PaymentMollieTest.php b/src/tests/Browser/Reseller/PaymentMollieTest.php new file mode 100644 index 00000000..7bb9ef29 --- /dev/null +++ b/src/tests/Browser/Reseller/PaymentMollieTest.php @@ -0,0 +1,116 @@ +getTestUser('reseller@kolabnow.com'); + $wallet = $user->wallets()->first(); + $wallet->payments()->delete(); + $wallet->balance = 0; + $wallet->save(); + + parent::tearDown(); + } + + /** + * Test the payment process + * + * @group mollie + */ + public function testPayment(): void + { + $this->browse(function (Browser $browser) { + $user = $this->getTestUser('reseller@kolabnow.com'); + $wallet = $user->wallets()->first(); + $wallet->payments()->delete(); + $wallet->balance = 0; + $wallet->save(); + + $browser->visit(new Home()) + ->submitLogon($user->email, 'reseller', true, ['paymentProvider' => 'mollie']) + ->on(new Dashboard()) + ->click('@links .link-wallet') + ->on(new WalletPage()) + ->assertSeeIn('@main button', 'Add credit') + ->click('@main button') + ->with(new Dialog('@payment-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'Top up your wallet') + ->waitFor('#payment-method-selection #creditcard') + ->waitFor('#payment-method-selection #paypal') + ->assertMissing('#payment-method-selection #banktransfer') + ->click('#creditcard'); + }) + ->with(new Dialog('@payment-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'Top up your wallet') + ->assertFocused('#amount') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@button-action', 'Continue') + // Test error handling + ->type('@body #amount', 'aaa') + ->click('@button-action') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + ->assertSeeIn('#amount + span + .invalid-feedback', 'The amount must be a number.') + // Submit valid data + ->type('@body #amount', '12.34') + // Note we use double click to assert it does not create redundant requests + ->click('@button-action') + ->click('@button-action'); + }) + ->on(new PaymentMollie()) + ->assertSeeIn('@title', \config('app.name') . ' Payment') + ->assertSeeIn('@amount', 'CHF 12.34'); + + $this->assertSame(1, $wallet->payments()->count()); + + // Looks like the Mollie testing mode is limited. + // We'll select credit card method and mark the payment as paid + // We can't do much more, we have to trust Mollie their page works ;) + + // For some reason I don't get the method selection form, it + // immediately jumps to the next step. Let's detect that + if ($browser->element('@methods')) { + $browser->click('@methods button.grid-button-creditcard') + ->waitFor('button.form__button'); + } + + $browser->click('@status-table input[value="paid"]') + ->click('button.form__button'); + + // Now it should redirect back to wallet page and in background + // use the webhook to update payment status (and balance). + + // Looks like in test-mode the webhook is executed before redirect + // so we can expect balance updated on the wallet page + + $browser->waitForLocation('/wallet') + ->on(new WalletPage()) + ->assertSeeIn('@main .card-title', 'Account balance 12,34 CHF'); + }); + } +} diff --git a/src/tests/Browser/Reseller/StatsTest.php b/src/tests/Browser/Reseller/StatsTest.php new file mode 100644 index 00000000..8a4830f8 --- /dev/null +++ b/src/tests/Browser/Reseller/StatsTest.php @@ -0,0 +1,54 @@ +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@kolabnow.com', 'reseller', true) + ->on(new Dashboard()) + ->assertSeeIn('@links .link-stats', 'Stats') + ->click('@links .link-stats') + ->on(new Stats()) + ->assertElementsCount('@container > div', 3) + ->waitFor('@container #chart-users svg') + ->assertSeeIn('@container #chart-users svg .title', 'Users - last 8 weeks') + ->waitFor('@container #chart-users-all svg') + ->assertSeeIn('@container #chart-users-all svg .title', 'All Users - last year') + ->waitFor('@container #chart-discounts svg') + ->assertSeeIn('@container #chart-discounts svg .title', 'Discounts'); + }); + } +} diff --git a/src/tests/Browser/Reseller/UserFinancesTest.php b/src/tests/Browser/Reseller/UserFinancesTest.php new file mode 100644 index 00000000..81b3c2d7 --- /dev/null +++ b/src/tests/Browser/Reseller/UserFinancesTest.php @@ -0,0 +1,325 @@ +getTestUser('john@kolab.org'); + $wallet = $john->wallets()->first(); + $wallet->discount()->dissociate(); + $wallet->balance = 0; + $wallet->save(); + $wallet->setSettings(['mollie_id' => null, 'stripe_id' => null]); + } + + /** + * Test Finances tab (and transactions) + */ + public function testFinances(): void + { + // Assert Jack's Finances tab + $this->browse(function (Browser $browser) { + $jack = $this->getTestUser('jack@kolab.org'); + $wallet = $jack->wallets()->first(); + $wallet->transactions()->delete(); + $wallet->setSetting('stripe_id', 'abc'); + $page = new UserPage($jack->id); + + $browser->visit(new Home()) + ->submitLogon('reseller@kolabnow.com', 'reseller', true) + ->on(new Dashboard()) + ->visit($page) + ->on($page) + ->assertSeeIn('@nav #tab-finances', 'Finances') + ->with('@user-finances', function (Browser $browser) { + $browser->waitUntilMissing('.app-loader') + ->assertSeeIn('.card-title:first-child', 'Account balance') + ->assertSeeIn('.card-title:first-child .text-success', '0,00 CHF') + ->with('form', function (Browser $browser) { + $browser->assertElementsCount('.row', 2) + ->assertSeeIn('.row:nth-child(1) label', 'Discount') + ->assertSeeIn('.row:nth-child(1) #discount span', 'none') + ->assertSeeIn('.row:nth-child(2) label', 'Stripe ID') + ->assertSeeIn('.row:nth-child(2) a', 'abc'); + }) + ->assertSeeIn('h2:nth-of-type(2)', 'Transactions') + ->with('table', function (Browser $browser) { + $browser->assertMissing('tbody') + ->assertSeeIn('tfoot td', "There are no transactions for this account."); + }) + ->assertMissing('table + button'); + }); + }); + + // Assert John's Finances tab (with discount, and debit) + $this->browse(function (Browser $browser) { + $john = $this->getTestUser('john@kolab.org'); + $page = new UserPage($john->id); + $discount = Discount::where('code', 'TEST')->first(); + $wallet = $john->wallet(); + $wallet->transactions()->delete(); + $wallet->discount()->associate($discount); + $wallet->debit(2010); + $wallet->save(); + + // Create test transactions + $transaction = Transaction::create([ + 'user_email' => 'jeroen@jeroen.jeroen', + 'object_id' => $wallet->id, + 'object_type' => Wallet::class, + 'type' => Transaction::WALLET_CREDIT, + 'amount' => 100, + 'description' => 'Payment', + ]); + $transaction->created_at = Carbon::now()->subMonth(); + $transaction->save(); + + // Click the managed-by link on Jack's page + $browser->click('@user-info #manager a') + ->on($page) + ->with('@user-finances', function (Browser $browser) { + $browser->waitUntilMissing('.app-loader') + ->assertSeeIn('.card-title:first-child', 'Account balance') + ->assertSeeIn('.card-title:first-child .text-danger', '-20,10 CHF') + ->with('form', function (Browser $browser) { + $browser->assertElementsCount('.row', 1) + ->assertSeeIn('.row:nth-child(1) label', 'Discount') + ->assertSeeIn('.row:nth-child(1) #discount span', '10% - Test voucher'); + }) + ->assertSeeIn('h2:nth-of-type(2)', 'Transactions') + ->with('table', function (Browser $browser) { + $browser->assertElementsCount('tbody tr', 2) + ->assertMissing('tfoot'); + + if (!$browser->isPhone()) { + $browser->assertSeeIn('tbody tr:last-child td.email', 'jeroen@jeroen.jeroen'); + } + }); + }); + }); + + // Now we go to Ned's info page, he's a controller on John's wallet + $this->browse(function (Browser $browser) { + $ned = $this->getTestUser('ned@kolab.org'); + $wallet = $ned->wallets()->first(); + $wallet->balance = 0; + $wallet->save(); + $page = new UserPage($ned->id); + + $browser->click('@nav #tab-users') + ->click('@user-users tbody tr:nth-child(4) td:first-child a') + ->on($page) + ->with('@user-finances', function (Browser $browser) { + $browser->waitUntilMissing('.app-loader') + ->assertSeeIn('.card-title:first-child', 'Account balance') + ->assertSeeIn('.card-title:first-child .text-success', '0,00 CHF') + ->with('form', function (Browser $browser) { + $browser->assertElementsCount('.row', 1) + ->assertSeeIn('.row:nth-child(1) label', 'Discount') + ->assertSeeIn('.row:nth-child(1) #discount span', 'none'); + }) + ->assertSeeIn('h2:nth-of-type(2)', 'Transactions') + ->with('table', function (Browser $browser) { + $browser->assertMissing('tbody') + ->assertSeeIn('tfoot td', "There are no transactions for this account."); + }) + ->assertMissing('table + button'); + }); + }); + } + + /** + * Test editing wallet discount + * + * @depends testFinances + */ + public function testWalletDiscount(): void + { + $this->browse(function (Browser $browser) { + $john = $this->getTestUser('john@kolab.org'); + + $browser->visit(new UserPage($john->id)) + ->pause(100) + ->waitUntilMissing('@user-finances .app-loader') + ->click('@user-finances #discount button') + // Test dialog content, and closing it with Cancel button + ->with(new Dialog('#discount-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'Account discount') + ->assertFocused('@body select') + ->assertSelected('@body select', '') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@button-action', 'Submit') + ->click('@button-cancel'); + }) + ->assertMissing('#discount-dialog') + ->click('@user-finances #discount button') + // Change the discount + ->with(new Dialog('#discount-dialog'), function (Browser $browser) { + $browser->click('@body select') + ->click('@body select option:nth-child(2)') + ->click('@button-action'); + }) + ->assertToast(Toast::TYPE_SUCCESS, 'User wallet updated successfully.') + ->assertSeeIn('#discount span', '10% - Test voucher') + ->click('@nav #tab-subscriptions') + ->with('@user-subscriptions', function (Browser $browser) { + $browser->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '3,99 CHF/month¹') + ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹') + ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,99 CHF/month¹') + ->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher'); + }) + // Change back to 'none' + ->click('@nav #tab-finances') + ->click('@user-finances #discount button') + ->with(new Dialog('#discount-dialog'), function (Browser $browser) { + $browser->click('@body select') + ->click('@body select option:nth-child(1)') + ->click('@button-action'); + }) + ->assertToast(Toast::TYPE_SUCCESS, 'User wallet updated successfully.') + ->assertSeeIn('#discount span', 'none') + ->click('@nav #tab-subscriptions') + ->with('@user-subscriptions', function (Browser $browser) { + $browser->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '4,44 CHF/month') + ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month') + ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '5,55 CHF/month') + ->assertMissing('table + .hint'); + }); + }); + } + + /** + * Test awarding/penalizing a wallet + * + * @depends testFinances + */ + public function testBonusPenalty(): void + { + $this->browse(function (Browser $browser) { + $john = $this->getTestUser('john@kolab.org'); + + $browser->visit(new UserPage($john->id)) + ->waitFor('@user-finances #button-award') + ->click('@user-finances #button-award') + // Test dialog content, and closing it with Cancel button + ->with(new Dialog('#oneoff-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'Add a bonus to the wallet') + ->assertFocused('@body input#oneoff_amount') + ->assertSeeIn('@body label[for="oneoff_amount"]', 'Amount') + ->assertvalue('@body input#oneoff_amount', '') + ->assertSeeIn('@body label[for="oneoff_description"]', 'Description') + ->assertvalue('@body input#oneoff_description', '') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@button-action', 'Submit') + ->click('@button-cancel'); + }) + ->assertMissing('#oneoff-dialog'); + + // Test bonus + $browser->click('@user-finances #button-award') + ->with(new Dialog('#oneoff-dialog'), function (Browser $browser) { + // Test input validation for a bonus + $browser->type('@body #oneoff_amount', 'aaa') + ->type('@body #oneoff_description', '') + ->click('@button-action') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + ->assertVisible('@body #oneoff_amount.is-invalid') + ->assertVisible('@body #oneoff_description.is-invalid') + ->assertSeeIn( + '@body #oneoff_amount + span + .invalid-feedback', + 'The amount must be a number.' + ) + ->assertSeeIn( + '@body #oneoff_description + .invalid-feedback', + 'The description field is required.' + ); + + // Test adding a bonus + $browser->type('@body #oneoff_amount', '12.34') + ->type('@body #oneoff_description', 'Test bonus') + ->click('@button-action') + ->assertToast(Toast::TYPE_SUCCESS, 'The bonus has been added to the wallet successfully.'); + }) + ->assertMissing('#oneoff-dialog') + ->assertSeeIn('@user-finances .card-title span.text-success', '12,34 CHF') + ->waitUntilMissing('.app-loader') + ->with('table', function (Browser $browser) { + $browser->assertElementsCount('tbody tr', 3) + ->assertMissing('tfoot') + ->assertSeeIn('tbody tr:first-child td.description', 'Bonus: Test bonus') + ->assertSeeIn('tbody tr:first-child td.price', '12,34 CHF'); + + if (!$browser->isPhone()) { + $browser->assertSeeIn('tbody tr:first-child td.email', 'reseller@kolabnow.com'); + } + }); + + $this->assertSame(1234, $john->wallets()->first()->balance); + + // Test penalty + $browser->click('@user-finances #button-penalty') + // Test dialog content, and closing it with Cancel button + ->with(new Dialog('#oneoff-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'Add a penalty to the wallet') + ->assertFocused('@body input#oneoff_amount') + ->assertSeeIn('@body label[for="oneoff_amount"]', 'Amount') + ->assertvalue('@body input#oneoff_amount', '') + ->assertSeeIn('@body label[for="oneoff_description"]', 'Description') + ->assertvalue('@body input#oneoff_description', '') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@button-action', 'Submit') + ->click('@button-cancel'); + }) + ->assertMissing('#oneoff-dialog') + ->click('@user-finances #button-penalty') + ->with(new Dialog('#oneoff-dialog'), function (Browser $browser) { + // Test input validation for a penalty + $browser->type('@body #oneoff_amount', '') + ->type('@body #oneoff_description', '') + ->click('@button-action') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + ->assertVisible('@body #oneoff_amount.is-invalid') + ->assertVisible('@body #oneoff_description.is-invalid') + ->assertSeeIn( + '@body #oneoff_amount + span + .invalid-feedback', + 'The amount field is required.' + ) + ->assertSeeIn( + '@body #oneoff_description + .invalid-feedback', + 'The description field is required.' + ); + + // Test adding a penalty + $browser->type('@body #oneoff_amount', '12.35') + ->type('@body #oneoff_description', 'Test penalty') + ->click('@button-action') + ->assertToast(Toast::TYPE_SUCCESS, 'The penalty has been added to the wallet successfully.'); + }) + ->assertMissing('#oneoff-dialog') + ->assertSeeIn('@user-finances .card-title span.text-danger', '-0,01 CHF'); + + $this->assertSame(-1, $john->wallets()->first()->balance); + }); + } +} diff --git a/src/tests/Browser/Reseller/UserTest.php b/src/tests/Browser/Reseller/UserTest.php new file mode 100644 index 00000000..53ce54de --- /dev/null +++ b/src/tests/Browser/Reseller/UserTest.php @@ -0,0 +1,473 @@ +getTestUser('john@kolab.org'); + $john->setSettings([ + 'phone' => '+48123123123', + 'external_email' => 'john.doe.external@gmail.com', + ]); + if ($john->isSuspended()) { + User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]); + } + $wallet = $john->wallets()->first(); + $wallet->discount()->dissociate(); + $wallet->save(); + + $this->deleteTestGroup('group-test@kolab.org'); + $this->clearMeetEntitlements(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $john = $this->getTestUser('john@kolab.org'); + $john->setSettings([ + 'phone' => null, + 'external_email' => 'john.doe.external@gmail.com', + ]); + if ($john->isSuspended()) { + User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]); + } + $wallet = $john->wallets()->first(); + $wallet->discount()->dissociate(); + $wallet->save(); + + $this->deleteTestGroup('group-test@kolab.org'); + $this->clearMeetEntitlements(); + + parent::tearDown(); + } + + /** + * Test user info page (unauthenticated) + */ + public function testUserUnauth(): void + { + // Test that the page requires authentication + $this->browse(function (Browser $browser) { + $jack = $this->getTestUser('jack@kolab.org'); + $browser->visit('/user/' . $jack->id)->on(new Home()); + }); + } + + /** + * Test user info page + */ + public function testUserInfo(): void + { + $this->browse(function (Browser $browser) { + $jack = $this->getTestUser('jack@kolab.org'); + $page = new UserPage($jack->id); + + $browser->visit(new Home()) + ->submitLogon('reseller@kolabnow.com', 'reseller', true) + ->on(new Dashboard()) + ->visit($page) + ->on($page); + + // Assert main info box content + $browser->assertSeeIn('@user-info .card-title', $jack->email) + ->with('@user-info form', function (Browser $browser) use ($jack) { + $browser->assertElementsCount('.row', 7) + ->assertSeeIn('.row:nth-child(1) label', 'Managed by') + ->assertSeeIn('.row:nth-child(1) #manager a', 'john@kolab.org') + ->assertSeeIn('.row:nth-child(2) label', 'ID (Created at)') + ->assertSeeIn('.row:nth-child(2) #userid', "{$jack->id} ({$jack->created_at})") + ->assertSeeIn('.row:nth-child(3) label', 'Status') + ->assertSeeIn('.row:nth-child(3) #status span.text-success', 'Active') + ->assertSeeIn('.row:nth-child(4) label', 'First name') + ->assertSeeIn('.row:nth-child(4) #first_name', 'Jack') + ->assertSeeIn('.row:nth-child(5) label', 'Last name') + ->assertSeeIn('.row:nth-child(5) #last_name', 'Daniels') + ->assertSeeIn('.row:nth-child(6) label', 'External email') + ->assertMissing('.row:nth-child(6) #external_email a') + ->assertSeeIn('.row:nth-child(7) label', 'Country') + ->assertSeeIn('.row:nth-child(7) #country', 'United States'); + }); + + // Some tabs are loaded in background, wait a second + $browser->pause(500) + ->assertElementsCount('@nav a', 6); + + // Note: Finances tab is tested in UserFinancesTest.php + $browser->assertSeeIn('@nav #tab-finances', 'Finances'); + + // Assert Aliases tab + $browser->assertSeeIn('@nav #tab-aliases', 'Aliases (1)') + ->click('@nav #tab-aliases') + ->whenAvailable('@user-aliases', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 1) + ->assertSeeIn('table tbody tr:first-child td:first-child', 'jack.daniels@kolab.org') + ->assertMissing('table tfoot'); + }); + + // Assert Subscriptions tab + $browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (3)') + ->click('@nav #tab-subscriptions') + ->with('@user-subscriptions', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 3) + ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox') + ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '4,44 CHF') + ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 2 GB') + ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF') + ->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features') + ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '5,55 CHF') + ->assertMissing('table tfoot') + ->assertMissing('#reset2fa'); + }); + + // Assert Domains tab + $browser->assertSeeIn('@nav #tab-domains', 'Domains (0)') + ->click('@nav #tab-domains') + ->with('@user-domains', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 0) + ->assertSeeIn('table tfoot tr td', 'There are no domains in this account.'); + }); + + // Assert Users tab + $browser->assertSeeIn('@nav #tab-users', 'Users (0)') + ->click('@nav #tab-users') + ->with('@user-users', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 0) + ->assertSeeIn('table tfoot tr td', 'There are no users in this account.'); + }); + + // Assert Distribution lists tab + $browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (0)') + ->click('@nav #tab-distlists') + ->with('@user-distlists', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 0) + ->assertSeeIn('table tfoot tr td', 'There are no distribution lists in this account.'); + }); + }); + } + + /** + * Test user info page (continue) + * + * @depends testUserInfo + */ + public function testUserInfo2(): void + { + $this->browse(function (Browser $browser) { + $john = $this->getTestUser('john@kolab.org'); + $page = new UserPage($john->id); + $discount = Discount::where('code', 'TEST')->first(); + $wallet = $john->wallet(); + $wallet->discount()->associate($discount); + $wallet->debit(2010); + $wallet->save(); + $group = $this->getTestGroup('group-test@kolab.org'); + $group->assignToWallet($john->wallets->first()); + + // Click the managed-by link on Jack's page + $browser->click('@user-info #manager a') + ->on($page); + + // Assert main info box content + $browser->assertSeeIn('@user-info .card-title', $john->email) + ->with('@user-info form', function (Browser $browser) use ($john) { + $ext_email = $john->getSetting('external_email'); + + $browser->assertElementsCount('.row', 9) + ->assertSeeIn('.row:nth-child(1) label', 'ID (Created at)') + ->assertSeeIn('.row:nth-child(1) #userid', "{$john->id} ({$john->created_at})") + ->assertSeeIn('.row:nth-child(2) label', 'Status') + ->assertSeeIn('.row:nth-child(2) #status span.text-success', 'Active') + ->assertSeeIn('.row:nth-child(3) label', 'First name') + ->assertSeeIn('.row:nth-child(3) #first_name', 'John') + ->assertSeeIn('.row:nth-child(4) label', 'Last name') + ->assertSeeIn('.row:nth-child(4) #last_name', 'Doe') + ->assertSeeIn('.row:nth-child(5) label', 'Organization') + ->assertSeeIn('.row:nth-child(5) #organization', 'Kolab Developers') + ->assertSeeIn('.row:nth-child(6) label', 'Phone') + ->assertSeeIn('.row:nth-child(6) #phone', $john->getSetting('phone')) + ->assertSeeIn('.row:nth-child(7) label', 'External email') + ->assertSeeIn('.row:nth-child(7) #external_email a', $ext_email) + ->assertAttribute('.row:nth-child(7) #external_email a', 'href', "mailto:$ext_email") + ->assertSeeIn('.row:nth-child(8) label', 'Address') + ->assertSeeIn('.row:nth-child(8) #billing_address', $john->getSetting('billing_address')) + ->assertSeeIn('.row:nth-child(9) label', 'Country') + ->assertSeeIn('.row:nth-child(9) #country', 'United States'); + }); + + // Some tabs are loaded in background, wait a second + $browser->pause(500) + ->assertElementsCount('@nav a', 6); + + // Note: Finances tab is tested in UserFinancesTest.php + $browser->assertSeeIn('@nav #tab-finances', 'Finances'); + + // Assert Aliases tab + $browser->assertSeeIn('@nav #tab-aliases', 'Aliases (1)') + ->click('@nav #tab-aliases') + ->whenAvailable('@user-aliases', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 1) + ->assertSeeIn('table tbody tr:first-child td:first-child', 'john.doe@kolab.org') + ->assertMissing('table tfoot'); + }); + + // Assert Subscriptions tab + $browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (3)') + ->click('@nav #tab-subscriptions') + ->with('@user-subscriptions', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 3) + ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox') + ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '3,99 CHF/month¹') + ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 2 GB') + ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹') + ->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features') + ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,99 CHF/month¹') + ->assertMissing('table tfoot') + ->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher'); + }); + + // Assert Domains tab + $browser->assertSeeIn('@nav #tab-domains', 'Domains (1)') + ->click('@nav #tab-domains') + ->with('@user-domains table', function (Browser $browser) { + $browser->assertElementsCount('tbody tr', 1) + ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'kolab.org') + ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success') + ->assertMissing('tfoot'); + }); + + // Assert Distribution lists tab + $browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (1)') + ->click('@nav #tab-distlists') + ->with('@user-distlists table', function (Browser $browser) { + $browser->assertElementsCount('tbody tr', 1) + ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'group-test@kolab.org') + ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-danger') + ->assertMissing('tfoot'); + }); + + // Assert Users tab + $browser->assertSeeIn('@nav #tab-users', 'Users (4)') + ->click('@nav #tab-users') + ->with('@user-users table', function (Browser $browser) { + $browser->assertElementsCount('tbody tr', 4) + ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'jack@kolab.org') + ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success') + ->assertSeeIn('tbody tr:nth-child(2) td:first-child a', 'joe@kolab.org') + ->assertVisible('tbody tr:nth-child(2) td:first-child svg.text-success') + ->assertSeeIn('tbody tr:nth-child(3) td:first-child span', 'john@kolab.org') + ->assertVisible('tbody tr:nth-child(3) td:first-child svg.text-success') + ->assertSeeIn('tbody tr:nth-child(4) td:first-child a', 'ned@kolab.org') + ->assertVisible('tbody tr:nth-child(4) td:first-child svg.text-success') + ->assertMissing('tfoot'); + }); + }); + + // Now we go to Ned's info page, he's a controller on John's wallet + $this->browse(function (Browser $browser) { + $ned = $this->getTestUser('ned@kolab.org'); + $page = new UserPage($ned->id); + + $browser->click('@user-users tbody tr:nth-child(4) td:first-child a') + ->on($page); + + // Assert main info box content + $browser->assertSeeIn('@user-info .card-title', $ned->email) + ->with('@user-info form', function (Browser $browser) use ($ned) { + $browser->assertSeeIn('.row:nth-child(2) label', 'ID (Created at)') + ->assertSeeIn('.row:nth-child(2) #userid', "{$ned->id} ({$ned->created_at})"); + }); + + // Some tabs are loaded in background, wait a second + $browser->pause(500) + ->assertElementsCount('@nav a', 6); + + // Note: Finances tab is tested in UserFinancesTest.php + $browser->assertSeeIn('@nav #tab-finances', 'Finances'); + + // Assert Aliases tab + $browser->assertSeeIn('@nav #tab-aliases', 'Aliases (0)') + ->click('@nav #tab-aliases') + ->whenAvailable('@user-aliases', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 0) + ->assertSeeIn('table tfoot tr td', 'This user has no email aliases.'); + }); + + // Assert Subscriptions tab, we expect John's discount here + $browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (5)') + ->click('@nav #tab-subscriptions') + ->with('@user-subscriptions', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 5) + ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox') + ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '3,99 CHF/month¹') + ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 2 GB') + ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹') + ->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features') + ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,99 CHF/month¹') + ->assertSeeIn('table tbody tr:nth-child(4) td:first-child', 'Activesync') + ->assertSeeIn('table tbody tr:nth-child(4) td:last-child', '0,90 CHF/month¹') + ->assertSeeIn('table tbody tr:nth-child(5) td:first-child', '2-Factor Authentication') + ->assertSeeIn('table tbody tr:nth-child(5) td:last-child', '0,00 CHF/month¹') + ->assertMissing('table tfoot') + ->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher') + ->assertSeeIn('#reset2fa', 'Reset 2-Factor Auth'); + }); + + // We don't expect John's domains here + $browser->assertSeeIn('@nav #tab-domains', 'Domains (0)') + ->click('@nav #tab-domains') + ->with('@user-domains', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 0) + ->assertSeeIn('table tfoot tr td', 'There are no domains in this account.'); + }); + + // We don't expect John's users here + $browser->assertSeeIn('@nav #tab-users', 'Users (0)') + ->click('@nav #tab-users') + ->with('@user-users', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 0) + ->assertSeeIn('table tfoot tr td', 'There are no users in this account.'); + }); + + // We don't expect John's distribution lists here + $browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (0)') + ->click('@nav #tab-distlists') + ->with('@user-distlists', function (Browser $browser) { + $browser->assertElementsCount('table tbody tr', 0) + ->assertSeeIn('table tfoot tr td', 'There are no distribution lists in this account.'); + }); + }); + } + + /** + * Test editing an external email + * + * @depends testUserInfo2 + */ + public function testExternalEmail(): void + { + $this->browse(function (Browser $browser) { + $john = $this->getTestUser('john@kolab.org'); + + $browser->visit(new UserPage($john->id)) + ->waitFor('@user-info #external_email button') + ->click('@user-info #external_email button') + // Test dialog content, and closing it with Cancel button + ->with(new Dialog('#email-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'External email') + ->assertFocused('@body input') + ->assertValue('@body input', 'john.doe.external@gmail.com') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@button-action', 'Submit') + ->click('@button-cancel'); + }) + ->assertMissing('#email-dialog') + ->click('@user-info #external_email button') + // Test email validation error handling, and email update + ->with(new Dialog('#email-dialog'), function (Browser $browser) { + $browser->type('@body input', 'test') + ->click('@button-action') + ->waitFor('@body input.is-invalid') + ->assertSeeIn( + '@body input + .invalid-feedback', + 'The external email must be a valid email address.' + ) + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + ->type('@body input', 'test@test.com') + ->click('@button-action'); + }) + ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.') + ->assertSeeIn('@user-info #external_email a', 'test@test.com') + ->click('@user-info #external_email button') + ->with(new Dialog('#email-dialog'), function (Browser $browser) { + $browser->assertValue('@body input', 'test@test.com') + ->assertMissing('@body input.is-invalid') + ->assertMissing('@body input + .invalid-feedback') + ->click('@button-cancel'); + }) + ->assertSeeIn('@user-info #external_email a', 'test@test.com'); + + // $john->getSetting() may not work here as it uses internal cache + // read the value form database + $current_ext_email = $john->settings()->where('key', 'external_email')->first()->value; + $this->assertSame('test@test.com', $current_ext_email); + }); + } + + /** + * Test suspending/unsuspending the user + */ + public function testSuspendAndUnsuspend(): void + { + $this->browse(function (Browser $browser) { + $john = $this->getTestUser('john@kolab.org'); + + $browser->visit(new UserPage($john->id)) + ->assertVisible('@user-info #button-suspend') + ->assertMissing('@user-info #button-unsuspend') + ->click('@user-info #button-suspend') + ->assertToast(Toast::TYPE_SUCCESS, 'User suspended successfully.') + ->assertSeeIn('@user-info #status span.text-warning', 'Suspended') + ->assertMissing('@user-info #button-suspend') + ->click('@user-info #button-unsuspend') + ->assertToast(Toast::TYPE_SUCCESS, 'User unsuspended successfully.') + ->assertSeeIn('@user-info #status span.text-success', 'Active') + ->assertVisible('@user-info #button-suspend') + ->assertMissing('@user-info #button-unsuspend'); + }); + } + + /** + * Test resetting 2FA for the user + */ + public function testReset2FA(): void + { + $this->browse(function (Browser $browser) { + $this->deleteTestUser('userstest1@kolabnow.com'); + $user = $this->getTestUser('userstest1@kolabnow.com'); + $sku2fa = Sku::firstOrCreate(['title' => '2fa']); + $user->assignSku($sku2fa); + SecondFactor::seed('userstest1@kolabnow.com'); + + $browser->visit(new UserPage($user->id)) + ->click('@nav #tab-subscriptions') + ->with('@user-subscriptions', function (Browser $browser) use ($sku2fa) { + $browser->waitFor('#reset2fa') + ->assertVisible('#sku' . $sku2fa->id); + }) + ->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (1)') + ->click('#reset2fa') + ->with(new Dialog('#reset-2fa-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', '2-Factor Authentication Reset') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@button-action', 'Reset') + ->click('@button-action'); + }) + ->assertToast(Toast::TYPE_SUCCESS, '2-Factor authentication reset successfully.') + ->assertMissing('#sku' . $sku2fa->id) + ->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (0)'); + }); + } +} diff --git a/src/tests/Browser/Reseller/WalletTest.php b/src/tests/Browser/Reseller/WalletTest.php new file mode 100644 index 00000000..4fc4f2ac --- /dev/null +++ b/src/tests/Browser/Reseller/WalletTest.php @@ -0,0 +1,248 @@ +getTestUser('reseller@kolabnow.com'); + $wallet = $reseller->wallets()->first(); + $wallet->balance = 0; + $wallet->save(); + $wallet->payments()->delete(); + $wallet->transactions()->delete(); + + parent::tearDown(); + } + + /** + * Test wallet page (unauthenticated) + */ + public function testWalletUnauth(): void + { + // Test that the page requires authentication + $this->browse(function (Browser $browser) { + $browser->visit('/wallet')->on(new Home()); + }); + } + + /** + * Test wallet "box" on Dashboard + */ + public function testDashboard(): void + { + $reseller = $this->getTestUser('reseller@kolabnow.com'); + Wallet::where('user_id', $reseller->id)->update(['balance' => 125]); + + // Positive balance + $this->browse(function (Browser $browser) { + $browser->visit(new Home()) + ->submitLogon('reseller@kolabnow.com', 'reseller', true) + ->on(new Dashboard()) + ->assertSeeIn('@links .link-wallet .name', 'Wallet') + ->assertSeeIn('@links .link-wallet .badge-success', '1,25 CHF'); + }); + + Wallet::where('user_id', $reseller->id)->update(['balance' => -1234]); + + // Negative balance + $this->browse(function (Browser $browser) { + $browser->visit(new Dashboard()) + ->assertSeeIn('@links .link-wallet .name', 'Wallet') + ->assertSeeIn('@links .link-wallet .badge-danger', '-12,34 CHF'); + }); + } + + /** + * Test wallet page + * + * @depends testDashboard + */ + public function testWallet(): void + { + $reseller = $this->getTestUser('reseller@kolabnow.com'); + Wallet::where('user_id', $reseller->id)->update(['balance' => -1234]); + + $this->browse(function (Browser $browser) { + $browser->click('@links .link-wallet') + ->on(new WalletPage()) + ->assertSeeIn('#wallet .card-title', 'Account balance -12,34 CHF') + ->assertSeeIn('#wallet .card-title .text-danger', '-12,34 CHF') + ->assertSeeIn('#wallet .card-text', 'You are out of credit'); + }); + } + + /** + * Test Receipts tab + * + * @depends testWallet + */ + public function testReceipts(): void + { + $user = $this->getTestUser('reseller@kolabnow.com'); + $wallet = $user->wallets()->first(); + $wallet->payments()->delete(); + + // Assert Receipts tab content when there's no receipts available + $this->browse(function (Browser $browser) { + $browser->visit(new WalletPage()) + ->assertSeeIn('#wallet .card-title', 'Account balance 0,00 CHF') + ->assertSeeIn('#wallet .card-title .text-success', '0,00 CHF') + ->assertSeeIn('#wallet .card-text', 'You are in your free trial period.') // TODO + ->assertSeeIn('@nav #tab-receipts', 'Receipts') + ->with('@receipts-tab', function (Browser $browser) { + $browser->waitUntilMissing('.app-loader') + ->assertSeeIn('p', 'There are no receipts for payments') + ->assertDontSeeIn('p', 'Here you can download') + ->assertMissing('select') + ->assertMissing('button'); + }); + }); + + // Create some sample payments + $receipts = []; + $date = Carbon::create(intval(date('Y')) - 1, 3, 30); + $payment = Payment::create([ + 'id' => 'AAA1', + 'status' => PaymentProvider::STATUS_PAID, + 'type' => PaymentProvider::TYPE_ONEOFF, + 'description' => 'Paid in March', + 'wallet_id' => $wallet->id, + 'provider' => 'stripe', + 'amount' => 1111, + 'currency_amount' => 1111, + 'currency' => 'CHF', + ]); + $payment->updated_at = $date; + $payment->save(); + $receipts[] = $date->format('Y-m'); + + $date = Carbon::create(intval(date('Y')) - 1, 4, 30); + $payment = Payment::create([ + 'id' => 'AAA2', + 'status' => PaymentProvider::STATUS_PAID, + 'type' => PaymentProvider::TYPE_ONEOFF, + 'description' => 'Paid in April', + 'wallet_id' => $wallet->id, + 'provider' => 'stripe', + 'amount' => 1111, + 'currency_amount' => 1111, + 'currency' => 'CHF', + ]); + $payment->updated_at = $date; + $payment->save(); + $receipts[] = $date->format('Y-m'); + + // Assert Receipts tab with receipts available + $this->browse(function (Browser $browser) use ($receipts) { + $browser->refresh() + ->on(new WalletPage()) + ->assertSeeIn('@nav #tab-receipts', 'Receipts') + ->with('@receipts-tab', function (Browser $browser) use ($receipts) { + $browser->waitUntilMissing('.app-loader') + ->assertDontSeeIn('p', 'There are no receipts for payments') + ->assertSeeIn('p', 'Here you can download') + ->assertSeeIn('button', 'Download') + ->assertElementsCount('select > option', 2) + ->assertSeeIn('select > option:nth-child(1)', $receipts[1]) + ->assertSeeIn('select > option:nth-child(2)', $receipts[0]); + + // Download a receipt file + $browser->select('select', $receipts[0]) + ->click('button') + ->pause(2000); + + $files = glob(__DIR__ . '/../downloads/*.pdf'); + + $filename = pathinfo($files[0], PATHINFO_BASENAME); + $this->assertTrue(strpos($filename, $receipts[0]) !== false); + + $content = $browser->readDownloadedFile($filename, 0); + $this->assertStringStartsWith("%PDF-1.", $content); + + $browser->removeDownloadedFile($filename); + }); + }); + } + + /** + * Test History tab + * + * @depends testWallet + */ + public function testHistory(): void + { + $user = $this->getTestUser('reseller@kolabnow.com'); + $wallet = $user->wallets()->first(); + $wallet->transactions()->delete(); + + // Create some sample transactions + $transactions = $this->createTestTransactions($wallet); + $transactions = array_reverse($transactions); + $pages = array_chunk($transactions, 10 /* page size*/); + + $this->browse(function (Browser $browser) use ($pages) { + $browser->on(new WalletPage()) + ->assertSeeIn('@nav #tab-history', 'History') + ->click('@nav #tab-history') + ->with('@history-tab', function (Browser $browser) use ($pages) { + $browser->waitUntilMissing('.app-loader') + ->assertElementsCount('table tbody tr', 10) + ->assertMissing('table td.email') + ->assertSeeIn('#transactions-loader button', 'Load more'); + + foreach ($pages[0] as $idx => $transaction) { + $selector = 'table tbody tr:nth-child(' . ($idx + 1) . ')'; + $priceStyle = $transaction->type == Transaction::WALLET_AWARD ? 'text-success' : 'text-danger'; + $browser->assertSeeIn("$selector td.description", $transaction->shortDescription()) + ->assertMissing("$selector td.selection button") + ->assertVisible("$selector td.price.{$priceStyle}"); + // TODO: Test more transaction details + } + + // Load the next page + $browser->click('#transactions-loader button') + ->waitUntilMissing('.app-loader') + ->assertElementsCount('table tbody tr', 12) + ->assertMissing('#transactions-loader button'); + + $debitEntry = null; + foreach ($pages[1] as $idx => $transaction) { + $selector = 'table tbody tr:nth-child(' . ($idx + 1 + 10) . ')'; + $priceStyle = $transaction->type == Transaction::WALLET_CREDIT ? 'text-success' : 'text-danger'; + $browser->assertSeeIn("$selector td.description", $transaction->shortDescription()); + + if ($transaction->type == Transaction::WALLET_DEBIT) { + $debitEntry = $selector; + } else { + $browser->assertMissing("$selector td.selection button"); + } + } + }); + }); + } +} diff --git a/src/tests/Browser/SignupTest.php b/src/tests/Browser/SignupTest.php index 79e8171f..02db518a 100644 --- a/src/tests/Browser/SignupTest.php +++ b/src/tests/Browser/SignupTest.php @@ -1,543 +1,636 @@ deleteTestUser('signuptestdusk@' . \config('app.domain')); $this->deleteTestUser('admin@user-domain-signup.com'); $this->deleteTestDomain('user-domain-signup.com'); } + /** + * {@inheritDoc} + */ public function tearDown(): void { $this->deleteTestUser('signuptestdusk@' . \config('app.domain')); $this->deleteTestUser('admin@user-domain-signup.com'); $this->deleteTestDomain('user-domain-signup.com'); + SignupInvitation::truncate(); parent::tearDown(); } /** * Test signup code verification with a link */ public function testSignupCodeByLink(): void { // Test invalid code (invalid format) $this->browse(function (Browser $browser) { // Register Signup page element selectors we'll be using $browser->onWithoutAssert(new Signup()); // TODO: Test what happens if user is logged in $browser->visit('/signup/invalid-code'); // TODO: According to https://github.com/vuejs/vue-router/issues/977 // it is not yet easily possible to display error page component (route) // without changing the URL // TODO: Instead of css selector we should probably define page/component // and use it instead $browser->waitFor('#error-page'); }); // Test invalid code (valid format) $this->browse(function (Browser $browser) { $browser->visit('/signup/XXXXX-code'); // FIXME: User will not be able to continue anyway, so we should // either display 1st step or 404 error page $browser->waitFor('@step1') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); }); // Test valid code $this->browse(function (Browser $browser) { $code = SignupCode::create([ 'email' => 'User@example.org', 'first_name' => 'User', 'last_name' => 'Name', 'plan' => 'individual', 'voucher' => '', ]); $browser->visit('/signup/' . $code->short_code . '-' . $code->code) ->waitFor('@step3') ->assertMissing('@step1') ->assertMissing('@step2'); // FIXME: Find a nice way to read javascript data without using hidden inputs $this->assertSame($code->code, $browser->value('@step2 #signup_code')); // TODO: Test if the signup process can be completed }); } /** * Test signup "welcome" page */ public function testSignupStep0(): void { $this->browse(function (Browser $browser) { $browser->visit(new Signup()); $browser->assertVisible('@step0') ->assertMissing('@step1') ->assertMissing('@step2') ->assertMissing('@step3'); $browser->within(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login', 'lang'], 'signup'); }); $browser->waitFor('@step0 .plan-selector > .card'); // Assert first plan box and press the button $browser->with('@step0 .plan-selector > .plan-individual', function ($step) { $step->assertVisible('button') ->assertSeeIn('button', 'Individual Account') ->assertVisible('.plan-description') ->click('button'); }); $browser->waitForLocation('/signup/individual') ->assertVisible('@step1') ->assertMissing('@step0') ->assertMissing('@step2') ->assertMissing('@step3') ->assertFocused('@step1 #signup_first_name'); // Click Back button $browser->click('@step1 [type=button]') ->waitForLocation('/signup') ->assertVisible('@step0') ->assertMissing('@step1') ->assertMissing('@step2') ->assertMissing('@step3'); // Choose the group account plan $browser->click('@step0 .plan-selector > .plan-group button') ->waitForLocation('/signup/group') ->assertVisible('@step1') ->assertMissing('@step0') ->assertMissing('@step2') ->assertMissing('@step3') ->assertFocused('@step1 #signup_first_name'); // TODO: Test if 'plan' variable is set properly in vue component }); } /** * Test 1st step of the signup process */ public function testSignupStep1(): void { $this->browse(function (Browser $browser) { $browser->visit('/signup/individual') ->onWithoutAssert(new Signup()); // Here we expect two text inputs and Back and Continue buttons $browser->with('@step1', function ($step) { $step->assertVisible('#signup_last_name') ->assertVisible('#signup_first_name') ->assertFocused('#signup_first_name') ->assertVisible('#signup_email') ->assertVisible('[type=button]') ->assertVisible('[type=submit]'); }); // Submit empty form // Email is required, so after pressing Submit // we expect focus to be moved to the email input $browser->with('@step1', function ($step) { $step->click('[type=submit]'); $step->assertFocused('#signup_email'); }); $browser->within(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login', 'lang'], 'signup'); }); // Submit invalid email, and first_name // We expect both inputs to have is-invalid class added, with .invalid-feedback element $browser->with('@step1', function ($step) { $step->type('#signup_first_name', str_repeat('a', 250)) ->type('#signup_email', '@test') ->click('[type=submit]') ->waitFor('#signup_email.is-invalid') ->assertVisible('#signup_first_name.is-invalid') ->assertVisible('#signup_email + .invalid-feedback') ->assertVisible('#signup_last_name + .invalid-feedback') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); }); // Submit valid data // We expect error state on email input to be removed, and Step 2 form visible $browser->with('@step1', function ($step) { $step->type('#signup_first_name', 'Test') ->type('#signup_last_name', 'User') ->type('#signup_email', 'BrowserSignupTestUser1@kolab.org') ->click('[type=submit]') ->assertMissing('#signup_email.is-invalid') ->assertMissing('#signup_email + .invalid-feedback'); }); $browser->waitUntilMissing('@step2 #signup_code[value=""]'); $browser->waitFor('@step2'); $browser->assertMissing('@step1'); }); } /** * Test 2nd Step of the signup process * * @depends testSignupStep1 */ public function testSignupStep2(): void { $this->browse(function (Browser $browser) { $browser->assertVisible('@step2') ->assertMissing('@step0') ->assertMissing('@step1') ->assertMissing('@step3'); // Here we expect one text input, Back and Continue buttons $browser->with('@step2', function ($step) { $step->assertVisible('#signup_short_code') ->assertFocused('#signup_short_code') ->assertVisible('[type=button]') ->assertVisible('[type=submit]'); }); // Test Back button functionality $browser->click('@step2 [type=button]') ->waitFor('@step1') ->assertFocused('@step1 #signup_first_name') ->assertMissing('@step2'); // Submit valid Step 1 data (again) $browser->with('@step1', function ($step) { $step->type('#signup_first_name', 'User') ->type('#signup_last_name', 'User') ->type('#signup_email', 'BrowserSignupTestUser1@kolab.org') ->click('[type=submit]'); }); $browser->waitFor('@step2'); $browser->assertMissing('@step1'); // Submit invalid code // We expect code input to have is-invalid class added, with .invalid-feedback element $browser->with('@step2', function ($step) { $step->type('#signup_short_code', 'XXXXX'); $step->click('[type=submit]'); $step->waitFor('#signup_short_code.is-invalid') ->assertVisible('#signup_short_code + .invalid-feedback') ->assertFocused('#signup_short_code') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); }); // Submit valid code // We expect error state on code input to be removed, and Step 3 form visible $browser->with('@step2', function ($step) { // Get the code and short_code from database // FIXME: Find a nice way to read javascript data without using hidden inputs $code = $step->value('#signup_code'); $this->assertNotEmpty($code); $code = SignupCode::find($code); $step->type('#signup_short_code', $code->short_code); $step->click('[type=submit]'); $step->assertMissing('#signup_short_code.is-invalid'); $step->assertMissing('#signup_short_code + .invalid-feedback'); }); $browser->waitFor('@step3'); $browser->assertMissing('@step2'); }); } /** * Test 3rd Step of the signup process * * @depends testSignupStep2 */ public function testSignupStep3(): void { $this->browse(function (Browser $browser) { $browser->assertVisible('@step3'); // Here we expect 3 text inputs, Back and Continue buttons $browser->with('@step3', function ($step) { - $step->assertVisible('#signup_login'); - $step->assertVisible('#signup_password'); - $step->assertVisible('#signup_confirm'); - $step->assertVisible('select#signup_domain'); - $step->assertVisible('[type=button]'); - $step->assertVisible('[type=submit]'); - $step->assertFocused('#signup_login'); - $step->assertValue('select#signup_domain', \config('app.domain')); - $step->assertValue('#signup_login', ''); - $step->assertValue('#signup_password', ''); - $step->assertValue('#signup_confirm', ''); + $domains_count = count(Domain::getPublicDomains()); + + $step->assertSeeIn('.card-title', 'Sign Up - Step 3/3') + ->assertMissing('#signup_last_name') + ->assertMissing('#signup_first_name') + ->assertVisible('#signup_login') + ->assertVisible('#signup_password') + ->assertVisible('#signup_confirm') + ->assertVisible('select#signup_domain') + ->assertElementsCount('select#signup_domain option', $domains_count, false) + ->assertVisible('[type=button]') + ->assertVisible('[type=submit]') + ->assertSeeIn('[type=submit]', 'Submit') + ->assertFocused('#signup_login') + ->assertValue('select#signup_domain', \config('app.domain')) + ->assertValue('#signup_login', '') + ->assertValue('#signup_password', '') + ->assertValue('#signup_confirm', ''); // TODO: Test domain selector }); // Test Back button $browser->click('@step3 [type=button]'); $browser->waitFor('@step2'); $browser->assertFocused('@step2 #signup_short_code'); $browser->assertMissing('@step3'); // TODO: Test form reset when going back // Submit valid code again $browser->with('@step2', function ($step) { $code = $step->value('#signup_code'); $this->assertNotEmpty($code); $code = SignupCode::find($code); $step->type('#signup_short_code', $code->short_code); $step->click('[type=submit]'); }); $browser->waitFor('@step3'); // Submit invalid data $browser->with('@step3', function ($step) { $step->assertFocused('#signup_login') ->type('#signup_login', '*') ->type('#signup_password', '12345678') ->type('#signup_confirm', '123456789') ->click('[type=submit]') ->waitFor('#signup_login.is-invalid') ->assertVisible('#signup_domain + .invalid-feedback') ->assertVisible('#signup_password.is-invalid') ->assertVisible('#signup_password + .invalid-feedback') ->assertFocused('#signup_login') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); }); // Submit invalid data (valid login, invalid password) $browser->with('@step3', function ($step) { $step->type('#signup_login', 'SignupTestDusk') ->click('[type=submit]') ->waitFor('#signup_password.is-invalid') ->assertVisible('#signup_password + .invalid-feedback') ->assertMissing('#signup_login.is-invalid') ->assertMissing('#signup_domain + .invalid-feedback') ->assertFocused('#signup_password') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); }); // Submit valid data $browser->with('@step3', function ($step) { $step->type('#signup_confirm', '12345678'); $step->click('[type=submit]'); }); // At this point we should be auto-logged-in to dashboard $browser->waitUntilMissing('@step3') ->waitUntilMissing('.app-loader') ->on(new Dashboard()) ->assertUser('signuptestdusk@' . \config('app.domain')) ->assertVisible('@links a.link-profile') ->assertMissing('@links a.link-domains') ->assertVisible('@links a.link-users') ->assertVisible('@links a.link-wallet'); // Logout the user $browser->within(new Menu(), function ($browser) { $browser->clickMenuItem('logout'); }); }); } /** * Test signup for a group account */ public function testSignupGroup(): void { $this->browse(function (Browser $browser) { $browser->visit(new Signup()); // Choose the group account plan $browser->waitFor('@step0 .plan-group button') ->click('@step0 .plan-group button'); // Submit valid data // We expect error state on email input to be removed, and Step 2 form visible $browser->whenAvailable('@step1', function ($step) { $step->type('#signup_first_name', 'Test') ->type('#signup_last_name', 'User') ->type('#signup_email', 'BrowserSignupTestUser1@kolab.org') ->click('[type=submit]'); }); // Submit valid code $browser->whenAvailable('@step2', function ($step) { // Get the code and short_code from database // FIXME: Find a nice way to read javascript data without using hidden inputs $code = $step->value('#signup_code'); $code = SignupCode::find($code); $step->type('#signup_short_code', $code->short_code) ->click('[type=submit]'); }); // Here we expect 4 text inputs, Back and Continue buttons $browser->whenAvailable('@step3', function ($step) { $step->assertVisible('#signup_login') ->assertVisible('#signup_password') ->assertVisible('#signup_confirm') ->assertVisible('input#signup_domain') ->assertVisible('[type=button]') ->assertVisible('[type=submit]') ->assertFocused('#signup_login') ->assertValue('input#signup_domain', '') ->assertValue('#signup_login', '') ->assertValue('#signup_password', '') ->assertValue('#signup_confirm', ''); }); // Submit invalid login and password data $browser->with('@step3', function ($step) { $step->assertFocused('#signup_login') ->type('#signup_login', '*') ->type('#signup_domain', 'test.com') ->type('#signup_password', '12345678') ->type('#signup_confirm', '123456789') ->click('[type=submit]') ->waitFor('#signup_login.is-invalid') ->assertVisible('#signup_domain + .invalid-feedback') ->assertVisible('#signup_password.is-invalid') ->assertVisible('#signup_password + .invalid-feedback') ->assertFocused('#signup_login') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); }); // Submit invalid domain $browser->with('@step3', function ($step) { $step->type('#signup_login', 'admin') ->type('#signup_domain', 'aaa') ->type('#signup_password', '12345678') ->type('#signup_confirm', '12345678') ->click('[type=submit]') ->waitUntilMissing('#signup_login.is-invalid') ->waitFor('#signup_domain.is-invalid + .invalid-feedback') ->assertMissing('#signup_password.is-invalid') ->assertMissing('#signup_password + .invalid-feedback') ->assertFocused('#signup_domain') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); }); // Submit invalid domain $browser->with('@step3', function ($step) { $step->type('#signup_domain', 'user-domain-signup.com') ->click('[type=submit]'); }); // At this point we should be auto-logged-in to dashboard $browser->waitUntilMissing('@step3') ->waitUntilMissing('.app-loader') ->on(new Dashboard()) ->assertUser('admin@user-domain-signup.com') ->assertVisible('@links a.link-profile') ->assertVisible('@links a.link-domains') ->assertVisible('@links a.link-users') ->assertVisible('@links a.link-wallet'); $browser->within(new Menu(), function ($browser) { $browser->clickMenuItem('logout'); }); }); } /** * Test signup with voucher */ public function testSignupVoucherLink(): void { $this->browse(function (Browser $browser) { $browser->visit('/signup/voucher/TEST') ->onWithoutAssert(new Signup()) ->waitUntilMissing('.app-loader') ->waitFor('@step0') ->click('.plan-individual button') ->whenAvailable('@step1', function (Browser $browser) { $browser->type('#signup_first_name', 'Test') ->type('#signup_last_name', 'User') ->type('#signup_email', 'BrowserSignupTestUser1@kolab.org') ->click('[type=submit]'); }) ->whenAvailable('@step2', function (Browser $browser) { // Get the code and short_code from database // FIXME: Find a nice way to read javascript data without using hidden inputs $code = $browser->value('#signup_code'); $this->assertNotEmpty($code); $code = SignupCode::find($code); $browser->type('#signup_short_code', $code->short_code) ->click('[type=submit]'); }) ->whenAvailable('@step3', function (Browser $browser) { // Assert that the code is filled in the input // Change it and test error handling $browser->assertValue('#signup_voucher', 'TEST') ->type('#signup_voucher', 'TESTXX') ->type('#signup_login', 'signuptestdusk') ->type('#signup_password', '123456789') ->type('#signup_confirm', '123456789') ->click('[type=submit]') ->waitFor('#signup_voucher.is-invalid') ->assertVisible('#signup_voucher + .invalid-feedback') ->assertFocused('#signup_voucher') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') // Submit the correct code ->type('#signup_voucher', 'TEST') ->click('[type=submit]'); }) ->waitUntilMissing('@step3') ->waitUntilMissing('.app-loader') ->on(new Dashboard()) ->assertUser('signuptestdusk@' . \config('app.domain')) // Logout the user ->within(new Menu(), function ($browser) { $browser->clickMenuItem('logout'); }); }); $user = $this->getTestUser('signuptestdusk@' . \config('app.domain')); $discount = Discount::where('code', 'TEST')->first(); $this->assertSame($discount->id, $user->wallets()->first()->discount_id); } + + /** + * Test signup via invitation link + */ + public function testSignupInvitation(): void + { + // Test non-existing invitation + $this->browse(function (Browser $browser) { + $browser->visit('/signup/invite/TEST') + ->onWithoutAssert(new Signup()) + ->waitFor('#app > #error-page') + ->assertErrorPage(404); + }); + + $invitation = SignupInvitation::create(['email' => 'test@domain.org']); + + $this->browse(function (Browser $browser) use ($invitation) { + $browser->visit('/signup/invite/' . $invitation->id) + ->onWithoutAssert(new Signup()) + ->waitUntilMissing('.app-loader') + ->with('@step3', function ($step) { + $domains_count = count(Domain::getPublicDomains()); + + $step->assertMissing('.card-title') + ->assertVisible('#signup_last_name') + ->assertVisible('#signup_first_name') + ->assertVisible('#signup_login') + ->assertVisible('#signup_password') + ->assertVisible('#signup_confirm') + ->assertVisible('select#signup_domain') + ->assertElementsCount('select#signup_domain option', $domains_count, false) + ->assertVisible('[type=submit]') + ->assertMissing('[type=button]') // Back button + ->assertSeeIn('[type=submit]', 'Sign Up') + ->assertFocused('#signup_first_name') + ->assertValue('select#signup_domain', \config('app.domain')) + ->assertValue('#signup_first_name', '') + ->assertValue('#signup_last_name', '') + ->assertValue('#signup_login', '') + ->assertValue('#signup_password', '') + ->assertValue('#signup_confirm', ''); + + // Submit invalid data + $step->type('#signup_login', '*') + ->type('#signup_password', '12345678') + ->type('#signup_confirm', '123456789') + ->click('[type=submit]') + ->waitFor('#signup_login.is-invalid') + ->assertVisible('#signup_domain + .invalid-feedback') + ->assertVisible('#signup_password.is-invalid') + ->assertVisible('#signup_password + .invalid-feedback') + ->assertFocused('#signup_login') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); + + // Submit valid data + $step->type('#signup_confirm', '12345678') + ->type('#signup_login', 'signuptestdusk') + ->type('#signup_first_name', 'First') + ->type('#signup_last_name', 'Last') + ->click('[type=submit]'); + }) + // At this point we should be auto-logged-in to dashboard + ->waitUntilMissing('@step3') + ->waitUntilMissing('.app-loader') + ->on(new Dashboard()) + ->assertUser('signuptestdusk@' . \config('app.domain')) + // Logout the user + ->within(new Menu(), function ($browser) { + $browser->clickMenuItem('logout'); + }); + }); + + $invitation->refresh(); + $user = User::where('email', 'signuptestdusk@' . \config('app.domain'))->first(); + + $this->assertTrue($invitation->isCompleted()); + $this->assertSame($user->id, $invitation->user_id); + $this->assertSame('First', $user->getSetting('first_name')); + $this->assertSame('Last', $user->getSetting('last_name')); + $this->assertSame($invitation->email, $user->getSetting('external_email')); + } } diff --git a/src/tests/Feature/Console/DiscountListTest.php b/src/tests/Feature/Console/DiscountsTest.php similarity index 70% rename from src/tests/Feature/Console/DiscountListTest.php rename to src/tests/Feature/Console/DiscountsTest.php index 57390b21..13f5f698 100644 --- a/src/tests/Feature/Console/DiscountListTest.php +++ b/src/tests/Feature/Console/DiscountsTest.php @@ -1,16 +1,16 @@ artisan('discount:list') + $this->artisan('discounts') ->assertExitCode(0); $this->markTestIncomplete(); } } diff --git a/src/tests/Feature/Controller/Admin/DomainsTest.php b/src/tests/Feature/Controller/Admin/DomainsTest.php index 5dcaa7a8..6437a5c5 100644 --- a/src/tests/Feature/Controller/Admin/DomainsTest.php +++ b/src/tests/Feature/Controller/Admin/DomainsTest.php @@ -1,159 +1,223 @@ deleteTestDomain('domainscontroller.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $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 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 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'); $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'); $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 2e886d16..febdb014 100644 --- a/src/tests/Feature/Controller/Admin/GroupsTest.php +++ b/src/tests/Feature/Controller/Admin/GroupsTest.php @@ -1,184 +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'); + $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 new file mode 100644 index 00000000..98d0ce84 --- /dev/null +++ b/src/tests/Feature/Controller/Admin/SkusTest.php @@ -0,0 +1,94 @@ +clearBetaEntitlements(); + $this->clearMeetEntitlements(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->clearBetaEntitlements(); + $this->clearMeetEntitlements(); + + parent::tearDown(); + } + + /** + * Test fetching SKUs list + */ + public function testIndex(): void + { + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $user = $this->getTestUser('john@kolab.org'); + $sku = Sku::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); + // Note: Details are tested where we test API\V4\SkusController + } +} diff --git a/src/tests/Feature/Controller/Admin/UsersTest.php b/src/tests/Feature/Controller/Admin/UsersTest.php index ad995165..74aec7f5 100644 --- a/src/tests/Feature/Controller/Admin/UsersTest.php +++ b/src/tests/Feature/Controller/Admin/UsersTest.php @@ -1,355 +1,385 @@ deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestUser('test@testsearch.com'); $this->deleteTestDomain('testsearch.com'); $this->deleteTestGroup('group-test@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $jack->setSetting('external_email', null); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestUser('test@testsearch.com'); $this->deleteTestDomain('testsearch.com'); $jack = $this->getTestUser('jack@kolab.org'); $jack->setSetting('external_email', null); parent::tearDown(); } + /** + * Test user deleting (DELETE /api/v4/users/) + */ + public function testDestroy(): void + { + $john = $this->getTestUser('john@kolab.org'); + $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + + // Test unauth access + $response = $this->delete("api/v4/users/{$user->id}"); + $response->assertStatus(401); + + // The end-point does not exist + $response = $this->actingAs($admin)->delete("api/v4/users/{$user->id}"); + $response->assertStatus(404); + } + /** * Test users searching (/api/v4/users) */ 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/users"); $response->assertStatus(403); // Search with no search criteria $response = $this->actingAs($admin)->get("api/v4/users"); $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/users?search=abcd1234efgh5678"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search by domain $response = $this->actingAs($admin)->get("api/v4/users?search=kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); // Search by user ID $response = $this->actingAs($admin)->get("api/v4/users?search={$user->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); // Search by email (primary) $response = $this->actingAs($admin)->get("api/v4/users?search=john@kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); // Search by email (alias) $response = $this->actingAs($admin)->get("api/v4/users?search=john.doe@kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); // Search by email (external), expect two users in a result $jack = $this->getTestUser('jack@kolab.org'); $jack->setSetting('external_email', 'john.doe.external@gmail.com'); $response = $this->actingAs($admin)->get("api/v4/users?search=john.doe.external@gmail.com"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(2, $json['count']); $this->assertCount(2, $json['list']); $emails = array_column($json['list'], 'email'); $this->assertContains($user->email, $emails); $this->assertContains($jack->email, $emails); // Search by owner $response = $this->actingAs($admin)->get("api/v4/users?owner={$user->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(4, $json['count']); $this->assertCount(4, $json['list']); // Search by owner (Ned is a controller on John's wallets, // here we expect only users assigned to Ned's wallet(s)) $ned = $this->getTestUser('ned@kolab.org'); $response = $this->actingAs($admin)->get("api/v4/users?owner={$ned->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertCount(0, $json['list']); // Search by distribution list email $response = $this->actingAs($admin)->get("api/v4/users?search=group-test@kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); // Deleted users/domains $domain = $this->getTestDomain('testsearch.com', ['type' => \App\Domain::TYPE_EXTERNAL]); $user = $this->getTestUser('test@testsearch.com'); $plan = \App\Plan::where('title', 'group')->first(); $user->assignPlan($plan, $domain); $user->setAliases(['alias@testsearch.com']); Queue::fake(); $user->delete(); $response = $this->actingAs($admin)->get("api/v4/users?search=test@testsearch.com"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); $this->assertTrue($json['list'][0]['isDeleted']); $response = $this->actingAs($admin)->get("api/v4/users?search=alias@testsearch.com"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); $this->assertTrue($json['list'][0]['isDeleted']); $response = $this->actingAs($admin)->get("api/v4/users?search=testsearch.com"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); $this->assertTrue($json['list'][0]['isDeleted']); } /** * Test reseting 2FA (POST /api/v4/users//reset2FA) */ public function testReset2FA(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $sku2fa = Sku::firstOrCreate(['title' => '2fa']); $user->assignSku($sku2fa); SecondFactor::seed('userscontrollertest1@userscontroller.com'); // Test unauthorized access to admin API $response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/reset2FA", []); $response->assertStatus(403); $entitlements = $user->fresh()->entitlements()->where('sku_id', $sku2fa->id)->get(); $this->assertCount(1, $entitlements); $sf = new SecondFactor($user); $this->assertCount(1, $sf->factors()); // Test reseting 2FA $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/reset2FA", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("2-Factor authentication reset successfully.", $json['message']); $this->assertCount(2, $json); $entitlements = $user->fresh()->entitlements()->where('sku_id', $sku2fa->id)->get(); $this->assertCount(0, $entitlements); $sf = new SecondFactor($user); $this->assertCount(0, $sf->factors()); } + /** + * Test user creation (POST /api/v4/users) + */ + public function testStore(): void + { + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + + // The end-point does not exist + $response = $this->actingAs($admin)->post("/api/v4/users", []); + $response->assertStatus(404); + } + /** * Test user suspending (POST /api/v4/users//suspend) */ public function testSuspend(): void { Queue::fake(); // disable jobs $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // Test unauthorized access to admin API $response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/suspend", []); $response->assertStatus(403); $this->assertFalse($user->isSuspended()); // Test suspending the user $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/suspend", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("User suspended successfully.", $json['message']); $this->assertCount(2, $json); $this->assertTrue($user->fresh()->isSuspended()); } /** * Test user un-suspending (POST /api/v4/users//unsuspend) */ public function testUnsuspend(): void { Queue::fake(); // disable jobs $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // Test unauthorized access to admin API $response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/unsuspend", []); $response->assertStatus(403); $this->assertFalse($user->isSuspended()); $user->suspend(); $this->assertTrue($user->isSuspended()); // Test suspending the user $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/unsuspend", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("User unsuspended successfully.", $json['message']); $this->assertCount(2, $json); $this->assertFalse($user->fresh()->isSuspended()); } /** * Test user update (PUT /api/v4/users/) */ public function testUpdate(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // Test unauthorized access to admin API $response = $this->actingAs($user)->put("/api/v4/users/{$user->id}", []); $response->assertStatus(403); // Test updatig the user data (empty data) $response = $this->actingAs($admin)->put("/api/v4/users/{$user->id}", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully.", $json['message']); $this->assertCount(2, $json); // Test error handling $post = ['external_email' => 'aaa']; $response = $this->actingAs($admin)->put("/api/v4/users/{$user->id}", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame("The external email must be a valid email address.", $json['errors']['external_email'][0]); $this->assertCount(2, $json); // Test real update $post = ['external_email' => 'modified@test.com']; $response = $this->actingAs($admin)->put("/api/v4/users/{$user->id}", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully.", $json['message']); $this->assertCount(2, $json); $this->assertSame('modified@test.com', $user->getSetting('external_email')); } } diff --git a/src/tests/Feature/Controller/Admin/WalletsTest.php b/src/tests/Feature/Controller/Admin/WalletsTest.php index 5b8c4fb6..b1a37af0 100644 --- a/src/tests/Feature/Controller/Admin/WalletsTest.php +++ b/src/tests/Feature/Controller/Admin/WalletsTest.php @@ -1,228 +1,234 @@ 'stripe']); $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $wallet = $user->wallets()->first(); $wallet->discount_id = null; $wallet->save(); // Make sure there's no stripe/mollie identifiers $wallet->setSetting('stripe_id', null); $wallet->setSetting('stripe_mandate_id', null); $wallet->setSetting('mollie_id', null); $wallet->setSetting('mollie_mandate_id', null); // Non-admin user $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}"); $response->assertStatus(403); // Admin user $response = $this->actingAs($admin)->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->assertSame(0, $json['discount']); $this->assertTrue(empty($json['description'])); $this->assertTrue(empty($json['discount_description'])); $this->assertTrue(!empty($json['provider'])); $this->assertTrue(empty($json['providerLink'])); $this->assertTrue(!empty($json['mandate'])); } /** * Test awarding/penalizing a wallet (POST /api/v4/wallets/:id/one-off) */ public function testOneOff(): void { $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $wallet = $user->wallets()->first(); $balance = $wallet->balance; + $reseller = $this->getTestUser('reseller@kolabnow.com'); + $wallet = $user->wallets()->first(); + $reseller_wallet = $reseller->wallets()->first(); + $reseller_balance = $reseller_wallet->balance; Transaction::where('object_id', $wallet->id) ->whereIn('type', [Transaction::WALLET_AWARD, Transaction::WALLET_PENALTY]) ->delete(); // Non-admin user $response = $this->actingAs($user)->post("api/v4/wallets/{$wallet->id}/one-off", []); $response->assertStatus(403); // Admin user - invalid input $post = ['amount' => 'aaaa']; $response = $this->actingAs($admin)->post("api/v4/wallets/{$wallet->id}/one-off", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame('The amount must be a number.', $json['errors']['amount'][0]); $this->assertSame('The description field is required.', $json['errors']['description'][0]); $this->assertCount(2, $json); $this->assertCount(2, $json['errors']); // Admin user - a valid bonus $post = ['amount' => '50', 'description' => 'A bonus']; $response = $this->actingAs($admin)->post("api/v4/wallets/{$wallet->id}/one-off", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('The bonus has been added to the wallet successfully.', $json['message']); $this->assertSame($balance += 5000, $json['balance']); $this->assertSame($balance, $wallet->fresh()->balance); + $this->assertSame($reseller_balance, $reseller_wallet->fresh()->balance); $transaction = Transaction::where('object_id', $wallet->id) ->where('type', Transaction::WALLET_AWARD)->first(); $this->assertSame($post['description'], $transaction->description); $this->assertSame(5000, $transaction->amount); $this->assertSame($admin->email, $transaction->user_email); // Admin user - a valid penalty $post = ['amount' => '-40', 'description' => 'A penalty']; $response = $this->actingAs($admin)->post("api/v4/wallets/{$wallet->id}/one-off", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('The penalty has been added to the wallet successfully.', $json['message']); $this->assertSame($balance -= 4000, $json['balance']); $this->assertSame($balance, $wallet->fresh()->balance); + $this->assertSame($reseller_balance, $reseller_wallet->fresh()->balance); $transaction = Transaction::where('object_id', $wallet->id) ->where('type', Transaction::WALLET_PENALTY)->first(); $this->assertSame($post['description'], $transaction->description); $this->assertSame(-4000, $transaction->amount); $this->assertSame($admin->email, $transaction->user_email); } /** * Test fetching wallet transactions (GET /api/v4/wallets/:id/transactions) */ public function testTransactions(): void { // Note: Here we're testing only that the end-point works, // and admin can get the transaction log, response details // are tested in Feature/Controller/WalletsTest.php $this->deleteTestUser('wallets-controller@kolabnow.com'); $user = $this->getTestUser('wallets-controller@kolabnow.com'); $wallet = $user->wallets()->first(); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // Non-admin $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(403); // Create some sample transactions $transactions = $this->createTestTransactions($wallet); $transactions = array_reverse($transactions); $pages = array_chunk($transactions, 10 /* page size*/); // Get the 2nd page $response = $this->actingAs($admin)->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->assertFalse($json['list'][$idx]['hasDetails']); } // The 'user' key is set only on the admin end-point $this->assertSame('jeroen@jeroen.jeroen', $json['list'][1]['user']); } /** * Test updating a wallet (PUT /api/v4/wallets/:id) */ public function testUpdate(): void { $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $wallet = $user->wallets()->first(); $discount = Discount::where('code', 'TEST')->first(); // Non-admin user $response = $this->actingAs($user)->put("api/v4/wallets/{$wallet->id}", []); $response->assertStatus(403); // Admin user - setting a discount $post = ['discount' => $discount->id]; $response = $this->actingAs($admin)->put("api/v4/wallets/{$wallet->id}", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('User wallet updated successfully.', $json['message']); $this->assertSame($wallet->id, $json['id']); $this->assertSame($discount->discount, $json['discount']); $this->assertSame($discount->id, $json['discount_id']); $this->assertSame($discount->description, $json['discount_description']); $this->assertSame($discount->id, $wallet->fresh()->discount->id); // Admin user - removing a discount $post = ['discount' => null]; $response = $this->actingAs($admin)->put("api/v4/wallets/{$wallet->id}", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('User wallet updated successfully.', $json['message']); $this->assertSame($wallet->id, $json['id']); $this->assertSame(null, $json['discount_id']); $this->assertTrue(empty($json['discount_description'])); $this->assertSame(null, $wallet->fresh()->discount); } } diff --git a/src/tests/Feature/Controller/Reseller/DiscountsTest.php b/src/tests/Feature/Controller/Reseller/DiscountsTest.php new file mode 100644 index 00000000..b56f0f4b --- /dev/null +++ b/src/tests/Feature/Controller/Reseller/DiscountsTest.php @@ -0,0 +1,107 @@ +first(); + $tenant->discounts()->delete(); + + self::useResellerUrl(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + \config(['app.tenant_id' => 1]); + + $tenant = Tenant::where('title', 'Sample Tenant')->first(); + $tenant->discounts()->delete(); + + parent::tearDown(); + } + + /** + * Test listing discounts (/api/v4/discounts) + */ + public function testIndex(): void + { + $user = $this->getTestUser('john@kolab.org'); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $reseller = $this->getTestUser('reseller@reseller.com'); + $reseller2 = $this->getTestUser('reseller@kolabnow.com'); + $tenant = Tenant::where('title', 'Sample Tenant')->first(); + + \config(['app.tenant_id' => $tenant->id]); + + // Non-admin user + $response = $this->actingAs($user)->get("api/v4/discounts"); + $response->assertStatus(403); + + // Admin user + $response = $this->actingAs($admin)->get("api/v4/discounts"); + $response->assertStatus(403); + + // Reseller user, but different tenant + $response = $this->actingAs($reseller2)->get("api/v4/discounts"); + $response->assertStatus(403); + + // Reseller (empty list) + $response = $this->actingAs($reseller)->get("api/v4/discounts"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(0, $json['count']); + + // Add some discounts + $discount_test = Discount::create([ + 'description' => 'Test reseller voucher', + 'code' => 'RESELLER-TEST', + 'discount' => 10, + 'active' => true, + ]); + + $discount_free = Discount::create([ + 'description' => 'Free account', + 'discount' => 100, + 'active' => true, + ]); + + $discount_test->tenant_id = $tenant->id; + $discount_test->save(); + $discount_free->tenant_id = $tenant->id; + $discount_free->save(); + + $response = $this->actingAs($reseller)->get("api/v4/discounts"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(2, $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 reseller voucher [RESELLER-TEST]', $json['list'][0]['label']); + + $this->assertSame($discount_free->id, $json['list'][1]['id']); + $this->assertSame($discount_free->discount, $json['list'][1]['discount']); + $this->assertSame($discount_free->code, $json['list'][1]['code']); + $this->assertSame($discount_free->description, $json['list'][1]['description']); + $this->assertSame('100% - Free account', $json['list'][1]['label']); + } +} diff --git a/src/tests/Feature/Controller/Reseller/DomainsTest.php b/src/tests/Feature/Controller/Reseller/DomainsTest.php new file mode 100644 index 00000000..b5972346 --- /dev/null +++ b/src/tests/Feature/Controller/Reseller/DomainsTest.php @@ -0,0 +1,310 @@ + 1]); + + $this->deleteTestDomain('domainscontroller.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + \config(['app.tenant_id' => 1]); + $this->deleteTestDomain('domainscontroller.com'); + + parent::tearDown(); + } + + /** + * Test domain confirm request + */ + public function testConfirm(): void + { + $reseller1 = $this->getTestUser('reseller@kolabnow.com'); + $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 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@kolabnow.com'); + $reseller2 = $this->getTestUser('reseller@reseller.com'); + + // 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); + + // Reseller from a different tenant + $response = $this->actingAs($reseller2)->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 + \config(['app.tenant_id' => 2]); + + $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::where('title', 'domain-hosting')->first(); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $user = $this->getTestUser('test1@domainscontroller.com'); + $reseller1 = $this->getTestUser('reseller@kolabnow.com'); + $reseller2 = $this->getTestUser('reseller@reseller.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 + ]); + + // 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(403); + + $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 + + // Unauthorized access (other domain's tenant) + \config(['app.tenant_id' => 2]); + $response = $this->actingAs($reseller2)->get("api/v4/domains/{$domain->id}"); + $response->assertStatus(404); + } + + /** + * Test fetching domain status (GET /api/v4/domains//status) + */ + public function testStatus(): void + { + $reseller1 = $this->getTestUser('reseller@kolabnow.com'); + $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 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@kolabnow.com'); + $reseller2 = $this->getTestUser('reseller@reseller.com'); + + \config(['app.tenant_id' => 2]); + + $domain = $this->getTestDomain('domainscontroller.com', [ + 'status' => Domain::STATUS_NEW, + 'type' => Domain::TYPE_EXTERNAL, + ]); + $user = $this->getTestUser('test@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(403); + + $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 authenticated reseller, but domain belongs to another tenant + \config(['app.tenant_id' => 1]); + $response = $this->actingAs($reseller1)->post("/api/v4/domains/{$domain->id}/suspend", []); + $response->assertStatus(404); + } + + /** + * 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@kolabnow.com'); + $reseller2 = $this->getTestUser('reseller@reseller.com'); + + \config(['app.tenant_id' => 2]); + + $domain = $this->getTestDomain('domainscontroller.com', [ + 'status' => Domain::STATUS_NEW | Domain::STATUS_SUSPENDED, + 'type' => Domain::TYPE_EXTERNAL, + ]); + $user = $this->getTestUser('test@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(403); + + $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()); + + // Test unauthorized access to reseller API (another tenant) + \config(['app.tenant_id' => 1]); + $response = $this->actingAs($reseller1)->post("/api/v4/domains/{$domain->id}/unsuspend", []); + $response->assertStatus(404); + } +} diff --git a/src/tests/Feature/Controller/Admin/GroupsTest.php b/src/tests/Feature/Controller/Reseller/GroupsTest.php similarity index 50% copy from src/tests/Feature/Controller/Admin/GroupsTest.php copy to src/tests/Feature/Controller/Reseller/GroupsTest.php index 2e886d16..f3103f04 100644 --- a/src/tests/Feature/Controller/Admin/GroupsTest.php +++ b/src/tests/Feature/Controller/Reseller/GroupsTest.php @@ -1,184 +1,291 @@ 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@kolabnow.com'); + $reseller2 = $this->getTestUser('reseller@reseller.com'); $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 + // Admin user $response = $this->actingAs($admin)->get("api/v4/groups"); + $response->assertStatus(403); + + // Reseller from a different tenant + $response = $this->actingAs($reseller2)->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($admin)->get("api/v4/groups?search=john@kolab.org"); + $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($admin)->get("api/v4/groups?search={$group->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($admin)->get("api/v4/groups?owner={$user->id}"); + $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($admin)->get("api/v4/groups?owner={$ned->id}"); + $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']); + + // Test unauth access to other tenant's groups + \config(['app.tenant_id' => 2]); + + $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'); + $reseller1 = $this->getTestUser('reseller@kolabnow.com'); + $reseller2 = $this->getTestUser('reseller@reseller.com'); + $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(403); + + $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@kolabnow.com'); + $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@kolabnow.com'); - // Test unauthorized access to admin API + // Test unauthorized access to reseller API $response = $this->actingAs($user)->post("/api/v4/groups", []); $response->assertStatus(403); - // Admin can't create groups + // 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@kolabnow.com'); + $reseller2 = $this->getTestUser('reseller@reseller.com'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($user->wallets->first()); - // Test unauthorized access to admin API + // 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($admin)->post("/api/v4/groups/abc/suspend", []); + $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($admin)->post("/api/v4/groups/{$group->id}/suspend", []); + $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()); + + // Test unauth access to other tenant's groups + \config(['app.tenant_id' => 2]); + + $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@kolabnow.com'); + $reseller2 = $this->getTestUser('reseller@reseller.com'); $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 + // 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($admin)->post("/api/v4/groups/abc/unsuspend", []); + $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($admin)->post("/api/v4/groups/{$group->id}/unsuspend", []); + $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()); + + // Test unauth access to other tenant's groups + \config(['app.tenant_id' => 2]); + + $response = $this->actingAs($reseller2)->post("/api/v4/groups/{$group->id}/unsuspend", []); + $response->assertStatus(404); } } diff --git a/src/tests/Feature/Controller/Reseller/InvitationsTest.php b/src/tests/Feature/Controller/Reseller/InvitationsTest.php new file mode 100644 index 00000000..f3e88290 --- /dev/null +++ b/src/tests/Feature/Controller/Reseller/InvitationsTest.php @@ -0,0 +1,350 @@ + 1]); + + self::useResellerUrl(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + SignupInvitation::truncate(); + + \config(['app.tenant_id' => 1]); + + parent::tearDown(); + } + + /** + * Test deleting invitations (DELETE /api/v4/invitations/) + */ + public function testDestroy(): void + { + Queue::fake(); + + $user = $this->getTestUser('john@kolab.org'); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $reseller = $this->getTestUser('reseller@reseller.com'); + $reseller2 = $this->getTestUser('reseller@kolabnow.com'); + $tenant = Tenant::where('title', 'Sample Tenant')->first(); + + \config(['app.tenant_id' => $tenant->id]); + + $inv = SignupInvitation::create(['email' => 'email1@ext.com']); + + // Non-admin user + $response = $this->actingAs($user)->delete("api/v4/invitations/{$inv->id}"); + $response->assertStatus(403); + + // Admin user + $response = $this->actingAs($admin)->delete("api/v4/invitations/{$inv->id}"); + $response->assertStatus(403); + + // Reseller user, but different tenant + $response = $this->actingAs($reseller2)->delete("api/v4/invitations/{$inv->id}"); + $response->assertStatus(403); + + // Reseller - non-existing invitation identifier + $response = $this->actingAs($reseller)->delete("api/v4/invitations/abd"); + $response->assertStatus(404); + + // Reseller - existing invitation + $response = $this->actingAs($reseller)->delete("api/v4/invitations/{$inv->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertSame("Invitation deleted successfully.", $json['message']); + $this->assertSame(null, SignupInvitation::find($inv->id)); + } + + /** + * Test listing invitations (GET /api/v4/invitations) + */ + public function testIndex(): void + { + Queue::fake(); + + $user = $this->getTestUser('john@kolab.org'); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $reseller = $this->getTestUser('reseller@reseller.com'); + $reseller2 = $this->getTestUser('reseller@kolabnow.com'); + $tenant = Tenant::where('title', 'Sample Tenant')->first(); + + \config(['app.tenant_id' => $tenant->id]); + + // Non-admin user + $response = $this->actingAs($user)->get("api/v4/invitations"); + $response->assertStatus(403); + + // Admin user + $response = $this->actingAs($admin)->get("api/v4/invitations"); + $response->assertStatus(403); + + // Reseller user, but different tenant + $response = $this->actingAs($reseller2)->get("api/v4/invitations"); + $response->assertStatus(403); + + // Reseller (empty list) + $response = $this->actingAs($reseller)->get("api/v4/invitations"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(0, $json['count']); + $this->assertSame([], $json['list']); + $this->assertSame(1, $json['page']); + $this->assertFalse($json['hasMore']); + + // Add some invitations + $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']); + $i12 = SignupInvitation::create(['email' => 'email12@test.com']); + $i13 = SignupInvitation::create(['email' => 'email13@ext.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', $i11->id)->update(['created_at' => now()->subDays('3')]); + SignupInvitation::where('id', $i12->id)->update(['tenant_id' => 1]); + SignupInvitation::where('id', $i13->id)->update(['tenant_id' => 1]); + + $response = $this->actingAs($reseller)->get("api/v4/invitations"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(10, $json['count']); + $this->assertSame(1, $json['page']); + $this->assertTrue($json['hasMore']); + $this->assertSame($i1->id, $json['list'][0]['id']); + $this->assertSame($i1->email, $json['list'][0]['email']); + $this->assertSame(true, $json['list'][0]['isFailed']); + $this->assertSame(false, $json['list'][0]['isNew']); + $this->assertSame(false, $json['list'][0]['isSent']); + $this->assertSame(false, $json['list'][0]['isCompleted']); + $this->assertSame($i2->id, $json['list'][1]['id']); + $this->assertSame($i2->email, $json['list'][1]['email']); + $this->assertFalse(in_array($i12->email, array_column($json['list'], 'email'))); + $this->assertFalse(in_array($i13->email, array_column($json['list'], 'email'))); + + $response = $this->actingAs($reseller)->get("api/v4/invitations?page=2"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(1, $json['count']); + $this->assertSame(2, $json['page']); + $this->assertFalse($json['hasMore']); + $this->assertSame($i11->id, $json['list'][0]['id']); + + // Test searching (email address) + $response = $this->actingAs($reseller)->get("api/v4/invitations?search=email3@ext.com"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(1, $json['count']); + $this->assertSame(1, $json['page']); + $this->assertFalse($json['hasMore']); + $this->assertSame($i3->id, $json['list'][0]['id']); + + // Test searching (domain) + $response = $this->actingAs($reseller)->get("api/v4/invitations?search=ext.com"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(3, $json['count']); + $this->assertSame(1, $json['page']); + $this->assertFalse($json['hasMore']); + $this->assertSame($i1->id, $json['list'][0]['id']); + } + + /** + * Test resending invitations (POST /api/v4/invitations//resend) + */ + public function testResend(): void + { + Queue::fake(); + + $user = $this->getTestUser('john@kolab.org'); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $reseller = $this->getTestUser('reseller@reseller.com'); + $reseller2 = $this->getTestUser('reseller@kolabnow.com'); + $tenant = Tenant::where('title', 'Sample Tenant')->first(); + + \config(['app.tenant_id' => $tenant->id]); + + $inv = SignupInvitation::create(['email' => 'email1@ext.com']); + SignupInvitation::where('id', $inv->id)->update(['status' => SignupInvitation::STATUS_FAILED]); + + // Non-admin user + $response = $this->actingAs($user)->post("api/v4/invitations/{$inv->id}/resend"); + $response->assertStatus(403); + + // Admin user + $response = $this->actingAs($admin)->post("api/v4/invitations/{$inv->id}/resend"); + $response->assertStatus(403); + + // Reseller user, but different tenant + $response = $this->actingAs($reseller2)->post("api/v4/invitations/{$inv->id}/resend"); + $response->assertStatus(403); + + // Reseller - non-existing invitation identifier + $response = $this->actingAs($reseller)->post("api/v4/invitations/abd/resend"); + $response->assertStatus(404); + + // Reseller - existing invitation + $response = $this->actingAs($reseller)->post("api/v4/invitations/{$inv->id}/resend"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertSame("Invitation added to the sending queue successfully.", $json['message']); + $this->assertTrue($inv->fresh()->isNew()); + } + + /** + * Test creating invitations (POST /api/v4/invitations) + */ + public function testStore(): void + { + Queue::fake(); + + $user = $this->getTestUser('john@kolab.org'); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $reseller = $this->getTestUser('reseller@reseller.com'); + $reseller2 = $this->getTestUser('reseller@kolabnow.com'); + $tenant = Tenant::where('title', 'Sample Tenant')->first(); + + \config(['app.tenant_id' => $tenant->id]); + + // Non-admin user + $response = $this->actingAs($user)->post("api/v4/invitations", []); + $response->assertStatus(403); + + // Admin user + $response = $this->actingAs($admin)->post("api/v4/invitations", []); + $response->assertStatus(403); + + // Reseller user, but different tenant + $response = $this->actingAs($reseller2)->post("api/v4/invitations", []); + $response->assertStatus(403); + + // Reseller (empty post) + $response = $this->actingAs($reseller)->post("api/v4/invitations", []); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(1, $json['errors']); + $this->assertSame("The email field is required.", $json['errors']['email'][0]); + + // Invalid email address + $post = ['email' => 'test']; + $response = $this->actingAs($reseller)->post("api/v4/invitations", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(1, $json['errors']); + $this->assertSame("The email must be a valid email address.", $json['errors']['email'][0]); + + // Valid email address + $post = ['email' => 'test@external.org']; + $response = $this->actingAs($reseller)->post("api/v4/invitations", $post); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertSame("The invitation has been created.", $json['message']); + $this->assertSame(1, $json['count']); + $this->assertSame(1, SignupInvitation::count()); + + // Test file input (empty file) + $tmpfile = tmpfile(); + fwrite($tmpfile, ""); + $file = new File('test.csv', $tmpfile); + $post = ['file' => $file]; + $response = $this->actingAs($reseller)->post("api/v4/invitations", $post); + + fclose($tmpfile); + + $response->assertStatus(422); + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertSame("Failed to find any valid email addresses in the uploaded file.", $json['errors']['file']); + + // Test file input with an invalid email address + $tmpfile = tmpfile(); + fwrite($tmpfile, "t1@domain.tld\r\nt2@domain"); + $file = new File('test.csv', $tmpfile); + $post = ['file' => $file]; + $response = $this->actingAs($reseller)->post("api/v4/invitations", $post); + + fclose($tmpfile); + + $response->assertStatus(422); + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertSame("Found an invalid email address (t2@domain) on line 2.", $json['errors']['file']); + + // Test file input (two addresses) + $tmpfile = tmpfile(); + fwrite($tmpfile, "t1@domain.tld\r\nt2@domain.tld"); + $file = new File('test.csv', $tmpfile); + $post = ['file' => $file]; + $response = $this->actingAs($reseller)->post("api/v4/invitations", $post); + + fclose($tmpfile); + + $response->assertStatus(200); + $json = $response->json(); + + $this->assertSame(1, SignupInvitation::where('email', 't1@domain.tld')->count()); + $this->assertSame(1, SignupInvitation::where('email', 't2@domain.tld')->count()); + $this->assertSame('success', $json['status']); + $this->assertSame("2 invitations has been created.", $json['message']); + $this->assertSame(2, $json['count']); + } +} diff --git a/src/tests/Feature/Controller/Reseller/PaymentsMollieTest.php b/src/tests/Feature/Controller/Reseller/PaymentsMollieTest.php new file mode 100644 index 00000000..8488de99 --- /dev/null +++ b/src/tests/Feature/Controller/Reseller/PaymentsMollieTest.php @@ -0,0 +1,257 @@ + 'mollie']); + + $reseller = $this->getTestUser('reseller@kolabnow.com'); + $wallet = $reseller->wallets()->first(); + Payment::where('wallet_id', $wallet->id)->delete(); + Wallet::where('id', $wallet->id)->update(['balance' => 0]); + WalletSetting::where('wallet_id', $wallet->id)->delete(); + Transaction::where('object_id', $wallet->id)->delete(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $reseller = $this->getTestUser('reseller@kolabnow.com'); + $wallet = $reseller->wallets()->first(); + Payment::where('wallet_id', $wallet->id)->delete(); + Wallet::where('id', $wallet->id)->update(['balance' => 0]); + WalletSetting::where('wallet_id', $wallet->id)->delete(); + Transaction::where('object_id', $wallet->id)->delete(); + + parent::tearDown(); + } + + /** + * Test creating/updating/deleting an outo-payment mandate + * + * @group mollie + */ + public function testMandates(): void + { + // Unauth access not allowed + $response = $this->get("api/v4/payments/mandate"); + $response->assertStatus(401); + $response = $this->post("api/v4/payments/mandate", []); + $response->assertStatus(401); + $response = $this->put("api/v4/payments/mandate", []); + $response->assertStatus(401); + $response = $this->delete("api/v4/payments/mandate"); + $response->assertStatus(401); + + $reseller = $this->getTestUser('reseller@kolabnow.com'); + $wallet = $reseller->wallets()->first(); + $wallet->balance = -10; + $wallet->save(); + + // Test creating a mandate (valid input) + $post = ['amount' => 20.10, 'balance' => 0]; + $response = $this->actingAs($reseller)->post("api/v4/payments/mandate", $post); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertRegExp('|^https://www.mollie.com|', $json['redirectUrl']); + + // Assert the proper payment amount has been used + $payment = Payment::where('id', $json['id'])->first(); + + $this->assertSame(2010, $payment->amount); + $this->assertSame($wallet->id, $payment->wallet_id); + $this->assertSame(\config('app.name') . " Auto-Payment Setup", $payment->description); + $this->assertSame(PaymentProvider::TYPE_MANDATE, $payment->type); + + // Test fetching the mandate information + $response = $this->actingAs($reseller)->get("api/v4/payments/mandate"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertEquals(20.10, $json['amount']); + $this->assertEquals(0, $json['balance']); + $this->assertEquals('Credit Card', $json['method']); + $this->assertSame(true, $json['isPending']); + $this->assertSame(false, $json['isValid']); + $this->assertSame(false, $json['isDisabled']); + + $mandate_id = $json['id']; + + // We would have to invoke a browser to accept the "first payment" to make + // the mandate validated/completed. Instead, we'll mock the mandate object. + $mollie_response = [ + 'resource' => 'mandate', + 'id' => $mandate_id, + 'status' => 'valid', + 'method' => 'creditcard', + 'details' => [ + 'cardNumber' => '4242', + 'cardLabel' => 'Visa', + ], + 'customerId' => 'cst_GMfxGPt7Gj', + 'createdAt' => '2020-04-28T11:09:47+00:00', + ]; + + $responseStack = $this->mockMollie(); + $responseStack->append(new Response(200, [], json_encode($mollie_response))); + + $wallet = $reseller->wallets()->first(); + $wallet->setSetting('mandate_disabled', 1); + + $response = $this->actingAs($reseller)->get("api/v4/payments/mandate"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertEquals(20.10, $json['amount']); + $this->assertEquals(0, $json['balance']); + $this->assertEquals('Visa (**** **** **** 4242)', $json['method']); + $this->assertSame(false, $json['isPending']); + $this->assertSame(true, $json['isValid']); + $this->assertSame(true, $json['isDisabled']); + + Bus::fake(); + $wallet->setSetting('mandate_disabled', null); + $wallet->balance = 1000; + $wallet->save(); + + // Test updating a mandate (valid input) + $responseStack->append(new Response(200, [], json_encode($mollie_response))); + + $post = ['amount' => 30.10, 'balance' => 10]; + $response = $this->actingAs($reseller)->put("api/v4/payments/mandate", $post); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertSame('The auto-payment has been updated.', $json['message']); + $this->assertSame($mandate_id, $json['id']); + $this->assertFalse($json['isDisabled']); + + $wallet->refresh(); + + $this->assertEquals(30.10, $wallet->getSetting('mandate_amount')); + $this->assertEquals(10, $wallet->getSetting('mandate_balance')); + + Bus::assertDispatchedTimes(\App\Jobs\WalletCharge::class, 0); + + $this->unmockMollie(); + + // Delete mandate + $response = $this->actingAs($reseller)->delete("api/v4/payments/mandate"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertSame('The auto-payment has been removed.', $json['message']); + } + + /** + * Test creating a payment + * + * @group mollie + */ + public function testStore(): void + { + Bus::fake(); + + // Unauth access not allowed + $response = $this->post("api/v4/payments", []); + $response->assertStatus(401); + + $reseller = $this->getTestUser('reseller@kolabnow.com'); + + // Successful payment + $post = ['amount' => '12.34', 'currency' => 'CHF', 'methodId' => 'creditcard']; + $response = $this->actingAs($reseller)->post("api/v4/payments", $post); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertRegExp('|^https://www.mollie.com|', $json['redirectUrl']); + } + + /** + * Test listing a pending payment + * + * @group mollie + */ + public function testListingPayments(): void + { + Bus::fake(); + + $reseller = $this->getTestUser('reseller@kolabnow.com'); + + // Empty response + $response = $this->actingAs($reseller)->get("api/v4/payments/pending"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertSame(0, $json['count']); + $this->assertSame(1, $json['page']); + $this->assertSame(false, $json['hasMore']); + $this->assertCount(0, $json['list']); + + $response = $this->actingAs($reseller)->get("api/v4/payments/has-pending"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(false, $json['hasPending']); + } + + /** + * Test listing payment methods + * + * @group mollie + */ + public function testListingPaymentMethods(): void + { + Bus::fake(); + + $reseller = $this->getTestUser('reseller@kolabnow.com'); + + $response = $this->actingAs($reseller)->get('api/v4/payments/methods?type=' . PaymentProvider::TYPE_ONEOFF); + $response->assertStatus(200); + $json = $response->json(); + + $this->assertCount(2, $json); + $this->assertSame('creditcard', $json[0]['id']); + $this->assertSame('paypal', $json[1]['id']); + } +} diff --git a/src/tests/Feature/Controller/Reseller/SkusTest.php b/src/tests/Feature/Controller/Reseller/SkusTest.php new file mode 100644 index 00000000..693ba5c5 --- /dev/null +++ b/src/tests/Feature/Controller/Reseller/SkusTest.php @@ -0,0 +1,122 @@ + 1]); + + $this->clearBetaEntitlements(); + $this->clearMeetEntitlements(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + \config(['app.tenant_id' => 1]); + + $this->clearBetaEntitlements(); + $this->clearMeetEntitlements(); + + parent::tearDown(); + } + + /** + * Test fetching SKUs list + */ + public function testIndex(): void + { + $reseller1 = $this->getTestUser('reseller@kolabnow.com'); + $reseller2 = $this->getTestUser('reseller@reseller.com'); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $user = $this->getTestUser('john@kolab.org'); + $sku = Sku::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); + + // 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']); + + // TODO: Test limiting SKUs to the tenant's SKUs + } + + /** + * Test fetching SKUs list for a user (GET /users//skus) + */ + public function testUserSkus(): void + { + $reseller1 = $this->getTestUser('reseller@kolabnow.com'); + $reseller2 = $this->getTestUser('reseller@reseller.com'); + $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 not allowed + $response = $this->actingAs($reseller2)->get("api/v4/users/{$user->id}/skus"); + $response->assertStatus(403); + + // Reseller access + $response = $this->actingAs($reseller1)->get("api/v4/users/{$user->id}/skus"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(8, $json); + // Note: Details are tested where we test API\V4\SkusController + + // Reseller from another tenant not allowed + \config(['app.tenant_id' => 2]); + $response = $this->actingAs($reseller2)->get("api/v4/users/{$user->id}/skus"); + $response->assertStatus(404); + } +} diff --git a/src/tests/Feature/Controller/Reseller/StatsTest.php b/src/tests/Feature/Controller/Reseller/StatsTest.php new file mode 100644 index 00000000..36039681 --- /dev/null +++ b/src/tests/Feature/Controller/Reseller/StatsTest.php @@ -0,0 +1,89 @@ +) + */ + public function testChart(): void + { + $user = $this->getTestUser('john@kolab.org'); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $reseller = $this->getTestUser('reseller@kolabnow.com'); + + // 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']); + } +} diff --git a/src/tests/Feature/Controller/Admin/UsersTest.php b/src/tests/Feature/Controller/Reseller/UsersTest.php similarity index 54% copy from src/tests/Feature/Controller/Admin/UsersTest.php copy to src/tests/Feature/Controller/Reseller/UsersTest.php index ad995165..c7e7665f 100644 --- a/src/tests/Feature/Controller/Admin/UsersTest.php +++ b/src/tests/Feature/Controller/Reseller/UsersTest.php @@ -1,355 +1,479 @@ 1]); $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestUser('test@testsearch.com'); $this->deleteTestDomain('testsearch.com'); - $this->deleteTestGroup('group-test@kolab.org'); - - $jack = $this->getTestUser('jack@kolab.org'); - $jack->setSetting('external_email', null); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestUser('test@testsearch.com'); $this->deleteTestDomain('testsearch.com'); - $jack = $this->getTestUser('jack@kolab.org'); - $jack->setSetting('external_email', null); + \config(['app.tenant_id' => 1]); parent::tearDown(); } + /** + * Test user deleting (DELETE /api/v4/users/) + */ + public function testDestroy(): void + { + $reseller1 = $this->getTestUser('reseller@kolabnow.com'); + $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); + + // Test unauth access + $response = $this->delete("api/v4/users/{$user->id}"); + $response->assertStatus(401); + + // The end-point does not exist + $response = $this->actingAs($reseller1)->delete("api/v4/users/{$user->id}"); + $response->assertStatus(404); + } + /** * Test users searching (/api/v4/users) */ public function testIndex(): void { + Queue::fake(); + $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); - $group = $this->getTestGroup('group-test@kolab.org'); - $group->assignToWallet($user->wallets->first()); + $reseller1 = $this->getTestUser('reseller@kolabnow.com'); + $reseller2 = $this->getTestUser('reseller@reseller.com'); + + \config(['app.tenant_id' => 2]); - // Non-admin user + // Guess access + $response = $this->get("api/v4/users"); + $response->assertStatus(401); + + // Normal user $response = $this->actingAs($user)->get("api/v4/users"); $response->assertStatus(403); - // Search with no search criteria + // Admin user $response = $this->actingAs($admin)->get("api/v4/users"); + $response->assertStatus(403); + + // Reseller from another tenant + $response = $this->actingAs($reseller1)->get("api/v4/users"); + $response->assertStatus(403); + + // Search with no search criteria + $response = $this->actingAs($reseller2)->get("api/v4/users"); $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/users?search=abcd1234efgh5678"); + $response = $this->actingAs($reseller2)->get("api/v4/users?search=abcd1234efgh5678"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); + // Search by domain in another tenant + $response = $this->actingAs($reseller2)->get("api/v4/users?search=kolab.org"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(0, $json['count']); + $this->assertSame([], $json['list']); + + // Search by user ID in another tenant + $response = $this->actingAs($reseller2)->get("api/v4/users?search={$user->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(0, $json['count']); + $this->assertSame([], $json['list']); + + // Search by email (primary) - existing user in another tenant + $response = $this->actingAs($reseller2)->get("api/v4/users?search=john@kolab.org"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(0, $json['count']); + $this->assertSame([], $json['list']); + + // Search by owner - existing user in another tenant + $response = $this->actingAs($reseller2)->get("api/v4/users?owner={$user->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(0, $json['count']); + $this->assertSame([], $json['list']); + + // Create a domain with some users in the Sample Tenant so we have anything to search for + $domain = $this->getTestDomain('testsearch.com', ['type' => \App\Domain::TYPE_EXTERNAL]); + $domain->tenant_id = 2; + $domain->save(); + $user = $this->getTestUser('test@testsearch.com'); + $user->tenant_id = 2; + $user->save(); + $plan = \App\Plan::where('title', 'group')->first(); + $user->assignPlan($plan, $domain); + $user->setAliases(['alias@testsearch.com']); + $user->setSetting('external_email', 'john.doe.external@gmail.com'); + // Search by domain - $response = $this->actingAs($admin)->get("api/v4/users?search=kolab.org"); + $response = $this->actingAs($reseller2)->get("api/v4/users?search=testsearch.com"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); // Search by user ID - $response = $this->actingAs($admin)->get("api/v4/users?search={$user->id}"); + $response = $this->actingAs($reseller2)->get("api/v4/users?search={$user->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); - // Search by email (primary) - $response = $this->actingAs($admin)->get("api/v4/users?search=john@kolab.org"); + // Search by email (primary) - existing user in reseller's tenant + $response = $this->actingAs($reseller2)->get("api/v4/users?search=test@testsearch.com"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); // Search by email (alias) - $response = $this->actingAs($admin)->get("api/v4/users?search=john.doe@kolab.org"); + $response = $this->actingAs($reseller2)->get("api/v4/users?search=alias@testsearch.com"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); - // Search by email (external), expect two users in a result - $jack = $this->getTestUser('jack@kolab.org'); - $jack->setSetting('external_email', 'john.doe.external@gmail.com'); - - $response = $this->actingAs($admin)->get("api/v4/users?search=john.doe.external@gmail.com"); + // Search by email (external), there are two users with this email, but only one + // in the reseller's tenant + $response = $this->actingAs($reseller2)->get("api/v4/users?search=john.doe.external@gmail.com"); $response->assertStatus(200); $json = $response->json(); - $this->assertSame(2, $json['count']); - $this->assertCount(2, $json['list']); - - $emails = array_column($json['list'], 'email'); - - $this->assertContains($user->email, $emails); - $this->assertContains($jack->email, $emails); + $this->assertSame(1, $json['count']); + $this->assertCount(1, $json['list']); + $this->assertSame($user->id, $json['list'][0]['id']); + $this->assertSame($user->email, $json['list'][0]['email']); // Search by owner - $response = $this->actingAs($admin)->get("api/v4/users?owner={$user->id}"); - $response->assertStatus(200); - - $json = $response->json(); - - $this->assertSame(4, $json['count']); - $this->assertCount(4, $json['list']); - - // Search by owner (Ned is a controller on John's wallets, - // here we expect only users assigned to Ned's wallet(s)) - $ned = $this->getTestUser('ned@kolab.org'); - $response = $this->actingAs($admin)->get("api/v4/users?owner={$ned->id}"); - $response->assertStatus(200); - - $json = $response->json(); - - $this->assertSame(0, $json['count']); - $this->assertCount(0, $json['list']); - - // Search by distribution list email - $response = $this->actingAs($admin)->get("api/v4/users?search=group-test@kolab.org"); + $response = $this->actingAs($reseller2)->get("api/v4/users?owner={$user->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); // Deleted users/domains - $domain = $this->getTestDomain('testsearch.com', ['type' => \App\Domain::TYPE_EXTERNAL]); - $user = $this->getTestUser('test@testsearch.com'); - $plan = \App\Plan::where('title', 'group')->first(); - $user->assignPlan($plan, $domain); - $user->setAliases(['alias@testsearch.com']); - Queue::fake(); $user->delete(); - $response = $this->actingAs($admin)->get("api/v4/users?search=test@testsearch.com"); + $response = $this->actingAs($reseller2)->get("api/v4/users?search=test@testsearch.com"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); $this->assertTrue($json['list'][0]['isDeleted']); - $response = $this->actingAs($admin)->get("api/v4/users?search=alias@testsearch.com"); + $response = $this->actingAs($reseller2)->get("api/v4/users?search=alias@testsearch.com"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); $this->assertTrue($json['list'][0]['isDeleted']); - $response = $this->actingAs($admin)->get("api/v4/users?search=testsearch.com"); + $response = $this->actingAs($reseller2)->get("api/v4/users?search=testsearch.com"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($user->id, $json['list'][0]['id']); $this->assertSame($user->email, $json['list'][0]['email']); $this->assertTrue($json['list'][0]['isDeleted']); } /** * Test reseting 2FA (POST /api/v4/users//reset2FA) */ public function testReset2FA(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $reseller1 = $this->getTestUser('reseller@kolabnow.com'); + $reseller2 = $this->getTestUser('reseller@reseller.com'); - $sku2fa = Sku::firstOrCreate(['title' => '2fa']); + $sku2fa = \App\Sku::firstOrCreate(['title' => '2fa']); $user->assignSku($sku2fa); - SecondFactor::seed('userscontrollertest1@userscontroller.com'); + \App\Auth\SecondFactor::seed('userscontrollertest1@userscontroller.com'); - // Test unauthorized access to admin API + // Test unauthorized access $response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/reset2FA", []); $response->assertStatus(403); + $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/reset2FA", []); + $response->assertStatus(403); + + $response = $this->actingAs($reseller2)->post("/api/v4/users/{$user->id}/reset2FA", []); + $response->assertStatus(403); + + // Touching admins is forbidden + $response = $this->actingAs($reseller1)->post("/api/v4/users/{$admin->id}/reset2FA", []); + $response->assertStatus(404); + $entitlements = $user->fresh()->entitlements()->where('sku_id', $sku2fa->id)->get(); $this->assertCount(1, $entitlements); - $sf = new SecondFactor($user); + $sf = new \App\Auth\SecondFactor($user); $this->assertCount(1, $sf->factors()); // Test reseting 2FA - $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/reset2FA", []); + $response = $this->actingAs($reseller1)->post("/api/v4/users/{$user->id}/reset2FA", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("2-Factor authentication reset successfully.", $json['message']); $this->assertCount(2, $json); $entitlements = $user->fresh()->entitlements()->where('sku_id', $sku2fa->id)->get(); $this->assertCount(0, $entitlements); - $sf = new SecondFactor($user); + $sf = new \App\Auth\SecondFactor($user); $this->assertCount(0, $sf->factors()); + + // Other tenant's user + \config(['app.tenant_id' => 2]); + $response = $this->actingAs($reseller2)->post("/api/v4/users/{$user->id}/reset2FA", []); + $response->assertStatus(404); + } + + /** + * Test user creation (POST /api/v4/users) + */ + public function testStore(): void + { + $reseller1 = $this->getTestUser('reseller@kolabnow.com'); + + // The end-point does not exist + $response = $this->actingAs($reseller1)->post("/api/v4/users", []); + $response->assertStatus(404); } /** * Test user suspending (POST /api/v4/users//suspend) */ public function testSuspend(): void { Queue::fake(); // disable jobs $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $reseller1 = $this->getTestUser('reseller@kolabnow.com'); + $reseller2 = $this->getTestUser('reseller@reseller.com'); - // Test unauthorized access to admin API + // Test unauthorized access $response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/suspend", []); $response->assertStatus(403); + $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/suspend", []); + $response->assertStatus(403); + + $response = $this->actingAs($reseller2)->post("/api/v4/users/{$user->id}/suspend", []); + $response->assertStatus(403); + + $response = $this->actingAs($reseller1)->post("/api/v4/users/{$admin->id}/suspend", []); + $response->assertStatus(404); + $this->assertFalse($user->isSuspended()); // Test suspending the user - $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/suspend", []); + $response = $this->actingAs($reseller1)->post("/api/v4/users/{$user->id}/suspend", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("User suspended successfully.", $json['message']); $this->assertCount(2, $json); $this->assertTrue($user->fresh()->isSuspended()); + + // Access to other tenant's users + \config(['app.tenant_id' => 2]); + $response = $this->actingAs($reseller2)->post("/api/v4/users/{$user->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('UsersControllerTest1@userscontroller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $reseller1 = $this->getTestUser('reseller@kolabnow.com'); + $reseller2 = $this->getTestUser('reseller@reseller.com'); // Test unauthorized access to admin API $response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/unsuspend", []); $response->assertStatus(403); + $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/unsuspend", []); + $response->assertStatus(403); + + $response = $this->actingAs($reseller2)->post("/api/v4/users/{$user->id}/unsuspend", []); + $response->assertStatus(403); + + $response = $this->actingAs($reseller1)->post("/api/v4/users/{$admin->id}/unsuspend", []); + $response->assertStatus(404); + $this->assertFalse($user->isSuspended()); $user->suspend(); $this->assertTrue($user->isSuspended()); // Test suspending the user - $response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/unsuspend", []); + $response = $this->actingAs($reseller1)->post("/api/v4/users/{$user->id}/unsuspend", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("User unsuspended successfully.", $json['message']); $this->assertCount(2, $json); $this->assertFalse($user->fresh()->isSuspended()); + + // Access to other tenant's users + \config(['app.tenant_id' => 2]); + $response = $this->actingAs($reseller2)->post("/api/v4/users/{$user->id}/unsuspend", []); + $response->assertStatus(404); } /** * Test user update (PUT /api/v4/users/) */ public function testUpdate(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $reseller1 = $this->getTestUser('reseller@kolabnow.com'); + $reseller2 = $this->getTestUser('reseller@reseller.com'); - // Test unauthorized access to admin API + // Test unauthorized access $response = $this->actingAs($user)->put("/api/v4/users/{$user->id}", []); $response->assertStatus(403); - // Test updatig the user data (empty data) $response = $this->actingAs($admin)->put("/api/v4/users/{$user->id}", []); + $response->assertStatus(403); + + $response = $this->actingAs($reseller2)->put("/api/v4/users/{$user->id}", []); + $response->assertStatus(403); + + $response = $this->actingAs($reseller1)->put("/api/v4/users/{$admin->id}", []); + $response->assertStatus(404); + + // Test updatig the user data (empty data) + $response = $this->actingAs($reseller1)->put("/api/v4/users/{$user->id}", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully.", $json['message']); $this->assertCount(2, $json); // Test error handling $post = ['external_email' => 'aaa']; - $response = $this->actingAs($admin)->put("/api/v4/users/{$user->id}", $post); + $response = $this->actingAs($reseller1)->put("/api/v4/users/{$user->id}", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame("The external email must be a valid email address.", $json['errors']['external_email'][0]); $this->assertCount(2, $json); // Test real update $post = ['external_email' => 'modified@test.com']; - $response = $this->actingAs($admin)->put("/api/v4/users/{$user->id}", $post); + $response = $this->actingAs($reseller1)->put("/api/v4/users/{$user->id}", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully.", $json['message']); $this->assertCount(2, $json); $this->assertSame('modified@test.com', $user->getSetting('external_email')); + + // Access to other tenant's users + \config(['app.tenant_id' => 2]); + $response = $this->actingAs($reseller2)->put("/api/v4/users/{$user->id}", $post); + $response->assertStatus(404); } } diff --git a/src/tests/Feature/Controller/Admin/WalletsTest.php b/src/tests/Feature/Controller/Reseller/WalletsTest.php similarity index 63% copy from src/tests/Feature/Controller/Admin/WalletsTest.php copy to src/tests/Feature/Controller/Reseller/WalletsTest.php index 5b8c4fb6..202fe7bf 100644 --- a/src/tests/Feature/Controller/Admin/WalletsTest.php +++ b/src/tests/Feature/Controller/Reseller/WalletsTest.php @@ -1,228 +1,312 @@ 1]); } /** * {@inheritDoc} */ public function tearDown(): void { + \config(['app.tenant_id' => 1]); parent::tearDown(); } /** * Test fetching a wallet (GET /api/v4/wallets/:id) * * @group stripe */ public function testShow(): void { \config(['services.payment_provider' => 'stripe']); $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $reseller1 = $this->getTestUser('reseller@kolabnow.com'); + $reseller2 = $this->getTestUser('reseller@reseller.com'); $wallet = $user->wallets()->first(); $wallet->discount_id = null; $wallet->save(); // Make sure there's no stripe/mollie identifiers $wallet->setSetting('stripe_id', null); $wallet->setSetting('stripe_mandate_id', null); $wallet->setSetting('mollie_id', null); $wallet->setSetting('mollie_mandate_id', null); // Non-admin user $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}"); $response->assertStatus(403); // Admin user $response = $this->actingAs($admin)->get("api/v4/wallets/{$wallet->id}"); + $response->assertStatus(403); + + // Reseller from a different tenant + $response = $this->actingAs($reseller2)->get("api/v4/wallets/{$wallet->id}"); + $response->assertStatus(403); + + // Reseller + $response = $this->actingAs($reseller1)->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->assertSame(0, $json['discount']); $this->assertTrue(empty($json['description'])); $this->assertTrue(empty($json['discount_description'])); $this->assertTrue(!empty($json['provider'])); $this->assertTrue(empty($json['providerLink'])); $this->assertTrue(!empty($json['mandate'])); + $this->assertTrue(!empty($json['notice'])); + + // Reseller from a different tenant + \config(['app.tenant_id' => 2]); + $response = $this->actingAs($reseller2)->get("api/v4/wallets/{$wallet->id}"); + $response->assertStatus(404); } /** * Test awarding/penalizing a wallet (POST /api/v4/wallets/:id/one-off) */ public function testOneOff(): void { $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $reseller1 = $this->getTestUser('reseller@kolabnow.com'); + $reseller2 = $this->getTestUser('reseller@reseller.com'); $wallet = $user->wallets()->first(); + $reseller1_wallet = $reseller1->wallets()->first(); $balance = $wallet->balance; + $reseller1_balance = $reseller1_wallet->balance; Transaction::where('object_id', $wallet->id) ->whereIn('type', [Transaction::WALLET_AWARD, Transaction::WALLET_PENALTY]) ->delete(); + Transaction::where('object_id', $reseller1_wallet->id)->delete(); // Non-admin user $response = $this->actingAs($user)->post("api/v4/wallets/{$wallet->id}/one-off", []); $response->assertStatus(403); + // Admin user + $response = $this->actingAs($admin)->post("api/v4/wallets/{$wallet->id}/one-off", []); + $response->assertStatus(403); + + // Reseller from a different tenant + $response = $this->actingAs($reseller2)->post("api/v4/wallets/{$wallet->id}/one-off", []); + $response->assertStatus(403); + // Admin user - invalid input $post = ['amount' => 'aaaa']; - $response = $this->actingAs($admin)->post("api/v4/wallets/{$wallet->id}/one-off", $post); + $response = $this->actingAs($reseller1)->post("api/v4/wallets/{$wallet->id}/one-off", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame('The amount must be a number.', $json['errors']['amount'][0]); $this->assertSame('The description field is required.', $json['errors']['description'][0]); $this->assertCount(2, $json); $this->assertCount(2, $json['errors']); - // Admin user - a valid bonus + // A valid bonus $post = ['amount' => '50', 'description' => 'A bonus']; - $response = $this->actingAs($admin)->post("api/v4/wallets/{$wallet->id}/one-off", $post); + $response = $this->actingAs($reseller1)->post("api/v4/wallets/{$wallet->id}/one-off", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('The bonus has been added to the wallet successfully.', $json['message']); $this->assertSame($balance += 5000, $json['balance']); $this->assertSame($balance, $wallet->fresh()->balance); + $this->assertSame($reseller1_balance -= 5000, $reseller1_wallet->fresh()->balance); $transaction = Transaction::where('object_id', $wallet->id) ->where('type', Transaction::WALLET_AWARD)->first(); $this->assertSame($post['description'], $transaction->description); $this->assertSame(5000, $transaction->amount); - $this->assertSame($admin->email, $transaction->user_email); + $this->assertSame($reseller1->email, $transaction->user_email); + + $transaction = Transaction::where('object_id', $reseller1_wallet->id) + ->where('type', Transaction::WALLET_DEBIT)->first(); + + $this->assertSame("Awarded user {$user->email}", $transaction->description); + $this->assertSame(-5000, $transaction->amount); + $this->assertSame($reseller1->email, $transaction->user_email); - // Admin user - a valid penalty + // A valid penalty $post = ['amount' => '-40', 'description' => 'A penalty']; - $response = $this->actingAs($admin)->post("api/v4/wallets/{$wallet->id}/one-off", $post); + $response = $this->actingAs($reseller1)->post("api/v4/wallets/{$wallet->id}/one-off", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('The penalty has been added to the wallet successfully.', $json['message']); $this->assertSame($balance -= 4000, $json['balance']); $this->assertSame($balance, $wallet->fresh()->balance); + $this->assertSame($reseller1_balance += 4000, $reseller1_wallet->fresh()->balance); $transaction = Transaction::where('object_id', $wallet->id) ->where('type', Transaction::WALLET_PENALTY)->first(); $this->assertSame($post['description'], $transaction->description); $this->assertSame(-4000, $transaction->amount); - $this->assertSame($admin->email, $transaction->user_email); + $this->assertSame($reseller1->email, $transaction->user_email); + + $transaction = Transaction::where('object_id', $reseller1_wallet->id) + ->where('type', Transaction::WALLET_CREDIT)->first(); + + $this->assertSame("Penalized user {$user->email}", $transaction->description); + $this->assertSame(4000, $transaction->amount); + $this->assertSame($reseller1->email, $transaction->user_email); + + // Reseller from a different tenant + \config(['app.tenant_id' => 2]); + $response = $this->actingAs($reseller2)->post("api/v4/wallets/{$wallet->id}/one-off", []); + $response->assertStatus(404); } /** * Test fetching wallet transactions (GET /api/v4/wallets/:id/transactions) */ public function testTransactions(): void { // Note: Here we're testing only that the end-point works, // and admin can get the transaction log, response details // are tested in Feature/Controller/WalletsTest.php + $this->deleteTestUser('wallets-controller@kolabnow.com'); $user = $this->getTestUser('wallets-controller@kolabnow.com'); $wallet = $user->wallets()->first(); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $reseller1 = $this->getTestUser('reseller@kolabnow.com'); + $reseller2 = $this->getTestUser('reseller@reseller.com'); // Non-admin $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(403); + // Admin + $response = $this->actingAs($admin)->get("api/v4/wallets/{$wallet->id}/transactions"); + $response->assertStatus(403); + + // Reseller from a different tenant + $response = $this->actingAs($reseller2)->get("api/v4/wallets/{$wallet->id}/transactions"); + $response->assertStatus(403); + // Create some sample transactions $transactions = $this->createTestTransactions($wallet); $transactions = array_reverse($transactions); $pages = array_chunk($transactions, 10 /* page size*/); // Get the 2nd page - $response = $this->actingAs($admin)->get("api/v4/wallets/{$wallet->id}/transactions?page=2"); + $response = $this->actingAs($reseller1)->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->assertFalse($json['list'][$idx]['hasDetails']); } - // The 'user' key is set only on the admin end-point + // The 'user' key is set only on the admin/reseller end-point + // FIXME: Should we hide this for resellers? $this->assertSame('jeroen@jeroen.jeroen', $json['list'][1]['user']); + + // Reseller from a different tenant + \config(['app.tenant_id' => 2]); + $response = $this->actingAs($reseller2)->get("api/v4/wallets/{$wallet->id}/transactions"); + $response->assertStatus(403); } /** * Test updating a wallet (PUT /api/v4/wallets/:id) */ public function testUpdate(): void { - $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $reseller1 = $this->getTestUser('reseller@kolabnow.com'); + $reseller2 = $this->getTestUser('reseller@reseller.com'); + $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $discount = Discount::where('code', 'TEST')->first(); // Non-admin user $response = $this->actingAs($user)->put("api/v4/wallets/{$wallet->id}", []); $response->assertStatus(403); + // Admin + $response = $this->actingAs($admin)->put("api/v4/wallets/{$wallet->id}", []); + $response->assertStatus(403); + + // Reseller from another tenant + $response = $this->actingAs($reseller2)->put("api/v4/wallets/{$wallet->id}", []); + $response->assertStatus(403); + // Admin user - setting a discount $post = ['discount' => $discount->id]; - $response = $this->actingAs($admin)->put("api/v4/wallets/{$wallet->id}", $post); + $response = $this->actingAs($reseller1)->put("api/v4/wallets/{$wallet->id}", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('User wallet updated successfully.', $json['message']); $this->assertSame($wallet->id, $json['id']); $this->assertSame($discount->discount, $json['discount']); $this->assertSame($discount->id, $json['discount_id']); $this->assertSame($discount->description, $json['discount_description']); $this->assertSame($discount->id, $wallet->fresh()->discount->id); // Admin user - removing a discount $post = ['discount' => null]; - $response = $this->actingAs($admin)->put("api/v4/wallets/{$wallet->id}", $post); + $response = $this->actingAs($reseller1)->put("api/v4/wallets/{$wallet->id}", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('User wallet updated successfully.', $json['message']); $this->assertSame($wallet->id, $json['id']); $this->assertSame(null, $json['discount_id']); $this->assertTrue(empty($json['discount_description'])); $this->assertSame(null, $wallet->fresh()->discount); + + // Reseller from a different tenant + \config(['app.tenant_id' => 2]); + $response = $this->actingAs($reseller2)->put("api/v4/wallets/{$wallet->id}", []); + $response->assertStatus(404); } } diff --git a/src/tests/Feature/Controller/SignupTest.php b/src/tests/Feature/Controller/SignupTest.php index bbbe0221..b67ceb35 100644 --- a/src/tests/Feature/Controller/SignupTest.php +++ b/src/tests/Feature/Controller/SignupTest.php @@ -1,710 +1,782 @@ domain = $this->getPublicDomain(); $this->deleteTestUser("SignupControllerTest1@$this->domain"); $this->deleteTestUser("signuplogin@$this->domain"); $this->deleteTestUser("admin@external.com"); + $this->deleteTestUser("test-inv@kolabnow.com"); $this->deleteTestDomain('external.com'); $this->deleteTestDomain('signup-domain.com'); $this->deleteTestGroup('group-test@kolabnow.com'); + SI::truncate(); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser("SignupControllerTest1@$this->domain"); $this->deleteTestUser("signuplogin@$this->domain"); $this->deleteTestUser("admin@external.com"); + $this->deleteTestUser("test-inv@kolabnow.com"); $this->deleteTestDomain('external.com'); $this->deleteTestDomain('signup-domain.com'); $this->deleteTestGroup('group-test@kolabnow.com'); + SI::truncate(); parent::tearDown(); } /** * Return a public domain for signup tests */ private function getPublicDomain(): string { if (!$this->domain) { $this->refreshApplication(); $public_domains = Domain::getPublicDomains(); $this->domain = reset($public_domains); if (empty($this->domain)) { $this->domain = 'signup-domain.com'; Domain::create([ 'namespace' => $this->domain, 'status' => Domain::STATUS_ACTIVE, 'type' => Domain::TYPE_PUBLIC, ]); } } return $this->domain; } /** * Test fetching plans for signup - * - * @return void */ - public function testSignupPlans() + public function testSignupPlans(): void { $response = $this->get('/api/auth/signup/plans'); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertCount(2, $json['plans']); $this->assertArrayHasKey('title', $json['plans'][0]); $this->assertArrayHasKey('name', $json['plans'][0]); $this->assertArrayHasKey('description', $json['plans'][0]); $this->assertArrayHasKey('button', $json['plans'][0]); } + /** + * Test fetching invitation + */ + public function testSignupInvitations(): void + { + Queue::fake(); + + $invitation = SI::create(['email' => 'email1@ext.com']); + + // Test existing invitation + $response = $this->get("/api/auth/signup/invitations/{$invitation->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame($invitation->id, $json['id']); + + // Test non-existing invitation + $response = $this->get("/api/auth/signup/invitations/abc"); + $response->assertStatus(404); + + // Test completed invitation + SI::where('id', $invitation->id)->update(['status' => SI::STATUS_COMPLETED]); + $response = $this->get("/api/auth/signup/invitations/{$invitation->id}"); + $response->assertStatus(404); + } + /** * Test signup initialization with invalid input - * - * @return void */ - public function testSignupInitInvalidInput() + public function testSignupInitInvalidInput(): void { // Empty input data $data = []; $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('email', $json['errors']); // Data with missing name $data = [ 'email' => 'UsersApiControllerTest1@UsersApiControllerTest.com', 'first_name' => str_repeat('a', 250), 'last_name' => str_repeat('a', 250), ]; $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('first_name', $json['errors']); $this->assertArrayHasKey('last_name', $json['errors']); // Data with invalid email (but not phone number) $data = [ 'email' => '@example.org', 'first_name' => 'Signup', 'last_name' => 'User', ]; $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('email', $json['errors']); // Sanity check on voucher code, last/first name is optional $data = [ 'voucher' => '123456789012345678901234567890123', 'email' => 'valid@email.com', ]; $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('voucher', $json['errors']); // TODO: Test phone validation } /** * Test signup initialization with valid input - * - * @return array */ - public function testSignupInitValidInput() + public function testSignupInitValidInput(): array { Queue::fake(); // Assert that no jobs were pushed... Queue::assertNothingPushed(); $data = [ 'email' => 'testuser@external.com', 'first_name' => 'Signup', 'last_name' => 'User', 'plan' => 'individual', ]; $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(200); $this->assertCount(2, $json); $this->assertSame('success', $json['status']); $this->assertNotEmpty($json['code']); // Assert the email sending job was pushed once Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, 1); // Assert the job has proper data assigned Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, function ($job) use ($data, $json) { $code = TestCase::getObjectProperty($job, 'code'); return $code->code === $json['code'] && $code->plan === $data['plan'] && $code->email === $data['email'] && $code->first_name === $data['first_name'] && $code->last_name === $data['last_name']; }); // Try the same with voucher $data['voucher'] = 'TEST'; $response = $this->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(200); $this->assertCount(2, $json); $this->assertSame('success', $json['status']); $this->assertNotEmpty($json['code']); // Assert the job has proper data assigned Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, function ($job) use ($data, $json) { $code = TestCase::getObjectProperty($job, 'code'); return $code->code === $json['code'] && $code->plan === $data['plan'] && $code->email === $data['email'] && $code->voucher === $data['voucher'] && $code->first_name === $data['first_name'] && $code->last_name === $data['last_name']; }); return [ 'code' => $json['code'], 'email' => $data['email'], 'first_name' => $data['first_name'], 'last_name' => $data['last_name'], 'plan' => $data['plan'], 'voucher' => $data['voucher'] ]; } /** * Test signup code verification with invalid input * * @depends testSignupInitValidInput - * @return void */ - public function testSignupVerifyInvalidInput(array $result) + public function testSignupVerifyInvalidInput(array $result): void { // Empty data $data = []; $response = $this->post('/api/auth/signup/verify', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('code', $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); // Data with existing code but missing short_code $data = [ 'code' => $result['code'], ]; $response = $this->post('/api/auth/signup/verify', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); // Data with invalid short_code $data = [ 'code' => $result['code'], 'short_code' => 'XXXX', ]; $response = $this->post('/api/auth/signup/verify', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); // TODO: Test expired code } /** * Test signup code verification with valid input * * @depends testSignupInitValidInput - * - * @return array */ - public function testSignupVerifyValidInput(array $result) + public function testSignupVerifyValidInput(array $result): array { $code = SignupCode::find($result['code']); $data = [ 'code' => $code->code, 'short_code' => $code->short_code, ]; $response = $this->post('/api/auth/signup/verify', $data); $json = $response->json(); $response->assertStatus(200); $this->assertCount(7, $json); $this->assertSame('success', $json['status']); $this->assertSame($result['email'], $json['email']); $this->assertSame($result['first_name'], $json['first_name']); $this->assertSame($result['last_name'], $json['last_name']); $this->assertSame($result['voucher'], $json['voucher']); $this->assertSame(false, $json['is_domain']); $this->assertTrue(is_array($json['domains']) && !empty($json['domains'])); return $result; } /** * Test last signup step with invalid input * * @depends testSignupVerifyValidInput - * @return void */ - public function testSignupInvalidInput(array $result) + public function testSignupInvalidInput(array $result): void { // Empty data $data = []; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(3, $json['errors']); $this->assertArrayHasKey('login', $json['errors']); $this->assertArrayHasKey('password', $json['errors']); $this->assertArrayHasKey('domain', $json['errors']); $domain = $this->getPublicDomain(); // Passwords do not match and missing domain $data = [ 'login' => 'test', 'password' => 'test', 'password_confirmation' => 'test2', ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('password', $json['errors']); $this->assertArrayHasKey('domain', $json['errors']); $domain = $this->getPublicDomain(); // Login too short $data = [ 'login' => '1', 'domain' => $domain, 'password' => 'test', 'password_confirmation' => 'test', ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('login', $json['errors']); // Missing codes $data = [ 'login' => 'login-valid', 'domain' => $domain, 'password' => 'test', 'password_confirmation' => 'test', ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('code', $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); // Data with invalid short_code $data = [ 'login' => 'TestLogin', 'domain' => $domain, 'password' => 'test', 'password_confirmation' => 'test', 'code' => $result['code'], 'short_code' => 'XXXX', ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('short_code', $json['errors']); $code = SignupCode::find($result['code']); // Data with invalid voucher $data = [ 'login' => 'TestLogin', 'domain' => $domain, 'password' => 'test', 'password_confirmation' => 'test', 'code' => $result['code'], 'short_code' => $code->short_code, 'voucher' => 'XXX', ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('voucher', $json['errors']); // Valid code, invalid login $data = [ 'login' => 'żżżżżż', 'domain' => $domain, 'password' => 'test', 'password_confirmation' => 'test', 'code' => $result['code'], 'short_code' => $code->short_code, ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertArrayHasKey('login', $json['errors']); } /** * Test last signup step with valid input (user creation) * * @depends testSignupVerifyValidInput - * @return void */ - public function testSignupValidInput(array $result) + public function testSignupValidInput(array $result): void { $queue = Queue::fake(); $domain = $this->getPublicDomain(); $identity = \strtolower('SignupLogin@') . $domain; $code = SignupCode::find($result['code']); $data = [ 'login' => 'SignupLogin', 'domain' => $domain, 'password' => 'test', 'password_confirmation' => 'test', 'code' => $code->code, 'short_code' => $code->short_code, 'voucher' => 'TEST', ]; $response = $this->post('/api/auth/signup', $data); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame('bearer', $json['token_type']); $this->assertTrue(!empty($json['expires_in']) && is_int($json['expires_in']) && $json['expires_in'] > 0); $this->assertNotEmpty($json['access_token']); $this->assertSame($identity, $json['email']); Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1); Queue::assertPushed( \App\Jobs\User\CreateJob::class, function ($job) use ($data) { $userEmail = TestCase::getObjectProperty($job, 'userEmail'); return $userEmail === \strtolower($data['login'] . '@' . $data['domain']); } ); // Check if the code has been removed $this->assertNull(SignupCode::where('code', $result['code'])->first()); // Check if the user has been created $user = User::where('email', $identity)->first(); $this->assertNotEmpty($user); $this->assertSame($identity, $user->email); // Check user settings $this->assertSame($result['first_name'], $user->getSetting('first_name')); $this->assertSame($result['last_name'], $user->getSetting('last_name')); $this->assertSame($result['email'], $user->getSetting('external_email')); // Discount $discount = Discount::where('code', 'TEST')->first(); $this->assertSame($discount->id, $user->wallets()->first()->discount_id); // TODO: Check SKUs/Plan // TODO: Check if the access token works } /** * Test signup for a group (custom domain) account - * - * @return void */ - public function testSignupGroupAccount() + public function testSignupGroupAccount(): void { Queue::fake(); // Initial signup request $user_data = $data = [ 'email' => 'testuser@external.com', 'first_name' => 'Signup', 'last_name' => 'User', 'plan' => 'group', ]; $response = $this->withoutMiddleware()->post('/api/auth/signup/init', $data); $json = $response->json(); $response->assertStatus(200); $this->assertCount(2, $json); $this->assertSame('success', $json['status']); $this->assertNotEmpty($json['code']); // Assert the email sending job was pushed once Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, 1); // Assert the job has proper data assigned Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, function ($job) use ($data, $json) { $code = TestCase::getObjectProperty($job, 'code'); return $code->code === $json['code'] && $code->plan === $data['plan'] && $code->email === $data['email'] && $code->first_name === $data['first_name'] && $code->last_name === $data['last_name']; }); // Verify the code $code = SignupCode::find($json['code']); $data = [ 'code' => $code->code, 'short_code' => $code->short_code, ]; $response = $this->post('/api/auth/signup/verify', $data); $result = $response->json(); $response->assertStatus(200); $this->assertCount(7, $result); $this->assertSame('success', $result['status']); $this->assertSame($user_data['email'], $result['email']); $this->assertSame($user_data['first_name'], $result['first_name']); $this->assertSame($user_data['last_name'], $result['last_name']); $this->assertSame(null, $result['voucher']); $this->assertSame(true, $result['is_domain']); $this->assertSame([], $result['domains']); // Final signup request $login = 'admin'; $domain = 'external.com'; $data = [ 'login' => $login, 'domain' => $domain, 'password' => 'test', 'password_confirmation' => 'test', 'code' => $code->code, 'short_code' => $code->short_code, ]; $response = $this->post('/api/auth/signup', $data); $result = $response->json(); $response->assertStatus(200); $this->assertSame('success', $result['status']); $this->assertSame('bearer', $result['token_type']); $this->assertTrue(!empty($result['expires_in']) && is_int($result['expires_in']) && $result['expires_in'] > 0); $this->assertNotEmpty($result['access_token']); $this->assertSame("$login@$domain", $result['email']); Queue::assertPushed(\App\Jobs\Domain\CreateJob::class, 1); Queue::assertPushed( \App\Jobs\Domain\CreateJob::class, function ($job) use ($domain) { $domainNamespace = TestCase::getObjectProperty($job, 'domainNamespace'); return $domainNamespace === $domain; } ); Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1); Queue::assertPushed( \App\Jobs\User\CreateJob::class, function ($job) use ($data) { $userEmail = TestCase::getObjectProperty($job, 'userEmail'); return $userEmail === $data['login'] . '@' . $data['domain']; } ); // Check if the code has been removed $this->assertNull(SignupCode::find($code->id)); // Check if the user has been created $user = User::where('email', $login . '@' . $domain)->first(); $this->assertNotEmpty($user); // Check user settings $this->assertSame($user_data['email'], $user->getSetting('external_email')); $this->assertSame($user_data['first_name'], $user->getSetting('first_name')); $this->assertSame($user_data['last_name'], $user->getSetting('last_name')); // TODO: Check domain record // TODO: Check SKUs/Plan // TODO: Check if the access token works } + /** + * Test signup via invitation + */ + public function testSignupViaInvitation(): void + { + Queue::fake(); + + $invitation = SI::create(['email' => 'email1@ext.com']); + + $post = [ + 'invitation' => 'abc', + 'first_name' => 'Signup', + 'last_name' => 'User', + 'login' => 'test-inv', + 'domain' => 'kolabnow.com', + 'password' => 'test', + 'password_confirmation' => 'test', + ]; + + // Test invalid invitation identifier + $response = $this->post('/api/auth/signup', $post); + $response->assertStatus(404); + + // Test valid input + $post['invitation'] = $invitation->id; + $response = $this->post('/api/auth/signup', $post); + $result = $response->json(); + + $response->assertStatus(200); + $this->assertSame('success', $result['status']); + $this->assertSame('bearer', $result['token_type']); + $this->assertTrue(!empty($result['expires_in']) && is_int($result['expires_in']) && $result['expires_in'] > 0); + $this->assertNotEmpty($result['access_token']); + $this->assertSame('test-inv@kolabnow.com', $result['email']); + + // Check if the user has been created + $user = User::where('email', 'test-inv@kolabnow.com')->first(); + + $this->assertNotEmpty($user); + + // Check user settings + $this->assertSame($invitation->email, $user->getSetting('external_email')); + $this->assertSame($post['first_name'], $user->getSetting('first_name')); + $this->assertSame($post['last_name'], $user->getSetting('last_name')); + + $invitation->refresh(); + + $this->assertSame($user->id, $invitation->user_id); + $this->assertTrue($invitation->isCompleted()); + + // TODO: Test POST params validation + } + /** * List of login/domain validation cases for testValidateLogin() * * @return array Arguments for testValidateLogin() */ public function dataValidateLogin(): array { $domain = $this->getPublicDomain(); return [ // Individual account ['', $domain, false, ['login' => 'The login field is required.']], ['test123456', 'localhost', false, ['domain' => 'The specified domain is invalid.']], ['test123456', 'unknown-domain.org', false, ['domain' => 'The specified domain is invalid.']], ['test.test', $domain, false, null], ['test_test', $domain, false, null], ['test-test', $domain, false, null], ['admin', $domain, false, ['login' => 'The specified login is not available.']], ['administrator', $domain, false, ['login' => 'The specified login is not available.']], ['sales', $domain, false, ['login' => 'The specified login is not available.']], ['root', $domain, false, ['login' => 'The specified login is not available.']], // TODO existing (public domain) user // ['signuplogin', $domain, false, ['login' => 'The specified login is not available.']], // Domain account ['admin', 'kolabsys.com', true, null], ['testnonsystemdomain', 'invalid', true, ['domain' => 'The specified domain is invalid.']], ['testnonsystemdomain', '.com', true, ['domain' => 'The specified domain is invalid.']], // existing custom domain ['jack', 'kolab.org', true, ['domain' => 'The specified domain is not available.']], ]; } /** * Signup login/domain 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 dataValidateLogin */ public function testValidateLogin($login, $domain, $external, $expected_result): void { $result = $this->invokeMethod(new SignupController(), 'validateLogin', [$login, $domain, $external]); $this->assertSame($expected_result, $result); } /** * Signup login/domain validation, more cases * * Note: Technically these include unit tests, but let's keep it here for now. */ public function testValidateLoginMore(): void { $group = $this->getTestGroup('group-test@kolabnow.com'); $login = 'group-test'; $domain = 'kolabnow.com'; $external = false; $result = $this->invokeMethod(new SignupController(), 'validateLogin', [$login, $domain, $external]); $this->assertSame(['login' => 'The specified login is not available.'], $result); } } diff --git a/src/tests/Feature/DomainTest.php b/src/tests/Feature/DomainTest.php index a183e2e3..1c4a4d75 100644 --- a/src/tests/Feature/DomainTest.php +++ b/src/tests/Feature/DomainTest.php @@ -1,300 +1,306 @@ 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 = Domain::where('namespace', 'public-active.com')->first(); $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 + $domain->tenant_id = 2; + $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 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/EntitlementTest.php b/src/tests/Feature/EntitlementTest.php index a18f673b..e6f03802 100644 --- a/src/tests/Feature/EntitlementTest.php +++ b/src/tests/Feature/EntitlementTest.php @@ -1,184 +1,130 @@ deleteTestUser('entitlement-test@kolabnow.com'); $this->deleteTestUser('entitled-user@custom-domain.com'); $this->deleteTestDomain('custom-domain.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('entitlement-test@kolabnow.com'); $this->deleteTestUser('entitled-user@custom-domain.com'); $this->deleteTestDomain('custom-domain.com'); parent::tearDown(); } /** * Test for Entitlement::costsPerDay() */ public function testCostsPerDay(): void { // 444 // 28 days: 15.86 // 31 days: 14.32 $user = $this->getTestUser('entitlement-test@kolabnow.com'); $package = Package::where('title', 'kolab')->first(); $mailbox = Sku::where('title', 'mailbox')->first(); $user->assignPackage($package); $entitlement = $user->entitlements->where('sku_id', $mailbox->id)->first(); $costsPerDay = $entitlement->costsPerDay(); $this->assertTrue($costsPerDay < 15.86); $this->assertTrue($costsPerDay > 14.32); } /** * Tests for entitlements * @todo This really should be in User or Wallet tests file */ public function testEntitlements(): void { $packageDomain = Package::where('title', 'domain-hosting')->first(); $packageKolab = Package::where('title', 'kolab')->first(); $skuDomain = Sku::where('title', 'domain-hosting')->first(); $skuMailbox = Sku::where('title', 'mailbox')->first(); $owner = $this->getTestUser('entitlement-test@kolabnow.com'); $user = $this->getTestUser('entitled-user@custom-domain.com'); $domain = $this->getTestDomain( 'custom-domain.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ] ); $domain->assignPackage($packageDomain, $owner); - $owner->assignPackage($packageKolab); $owner->assignPackage($packageKolab, $user); $wallet = $owner->wallets->first(); $this->assertCount(4, $owner->entitlements()->get()); $this->assertCount(1, $skuDomain->entitlements()->where('wallet_id', $wallet->id)->get()); $this->assertCount(2, $skuMailbox->entitlements()->where('wallet_id', $wallet->id)->get()); $this->assertCount(9, $wallet->entitlements); $this->backdateEntitlements( $owner->entitlements, Carbon::now()->subMonthsWithoutOverflow(1) ); $wallet->chargeEntitlements(); $this->assertTrue($wallet->fresh()->balance < 0); } /** * @todo This really should be in User tests file */ public function testEntitlementFunctions(): void { $user = $this->getTestUser('entitlement-test@kolabnow.com'); $package = \App\Package::where('title', 'kolab')->first(); $user->assignPackage($package); $wallet = $user->wallets()->first(); $this->assertNotNull($wallet); $sku = \App\Sku::where('title', 'mailbox')->first(); $entitlement = Entitlement::where('wallet_id', $wallet->id) ->where('sku_id', $sku->id)->first(); $this->assertNotNull($entitlement); $this->assertSame($sku->id, $entitlement->sku->id); $this->assertSame($wallet->id, $entitlement->wallet->id); $this->assertEquals($user->id, $entitlement->entitleable->id); $this->assertTrue($entitlement->entitleable instanceof \App\User); } - - /** - * @todo This really should be in User or Wallet tests file - */ - public function testBillDeletedEntitlement(): void - { - $user = $this->getTestUser('entitlement-test@kolabnow.com'); - $package = \App\Package::where('title', 'kolab')->first(); - - $storage = \App\Sku::where('title', 'storage')->first(); - - $user->assignPackage($package); - // some additional SKUs so we have something to delete. - $user->assignSku($storage, 4); - - // the mailbox, the groupware, the 2 original storage and the additional 4 - $this->assertCount(8, $user->fresh()->entitlements); - - $wallet = $user->wallets()->first(); - - $backdate = Carbon::now()->subWeeks(7); - $this->backdateEntitlements($user->entitlements, $backdate); - - $charge = $wallet->chargeEntitlements(); - - $this->assertSame(-1099, $wallet->balance); - - $balance = $wallet->balance; - $discount = \App\Discount::where('discount', 30)->first(); - $wallet->discount()->associate($discount); - $wallet->save(); - - $user->removeSku($storage, 4); - - // we expect the wallet to have been charged for ~3 weeks of use of - // 4 deleted storage entitlements, it should also take discount into account - $backdate->addMonthsWithoutOverflow(1); - $diffInDays = $backdate->diffInDays(Carbon::now()); - - // entitlements-num * cost * discount * days-in-month - $max = intval(4 * 25 * 0.7 * $diffInDays / 28); - $min = intval(4 * 25 * 0.7 * $diffInDays / 31); - - $wallet->refresh(); - $this->assertTrue($wallet->balance >= $balance - $max); - $this->assertTrue($wallet->balance <= $balance - $min); - - $transactions = \App\Transaction::where('object_id', $wallet->id) - ->where('object_type', \App\Wallet::class)->get(); - - // one round of the monthly invoicing, four sku deletions getting invoiced - $this->assertCount(5, $transactions); - } } diff --git a/src/tests/Feature/Jobs/SignupInvitationEmailTest.php b/src/tests/Feature/Jobs/SignupInvitationEmailTest.php new file mode 100644 index 00000000..e91c7a3e --- /dev/null +++ b/src/tests/Feature/Jobs/SignupInvitationEmailTest.php @@ -0,0 +1,68 @@ +invitation = SI::create(['email' => 'SignupInvitationEmailTest@external.com']); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->invitation->delete(); + } + + /** + * Test job handle + */ + public function testSignupInvitationEmailHandle(): void + { + Mail::fake(); + + // Assert that no jobs were pushed... + Mail::assertNothingSent(); + + $job = new SignupInvitationEmail($this->invitation); + $job->handle(); + + // Assert the email sending job was pushed once + Mail::assertSent(SignupInvitation::class, 1); + + // Assert the mail was sent to the code's email + Mail::assertSent(SignupInvitation::class, function ($mail) { + return $mail->hasTo($this->invitation->email); + }); + + $this->assertTrue($this->invitation->isSent()); + } + + /** + * Test job failure handling + */ + public function testSignupInvitationEmailFailure(): void + { + $this->markTestIncomplete(); + } +} diff --git a/src/tests/Feature/PlanTest.php b/src/tests/Feature/PlanTest.php index b3a0dd0d..704a2788 100644 --- a/src/tests/Feature/PlanTest.php +++ b/src/tests/Feature/PlanTest.php @@ -1,109 +1,124 @@ delete(); } /** * {@inheritDoc} */ public function tearDown(): void { Plan::where('title', 'test-plan')->delete(); parent::tearDown(); } /** * Tests for plan attributes localization */ public function testPlanLocalization(): void { $plan = Plan::create([ 'title' => 'test-plan', 'description' => [ 'en' => 'Plan-EN', 'de' => 'Plan-DE', ], 'name' => 'Test', ]); $this->assertSame('Plan-EN', $plan->description); $this->assertSame('Test', $plan->name); $plan->save(); $plan = Plan::where('title', 'test-plan')->first(); $this->assertSame('Plan-EN', $plan->description); $this->assertSame('Test', $plan->name); $this->assertSame('Plan-DE', $plan->getTranslation('description', 'de')); $this->assertSame('Test', $plan->getTranslation('name', 'de')); $plan->setTranslation('name', 'de', 'PrĂ¼fung')->save(); $this->assertSame('PrĂ¼fung', $plan->getTranslation('name', 'de')); $this->assertSame('Test', $plan->getTranslation('name', 'en')); $plan = Plan::where('title', 'test-plan')->first(); $this->assertSame('PrĂ¼fung', $plan->getTranslation('name', 'de')); $this->assertSame('Test', $plan->getTranslation('name', 'en')); // TODO: Test system locale change } /** * Tests for Plan::hasDomain() */ public function testHasDomain(): void { $plan = Plan::where('title', 'individual')->first(); $this->assertTrue($plan->hasDomain() === false); $plan = Plan::where('title', 'group')->first(); $this->assertTrue($plan->hasDomain() === true); } /** * Test for a plan's cost. */ public function testCost(): void { $plan = Plan::where('title', 'individual')->first(); $package_costs = 0; foreach ($plan->packages as $package) { $package_costs += $package->cost(); } $this->assertTrue( $package_costs == 999, "The total costs of all packages for this plan is not 9.99" ); $this->assertTrue( $plan->cost() == 999, "The total costs for this plan is not 9.99" ); $this->assertTrue($plan->cost() == $package_costs); } + + public function testTenant(): void + { + $plan = Plan::where('title', 'individual')->first(); + + $tenant = $plan->tenant()->first(); + + $this->assertInstanceof(\App\Tenant::class, $tenant); + $this->assertSame(1, $tenant->id); + + $tenant = $plan->tenant; + + $this->assertInstanceof(\App\Tenant::class, $tenant); + $this->assertSame(1, $tenant->id); + } } diff --git a/src/tests/Feature/SignupInvitationTest.php b/src/tests/Feature/SignupInvitationTest.php new file mode 100644 index 00000000..b32c63ad --- /dev/null +++ b/src/tests/Feature/SignupInvitationTest.php @@ -0,0 +1,118 @@ + 'test@domain.org']); + + $this->assertSame('test@domain.org', $invitation->email); + $this->assertSame(SI::STATUS_NEW, $invitation->status); + $this->assertSame(\config('app.tenant_id'), $invitation->tenant_id); + $this->assertTrue(preg_match('/^[a-f0-9-]{36}$/', $invitation->id) > 0); + + Queue::assertPushed(\App\Jobs\SignupInvitationEmail::class, 1); + + Queue::assertPushed( + \App\Jobs\SignupInvitationEmail::class, + function ($job) use ($invitation) { + $inv = TestCase::getObjectProperty($job, 'invitation'); + + return $inv->id === $invitation->id && $inv->email === $invitation->email; + } + ); + + $inst = SI::find($invitation->id); + + $this->assertInstanceOf(SI::class, $inst); + $this->assertSame($inst->id, $invitation->id); + $this->assertSame($inst->email, $invitation->email); + } + + /** + * Test SignupInvitation update + */ + public function testUpdate(): void + { + Queue::fake(); + + $invitation = SI::create(['email' => 'test@domain.org']); + + Queue::fake(); + + // Test that these status changes do not dispatch the email sending job + foreach ([SI::STATUS_FAILED, SI::STATUS_SENT, SI::STATUS_COMPLETED, SI::STATUS_NEW] as $status) { + $invitation->status = $status; + $invitation->save(); + } + + Queue::assertNothingPushed(); + + // SENT -> NEW should resend the invitation + SI::where('id', $invitation->id)->update(['status' => SI::STATUS_SENT]); + $invitation->refresh(); + $invitation->status = SI::STATUS_NEW; + $invitation->save(); + + Queue::assertPushed(\App\Jobs\SignupInvitationEmail::class, 1); + + Queue::assertPushed( + \App\Jobs\SignupInvitationEmail::class, + function ($job) use ($invitation) { + $inv = TestCase::getObjectProperty($job, 'invitation'); + + return $inv->id === $invitation->id && $inv->email === $invitation->email; + } + ); + + Queue::fake(); + + // FAILED -> NEW should resend the invitation + SI::where('id', $invitation->id)->update(['status' => SI::STATUS_FAILED]); + $invitation->refresh(); + $invitation->status = SI::STATUS_NEW; + $invitation->save(); + + Queue::assertPushed(\App\Jobs\SignupInvitationEmail::class, 1); + + Queue::assertPushed( + \App\Jobs\SignupInvitationEmail::class, + function ($job) use ($invitation) { + $inv = TestCase::getObjectProperty($job, 'invitation'); + + return $inv->id === $invitation->id && $inv->email === $invitation->email; + } + ); + } +} diff --git a/src/tests/Feature/SkuTest.php b/src/tests/Feature/SkuTest.php index 406ed56b..88d0cdfd 100644 --- a/src/tests/Feature/SkuTest.php +++ b/src/tests/Feature/SkuTest.php @@ -1,94 +1,109 @@ deleteTestUser('jane@kolabnow.com'); } public function tearDown(): void { $this->deleteTestUser('jane@kolabnow.com'); parent::tearDown(); } public function testPackageEntitlements(): void { $user = $this->getTestUser('jane@kolabnow.com'); $wallet = $user->wallets()->first(); $package = Package::where('title', 'lite')->first(); $sku_mailbox = Sku::where('title', 'mailbox')->first(); $sku_storage = Sku::where('title', 'storage')->first(); $user = $user->assignPackage($package); $this->backdateEntitlements($user->fresh()->entitlements, Carbon::now()->subMonthsWithoutOverflow(1)); $wallet->chargeEntitlements(); $this->assertTrue($wallet->balance < 0); } public function testSkuEntitlements(): void { $this->assertCount(4, Sku::where('title', 'mailbox')->first()->entitlements); } public function testSkuPackages(): void { $this->assertCount(2, Sku::where('title', 'mailbox')->first()->packages); } public function testSkuHandlerDomainHosting(): void { $sku = Sku::where('title', 'domain-hosting')->first(); $entitlement = $sku->entitlements->first(); $this->assertSame( Handlers\DomainHosting::entitleableClass(), $entitlement->entitleable_type ); } public function testSkuHandlerMailbox(): void { $sku = Sku::where('title', 'mailbox')->first(); $entitlement = $sku->entitlements->first(); $this->assertSame( Handlers\Mailbox::entitleableClass(), $entitlement->entitleable_type ); } public function testSkuHandlerStorage(): void { $sku = Sku::where('title', 'storage')->first(); $entitlement = $sku->entitlements->first(); $this->assertSame( Handlers\Storage::entitleableClass(), $entitlement->entitleable_type ); } + + public function testSkuTenant(): void + { + $sku = Sku::where('title', 'storage')->first(); + + $tenant = $sku->tenant()->first(); + + $this->assertInstanceof(\App\Tenant::class, $tenant); + $this->assertSame(1, $tenant->id); + + $tenant = $sku->tenant; + + $this->assertInstanceof(\App\Tenant::class, $tenant); + $this->assertSame(1, $tenant->id); + } } diff --git a/src/tests/Feature/TenantTest.php b/src/tests/Feature/TenantTest.php new file mode 100644 index 00000000..02a395b8 --- /dev/null +++ b/src/tests/Feature/TenantTest.php @@ -0,0 +1,40 @@ +first(); + + $wallet = $tenant->wallet(); + + $this->assertInstanceof(\App\Wallet::class, $wallet); + $this->assertSame($user->wallets->first()->id, $wallet->id); + } +} diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php index 07611a59..62d1c0ca 100644 --- a/src/tests/Feature/UserTest.php +++ b/src/tests/Feature/UserTest.php @@ -1,703 +1,884 @@ 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 { $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 { - $this->markTestIncomplete(); + $john = $this->getTestUser('john@kolab.org'); + $ned = $this->getTestUser('ned@kolab.org'); + $jack = $this->getTestUser('jack@kolab.org'); + $reseller1 = $this->getTestUser('reseller@kolabnow.com'); + $reseller2 = $this->getTestUser('reseller@reseller.com'); + $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 { - $this->markTestIncomplete(); + $john = $this->getTestUser('john@kolab.org'); + $ned = $this->getTestUser('ned@kolab.org'); + $jack = $this->getTestUser('jack@kolab.org'); + $reseller1 = $this->getTestUser('reseller@kolabnow.com'); + $reseller2 = $this->getTestUser('reseller@reseller.com'); + $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 { // Fake the queue, assert that no jobs were pushed... Queue::fake(); Queue::assertNothingPushed(); $user = User::create([ 'email' => 'user-test@' . \config('app.domain') ]); Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1); 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; }); */ } /** * Tests for User::domains() */ public function testDomains(): void { $user = $this->getTestUser('john@kolab.org'); - $domains = []; + $domain = $this->getTestDomain('useraccount.com', [ + 'status' => Domain::STATUS_NEW | Domain::STATUS_ACTIVE, + 'type' => Domain::TYPE_PUBLIC, + ]); - foreach ($user->domains() as $domain) { - $domains[] = $domain->namespace; - } + $domains = collect($user->domains())->pluck('namespace')->all(); - $this->assertContains(\config('app.domain'), $domains); + $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 = []; - foreach ($user->domains() as $domain) { - $domains[] = $domain->namespace; - } + $domains = collect($user->domains())->pluck('namespace')->all(); - $this->assertContains(\config('app.domain'), $domains); + $this->assertContains($domain->namespace, $domains); $this->assertNotContains('kolab.org', $domains); + + // Public domains of other tenants should not be returned + $domain->tenant_id = 2; + $domain->save(); + + $domains = collect($user->domains())->pluck('namespace')->all(); + + $this->assertNotContains($domain->namespace, $domains); } public function testUserQuota(): void { // TODO: This test does not test much, probably could be removed // or moved to somewhere else, or extended with // other entitlements() related cases. $user = $this->getTestUser('john@kolab.org'); $storage_sku = \App\Sku::where('title', 'storage')->first(); $count = 0; foreach ($user->entitlements()->get() as $entitlement) { if ($entitlement->sku_id == $storage_sku->id) { $count += 1; } } $this->assertTrue($count == 2); } /** * Test user deletion */ public function testDelete(): void { Queue::fake(); $user = $this->getTestUser('user-test@' . \config('app.domain')); $package = \App\Package::where('title', 'kolab')->first(); $user->assignPackage($package); $id = $user->id; $this->assertCount(4, $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::where('title', 'kolab')->first(); $package_domain = \App\Package::where('title', 'domain-hosting')->first(); $domain = $this->getTestDomain('UserAccount.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $userA->assignPackage($package_kolab); $domain->assignPackage($package_domain, $userA); $userA->assignPackage($package_kolab, $userB); $userA->assignPackage($package_kolab, $userC); $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(4, $entitlementsA->count()); $this->assertSame(4, $entitlementsB->count()); $this->assertSame(4, $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::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); + } + /** * 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); // 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(\config('app.name') . ' 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::where('title', 'kolab')->first(); $package_domain = \App\Package::where('title', 'domain-hosting')->first(); $domainA = $this->getTestDomain('UserAccount.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $domainB = $this->getTestDomain('UserAccountAdd.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $userA->assignPackage($package_kolab); $domainA->assignPackage($package_domain, $userA); $domainB->assignPackage($package_domain, $userA); $userA->assignPackage($package_kolab, $userB); $storage_sku = \App\Sku::where('title', 'storage')->first(); $now = \Carbon\Carbon::now(); $wallet_id = $userA->wallets->first()->id; // add an extra storage entitlement $ent1 = \App\Entitlement::create([ 'wallet_id' => $wallet_id, 'sku_id' => $storage_sku->id, 'cost' => 0, 'entitleable_id' => $userA->id, 'entitleable_type' => User::class, ]); $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); $entitlementsDomain = \App\Entitlement::where('entitleable_id', $domainA->id); // First delete the user $userA->delete(); $this->assertSame(0, $entitlementsA->count()); $this->assertSame(0, $entitlementsB->count()); $this->assertSame(0, $entitlementsDomain->count()); $this->assertTrue($userA->fresh()->trashed()); $this->assertTrue($userB->fresh()->trashed()); $this->assertTrue($domainA->fresh()->trashed()); $this->assertTrue($domainB->fresh()->trashed()); $this->assertFalse($userA->isDeleted()); $this->assertFalse($userB->isDeleted()); $this->assertFalse($domainA->isDeleted()); // Backdate one storage entitlement (it's not expected to be restored) \App\Entitlement::withTrashed()->where('id', $ent1->id) ->update(['deleted_at' => $now->copy()->subMinutes(2)]); // Backdate entitlements to assert that they were restored with proper updated_at timestamp \App\Entitlement::withTrashed()->where('wallet_id', $wallet_id) ->update(['updated_at' => $now->subMinutes(10)]); Queue::fake(); // Then restore it $userA->restore(); $userA->refresh(); $this->assertFalse($userA->trashed()); $this->assertFalse($userA->isDeleted()); $this->assertFalse($userA->isSuspended()); $this->assertFalse($userA->isLdapReady()); $this->assertFalse($userA->isImapReady()); $this->assertTrue($userA->isActive()); $this->assertTrue($userB->fresh()->trashed()); $this->assertTrue($domainB->fresh()->trashed()); $this->assertFalse($domainA->fresh()->trashed()); // Assert entitlements $this->assertSame(4, $entitlementsA->count()); // mailbox + groupware + 2 x storage $this->assertTrue($ent1->fresh()->trashed()); $entitlementsA->get()->each(function ($ent) { $this->assertTrue($ent->updated_at->greaterThan(\Carbon\Carbon::now()->subSeconds(5))); }); // We expect only CreateJob + UpdateJob pair for both user and domain. // Because how Illuminate/Database/Eloquent/SoftDeletes::restore() method // is implemented we cannot skip the UpdateJob in any way. // I don't want to overwrite this method, the extra job shouldn't do any harm. $this->assertCount(4, Queue::pushedJobs()); // @phpstan-ignore-line Queue::assertPushed(\App\Jobs\Domain\UpdateJob::class, 1); Queue::assertPushed(\App\Jobs\Domain\CreateJob::class, 1); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1); Queue::assertPushed( \App\Jobs\User\CreateJob::class, function ($job) use ($userA) { return $userA->id === TestCase::getObjectProperty($job, 'userId'); } ); Queue::assertPushedWithChain( \App\Jobs\User\CreateJob::class, [ \App\Jobs\User\VerifyJob::class, ] ); } /** * Tests for UserAliasesTrait::setAliases() */ public function testSetAliases(): void { 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()); // Add an alias $user->setAliases(['UserAlias1@UserAccount.com']); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); $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); $aliases = $user->aliases()->orderBy('alias')->get(); $this->assertCount(2, $aliases); $this->assertSame('useralias1@useraccount.com', $aliases[0]->alias); $this->assertSame('useralias2@useraccount.com', $aliases[1]->alias); // Remove an alias $user->setAliases(['UserAlias1@UserAccount.com']); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 3); $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() */ 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); } /** * 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/Feature/WalletTest.php b/src/tests/Feature/WalletTest.php index 36d13983..f83b584d 100644 --- a/src/tests/Feature/WalletTest.php +++ b/src/tests/Feature/WalletTest.php @@ -1,282 +1,398 @@ users as $user) { $this->deleteTestUser($user); } } public function tearDown(): void { foreach ($this->users as $user) { $this->deleteTestUser($user); } + Sku::select()->update(['fee' => 0]); + parent::tearDown(); } /** * Test that turning wallet balance from negative to positive * unsuspends the account */ public function testBalancePositiveUnsuspend(): void { $user = $this->getTestUser('UserWallet1@UserWallet.com'); $user->suspend(); $wallet = $user->wallets()->first(); $wallet->balance = -100; $wallet->save(); $this->assertTrue($user->isSuspended()); $this->assertNotNull($wallet->getSetting('balance_negative_since')); $wallet->balance = 100; $wallet->save(); $this->assertFalse($user->fresh()->isSuspended()); $this->assertNull($wallet->getSetting('balance_negative_since')); // TODO: Test group account and unsuspending domain/members } /** * Test for Wallet::balanceLastsUntil() */ public function testBalanceLastsUntil(): void { // Monthly cost of all entitlements: 999 // 28 days: 35.68 per day // 31 days: 32.22 per day $user = $this->getTestUser('jane@kolabnow.com'); $package = Package::where('title', 'kolab')->first(); $user->assignPackage($package); $wallet = $user->wallets()->first(); // User/entitlements created today, balance=0 $until = $wallet->balanceLastsUntil(); $this->assertSame( Carbon::now()->addMonthsWithoutOverflow(1)->toDateString(), $until->toDateString() ); // User/entitlements created today, balance=-10 CHF $wallet->balance = -1000; $until = $wallet->balanceLastsUntil(); $this->assertSame(null, $until); // User/entitlements created today, balance=-9,99 CHF (monthly cost) $wallet->balance = 999; $until = $wallet->balanceLastsUntil(); $daysInLastMonth = \App\Utils::daysInLastMonth(); $this->assertSame( Carbon::now()->addMonthsWithoutOverflow(1)->addDays($daysInLastMonth)->toDateString(), $until->toDateString() ); // Old entitlements, 100% discount $this->backdateEntitlements($wallet->entitlements, Carbon::now()->subDays(40)); $discount = \App\Discount::where('discount', 100)->first(); $wallet->discount()->associate($discount); $until = $wallet->refresh()->balanceLastsUntil(); $this->assertSame(null, $until); // User with no entitlements $wallet->discount()->dissociate($discount); $wallet->entitlements()->delete(); $until = $wallet->refresh()->balanceLastsUntil(); $this->assertSame(null, $until); } /** * Test for Wallet::costsPerDay() */ public function testCostsPerDay(): void { // 999 // 28 days: 35.68 // 31 days: 32.22 $user = $this->getTestUser('jane@kolabnow.com'); $package = Package::where('title', 'kolab')->first(); $mailbox = Sku::where('title', 'mailbox')->first(); $user->assignPackage($package); $wallet = $user->wallets()->first(); $costsPerDay = $wallet->costsPerDay(); $this->assertTrue($costsPerDay < 35.68); $this->assertTrue($costsPerDay > 32.22); } /** * Verify a wallet is created, when a user is created. */ public function testCreateUserCreatesWallet(): void { $user = $this->getTestUser('UserWallet1@UserWallet.com'); $this->assertCount(1, $user->wallets); } /** * Verify a user can haz more wallets. */ public function testAddWallet(): void { $user = $this->getTestUser('UserWallet2@UserWallet.com'); $user->wallets()->save( new Wallet(['currency' => 'USD']) ); $this->assertCount(2, $user->wallets); $user->wallets()->each( function ($wallet) { $this->assertEquals(0, $wallet->balance); } ); } /** * Verify we can not delete a user wallet that holds balance. */ public function testDeleteWalletWithCredit(): void { $user = $this->getTestUser('UserWallet3@UserWallet.com'); $user->wallets()->each( function ($wallet) { $wallet->credit(100)->save(); } ); $user->wallets()->each( function ($wallet) { $this->assertFalse($wallet->delete()); } ); } /** * Verify we can not delete a wallet that is the last wallet. */ public function testDeleteLastWallet(): void { $user = $this->getTestUser('UserWallet4@UserWallet.com'); $this->assertCount(1, $user->wallets); $user->wallets()->each( function ($wallet) { $this->assertFalse($wallet->delete()); } ); } /** * Verify we can remove a wallet that is an additional wallet. */ public function testDeleteAddtWallet(): void { $user = $this->getTestUser('UserWallet5@UserWallet.com'); $user->wallets()->save( new Wallet(['currency' => 'USD']) ); $user->wallets()->each( function ($wallet) { if ($wallet->currency == 'USD') { $this->assertNotFalse($wallet->delete()); } } ); } /** * Verify a wallet can be assigned a controller. */ public function testAddWalletController(): void { $userA = $this->getTestUser('WalletControllerA@WalletController.com'); $userB = $this->getTestUser('WalletControllerB@WalletController.com'); $userA->wallets()->each( function ($wallet) use ($userB) { $wallet->addController($userB); } ); $this->assertCount(1, $userB->accounts); $aWallet = $userA->wallets()->first(); $bAccount = $userB->accounts()->first(); $this->assertTrue($bAccount->id === $aWallet->id); } /** * Verify controllers can also be removed from wallets. */ public function testRemoveWalletController(): void { $userA = $this->getTestUser('WalletController2A@WalletController.com'); $userB = $this->getTestUser('WalletController2B@WalletController.com'); $userA->wallets()->each( function ($wallet) use ($userB) { $wallet->addController($userB); } ); $userB->refresh(); $userB->accounts()->each( function ($wallet) use ($userB) { $wallet->removeController($userB); } ); $this->assertCount(0, $userB->accounts); } + + /** + * Test for charging and removing entitlements (including tenant commission calculations) + */ + public function testChargeAndDeleteEntitlements(): void + { + $user = $this->getTestUser('jane@kolabnow.com'); + $wallet = $user->wallets()->first(); + $discount = \App\Discount::where('discount', 30)->first(); + $wallet->discount()->associate($discount); + $wallet->save(); + + // Add 40% fee to all SKUs + Sku::select()->update(['fee' => DB::raw("`cost` * 0.4")]); + + $package = Package::where('title', 'kolab')->first(); + $storage = Sku::where('title', 'storage')->first(); + $user->assignPackage($package); + $user->assignSku($storage, 2); + $user->refresh(); + + // Reset reseller's wallet balance and transactions + $reseller_wallet = $user->tenant->wallet(); + $reseller_wallet->balance = 0; + $reseller_wallet->save(); + Transaction::where('object_id', $reseller_wallet->id)->where('object_type', \App\Wallet::class)->delete(); + + // ------------------------------------ + // Test normal charging of entitlements + // ------------------------------------ + + // Backdate and chanrge entitlements, we're expecting one month to be charged + // Set fake NOW date to make simpler asserting results that depend on number of days in current/last month + Carbon::setTestNow(Carbon::create(2021, 5, 21, 12)); + $backdate = Carbon::now()->subWeeks(7); + $this->backdateEntitlements($user->entitlements, $backdate); + $charge = $wallet->chargeEntitlements(); + $wallet->refresh(); + $reseller_wallet->refresh(); + + // 388 + 310 + 17 + 17 = 732 + $this->assertSame(-732, $wallet->balance); + // 388 - 555 x 40% + 310 - 444 x 40% + 34 - 50 x 40% = 312 + $this->assertSame(312, $reseller_wallet->balance); + + $transactions = Transaction::where('object_id', $wallet->id) + ->where('object_type', \App\Wallet::class)->get(); + $reseller_transactions = Transaction::where('object_id', $reseller_wallet->id) + ->where('object_type', \App\Wallet::class)->get(); + + $this->assertCount(1, $reseller_transactions); + $trans = $reseller_transactions[0]; + $this->assertSame("Charged user jane@kolabnow.com", $trans->description); + $this->assertSame(312, $trans->amount); + $this->assertSame(Transaction::WALLET_CREDIT, $trans->type); + + $this->assertCount(1, $transactions); + $trans = $transactions[0]; + $this->assertSame('', $trans->description); + $this->assertSame(-732, $trans->amount); + $this->assertSame(Transaction::WALLET_DEBIT, $trans->type); + + // TODO: Test entitlement transaction records + + // ----------------------------------- + // Test charging on entitlement delete + // ----------------------------------- + + $transactions = Transaction::where('object_id', $wallet->id) + ->where('object_type', \App\Wallet::class)->delete(); + $reseller_transactions = Transaction::where('object_id', $reseller_wallet->id) + ->where('object_type', \App\Wallet::class)->delete(); + + $user->removeSku($storage, 2); + + // we expect the wallet to have been charged for 19 days of use of + // 2 deleted storage entitlements + $wallet->refresh(); + $reseller_wallet->refresh(); + + // 2 x round(25 / 31 * 19 * 0.7) = 22 + $this->assertSame(-(732 + 22), $wallet->balance); + // 22 - 2 x round(25 * 0.4 / 31 * 19) = 10 + $this->assertSame(312 + 10, $reseller_wallet->balance); + + $transactions = Transaction::where('object_id', $wallet->id) + ->where('object_type', \App\Wallet::class)->get(); + $reseller_transactions = Transaction::where('object_id', $reseller_wallet->id) + ->where('object_type', \App\Wallet::class)->get(); + + $this->assertCount(2, $reseller_transactions); + $trans = $reseller_transactions[0]; + $this->assertSame("Charged user jane@kolabnow.com", $trans->description); + $this->assertSame(5, $trans->amount); + $this->assertSame(Transaction::WALLET_CREDIT, $trans->type); + $trans = $reseller_transactions[1]; + $this->assertSame("Charged user jane@kolabnow.com", $trans->description); + $this->assertSame(5, $trans->amount); + $this->assertSame(Transaction::WALLET_CREDIT, $trans->type); + + $this->assertCount(2, $transactions); + $trans = $transactions[0]; + $this->assertSame('', $trans->description); + $this->assertSame(-11, $trans->amount); + $this->assertSame(Transaction::WALLET_DEBIT, $trans->type); + $trans = $transactions[1]; + $this->assertSame('', $trans->description); + $this->assertSame(-11, $trans->amount); + $this->assertSame(Transaction::WALLET_DEBIT, $trans->type); + + // TODO: Test entitlement transaction records + } } diff --git a/src/tests/TestCase.php b/src/tests/TestCase.php index a69d434e..e9f933c6 100644 --- a/src/tests/TestCase.php +++ b/src/tests/TestCase.php @@ -1,36 +1,70 @@ withoutMiddleware(ThrottleRequests::class); + } + protected function backdateEntitlements($entitlements, $targetDate) { + $wallets = []; + $ids = []; + foreach ($entitlements as $entitlement) { - $entitlement->created_at = $targetDate; - $entitlement->updated_at = $targetDate; - $entitlement->save(); + $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(); - $owner = $entitlement->wallet->owner; - $owner->created_at = $targetDate; - $owner->save(); + \App\User::whereIn('id', $owners)->update(['created_at' => $targetDate]); } } /** * Set baseURL to the admin UI location */ protected static function useAdminUrl(): void { // This will set base URL for all tests in a file. // If we wanted to access both user and admin in one test // we can also just call post/get/whatever with full url \config(['app.url' => str_replace('//', '//admin.', \config('app.url'))]); url()->forceRootUrl(config('app.url')); } + + /** + * Set baseURL to the reseller UI location + */ + protected static function useResellerUrl(): void + { + // This will set base URL for all tests in a file. + // If we wanted to access both user and admin in one test + // we can also just call post/get/whatever with full url + \config(['app.url' => str_replace('//', '//reseller.', \config('app.url'))]); + url()->forceRootUrl(config('app.url')); + } } diff --git a/src/tests/TestCaseDusk.php b/src/tests/TestCaseDusk.php index 6fb3f5cd..30398dc9 100644 --- a/src/tests/TestCaseDusk.php +++ b/src/tests/TestCaseDusk.php @@ -1,105 +1,116 @@ addArguments([ '--lang=en_US', '--disable-gpu', '--headless', '--use-fake-ui-for-media-stream', '--use-fake-device-for-media-stream', '--enable-usermedia-screen-capturing', // '--auto-select-desktop-capture-source="Entire screen"', '--ignore-certificate-errors', '--incognito', ]); // For file download handling $prefs = [ 'profile.default_content_settings.popups' => 0, 'download.default_directory' => __DIR__ . '/Browser/downloads', ]; $options->setExperimentalOption('prefs', $prefs); if (getenv('TESTS_MODE') == 'phone') { // Fake User-Agent string for mobile mode $ua = 'Mozilla/5.0 (Linux; Android 4.0.4; Galaxy Nexus Build/IMM76B) AppleWebKit/537.36' . ' (KHTML, like Gecko) Chrome/60.0.3112.90 Mobile Safari/537.36'; $options->setExperimentalOption('mobileEmulation', ['userAgent' => $ua]); $options->addArguments(['--window-size=375,667']); } elseif (getenv('TESTS_MODE') == 'tablet') { // Fake User-Agent string for mobile mode $ua = 'Mozilla/5.0 (Linux; Android 6.0.1; vivo 1603 Build/MMB29M) AppleWebKit/537.36 ' . ' (KHTML, like Gecko) Chrome/58.0.3029.83 Mobile Safari/537.36'; $options->setExperimentalOption('mobileEmulation', ['userAgent' => $ua]); $options->addArguments(['--window-size=800,640']); } else { $options->addArguments(['--window-size=1280,1024']); } // Make sure downloads dir exists and is empty if (!file_exists(__DIR__ . '/Browser/downloads')) { mkdir(__DIR__ . '/Browser/downloads', 0777, true); } else { foreach (glob(__DIR__ . '/Browser/downloads/*') as $file) { @unlink($file); } } return RemoteWebDriver::create( 'http://localhost:9515', DesiredCapabilities::chrome()->setCapability( ChromeOptions::CAPABILITY, $options ) ); } /** * Replace Dusk's Browser with our (extended) Browser */ protected function newBrowser($driver) { return new Browser($driver); } /** * Set baseURL to the admin UI location */ protected static function useAdminUrl(): void { // This will set baseURL for all tests in this file // If we wanted to visit both user and admin in one test // we can also just call visit() with full url Browser::$baseUrl = str_replace('//', '//admin.', \config('app.url')); } + + /** + * Set baseURL to the reseller UI location + */ + protected static function useResellerUrl(): void + { + // This will set baseURL for all tests in this file + // If we wanted to visit both user and admin in one test + // we can also just call visit() with full url + Browser::$baseUrl = str_replace('//', '//reseller.', \config('app.url')); + } } diff --git a/src/tests/Unit/Mail/SignupInvitationTest.php b/src/tests/Unit/Mail/SignupInvitationTest.php new file mode 100644 index 00000000..c27b4188 --- /dev/null +++ b/src/tests/Unit/Mail/SignupInvitationTest.php @@ -0,0 +1,44 @@ + 'abc', + 'email' => 'test@email', + ]); + + $mail = $this->fakeMail(new SignupInvitation($invitation)); + + $html = $mail['html']; + $plain = $mail['plain']; + + $url = Utils::serviceUrl('/signup/invite/' . $invitation->id); + $link = "$url"; + $appName = \config('app.name'); + + $this->assertMailSubject("$appName Invitation", $mail['message']); + + $this->assertStringStartsWith('', $html); + $this->assertTrue(strpos($html, $link) > 0); + $this->assertTrue(strpos($html, "invited to join $appName") > 0); + + $this->assertStringStartsWith("Hi,", $plain); + $this->assertTrue(strpos($plain, "invited to join $appName") > 0); + $this->assertTrue(strpos($plain, $url) > 0); + } +} diff --git a/src/tests/Unit/SignupInvitationTest.php b/src/tests/Unit/SignupInvitationTest.php new file mode 100644 index 00000000..640a4644 --- /dev/null +++ b/src/tests/Unit/SignupInvitationTest.php @@ -0,0 +1,35 @@ +status = $status; + + $this->assertSame($status === SignupInvitation::STATUS_NEW, $invitation->isNew()); + $this->assertSame($status === SignupInvitation::STATUS_SENT, $invitation->isSent()); + $this->assertSame($status === SignupInvitation::STATUS_FAILED, $invitation->isFailed()); + $this->assertSame($status === SignupInvitation::STATUS_COMPLETED, $invitation->isCompleted()); + } + } +} diff --git a/src/tests/data/email.csv b/src/tests/data/email.csv new file mode 100644 index 00000000..ed3af847 --- /dev/null +++ b/src/tests/data/email.csv @@ -0,0 +1,2 @@ +email1@test.com +email2@test.com diff --git a/src/tests/data/empty.csv b/src/tests/data/empty.csv new file mode 100644 index 00000000..e69de29b diff --git a/src/webpack.mix.js b/src/webpack.mix.js index 0b343e59..b0d4570e 100644 --- a/src/webpack.mix.js +++ b/src/webpack.mix.js @@ -1,37 +1,38 @@ /* |-------------------------------------------------------------------------- | Mix Asset Management |-------------------------------------------------------------------------- | | Mix provides a clean, fluent API for defining some Webpack build steps | for your Laravel application. By default, we are compiling the Sass | file for the application as well as bundling up all the JS files. | */ const { spawn } = require('child_process'); const glob = require('glob'); const mix = require('laravel-mix'); mix.webpackConfig({ resolve: { alias: { 'jquery$': 'jquery/dist/jquery.slim.js', } } }) +mix.js('resources/js/user/app.js', 'public/js/user.js').vue() + .js('resources/js/admin/app.js', 'public/js/admin.js').vue() + .js('resources/js/reseller/app.js', 'public/js/reseller.js').vue() + mix.before(() => { spawn('php', ['resources/build/before.php'], { stdio: 'inherit' }) }) -mix.js('resources/js/user.js', 'public/js').vue() - .js('resources/js/admin.js', 'public/js').vue() - glob.sync('resources/themes/*/', {}).forEach(fromDir => { const toDir = fromDir.replace('resources/themes/', 'public/themes/') mix.sass(fromDir + 'app.scss', toDir) .sass(fromDir + 'document.scss', toDir); })