diff --git a/README.md b/README.md index 2dc27ee6..24004978 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,32 @@ ## Quickstart Instructions Really quick? ``` $ bin/quickstart.sh ``` More detailed: ``` $ bin/regen-certs $ docker pull kolab/centos7:latest $ docker-compose down $ docker-compose up -d $ cd src/ $ composer install $ npm install $ cp .env.example .env $ echo "" >> .env $ cat .env.local >> .env $ ./artisan key:generate $ ./artisan jwt:secret -f -$ artisan clear-compiled +$ ./artisan clear-compiled $ npm run dev $ rm -rf database/database.sqlite $ touch database/database.sqlite $ ./artisan migrate:refresh --seed $ ./artisan serve ``` NOTE: Set `APP_PUBLIC_URL` and `MOLLIE_KEY` and other such private settings in `.env.local` diff --git a/bin/phpunit-fast b/bin/phpunit-fast new file mode 100755 index 00000000..e2f0e4d9 --- /dev/null +++ b/bin/phpunit-fast @@ -0,0 +1,13 @@ +#!/bin/bash + +cwd=$(dirname $0) + +pushd ${cwd}/../src/ + +php vendor/bin/phpunit \ + --no-coverage \ + --stop-on-defect \ + --stop-on-error \ + --stop-on-failure $* + +popd diff --git a/bin/quickstart.sh b/bin/quickstart.sh index 45cb849c..7511b561 100755 --- a/bin/quickstart.sh +++ b/bin/quickstart.sh @@ -1,87 +1,87 @@ #!/bin/bash set -e function die() { echo "$1" exit 1 } rpm -qv composer >/dev/null 2>&1 || \ test ! -z "$(which composer 2>/dev/null)" || \ die "Is composer installed?" rpm -qv docker-compose >/dev/null 2>&1 || \ test ! -z "$(which docker-compose 2>/dev/null)" || \ die "Is docker-compose installed?" rpm -qv npm >/dev/null 2>&1 || \ test ! -z "$(which npm 2>/dev/null)" || \ die "Is npm installed?" rpm -qv php >/dev/null 2>&1 || \ test ! -z "$(which php 2>/dev/null)" || \ die "Is php installed?" rpm -qv php-ldap >/dev/null 2>&1 || \ test ! -z "$(php --ini | grep ldap)" || \ die "Is php-ldap installed?" rpm -qv php-mysqlnd >/dev/null 2>&1 || \ test ! -z "$(php --ini | grep mysql)" || \ die "Is php-mysqlnd installed?" base_dir=$(dirname $(dirname $0)) docker pull docker.io/kolab/centos7:latest docker-compose down docker-compose build pushd ${base_dir}/src/ cp .env.example .env if [ -f ".env.local" ]; then # Ensure there's a line ending echo "" >> .env cat .env.local >> .env fi popd bin/regen-certs docker-compose up -d coturn kolab mariadb openvidu proxy redis pushd ${base_dir}/src/ rm -rf vendor/ composer.lock -composer install +php -dmemory_limit=-1 /bin/composer install npm install find bootstrap/cache/ -type f ! -name ".gitignore" -delete ./artisan key:generate ./artisan jwt:secret -f ./artisan clear-compiled ./artisan cache:clear if [ ! -z "$(rpm -qv chromium 2>/dev/null)" ]; then chver=$(rpmquery --queryformat="%{VERSION}" chromium | awk -F'.' '{print $1}') ./artisan dusk:chrome-driver ${chver} fi if [ ! -f 'resources/countries.php' ]; then ./artisan data:countries fi npm run dev popd docker-compose up -d worker pushd ${base_dir}/src/ rm -rf database/database.sqlite ./artisan db:ping --wait php -dmemory_limit=512M ./artisan migrate:refresh --seed ./artisan serve popd diff --git a/docker/ds389/Dockerfile b/docker/ds389/Dockerfile new file mode 100644 index 00000000..50831532 --- /dev/null +++ b/docker/ds389/Dockerfile @@ -0,0 +1,41 @@ +FROM centos/centos7:latest + +MAINTAINER Liutauras Adomaitis + +RUN yum -y install \ + epel-release \ + 389-ds-base \ + 389-adminutil \ + gettext && \ + yum clean all + +COPY *.tpl / +COPY kolab-schema.ldif /etc/dirsrv/schema/99kolab-schema.ldif + +RUN for F in $(ls *.tpl); do eval "echo \"$(cat $F)\"" | tee ${F%%.tpl}; done + +RUN rm -fr /var/lock /usr/lib/systemd/system && \ + setup-ds.pl -ddd --silent --file /ds_setup.inf && \ + chown nobody.nobody -R /var/lib/dirsrv/ && \ + mv mgmt_com-install.ldif ${DOMAIN_DB}-install.ldif && \ + mv hosted_com-install.ldif ${HOSTED_DOMAIN_DB}-install.ldif && \ + mv *.ldif /var/lib/dirsrv/slapd-${DS_INSTANCE_NAME}/ldif/. + +# This only copies another kolab-schema.ldif +#COPY *.ldif /var/lib/dirsrv/slapd-${DS_INSTANCE_NAME}/ldif/ + +EXPOSE 389 + +CMD cd /var/lib/dirsrv/slapd-${DS_INSTANCE_NAME}/ldif/ && \ + for ldif in $(ls *-import.ldif || ls *-install.ldif || true); do \ + sed -r -i -e 's/mailHost: .*$/mailHost: localhost/g' ${ldif}; \ + chmod 644 ${ldif}; \ + namespace=$(echo "${ldif}" | sed -e 's/-import.ldif$//' -e 's/-install.ldif$//'); \ + /usr/lib64/dirsrv/slapd-${DS_INSTANCE_NAME}/ldif2db \ + -Z ${DS_INSTANCE_NAME} \ + -n ${namespace} \ + -i /var/lib/dirsrv/slapd-${DS_INSTANCE_NAME}/ldif/${ldif}; \ + done && \ + /usr/lib64/dirsrv/slapd-hkccp/start-slapd && \ + tail -F /var/log/dirsrv/slapd-${DS_INSTANCE_NAME}/{access,audit,errors} + diff --git a/docker/ds389/ds_adjustments.ldif.tpl b/docker/ds389/ds_adjustments.ldif.tpl new file mode 100644 index 00000000..525979d9 --- /dev/null +++ b/docker/ds389/ds_adjustments.ldif.tpl @@ -0,0 +1,105 @@ +dn: cn=config +changetype: modify +replace: nsslapd-accesslog-logging-enabled +nsslapd-accesslog-logging-enabled: ${DS389_ACCESSLOG:-on} + +dn: cn=config +changetype: modify +replace: nsslapd-auditlog-logging-enabled +nsslapd-auditlog-logging-enabled: ${DS389_AUDITLOG:-on} + +dn: cn=config +changetype: modify +replace: nsslapd-sizelimit +nsslapd-sizelimit: -1 + +dn: cn=config +changetype: modify +replace: nsslapd-idletimeout +nsslapd-idletimeout: 0 + +dn: cn=config +changetype: modify +replace: nsslapd-timelimit +nsslapd-timelimit: -1 + +dn: cn=config +changetype: modify +replace: nsslapd-lookthroughlimit +nsslapd-lookthroughlimit: -1 + +dn: cn=config +changetype: modify +replace: nsslapd-allow-anonymous-access +nsslapd-allow-anonymous-access: rootdse + +dn: cn=alias,cn=default indexes,cn=config,cn=ldbm database,cn=plugins,cn=config +changetype: add +objectClass: top +objectClass: nsIndex +cn: alias +nsSystemIndex: false +nsIndexType: pres +nsIndexType: eq +nsIndexType: sub + +dn: cn=mailAlternateAddress,cn=default indexes,cn=config,cn=ldbm database,cn=plugins,cn=config +changetype: modify +add: nsIndexType +nsIndexType: pres +nsIndexType: sub + +dn: cn=associateddomain,cn=default indexes,cn=config,cn=ldbm database,cn=plugins,cn=config +changetype: add +objectclass: top +objectclass: nsindex +cn: associateddomain +nsSystemIndex: false +nsindextype: pres +nsindextype: eq + +dn: cn=ACL Plugin,cn=plugins,cn=config +changetype: modify +replace: nsslapd-aclpb-max-selected-acls +nsslapd-aclpb-max-selected-acls: 8192 + +dn: cn=7-bit check,cn=plugins,cn=config +changetype: modify +replace: nsslapd-pluginEnabled +nsslapd-pluginEnabled: off + +dn: cn=attribute uniqueness,cn=plugins,cn=config +changetype: modify +replace: nsslapd-pluginEnabled +nsslapd-pluginEnabled: on + +dn: cn=referential integrity postoperation,cn=plugins,cn=config +changetype: modify +replace: nsslapd-pluginEnabled +nsslapd-pluginEnabled: on + +dn: cn=Account Policy Plugin,cn=plugins,cn=config +changetype: modify +replace: nsslapd-pluginEnabled +nsslapd-pluginEnabled: on + +dn: cn=Account Policy Plugin,cn=plugins,cn=config +changetype: modify +replace: nsslapd-pluginarg0 +nsslapd-pluginarg0: cn=config,cn=Account Policy Plugin,cn=plugins,cn=config + +dn: cn=config,cn=Account Policy Plugin,cn=plugins,cn=config +changetype: modify +replace: alwaysrecordlogin +alwaysrecordlogin: yes + +dn: cn=config,cn=Account Policy Plugin,cn=plugins,cn=config +changetype: modify +replace: stateattrname +stateattrname: lastLoginTime + +dn: cn=config,cn=Account Policy Plugin,cn=plugins,cn=config +changetype: modify +replace: altstateattrname +altstateattrname: createTimestamp + diff --git a/docker/ds389/ds_admin_backend.ldif.tpl b/docker/ds389/ds_admin_backend.ldif.tpl new file mode 100644 index 00000000..e269b7ab --- /dev/null +++ b/docker/ds389/ds_admin_backend.ldif.tpl @@ -0,0 +1,20 @@ +dn: cn=\"${LDAP_ADMIN_ROOT_DN}\",cn=mapping tree,cn=config +objectClass: top +objectClass: extensibleObject +objectClass: nsMappingTree +cn: ${LDAP_ADMIN_ROOT_DN} +nsslapd-state: backend +nsslapd-backend: ${DOMAIN_DB} + +dn: cn=${DOMAIN_DB},cn=ldbm database,cn=plugins,cn=config +objectClass: top +objectClass: extensibleObject +objectClass: nsBackendInstance +cn: ${DOMAIN_DB} +nsslapd-suffix: ${LDAP_ADMIN_ROOT_DN} +nsslapd-cachesize: -1 +nsslapd-cachememsize: 10485760 +nsslapd-readonly: off +nsslapd-require-index: off +nsslapd-directory: /var/lib/dirsrv/slapd-${DS_INSTANCE_NAME:-$(hostname -s)}/db/${DOMAIN_DB} +nsslapd-dncachememsize: 10485760 diff --git a/docker/ds389/ds_hosted_backend.ldif.tpl b/docker/ds389/ds_hosted_backend.ldif.tpl new file mode 100644 index 00000000..329a4978 --- /dev/null +++ b/docker/ds389/ds_hosted_backend.ldif.tpl @@ -0,0 +1,21 @@ +dn: cn=\"${LDAP_HOSTED_ROOT_DN}\",cn=mapping tree,cn=config +objectClass: top +objectClass: extensibleObject +objectClass: nsMappingTree +nsslapd-state: backend +cn: ${LDAP_HOSTED_ROOT_DN} +nsslapd-backend: ${HOSTED_DOMAIN_DB} + +dn: cn=${HOSTED_DOMAIN_DB},cn=ldbm database,cn=plugins,cn=config +objectClass: top +objectClass: extensibleobject +objectClass: nsbackendinstance +cn: ${HOSTED_DOMAIN_DB} +nsslapd-suffix: ${LDAP_HOSTED_ROOT_DN} +nsslapd-cachesize: -1 +nsslapd-cachememsize: 10485760 +nsslapd-readonly: off +nsslapd-require-index: off +nsslapd-directory: /var/lib/dirsrv/slapd-${DS_INSTANCE_NAME:-$(hostname -s)}/db/${HOSTED_DOMAIN_DB} +nsslapd-dncachememsize: 10485760 + diff --git a/docker/ds389/ds_setup.inf.tpl b/docker/ds389/ds_setup.inf.tpl new file mode 100644 index 00000000..8582d963 --- /dev/null +++ b/docker/ds389/ds_setup.inf.tpl @@ -0,0 +1,26 @@ +[General] +FullMachineName = ${FULL_MACHINE_NAME} +SuiteSpotUserID = nobody +SuiteSpotGroup = nobody +AdminDomain = ${DOMAIN} +StrictHostCheck = ${STRICT_HOST_CHECK} +ConfigDirectoryLdapURL = ldap://${DS_INSTANCE_NAME}:389/o=NetscapeRoot +ConfigDirectoryAdminID = admin +ConfigDirectoryAdminPwd = ${LDAP_ADMIN_BIND_PW} + +[slapd] +start_server = 0 +SlapdConfigForMC = Yes +UseExistingMC = 0 +ServerPort = 389 +ServerIdentifier = ${DS_INSTANCE_NAME} +RootDN = ${LDAP_ADMIN_BIND_DN} +RootDNPwd = ${LDAP_ADMIN_BIND_PW} +AddSampleEntries = No +## InstallLdifFile = /ds_install.ldif +ConfigFile = /ds_adjustments.ldif +ds_bename = ${DOMAIN_DB} +Suffix = ${LDAP_ADMIN_ROOT_DN} +ConfigFile = /ds_admin_backend.ldif +ConfigFile = /ds_hosted_backend.ldif + diff --git a/docker/ds389/hosted_com-install.ldif.tpl b/docker/ds389/hosted_com-install.ldif.tpl new file mode 100644 index 00000000..6ab8ff38 --- /dev/null +++ b/docker/ds389/hosted_com-install.ldif.tpl @@ -0,0 +1,77 @@ +# ${HOSTED_DOMAIN}, ${LDAP_DOMAIN_BASE_DN} +dn: associateddomain=${HOSTED_DOMAIN},${LDAP_DOMAIN_BASE_DN} +objectclass: top +objectclass: domainrelatedobject +objectclass: inetdomain +inetdomainstatus: active +associateddomain: ${HOSTED_DOMAIN} +inetdomainbasedn: ${LDAP_HOSTED_ROOT_DN} + +# ${LDAP_HOSTED_ROOT_DN} +dn: ${LDAP_HOSTED_ROOT_DN} +aci: (targetattr=\"carLicense || description || displayName || facsimileTelephoneNumber || homePhone || homePostalAddress || initials || jpegPhoto || labeledURI || mobile || pager || photo || postOfficeBox || postalAddress || postalCode || preferredDeliveryMethod || preferredLanguage || registeredAddress || roomNumber || secretary || seeAlso || st || street || telephoneNumber || telexNumber || title || userCertificate || userPassword || userSMIMECertificate || x500UniqueIdentifier\")(version 3.0; acl \"Enable self write for common attributes\"; allow (write) userdn=\"ldap:///self\";) +aci: (targetattr=\"*\")(version 3.0;acl \"Directory Administrators Group\";allow (all) (groupdn=\"ldap:///cn=Directory Administrators,${LDAP_HOSTED_ROOT_DN}\" or roledn=\"ldap:///cn=kolab-admin,${LDAP_HOSTED_ROOT_DN}\");) +aci: (targetattr=\"*\")(version 3.0; acl \"Configuration Administrators Group\"; allow (all) groupdn=\"ldap:///cn=Configuration Administrators,ou=Groups,ou=TopologyManagement,o=NetscapeRoot\";) +aci: (targetattr=\"*\")(version 3.0; acl \"Configuration Administrator\"; allow (all) userdn=\"ldap:///uid=admin,ou=Administrators,ou=TopologyManagement,o=NetscapeRoot\";) +aci: (targetattr=\"*\")(version 3.0; acl \"SIE Group\"; allow (all) groupdn = \"ldap:///cn=slapd-${DS_INSTANCE_NAME},cn=389 Directory Server,cn=Server Group,cn=${FULL_MACHINE_NAME},ou=${DOMAIN},o=NetscapeRoot\";) +aci: (targetattr=\"*\") (version 3.0;acl \"Search Access\";allow (read,compare,search)(userdn = \"ldap:///${LDAP_HOSTED_ROOT_DN}??sub?(objectclass=*)\");) +aci: (targetattr=\"*\") (version 3.0;acl \"Service Search Access\";allow (read,compare,search)(userdn = \"ldap:///${LDAP_SERVICE_BIND_DN}\");) +objectClass: top +objectClass: domain +dc: ${HOSTED_DOMAIN%.com} + +# cn=2fa-user, ${LDAP_HOSTED_ROOT_DN} +dn: cn=2fa-user,${LDAP_HOSTED_ROOT_DN} +cn: 2fa-user +description: 2fa-user role +objectclass: top +objectclass: ldapsubentry +objectclass: nsmanagedroledefinition +objectclass: nsroledefinition +objectclass: nssimpleroledefinition + +# cn=activesync-user, ${LDAP_HOSTED_ROOT_DN} +dn: cn=activesync-user,${LDAP_HOSTED_ROOT_DN} +cn: activesync-user +description: activesync-user role +objectclass: top +objectclass: ldapsubentry +objectclass: nsmanagedroledefinition +objectclass: nsroledefinition +objectclass: nssimpleroledefinition + +# cn=imap-user, ${LDAP_HOSTED_ROOT_DN} +dn: cn=imap-user,${LDAP_HOSTED_ROOT_DN} +cn: imap-user +description: imap-user role +objectclass: top +objectclass: ldapsubentry +objectclass: nsmanagedroledefinition +objectclass: nsroledefinition +objectclass: nssimpleroledefinition + +# ou=Groups, ${LDAP_HOSTED_ROOT_DN} +dn: ou=Groups,${LDAP_HOSTED_ROOT_DN} +ou: Groups +objectClass: top +objectClass: organizationalunit + +# ou=People, ${LDAP_HOSTED_ROOT_DN} +dn: ou=People,${LDAP_HOSTED_ROOT_DN} +aci: (targetattr=\"*\") (version 3.0;acl \"Hosted Kolab Services\";allow (all)(userdn = \"ldap:///${LDAP_HOSTED_BIND_DN}\");) +ou: People +objectClass: top +objectClass: organizationalunit + +# ou=Resources, ${LDAP_HOSTED_ROOT_DN} +dn: ou=Resources,${LDAP_HOSTED_ROOT_DN} +ou: Resources +objectClass: top +objectClass: organizationalunit + +# ou=Shared Folders, ${LDAP_HOSTED_ROOT_DN} +dn: ou=Shared Folders,${LDAP_HOSTED_ROOT_DN} +ou: Shared Folders +objectClass: top +objectClass: organizationalunit + diff --git a/docker/ds389/kolab-schema.ldif b/docker/ds389/kolab-schema.ldif new file mode 100644 index 00000000..222891d0 --- /dev/null +++ b/docker/ds389/kolab-schema.ldif @@ -0,0 +1,384 @@ +# $Id$ +# (c) 2003, 2004 Tassilo Erlewein +# (c) 2003-2009 Martin Konold +# (c) 2003 Achim Frank +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# The name of the author may not be used to endorse or promote products derived +# from this software without specific prior written permission. +# +# +# THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR IMPLIED +# WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO +# EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; +# OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR +# OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF +# ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# This schema highly depends on the core.schema, cosine.schema and the inetorgperson.schema +# as provided by 3rd parties like OpenLDAP. +# +# slapd.conf then looks like +# include /kolab/etc/openldap/schema/core.schema +# include /kolab/etc/openldap/schema/cosine.schema +# include /kolab/etc/openldap/schema/inetorgperson.schema +# include /kolab/etc/openldap/schema/rfc2739.schema +# include /kolab/etc/openldap/schema/kolab3.schema +# Prefix for OIDs: 1.3.6.1.4.1.19414 <- registered +# Prefix for OIDs: 1.3.6.1.4.1.19414.2000 <-- temporarily reserved for ob +# Prefix for attributes: 1.3.6.1.4.1.19414.1 +# Prefix for attributes: 1.3.6.1.4.1.19414.2 +# Prefix for objectclasses: 1.3.6.1.4.1.19414.3 +# nameprefix: kolab +# +dn: cn=schema +#################### +# kolab attributes # +#################### +# kolabDeleteflag used to be a boolean but describes with Kolab 2 +# the fqdn of the server which is requested to delete this objects +# in its local store +attributeTypes: ( 1.3.6.1.4.1.19414.2.1.2 + NAME 'kolabDeleteflag' + DESC 'Per host deletion status' + EQUALITY caseIgnoreIA5Match + SUBSTR caseIgnoreIA5SubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{256} ) +# alias used to provide alternative rfc822 email addresses for kolab users +attributeTypes: ( 1.3.6.1.4.1.19414.2.1.3 + NAME 'alias' + DESC 'RFC1274: RFC822 Mailbox' + EQUALITY caseIgnoreIA5Match + SUBSTR caseIgnoreIA5SubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{256} ) +# Specifies the email delegates. +# An email delegate can send email on behalf of the account +# which means using the "from" of the account. +# Delegates are specified by the syntax of rfc822 email addresses. +attributeTypes: ( 1.3.6.1.4.1.19414.1.1.1.3 + NAME 'kolabDelegate' + DESC 'Kolab user allowed to act as delegates - RFC822 Mailbox/Alias' + EQUALITY caseIgnoreIA5Match + SUBSTR caseIgnoreIA5SubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{256} ) +# For user, group and resource Kolab accounts +# Describes how to respond to invitations +# We keep the attribute as a string, but actually it can only have one +# of the following values: +# +# ACT_ALWAYS_ACCEPT +# ACT_ALWAYS_REJECT +# ACT_REJECT_IF_CONFLICTS +# ACT_MANUAL_IF_CONFLICTS +# ACT_MANUAL +# In addition one of these values may be prefixed with a primary email +# address followed by a colon like +# user@domain.tld: ACT_ALWAYS_ACCEPT +attributeTypes: ( 1.3.6.1.4.1.19414.1.1.1.4 + NAME ( 'kolabInvitationPolicy' 'kolabResourceAction' ) + DESC 'defines how to respond to invitations' + EQUALITY caseIgnoreIA5Match + SUBSTR caseIgnoreIA5SubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{256} ) +# Begin date of Kolab vacation period. Sender will +# be notified every kolabVacationResendIntervall days +# that recipient is absent until kolabVacationEnd. +# Values in this syntax are encoded as printable strings, +# represented as specified in X.208. +# Note that the time zone must be specified. +# For Kolab we limit ourself to GMT +# YYYYMMDDHHMMZ e.g. 200512311458Z. +# see also: rfc 2252. +# Currently this attribute is not used in Kolab. +attributeTypes: ( 1.3.6.1.4.1.19414.1.1.1.8 + NAME 'kolabVacationBeginDateTime' + DESC 'Begin date of vacation' + EQUALITY generalizedTimeMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 + SINGLE-VALUE ) +# End date of Kolab vacation period. Sender will +# be notified every kolabVacationResendIntervall days +# that recipient is absent starting from kolabVacationBeginDateTime. +# Values in this syntax are encoded as printable strings, +# represented as specified in X.208. +# Note that the time zone must be specified. +# For Kolab we limit ourself to GMT +# YYYYMMDDHHMMZ e.g. 200601012258Z. +# see also: rfc 2252. +# Currently this attribute is not used in Kolab. +attributeTypes: ( 1.3.6.1.4.1.19414.1.1.1.9 + NAME 'kolabVacationEndDateTime' + DESC 'End date of vacation' + EQUALITY generalizedTimeMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 + SINGLE-VALUE ) +# Intervall in days after which senders get +# another vacation message. +# Currently this attribute is not used in Kolab. +attributeTypes: ( 1.3.6.1.4.1.19414.1.1.1.10 + NAME 'kolabVacationResendInterval' + DESC 'Vacation notice interval in days' + EQUALITY integerMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 + SINGLE-VALUE ) +# Email recipient addresses which are handled by the +# vacation script. There can be multiple kolabVacationAddress +# entries for each kolabInetOrgPerson. +# Default is the primary email address and all +# email aliases of the kolabInetOrgPerson. +# Currently this attribute is not used in Kolab. +attributeTypes: ( 1.3.6.1.4.1.19414.1.1.1.11 + NAME 'kolabVacationAddress' + DESC 'Email address for vacation to response upon' + EQUALITY caseIgnoreIA5Match + SUBSTR caseIgnoreIA5SubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{256} ) +# Enable sending vacation notices in reaction +# unsolicited commercial email. +# Default is no. +# Currently this attribute is not used in Kolab. +attributeTypes: ( 1.3.6.1.4.1.19414.1.1.1.12 + NAME 'kolabVacationReplyToUCE' + DESC 'Enable vacation notices to UCE' + EQUALITY booleanMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.7 + SINGLE-VALUE ) +# Email recipient domains which are handled by the +# vacation script. There can be multiple kolabVacationReactDomain +# entries for each kolabInetOrgPerson +# Default is to handle all domains. +# Currently this attribute is not used in Kolab. +attributeTypes: ( 1.3.6.1.4.1.19414.1.1.1.13 + NAME 'kolabVacationReactDomain' + DESC 'Multivalued -- Email domain for vacation to response upon' + EQUALITY caseIgnoreIA5Match + SUBSTR caseIgnoreIA5SubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{256} ) +# Keep local copy when forwarding emails to list of +# kolabForwardAddress. +# Default is no. +# Currently this attribute is not used in Kolab. +attributeTypes: ( 1.3.6.1.4.1.19414.1.1.1.15 + NAME 'kolabForwardKeepCopy' + DESC 'Keep copy when forwarding' + EQUALITY booleanMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.7 + SINGLE-VALUE ) +# Enable forwarding of UCE. +# Default is yes. +# Currently this attribute is not used in Kolab. +attributeTypes: ( 1.3.6.1.4.1.19414.1.1.1.16 + NAME 'kolabForwardUCE' + DESC 'Enable forwarding of mails known as UCE' + EQUALITY booleanMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.7 + SINGLE-VALUE ) +# Describes the allowed or disallowed smtp recipient addresses for mail sent +# by the user associated with the LDAP object this attribute is associated with. +# +# If this attribute is not set for a user or distribution group, +# no Kolab recipient policy does apply. +# +# Example entries: +# .tld - allow mail to every recipient for this tld +# domain.tld - allow mail to everyone in domain.tld +# .domain.tld - allow mail to everyone in domain.tld and its subdomains +# user@domain.tld - allow mail to explicit user@domain.tld +# user@ - allow mail to this user but any domain +# -.tld - disallow mail to every recipient for this tld +# -domain.tld - disallow mail to everyone in domain.tld +# -.domain.tld - disallow mail to everyone in domain.tld and its subdomains +# -user@domain.tld - disallow mail to explicit user@domain.tld +# -user@ - disallow mail to this user but any domain +attributeTypes: ( 1.3.6.1.4.1.19414.1.1.1.18 + NAME 'kolabAllowSMTPRecipient' + DESC 'SMTP address allowed for destination (multi-valued)' + EQUALITY caseIgnoreIA5Match + SUBSTR caseIgnoreIA5SubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{512} ) +# Jeroen van Meeuwen (Kolab Systems): Unnecessary in this deployment, as users +# will be created on one server only, however we keep this in here to allow the +# mail server to use to be specified from the user provisioning batch operation. +# +# Create the user mailbox on the kolabHomeServer only. +# Default is no. +attributeTypes: ( 1.3.6.1.4.1.19414.1.1.1.19 + NAME 'kolabHomeServerOnly' + DESC 'Create the user mailbox on the kolabHomeServer only' + EQUALITY booleanMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.7 + SINGLE-VALUE ) +# Describes the allowed or disallowed smtp envelope sender addresses used for +# the recipient this attribute is associated with. +# +# If this attribute is not set for a user or distribution +# kolab sender policy does apply. +# +# Example entries: +# .tld - allow mail to every recipient for this tld +# domain.tld - allow mail to everyone in domain.tld +# .domain.tld - allow mail to everyone in domain.tld and its subdomains +# user@domain.tld - allow mail to explicit user@domain.tld +# user@ - allow mail to this user but any domain +# -.tld - disallow mail to every recipient for this tld +# -domain.tld - disallow mail to everyone in domain.tld +# -.domain.tld - disallow mail to everyone in domain.tld and its subdomains +# -user@domain.tld - disallow mail to explicit user@domain.tld +# -user@ - disallow mail to this user but any domain +attributeTypes: ( 1.3.6.1.4.1.19414.1.1.1.43 + NAME 'kolabAllowSMTPSender' + DESC 'SMTP envelope sender address accepted for delivery (multi-valued)' + EQUALITY caseIgnoreIA5Match + SUBSTR caseIgnoreIA5SubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{512} ) +# kolabFolderType describes the kind of Kolab folder +# as defined in the kolab format specification. +# We will annotate all folders with an entry +# /vendor/kolab/folder-type containing the attribute +# value.shared set to: [.]. +# The can be: mail, event, journal, task, note, +# or contact. The for a mail folder can be +# inbox, drafts, sentitems, or junkemail (this one holds +# spam mails). For the other s, it can only be +# default, or not set. For other types of folders +# supported by the clients, these should be prefixed with +# "k-" for KMail, "h-" for Horde and "o-" for Outlook, and +# look like for example "kolab.o-voicemail". Other third-party +# clients shall use the "x-" prefix. +# We then use the ANNOTATEMORE IMAP extension to +# associate the folder type with a folder. +attributeTypes: ( 1.3.6.1.4.1.19414.2.1.7 + NAME 'kolabFolderType' + DESC 'type of a kolab folder' + EQUALITY caseIgnoreIA5Match + SUBSTR caseIgnoreIA5SubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{256} + SINGLE-VALUE ) +attributeTypes: ( 1.3.6.1.4.1.19414.2.1.8 + NAME 'kolabTargetFolder' + DESC 'Target for a Kolab Shared Folder delivery' + EQUALITY caseExactMatch + SUBSTR caseExactSubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{512} + SINGLE-VALUE ) +# cyrus imapd access control list +# acls work with users and groups +attributeTypes: ( 1.3.6.1.4.1.19414.2.1.651 + NAME 'acl' + EQUALITY caseIgnoreIA5Match + SUBSTR caseIgnoreIA5SubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{256} ) +# Extended attributes for Resources +attributeTypes: ( 1.3.6.1.4.1.19414.3.1.1 + NAME 'kolabDescAttribute' + DESC 'Descriptive attribute or parameter for a Resource' + EQUALITY caseIgnoreIA5Match + SUBSTR caseIgnoreIA5SubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{256} ) +########################## +# kolabfilter attributes # +########################## +# enable trustable From: +attributeTypes: ( 1.3.6.1.4.1.19414.2.1.750 + NAME 'kolabfilter-verify-from-header' + EQUALITY booleanMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.7 ) +# should Sender header be allowed instead of From +# when present? +attributeTypes: ( 1.3.6.1.4.1.19414.2.1.751 + NAME 'kolabfilter-allow-sender-header' + EQUALITY booleanMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.7 ) +# Should reject messages with From headers that dont match +# the envelope? Default is to rewrite the header +attributeTypes: ( 1.3.6.1.4.1.19414.2.1.752 + NAME 'kolabfilter-reject-forged-from-header' + EQUALITY booleanMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.7 ) +######################## +# kolab object classes # +######################## +# public folders are typically visible to everyone subscribed to +# the server without the need for an extra login. Subfolders are +# defined using the hiarchy seperator '/' e.g. "sf/sub1". Please note +# that the term public folder is prefered to shared folder because +# normal user mailboxes can also share folders using acls. +objectClasses: ( 1.3.6.1.4.1.19414.2.2.9 + NAME 'kolabSharedFolder' + DESC 'Kolab public shared folder' + SUP top AUXILIARY + MUST cn + MAY ( acl $ + alias $ + mailHost $ + kolabFolderType $ + kolabDeleteflag $ + kolabDelegate $ + kolabTargetFolder $ + kolabAllowSMTPRecipient $ + kolabAllowSMTPSender $ + owner ) ) +# kolab account +# we use an auxiliary in order to ease integration +# with existing inetOrgPerson objects +# Please note that userPassword is a may +# attribute in the schema but is mandatory for +# Kolab +objectClasses: ( 1.3.6.1.4.1.19414.3.2.2 + NAME 'kolabInetOrgPerson' + DESC 'Kolab Internet Organizational Person' + SUP top AUXILIARY + MAY ( alias $ + mailHost $ + kolabHomeServerOnly $ + kolabDelegate $ + kolabInvitationPolicy $ + kolabVacationBeginDateTime $ + kolabVacationEndDateTime $ + kolabVacationResendInterval $ + kolabVacationAddress $ + kolabVacationReplyToUCE $ + kolabVacationReactDomain $ + kolabForwardKeepCopy $ + kolabForwardUCE $ + kolabAllowSMTPRecipient $ + kolabAllowSMTPSender $ + kolabDeleteflag ) ) +# kolab groupOfNames with extra kolabDeleteflag and the required +# attribute mail. +# The mail attribute for kolab objects of the type kolabGroupOfNames +# is not arbitrary but MUST be a single attribute of the form +# of an valid SMTP address with the CN as the local part. +# E.g cn@kolabdomain (e.g. employees@mydomain.com). The +# mail attribute MUST be globally unique. +objectClasses: ( 1.3.6.1.4.1.19414.3.2.8 + NAME 'kolabGroupOfUniqueNames' + DESC 'Kolab group of names (DNs) derived from RFC2256' + SUP top AUXILIARY + MAY ( mail $ + alias $ + kolabDelegate $ + kolabDeleteflag $ + kolabAllowSMTPRecipient $ + kolabAllowSMTPSender ) ) +# kolab resources +objectClasses: ( 1.3.6.1.4.1.19414.3.2.9 + NAME 'kolabResource' + DESC 'Kolab Resource' + SUP top AUXILIARY + MAY ( kolabInvitationPolicy $ + kolabDescAttribute $ + description $ + owner ) ) diff --git a/docker/ds389/mgmt_com-install.ldif.tpl b/docker/ds389/mgmt_com-install.ldif.tpl new file mode 100644 index 00000000..28ddc0fd --- /dev/null +++ b/docker/ds389/mgmt_com-install.ldif.tpl @@ -0,0 +1,119 @@ +# ${LDAP_ADMIN_ROOT_DN} +dn: ${LDAP_ADMIN_ROOT_DN} +aci: (targetattr = \"carLicense || description || displayName || facsimileTelephoneNumber || homePhone || homePostalAddress || initials || jpegPhoto || l || labeledURI || mobile || o || pager || photo || postOfficeBox || postalAddress || postalCode || preferredDeliveryMethod || preferredLanguage || registeredAddress || roomNumber || secretary || seeAlso || st || street || telephoneNumber || telexNumber || title || userCertificate || userPassword || userSMIMECertificate || x500UniqueIdentifier || kolabDelegate || kolabInvitationPolicy || kolabAllowSMTPSender\") (version 3.0; acl \"Enable self write for common attributes\"; allow (read,compare,search,write)(userdn = \"ldap:///self\");) +aci: (targetattr = \"*\") (version 3.0;acl \"Directory Administrators Group\";allow (all)(groupdn = \"ldap:///cn=Directory Administrators,${LDAP_ADMIN_ROOT_DN}\" or roledn = \"ldap:///cn=kolab-admin,${LDAP_ADMIN_ROOT_DN}\");) +aci: (targetattr=\"*\")(version 3.0; acl \"Configuration Administrators Group\"; allow (all) groupdn=\"ldap:///cn=Configuration Administrators,ou=Groups,ou=TopologyManagement,o=NetscapeRoot\";) +aci: (targetattr=\"*\")(version 3.0; acl \"Configuration Administrator\"; allow (all) userdn=\"ldap:///uid=admin,ou=Administrators,ou=TopologyManagement,o=NetscapeRoot\";) +aci: (targetattr = \"*\")(version 3.0; acl \"SIE Group\"; allow (all) groupdn = \"ldap:///cn=slapd-ldap-k8s,cn=389 Directory Server,cn=Server Group,cn=${FULL_MACHINE_NAME},ou=${DOMAIN},o=NetscapeRoot\";) +aci: (targetattr != \"userPassword\") (version 3.0;acl \"Search Access\";allow (read,compare,search)(userdn = \"ldap:///all\");) +objectClass: top +objectClass: domain + +# Directory Administrators, ${DOMAIN} +dn: cn=Directory Administrators,${LDAP_ADMIN_ROOT_DN} +objectClass: top +objectClass: groupofuniquenames +cn: Directory Administrators +uniqueMember: cn=Directory Manager + +# Domains definition location ${DOMAIN} +dn: ${LDAP_DOMAIN_BASE_DN} +objectclass: top +objectclass: extensibleobject +ou: Domains +aci: (targetattr = \"*\") (version 3.0;acl \"Kolab Services\";allow (read,compare,search)(userdn = \"ldap:///uid=kolab-service,ou=Special Users,${LDAP_ADMIN_ROOT_DN}\");) + +# Groups, ${DOMAIN} +dn: ou=Groups,${LDAP_ADMIN_ROOT_DN} +objectClass: top +objectClass: organizationalunit +ou: Groups + +# People, ${DOMAIN} +dn: ou=People,${LDAP_ADMIN_ROOT_DN} +objectClass: top +objectClass: organizationalunit +ou: People + +# Resources, ${DOMAIN} +dn: ou=Resources,${LDAP_ADMIN_ROOT_DN} +objectClass: top +objectClass: organizationalunit +ou: Resources + +# Shared Folders, ${DOMAIN} +dn: ou=Shared Folders,${LDAP_ADMIN_ROOT_DN} +objectClass: top +objectClass: organizationalunit +ou: Shared Folders + +# Special User, ${DOMAIN} +dn: ou=Special Users,${LDAP_ADMIN_ROOT_DN} +objectClass: top +objectClass: organizationalUnit +ou: Special Users +description: Special Administrative Accounts + +# Add kolab-admin role +dn: cn=kolab-admin,${LDAP_ADMIN_ROOT_DN} +objectClass: top +objectClass: ldapsubentry +objectClass: nsroledefinition +objectClass: nssimpleroledefinition +objectClass: nsmanagedroledefinition +cn: kolab-admin +description: Kolab Administrator + +# cyrus-admin, Special Users, ${DOMAIN} +dn: uid=cyrus-admin,ou=Special Users,${LDAP_ADMIN_ROOT_DN} +sn: Administrator +uid: cyrus-admin +objectClass: top +objectClass: person +objectClass: inetorgperson +objectClass: organizationalperson +givenName: Cyrus +cn: Cyrus Administrator +userPassword: ${IMAP_ADMIN_PASSWORD} + +# kolab-service, Special Users, ${DOMAIN} +dn: ${LDAP_SERVICE_BIND_DN} +sn: Service +uid: kolab-service +objectClass: top +objectClass: person +objectClass: inetorgperson +objectClass: organizationalperson +givenName: Kolab +cn: Kolab Service +userPassword: ${LDAP_SERVICE_BIND_PW} +nsIdleTimeout: -1 +nsTimeLimit: -1 +nsSizeLimit: -1 +nsLookThroughLimit: -1 + +# hosted-kolab-service, Special Users, ${DOMAIN} +dn: ${LDAP_HOSTED_BIND_DN} +objectclass: top +objectclass: inetorgperson +objectclass: person +uid: hosted-kolab-service +cn: Hosted Kolab Service Account +sn: Service Account +givenname: Hosted Kolab +userpassword: ${LDAP_HOSTED_BIND_PW} +nsIdleTimeout: -1 +nsTimeLimit: -1 +nsSizeLimit: -1 +nsLookThroughLimit: -1 + +# ${DOMAIN}, ${LDAP_DOMAIN_BASE_DN} +dn: associateddomain=${DOMAIN},${LDAP_DOMAIN_BASE_DN} +objectclass: top +objectclass: domainrelatedobject +associateddomain: ${DOMAIN} +associateddomain: localhost.localdomain +associateddomain: localhost +aci: (targetattr = \"*\")(version 3.0;acl \"Deny Rest\";deny (all)(userdn != \"ldap:///${LDAP_SERVICE_BIND_DN} || ldap:///${LDAP_ADMIN_ROOT_DN}??sub?\(objectclass=*\)\");) +aci: (targetattr = \"*\")(version 3.0;acl \"Deny Hosted Kolab\";deny (all)(userdn = \"ldap:///${LDAP_HOSTED_BIND_DN}\");) + diff --git a/docker/kolab/Dockerfile b/docker/kolab/Dockerfile index 915aad77..2d5278ca 100644 --- a/docker/kolab/Dockerfile +++ b/docker/kolab/Dockerfile @@ -1,28 +1,80 @@ -FROM kolab/centos7:latest +FROM centos:7 -RUN yum -y install rsyslog && \ - yum --enablerepo=kolab-16-updates-testing -y update pykolab && \ +LABEL maintainer="contact@kolabsystems.com" +LABEL dist=centos7 +LABEL tier=${TIER} + +ENV container docker +ENV SYSTEMD_PAGER='' +ENV DISTRO=centos7 +ENV LANG=en_US.utf8 +ENV LC_ALL=en_US.utf8 + +RUN (cd /lib/systemd/system/sysinit.target.wants/; for i in *; do [ $i == systemd-tmpfiles-setup.service ] || rm -f $i; done); \ + rm -f /lib/systemd/system/multi-user.target.wants/*; \ + rm -f /etc/systemd/system/*.wants/*; \ + rm -f /lib/systemd/system/local-fs.target.wants/*; \ + rm -f /lib/systemd/system/sockets.target.wants/*udev*; \ + rm -f /lib/systemd/system/sockets.target.wants/*initctl*; \ + rm -f /lib/systemd/system/basic.target.wants/*; \ + rm -f /lib/systemd/system/anaconda.target.wants/*; + +# To speed things up, disable fastestmirror. +RUN sed -r -i \ + -e 's/^enabled.*$/enabled = 0/g' \ + /etc/yum/pluginconf.d/fastestmirror.conf + +# Avoid using a mirrorlist (use a transparent proxy and cache everything instead). +RUN sed -r -i \ + -e 's/^mirrorlist/#mirrorlist/g' \ + -e 's/^#baseurl/baseurl/g' \ + /etc/yum.repos.d/*.repo + +RUN sed -i -e '/tsflags=nodocs/d' /etc/yum.conf + +# Add EPEL. +RUN yum -y install \ + epel-release && \ yum clean all +# Add the EPEL key. +RUN rpm --import /etc/pki/rpm-gpg/RPM-GPG-KEY-EPEL-7 + +RUN rpm --import https://mirror.kolabenterprise.com/maipo.asc + +RUN yum -y install https://mirror.kolabenterprise.com/kolab-16-for-el7.rpm && \ + yum -y install kolab-16-release-development && \ + yum clean all + +RUN yum -y --setopt tsflags= install kolab + COPY kolab-init.service /etc/systemd/system/kolab-init.service +COPY kolab-setenv.service /etc/systemd/system/kolab-setenv.service COPY kolab-vlv.service /etc/systemd/system/kolab-vlv.service +COPY utils /root/utils RUN rm -rf /etc/systemd/system/multi-user.target.wants/{avahi-daemon,sshd}.* && \ ln -s /etc/systemd/system/kolab-init.service \ /etc/systemd/system/multi-user.target.wants/kolab-init.service && \ + ln -s /etc/systemd/system/kolab-setenv.service \ + /etc/systemd/system/multi-user.target.wants/kolab-setenv.service && \ ln -s /etc/systemd/system/kolab-vlv.service \ /etc/systemd/system/multi-user.target.wants/kolab-vlv.service RUN sed -i -r -e 's/^SELINUX=.*$/SELINUX=permissive/g' /etc/selinux/config 2>/dev/null || : RUN sed -i -r -e 's/^Listen 80$/Listen 9080/g' /etc/httpd/conf/httpd.conf #RUN sed -i -r -e 's/^Listen 443$/Listen 9443/g' /etc/httpd/conf/httpd.conf COPY kolab-init.sh /usr/local/sbin/ RUN chmod 750 /usr/local/sbin/kolab-init.sh COPY kolab-vlv.sh /usr/local/sbin/ RUN chmod 750 /usr/local/sbin/kolab-vlv.sh +VOLUME [ "/sys/fs/cgroup" ] + +WORKDIR /root/ + CMD ["/lib/systemd/systemd"] EXPOSE 21/tcp 22/tcp 25/tcp 53/tcp 53/udp 80/tcp 110/tcp 143/tcp 389/tcp 443/tcp 465/tcp 587/tcp 993/tcp 995/tcp 5353/udp 8880/tcp 8443/tcp 8447/tcp diff --git a/docker/kolab/kolab-init.service b/docker/kolab/kolab-init.service index fdbd3db8..b293055d 100644 --- a/docker/kolab/kolab-init.service +++ b/docker/kolab/kolab-init.service @@ -1,9 +1,12 @@ [Unit] Description=Kolab Setup Service +Requires=kolab-setenv.service +After=kolab-setenv.service [Service] Type=oneshot +EnvironmentFile=/etc/openshift-environment ExecStart=/usr/local/sbin/kolab-init.sh [Install] WantedBy=multi-user.target diff --git a/docker/kolab/kolab-init.sh b/docker/kolab/kolab-init.sh index f93ceceb..01915c1c 100755 --- a/docker/kolab/kolab-init.sh +++ b/docker/kolab/kolab-init.sh @@ -1,31 +1,31 @@ #!/bin/bash if [ -d "/etc/dirsrv/slapd-kolab/" ]; then exit 0 fi pushd /root/utils/ -./01-reverse-etc-hosts.sh -./02-write-my.cnf.sh -./03-setup-kolab.sh -./04-reset-mysql-kolab-password.sh -./05-replace-localhost.sh -./06-mysql-for-kolabdev.sh -./07-adjust-base-dns.sh -./08-disable-amavisd.sh -./09-enable-debugging.sh -./10-reset-kolab-service-password.sh -./11-reset-cyrus-admin-password.sh -./12-create-hosted-kolab-service.sh -./13-create-ou-domains.sh -./14-create-management-domain.sh -./15-create-hosted-domain.sh -./16-remove-cn-kolab-cn-config.sh -./17-remove-hosted-service-access-from-mgmt-domain.sh -./18-adjust-kolab-conf.sh -./19-turn-on-vlv-in-roundcube.sh -./20-add-alias-attribute-index.sh -./21-adjust-postfix-config.sh +./01-reverse-etc-hosts.sh && echo "01 done" +./02-write-my.cnf.sh && echo "02 done" +./03-setup-kolab.sh && echo "03 done" +./04-reset-mysql-kolab-password.sh && echo "04 done" +./05-replace-localhost.sh && echo "05 done" +./06-mysql-for-kolabdev.sh && echo "06 done" +./07-adjust-base-dns.sh && echo "07 done" +./08-disable-amavisd.sh && echo "08 done" +./09-enable-debugging.sh && echo "09 done" +./10-reset-kolab-service-password.sh && echo "10 done" +./11-reset-cyrus-admin-password.sh && echo "11 done" +./12-create-hosted-kolab-service.sh && echo "12 done" +./13-create-ou-domains.sh && echo "13 done" +./14-create-management-domain.sh && echo "14 done" +./15-create-hosted-domain.sh && echo "15 done" +./16-remove-cn-kolab-cn-config.sh && echo "16 done" +./17-remove-hosted-service-access-from-mgmt-domain.sh && echo "17 done" +./18-adjust-kolab-conf.sh && echo "18 done" +./19-turn-on-vlv-in-roundcube.sh && echo "19 done" +./20-add-alias-attribute-index.sh && echo "20 done" +./21-adjust-postfix-config.sh && echo "21 done" touch /tmp/kolab-init.done diff --git a/docker/kolab/kolab-setenv.service b/docker/kolab/kolab-setenv.service new file mode 100644 index 00000000..d460888c --- /dev/null +++ b/docker/kolab/kolab-setenv.service @@ -0,0 +1,9 @@ +[Unit] +Description=Kolab Set Environment + +[Service] +Type=oneshot +ExecStart=/bin/bash -c "cat /proc/1/environ | tr '\0' '\n' > /etc/openshift-environment" + +[Install] +WantedBy=multi-user.target diff --git a/docker/kolab/utils/02-write-my.cnf.sh b/docker/kolab/utils/02-write-my.cnf.sh index df221775..d838c62e 100755 --- a/docker/kolab/utils/02-write-my.cnf.sh +++ b/docker/kolab/utils/02-write-my.cnf.sh @@ -1,9 +1,8 @@ #!/bin/bash cat > /root/.my.cnf << EOF [client] -host=127.0.0.1 +host=${DB_HOST:-127.0.0.1} user=root -password=Welcome2KolabSystems +password=${DB_ROOT_PASSWORD:-Welcome2KolabSystems} EOF - diff --git a/docker/kolab/utils/03-setup-kolab.sh b/docker/kolab/utils/03-setup-kolab.sh index 5637cb0f..6f85cf57 100755 --- a/docker/kolab/utils/03-setup-kolab.sh +++ b/docker/kolab/utils/03-setup-kolab.sh @@ -1,11 +1,38 @@ #!/bin/bash -setup-kolab \ - --default \ - --fqdn=kolab.mgmt.com \ +. ./settings.sh + +if [ -f /root/kolab.conf.template ]; then + eval "echo \"$(cat /root/kolab.conf.template)\"" > /root/kolab.conf.ref + KOLAB_CONFIG_REF="--config=/root/kolab.conf.ref" + cp -f ${KOLAB_CONFIG_REF#--config=} /etc/kolab/kolab.conf +fi + +CMD="$(which setup-kolab) \ + --default ${LDAP_HOST+--without-ldap} ${KOLAB_CONFIG_REF} \ + --fqdn=kolab.${domain} \ --timezone=Europe/Zurich \ - --mysqlhost=127.0.0.1 \ + --mysqlhost=${DB_HOST:-127.0.0.1} \ --mysqlserver=existing \ - --mysqlrootpw=Welcome2KolabSystems \ - --directory-manager-pwd=Welcome2KolabSystems 2>&1 | tee /root/setup-kolab.log + --mysqlrootpw=${DB_ROOT_PASSWORD:-Welcome2KolabSystems} \ + --directory-manager-pwd=${LDAP_ADMIN_BIND_PW:-Welcome2KolabSystems}" + +echo ${CMD} | tee -a /root/setup-kolab.log +echo -n "Wait for MariaDB container: " | tee -a /root/setup-kolab.log +while ! mysqladmin -u root ping > /dev/null 2>&1 ; do + echo -n '.' + sleep 3 +done | tee -a /root/setup-kolab.log +echo "OK!" | tee -a /root/setup-kolab.log + +if [ ! -z "${LDAP_HOST}" ]; then + echo -n "Wait for DS389 container: " | tee -a /root/setup-kolab.log + while ! ldapsearch -h ${LDAP_HOST} -D "${LDAP_ADMIN_BIND_DN}" -w "${LDAP_ADMIN_BIND_PW}" -b "" -s base > /dev/null 2>&1 ; do + echo -n '.' + sleep 3 + done | tee -a /root/setup-kolab.log + echo "OK!" | tee -a /root/setup-kolab.log +fi + +${CMD} 2>&1 | tee -a /root/setup-kolab.log diff --git a/docker/kolab/utils/04-reset-mysql-kolab-password.sh b/docker/kolab/utils/04-reset-mysql-kolab-password.sh index 07a18ad1..e0014f4d 100755 --- a/docker/kolab/utils/04-reset-mysql-kolab-password.sh +++ b/docker/kolab/utils/04-reset-mysql-kolab-password.sh @@ -1,7 +1,12 @@ #!/bin/bash sqlpw=$(grep ^sql_uri /etc/kolab/kolab.conf | awk -F':' '{print $3}' | awk -F'@' '{print $1}') -mysql -h 127.0.0.1 -u root --password=Welcome2KolabSystems \ - -e "SET PASSWORD FOR 'kolab'@'localhost' = PASSWORD('${sqlpw}');" +mysql -h ${DB_HOST} -u root --password=${DB_ROOT_PASSWORD} \ + -e "SET PASSWORD FOR '${DB_HKCCP_USERNAME:-kolabdev}'@'%' = PASSWORD('${DB_HKCCP_PASSWORD:-Welcome2KolabSystems}');" +mysql -h ${DB_HOST} -u root --password=${DB_ROOT_PASSWORD} \ + -e "SET PASSWORD FOR '${DB_KOLAB_USERNAME:-kolab}'@'%' = PASSWORD('${DB_KOLAB_PASSWORD:=$sqlpw}');" + +mysql -h ${DB_HOST} -u root --password=${DB_ROOT_PASSWORD} \ + -e "SET PASSWORD FOR '${DB_RC_USERNAME:-roundcube}'@'%' = PASSWORD('${DB_RC_PASSWORD:-Welcome2KolabSystems}');" diff --git a/docker/kolab/utils/05-replace-localhost.sh b/docker/kolab/utils/05-replace-localhost.sh index d7b783f0..c8f4be47 100755 --- a/docker/kolab/utils/05-replace-localhost.sh +++ b/docker/kolab/utils/05-replace-localhost.sh @@ -1,23 +1,31 @@ #!/bin/bash -mysql -h 127.0.0.1 -u root --password=Welcome2KolabSystems \ - -e "UPDATE mysql.db SET Host = '127.0.0.1' WHERE Host = 'localhost';" +if [[ ${DB_HOST} == "localhost" || ${DB_HOST} == "127.0.0.1" ]]; then + mysql -h ${DB_HOST:-127.0.0.1} -u root --password=${DB_ROOT_PASSWORD:-Welcome2KolabSystems} \ + -e "UPDATE mysql.db SET Host = '127.0.0.1' WHERE Host = 'localhost';" + + mysql -h ${DB_HOST:-127.0.0.1} -u root --password=${DB_ROOT_PASSWORD:-Welcome2KolabSystems} \ + -e "UPDATE mysql.user SET Host = '127.0.0.1' WHERE Host = 'localhost';" + + mysql -h ${DB_HOST:-127.0.0.1} -u root --password=${DB_ROOT_PASSWORD:-Welcome2KolabSystems} \ + -e "FLUSH PRIVILEGES;" +fi -mysql -h 127.0.0.1 -u root --password=Welcome2KolabSystems \ - -e "UPDATE mysql.user SET Host = '127.0.0.1' WHERE Host = 'localhost';" - -mysql -h 127.0.0.1 -u root --password=Welcome2KolabSystems \ - -e "FLUSH PRIVILEGES;" - -sed -i -e 's/localhost/127.0.0.1/g' \ - /etc/imapd.conf \ - /etc/iRony/dav.inc.php \ - /etc/kolab/kolab.conf \ - /etc/kolab-freebusy/config.ini \ - /etc/postfix/ldap/*.cf \ - /etc/roundcubemail/password.inc.php \ - /etc/roundcubemail/kolab_auth.inc.php \ - /etc/roundcubemail/config.inc.php \ - /etc/roundcubemail/calendar.inc.php +sed -i -e "s#^ldap_servers:.*#ldap_servers: ldap://${LDAP_HOST:-127.0.0.1}:389#" /etc/imapd.conf +sed -i -e "/hosts/s/localhost/${LDAP_HOST:-127.0.0.1}/" /etc/iRony/dav.inc.php +sed -i -e "s#^ldap_uri.*#ldap_uri = ldap://${LDAP_HOST:-127.0.0.1}:389#" \ + -e "s#^cache_uri.*mysql://\(.*\):\(.*\)@\(.*\)\/\(.*\)#cache_uri = mysql://${DB_KOLAB_USERNAME:-\1}:${DB_KOLAB_PASSWORD:-\2}@${DB_HOST:-127.0.0.1}/${DB_KOLAB_DATABASE:-\4}#" \ + -e "s#^sql_uri.*mysql://\(.*\):\(.*\)@\(.*\)\/\(.*\)#sql_uri = mysql://${DB_KOLAB_USERNAME:-\1}:${DB_KOLAB_PASSWORD:-\2}@${DB_HOST:-127.0.0.1}/${DB_KOLAB_DATABASE:-\4}#" \ + -e "s#^uri.*#uri = imaps://${IMAP_HOST:-127.0.0.1}:993#" /etc/kolab/kolab.conf +sed -i -e "/host/s/localhost/${LDAP_HOST:-127.0.0.1}/g" \ + -e "/fbsource/s/localhost/${IMAP_HOST:-127.0.0.1}/g" /etc/kolab-freebusy/config.ini +sed -i -e "s/server_host.*/server_host = ${LDAP_HOST:-127.0.0.1}/g" /etc/postfix/ldap/* +sed -i -e "/password_ldap_host/s/localhost/${LDAP_HOST:-127.0.0.1}/" /etc/roundcubemail/password.inc.php +sed -i -e "/hosts/s/localhost/${LDAP_HOST:-127.0.0.1}/" /etc/roundcubemail/kolab_auth.inc.php +sed -i -e "#db_dsnw#s#=.*$#= mysqli//${DB_RC_USERNAME:-roundcube}:${DB_RC_PASSWORD:-Welcome2KolabSystems}@${DB_HOST:-127.0.0.1}/${DB_RC_DATABASE:-roundcube}#" \ + -e "/default_host/s/localhost/${IMAP_HOST:-127.0.0.1}/" \ + -e "/smtp_server/s/localhost/${MAIL_HOST:-127.0.0.1}/" \ + -e "/hosts/s/localhost/${LDAP_HOST:-127.0.0.1}/" /etc/roundcubemail/config.inc.php +sed -i -e "/hosts/s/localhost/${LDAP_HOST:-127.0.0.1}/" /etc/roundcubemail/calendar.inc.php systemctl restart cyrus-imapd postfix diff --git a/docker/kolab/utils/06-mysql-for-kolabdev.sh b/docker/kolab/utils/06-mysql-for-kolabdev.sh index 7e9bb269..6d3df98f 100755 --- a/docker/kolab/utils/06-mysql-for-kolabdev.sh +++ b/docker/kolab/utils/06-mysql-for-kolabdev.sh @@ -1,11 +1,11 @@ #!/bin/bash -mysql -h 127.0.0.1 -u root --password=Welcome2KolabSystems \ - -e "CREATE DATABASE kolabdev;" +mysql -h ${DB_HOST:-127.0.0.1} -u root --password=${DB_ROOT_PASSWORD:-Welcome2KolabSystems} \ + -e "CREATE DATABASE IF NOT EXISTS ${DB_HKCCP_DATABASE:-kolabdev};" -mysql -h 127.0.0.1 -u root --password=Welcome2KolabSystems \ - -e "GRANT ALL PRIVILEGES ON kolabdev.* TO 'kolabdev'@'127.0.0.1' IDENTIFIED BY 'kolab';" +mysql -h ${DB_HOST:-127.0.0.1} -u root --password=${DB_ROOT_PASSWORD:-Welcome2KolabSystems} \ + -e "GRANT ALL PRIVILEGES ON ${DB_HKCCP_DATABASE:-kolabdev}.* TO '${DB_HKCCP_USERNAME:-kolabdev}'@'%' IDENTIFIED BY '${DB_HKCCP_PASSWORD:-kolab}';" -mysql -h 127.0.0.1 -u root --password=Welcome2KolabSystems \ +mysql -h ${DB_HOST:-127.0.0.1} -u root --password=${DB_ROOT_PASSWORD:-Welcome2KolabSystems} \ -e "FLUSH PRIVILEGES;" diff --git a/docker/kolab/utils/07-adjust-base-dns.sh b/docker/kolab/utils/07-adjust-base-dns.sh index 398d7435..b8894906 100755 --- a/docker/kolab/utils/07-adjust-base-dns.sh +++ b/docker/kolab/utils/07-adjust-base-dns.sh @@ -1,24 +1,26 @@ #!/bin/bash . ./settings.sh echo "ldap_domain_base_dn: ${domain_base_dn}" >> /etc/imapd.conf sed -i -r \ -e "s/(\s+)base => '.*',$/\1base => '${hosted_domain_rootdn}',/g" \ + -e "/\\\$mydomain = / a\ +\$myhostname = '${HOSTNAME:-kolab}.${DOMAIN:-mgmt.com}';" \ -e "s/^base_dn = .*$/base_dn = ${hosted_domain_rootdn}/g" \ -e "s/^search_base = .*$/search_base = ${hosted_domain_rootdn}/g" \ -e "s/(\s+)'base_dn'(\s+)=> '.*',/\1'base_dn'\2=> '${hosted_domain_rootdn}',/g" \ -e "s/(\s+)'search_base_dn'(\s+)=> '.*',/\1'search_base_dn'\2=> '${hosted_domain_rootdn}',/g" \ -e "s/(\s+)'user_specific'(\s+)=> false,/\1'user_specific'\2=> true,/g" \ /etc/amavisd/amavisd.conf \ /etc/kolab-freebusy/config.ini \ /etc/postfix/ldap/*.cf \ /etc/roundcubemail/config.inc.php \ /etc/roundcubemail/kolab_auth.inc.php sed -i -r \ -e "s/^search_base = .*$/search_base = ${domain_base_dn}/g" \ /etc/postfix/ldap/mydestination.cf systemctl restart cyrus-imapd postfix diff --git a/docker/kolab/utils/10-reset-kolab-service-password.sh b/docker/kolab/utils/10-reset-kolab-service-password.sh index b3257aa0..8c0427b3 100755 --- a/docker/kolab/utils/10-reset-kolab-service-password.sh +++ b/docker/kolab/utils/10-reset-kolab-service-password.sh @@ -1,23 +1,23 @@ #!/bin/bash . ./settings.sh ( echo "dn: uid=kolab-service,ou=Special Users,${rootdn}" echo "changetype: modify" echo "replace: userpassword" - echo "userpassword: ${ldap_bindpw}" + echo "userpassword: ${kolab_service_pw}" echo "" ) | ldapmodify -x -h ${ldap_host} -D "${ldap_binddn}" -w "${ldap_bindpw}" oldpw=$(grep ^service_bind_pw /etc/kolab/kolab.conf | awk '{print $3}') sed -i -r \ - -e "s/${oldpw}/${ldap_bindpw}/g" \ + -e "s/${oldpw}/${kolab_service_pw}/g" \ $(grep -rn -- ${oldpw} /etc/ | awk -F':' '{print $1}' | sort -u) systemctl restart \ cyrus-imapd \ kolabd \ kolab-saslauthd \ postfix diff --git a/docker/kolab/utils/11-reset-cyrus-admin-password.sh b/docker/kolab/utils/11-reset-cyrus-admin-password.sh index 17996528..abb7e203 100755 --- a/docker/kolab/utils/11-reset-cyrus-admin-password.sh +++ b/docker/kolab/utils/11-reset-cyrus-admin-password.sh @@ -1,20 +1,20 @@ #!/bin/bash . ./settings.sh ( echo "dn: uid=cyrus-admin,ou=Special Users,${rootdn}" echo "changetype: modify" echo "replace: userpassword" - echo "userpassword: ${ldap_bindpw}" + echo "userpassword: ${cyrus_admin_pw}" echo "" ) | ldapmodify -x -h ${ldap_host} -D "${ldap_binddn}" -w "${ldap_bindpw}" oldpw=$(grep ^admin_password /etc/kolab/kolab.conf | awk '{print $3}') sed -i -r \ - -e "s/${oldpw}/${ldap_bindpw}/g" \ + -e "s/${oldpw}/${cyrus_admin_pw}/g" \ /etc/kolab/kolab.conf systemctl restart kolabd wallace diff --git a/docker/kolab/utils/12-create-hosted-kolab-service.sh b/docker/kolab/utils/12-create-hosted-kolab-service.sh index 6af577ff..9e42381a 100755 --- a/docker/kolab/utils/12-create-hosted-kolab-service.sh +++ b/docker/kolab/utils/12-create-hosted-kolab-service.sh @@ -1,16 +1,17 @@ #!/bin/bash - . ./settings.sh +. ./settings.sh + ( echo "dn: uid=hosted-kolab-service,ou=Special Users,${rootdn}" echo "objectclass: top" echo "objectclass: inetorgperson" echo "objectclass: person" echo "uid: hosted-kolab-service" echo "cn: Hosted Kolab Service Account" echo "sn: Service Account" echo "givenname: Hosted Kolab" echo "userpassword: ${hosted_kolab_service_pw}" echo "" ) | ldapadd -x -h ${ldap_host} -D "${ldap_binddn}" -w "${ldap_bindpw}" diff --git a/docker/kolab/utils/13-create-ou-domains.sh b/docker/kolab/utils/13-create-ou-domains.sh index 400002d7..7ec8d6da 100755 --- a/docker/kolab/utils/13-create-ou-domains.sh +++ b/docker/kolab/utils/13-create-ou-domains.sh @@ -1,10 +1,11 @@ #!/bin/bash . ./settings.sh + ( echo "dn: ou=Domains,${rootdn}" echo "ou: Domains" echo "objectClass: top" echo "objectClass: organizationalunit" echo "" ) | ldapadd -x -h ${ldap_host} -D "${ldap_binddn}" -w "${ldap_bindpw}" diff --git a/docker/kolab/utils/14-create-management-domain.sh b/docker/kolab/utils/14-create-management-domain.sh index 98184bb2..973a47af 100755 --- a/docker/kolab/utils/14-create-management-domain.sh +++ b/docker/kolab/utils/14-create-management-domain.sh @@ -1,14 +1,15 @@ #!/bin/bash - . ./settings.sh +. ./settings.sh + ( echo "dn: associateddomain=${domain},${domain_base_dn}" echo "aci: (targetattr = \"*\")(version 3.0;acl \"Deny Rest\";deny (all)(userdn != \"ldap:///uid=kolab-service,ou=Special Users,${rootdn} || ldap:///${rootdn}??sub?(objectclass=*)\");)" echo "aci: (targetattr = \"*\")(version 3.0;acl \"Deny Hosted Kolab\";deny (all)(userdn = \"ldap:///uid=hosted-kolab-service,ou=Special Users,${rootdn}\");)" echo "inetDomainStatus: active" echo "objectClass: top" echo "objectClass: domainrelatedobject" echo "objectClass: inetdomain" echo "associatedDomain: ${domain}" echo "" ) | ldapadd -x -h ${ldap_host} -D "${ldap_binddn}" -w "${ldap_bindpw}" diff --git a/docker/kolab/utils/15-create-hosted-domain.sh b/docker/kolab/utils/15-create-hosted-domain.sh index 9e15ae0f..96647adb 100755 --- a/docker/kolab/utils/15-create-hosted-domain.sh +++ b/docker/kolab/utils/15-create-hosted-domain.sh @@ -1,99 +1,99 @@ #!/bin/bash . ./settings.sh ( - echo "dn: associateddomain=${hosted_domain},ou=Domains,${rootdn}" + echo "dn: associateddomain=${hosted_domain},${domain_base_dn}" echo "objectclass: top" echo "objectclass: domainrelatedobject" echo "objectclass: inetdomain" echo "inetdomainstatus: active" echo "associateddomain: ${hosted_domain}" echo "inetdomainbasedn: ${hosted_domain_rootdn}" echo "" echo "dn: cn=$(echo ${hosted_domain_rootdn} | sed -e 's/=/\\3D/g' -e 's/,/\\2D/g'),cn=mapping tree,cn=config" echo "objectClass: top" echo "objectClass: extensibleObject" echo "objectClass: nsMappingTree" echo "nsslapd-state: backend" echo "cn: ${hosted_domain_rootdn}" echo "nsslapd-backend: $(echo ${hosted_domain} | sed -e 's/\./_/g')" echo "" echo "dn: cn=$(echo ${hosted_domain} | sed -e 's/\./_/g'),cn=ldbm database,cn=plugins,cn=config" echo "objectClass: top" echo "objectClass: extensibleobject" echo "objectClass: nsbackendinstance" echo "cn: $(echo ${hosted_domain} | sed -e 's/\./_/g')" echo "nsslapd-suffix: ${hosted_domain_rootdn}" echo "nsslapd-cachesize: -1" echo "nsslapd-cachememsize: 10485760" echo "nsslapd-readonly: off" echo "nsslapd-require-index: off" - echo "nsslapd-directory: /var/lib/dirsrv/slapd-$(hostname -s)/db/$(echo ${hosted_domain} | sed -e 's/\./_/g')" + echo "nsslapd-directory: /var/lib/dirsrv/slapd-${DS_INSTANCE_NAME:-$(hostname -s)}/db/$(echo ${hosted_domain} | sed -e 's/\./_/g')" echo "nsslapd-dncachememsize: 10485760" echo "" ) | ldapadd -x -h ${ldap_host} -D "${ldap_binddn}" -w "${ldap_bindpw}" ( echo "dn: ${hosted_domain_rootdn}" echo "aci: (targetattr=\"carLicense || description || displayName || facsimileTelephoneNumber || homePhone || homePostalAddress || initials || jpegPhoto || labeledURI || mobile || pager || photo || postOfficeBox || postalAddress || postalCode || preferredDeliveryMethod || preferredLanguage || registeredAddress || roomNumber || secretary || seeAlso || st || street || telephoneNumber || telexNumber || title || userCertificate || userPassword || userSMIMECertificate || x500UniqueIdentifier\")(version 3.0; acl \"Enable self write for common attributes\"; allow (write) userdn=\"ldap:///self\";)" echo "aci: (targetattr =\"*\")(version 3.0;acl \"Directory Administrators Group\";allow (all) (groupdn=\"ldap:///cn=Directory Administrators,${hosted_domain_rootdn}\" or roledn=\"ldap:///cn=kolab-admin,${hosted_domain_rootdn}\");)" echo "aci: (targetattr=\"*\")(version 3.0; acl \"Configuration Administrators Group\"; allow (all) groupdn=\"ldap:///cn=Configuration Administrators,ou=Groups,ou=TopologyManagement,o=NetscapeRoot\";)" echo "aci: (targetattr=\"*\")(version 3.0; acl \"Configuration Administrator\"; allow (all) userdn=\"ldap:///uid=admin,ou=Administrators,ou=TopologyManagement,o=NetscapeRoot\";)" echo "aci: (targetattr = \"*\")(version 3.0; acl \"SIE Group\"; allow (all) groupdn = \"ldap:///cn=slapd-$(hostname -s),cn=389 Directory Server,cn=Server Group,cn=$(hostname -f),ou=${domain},o=NetscapeRoot\";)" echo "aci: (targetattr = \"*\") (version 3.0;acl \"Search Access\";allow (read,compare,search)(userdn = \"ldap:///${hosted_domain_rootdn}??sub?(objectclass=*)\");)" echo "aci: (targetattr = \"*\") (version 3.0;acl \"Service Search Access\";allow (read,compare,search)(userdn = \"ldap:///uid=kolab-service,ou=Special Users,${rootdn}\");)" echo "objectClass: top" echo "objectClass: domain" echo "dc: $(echo ${hosted_domain} | cut -d'.' -f 1)" echo "" ) | ldapadd -x -h ${ldap_host} -D "${ldap_binddn}" -w "${ldap_bindpw}" ( for role in "2fa-user" "activesync-user" "imap-user"; do echo "dn: cn=${role},${hosted_domain_rootdn}" echo "cn: ${role}" echo "description: ${role} role" echo "objectclass: top" echo "objectclass: ldapsubentry" echo "objectclass: nsmanagedroledefinition" echo "objectclass: nsroledefinition" echo "objectclass: nssimpleroledefinition" echo "" done echo "dn: ou=Groups,${hosted_domain_rootdn}" echo "ou: Groups" echo "objectClass: top" echo "objectClass: organizationalunit" echo "" echo "dn: ou=People,${hosted_domain_rootdn}" echo "aci: (targetattr = \"*\") (version 3.0;acl \"Hosted Kolab Services\";allow (all)(userdn = \"ldap:///uid=hosted-kolab-service,ou=Special Users,${rootdn}\");)" echo "ou: People" echo "objectClass: top" echo "objectClass: organizationalunit" echo "" echo "dn: ou=Special Users,${hosted_domain_rootdn}" echo "ou: Special Users" echo "objectClass: top" echo "objectClass: organizationalunit" echo "" echo "dn: ou=Resources,${hosted_domain_rootdn}" echo "ou: Resources" echo "objectClass: top" echo "objectClass: organizationalunit" echo "" echo "dn: ou=Shared Folders,${hosted_domain_rootdn}" echo "ou: Shared Folders" echo "objectClass: top" echo "objectClass: organizationalunit" echo "" ) | ldapadd -x -h ${ldap_host} -D "${ldap_binddn}" -w "${ldap_bindpw}" diff --git a/docker/kolab/utils/17-remove-hosted-service-access-from-mgmt-domain.sh b/docker/kolab/utils/17-remove-hosted-service-access-from-mgmt-domain.sh index 5460d8e1..3f930c81 100755 --- a/docker/kolab/utils/17-remove-hosted-service-access-from-mgmt-domain.sh +++ b/docker/kolab/utils/17-remove-hosted-service-access-from-mgmt-domain.sh @@ -1,11 +1,12 @@ #!/bin/bash - . ./settings.sh +. ./settings.sh + ( echo "dn: associateddomain=${domain},ou=Domains,${rootdn}" echo "changetype: modify" echo "replace: aci" echo "aci: (targetattr = \"*\")(version 3.0;acl \"Deny Rest\";deny (all)(userdn != \"ldap:///uid=kolab-service,ou=Special Users,${rootdn} || ldap:///${rootdn}??sub?(objectclass=*)\");)" echo "aci: (targetattr = \"*\")(version 3.0;acl \"Deny Hosted Kolab\";deny (all)(userdn = \"ldap:///uid=hosted-kolab-service,ou=Special Users,${rootdn}\");)" echo "" ) | ldapmodify -x -h ${ldap_host} -D "${ldap_binddn}" -w "${ldap_bindpw}" diff --git a/docker/kolab/utils/20-add-alias-attribute-index.sh b/docker/kolab/utils/20-add-alias-attribute-index.sh index 6f0e1d50..a43c582c 100755 --- a/docker/kolab/utils/20-add-alias-attribute-index.sh +++ b/docker/kolab/utils/20-add-alias-attribute-index.sh @@ -1,55 +1,55 @@ #!/bin/bash - . ./settings.sh +. ./settings.sh export index_attr=alias ( echo "dn: cn=${index_attr},cn=index,cn=${hosted_domain_db},cn=ldbm database,cn=plugins,cn=config" echo "objectclass: top" echo "objectclass: nsindex" echo "cn: ${index_attr}" echo "nsSystemIndex: false" echo "nsindextype: pres" echo "nsindextype: eq" echo "nsindextype: sub" ) | ldapadd -x -h ${ldap_host} -D "${ldap_binddn}" -w "${ldap_bindpw}" -c ( echo "dn: cn=${hosted_domain_db} ${index_attr} index,cn=index,cn=tasks,cn=config" echo "objectclass: top" echo "objectclass: extensibleObject" echo "cn: ${hosted_domain_db} ${index_attr} index" echo "nsinstance: ${hosted_domain_db}" echo "nsIndexAttribute: ${index_attr}:pres" echo "nsIndexAttribute: ${index_attr}:eq" echo "nsIndexAttribute: ${index_attr}:sub" echo "" ) | ldapadd -x -h ${ldap_host} -D "${ldap_binddn}" -w "${ldap_bindpw}" -c ldap_complete=0 while [ ${ldap_complete} -ne 1 ]; do result=$( ldapsearch \ -x \ -h ${ldap_host} \ -D "${ldap_binddn}" \ -w "${ldap_bindpw}" \ -c \ -LLL \ -b "cn=${hosted_domain_db} ${index_attr} index,cn=index,cn=tasks,cn=config" \ '(!(nstaskexitcode=0))' \ -s base 2>/dev/null ) if [ -z "$result" ]; then ldap_complete=1 echo "" else echo -n "." sleep 1 fi done diff --git a/docker/kolab/utils/settings.sh b/docker/kolab/utils/settings.sh index b164c4f4..47a14b10 100755 --- a/docker/kolab/utils/settings.sh +++ b/docker/kolab/utils/settings.sh @@ -1,23 +1,24 @@ #!/bin/bash -export rootdn="dc=mgmt,dc=com" -export domain="mgmt.com" -export domain_db="mgmt_com" -export ldap_host="127.0.0.1" -export ldap_binddn="cn=Directory Manager" -export ldap_bindpw="Welcome2KolabSystems" +export rootdn=${LDAP_ADMIN_ROOT_DN:-"dc=mgmt,dc=com"} +export domain=${DOMAIN:-"mgmt.com"} +export domain_db=${DOMAIN_DB:-"mgmt_com"} +export ldap_host=${LDAP_HOST:-"127.0.0.1"} +export ldap_binddn=${LDAP_ADMIN_BIND_DN:-"cn=Directory Manager"} +export ldap_bindpw=${LDAP_ADMIN_BIND_PW:-"Welcome2KolabSystems"} -export cyrus_admin="cyrus-admin" +export cyrus_admin=${IMAP_ADMIN_LOGIN:-"cyrus-admin"} -export imap_host="127.0.0.1" -export cyrus_admin_pw="Welcome2KolabSystems" +export imap_host=${IMAP_HOST:-"127.0.0.1"} +export cyrus_admin_pw=${IMAP_ADMIN_PASSWORD:-"Welcome2KolabSystems"} -export hosted_kolab_service_pw="Welcome2KolabSystems" +export kolab_service_pw=${LDAP_SERVICE_BIND_PW:-"Welcome2KolabSystems"} +export hosted_kolab_service_pw=${LDAP_HOSTED_BIND_PW:-"Welcome2KolabSystems"} -export hosted_domain="hosted.com" -export hosted_domain_db="hosted_com" -export hosted_domain_rootdn="dc=hosted,dc=com" +export hosted_domain=${HOSTED_DOMAIN:-"hosted.com"} +export hosted_domain_db=${HOSTED_DOMAIN_DB:-"hosted_com"} +export hosted_domain_rootdn=${LDAP_HOSTED_ROOT_DN:-"dc=hosted,dc=com"} -export domain_base_dn="ou=Domains,dc=mgmt,dc=com" +export domain_base_dn=${LDAP_DOMAIN_BASE_DN:-"ou=Domains,dc=mgmt,dc=com"} -export default_user_password="Welcome2KolabSystems" +export default_user_password=${DEFAULT_USER_PASSWORD:-"Welcome2KolabSystems"} diff --git a/docker/mariadb/mysql-init/80-add-users.sh b/docker/mariadb/mysql-init/80-add-users.sh new file mode 100644 index 00000000..4b81c9b4 --- /dev/null +++ b/docker/mariadb/mysql-init/80-add-users.sh @@ -0,0 +1,29 @@ +create_arbitrary_users() { + + # Do not care what option is compulsory here, just create what is specified + log_info "Creating user specified by (${2}) ..." +mysql $mysql_flags <> $(rm -vrf vendor/ composer.lock)" + +if [ -f ".env.local" ]; then + # Ensure there's a line ending + echo "---->> Append .env.local" + echo "" >> .env + cat .env.local >> .env +fi + +#env + +/usr/libexec/s2i/assemble + +#cat >> /opt/app-root/etc/conf.d/99-loglevel.conf << EOF +#LogLevel warn mod_rewrite.c:trace4 +#EOF + +# Won't work due to: +# Cannot install, php_dir for channel "pecl.php.net" is not writeable by the current user +#pecl channel-update pecl.php.net +#pecl install swoole + +pushd /opt/app-root/src + +echo "---->> Run npm run prod" +npm install cross-env +npm run prod + diff --git a/src/.s2i/bin/run b/src/.s2i/bin/run new file mode 100755 index 00000000..7c021ea9 --- /dev/null +++ b/src/.s2i/bin/run @@ -0,0 +1,60 @@ +#!/bin/bash + +shopt -s dotglob + +pushd /opt/app-root/src + +echo "----> Remove bootstrap cache" +find bootstrap/cache/ -type f ! -name ".gitignore" -delete + +if [ -z ${APP_KEY} ]; then + echo "----> Run artisan key:generate" + ./artisan key:generate +fi + +if [ -z ${JWT_SECRET} ]; then + echo "----> Run artisan jwt:secret" + ./artisan jwt:secret --always-no +fi + +echo "----> Run artisan clear-compiled" +./artisan clear-compiled + +echo "----> Run artisan cache:clear" +./artisan ${ARTISAN_VERBOSITY} cache:clear || true + +# rpm -qv chromium +# if [ ! -z "$(rpm -qv chromium 2>/dev/null)" ]; then +# echo "---- Run artisan dusk:chrome-driver" +# chver=$(rpmquery --queryformat="%{VERSION}" chromium | awk -F'.' '{print $1}') +# ./artisan dusk:chrome-driver ${chver} +# fi + +if [ ! -f 'resources/countries.php' ]; then + echo "----> Run artisan data:countries" + ./artisan data:countries +fi + +rm -rvf bootstrap/cache/ +mkdir -vp bootstrap/cache/ +chown default bootstrap/cache + +./artisan db:ping --wait || exit 1 + +./artisan migrate --force || : +#./artisan db:seed --force || : + +case ${HKCCP_APP} in + worker|WORKER ) + echo "----> Running worker " + ./artisan queue:work;; + server|SERVER ) + echo "----> Running server " + ./artisan serve;; + apache|APACHE|httpd|HTTPD ) + echo "----> Starting httpd " + /usr/libexec/s2i/run 2>&1;; + * ) + echo "----> Sleeping" + sleep 10000;; +esac diff --git a/src/app/Auth/LDAPUserProvider.php b/src/app/Auth/LDAPUserProvider.php index bbbee25c..6c9a21c2 100644 --- a/src/app/Auth/LDAPUserProvider.php +++ b/src/app/Auth/LDAPUserProvider.php @@ -1,101 +1,101 @@ get(); + $entries = User::where('email', \strtolower($credentials['email']))->get(); $count = $entries->count(); if ($count == 1) { return $entries->first(); } if ($count > 1) { \Log::warning("Multiple entries for {$credentials['email']}"); } else { \Log::warning("No entries for {$credentials['email']}"); } return null; } /** * Validate the credentials for a user. * * @param Authenticatable $user The user. * @param array $credentials The credentials. * * @return bool */ public function validateCredentials(Authenticatable $user, array $credentials): bool { $authenticated = false; - if ($user->email == $credentials['email']) { + if ($user->email === \strtolower($credentials['email'])) { if (!empty($user->password)) { if (Hash::check($credentials['password'], $user->password)) { $authenticated = true; } } elseif (!empty($user->password_ldap)) { if (substr($user->password_ldap, 0, 6) == "{SSHA}") { $salt = substr(base64_decode(substr($user->password_ldap, 6)), 20); $hash = '{SSHA}' . base64_encode( sha1($credentials['password'] . $salt, true) . $salt ); if ($hash == $user->password_ldap) { $authenticated = true; } } elseif (substr($user->password_ldap, 0, 9) == "{SSHA512}") { $salt = substr(base64_decode(substr($user->password_ldap, 9)), 64); $hash = '{SSHA512}' . base64_encode( pack('H*', hash('sha512', $credentials['password'] . $salt)) . $salt ); if ($hash == $user->password_ldap) { $authenticated = true; } } } else { \Log::error("Incomplete credentials for {$user->email}"); } } if ($authenticated) { \Log::info("Successful authentication for {$user->email}"); // TODO: update last login time if (empty($user->password) || empty($user->password_ldap)) { $user->password = $credentials['password']; $user->save(); } } else { // TODO: Try actual LDAP? \Log::info("Authentication failed for {$user->email}"); } return $authenticated; } } diff --git a/src/app/Backends/LDAP.php b/src/app/Backends/LDAP.php index 72985758..1f47d231 100644 --- a/src/app/Backends/LDAP.php +++ b/src/app/Backends/LDAP.php @@ -1,585 +1,679 @@ close(); self::$ldap = null; } } /** * Create a domain in LDAP. * * @param \App\Domain $domain The domain to create. * - * @return void + * @throws \Exception */ - public static function createDomain(Domain $domain) + public static function createDomain(Domain $domain): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $hostedRootDN = \config('ldap.hosted.root_dn'); $mgmtRootDN = \config('ldap.admin.root_dn'); $domainBaseDN = "ou={$domain->namespace},{$hostedRootDN}"; $aci = [ '(targetattr = "*")' . '(version 3.0; acl "Deny Unauthorized"; deny (all)' . '(userdn != "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . ' || ldap:///ou=People,' . $domainBaseDN . '??sub?(objectclass=inetorgperson)") ' . 'AND NOT roledn = "ldap:///cn=kolab-admin,' . $mgmtRootDN . '";)', '(targetattr != "userPassword")' . '(version 3.0;acl "Search Access";allow (read,compare,search)' . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . ' || ldap:///ou=People,' . $domainBaseDN . '??sub?(objectclass=inetorgperson)");)', '(targetattr = "*")' . '(version 3.0;acl "Kolab Administrators";allow (all)' . '(roledn = "ldap:///cn=kolab-admin,' . $domainBaseDN . ' || ldap:///cn=kolab-admin,' . $mgmtRootDN . '");)' ]; $entry = [ 'aci' => $aci, 'associateddomain' => $domain->namespace, 'inetdomainbasedn' => $domainBaseDN, 'objectclass' => [ 'top', 'domainrelatedobject', 'inetdomain' ], ]; $dn = "associateddomain={$domain->namespace},{$config['domain_base_dn']}"; + self::setDomainAttributes($domain, $entry); + if (!$ldap->get_entry($dn)) { - $ldap->add_entry($dn, $entry); + $result = $ldap->add_entry($dn, $entry); + + if (!$result) { + self::throwException($ldap, "Failed to create domain {$domain->namespace} in LDAP"); + } } // create ou, roles, ous $entry = [ 'description' => $domain->namespace, 'objectclass' => [ 'top', 'organizationalunit' ], 'ou' => $domain->namespace, ]; $entry['aci'] = array( '(targetattr = "*")' . '(version 3.0;acl "Deny Unauthorized"; deny (all)' . '(userdn != "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . ' || ldap:///ou=People,' . $domainBaseDN . '??sub?(objectclass=inetorgperson)") ' . 'AND NOT roledn = "ldap:///cn=kolab-admin,' . $mgmtRootDN . '";)', '(targetattr != "userPassword")' . '(version 3.0;acl "Search Access";allow (read,compare,search,write)' . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . ' || ldap:///ou=People,' . $domainBaseDN . '??sub?(objectclass=inetorgperson)");)', '(targetattr = "*")' . '(version 3.0;acl "Kolab Administrators";allow (all)' . '(roledn = "ldap:///cn=kolab-admin,' . $domainBaseDN . ' || ldap:///cn=kolab-admin,' . $mgmtRootDN . '");)', '(target = "ldap:///ou=*,' . $domainBaseDN . '")' . '(targetattr="objectclass || aci || ou")' . '(version 3.0;acl "Allow Domain sub-OU Registration"; allow (add)' . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . '");)', '(target = "ldap:///uid=*,ou=People,' . $domainBaseDN . '")(targetattr="*")' . '(version 3.0;acl "Allow Domain First User Registration"; allow (add)' . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . '");)', '(target = "ldap:///cn=*,' . $domainBaseDN . '")(targetattr="objectclass || cn")' . '(version 3.0;acl "Allow Domain Role Registration"; allow (add)' . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . '");)', ); if (!$ldap->get_entry($domainBaseDN)) { $ldap->add_entry($domainBaseDN, $entry); } foreach (['Groups', 'People', 'Resources', 'Shared Folders'] as $item) { if (!$ldap->get_entry("ou={$item},{$domainBaseDN}")) { $ldap->add_entry( "ou={$item},{$domainBaseDN}", [ 'ou' => $item, 'description' => $item, 'objectclass' => [ 'top', 'organizationalunit' ] ] ); } } - foreach (['kolab-admin', 'billing-user'] as $item) { + foreach (['kolab-admin'] as $item) { if (!$ldap->get_entry("cn={$item},{$domainBaseDN}")) { $ldap->add_entry( "cn={$item},{$domainBaseDN}", [ 'cn' => $item, 'description' => "{$item} role", 'objectclass' => [ 'top', 'ldapsubentry', 'nsmanagedroledefinition', 'nsroledefinition', 'nssimpleroledefinition' ] ] ); } } + // TODO: Assign kolab-admin role to the owner? + if (empty(self::$ldap)) { $ldap->close(); } } /** * Create a user in LDAP. * * Only need to add user if in any of the local domains? Figure that out here for now. Should * have Context-Based Access Controls before the job is queued though, probably. * * Use one of three modes; * * 1) The authenticated user account. * * * Only valid if the authenticated user is a domain admin. * * We don't know the originating user here. * * We certainly don't have its password anymore. * * 2) The hosted kolab account. * * 3) The Directory Manager account. * * @param \App\User $user The user account to create. * - * @return bool|void + * @throws \Exception */ - public static function createUser(User $user) + public static function createUser(User $user): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $entry = [ 'objectclass' => [ 'top', 'inetorgperson', 'inetuser', 'kolabinetorgperson', 'mailrecipient', 'person' ], 'mail' => $user->email, 'uid' => $user->email, 'nsroledn' => [] ]; if (!self::getUserEntry($ldap, $user->email, $dn) && $dn) { self::setUserAttributes($user, $entry); - $ldap->add_entry($dn, $entry); + $result = $ldap->add_entry($dn, $entry); + + if (!$result) { + self::throwException($ldap, "Failed to create user {$user->email} in LDAP"); + } } if (empty(self::$ldap)) { $ldap->close(); } } /** - * Update a domain in LDAP. + * Delete a domain from LDAP. * * @param \App\Domain $domain The domain to update. * - * @return void + * @throws \Exception */ - public static function updateDomain(Domain $domain) + public static function deleteDomain(Domain $domain): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); - $ldapDomain = $ldap->find_domain($domain->namespace); + $hostedRootDN = \config('ldap.hosted.root_dn'); + $mgmtRootDN = \config('ldap.admin.root_dn'); - $oldEntry = $ldap->get_entry($ldapDomain['dn']); - $newEntry = $oldEntry; + $domainBaseDN = "ou={$domain->namespace},{$hostedRootDN}"; - self::setDomainAttributes($domain, $newEntry); + if ($ldap->get_entry($domainBaseDN)) { + $result = $ldap->delete_entry_recursive($domainBaseDN); + + if (!$result) { + self::throwException($ldap, "Failed to delete domain {$domain->namespace} from LDAP"); + } + } - $ldap->modify_entry($ldapDomain['dn'], $oldEntry, $newEntry); + if ($ldap_domain = $ldap->find_domain($domain->namespace)) { + if ($ldap->get_entry($ldap_domain['dn'])) { + $result = $ldap->delete_entry($ldap_domain['dn']); + + if (!$result) { + self::throwException($ldap, "Failed to delete domain {$domain->namespace} from LDAP"); + } + } + } if (empty(self::$ldap)) { $ldap->close(); } } /** - * Delete a domain from LDAP. + * Delete a user from LDAP. * - * @param \App\Domain $domain The domain to update. + * @param \App\User $user The user account to update. * - * @return void + * @throws \Exception */ - public static function deleteDomain(Domain $domain) + public static function deleteUser(User $user): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); - $hostedRootDN = \config('ldap.hosted.root_dn'); - $mgmtRootDN = \config('ldap.admin.root_dn'); - - $domainBaseDN = "ou={$domain->namespace},{$hostedRootDN}"; - - if ($ldap->get_entry($domainBaseDN)) { - $ldap->delete_entry_recursive($domainBaseDN); - } + if (self::getUserEntry($ldap, $user->email, $dn)) { + $result = $ldap->delete_entry($dn); - if ($ldap_domain = $ldap->find_domain($domain->namespace)) { - if ($ldap->get_entry($ldap_domain['dn'])) { - $ldap->delete_entry($ldap_domain['dn']); + if (!$result) { + self::throwException($ldap, "Failed to delete user {$user->email} from LDAP"); } } if (empty(self::$ldap)) { $ldap->close(); } } /** - * Delete a user from LDAP. + * Get a domain data from LDAP. * - * @param \App\User $user The user account to update. + * @param string $namespace The domain name * - * @return void + * @return array|false|null + * @throws \Exception */ - public static function deleteUser(User $user) + public static function getDomain(string $namespace) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); - if (self::getUserEntry($ldap, $user->email, $dn)) { - $ldap->delete_entry($dn); + $ldapDomain = $ldap->find_domain($namespace); + + if ($ldapDomain) { + $domain = $ldap->get_entry($ldapDomain['dn']); } if (empty(self::$ldap)) { $ldap->close(); } + + return $domain ?? null; } /** * Get a user data from LDAP. * * @param string $email The user email. * * @return array|false|null + * @throws \Exception */ public static function getUser(string $email) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $user = self::getUserEntry($ldap, $email, $dn, true); if (empty(self::$ldap)) { $ldap->close(); } return $user; } + /** + * Update a domain in LDAP. + * + * @param \App\Domain $domain The domain to update. + * + * @throws \Exception + */ + public static function updateDomain(Domain $domain): void + { + $config = self::getConfig('admin'); + $ldap = self::initLDAP($config); + + $ldapDomain = $ldap->find_domain($domain->namespace); + + if (!$ldapDomain) { + self::throwException($ldap, "Failed to update domain {$domain->namespace} in LDAP (domain not found)"); + } + + $oldEntry = $ldap->get_entry($ldapDomain['dn']); + $newEntry = $oldEntry; + + self::setDomainAttributes($domain, $newEntry); + + if (array_key_exists('inetdomainstatus', $newEntry)) { + $newEntry['inetdomainstatus'] = (string) $newEntry['inetdomainstatus']; + } + + $result = $ldap->modify_entry($ldapDomain['dn'], $oldEntry, $newEntry); + + if (!is_array($result)) { + self::throwException($ldap, "Failed to update domain {$domain->namespace} in LDAP"); + } + + if (empty(self::$ldap)) { + $ldap->close(); + } + } + /** * Update a user in LDAP. * * @param \App\User $user The user account to update. * - * @return false|void + * @throws \Exception */ - public static function updateUser(User $user) + public static function updateUser(User $user): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $newEntry = $oldEntry = self::getUserEntry($ldap, $user->email, $dn, true); if (!$oldEntry) { - return false; + self::throwException($ldap, "Failed to update user {$user->email} in LDAP (user not found)"); } self::setUserAttributes($user, $newEntry); - $ldap->modify_entry($dn, $oldEntry, $newEntry); + if (array_key_exists('objectclass', $newEntry)) { + if (!in_array('inetuser', $newEntry['objectclass'])) { + $newEntry['objectclass'][] = 'inetuser'; + } + } + + if (array_key_exists('inetuserstatus', $newEntry)) { + $newEntry['inetuserstatus'] = (string) $newEntry['inetuserstatus']; + } + + if (array_key_exists('mailquota', $newEntry)) { + $newEntry['mailquota'] = (string) $newEntry['mailquota']; + } + + $result = $ldap->modify_entry($dn, $oldEntry, $newEntry); + + if (!is_array($result)) { + self::throwException($ldap, "Failed to update user {$user->email} in LDAP"); + } if (empty(self::$ldap)) { $ldap->close(); } } /** * Initialize connection to LDAP */ private static function initLDAP(array $config, string $privilege = 'admin') { if (self::$ldap) { return self::$ldap; } $ldap = new \Net_LDAP3($config); - $ldap->connect(); + $connected = $ldap->connect(); - $ldap->bind(\config("ldap.{$privilege}.bind_dn"), \config("ldap.{$privilege}.bind_pw")); + if (!$connected) { + throw new \Exception("Failed to connect to LDAP"); + } - // TODO: error handling + $bound = $ldap->bind(\config("ldap.{$privilege}.bind_dn"), \config("ldap.{$privilege}.bind_pw")); + + if (!$bound) { + throw new \Exception("Failed to bind to LDAP"); + } return $ldap; } /** * Set domain attributes */ private static function setDomainAttributes(Domain $domain, array &$entry) { $entry['inetdomainstatus'] = $domain->status; } /** * Set common user attributes */ private static function setUserAttributes(User $user, array &$entry) { $firstName = $user->getSetting('first_name'); $lastName = $user->getSetting('last_name'); $cn = "unknown"; $displayname = ""; if ($firstName) { if ($lastName) { $cn = "{$firstName} {$lastName}"; $displayname = "{$lastName}, {$firstName}"; } else { $lastName = "unknown"; $cn = "{$firstName}"; $displayname = "{$firstName}"; } } else { $firstName = ""; if ($lastName) { $cn = "{$lastName}"; $displayname = "{$lastName}"; } else { $lastName = "unknown"; } } $entry['cn'] = $cn; $entry['displayname'] = $displayname; $entry['givenname'] = $firstName; $entry['sn'] = $lastName; $entry['userpassword'] = $user->password_ldap; $entry['inetuserstatus'] = $user->status; $entry['o'] = $user->getSetting('organization'); $entry['mailquota'] = 0; $entry['alias'] = $user->aliases->pluck('alias')->toArray(); $roles = []; foreach ($user->entitlements as $entitlement) { \Log::debug("Examining {$entitlement->sku->title}"); switch ($entitlement->sku->title) { case "mailbox": break; case "storage": $entry['mailquota'] += 1048576; break; default: $roles[] = $entitlement->sku->title; break; } } $hostedRootDN = \config('ldap.hosted.root_dn'); - if (empty($roles)) { - if (array_key_exists('nsroledn', $entry)) { - unset($entry['nsroledn']); - } - - return; - } - $entry['nsroledn'] = []; if (in_array("2fa", $roles)) { $entry['nsroledn'][] = "cn=2fa-user,{$hostedRootDN}"; } if (in_array("activesync", $roles)) { $entry['nsroledn'][] = "cn=activesync-user,{$hostedRootDN}"; } if (!in_array("groupware", $roles)) { $entry['nsroledn'][] = "cn=imap-user,{$hostedRootDN}"; } - - if (empty($entry['nsroledn'])) { - unset($entry['nsroledn']); - } } /** * Get LDAP configuration for specified access level */ private static function getConfig(string $privilege) { $config = [ 'domain_base_dn' => \config('ldap.domain_base_dn'), 'domain_filter' => \config('ldap.domain_filter'), 'domain_name_attribute' => \config('ldap.domain_name_attribute'), 'hosts' => \config('ldap.hosts'), 'sort' => false, 'vlv' => false, 'log_hook' => 'App\Backends\LDAP::logHook', ]; return $config; } /** * Get user entry from LDAP. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $email User email (uid) * @param string $dn Reference to user DN * @param bool $full Get extra attributes, e.g. nsroledn * * @return false|null|array User entry, False on error, NULL if not found */ - protected static function getUserEntry($ldap, $email, &$dn = null, $full = false) + private static function getUserEntry($ldap, $email, &$dn = null, $full = false) { list($_local, $_domain) = explode('@', $email, 2); $domain = $ldap->find_domain($_domain); if (!$domain) { - return false; + return $domain; } $base_dn = $ldap->domain_root_dn($_domain); $dn = "uid={$email},ou=People,{$base_dn}"; $entry = $ldap->get_entry($dn); if ($entry && $full) { if (!array_key_exists('nsroledn', $entry)) { $roles = $ldap->get_entry_attributes($dn, ['nsroledn']); if (!empty($roles)) { $entry['nsroledn'] = (array) $roles['nsroledn']; } } } return $entry ?: null; } /** * Logging callback */ public static function logHook($level, $msg): void { if ( ( $level == LOG_INFO || $level == LOG_DEBUG || $level == LOG_NOTICE ) && !\config('app.debug') ) { return; } switch ($level) { case LOG_CRIT: $function = 'critical'; break; case LOG_EMERG: $function = 'emergency'; break; case LOG_ERR: $function = 'error'; break; case LOG_ALERT: $function = 'alert'; break; case LOG_WARNING: $function = 'warning'; break; case LOG_INFO: $function = 'info'; break; case LOG_DEBUG: $function = 'debug'; break; case LOG_NOTICE: $function = 'notice'; break; default: $function = 'info'; } if (is_array($msg)) { $msg = implode("\n", $msg); } $msg = '[LDAP] ' . $msg; \Log::{$function}($msg); } + + /** + * Throw exception and close the connection when needed + * + * @param \Net_LDAP3 $ldap Ldap connection + * @param string $message Exception message + * + * @throws \Exception + */ + private static function throwException($ldap, string $message): void + { + if (empty(self::$ldap) && !empty($ldap)) { + $ldap->close(); + } + + throw new \Exception($message); + } } diff --git a/src/app/Console/Commands/DataCountries.php b/src/app/Console/Commands/DataCountries.php index 3c6bbd60..ac94cdf6 100644 --- a/src/app/Console/Commands/DataCountries.php +++ b/src/app/Console/Commands/DataCountries.php @@ -1,142 +1,97 @@ currency + 'LT' => 'EUR', + ]; + /** * The name and signature of the console command. * * @var string */ protected $signature = 'data:countries'; /** * The console command description. * * @var string */ protected $description = 'Fetches countries map from wikipedia'; /** * Execute the console command. * * @return mixed */ public function handle() { $countries = []; $currencies = []; - $currencies_url = 'http://en.wikipedia.org/wiki/ISO_4217'; - $countries_url = 'http://en.wikipedia.org/wiki/ISO_3166-1'; + $currencies_url = 'http://country.io/currency.json'; + $countries_url = 'http://country.io/names.json'; $this->info("Fetching currencies from $currencies_url..."); // fetch currency table and create an index by country page url - $page = file_get_contents($currencies_url); + $currencies_json = file_get_contents($currencies_url); - if (!$page) { + if (!$currencies_json) { $this->error("Failed to fetch currencies"); return; } - $table_regexp = '!!ims'; - if (preg_match_all($table_regexp, $page, $matches, PREG_PATTERN_ORDER)) { - foreach ($matches[0] as $currency_table) { - preg_match_all('!\s*\s*!Ums', $currency_table, $rows); - - foreach ($rows[1] as $row) { - $cells = preg_split('!\s*]*>!', $row); - - if (count($cells) == 5) { - // actual currency table - $currency = preg_match('/([A-Z]{3})/', $cells[0], $m) ? $m[1] : ''; - - if (preg_match('/(\d+)/', $cells[1], $m)) { - $isocode = $m[1]; - $currencies[$m[1]] = $currency; - } - - preg_match_all('!]+href="(/wiki/[^"]+)"[^>]*>!', $cells[4], $links, PREG_PATTERN_ORDER); - - foreach ($links[1] as $link) { - $currencies[strtolower($link)] = $currency; - } - } elseif (count($cells) == 7) { - // replacements table - $currency = preg_match('/([A-Z]{3})/', $cells[6], $m) ? $m[1] : ''; - - if (preg_match('/(\d+)/', $cells[1], $m)) { - $currencies[$m[1]] = $currency; - } - } - } - } - } - - $namecol = 0; - $codecol = 1; - $numcol = 3; - $lang = 'en'; - $this->info("Fetching countries from $countries_url..."); - $page = file_get_contents($countries_url); + $countries_json = file_get_contents($countries_url); - if (!$page) { + if (!$countries_json) { $this->error("Failed to fetch countries"); return; } - if (preg_match($table_regexp, $page, $matches)) { - preg_match_all('!\s*\s*!Ums', $matches[0], $rows); - - foreach ($rows[1] as $row) { - $cells = preg_split('!\s*]*>!', $row); - - if (count($cells) < 5) { - continue; - } - - $regexp = '!]+href="(/wiki/[^"]+)"[^>]*>([^>]+)!i'; - $content = preg_match($regexp, $cells[$namecol], $m) ? $m : null; - - if (preg_match('/>([A-Z]{2})error("Invalid countries data"); + return; + } + + if (!is_array($currencies) || empty($currencies)) { + $this->error("Invalid currencies data"); + return; } $file = resource_path('countries.php'); $this->info("Generating resource file $file..."); + asort($countries); + $out = " $names) { - if (!empty($names['en']) && !empty($names['currency'])) { - $out .= sprintf(" '%s' => ['%s','%s'],\n", $code, $names['currency'], addslashes($names['en'])); + foreach ($countries as $code => $name) { + $currency = $currencies[$code] ?? null; + + if (!empty($this->currency_fixes[$code])) { + $currency = $this->currency_fixes[$code]; + } + + if (!$currency) { + $this->error("Unknown currency for {$name} ({$code}). Skipped."); + continue; } + + $out .= sprintf(" '%s' => ['%s','%s'],\n", $code, $currency, addslashes($name)); } $out .= "];\n"; file_put_contents($file, $out); } } diff --git a/src/app/Console/Commands/DomainAdd.php b/src/app/Console/Commands/DomainAdd.php new file mode 100644 index 00000000..fbc8d0e4 --- /dev/null +++ b/src/app/Console/Commands/DomainAdd.php @@ -0,0 +1,76 @@ +argument('domain')); + + // must use withTrashed(), because unique constraint + $domain = 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; + $domain->save(); + + // remove existing entitlement + $entitlement = Entitlement::withTrashed()->where( + [ + 'entitleable_id' => $domain->id, + 'entitleable_type' => \App\Domain::class + ] + )->first(); + + if ($entitlement) { + $entitlement->forceDelete(); + } + } else { + $this->error("Domain {$namespace} not marked as deleted... examine more closely"); + return 1; + } + } else { + $domain = Domain::create([ + 'namespace' => $namespace, + 'type' => Domain::TYPE_EXTERNAL, + ]); + } + + $this->info($domain->id); + } +} diff --git a/src/app/Console/Commands/DomainStatus.php b/src/app/Console/Commands/DomainSetStatus.php similarity index 61% copy from src/app/Console/Commands/DomainStatus.php copy to src/app/Console/Commands/DomainSetStatus.php index eba31be3..c97078d9 100644 --- a/src/app/Console/Commands/DomainStatus.php +++ b/src/app/Console/Commands/DomainSetStatus.php @@ -1,51 +1,45 @@ argument('domain'))->first(); if (!$domain) { return 1; } - $this->info("Found domain: {$domain->id}"); + 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 new file mode 100644 index 00000000..9c528411 --- /dev/null +++ b/src/app/Console/Commands/DomainSetWallet.php @@ -0,0 +1,68 @@ +argument('domain'))->first(); + + if (!$domain) { + $this->error("Domain not found."); + return 1; + } + + $wallet = Wallet::find($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, + '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 eba31be3..4ad1e664 100644 --- a/src/app/Console/Commands/DomainStatus.php +++ b/src/app/Console/Commands/DomainStatus.php @@ -1,51 +1,49 @@ argument('domain'))->first(); if (!$domain) { return 1; } - $this->info("Found domain: {$domain->id}"); - $this->info($domain->status); } } diff --git a/src/app/Console/Commands/DomainStatus.php b/src/app/Console/Commands/Job/DomainCreate.php similarity index 53% copy from src/app/Console/Commands/DomainStatus.php copy to src/app/Console/Commands/Job/DomainCreate.php index eba31be3..7fa57b90 100644 --- a/src/app/Console/Commands/DomainStatus.php +++ b/src/app/Console/Commands/Job/DomainCreate.php @@ -1,51 +1,40 @@ argument('domain'))->first(); if (!$domain) { return 1; } - $this->info("Found domain: {$domain->id}"); - - $this->info($domain->status); + $job = new \App\Jobs\DomainCreate($domain); + $job->handle(); } } diff --git a/src/app/Console/Commands/DomainStatus.php b/src/app/Console/Commands/Job/DomainUpdate.php similarity index 53% copy from src/app/Console/Commands/DomainStatus.php copy to src/app/Console/Commands/Job/DomainUpdate.php index eba31be3..3b76bbb6 100644 --- a/src/app/Console/Commands/DomainStatus.php +++ b/src/app/Console/Commands/Job/DomainUpdate.php @@ -1,51 +1,40 @@ argument('domain'))->first(); if (!$domain) { return 1; } - $this->info("Found domain: {$domain->id}"); - - $this->info($domain->status); + $job = new \App\Jobs\DomainUpdate($domain->id); + $job->handle(); } } diff --git a/src/app/Console/Commands/Job/UserCreate.php b/src/app/Console/Commands/Job/UserCreate.php new file mode 100644 index 00000000..46b6a370 --- /dev/null +++ b/src/app/Console/Commands/Job/UserCreate.php @@ -0,0 +1,40 @@ +argument('user'))->first(); + + if (!$user) { + return 1; + } + + $job = new \App\Jobs\UserCreate($user); + $job->handle(); + } +} diff --git a/src/app/Console/Commands/Job/UserUpdate.php b/src/app/Console/Commands/Job/UserUpdate.php new file mode 100644 index 00000000..5b80248e --- /dev/null +++ b/src/app/Console/Commands/Job/UserUpdate.php @@ -0,0 +1,40 @@ +argument('user'))->first(); + + if (!$user) { + return 1; + } + + $job = new \App\Jobs\UserUpdate($user); + $job->handle(); + } +} diff --git a/src/app/Console/Commands/Job/WalletCheck.php b/src/app/Console/Commands/Job/WalletCheck.php new file mode 100644 index 00000000..5b80f707 --- /dev/null +++ b/src/app/Console/Commands/Job/WalletCheck.php @@ -0,0 +1,40 @@ +argument('wallet')); + + if (!$wallet) { + return 1; + } + + $job = new \App\Jobs\WalletCheck($wallet); + $job->handle(); + } +} diff --git a/src/app/Console/Commands/UserAddAlias.php b/src/app/Console/Commands/UserAddAlias.php new file mode 100644 index 00000000..d2a3bdb9 --- /dev/null +++ b/src/app/Console/Commands/UserAddAlias.php @@ -0,0 +1,69 @@ +argument('user'))->first(); + + 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::validateEmail($alias, $controller, true); + + if ($error) { + if (!$this->option('force')) { + $this->error($error); + return 1; + } + } + + $user->aliases()->create(['alias' => $alias]); + } +} diff --git a/src/app/Console/Commands/DomainStatus.php b/src/app/Console/Commands/WalletAddTransaction.php similarity index 53% copy from src/app/Console/Commands/DomainStatus.php copy to src/app/Console/Commands/WalletAddTransaction.php index eba31be3..445c9652 100644 --- a/src/app/Console/Commands/DomainStatus.php +++ b/src/app/Console/Commands/WalletAddTransaction.php @@ -1,51 +1,56 @@ argument('domain'))->first(); + $wallet = \App\Wallet::find($this->argument('wallet')); - if (!$domain) { + if (!$wallet) { return 1; } - $this->info("Found domain: {$domain->id}"); + $qty = (int) $this->argument('qty'); - $this->info($domain->status); + $message = $this->option('message'); + + if ($qty < 0) { + $wallet->debit($qty, $message); + } else { + $wallet->credit($qty, $message); + } } } diff --git a/src/app/Console/Commands/WalletCharge.php b/src/app/Console/Commands/WalletCharge.php index eb0199a9..a41b2333 100644 --- a/src/app/Console/Commands/WalletCharge.php +++ b/src/app/Console/Commands/WalletCharge.php @@ -1,55 +1,76 @@ argument('wallet')) { + // Find specified wallet by ID + $wallet = Wallet::find($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') + ->whereNull('users.deleted_at') + ->get(); + } 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/Domain.php b/src/app/Domain.php index ece91c56..ed292a0c 100644 --- a/src/app/Domain.php +++ b/src/app/Domain.php @@ -1,406 +1,406 @@ isPublic()) { return $this; } // See if this domain is already owned by another user. $wallet = $this->wallet(); if ($wallet) { \Log::error( "Domain {$this->namespace} is already assigned to {$wallet->owner->email}" ); return $this; } $wallet_id = $user->wallets()->first()->id; foreach ($package->skus as $sku) { for ($i = $sku->pivot->qty; $i > 0; $i--) { \App\Entitlement::create( [ 'wallet_id' => $wallet_id, 'sku_id' => $sku->id, 'cost' => $sku->pivot->cost(), 'entitleable_id' => $this->id, 'entitleable_type' => Domain::class ] ); } } return $this; } public function entitlement() { return $this->morphOne('App\Entitlement', 'entitleable'); } /** * Return list of public+active domain names */ public static function getPublicDomains(): array { $where = sprintf('(type & %s)', Domain::TYPE_PUBLIC); return self::whereRaw($where)->get(['namespace'])->pluck('namespace')->toArray(); } /** * Returns whether this domain is active. * * @return bool */ public function isActive(): bool { return ($this->status & self::STATUS_ACTIVE) > 0; } /** * Returns whether this domain is confirmed the ownership of. * * @return bool */ public function isConfirmed(): bool { return ($this->status & self::STATUS_CONFIRMED) > 0; } /** * Returns whether this domain is deleted. * * @return bool */ public function isDeleted(): bool { return ($this->status & self::STATUS_DELETED) > 0; } /** * Returns whether this domain is registered with us. * * @return bool */ public function isExternal(): bool { return ($this->type & self::TYPE_EXTERNAL) > 0; } /** * Returns whether this domain is hosted with us. * * @return bool */ public function isHosted(): bool { return ($this->type & self::TYPE_HOSTED) > 0; } /** * Returns whether this domain is new. * * @return bool */ public function isNew(): bool { return ($this->status & self::STATUS_NEW) > 0; } /** * Returns whether this domain is public. * * @return bool */ public function isPublic(): bool { return ($this->type & self::TYPE_PUBLIC) > 0; } /** * Returns whether this domain is registered in LDAP. * * @return bool */ public function isLdapReady(): bool { return ($this->status & self::STATUS_LDAP_READY) > 0; } /** * Returns whether this domain is suspended. * * @return bool */ public function isSuspended(): bool { return ($this->status & self::STATUS_SUSPENDED) > 0; } /** * Returns whether this (external) domain has been verified * to exist in DNS. * * @return bool */ public function isVerified(): bool { return ($this->status & self::STATUS_VERIFIED) > 0; } /** * Ensure the namespace is appropriately cased. */ 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_CONFIRMED, self::STATUS_SUSPENDED, self::STATUS_DELETED, self::STATUS_LDAP_READY, self::STATUS_VERIFIED, ]; foreach ($allowed_values as $value) { if ($status & $value) { $new_status |= $value; $status ^= $value; } } if ($status > 0) { throw new \Exception("Invalid domain status: {$status}"); } $this->attributes['status'] = $new_status; } /** * Ownership verification by checking for a TXT (or CNAME) record * in the domain's DNS (that matches the verification hash). * * @return bool True if verification was successful, false otherwise * @throws \Exception Throws exception on DNS or DB errors */ public function confirm(): bool { if ($this->isConfirmed()) { return true; } $hash = $this->hash(self::HASH_TEXT); $confirmed = false; // Get DNS records and find a matching TXT entry $records = \dns_get_record($this->namespace, DNS_TXT); if ($records === false) { throw new \Exception("Failed to get DNS record for {$this->namespace}"); } foreach ($records as $record) { if ($record['txt'] === $hash) { $confirmed = true; break; } } // Get DNS records and find a matching CNAME entry // Note: some servers resolve every non-existing name // so we need to define left and right side of the CNAME record // i.e.: kolab-verify IN CNAME .domain.tld. if (!$confirmed) { $cname = $this->hash(self::HASH_CODE) . '.' . $this->namespace; $records = \dns_get_record('kolab-verify.' . $this->namespace, DNS_CNAME); if ($records === false) { throw new \Exception("Failed to get DNS record for {$this->namespace}"); } foreach ($records as $records) { if ($records['target'] === $cname) { $confirmed = true; break; } } } if ($confirmed) { $this->status |= Domain::STATUS_CONFIRMED; $this->save(); } return $confirmed; } /** * Generate a verification hash for this domain * * @param int $mod One of: HASH_CNAME, HASH_CODE (Default), HASH_TEXT * * @return string Verification hash */ public function hash($mod = null): string { $cname = 'kolab-verify'; if ($mod === self::HASH_CNAME) { return $cname; } $hash = \md5('hkccp-verify-' . $this->namespace); return $mod === self::HASH_TEXT ? "$cname=$hash" : $hash; } /** * Suspend this domain. * * @return void */ public function suspend(): void { if ($this->isSuspended()) { return; } $this->status |= Domain::STATUS_SUSPENDED; $this->save(); } /** * Unsuspend this domain. * * @return void */ public function unsuspend(): void { if (!$this->isSuspended()) { return; } $this->status ^= Domain::STATUS_SUSPENDED; $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()->first(); + $entitlement = $this->entitlement()->withTrashed()->first(); return $entitlement ? $entitlement->wallet : null; } } diff --git a/src/app/Http/Controllers/API/AuthController.php b/src/app/Http/Controllers/API/AuthController.php index 3b84e2b3..f68dca85 100644 --- a/src/app/Http/Controllers/API/AuthController.php +++ b/src/app/Http/Controllers/API/AuthController.php @@ -1,127 +1,125 @@ user(); $response = V4\UsersController::userResponse($user); + if (!empty(request()->input('refresh_token'))) { + // @phpstan-ignore-next-line + return $this->respondWithToken(Auth::guard()->refresh(), $response); + } + return response()->json($response); } /** * Helper method for other controllers with user auto-logon * functionality * * @param \App\User $user User model object */ public static function logonResponse(User $user) { // @phpstan-ignore-next-line $token = Auth::guard()->login($user); - return response()->json([ - 'status' => 'success', - 'access_token' => $token, - 'token_type' => 'bearer', - // @phpstan-ignore-next-line - 'expires_in' => Auth::guard()->factory()->getTTL() * 60, - ]); + return self::respondWithToken($token, ['status' => 'success']); } /** * Get a JWT token via given credentials. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse */ public function login(Request $request) { // TODO: Redirect to dashboard if authenticated. $v = Validator::make( $request->all(), [ 'email' => 'required|min:2', 'password' => 'required|min:4', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } $credentials = $request->only('email', 'password'); if ($token = Auth::guard()->attempt($credentials)) { $sf = new \App\Auth\SecondFactor(Auth::guard()->user()); if ($response = $sf->requestHandler($request)) { return $response; } return $this->respondWithToken($token); } return response()->json(['status' => 'error', 'message' => __('auth.failed')], 401); } /** * Log the user out (Invalidate the token) * * @return \Illuminate\Http\JsonResponse */ public function logout() { Auth::guard()->logout(); return response()->json([ 'status' => 'success', 'message' => __('auth.logoutsuccess') ]); } /** * Refresh a token. * * @return \Illuminate\Http\JsonResponse */ public function refresh() { // @phpstan-ignore-next-line return $this->respondWithToken(Auth::guard()->refresh()); } /** * Get the token array structure. * - * @param string $token Respond with this token. + * @param string $token Respond with this token. + * @param array $response Additional response data * * @return \Illuminate\Http\JsonResponse */ - protected function respondWithToken($token) + protected static function respondWithToken($token, array $response = []) { - return response()->json( - [ - 'access_token' => $token, - 'token_type' => 'bearer', - // @phpstan-ignore-next-line - 'expires_in' => Auth::guard()->factory()->getTTL() * 60 - ] - ); + $response['access_token'] = $token; + $response['token_type'] = 'bearer'; + // @phpstan-ignore-next-line + $response['expires_in'] = Auth::guard()->factory()->getTTL() * 60; + + return response()->json($response); } } diff --git a/src/app/Http/Controllers/API/V4/Admin/DomainsController.php b/src/app/Http/Controllers/API/V4/Admin/DomainsController.php index 3946ab6f..e2d3b4bc 100644 --- a/src/app/Http/Controllers/API/V4/Admin/DomainsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/DomainsController.php @@ -1,55 +1,104 @@ input('search')); $owner = trim(request()->input('owner')); $result = collect([]); if ($owner) { if ($owner = User::find($owner)) { foreach ($owner->wallets as $wallet) { $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get(); foreach ($entitlements as $entitlement) { $domain = $entitlement->entitleable; $result->push($domain); } } - $result = $result->sortBy('namespace'); + $result = $result->sortBy('namespace')->values(); } } elseif (!empty($search)) { if ($domain = Domain::where('namespace', $search)->first()) { $result->push($domain); } } // Process the result $result = $result->map(function ($domain) { $data = $domain->toArray(); $data = array_merge($data, self::domainStatuses($domain)); return $data; }); $result = [ 'list' => $result, 'count' => count($result), 'message' => \trans('app.search-foundxdomains', ['x' => count($result)]), ]; return response()->json($result); } + + /** + * Suspend the domain + * + * @param \Illuminate\Http\Request $request The API request. + * @params string $id Domain identifier + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function suspend(Request $request, $id) + { + $domain = Domain::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. + * @params string $id Domain identifier + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function unsuspend(Request $request, $id) + { + $domain = Domain::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/UsersController.php b/src/app/Http/Controllers/API/V4/Admin/UsersController.php index f96c0a6d..b95409c2 100644 --- a/src/app/Http/Controllers/API/V4/Admin/UsersController.php +++ b/src/app/Http/Controllers/API/V4/Admin/UsersController.php @@ -1,168 +1,201 @@ input('search')); $owner = trim(request()->input('owner')); $result = collect([]); if ($owner) { if ($owner = User::find($owner)) { $result = $owner->users(false)->orderBy('email')->get(); } } elseif (strpos($search, '@')) { // Search by email - $user = User::where('email', $search)->first(); - if ($user) { - $result->push($user); - } else { + $result = User::withTrashed()->where('email', $search) + ->orderBy('email')->get(); + + if ($result->isEmpty()) { // Search by an alias $user_ids = UserAlias::where('alias', $search)->get()->pluck('user_id'); - if ($user_ids->isEmpty()) { - // Search by an external email - $user_ids = UserSetting::where('key', 'external_email') - ->where('value', $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'); + + $user_ids = $user_ids->merge($ext_user_ids)->unique(); if (!$user_ids->isEmpty()) { - $result = User::whereIn('id', $user_ids)->orderBy('email')->get(); + $result = User::withTrashed()->whereIn('id', $user_ids) + ->orderBy('email')->get(); } } } elseif (is_numeric($search)) { // Search by user ID - if ($user = User::find($search)) { + if ($user = User::withTrashed()->find($search)) { $result->push($user); } } elseif (!empty($search)) { // Search by domain - if ($domain = Domain::where('namespace', $search)->first()) { + if ($domain = Domain::withTrashed()->where('namespace', $search)->first()) { if ($wallet = $domain->wallet()) { - $result->push($wallet->owner); + $result->push($wallet->owner()->withTrashed()->first()); } } } // 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. + * @params string $id User identifier + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function reset2FA(Request $request, $id) + { + $user = User::find($id); + + if (empty($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'), + ]); + } + /** * Suspend the user * * @param \Illuminate\Http\Request $request The API request. * @params string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function suspend(Request $request, $id) { $user = User::find($id); if (empty($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. * @params string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function unsuspend(Request $request, $id) { $user = User::find($id); if (empty($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. * @params string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { $user = User::find($id); if (empty($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/PaymentsController.php b/src/app/Http/Controllers/API/V4/PaymentsController.php index 2fce5021..89b5eb38 100644 --- a/src/app/Http/Controllers/API/V4/PaymentsController.php +++ b/src/app/Http/Controllers/API/V4/PaymentsController.php @@ -1,322 +1,322 @@ user(); // TODO: Wallet selection $wallet = $user->wallets->first(); $mandate = self::walletMandate($wallet); return response()->json($mandate); } /** * Create a new auto-payment mandate. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function mandateCreate(Request $request) { $current_user = Auth::guard()->user(); // TODO: Wallet selection $wallet = $current_user->wallets->first(); $rules = [ 'amount' => 'required|numeric', 'balance' => 'required|numeric|min:0', ]; // Check required fields $v = Validator::make($request->all(), $rules); // TODO: allow comma as a decimal point? if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } $amount = (int) ($request->amount * 100); // Validate the minimum value if ($amount < PaymentProvider::MIN_AMOUNT) { $min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF'; $errors = ['amount' => \trans('validation.minamount', ['amount' => $min])]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } $wallet->setSettings([ 'mandate_amount' => $request->amount, 'mandate_balance' => $request->balance, ]); $request = [ 'currency' => 'CHF', 'amount' => $amount, 'description' => \config('app.name') . ' Auto-Payment Setup', ]; $provider = PaymentProvider::factory($wallet); $result = $provider->createMandate($wallet, $request); $result['status'] = 'success'; return response()->json($result); } /** * Revoke the auto-payment mandate. * * @return \Illuminate\Http\JsonResponse The response */ public function mandateDelete() { $user = Auth::guard()->user(); // TODO: Wallet selection $wallet = $user->wallets->first(); $provider = PaymentProvider::factory($wallet); $provider->deleteMandate($wallet); $wallet->setSetting('mandate_disabled', null); return response()->json([ 'status' => 'success', 'message' => \trans('app.mandate-delete-success'), ]); } /** * Update a new auto-payment mandate. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function mandateUpdate(Request $request) { $current_user = Auth::guard()->user(); // TODO: Wallet selection $wallet = $current_user->wallets->first(); $rules = [ 'amount' => 'required|numeric', 'balance' => 'required|numeric|min:0', ]; // Check required fields $v = Validator::make($request->all(), $rules); // TODO: allow comma as a decimal point? if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } $amount = (int) ($request->amount * 100); // Validate the minimum value if ($amount < PaymentProvider::MIN_AMOUNT) { $min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF'; $errors = ['amount' => \trans('validation.minamount', ['amount' => $min])]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } // If the mandate is disabled the update will trigger // an auto-payment and the amount must cover the debt if ($wallet->getSetting('mandate_disabled')) { if ($wallet->balance < 0 && $wallet->balance + $amount < 0) { $errors = ['amount' => \trans('validation.minamountdebt')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } $wallet->setSetting('mandate_disabled', null); if ($wallet->balance < intval($request->balance * 100)) { \App\Jobs\WalletCharge::dispatch($wallet); } } $wallet->setSettings([ 'mandate_amount' => $request->amount, 'mandate_balance' => $request->balance, ]); $result = self::walletMandate($wallet); $result['status'] = 'success'; $result['message'] = \trans('app.mandate-update-success'); return response()->json($result); } /** * Create a new payment. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { $current_user = Auth::guard()->user(); // TODO: Wallet selection $wallet = $current_user->wallets->first(); $rules = [ 'amount' => 'required|numeric', ]; // Check required fields $v = Validator::make($request->all(), $rules); // TODO: allow comma as a decimal point? if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } $amount = (int) ($request->amount * 100); // Validate the minimum value if ($amount < PaymentProvider::MIN_AMOUNT) { $min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF'; $errors = ['amount' => \trans('validation.minamount', ['amount' => $min])]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } $request = [ 'type' => PaymentProvider::TYPE_ONEOFF, 'currency' => 'CHF', 'amount' => $amount, 'description' => \config('app.name') . ' Payment', ]; $provider = PaymentProvider::factory($wallet); $result = $provider->payment($wallet, $request); $result['status'] = 'success'; return response()->json($result); } /** * Update payment status (and balance). * * @param string $provider Provider name * * @return \Illuminate\Http\Response The response */ public function webhook($provider) { $code = 200; if ($provider = PaymentProvider::factory($provider)) { $code = $provider->webhook(); } return response($code < 400 ? 'Success' : 'Server error', $code); } /** * Top up a wallet with a "recurring" payment. * * @param \App\Wallet $wallet The wallet to charge * * @return bool True if the payment has been initialized */ public static function topUpWallet(Wallet $wallet): bool { if ((bool) $wallet->getSetting('mandate_disabled')) { return false; } $min_balance = (int) (floatval($wallet->getSetting('mandate_balance')) * 100); $amount = (int) (floatval($wallet->getSetting('mandate_amount')) * 100); // The wallet balance is greater than the auto-payment threshold if ($wallet->balance >= $min_balance) { // Do nothing return false; } + $provider = PaymentProvider::factory($wallet); + $mandate = (array) $provider->getMandate($wallet); + + if (empty($mandate['isValid'])) { + return false; + } + // The defined top-up amount is not enough // Disable auto-payment and notify the user if ($wallet->balance + $amount < 0) { // Disable (not remove) the mandate $wallet->setSetting('mandate_disabled', 1); \App\Jobs\PaymentMandateDisabledEmail::dispatch($wallet); return false; } - $provider = PaymentProvider::factory($wallet); - $mandate = (array) $provider->getMandate($wallet); - - if (empty($mandate['isValid'])) { - return false; - } - $request = [ 'type' => PaymentProvider::TYPE_RECURRING, 'currency' => 'CHF', 'amount' => $amount, 'description' => \config('app.name') . ' Recurring Payment', ]; $result = $provider->payment($wallet, $request); return !empty($result); } /** * Returns auto-payment mandate info for the specified wallet * * @param \App\Wallet $wallet A wallet object * * @return array A mandate metadata */ public static function walletMandate(Wallet $wallet): array { $provider = PaymentProvider::factory($wallet); // Get the Mandate info $mandate = (array) $provider->getMandate($wallet); $mandate['amount'] = (int) (PaymentProvider::MIN_AMOUNT / 100); $mandate['balance'] = 0; $mandate['isDisabled'] = !empty($mandate['id']) && $wallet->getSetting('mandate_disabled'); foreach (['amount', 'balance'] as $key) { if (($value = $wallet->getSetting("mandate_{$key}")) !== null) { $mandate[$key] = $value; } } return $mandate; } } diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php index db065ca6..8af97fb3 100644 --- a/src/app/Http/Controllers/API/V4/UsersController.php +++ b/src/app/Http/Controllers/API/V4/UsersController.php @@ -1,671 +1,679 @@ errorResponse(404); } // User can't remove himself until he's the controller if (!$this->guard()->user()->canDelete($user)) { return $this->errorResponse(403); } $user->delete(); return response()->json([ 'status' => 'success', 'message' => __('app.user-delete-success'), ]); } /** * Listing of users. * * The user-entitlements billed to the current user wallet(s) * * @return \Illuminate\Http\JsonResponse */ public function index() { $user = $this->guard()->user(); $result = $user->users()->orderBy('email')->get()->map(function ($user) { $data = $user->toArray(); $data = array_merge($data, self::userStatuses($user)); return $data; }); return response()->json($result); } /** * Display information on the user account specified by $id. * * @param int $id The account to show information for. * * @return \Illuminate\Http\JsonResponse */ public function show($id) { $user = User::find($id); if (empty($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($user)) { return $this->errorResponse(403); } $response = $this->userResponse($user); // Simplified Entitlement/SKU information, // TODO: I agree this format may need to be extended in future $response['skus'] = []; foreach ($user->entitlements as $ent) { $sku = $ent->sku; $response['skus'][$sku->id] = [ // 'cost' => $ent->cost, 'count' => isset($response['skus'][$sku->id]) ? $response['skus'][$sku->id]['count'] + 1 : 1, ]; } return response()->json($response); } /** * Fetch user status (and reload setup process) * * @param int $id User identifier * * @return \Illuminate\Http\JsonResponse */ public function status($id) { $user = User::find($id); if (empty($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($user)) { return $this->errorResponse(403); } $response = self::statusInfo($user); if (!empty(request()->input('refresh'))) { $updated = false; $last_step = 'none'; foreach ($response['process'] as $idx => $step) { $last_step = $step['label']; if (!$step['state']) { if (!$this->execProcessStep($user, $step['label'])) { break; } $updated = true; } } if ($updated) { $response = self::statusInfo($user); } $success = $response['isReady']; $suffix = $success ? 'success' : 'error-' . $last_step; $response['status'] = $success ? 'success' : 'error'; $response['message'] = \trans('app.process-' . $suffix); } $response = array_merge($response, self::userStatuses($user)); return response()->json($response); } /** * User status (extended) information * * @param \App\User $user User object * * @return array Status information */ public static function statusInfo(User $user): array { $process = []; $steps = [ 'user-new' => true, 'user-ldap-ready' => $user->isLdapReady(), 'user-imap-ready' => $user->isImapReady(), ]; // Create a process check list foreach ($steps as $step_name => $state) { $step = [ 'label' => $step_name, 'title' => \trans("app.process-{$step_name}"), 'state' => $state, ]; $process[] = $step; } list ($local, $domain) = explode('@', $user->email); $domain = Domain::where('namespace', $domain)->first(); // If that is not a public domain, add domain specific steps if ($domain && !$domain->isPublic()) { $domain_status = DomainsController::statusInfo($domain); $process = array_merge($process, $domain_status['process']); } $all = count($process); $checked = count(array_filter($process, function ($v) { return $v['state']; })); $state = $all === $checked ? 'done' : 'running'; // After 180 seconds assume the process is in failed state, // this should unlock the Refresh button in the UI if ($all !== $checked && $user->created_at->diffInSeconds(Carbon::now()) > 180) { $state = 'failed'; } + // Check if the user is a controller of his wallet + $isController = $user->canDelete($user); + $hasCustomDomain = $user->wallet()->entitlements() + ->where('entitleable_type', Domain::class) + ->count() > 0; + return [ + // TODO: This will change when we enable all users to create domains + 'enableDomains' => $isController && $hasCustomDomain, + 'enableUsers' => $isController, + 'enableWallets' => $isController, 'process' => $process, 'processState' => $state, 'isReady' => $all === $checked, ]; } /** * Create a new user record. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { $current_user = $this->guard()->user(); $owner = $current_user->wallet()->owner; if ($owner->id != $current_user->id) { return $this->errorResponse(403); } if ($error_response = $this->validateUserRequest($request, null, $settings)) { return $error_response; } if (empty($request->package) || !($package = \App\Package::find($request->package))) { $errors = ['package' => \trans('validation.packagerequired')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } if ($package->isDomain()) { $errors = ['package' => \trans('validation.packageinvalid')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } DB::beginTransaction(); // Create user record $user = User::create([ 'email' => $request->email, 'password' => $request->password, ]); $owner->assignPackage($package, $user); if (!empty($settings)) { $user->setSettings($settings); } if (!empty($request->aliases)) { $user->setAliases($request->aliases); } DB::commit(); return response()->json([ 'status' => 'success', 'message' => __('app.user-create-success'), ]); } /** * Update user data. * * @param \Illuminate\Http\Request $request The API request. * @params string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { $user = User::find($id); if (empty($user)) { return $this->errorResponse(404); } $current_user = $this->guard()->user(); // TODO: Decide what attributes a user can change on his own profile if (!$current_user->canUpdate($user)) { return $this->errorResponse(403); } if ($error_response = $this->validateUserRequest($request, $user, $settings)) { return $error_response; } // Entitlements, only controller can do that if ($request->skus !== null && !$current_user->canDelete($user)) { return $this->errorResponse(422, "You have no permission to change entitlements"); } DB::beginTransaction(); $this->updateEntitlements($user, $request->skus); if (!empty($settings)) { $user->setSettings($settings); } if (!empty($request->password)) { $user->password = $request->password; $user->save(); } if (isset($request->aliases)) { $user->setAliases($request->aliases); } // TODO: Make sure that UserUpdate job is created in case of entitlements update // and no password change. So, for example quota change is applied to LDAP // TODO: Review use of $user->save() in the above context DB::commit(); return response()->json([ 'status' => 'success', 'message' => __('app.user-update-success'), ]); } /** * Get the guard to be used during authentication. * * @return \Illuminate\Contracts\Auth\Guard */ public function guard() { return Auth::guard(); } /** * Update user entitlements. * * @param \App\User $user The user * @param array $rSkus List of SKU IDs requested for the user in the form [id=>qty] */ protected function updateEntitlements(User $user, $rSkus) { if (!is_array($rSkus)) { return; } // list of skus, [id=>obj] $skus = Sku::all()->mapWithKeys( function ($sku) { return [$sku->id => $sku]; } ); // existing entitlement's SKUs $eSkus = []; $user->entitlements()->groupBy('sku_id') ->selectRaw('count(*) as total, sku_id')->each( function ($e) use (&$eSkus) { $eSkus[$e->sku_id] = $e->total; } ); foreach ($skus as $skuID => $sku) { $e = array_key_exists($skuID, $eSkus) ? $eSkus[$skuID] : 0; $r = array_key_exists($skuID, $rSkus) ? $rSkus[$skuID] : 0; if ($sku->handler_class == \App\Handlers\Mailbox::class) { if ($r != 1) { throw new \Exception("Invalid quantity of mailboxes"); } } if ($e > $r) { // remove those entitled more than existing $user->removeSku($sku, ($e - $r)); } elseif ($e < $r) { // add those requested more than entitled $user->assignSku($sku, ($r - $e)); } } } /** * Create a response data array for specified user. * * @param \App\User $user User object * * @return array Response data */ public static function userResponse(User $user): array { $response = $user->toArray(); // Settings $response['settings'] = []; foreach ($user->settings()->whereIn('key', self::USER_SETTINGS)->get() as $item) { $response['settings'][$item->key] = $item->value; } // Aliases $response['aliases'] = []; foreach ($user->aliases as $item) { $response['aliases'][] = $item->alias; } // Status info $response['statusInfo'] = self::statusInfo($user); $response = array_merge($response, self::userStatuses($user)); // Add more info to the wallet object output $map_func = function ($wallet) use ($user) { $result = $wallet->toArray(); if ($wallet->discount) { $result['discount'] = $wallet->discount->discount; $result['discount_description'] = $wallet->discount->description; } if ($wallet->user_id != $user->id) { $result['user_email'] = $wallet->owner->email; } $provider = \App\Providers\PaymentProvider::factory($wallet); $result['provider'] = $provider->name(); return $result; }; // Information about wallets and accounts for access checks $response['wallets'] = $user->wallets->map($map_func)->toArray(); $response['accounts'] = $user->accounts->map($map_func)->toArray(); $response['wallet'] = $map_func($user->wallet()); return $response; } /** * Prepare user statuses for the UI * * @param \App\User $user User object * * @return array Statuses array */ protected static function userStatuses(User $user): array { return [ 'isImapReady' => $user->isImapReady(), 'isLdapReady' => $user->isLdapReady(), 'isSuspended' => $user->isSuspended(), 'isActive' => $user->isActive(), 'isDeleted' => $user->isDeleted() || $user->trashed(), ]; } /** * Validate user input * * @param \Illuminate\Http\Request $request The API request. * @param \App\User|null $user User identifier * @param array $settings User settings (from the request) * * @return \Illuminate\Http\JsonResponse|null The error response on error */ protected function validateUserRequest(Request $request, $user, &$settings = []) { $rules = [ 'external_email' => 'nullable|email', 'phone' => 'string|nullable|max:64|regex:/^[0-9+() -]+$/', 'first_name' => 'string|nullable|max:128', 'last_name' => 'string|nullable|max:128', 'organization' => 'string|nullable|max:512', 'billing_address' => 'string|nullable|max:1024', 'country' => 'string|nullable|alpha|size:2', 'currency' => 'string|nullable|alpha|size:3', 'aliases' => 'array|nullable', ]; if (empty($user) || !empty($request->password) || !empty($request->password_confirmation)) { $rules['password'] = 'required|min:4|max:2048|confirmed'; } $errors = []; // Validate input $v = Validator::make($request->all(), $rules); if ($v->fails()) { $errors = $v->errors()->toArray(); } $controller = $user ? $user->wallet()->owner : $this->guard()->user(); // For new user validate email address if (empty($user)) { $email = $request->email; if (empty($email)) { $errors['email'] = \trans('validation.required', ['attribute' => 'email']); } elseif ($error = self::validateEmail($email, $controller, false)) { $errors['email'] = $error; } } // Validate aliases input if (isset($request->aliases)) { $aliases = []; $existing_aliases = $user ? $user->aliases()->get()->pluck('alias')->toArray() : []; foreach ($request->aliases as $idx => $alias) { if (is_string($alias) && !empty($alias)) { // Alias cannot be the same as the email address (new user) if (!empty($email) && Str::lower($alias) == Str::lower($email)) { continue; } // validate new aliases if ( !in_array($alias, $existing_aliases) && ($error = self::validateEmail($alias, $controller, true)) ) { if (!isset($errors['aliases'])) { $errors['aliases'] = []; } $errors['aliases'][$idx] = $error; continue; } $aliases[] = $alias; } } $request->aliases = $aliases; } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } // Update user settings $settings = $request->only(array_keys($rules)); unset($settings['password'], $settings['aliases'], $settings['email']); return null; } /** * Execute (synchronously) specified step in a user setup process. * * @param \App\User $user User object * @param string $step Step identifier (as in self::statusInfo()) * * @return bool True if the execution succeeded, False otherwise */ public static function execProcessStep(User $user, string $step): bool { try { if (strpos($step, 'domain-') === 0) { list ($local, $domain) = explode('@', $user->email); $domain = Domain::where('namespace', $domain)->first(); return DomainsController::execProcessStep($domain, $step); } switch ($step) { case 'user-ldap-ready': // User not in LDAP, create it $job = new \App\Jobs\UserCreate($user); $job->handle(); return $user->isLdapReady(); case 'user-imap-ready': // User not in IMAP? Verify again $job = new \App\Jobs\UserVerify($user); $job->handle(); return $user->isImapReady(); } } catch (\Exception $e) { \Log::error($e); } return false; } /** * Email address (login or alias) validation * * @param string $email Email address * @param \App\User $user The account owner * @param bool $is_alias The email is an alias * * @return string Error message on validation error */ public static function validateEmail( string $email, \App\User $user, bool $is_alias = false ): ?string { $attribute = $is_alias ? 'alias' : 'email'; if (strpos($email, '@') === false) { return \trans('validation.entryinvalid', ['attribute' => $attribute]); } list($login, $domain) = explode('@', $email); + if (strlen($login) === 0 || strlen($domain) === 0) { + return \trans('validation.entryinvalid', ['attribute' => $attribute]); + } + // Check if domain exists $domain = Domain::where('namespace', Str::lower($domain))->first(); if (empty($domain)) { return \trans('validation.domaininvalid'); } // Validate login part alone $v = Validator::make( [$attribute => $login], [$attribute => ['required', new UserEmailLocal(!$domain->isPublic())]] ); if ($v->fails()) { return $v->errors()->toArray()[$attribute][0]; } // Check if it is one of domains available to the user - // TODO: We should have a helper that returns "flat" array with domain names - // I guess we could use pluck() somehow - $domains = array_map( - function ($domain) { - return $domain->namespace; - }, - $user->domains() - ); + $domains = \collect($user->domains())->pluck('namespace')->all(); if (!in_array($domain->namespace, $domains)) { return \trans('validation.entryexists', ['attribute' => 'domain']); } // Check if a user/alias with specified address already exists // Allow assigning the same alias to a user in the same group account, // but only for non-public domains - if ($exists = User::emailExists($email, true, $alias_exists)) { + // Allow an alias in a custom domain to an address that was a user before + if ($exists = User::emailExists($email, true, $alias_exists, $is_alias && !$domain->isPublic())) { if ( !$is_alias || !$alias_exists || $domain->isPublic() || $exists->wallet()->user_id != $user->id ) { return \trans('validation.entryexists', ['attribute' => $attribute]); } } return null; } } diff --git a/src/app/Http/Controllers/API/V4/WalletsController.php b/src/app/Http/Controllers/API/V4/WalletsController.php index 3a208586..fb53b2c7 100644 --- a/src/app/Http/Controllers/API/V4/WalletsController.php +++ b/src/app/Http/Controllers/API/V4/WalletsController.php @@ -1,318 +1,322 @@ errorResponse(404); } /** * Show the form for creating a new resource. * * @return \Illuminate\Http\JsonResponse */ public function create() { 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); } /** * Return data of the specified wallet. * * @param string $id A wallet identifier * * @return \Illuminate\Http\JsonResponse The response */ public function show($id) { $wallet = Wallet::find($id); if (empty($wallet)) { return $this->errorResponse(404); } // Only owner (or admin) has access to the wallet if (!Auth::guard()->user()->canRead($wallet)) { return $this->errorResponse(403); } $result = $wallet->toArray(); $provider = \App\Providers\PaymentProvider::factory($wallet); $result['provider'] = $provider->name(); $result['notice'] = $this->getWalletNotice($wallet); return response()->json($result); } /** * Show the form for editing the specified resource. * * @param int $id * * @return \Illuminate\Http\JsonResponse */ public function edit($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); } /** * Remove the specified resource from storage. * * @param int $id * * @return \Illuminate\Http\JsonResponse */ public function destroy($id) { return $this->errorResponse(404); } /** * Download a receipt in pdf format. * * @param string $id Wallet identifier * @param string $receipt Receipt identifier (YYYY-MM) * * @return \Illuminate\Http\Response */ public function receiptDownload($id, $receipt) { $wallet = Wallet::find($id); // Only owner (or admin) has access to the wallet if (!Auth::guard()->user()->canRead($wallet)) { abort(403); } list ($year, $month) = explode('-', $receipt); if (empty($year) || empty($month) || $year < 2000 || $month < 1 || $month > 12) { abort(404); } if ($receipt >= date('Y-m')) { abort(404); } $params = [ 'id' => sprintf('%04d-%02d', $year, $month), 'site' => \config('app.name') ]; $filename = \trans('documents.receipt-filename', $params); $receipt = new \App\Documents\Receipt($wallet, (int) $year, (int) $month); $content = $receipt->pdfOutput(); return response($content) ->withHeaders([ 'Content-Type' => 'application/pdf', 'Content-Disposition' => 'attachment; filename="' . $filename . '"', 'Content-Length' => strlen($content), ]); } /** * Fetch wallet receipts list. * * @param string $id Wallet identifier * * @return \Illuminate\Http\JsonResponse */ public function receipts($id) { $wallet = Wallet::find($id); // Only owner (or admin) has access to the wallet if (!Auth::guard()->user()->canRead($wallet)) { return $this->errorResponse(403); } $result = $wallet->payments() ->selectRaw('distinct date_format(updated_at, "%Y-%m") as ident') ->where('status', PaymentProvider::STATUS_PAID) ->where('amount', '>', 0) ->orderBy('ident', 'desc') ->get() ->whereNotIn('ident', [date('Y-m')]) // exclude current month ->pluck('ident'); return response()->json([ 'status' => 'success', 'list' => $result, 'count' => count($result), 'hasMore' => false, 'page' => 1, ]); } /** * Fetch wallet transactions. * * @param string $id Wallet identifier * * @return \Illuminate\Http\JsonResponse */ public function transactions($id) { $wallet = Wallet::find($id); // Only owner (or admin) has access to the wallet if (!Auth::guard()->user()->canRead($wallet)) { return $this->errorResponse(403); } $pageSize = 10; $page = intval(request()->input('page')) ?: 1; $hasMore = false; $isAdmin = $this instanceof Admin\WalletsController; if ($transaction = request()->input('transaction')) { // Get sub-transactions for the specified transaction ID, first // check access rights to the transaction's wallet $transaction = $wallet->transactions()->where('id', $transaction)->first(); if (!$transaction) { return $this->errorResponse(404); } $result = Transaction::where('transaction_id', $transaction->id)->get(); } else { // Get main transactions (paged) $result = $wallet->transactions() // FIXME: Do we know which (type of) transaction has sub-transactions // without the sub-query? ->selectRaw("*, (SELECT count(*) FROM transactions sub " . "WHERE sub.transaction_id = transactions.id) AS cnt") ->whereNull('transaction_id') ->latest() ->limit($pageSize + 1) ->offset($pageSize * ($page - 1)) ->get(); if (count($result) > $pageSize) { $result->pop(); $hasMore = true; } } $result = $result->map(function ($item) use ($isAdmin) { $amount = $item->amount; if (in_array($item->type, [Transaction::WALLET_PENALTY, Transaction::WALLET_DEBIT])) { $amount *= -1; } $entry = [ 'id' => $item->id, 'createdAt' => $item->created_at->format('Y-m-d H:i'), 'type' => $item->type, 'description' => $item->shortDescription(), 'amount' => $amount, 'hasDetails' => !empty($item->cnt), ]; if ($isAdmin && $item->user_email) { $entry['user'] = $item->user_email; } return $entry; }); return response()->json([ 'status' => 'success', 'list' => $result, 'count' => count($result), 'hasMore' => $hasMore, 'page' => $page, ]); } /** * Returns human readable notice about the wallet state. * * @param \App\Wallet $wallet The wallet */ protected function getWalletNotice(Wallet $wallet): ?string { + // there is no credit if ($wallet->balance < 0) { return \trans('app.wallet-notice-nocredit'); } + // the discount is 100%, no credit is needed if ($wallet->discount && $wallet->discount->discount == 100) { return null; } - if ($wallet->owner->created_at > Carbon::now()->subDays(14)) { + // the owner was created less than a month ago + if ($wallet->owner->created_at > Carbon::now()->subMonthsWithoutOverflow(1)) { + // but more than two weeks ago, notice of trial ending + if ($wallet->owner->created_at <= Carbon::now()->subWeeks(2)) { + return \trans('app.wallet-notice-trial-end'); + } + return \trans('app.wallet-notice-trial'); } if ($until = $wallet->balanceLastsUntil()) { if ($until->isToday()) { - if ($wallet->owner->created_at > Carbon::now()->subDays(30)) { - return \trans('app.wallet-notice-trial-end'); - } - return \trans('app.wallet-notice-today'); } $params = [ 'date' => $until->toDateString(), 'days' => Carbon::now()->diffForHumans($until, Carbon::DIFF_ABSOLUTE), ]; return \trans('app.wallet-notice-date', $params); } return null; } } diff --git a/src/app/Http/Middleware/TrustProxies.php b/src/app/Http/Middleware/TrustProxies.php index fa5d7652..c8fa0d98 100644 --- a/src/app/Http/Middleware/TrustProxies.php +++ b/src/app/Http/Middleware/TrustProxies.php @@ -1,23 +1,28 @@ payment = $payment; $this->controller = $controller; } /** * Execute the job. * * @return void */ public function handle() { $wallet = $this->payment->wallet; if (empty($this->controller)) { $this->controller = $wallet->owner; } - $ext_email = $this->controller->getSetting('external_email'); - $cc = []; - - if ($ext_email && $ext_email != $this->controller->email) { - $cc[] = $ext_email; + if (empty($this->controller)) { + return; } if ($this->payment->status == PaymentProvider::STATUS_PAID) { $mail = new \App\Mail\PaymentSuccess($this->payment, $this->controller); + $label = "Success"; } elseif ( $this->payment->status == PaymentProvider::STATUS_EXPIRED || $this->payment->status == PaymentProvider::STATUS_FAILED ) { $mail = new \App\Mail\PaymentFailure($this->payment, $this->controller); + $label = "Failure"; } else { return; } - Mail::to($this->controller->email)->cc($cc)->send($mail); + list($to, $cc) = \App\Mail\Helper::userEmails($this->controller); + + if (!empty($to)) { + try { + Mail::to($to)->cc($cc)->send($mail); + + $msg = sprintf( + "[Payment] %s mail sent for %s (%s)", + $label, + $wallet->id, + empty($cc) ? $to : implode(', ', array_merge([$to], $cc)) + ); + + \Log::info($msg); + } catch (\Exception $e) { + $msg = sprintf( + "[Payment] Failed to send mail for wallet %s (%s): %s", + $wallet->id, + empty($cc) ? $to : implode(', ', array_merge([$to], $cc)), + $e->getMessage() + ); + + \Log::error($msg); + throw $e; + } + } /* // Send the email to all wallet controllers too if ($wallet->owner->id == $this->controller->id) { $this->wallet->controllers->each(function ($controller) { self::dispatch($this->payment, $controller); } }); */ } } diff --git a/src/app/Jobs/PaymentMandateDisabledEmail.php b/src/app/Jobs/PaymentMandateDisabledEmail.php index 4c980b9c..341f3e33 100644 --- a/src/app/Jobs/PaymentMandateDisabledEmail.php +++ b/src/app/Jobs/PaymentMandateDisabledEmail.php @@ -1,83 +1,104 @@ wallet = $wallet; $this->controller = $controller; } /** * Execute the job. * * @return void */ public function handle() { if (empty($this->controller)) { $this->controller = $this->wallet->owner; } - $ext_email = $this->controller->getSetting('external_email'); - $cc = []; - - if ($ext_email && $ext_email != $this->controller->email) { - $cc[] = $ext_email; + if (empty($this->controller)) { + return; } $mail = new PaymentMandateDisabled($this->wallet, $this->controller); - Mail::to($this->controller->email)->cc($cc)->send($mail); + list($to, $cc) = \App\Mail\Helper::userEmails($this->controller); + + if (!empty($to)) { + try { + Mail::to($to)->cc($cc)->send($mail); + + $msg = sprintf( + "[PaymentMandateDisabled] Sent mail for %s (%s)", + $this->wallet->id, + empty($cc) ? $to : implode(', ', array_merge([$to], $cc)) + ); + + \Log::info($msg); + } catch (\Exception $e) { + $msg = sprintf( + "[PaymentMandateDisabled] Failed to send mail for wallet %s (%s): %s", + $this->wallet->id, + empty($cc) ? $to : implode(', ', array_merge([$to], $cc)), + $e->getMessage() + ); + + \Log::error($msg); + throw $e; + } + } /* // Send the email to all controllers too if ($this->controller->id == $this->wallet->owner->id) { $this->wallet->controllers->each(function ($controller) { self::dispatch($this->wallet, $controller); } }); */ } } diff --git a/src/app/Jobs/UserVerify.php b/src/app/Jobs/UserVerify.php index cb131732..8fec9add 100644 --- a/src/app/Jobs/UserVerify.php +++ b/src/app/Jobs/UserVerify.php @@ -1,75 +1,60 @@ user = $user; } /** * Execute the job. * * @return void */ public function handle() { - // Verify a mailbox sku is among the user entitlements. - $skuMailbox = \App\Sku::where('title', 'mailbox')->first(); - - if (!$skuMailbox) { - return; - } - - $mailbox = \App\Entitlement::where( - [ - 'sku_id' => $skuMailbox->id, - 'entitleable_id' => $this->user->id, - 'entitleable_type' => User::class - ] - )->first(); - - if (!$mailbox) { + if (!$this->user->hasSku('mailbox')) { return; } // The user has a mailbox if (!$this->user->isImapReady()) { if (IMAP::verifyAccount($this->user->email)) { $this->user->status |= User::STATUS_IMAP_READY; $this->user->status |= User::STATUS_ACTIVE; $this->user->save(); } } } } diff --git a/src/app/Jobs/WalletCheck.php b/src/app/Jobs/WalletCheck.php new file mode 100644 index 00000000..0eb7f41d --- /dev/null +++ b/src/app/Jobs/WalletCheck.php @@ -0,0 +1,311 @@ +wallet = $wallet; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + if ($this->wallet->balance >= 0) { + return; + } + + $now = Carbon::now(); + + // Delete the account + if (self::threshold($this->wallet, self::THRESHOLD_DELETE) < $now) { + $this->deleteAccount(); + return; + } + + // Warn about the upcomming account deletion + if (self::threshold($this->wallet, self::THRESHOLD_BEFORE_DELETE) < $now) { + $this->warnBeforeDelete(); + return; + } + + // Suspend the account + if (self::threshold($this->wallet, self::THRESHOLD_SUSPEND) < $now) { + $this->suspendAccount(); + return; + } + + // Send the second reminder + if (self::threshold($this->wallet, self::THRESHOLD_REMINDER) < $now) { + $this->secondReminder(); + return; + } + + // Send the initial reminder + if (self::threshold($this->wallet, self::THRESHOLD_INITIAL) < $now) { + $this->initialReminder(); + return; + } + } + + /** + * Send the initial reminder + */ + protected function initialReminder() + { + if ($this->wallet->getSetting('balance_warning_initial')) { + return; + } + + // TODO: Should we check if the account is already suspended? + + $label = "Notification sent for"; + + $this->sendMail(\App\Mail\NegativeBalance::class, false, $label); + + $now = \Carbon\Carbon::now()->toDateTimeString(); + $this->wallet->setSetting('balance_warning_initial', $now); + } + + /** + * Send the second reminder + */ + protected function secondReminder() + { + if ($this->wallet->getSetting('balance_warning_reminder')) { + return; + } + + // TODO: Should we check if the account is already suspended? + + $label = "Reminder sent for"; + + $this->sendMail(\App\Mail\NegativeBalanceReminder::class, false, $label); + + $now = \Carbon\Carbon::now()->toDateTimeString(); + $this->wallet->setSetting('balance_warning_reminder', $now); + } + + /** + * Suspend the account (and send the warning) + */ + protected function suspendAccount() + { + if ($this->wallet->getSetting('balance_warning_suspended')) { + return; + } + + // Sanity check, already deleted + if (!$this->wallet->owner) { + return; + } + + // Suspend the account + $this->wallet->owner->suspend(); + foreach ($this->wallet->entitlements as $entitlement) { + if ( + $entitlement->entitleable_type == \App\Domain::class + || $entitlement->entitleable_type == \App\User::class + ) { + $entitlement->entitleable->suspend(); + } + } + + $label = "Account suspended"; + + $this->sendMail(\App\Mail\NegativeBalanceSuspended::class, false, $label); + + $now = \Carbon\Carbon::now()->toDateTimeString(); + $this->wallet->setSetting('balance_warning_suspended', $now); + } + + /** + * Send the last warning before delete + */ + protected function warnBeforeDelete() + { + if ($this->wallet->getSetting('balance_warning_before_delete')) { + return; + } + + // Sanity check, already deleted + if (!$this->wallet->owner) { + return; + } + + $label = "Last warning sent for"; + + $this->sendMail(\App\Mail\NegativeBalanceBeforeDelete::class, true, $label); + + $now = \Carbon\Carbon::now()->toDateTimeString(); + $this->wallet->setSetting('balance_warning_before_delete', $now); + } + + /** + * Delete the account + */ + protected function deleteAccount() + { + // TODO: This will not work when we actually allow multiple-wallets per account + // but in this case we anyway have to change the whole thing + // and calculate summarized balance from all wallets. + // The dirty work will be done by UserObserver + if ($this->wallet->owner) { + $email = $this->wallet->owner->email; + + $this->wallet->owner->delete(); + + \Log::info( + sprintf( + "[WalletCheck] Account deleted %s (%s)", + $this->wallet->id, + $email + ) + ); + } + } + + /** + * Send the email + * + * @param string $class Mailable class name + * @param bool $with_external Use users's external email + * @param ?string $log_label Log label + */ + protected function sendMail($class, $with_external = false, $log_label = null): void + { + // TODO: Send the email to all wallet controllers? + + $mail = new $class($this->wallet, $this->wallet->owner); + + list($to, $cc) = \App\Mail\Helper::userEmails($this->wallet->owner, $with_external); + + if (!empty($to) || !empty($cc)) { + try { + Mail::to($to)->cc($cc)->send($mail); + + if ($log_label) { + $msg = sprintf( + "[WalletCheck] %s %s (%s)", + $log_label, + $this->wallet->id, + empty($cc) ? $to : implode(', ', array_merge([$to], $cc)), + ); + + \Log::info($msg); + } + } catch (\Exception $e) { + $msg = sprintf( + "[WalletCheck] Failed to send mail for %s (%s): %s", + $this->wallet->id, + empty($cc) ? $to : implode(', ', array_merge([$to], $cc)), + $e->getMessage() + ); + + \Log::error($msg); + throw $e; + } + } + } + + /** + * Get the date-time for an action threshold. Calculated using + * the date when a wallet balance turned negative. + * + * @param \App\Wallet $wallet A wallet + * @param string $type Action type (one of self::THRESHOLD_*) + * + * @return \Carbon\Carbon The threshold date-time object + */ + public static function threshold(Wallet $wallet, string $type): ?Carbon + { + $negative_since = $wallet->getSetting('balance_negative_since'); + + // Migration scenario: balance<0, but no balance_negative_since set + if (!$negative_since) { + // 2h back from now, so first run can sent the initial notification + $negative_since = Carbon::now()->subHours(2); + $wallet->setSetting('balance_negative_since', $negative_since->toDateTimeString()); + } else { + $negative_since = new Carbon($negative_since); + } + + $remind = 7; // remind after first X days + $suspend = 14; // suspend after next X days + $delete = 21; // delete after next X days + $warn = 3; // warn about delete on X days before delete + + // Acount deletion + if ($type == self::THRESHOLD_DELETE) { + return $negative_since->addDays($delete + $suspend + $remind); + } + + // Warning about the upcomming account deletion + if ($type == self::THRESHOLD_BEFORE_DELETE) { + return $negative_since->addDays($delete + $suspend + $remind - $warn); + } + + // Account suspension + if ($type == self::THRESHOLD_SUSPEND) { + return $negative_since->addDays($suspend + $remind); + } + + // Second notification + if ($type == self::THRESHOLD_REMINDER) { + return $negative_since->addDays($remind); + } + + // Initial notification + // Give it an hour so the async recurring payment has a chance to be finished + if ($type == self::THRESHOLD_INITIAL) { + return $negative_since->addHours(1); + } + + return null; + } +} diff --git a/src/app/Mail/Helper.php b/src/app/Mail/Helper.php index 756b6b8b..fd6d0c65 100644 --- a/src/app/Mail/Helper.php +++ b/src/app/Mail/Helper.php @@ -1,33 +1,61 @@ build(); // @phpstan-ignore-line $mailer = \Illuminate\Container\Container::getInstance()->make('mailer'); return $mailer->render(['text' => $mail->textView], $mail->buildViewData()); } elseif ($type != 'html') { throw new \Exception("Unsupported output format"); } // HTML output return $mail->build()->render(); // @phpstan-ignore-line } + + /** + * Return user's email addresses, separately for use in To and Cc. + * + * @param \App\User $user The user + * @param bool $external Include users's external email + * + * @return array To address as the first element, Cc address(es) as the second. + */ + public static function userEmails(\App\User $user, bool $external = false): array + { + $to = $user->email; + $cc = []; + + // If user has no mailbox entitlement we should not send + // the email to his main address, but use external address, if defined + if (!$user->hasSku('mailbox')) { + $to = $user->getSetting('external_email'); + } elseif ($external) { + $ext_email = $user->getSetting('external_email'); + + if ($ext_email && $ext_email != $to) { + $cc[] = $ext_email; + } + } + + return [$to, $cc]; + } } diff --git a/src/app/Mail/NegativeBalance.php b/src/app/Mail/NegativeBalance.php index dfac7f47..85947e73 100644 --- a/src/app/Mail/NegativeBalance.php +++ b/src/app/Mail/NegativeBalance.php @@ -1,72 +1,77 @@ account = $account; + $this->wallet = $wallet; + $this->user = $user; } /** * Build the message. * * @return $this */ public function build() { - $user = $this->account; - $subject = \trans('mail.negativebalance-subject', ['site' => \config('app.name')]); $this->view('emails.html.negative_balance') ->text('emails.plain.negative_balance') ->subject($subject) ->with([ 'site' => \config('app.name'), 'subject' => $subject, - 'username' => $user->name(true), + 'username' => $this->user->name(true), 'supportUrl' => \config('app.support_url'), 'walletUrl' => Utils::serviceUrl('/wallet'), ]); 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 { + $wallet = new Wallet(); $user = new User(); - $mail = new self($user); + $mail = new self($wallet, $user); return Helper::render($mail, $type); } } diff --git a/src/app/Mail/NegativeBalance.php b/src/app/Mail/NegativeBalanceBeforeDelete.php similarity index 51% copy from src/app/Mail/NegativeBalance.php copy to src/app/Mail/NegativeBalanceBeforeDelete.php index dfac7f47..3e826207 100644 --- a/src/app/Mail/NegativeBalance.php +++ b/src/app/Mail/NegativeBalanceBeforeDelete.php @@ -1,72 +1,81 @@ account = $account; + $this->wallet = $wallet; + $this->user = $user; } /** * Build the message. * * @return $this */ public function build() { - $user = $this->account; + $threshold = WalletCheck::threshold($this->wallet, WalletCheck::THRESHOLD_DELETE); - $subject = \trans('mail.negativebalance-subject', ['site' => \config('app.name')]); + $subject = \trans('mail.negativebalancebeforedelete-subject', ['site' => \config('app.name')]); - $this->view('emails.html.negative_balance') - ->text('emails.plain.negative_balance') + $this->view('emails.html.negative_balance_before_delete') + ->text('emails.plain.negative_balance_before_delete') ->subject($subject) ->with([ 'site' => \config('app.name'), 'subject' => $subject, - 'username' => $user->name(true), + 'username' => $this->user->name(true), 'supportUrl' => \config('app.support_url'), 'walletUrl' => Utils::serviceUrl('/wallet'), + 'date' => $threshold->toDateString(), ]); 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 { + $wallet = new Wallet(); $user = new User(); - $mail = new self($user); + $mail = new self($wallet, $user); return Helper::render($mail, $type); } } diff --git a/src/app/Mail/NegativeBalance.php b/src/app/Mail/NegativeBalanceReminder.php similarity index 52% copy from src/app/Mail/NegativeBalance.php copy to src/app/Mail/NegativeBalanceReminder.php index dfac7f47..41fad6ac 100644 --- a/src/app/Mail/NegativeBalance.php +++ b/src/app/Mail/NegativeBalanceReminder.php @@ -1,72 +1,81 @@ account = $account; + $this->wallet = $wallet; + $this->user = $user; } /** * Build the message. * * @return $this */ public function build() { - $user = $this->account; + $threshold = WalletCheck::threshold($this->wallet, WalletCheck::THRESHOLD_SUSPEND); - $subject = \trans('mail.negativebalance-subject', ['site' => \config('app.name')]); + $subject = \trans('mail.negativebalancereminder-subject', ['site' => \config('app.name')]); - $this->view('emails.html.negative_balance') - ->text('emails.plain.negative_balance') + $this->view('emails.html.negative_balance_reminder') + ->text('emails.plain.negative_balance_reminder') ->subject($subject) ->with([ 'site' => \config('app.name'), 'subject' => $subject, - 'username' => $user->name(true), + 'username' => $this->user->name(true), 'supportUrl' => \config('app.support_url'), 'walletUrl' => Utils::serviceUrl('/wallet'), + 'date' => $threshold->toDateString(), ]); 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 { + $wallet = new Wallet(); $user = new User(); - $mail = new self($user); + $mail = new self($wallet, $user); return Helper::render($mail, $type); } } diff --git a/src/app/Mail/NegativeBalance.php b/src/app/Mail/NegativeBalanceSuspended.php similarity index 51% copy from src/app/Mail/NegativeBalance.php copy to src/app/Mail/NegativeBalanceSuspended.php index dfac7f47..3f88bcd1 100644 --- a/src/app/Mail/NegativeBalance.php +++ b/src/app/Mail/NegativeBalanceSuspended.php @@ -1,72 +1,81 @@ account = $account; + $this->wallet = $wallet; + $this->user = $user; } /** * Build the message. * * @return $this */ public function build() { - $user = $this->account; + $threshold = WalletCheck::threshold($this->wallet, WalletCheck::THRESHOLD_DELETE); - $subject = \trans('mail.negativebalance-subject', ['site' => \config('app.name')]); + $subject = \trans('mail.negativebalancesuspended-subject', ['site' => \config('app.name')]); - $this->view('emails.html.negative_balance') - ->text('emails.plain.negative_balance') + $this->view('emails.html.negative_balance_suspended') + ->text('emails.plain.negative_balance_suspended') ->subject($subject) ->with([ 'site' => \config('app.name'), 'subject' => $subject, - 'username' => $user->name(true), + 'username' => $this->user->name(true), 'supportUrl' => \config('app.support_url'), 'walletUrl' => Utils::serviceUrl('/wallet'), + 'date' => $threshold->toDateString(), ]); 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 { + $wallet = new Wallet(); $user = new User(); - $mail = new self($user); + $mail = new self($wallet, $user); return Helper::render($mail, $type); } } diff --git a/src/app/Mail/PasswordReset.php b/src/app/Mail/PasswordReset.php index 4544a192..976c259e 100644 --- a/src/app/Mail/PasswordReset.php +++ b/src/app/Mail/PasswordReset.php @@ -1,81 +1,81 @@ code = $code; } /** * Build the message. * * @return $this */ public function build() { $href = Utils::serviceUrl( - sprintf('/login/reset/%s-%s', $this->code->short_code, $this->code->code) + sprintf('/password-reset/%s-%s', $this->code->short_code, $this->code->code) ); $this->view('emails.html.password_reset') ->text('emails.plain.password_reset') ->subject(__('mail.passwordreset-subject', ['site' => \config('app.name')])) ->with([ 'site' => \config('app.name'), 'code' => $this->code->code, 'short_code' => $this->code->short_code, 'link' => sprintf('%s', $href, $href), 'username' => $this->code->user->name(true) ]); 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 { $code = new VerificationCode([ 'code' => Str::random(VerificationCode::CODE_LENGTH), 'short_code' => VerificationCode::generateShortCode(), ]); $code->user = new User([ 'email' => 'test@' . \config('app.domain'), ]); $mail = new self($code); return Helper::render($mail, $type); } } diff --git a/src/app/Observers/EntitlementObserver.php b/src/app/Observers/EntitlementObserver.php index 7e388de0..f9db6619 100644 --- a/src/app/Observers/EntitlementObserver.php +++ b/src/app/Observers/EntitlementObserver.php @@ -1,146 +1,154 @@ {$entitlement->getKeyName()} = $allegedly_unique; break; } } // can't dispatch job here because it'll fail serialization // Make sure the owner is at least a controller on the wallet $wallet = \App\Wallet::find($entitlement->wallet_id); if (!$wallet || !$wallet->owner) { return false; } $sku = \App\Sku::find($entitlement->sku_id); if (!$sku) { return false; } $result = $sku->handler_class::preReq($entitlement, $wallet->owner); if (!$result) { return false; } return true; } /** * Handle the entitlement "created" event. * * @param \App\Entitlement $entitlement The entitlement. * * @return void */ public function created(Entitlement $entitlement) { $entitlement->entitleable->updated_at = Carbon::now(); $entitlement->entitleable->save(); $entitlement->createTransaction(\App\Transaction::ENTITLEMENT_CREATED); } /** * Handle the entitlement "deleted" event. * * @param \App\Entitlement $entitlement The entitlement. * * @return void */ public function deleted(Entitlement $entitlement) { // Remove all configured 2FA methods from Roundcube database if ($entitlement->sku->title == '2fa') { // FIXME: Should that be an async job? $sf = new \App\Auth\SecondFactor($entitlement->entitleable); $sf->removeFactors(); } $entitlement->entitleable->updated_at = Carbon::now(); $entitlement->entitleable->save(); $entitlement->createTransaction(\App\Transaction::ENTITLEMENT_DELETED); } public function deleting(Entitlement $entitlement) { // Start calculating the costs for the consumption of this entitlement if the // existing consumption spans >= 14 days. - // anything's free for 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(Carbon::now()); + $diffInMonths = $entitlement->updated_at->diffInMonths($now); $cost += (int) ($entitlement->cost * $discount * $diffInMonths); // this moves the hypothetical updated at forward to however many months past the original $updatedAt = $entitlement->updated_at->copy()->addMonthsWithoutOverflow($diffInMonths); // now we have the diff in days since the last "billed" period end. // This may be an entitlement paid up until February 28th, 2020, with today being March - // 12th 2020. Calculating the costs for the entitlement is based on the daily price for the - // past month -- i.e. $price/29 in the case at hand -- times the number of (full) days in - // between the period end and now. - // - // a) The number of days left in the past month, 1 - // b) The cost divided by the number of days in the past month, for example, 555/29, - // c) a) + Todays day-of-month, 12, so 13. - // + // 12th 2020. Calculating the costs for the entitlement is based on the daily price - $diffInDays = $updatedAt->diffInDays(Carbon::now()); + // 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 - $dayOfThisMonth = Carbon::now()->day; + $diffInDays = $updatedAt->diffInDays($now); - // days in the month for the month prior to this one. - // the price per day is based on the number of days left in the last month - $daysInLastMonth = \App\Utils::daysInLastMonth(); + if ($now->day >= $diffInDays) { + $daysInMonth = $now->daysInMonth; + } else { + $daysInMonth = \App\Utils::daysInLastMonth(); + } - $pricePerDay = (float)$entitlement->cost / $daysInLastMonth; + $pricePerDay = $entitlement->cost / $daysInMonth; - $cost += (int) (round($pricePerDay * $diffInDays, 0)); + $cost += (int) (round($pricePerDay * $discount * $diffInDays, 0)); if ($cost == 0) { return; } $entitlement->wallet->debit($cost); } } diff --git a/src/app/Observers/UserAliasObserver.php b/src/app/Observers/UserAliasObserver.php index d246b1dc..5b4bbead 100644 --- a/src/app/Observers/UserAliasObserver.php +++ b/src/app/Observers/UserAliasObserver.php @@ -1,89 +1,94 @@ alias = \strtolower($alias->alias); - if ($exists = User::emailExists($alias->alias, true, $alias_exists)) { + list($login, $domain) = explode('@', $alias->alias); + + $domain = Domain::where('namespace', $domain)->first(); + + if (!$domain) { + \Log::error("Failed creating alias {$alias->alias}. Domain does not exist."); + return false; + } +/* + if ($exists = User::emailExists($alias->alias, true, $alias_exists, !$domain->isPublic())) { if (!$alias_exists) { \Log::error("Failed creating alias {$alias->alias}. Email address exists."); return false; } - list($login, $domain) = explode('@', $alias->alias); - - $domain = Domain::where('namespace', $domain)->first(); - - if (!$domain || $domain->isPublic()) { + if ($domain->isPublic()) { \Log::error("Failed creating alias {$alias->alias}. Alias exists in public domain."); return false; } if ($exists->wallet()->user_id != $alias->user->wallet()->user_id) { \Log::error("Failed creating alias {$alias->alias}. Alias exists in another account."); return false; } } - +*/ return true; } /** * Handle the user alias "created" event. * * @param \App\UserAlias $alias User email alias * * @return void */ public function created(UserAlias $alias) { if ($alias->user) { \App\Jobs\UserUpdate::dispatch($alias->user); } } /** * Handle the user setting "updated" event. * * @param \App\UserAlias $alias User email alias * * @return void */ public function updated(UserAlias $alias) { if ($alias->user) { \App\Jobs\UserUpdate::dispatch($alias->user); } } /** * Handle the user setting "deleted" event. * * @param \App\UserAlias $alias User email alias * * @return void */ public function deleted(UserAlias $alias) { if ($alias->user) { \App\Jobs\UserUpdate::dispatch($alias->user); } } } diff --git a/src/app/Observers/WalletObserver.php b/src/app/Observers/WalletObserver.php index 02216e8e..839724cc 100644 --- a/src/app/Observers/WalletObserver.php +++ b/src/app/Observers/WalletObserver.php @@ -1,72 +1,112 @@ {$wallet->getKeyName()} = $allegedly_unique; break; } } } /** * Handle the wallet "deleting" event. * * Ensures that a wallet with a non-zero balance can not be deleted. * * Ensures that the wallet being deleted is not the last wallet for the user. * * Ensures that no entitlements are being billed to the wallet currently. * * @param Wallet $wallet The wallet being deleted. * * @return bool */ public function deleting(Wallet $wallet): bool { // can't delete a wallet that has any balance on it (positive and negative). if ($wallet->balance != 0.00) { return false; } if (!$wallet->owner) { throw new \Exception("Wallet: " . var_export($wallet, true)); } // can't remove the last wallet for the owner. if ($wallet->owner->wallets()->count() <= 1) { return false; } // can't remove a wallet that has billable entitlements attached. if ($wallet->entitlements()->count() > 0) { return false; } /* // can't remove a wallet that has payments attached. if ($wallet->payments()->count() > 0) { return false; } */ return true; } + + /** + * Handle the wallet "updated" event. + * + * @param \App\Wallet $wallet The wallet. + * + * @return void + */ + public function updated(Wallet $wallet) + { + $negative_since = $wallet->getSetting('balance_negative_since'); + + if ($wallet->balance < 0) { + if (!$negative_since) { + $now = \Carbon\Carbon::now()->toDateTimeString(); + $wallet->setSetting('balance_negative_since', $now); + } + } elseif ($negative_since) { + $wallet->setSettings([ + 'balance_negative_since' => null, + 'balance_warning_initial' => null, + 'balance_warning_reminder' => null, + 'balance_warning_suspended' => null, + 'balance_warning_before_delete' => null, + ]); + + // Unsuspend the account/domains/users + if ($wallet->owner) { + $wallet->owner->unsuspend(); + } + foreach ($wallet->entitlements as $entitlement) { + if ( + $entitlement->entitleable_type == \App\Domain::class + || $entitlement->entitleable_type == \App\User::class + ) { + $entitlement->entitleable->unsuspend(); + } + } + } + } } diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php index b7d91da6..2fdda4ad 100644 --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -1,49 +1,52 @@ sql, implode(', ', $query->bindings))); }); } } } diff --git a/src/app/Providers/Payment/Mollie.php b/src/app/Providers/Payment/Mollie.php index 4c49cecb..c6332eb9 100644 --- a/src/app/Providers/Payment/Mollie.php +++ b/src/app/Providers/Payment/Mollie.php @@ -1,456 +1,461 @@ tag */ public function customerLink(Wallet $wallet): ?string { - $customer_id = self::mollieCustomerId($wallet); + $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 * - currency: The operation currency * - description: Operation desc. * * @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); + $customer_id = self::mollieCustomerId($wallet, true); $request = [ 'amount' => [ 'currency' => $payment['currency'], 'value' => '0.00', ], 'customerId' => $customer_id, 'sequenceType' => 'first', 'description' => $payment['description'], 'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'), - 'redirectUrl' => \url('/wallet'), + 'redirectUrl' => Utils::serviceUrl('/wallet'), 'locale' => 'en_US', // 'method' => 'creditcard', ]; // Create the payment in Mollie $response = mollie()->payments()->create($request); if ($response->mandateId) { $wallet->setSetting('mollie_mandate_id', $response->mandateId); } 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. * - 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') ]; 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. * * @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); + $customer_id = self::mollieCustomerId($wallet, true); // Note: Required fields: description, amount/currency, amount/value $request = [ 'amount' => [ 'currency' => $payment['currency'], // a number with two decimals is required 'value' => sprintf('%.2f', $payment['amount'] / 100), ], 'customerId' => $customer_id, 'sequenceType' => $payment['type'], 'description' => $payment['description'], 'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'), 'locale' => 'en_US', // 'method' => 'creditcard', - 'redirectUrl' => \url('/wallet') // required for non-recurring payments + 'redirectUrl' => Utils::serviceUrl('/wallet') // 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(), ]; } /** * 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); + $customer_id = self::mollieCustomerId($wallet, true); // Note: Required fields: description, amount/currency, amount/value $request = [ 'amount' => [ 'currency' => $payment['currency'], // a number with two decimals is required 'value' => sprintf('%.2f', $payment['amount'] / 100), ], 'customerId' => $customer_id, 'sequenceType' => $payment['type'], 'description' => $payment['description'], 'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'), 'locale' => 'en_US', // 'method' => 'creditcard', '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 $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; } if ($mollie_payment->isPaid()) { if (!$mollie_payment->hasRefunds() && !$mollie_payment->hasChargebacks()) { // The payment is paid and isn't refunded or charged back. // Update the balance, if it wasn't already if ($payment->status != self::STATUS_PAID && $payment->amount > 0) { $credit = true; $notify = $payment->type == self::TYPE_RECURRING; } } elseif ($mollie_payment->hasRefunds()) { // The payment has been (partially) refunded. // The status of the payment is still "paid" // TODO: Update balance } elseif ($mollie_payment->hasChargebacks()) { // The payment has been (partially) charged back. // The status of the payment is still "paid" // TODO: Update balance } } 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); } 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 + * @return ?string Mollie customer identifier */ - protected static function mollieCustomerId(Wallet $wallet): string + 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)) { + 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) { $mandate = mollie()->mandates()->getForId($customer_id, $mandate_id); if ($mandate) {// && ($mandate->isValid() || $mandate->isPending())) { return $mandate; } } // Get all mandates from Mollie and find the active one /* foreach ($customer->mandates() as $mandate) { if ($mandate->isValid() || $mandate->isPending()) { $wallet->setSetting('mollie_mandate_id', $mandate->id); return $mandate; } } */ } /** * 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 '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 'directdebit': return sprintf('Direct Debit (%s)', $details->customerAccount); case 'paypal': return sprintf('PayPal (%s)', $details->consumerAccount); } return $default; } } diff --git a/src/app/Providers/Payment/Stripe.php b/src/app/Providers/Payment/Stripe.php index be0c614e..00b0fa75 100644 --- a/src/app/Providers/Payment/Stripe.php +++ b/src/app/Providers/Payment/Stripe.php @@ -1,460 +1,465 @@ tag */ public function customerLink(Wallet $wallet): ?string { - $customer_id = self::stripeCustomerId($wallet); + $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 * - 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); + $customer_id = self::stripeCustomerId($wallet, true); $request = [ 'customer' => $customer_id, - 'cancel_url' => \url('/wallet'), // required - 'success_url' => \url('/wallet'), // required + 'cancel_url' => Utils::serviceUrl('/wallet'), // required + 'success_url' => Utils::serviceUrl('/wallet'), // required 'payment_method_types' => ['card'], // required 'locale' => 'en', 'mode' => 'setup', ]; $session = StripeAPI\Checkout\Session::create($request); $payment = [ 'id' => $session->setup_intent, '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); + $customer_id = self::stripeCustomerId($wallet, true); $request = [ 'customer' => $customer_id, - 'cancel_url' => \url('/wallet'), // required - 'success_url' => \url('/wallet'), // required + 'cancel_url' => Utils::serviceUrl('/wallet'), // required + 'success_url' => Utils::serviceUrl('/wallet'), // required 'payment_method_types' => ['card'], // required 'locale' => 'en', 'line_items' => [ [ 'name' => $payment['description'], 'amount' => $payment['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; } $request = [ 'amount' => $payment['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) { // 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); } $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 Stripe customer identifier + * @return string|null Stripe customer identifier */ - protected static function stripeCustomerId(Wallet $wallet): string + 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)) { + 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; } } diff --git a/src/app/Providers/RouteServiceProvider.php b/src/app/Providers/RouteServiceProvider.php index fb260788..14d17ef3 100644 --- a/src/app/Providers/RouteServiceProvider.php +++ b/src/app/Providers/RouteServiceProvider.php @@ -1,73 +1,74 @@ mapApiRoutes(); $this->mapWebRoutes(); // } /** * Define the "web" routes for the application. * * These routes all receive session state, CSRF protection, etc. * * @return void */ protected function mapWebRoutes() { Route::middleware('web') ->namespace($this->namespace) ->group(base_path('routes/web.php')); } /** * Define the "api" routes for the application. * * These routes are typically stateless. * * @return void */ protected function mapApiRoutes() { - Route::prefix('api') - ->middleware('api') + // Note: We removed the prefix from here, to have more control + // over it in routes/api.php + Route::middleware('api') ->namespace($this->namespace) ->group(base_path('routes/api.php')); } } diff --git a/src/app/User.php b/src/app/User.php index 9cdd073d..97cf736b 100644 --- a/src/app/User.php +++ b/src/app/User.php @@ -1,690 +1,720 @@ belongsToMany( 'App\Wallet', // The foreign object definition 'user_accounts', // The table name 'user_id', // The local foreign key 'wallet_id' // The remote foreign key ); } /** * Email aliases of this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function aliases() { return $this->hasMany('App\UserAlias', 'user_id'); } /** * Assign a package to a user. The user should not have any existing entitlements. * * @param \App\Package $package The package to assign. * @param \App\User|null $user Assign the package to another user. * * @return \App\User */ public function assignPackage($package, $user = null) { if (!$user) { $user = $this; } $wallet_id = $this->wallets()->first()->id; foreach ($package->skus as $sku) { for ($i = $sku->pivot->qty; $i > 0; $i--) { \App\Entitlement::create( [ 'wallet_id' => $wallet_id, 'sku_id' => $sku->id, 'cost' => $sku->pivot->cost(), 'entitleable_id' => $user->id, 'entitleable_type' => User::class ] ); } } return $user; } /** * Assign a package plan to a user. * * @param \App\Plan $plan The plan to assign * @param \App\Domain $domain Optional domain object * * @return \App\User Self */ public function assignPlan($plan, $domain = null): User { $this->setSetting('plan_id', $plan->id); foreach ($plan->packages as $package) { if ($package->isDomain()) { $domain->assignPackage($package, $this); } else { $this->assignPackage($package); } } return $this; } /** * Assign a Sku to a user. * * @param \App\Sku $sku The sku to assign. * @param int $count Count of entitlements to add * * @return \App\User Self * @throws \Exception */ public function assignSku(Sku $sku, int $count = 1): User { // TODO: I guess wallet could be parametrized in future $wallet = $this->wallet(); $exists = $this->entitlements()->where('sku_id', $sku->id)->count(); // TODO: Sanity check, this probably should be in preReq() on handlers // or in EntitlementObserver if ($sku->handler_class::entitleableClass() != User::class) { throw new \Exception("Cannot assign non-user SKU ({$sku->title}) to a user"); } while ($count > 0) { \App\Entitlement::create([ 'wallet_id' => $wallet->id, 'sku_id' => $sku->id, 'cost' => $exists >= $sku->units_free ? $sku->cost : 0, 'entitleable_id' => $this->id, 'entitleable_type' => User::class ]); $exists++; $count--; } return $this; } /** * Check if current user can delete another object. * * @param \App\User|\App\Domain $object A user|domain object * * @return bool True if he can, False otherwise */ public function canDelete($object): bool { if (!method_exists($object, 'wallet')) { return false; } $wallet = $object->wallet(); // TODO: For now controller can delete/update the account owner, // this may change in future, controllers are not 0-regression feature return $this->wallets->contains($wallet) || $this->accounts->contains($wallet); } /** * Check if current user can read data of another object. * * @param \App\User|\App\Domain|\App\Wallet $object A user|domain|wallet object * * @return bool True if he can, False otherwise */ public function canRead($object): bool { if ($this->role == "admin") { return true; } if ($object instanceof User && $this->id == $object->id) { return true; } if ($object instanceof Wallet) { return $object->user_id == $this->id || $object->controllers->contains($this); } if (!method_exists($object, 'wallet')) { return false; } $wallet = $object->wallet(); return $this->wallets->contains($wallet) || $this->accounts->contains($wallet); } /** * Check if current user can update data of another object. * * @param \App\User|\App\Domain $object A user|domain object * * @return bool True if he can, False otherwise */ public function canUpdate($object): bool { if (!method_exists($object, 'wallet')) { return false; } if ($object instanceof User && $this->id == $object->id) { return true; } return $this->canDelete($object); } /** * List the domains to which this user is entitled. * * @return Domain[] */ public function domains() { $dbdomains = Domain::whereRaw( sprintf( '(type & %s) AND (status & %s)', Domain::TYPE_PUBLIC, Domain::STATUS_ACTIVE ) )->get(); $domains = []; foreach ($dbdomains as $dbdomain) { $domains[] = $dbdomain; } foreach ($this->wallets as $wallet) { $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get(); foreach ($entitlements as $entitlement) { $domain = $entitlement->entitleable; \Log::info("Found domain for {$this->email}: {$domain->namespace} (owned)"); $domains[] = $domain; } } foreach ($this->accounts as $wallet) { $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get(); foreach ($entitlements as $entitlement) { $domain = $entitlement->entitleable; \Log::info("Found domain {$this->email}: {$domain->namespace} (charged)"); $domains[] = $domain; } } return $domains; } public function entitlement() { return $this->morphOne('App\Entitlement', 'entitleable'); } /** * Entitlements for this user. * * Note that these are entitlements that apply to the user account, and not entitlements that * this user owns. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function entitlements() { return $this->hasMany('App\Entitlement', 'entitleable_id', 'id'); } public function addEntitlement($entitlement) { if (!$this->entitlements->contains($entitlement)) { return $this->entitlements()->save($entitlement); } } /** * Find whether an email address exists (user or alias). * Note: This will also find deleted users. * * @param string $email Email address * @param bool $return_user Return User instance instead of boolean * @param bool $is_alias Set to True if the existing email is an alias + * @param bool $existing Ignore deleted users * * @return \App\User|bool True or User model object if found, False otherwise */ - public static function emailExists(string $email, bool $return_user = false, &$is_alias = false) + public static function emailExists(string $email, bool $return_user = false, &$is_alias = false, $existing = false) { if (strpos($email, '@') === false) { return false; } $email = \strtolower($email); - $user = self::withTrashed()->where('email', $email)->first(); + if ($existing) { + $user = self::where('email', $email)->first(); + } else { + $user = self::withTrashed()->where('email', $email)->first(); + } if ($user) { return $return_user ? $user : true; } - $alias = UserAlias::where('alias', $email)->first(); + $aliases = UserAlias::where('alias', $email); + + if ($existing) { + $aliases = $aliases->join('users', 'user_id', '=', 'users.id') + ->whereNull('users.deleted_at'); + } + + $alias = $aliases->first(); if ($alias) { $is_alias = true; - return $return_user ? User::withTrashed()->find($alias->user_id) : true; + return $return_user ? self::withTrashed()->find($alias->user_id) : true; } return false; } /** * Helper to find user by email address, whether it is * main email address, alias or an external email. * * If there's more than one alias NULL will be returned. * * @param string $email Email address * @param bool $external Search also for an external email * * @return \App\User User model object if found */ public static function findByEmail(string $email, bool $external = false): ?User { if (strpos($email, '@') === false) { return null; } $email = \strtolower($email); $user = self::where('email', $email)->first(); if ($user) { return $user; } $aliases = UserAlias::where('alias', $email)->get(); if (count($aliases) == 1) { return $aliases->first()->user; } // TODO: External email return null; } public function getJWTIdentifier() { return $this->getKey(); } public function getJWTCustomClaims() { return []; } + /** + * Check if user has an entitlement for the specified SKU. + * + * @param string $title The SKU title + * + * @return bool True if specified SKU entitlement exists + */ + public function hasSku($title): bool + { + $sku = Sku::where('title', $title)->first(); + + if (!$sku) { + return false; + } + + return $this->entitlements()->where('sku_id', $sku->id)->count() > 0; + } + /** * Returns whether this domain is active. * * @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(); } /** * Unsuspend this domain. * * @return void */ public function unsuspend(): void { if (!$this->isSuspended()) { return; } $this->status ^= User::STATUS_SUSPENDED; $this->save(); } /** * Return users controlled by the current user. * * @param bool $with_accounts Include users assigned to wallets * the current user controls but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function users($with_accounts = true) { $wallets = $this->wallets()->pluck('id')->all(); if ($with_accounts) { $wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all()); } return $this->select(['users.*', 'entitlements.wallet_id']) ->distinct() ->leftJoin('entitlements', 'entitlements.entitleable_id', '=', 'users.id') ->whereIn('entitlements.wallet_id', $wallets) ->where('entitlements.entitleable_type', 'App\User'); } /** * Verification codes for this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function verificationcodes() { return $this->hasMany('App\VerificationCode', 'user_id', 'id'); } /** * Returns the wallet by which the user is controlled * * @return \App\Wallet A wallet object */ public function wallet(): Wallet { $entitlement = $this->entitlement()->first(); // TODO: No entitlement should not happen, but in tests we have // such cases, so we fallback to the user's wallet in this case return $entitlement ? $entitlement->wallet : $this->wallets()->first(); } /** * Wallets this user owns. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function wallets() { return $this->hasMany('App\Wallet'); } /** * 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 6b78981a..d2074419 100644 --- a/src/app/Utils.php +++ b/src/app/Utils.php @@ -1,206 +1,203 @@ diffInDays($end) + 1; } /** * 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 = \url($route); + $url = \config('app.public_url'); - $app_url = trim(\config('app.url'), '/'); - $pub_url = trim(\config('app.public_url'), '/'); - - if ($pub_url != $app_url) { - $url = str_replace($app_url, $pub_url, $url); + if (!$url) { + $url = \config('app.url'); } - return $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']; $env = \app('config')->getMany($opts); $env['countries'] = $countries ?: []; $env['view'] = 'root'; $env['jsapp'] = 'user.js'; if ($path == 'meet' || strpos($path, 'meet/') === 0) { $env['view'] = 'meet'; $env['jsapp'] = 'meet.js'; } elseif ($req_domain == "admin.$sys_domain") { $env['jsapp'] = 'admin.js'; } $env['paymentProvider'] = \config('services.payment_provider'); $env['stripePK'] = \config('services.stripe.public_key'); return $env; } } diff --git a/src/app/Wallet.php b/src/app/Wallet.php index cadc3650..e5ee16de 100644 --- a/src/app/Wallet.php +++ b/src/app/Wallet.php @@ -1,382 +1,400 @@ 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; + } + $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; } - // created more than a month ago -- was it billed? + // 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); $charges += $cost; // if we're in dry-run, you know... if (!$apply) { continue; } - $entitlement->updated_at = $entitlement->updated_at->copy()->addMonthsWithoutOverflow($diff); + $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); } 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. * @return Wallet Self */ public function debit(int $amount, 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 ] ); \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/2fa.php b/src/config/2fa.php index a134f920..85017364 100644 --- a/src/config/2fa.php +++ b/src/config/2fa.php @@ -1,14 +1,14 @@ [ - 'digits' => (int) env('2FA_TOTP_DIGITS', 6), - 'interval' => (int) env('2FA_TOTP_INTERVAL', 30), - 'digest' => env('2FA_TOTP_DIGEST', 'sha1'), + 'digits' => (int) env('MFA_TOTP_DIGITS', 6), + 'interval' => (int) env('MFA_TOTP_INTERVAL', 30), + 'digest' => env('MFA_TOTP_DIGEST', 'sha1'), 'issuer' => env('APP_NAME', 'Laravel'), ], - 'dsn' => env('2FA_DSN'), + 'dsn' => env('MFA_DSN'), ]; diff --git a/src/config/database.php b/src/config/database.php index 59109682..8b447d9b 100644 --- a/src/config/database.php +++ b/src/config/database.php @@ -1,146 +1,146 @@ 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), ], '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' => 'utf8mb4', - 'collation' => 'utf8mb4_unicode_ci', + 'charset' => 'utf8', + 'collation' => 'utf8_unicode_ci', 'prefix' => '', 'prefix_indexes' => true, 'strict' => true, '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_09_17_102628_create_sku_entitlements.php b/src/database/migrations/2019_09_17_102628_create_sku_entitlements.php index 7af4108c..5c37c72d 100644 --- a/src/database/migrations/2019_09_17_102628_create_sku_entitlements.php +++ b/src/database/migrations/2019_09_17_102628_create_sku_entitlements.php @@ -1,63 +1,65 @@ string('id', 36)->primary(); $table->string('title', 64); - $table->json('name'); - $table->json('description'); + // were json, but mariadb + $table->text('name'); + $table->text('description'); + // end of $table->integer('cost'); $table->smallinteger('units_free')->default('0'); $table->string('period', strlen('monthly'))->default('monthly'); $table->string('handler_class')->nullable(); $table->boolean('active')->default(false); $table->timestamp('created_at')->useCurrent(); $table->timestamp('updated_at')->useCurrent(); } ); Schema::create( 'entitlements', function (Blueprint $table) { $table->string('id', 36)->primary(); $table->bigInteger('entitleable_id'); $table->string('entitleable_type'); $table->integer('cost')->default(0)->nullable(); $table->string('wallet_id', 36); $table->string('sku_id', 36); $table->string('description')->nullable(); $table->timestamps(); $table->foreign('sku_id')->references('id')->on('skus')->onDelete('cascade'); $table->foreign('wallet_id')->references('id')->on('wallets')->onDelete('cascade'); } ); } /** * Reverse the migrations. * * @return void */ public function down() { // TODO: drop foreign keys first Schema::dropIfExists('entitlements'); Schema::dropIfExists('skus'); } } diff --git a/src/database/migrations/2019_12_10_095027_create_packages_table.php b/src/database/migrations/2019_12_10_095027_create_packages_table.php index f70ba550..dbf02010 100644 --- a/src/database/migrations/2019_12_10_095027_create_packages_table.php +++ b/src/database/migrations/2019_12_10_095027_create_packages_table.php @@ -1,40 +1,42 @@ string('id', 36); $table->string('title', 36); - $table->json('name'); - $table->json('description'); + // were json, but mariadb + $table->text('name'); + $table->text('description'); + // end of $table->integer('discount_rate')->default(0)->nullable(); $table->primary('id'); } ); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::dropIfExists('packages'); } } diff --git a/src/database/migrations/2019_12_10_105428_create_plans_table.php b/src/database/migrations/2019_12_10_105428_create_plans_table.php index e756f52b..0e322a83 100644 --- a/src/database/migrations/2019_12_10_105428_create_plans_table.php +++ b/src/database/migrations/2019_12_10_105428_create_plans_table.php @@ -1,45 +1,47 @@ string('id', 36); $table->string('title', 36); - $table->json('name'); - $table->json('description'); + // were json, but mariadb + $table->text('name'); + $table->text('description'); + // end of $table->datetime('promo_from')->nullable(); $table->datetime('promo_to')->nullable(); $table->integer('qty_min')->default(0)->nullable(); $table->integer('qty_max')->default(0)->nullable(); $table->integer('discount_qty')->default(0); $table->integer('discount_rate')->default(0); $table->primary('id'); } ); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::dropIfExists('plans'); } } diff --git a/src/database/migrations/2020_03_30_100000_create_discounts.php b/src/database/migrations/2020_03_30_100000_create_discounts.php index ed300af9..b0b2a550 100644 --- a/src/database/migrations/2020_03_30_100000_create_discounts.php +++ b/src/database/migrations/2020_03_30_100000_create_discounts.php @@ -1,60 +1,62 @@ string('id', 36); $table->tinyInteger('discount')->unsigned(); - $table->json('description'); + // was json, but mariadb + $table->text('description'); + // end of $table->string('code', 32)->nullable(); $table->boolean('active')->default(false); $table->timestamp('created_at')->useCurrent(); $table->timestamp('updated_at')->useCurrent(); $table->primary('id'); } ); Schema::table( 'wallets', function (Blueprint $table) { $table->string('discount_id', 36)->nullable(); $table->foreign('discount_id')->references('id') ->on('discounts')->onDelete('set null'); } ); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::table( 'wallets', function (Blueprint $table) { $table->dropForeign(['discount_id']); $table->dropColumn('discount_id'); } ); Schema::dropIfExists('discounts'); } } diff --git a/src/database/migrations/2019_12_10_095027_create_packages_table.php b/src/database/migrations/2020_09_02_150004_unique_discounts.php similarity index 51% copy from src/database/migrations/2019_12_10_095027_create_packages_table.php copy to src/database/migrations/2020_09_02_150004_unique_discounts.php index f70ba550..fc2b498c 100644 --- a/src/database/migrations/2019_12_10_095027_create_packages_table.php +++ b/src/database/migrations/2020_09_02_150004_unique_discounts.php @@ -1,40 +1,39 @@ string('id', 36); - $table->string('title', 36); - $table->json('name'); - $table->json('description'); - $table->integer('discount_rate')->default(0)->nullable(); - - $table->primary('id'); + $table->unique(['discount', 'description', 'code', 'active']); } ); } /** * Reverse the migrations. * * @return void */ public function down() { - Schema::dropIfExists('packages'); + Schema::table( + 'discounts', + function (Blueprint $table) { + $table->dropUnique(['discount', 'description', 'code', 'active']); + } + ); } } diff --git a/src/database/seeds/production/PackageSeeder.php b/src/database/seeds/production/PackageSeeder.php index e1287cfa..2500aa23 100644 --- a/src/database/seeds/production/PackageSeeder.php +++ b/src/database/seeds/production/PackageSeeder.php @@ -1,84 +1,86 @@ 'activesync']); $skuGroupware = Sku::firstOrCreate(['title' => 'groupware']); $skuMailbox = Sku::firstOrCreate(['title' => 'mailbox']); $skuStorage = Sku::firstOrCreate(['title' => 'storage']); $package = Package::create( [ 'title' => 'kolab', 'name' => 'Groupware Account', 'description' => 'A fully functional groupware account.', 'discount_rate' => 0 ] ); $skus = [ $skuMailbox, $skuGroupware, - $skuStorage + $skuStorage, + $skuActiveSync ]; $package->skus()->saveMany($skus); // This package contains 2 units of the storage SKU, which just so happens to also // be the number of SKU free units. $package->skus()->updateExistingPivot( $skuStorage, ['qty' => 2], false ); $package = Package::create( [ 'title' => 'lite', 'name' => 'Lite Account', 'description' => 'Just mail and no more.', 'discount_rate' => 0 ] ); $skus = [ $skuMailbox, $skuStorage ]; $package->skus()->saveMany($skus); $package->skus()->updateExistingPivot( Sku::firstOrCreate(['title' => 'storage']), ['qty' => 2], false ); $package = Package::create( [ 'title' => 'domain-hosting', 'name' => 'Domain Hosting', 'description' => 'Use your own, existing domain.', 'discount_rate' => 0 ] ); $skus = [ Sku::firstOrCreate(['title' => 'domain-hosting']) ]; $package->skus()->saveMany($skus); } } diff --git a/src/phpunit-fast b/src/phpunit-fast new file mode 120000 index 00000000..31e31c0a --- /dev/null +++ b/src/phpunit-fast @@ -0,0 +1 @@ +../bin/phpunit-fast \ No newline at end of file diff --git a/src/resources/countries.php b/src/resources/countries.php index f1a25610..42708918 100644 --- a/src/resources/countries.php +++ b/src/resources/countries.php @@ -1,237 +1,251 @@ ['AFN','Afghanistan'], - 'AX' => ['EUR','Åland Islands'], + 'AX' => ['EUR','Aland Islands'], 'AL' => ['ALL','Albania'], 'DZ' => ['DZD','Algeria'], 'AS' => ['USD','American Samoa'], 'AD' => ['EUR','Andorra'], 'AO' => ['AOA','Angola'], 'AI' => ['XCD','Anguilla'], 'AG' => ['XCD','Antigua and Barbuda'], 'AR' => ['ARS','Argentina'], 'AM' => ['AMD','Armenia'], 'AW' => ['AWG','Aruba'], 'AU' => ['AUD','Australia'], 'AT' => ['EUR','Austria'], 'AZ' => ['AZN','Azerbaijan'], + 'BS' => ['BSD','Bahamas'], 'BH' => ['BHD','Bahrain'], 'BD' => ['BDT','Bangladesh'], - 'BB' => ['USD','Barbados'], - 'BY' => ['BYN','Belarus'], + 'BB' => ['BBD','Barbados'], + 'BY' => ['BYR','Belarus'], 'BE' => ['EUR','Belgium'], 'BZ' => ['BZD','Belize'], 'BJ' => ['XOF','Benin'], - 'BM' => ['USD','Bermuda'], - 'BT' => ['INR','Bhutan'], - 'BO' => ['BOV','Bolivia (Plurinational State of)'], - 'BQ' => ['USD','Bonaire, Sint Eustatius and Saba'], + 'BM' => ['BMD','Bermuda'], + 'BT' => ['BTN','Bhutan'], + 'BO' => ['BOB','Bolivia'], + 'BQ' => ['USD','Bonaire, Saint Eustatius and Saba '], 'BA' => ['BAM','Bosnia and Herzegovina'], 'BW' => ['BWP','Botswana'], 'BV' => ['NOK','Bouvet Island'], 'BR' => ['BRL','Brazil'], 'IO' => ['USD','British Indian Ocean Territory'], + 'VG' => ['USD','British Virgin Islands'], + 'BN' => ['BND','Brunei'], 'BG' => ['BGN','Bulgaria'], 'BF' => ['XOF','Burkina Faso'], 'BI' => ['BIF','Burundi'], - 'KH' => ['USD','Cambodia'], + 'KH' => ['KHR','Cambodia'], 'CM' => ['XAF','Cameroon'], 'CA' => ['CAD','Canada'], + 'CV' => ['CVE','Cape Verde'], 'KY' => ['KYD','Cayman Islands'], 'CF' => ['XAF','Central African Republic'], 'TD' => ['XAF','Chad'], 'CL' => ['CLP','Chile'], 'CN' => ['CNY','China'], 'CX' => ['AUD','Christmas Island'], - 'CC' => ['AUD','Cocos (Keeling) Islands'], - 'CO' => ['COU','Colombia'], + 'CC' => ['AUD','Cocos Islands'], + 'CO' => ['COP','Colombia'], 'KM' => ['KMF','Comoros'], - 'CG' => ['XAF','Congo'], - 'CD' => ['CDF','Congo, Democratic Republic of the'], 'CK' => ['NZD','Cook Islands'], 'CR' => ['CRC','Costa Rica'], - 'CI' => ['XOF','Côte d\'Ivoire'], 'HR' => ['HRK','Croatia'], 'CU' => ['CUP','Cuba'], - 'CW' => ['ANG','Curaçao'], + 'CW' => ['ANG','Curacao'], 'CY' => ['EUR','Cyprus'], - 'CZ' => ['CZK','Czechia'], + 'CZ' => ['CZK','Czech Republic'], + 'CD' => ['CDF','Democratic Republic of the Congo'], 'DK' => ['DKK','Denmark'], 'DJ' => ['DJF','Djibouti'], 'DM' => ['XCD','Dominica'], 'DO' => ['DOP','Dominican Republic'], + 'TL' => ['USD','East Timor'], 'EC' => ['USD','Ecuador'], 'EG' => ['EGP','Egypt'], 'SV' => ['USD','El Salvador'], 'GQ' => ['XAF','Equatorial Guinea'], 'ER' => ['ERN','Eritrea'], 'EE' => ['EUR','Estonia'], - 'SZ' => ['SZL','Eswatini'], 'ET' => ['ETB','Ethiopia'], - 'FK' => ['FKP','Falkland Islands (Malvinas)'], + 'FK' => ['FKP','Falkland Islands'], 'FO' => ['DKK','Faroe Islands'], 'FJ' => ['FJD','Fiji'], 'FI' => ['EUR','Finland'], 'FR' => ['EUR','France'], 'GF' => ['EUR','French Guiana'], 'PF' => ['XPF','French Polynesia'], 'TF' => ['EUR','French Southern Territories'], 'GA' => ['XAF','Gabon'], + 'GM' => ['GMD','Gambia'], 'GE' => ['GEL','Georgia'], 'DE' => ['EUR','Germany'], 'GH' => ['GHS','Ghana'], 'GI' => ['GIP','Gibraltar'], 'GR' => ['EUR','Greece'], 'GL' => ['DKK','Greenland'], 'GD' => ['XCD','Grenada'], 'GP' => ['EUR','Guadeloupe'], 'GU' => ['USD','Guam'], 'GT' => ['GTQ','Guatemala'], + 'GG' => ['GBP','Guernsey'], 'GN' => ['GNF','Guinea'], 'GW' => ['XOF','Guinea-Bissau'], 'GY' => ['GYD','Guyana'], - 'HT' => ['USD','Haiti'], + 'HT' => ['HTG','Haiti'], 'HM' => ['AUD','Heard Island and McDonald Islands'], 'HN' => ['HNL','Honduras'], 'HK' => ['HKD','Hong Kong'], 'HU' => ['HUF','Hungary'], 'IS' => ['ISK','Iceland'], 'IN' => ['INR','India'], 'ID' => ['IDR','Indonesia'], - 'IR' => ['IRR','Iran (Islamic Republic of)'], + 'IR' => ['IRR','Iran'], 'IQ' => ['IQD','Iraq'], 'IE' => ['EUR','Ireland'], 'IM' => ['GBP','Isle of Man'], 'IL' => ['ILS','Israel'], 'IT' => ['EUR','Italy'], + 'CI' => ['XOF','Ivory Coast'], 'JM' => ['JMD','Jamaica'], 'JP' => ['JPY','Japan'], 'JE' => ['GBP','Jersey'], 'JO' => ['JOD','Jordan'], 'KZ' => ['KZT','Kazakhstan'], 'KE' => ['KES','Kenya'], 'KI' => ['AUD','Kiribati'], - 'KP' => ['KPW','Korea (Democratic People\'s Republic of)'], - 'KR' => ['KRW','Korea, Republic of'], + 'XK' => ['EUR','Kosovo'], 'KW' => ['KWD','Kuwait'], 'KG' => ['KGS','Kyrgyzstan'], - 'LA' => ['LAK','Lao People\'s Democratic Republic'], + 'LA' => ['LAK','Laos'], 'LV' => ['EUR','Latvia'], 'LB' => ['LBP','Lebanon'], - 'LS' => ['ZAR','Lesotho'], + 'LS' => ['LSL','Lesotho'], 'LR' => ['LRD','Liberia'], 'LY' => ['LYD','Libya'], 'LI' => ['CHF','Liechtenstein'], 'LT' => ['EUR','Lithuania'], 'LU' => ['EUR','Luxembourg'], 'MO' => ['MOP','Macao'], + 'MK' => ['MKD','Macedonia'], 'MG' => ['MGA','Madagascar'], 'MW' => ['MWK','Malawi'], 'MY' => ['MYR','Malaysia'], 'MV' => ['MVR','Maldives'], 'ML' => ['XOF','Mali'], 'MT' => ['EUR','Malta'], 'MH' => ['USD','Marshall Islands'], 'MQ' => ['EUR','Martinique'], - 'MR' => ['MRU','Mauritania'], + 'MR' => ['MRO','Mauritania'], 'MU' => ['MUR','Mauritius'], 'YT' => ['EUR','Mayotte'], - 'MX' => ['MXV','Mexico'], - 'FM' => ['USD','Micronesia (Federated States of)'], - 'MD' => ['MDL','Moldova, Republic of'], + 'MX' => ['MXN','Mexico'], + 'FM' => ['USD','Micronesia'], + 'MD' => ['MDL','Moldova'], 'MC' => ['EUR','Monaco'], 'MN' => ['MNT','Mongolia'], 'ME' => ['EUR','Montenegro'], 'MS' => ['XCD','Montserrat'], 'MA' => ['MAD','Morocco'], 'MZ' => ['MZN','Mozambique'], 'MM' => ['MMK','Myanmar'], - 'NA' => ['ZAR','Namibia'], + 'NA' => ['NAD','Namibia'], 'NR' => ['AUD','Nauru'], 'NP' => ['NPR','Nepal'], + 'NL' => ['EUR','Netherlands'], 'NC' => ['XPF','New Caledonia'], 'NZ' => ['NZD','New Zealand'], 'NI' => ['NIO','Nicaragua'], 'NE' => ['XOF','Niger'], 'NG' => ['NGN','Nigeria'], 'NU' => ['NZD','Niue'], 'NF' => ['AUD','Norfolk Island'], - 'MK' => ['MKD','North Macedonia'], + 'KP' => ['KPW','North Korea'], 'MP' => ['USD','Northern Mariana Islands'], 'NO' => ['NOK','Norway'], 'OM' => ['OMR','Oman'], 'PK' => ['PKR','Pakistan'], 'PW' => ['USD','Palau'], - 'PA' => ['USD','Panama'], + 'PS' => ['ILS','Palestinian Territory'], + 'PA' => ['PAB','Panama'], 'PG' => ['PGK','Papua New Guinea'], 'PY' => ['PYG','Paraguay'], 'PE' => ['PEN','Peru'], 'PH' => ['PHP','Philippines'], 'PN' => ['NZD','Pitcairn'], 'PL' => ['PLN','Poland'], 'PT' => ['EUR','Portugal'], 'PR' => ['USD','Puerto Rico'], 'QA' => ['QAR','Qatar'], - 'RE' => ['EUR','Réunion'], + 'CG' => ['XAF','Republic of the Congo'], + 'RE' => ['EUR','Reunion'], 'RO' => ['RON','Romania'], - 'RU' => ['RUB','Russian Federation'], + 'RU' => ['RUB','Russia'], 'RW' => ['RWF','Rwanda'], - 'BL' => ['EUR','Saint Barthélemy'], + 'BL' => ['EUR','Saint Barthelemy'], + 'SH' => ['SHP','Saint Helena'], 'KN' => ['XCD','Saint Kitts and Nevis'], 'LC' => ['XCD','Saint Lucia'], - 'MF' => ['EUR','Saint Martin (French part)'], + 'MF' => ['EUR','Saint Martin'], 'PM' => ['EUR','Saint Pierre and Miquelon'], 'VC' => ['XCD','Saint Vincent and the Grenadines'], 'WS' => ['WST','Samoa'], 'SM' => ['EUR','San Marino'], + 'ST' => ['STD','Sao Tome and Principe'], 'SA' => ['SAR','Saudi Arabia'], 'SN' => ['XOF','Senegal'], 'RS' => ['RSD','Serbia'], 'SC' => ['SCR','Seychelles'], 'SL' => ['SLL','Sierra Leone'], 'SG' => ['SGD','Singapore'], - 'SX' => ['ANG','Sint Maarten (Dutch part)'], + 'SX' => ['ANG','Sint Maarten'], 'SK' => ['EUR','Slovakia'], 'SI' => ['EUR','Slovenia'], 'SB' => ['SBD','Solomon Islands'], 'SO' => ['SOS','Somalia'], 'ZA' => ['ZAR','South Africa'], + 'GS' => ['GBP','South Georgia and the South Sandwich Islands'], + 'KR' => ['KRW','South Korea'], 'SS' => ['SSP','South Sudan'], 'ES' => ['EUR','Spain'], 'LK' => ['LKR','Sri Lanka'], 'SD' => ['SDG','Sudan'], 'SR' => ['SRD','Suriname'], + 'SJ' => ['NOK','Svalbard and Jan Mayen'], + 'SZ' => ['SZL','Swaziland'], 'SE' => ['SEK','Sweden'], - 'CH' => ['CHW','Switzerland'], - 'SY' => ['SYP','Syrian Arab Republic'], + 'CH' => ['CHF','Switzerland'], + 'SY' => ['SYP','Syria'], + 'TW' => ['TWD','Taiwan'], 'TJ' => ['TJS','Tajikistan'], - 'TZ' => ['TZS','Tanzania, United Republic of'], + 'TZ' => ['TZS','Tanzania'], 'TH' => ['THB','Thailand'], - 'TL' => ['USD','Timor-Leste'], 'TG' => ['XOF','Togo'], 'TK' => ['NZD','Tokelau'], 'TO' => ['TOP','Tonga'], 'TT' => ['TTD','Trinidad and Tobago'], 'TN' => ['TND','Tunisia'], 'TR' => ['TRY','Turkey'], 'TM' => ['TMT','Turkmenistan'], 'TC' => ['USD','Turks and Caicos Islands'], 'TV' => ['AUD','Tuvalu'], + 'VI' => ['USD','U.S. Virgin Islands'], 'UG' => ['UGX','Uganda'], 'UA' => ['UAH','Ukraine'], 'AE' => ['AED','United Arab Emirates'], - 'GB' => ['GBP','United Kingdom of Great Britain and Northern Ireland'], - 'US' => ['USN','United States of America'], + 'GB' => ['GBP','United Kingdom'], + 'US' => ['USD','United States'], 'UM' => ['USD','United States Minor Outlying Islands'], - 'UY' => ['UYW','Uruguay'], + 'UY' => ['UYU','Uruguay'], 'UZ' => ['UZS','Uzbekistan'], 'VU' => ['VUV','Vanuatu'], - 'VE' => ['VES','Venezuela (Bolivarian Republic of)'], - 'VN' => ['VND','Viet Nam'], - 'VG' => ['USD','Virgin Islands (British)'], - 'VI' => ['USD','Virgin Islands (U.S.)'], + 'VA' => ['EUR','Vatican'], + 'VE' => ['VEF','Venezuela'], + 'VN' => ['VND','Vietnam'], 'WF' => ['XPF','Wallis and Futuna'], 'EH' => ['MAD','Western Sahara'], 'YE' => ['YER','Yemen'], - 'ZM' => ['ZMW','Zambia'], + 'ZM' => ['ZMK','Zambia'], 'ZW' => ['ZWL','Zimbabwe'], ]; diff --git a/src/resources/js/app.js b/src/resources/js/app.js index 5fcaa44f..f26d1979 100644 --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -1,366 +1,367 @@ /** * 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 store from './store' const loader = '
Loading
' const app = new Vue({ el: '#app', components: { AppComponent, MenuComponent, }, store, router: window.router, data() { return { isLoading: true, isAdmin: window.isAdmin } }, methods: { // Clear (bootstrap) form validation state clearFormValidation(form) { $(form).find('.is-invalid').removeClass('is-invalid') $(form).find('.invalid-feedback').remove() }, hasRoute(name) { return this.$router.resolve({ name: name }).resolved.matched.length > 0 }, 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 (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 - // or immediately when we have no expiration time (on token re-use) 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) { if (this.hasRoute('login')) { this.$router.push({ name: 'login' }) } } clearTimeout(this.refreshTimeout) }, // Display "loading" overlay inside of the specified element addLoader(elem) { $(elem).css({position: 'relative'}).append($(loader).addClass('small')) }, // Remove loader element added in addLoader() removeLoader(elem) { $(elem).find('.app-loader').remove() }, startLoading() { this.isLoading = true // Lock the UI with the 'loading...' element - let loading = $('#app > .app-loader').show() + let loading = $('#app > .app-loader').removeClass('fadeOut') if (!loading.length) { $('#app').append($(loader)) } }, // Hide "loading" overlay stopLoading() { $('#app > .app-loader').addClass('fadeOut') this.isLoading = false }, errorPage(code, msg) { // 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" const error_page = `
${code}
${msg}
` $('#error-page').remove() $('#app').append(error_page) }, errorHandler(error) { this.stopLoading() if (!error.response) { // TODO: probably network connection error } else if (error.response.status === 401) { if (!store.state.afterLogin && this.$router.currentRoute.name != 'login') { store.state.afterLogin = this.$router.currentRoute } this.logoutUser() } 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/100).toLocaleString('de-DE', { style: 'currency', currency: currency || 'CHF' }) + return ((price || 0) / 100).toLocaleString('de-DE', { style: 'currency', currency: currency || 'CHF' }) }, priceLabel(cost, units = 1, discount) { let index = '' if (units < 0) { units = 1 } if (discount) { cost = Math.floor(cost * ((100 - discount) / 100)) index = '\u00B9' } return this.price(cost * units) + '/month' + index }, clickRecord(event) { if (!/^(a|button|svg|path)$/i.test(event.target.nodeName)) { - $(event.target).closest('tr').find('a')[0].click() + 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' }, 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' } } }) // 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 => { // Do nothing 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.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\ // 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 input.children(':not(:first-child)').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/bootstrap.js b/src/resources/js/bootstrap.js index 00413a36..88b4aecd 100644 --- a/src/resources/js/bootstrap.js +++ b/src/resources/js/bootstrap.js @@ -1,92 +1,101 @@ /** * We'll load jQuery and the Bootstrap jQuery plugin which provides support * for JavaScript based Bootstrap features such as modals and tabs. This * code may be modified to fit the specific needs of your application. */ window.Popper = require('popper.js').default window.$ = window.jQuery = require('jquery') require('bootstrap') /** * We'll load Vue, VueRouter and global components */ import FontAwesomeIcon from './fontawesome' import VueRouter from 'vue-router' import Toast from '../vue/Widgets/Toast' import store from './store' window.Vue = require('vue') Vue.component('svg-icon', FontAwesomeIcon) const vTooltip = (el, binding) => { const t = [] if (binding.modifiers.focus) t.push('focus') if (binding.modifiers.hover) t.push('hover') if (binding.modifiers.click) t.push('click') if (!t.length) t.push('hover') $(el).tooltip({ title: binding.value, placement: binding.arg || 'top', trigger: t.join(' '), html: !!binding.modifiers.html, }); } Vue.directive('tooltip', { bind: vTooltip, update: vTooltip, unbind (el) { $(el).tooltip('dispose') } }) Vue.use(Toast) Vue.use(VueRouter) +let vueRouterBase = '/' +try { + let url = new URL(window.config['app.url']) + vueRouterBase = url.pathname +} catch(e) { + // ignore +} + window.router = new VueRouter({ + base: vueRouterBase, mode: 'history', routes: window.routes, scrollBehavior (to, from, savedPosition) { // Scroll the page to top, but not on Back action return savedPosition || { x: 0, y: 0 } } }) router.beforeEach((to, from, next) => { // check if the route requires authentication and user is not logged in if (to.matched.some(route => route.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 } next() }) router.afterEach((to, from) => { // When changing a page remove old: // - error page // - modal backdrop $('#error-page,.modal-backdrop.show').remove() }) /** * We'll load the axios HTTP library which allows us to easily issue requests * to our Laravel back-end. This library automatically handles sending the * CSRF token as a header based on the value of the "XSRF" token cookie. */ window.axios = require('axios') - +axios.defaults.baseURL = vueRouterBase axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest' diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php index 71dfa809..76974430 100644 --- a/src/resources/lang/en/app.php +++ b/src/resources/lang/en/app.php @@ -1,53 +1,56 @@ 'The auto-payment has been removed.', 'mandate-update-success' => 'The auto-payment has been updated.', 'planbutton' => 'Choose :plan', 'process-user-new' => 'Registering a user...', 'process-user-ldap-ready' => 'Creating a user...', 'process-user-imap-ready' => 'Creating a mailbox...', '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.', '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-foundxusers' => ':x user accounts have been found.', '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 994ecfac..687be129 100644 --- a/src/resources/lang/en/mail.php +++ b/src/resources/lang/en/mail.php @@ -1,65 +1,84 @@ "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 Reminder", - 'negativebalance-body' => "It has probably skipped your attention that you are behind on paying for your :site account. " - . "Consider setting up auto-payment to avoid messages like this in the future.", + '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:", '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/sass/app.scss b/src/resources/sass/app.scss index d105d54d..2ebb961f 100644 --- a/src/resources/sass/app.scss +++ b/src/resources/sass/app.scss @@ -1,370 +1,433 @@ @import 'variables'; @import 'bootstrap'; @import 'meet'; @import 'menu'; @import 'toast'; @import 'forms'; 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-items: center; display: flex; 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; } } .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 400ms linear, opacity 400ms 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, td.email, td.price, td.datetime, td.selection { width: 1%; white-space: nowrap; } 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-ico { font-size: 3.8rem; color: #f1a539; border: 3px solid #f1a539; width: 6rem; height: 6rem; margin-bottom: 1rem; border-radius: 50%; } } .plan-description { & > ul { padding-left: 1.2em; &:last-child { margin-bottom: 0; } } } .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; } .badge { position: absolute; top: 0.5rem; right: 0.5rem; } } svg { width: 6rem; height: 6rem; margin: auto; } } // Various improvements for mobile @include media-breakpoint-down(sm) { .card { 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/sass/menu.scss b/src/resources/sass/menu.scss index 25c99965..e02f713b 100644 --- a/src/resources/sass/menu.scss +++ b/src/resources/sass/menu.scss @@ -1,150 +1,151 @@ #header-menu { background-color: $menu-bg-color; padding: 0; line-height: 85px; .navbar-brand { padding: 0; outline: 0; > img { display: inline; vertical-align: middle; } } .nav-link { color: #202020; line-height: 85px; padding: 0 0 0 25px; background: transparent; &:focus { text-decoration: underline; outline: 0; } &:hover { color: $main-color; text-decoration: underline; } &.active:not(.menulogin) { font-weight: bold; } } } #footer-menu { background-color: $main-color; height: 100px; + overflow: hidden; .navbar-brand { margin: 0; img { width: 170px; } } .footer { text-align: right; color: #fff; font-size: 0.75rem; padding: 0 0.5rem; } } @include media-breakpoint-up(lg) { #header-menu { a.menulogin { text-transform: uppercase; border: 2px solid $main-color; border-radius: 21px; line-height: 21px; letter-spacing: 1px; padding: 6px 34px; margin: 25px 0 25px 25px; &:focus, &:hover { text-decoration: none; background-color: $main-color; color: #fff; font-weight: normal; } } } - #footer-menu { + .navbar { .navbar { - flex-direction: column; - align-items: flex-end; + justify-content: flex-end; } } - .navbar { + #footer-menu { .navbar { - justify-content: flex-end; + flex-direction: column; + align-items: flex-end; } } } @include media-breakpoint-down(md) { #header-menu { .navbar-nav { padding-bottom: 1em; } .nav-link { line-height: 45px; padding: 0; } } #footer-menu { .navbar-nav { display: none; } .container { flex-wrap: nowrap; } } } @include media-breakpoint-down(sm) { #header-menu { padding: 0 1em; .navbar-nav { display: block; width: 100%; padding: 0; li { border-top: 1px solid #eee; } } } #footer-menu { height: 80px; .container { flex-direction: column; } #footer-company { display: none; } } } @media (max-width: 340px) { #header-menu { .navbar-brand img { width: 160px; } } } diff --git a/src/resources/views/documents/receipt.blade.php b/src/resources/views/documents/receipt.blade.php index 8c3d5684..ce9e9e2c 100644 --- a/src/resources/views/documents/receipt.blade.php +++ b/src/resources/views/documents/receipt.blade.php @@ -1,66 +1,66 @@
(.+)
(.+)

{{ $title }}

{!! $customer['customer'] !!} - {{ __('documents.account-id') }} {{ $customer['wallet_id'] }}
+ {{--{{ __('documents.account-id') }} {{ $customer['wallet_id'] }}
--}} {{ __('documents.customer-no') }} {{ $customer['id'] }}
@foreach ($items as $item) @endforeach @if ($vat) @endif
{{ __('documents.date') }} {{ __('documents.description') }} {{ __('documents.amount') }}
{{ $item['date'] }} {{ $item['description'] }} {{ $item['amount'] }}
{{ __('documents.subtotal') }} {{ $subTotal }}
{{ __('documents.vat', ['rate' => $vatRate]) }} {{ $totalVat }}
{{ __('documents.total') }} {{ $total }}
diff --git a/src/resources/views/emails/html/negative_balance_before_delete.blade.php b/src/resources/views/emails/html/negative_balance_before_delete.blade.php new file mode 100644 index 00000000..ee5b5476 --- /dev/null +++ b/src/resources/views/emails/html/negative_balance_before_delete.blade.php @@ -0,0 +1,21 @@ + + + + + + +

{{ __('mail.header', ['name' => $username]) }}

+ +

{{ __('mail.negativebalancebeforedelete-body', ['site' => $site, 'date' => $date]) }}

+

{{ __('mail.negativebalancebeforedelete-body-ext', ['site' => $site]) }}

+

{{ $walletUrl }}

+ +@if ($supportUrl) +

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

+

{{ $supportUrl }}

+@endif + +

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

+

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

+ + diff --git a/src/resources/views/emails/html/negative_balance_reminder.blade.php b/src/resources/views/emails/html/negative_balance_reminder.blade.php new file mode 100644 index 00000000..876bd023 --- /dev/null +++ b/src/resources/views/emails/html/negative_balance_reminder.blade.php @@ -0,0 +1,22 @@ + + + + + + +

{{ __('mail.header', ['name' => $username]) }}

+ +

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

+

{{ __('mail.negativebalancereminder-body-ext', ['site' => $site]) }}

+

{{ $walletUrl }}

+

{{ __('mail.negativebalancereminder-body-warning', ['site' => $site, 'date' => $date]) }}

+ +@if ($supportUrl) +

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

+

{{ $supportUrl }}

+@endif + +

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

+

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

+ + diff --git a/src/resources/views/emails/html/negative_balance_suspended.blade.php b/src/resources/views/emails/html/negative_balance_suspended.blade.php new file mode 100644 index 00000000..6fde9c19 --- /dev/null +++ b/src/resources/views/emails/html/negative_balance_suspended.blade.php @@ -0,0 +1,22 @@ + + + + + + +

{{ __('mail.header', ['name' => $username]) }}

+ +

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

+

{{ __('mail.negativebalancesuspended-body-ext', ['site' => $site]) }}

+

{{ $walletUrl }}

+

{{ __('mail.negativebalancesuspended-body-warning', ['site' => $site, 'date' => $date]) }}

+ +@if ($supportUrl) +

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

+

{{ $supportUrl }}

+@endif + +

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

+

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

+ + diff --git a/src/resources/views/emails/plain/negative_balance_before_delete.blade.php b/src/resources/views/emails/plain/negative_balance_before_delete.blade.php new file mode 100644 index 00000000..29a76169 --- /dev/null +++ b/src/resources/views/emails/plain/negative_balance_before_delete.blade.php @@ -0,0 +1,17 @@ +{!! __('mail.header', ['name' => $username]) !!} + +{!! __('mail.negativebalancebeforedelete-body', ['site' => $site, 'date' => $date]) !!} + +{!! __('mail.negativebalancebeforedelete-body-ext', ['site' => $site]) !!} + +{!! $walletUrl !!} + +@if ($supportUrl) +{!! __('mail.support', ['site' => $site]) !!} + +{!! $supportUrl !!} +@endif + +-- +{!! __('mail.footer1') !!} +{!! __('mail.footer2', ['site' => $site]) !!} diff --git a/src/resources/views/emails/plain/negative_balance_reminder.blade.php b/src/resources/views/emails/plain/negative_balance_reminder.blade.php new file mode 100644 index 00000000..6be4f048 --- /dev/null +++ b/src/resources/views/emails/plain/negative_balance_reminder.blade.php @@ -0,0 +1,19 @@ +{!! __('mail.header', ['name' => $username]) !!} + +{!! __('mail.negativebalancereminder-body', ['site' => $site]) !!} + +{!! __('mail.negativebalancereminder-body-ext', ['site' => $site]) !!} + +{!! $walletUrl !!} + +{!! __('mail.negativebalancereminder-body-warning', ['site' => $site, 'date' => $date]) !!} + +@if ($supportUrl) +{!! __('mail.support', ['site' => $site]) !!} + +{!! $supportUrl !!} +@endif + +-- +{!! __('mail.footer1') !!} +{!! __('mail.footer2', ['site' => $site]) !!} diff --git a/src/resources/views/emails/plain/negative_balance_suspended.blade.php b/src/resources/views/emails/plain/negative_balance_suspended.blade.php new file mode 100644 index 00000000..e9049897 --- /dev/null +++ b/src/resources/views/emails/plain/negative_balance_suspended.blade.php @@ -0,0 +1,19 @@ +{!! __('mail.header', ['name' => $username]) !!} + +{!! __('mail.negativebalancesuspended-body', ['site' => $site]) !!} + +{!! __('mail.negativebalancesuspended-body-ext', ['site' => $site]) !!} + +{!! $walletUrl !!} + +{!! __('mail.negativebalancesuspended-body-warning', ['site' => $site, 'date' => $date]) !!} + +@if ($supportUrl) +{!! __('mail.support', ['site' => $site]) !!} + +{!! $supportUrl !!} +@endif + +-- +{!! __('mail.footer1') !!} +{!! __('mail.footer2', ['site' => $site]) !!} diff --git a/src/resources/views/layouts/app.blade.php b/src/resources/views/layouts/app.blade.php index 11e14c40..ab6e2cc7 100644 --- a/src/resources/views/layouts/app.blade.php +++ b/src/resources/views/layouts/app.blade.php @@ -1,23 +1,23 @@ - + {{ config('app.name') }} -- @yield('title') {{-- TODO: PWA disabled for now: @laravelPWA --}} - +
@yield('content')
- + diff --git a/src/resources/vue/Admin/Dashboard.vue b/src/resources/vue/Admin/Dashboard.vue index f1616a88..9d267bf8 100644 --- a/src/resources/vue/Admin/Dashboard.vue +++ b/src/resources/vue/Admin/Dashboard.vue @@ -1,83 +1,94 @@ diff --git a/src/resources/vue/Admin/Domain.vue b/src/resources/vue/Admin/Domain.vue index cfa0159c..ff9af51b 100644 --- a/src/resources/vue/Admin/Domain.vue +++ b/src/resources/vue/Admin/Domain.vue @@ -1,69 +1,91 @@ diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue index e2678d28..1cff1957 100644 --- a/src/resources/vue/Admin/User.vue +++ b/src/resources/vue/Admin/User.vue @@ -1,597 +1,644 @@ diff --git a/src/resources/vue/App.vue b/src/resources/vue/App.vue index a52723a5..19c62868 100644 --- a/src/resources/vue/App.vue +++ b/src/resources/vue/App.vue @@ -1,51 +1,51 @@ diff --git a/src/resources/vue/Dashboard.vue b/src/resources/vue/Dashboard.vue index 38d03ab3..fe982f8c 100644 --- a/src/resources/vue/Dashboard.vue +++ b/src/resources/vue/Dashboard.vue @@ -1,72 +1,72 @@ diff --git a/src/resources/vue/Domain/Info.vue b/src/resources/vue/Domain/Info.vue index 34f38df2..f5c9249b 100644 --- a/src/resources/vue/Domain/Info.vue +++ b/src/resources/vue/Domain/Info.vue @@ -1,88 +1,93 @@ diff --git a/src/resources/vue/Domain/List.vue b/src/resources/vue/Domain/List.vue index 876e8711..ca8cfed1 100644 --- a/src/resources/vue/Domain/List.vue +++ b/src/resources/vue/Domain/List.vue @@ -1,45 +1,53 @@ diff --git a/src/resources/vue/User/Info.vue b/src/resources/vue/User/Info.vue index 6831e4fd..d4d6ee53 100644 --- a/src/resources/vue/User/Info.vue +++ b/src/resources/vue/User/Info.vue @@ -1,370 +1,370 @@ diff --git a/src/resources/vue/User/List.vue b/src/resources/vue/User/List.vue index 02dc12bf..ed26eb94 100644 --- a/src/resources/vue/User/List.vue +++ b/src/resources/vue/User/List.vue @@ -1,123 +1,131 @@ diff --git a/src/resources/vue/User/Profile.vue b/src/resources/vue/User/Profile.vue index 01cbd251..fe38f355 100644 --- a/src/resources/vue/User/Profile.vue +++ b/src/resources/vue/User/Profile.vue @@ -1,114 +1,117 @@ diff --git a/src/resources/vue/Widgets/ListInput.vue b/src/resources/vue/Widgets/ListInput.vue index 37fe1f9a..8348e249 100644 --- a/src/resources/vue/Widgets/ListInput.vue +++ b/src/resources/vue/Widgets/ListInput.vue @@ -1,55 +1,55 @@ diff --git a/src/resources/vue/Widgets/Menu.vue b/src/resources/vue/Widgets/Menu.vue index 152360dd..bd657a63 100644 --- a/src/resources/vue/Widgets/Menu.vue +++ b/src/resources/vue/Widgets/Menu.vue @@ -1,82 +1,82 @@ diff --git a/src/resources/vue/Widgets/Status.vue b/src/resources/vue/Widgets/Status.vue index 2549569f..241f1794 100644 --- a/src/resources/vue/Widgets/Status.vue +++ b/src/resources/vue/Widgets/Status.vue @@ -1,177 +1,185 @@ diff --git a/src/resources/vue/Widgets/TransactionLog.vue b/src/resources/vue/Widgets/TransactionLog.vue index 6fb4e356..3e725251 100644 --- a/src/resources/vue/Widgets/TransactionLog.vue +++ b/src/resources/vue/Widgets/TransactionLog.vue @@ -1,122 +1,122 @@ diff --git a/src/routes/api.php b/src/routes/api.php index 475ae1f9..81b6166d 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -1,128 +1,134 @@ 'api', - 'prefix' => 'auth' + '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' => 'auth' + '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::post('signup/verify', 'API\SignupController@verify'); Route::post('signup', 'API\SignupController@signup'); } ); Route::group( [ 'domain' => \config('app.domain'), 'middleware' => 'auth:api', - 'prefix' => 'v4' + '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('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}/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::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('openvidu/rooms', 'API\V4\OpenViduController@index'); Route::post('openvidu/rooms/{id}/close', 'API\V4\OpenViduController@closeRoom'); } ); // Note: In Laravel 7.x we could just use withoutMiddleware() instead of a separate group Route::group( [ 'domain' => \config('app.domain'), 'prefix' => 'v4' ], function () { Route::get('openvidu/rooms/{id}', 'API\V4\OpenViduController@joinRoom'); } ); Route::group( [ 'domain' => \config('app.domain'), + 'prefix' => $prefix . 'api/webhooks', ], function () { - Route::post('webhooks/payment/{provider}', 'API\V4\PaymentsController@webhook'); + Route::post('payment/{provider}', 'API\V4\PaymentsController@webhook'); Route::post('webhooks/meet/openvidu', 'API\V4\OpenViduController@webhook'); } ); Route::group( [ 'domain' => 'admin.' . \config('app.domain'), 'middleware' => ['auth:api', 'admin'], - 'prefix' => 'v4', + '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('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::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); } ); diff --git a/src/routes/web.php b/src/routes/web.php index 42289e4d..0d3adebc 100644 --- a/src/routes/web.php +++ b/src/routes/web.php @@ -1,11 +1,18 @@ \config('app.domain'), + ], function () { - $env = \App\Utils::uiEnv(); - return view($env['view'])->with('env', $env); + Route::fallback( + function () { + $env = \App\Utils::uiEnv(); + return view($env['view'])->with('env', $env); + } + ); } ); diff --git a/src/tests/Browser.php b/src/tests/Browser.php index 830b2202..e319856f 100644 --- a/src/tests/Browser.php +++ b/src/tests/Browser.php @@ -1,207 +1,245 @@ elements($selector); $count = count($elements); if ($visible) { foreach ($elements as $element) { if (!$element->isDisplayed()) { $count--; } } } Assert::assertEquals($expected_count, $count, "Count of [$selector] elements is not $expected_count"); return $this; } /** * Assert Tip element content */ public function assertTip($selector, $content) { return $this->click($selector) ->withinBody(function ($browser) use ($content) { $browser->waitFor('div.tooltip .tooltip-inner') ->assertSeeIn('div.tooltip .tooltip-inner', $content); }) ->click($selector); } /** * Assert Toast element content (and close it) */ public function assertToast(string $type, string $message, $title = null) { return $this->withinBody(function ($browser) use ($type, $title, $message) { $browser->with(new Toast($type), function (Browser $browser) use ($title, $message) { $browser->assertToastTitle($title) ->assertToastMessage($message) ->closeToast(); }); }); } /** * Assert specified error page is displayed. */ public function assertErrorPage(int $error_code) { $this->with(new Error($error_code), function ($browser) { // empty, assertions will be made by the Error component itself }); return $this; } /** * Assert that the given element has specified class assigned. */ public function assertHasClass($selector, $class_name) { $element = $this->resolver->findOrFail($selector); $classes = explode(' ', (string) $element->getAttribute('class')); Assert::assertContains($class_name, $classes, "[$selector] has no class '{$class_name}'"); return $this; } /** * Assert that the given element is readonly */ public function assertReadonly($selector) { $element = $this->resolver->findOrFail($selector); $value = $element->getAttribute('readonly'); Assert::assertTrue($value == 'true', "Element [$selector] is not readonly"); return $this; } /** * Assert that the given element is not readonly */ public function assertNotReadonly($selector) { $element = $this->resolver->findOrFail($selector); $value = $element->getAttribute('readonly'); Assert::assertTrue($value != 'true', "Element [$selector] is not readonly"); return $this; } /** * Assert that the given element contains specified text, * no matter it's displayed or not. */ public function assertText($selector, $text) { $element = $this->resolver->findOrFail($selector); - Assert::assertTrue(strpos($element->getText(), $text) !== false, "No expected text in [$selector]"); + if ($text === '') { + Assert::assertTrue((string) $element->getText() === $text, "Element's text is not empty [$selector]"); + } else { + Assert::assertTrue(strpos($element->getText(), $text) !== false, "No expected text in [$selector]"); + } + + return $this; + } + + /** + * Assert that the given element contains specified text, + * no matter it's displayed or not - using a regular expression. + */ + public function assertTextRegExp($selector, $regexp) + { + $element = $this->resolver->findOrFail($selector); + + Assert::assertRegExp($regexp, $element->getText(), "No expected text in [$selector]"); return $this; } /** * Remove all toast messages */ public function clearToasts() { $this->script("jQuery('.toast-container > *').remove()"); return $this; } /** * Check if in Phone mode */ public static function isPhone() { return getenv('TESTS_MODE') == 'phone'; } /** * Check if in Tablet mode */ public static function isTablet() { return getenv('TESTS_MODE') == 'tablet'; } /** * Check if in Desktop mode */ public static function isDesktop() { return !self::isPhone() && !self::isTablet(); } /** * Returns content of a downloaded file */ public function readDownloadedFile($filename, $sleep = 5) { $filename = __DIR__ . "/Browser/downloads/$filename"; // Give the browser a chance to finish download if (!file_exists($filename) && $sleep) { sleep($sleep); } Assert::assertFileExists($filename); return file_get_contents($filename); } /** * Removes downloaded file */ public function removeDownloadedFile($filename) { @unlink(__DIR__ . "/Browser/downloads/$filename"); return $this; } + /** + * Clears the input field and related vue v-model data. + */ + public function vueClear($selector) + { + if ($this->resolver->prefix != 'body') { + $selector = $this->resolver->prefix . ' ' . $selector; + } + + // The existing clear(), and type() with empty string do not work. + // We have to clear the field and dispatch 'input' event programatically. + + $this->script( + "var element = document.querySelector('$selector');" + . "element.value = '';" + . "element.dispatchEvent(new Event('input'))" + ); + + return $this; + } + /** * Execute code within body context. * Useful to execute code that selects elements outside of a component context */ public function withinBody($callback) { if ($this->resolver->prefix != 'body') { $orig_prefix = $this->resolver->prefix; $this->resolver->prefix = 'body'; } call_user_func($callback, $this); if (isset($orig_prefix)) { $this->resolver->prefix = $orig_prefix; } return $this; } } diff --git a/src/tests/Browser/Admin/DashboardTest.php b/src/tests/Browser/Admin/DashboardTest.php index 7e1f480f..a6077e53 100644 --- a/src/tests/Browser/Admin/DashboardTest.php +++ b/src/tests/Browser/Admin/DashboardTest.php @@ -1,77 +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('jeroen@jeroen.jeroen', 'jeroen', 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) { - $browser->assertElementsCount('tbody tr', 2); - // TODO: Assert table content + ->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) - ->waitFor('#user-info') - ->assertSeeIn('#user-info .card-title', $john->email); + ->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('jeroen@jeroen.jeroen', 'jeroen', 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/Admin/DomainTest.php b/src/tests/Browser/Admin/DomainTest.php index e6055a5d..109c55bd 100644 --- a/src/tests/Browser/Admin/DomainTest.php +++ b/src/tests/Browser/Admin/DomainTest.php @@ -1,89 +1,119 @@ browse(function (Browser $browser) { $domain = $this->getTestDomain('kolab.org'); $browser->visit('/domain/' . $domain->id)->on(new Home()); }); } /** * Test domain info page */ public function testDomainInfo(): void { $this->browse(function (Browser $browser) { $domain = $this->getTestDomain('kolab.org'); $domain_page = new DomainPage($domain->id); $john = $this->getTestUser('john@kolab.org'); $user_page = new UserPage($john->id); // Goto the domain page $browser->visit(new Home()) ->submitLogon('jeroen@jeroen.jeroen', 'jeroen', 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/Admin/UserFinancesTest.php b/src/tests/Browser/Admin/UserFinancesTest.php index 3a64fdaa..0ff4bd73 100644 --- a/src/tests/Browser/Admin/UserFinancesTest.php +++ b/src/tests/Browser/Admin/UserFinancesTest.php @@ -1,314 +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'); - $jack->wallets()->first()->transactions()->delete(); + $wallet = $jack->wallets()->first(); + $wallet->transactions()->delete(); + $wallet->setSetting('stripe_id', 'abc'); $page = new UserPage($jack->id); $browser->visit(new Home()) ->submitLogon('jeroen@jeroen.jeroen', 'jeroen', 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) { - $payment_provider = ucfirst(\config('services.payment_provider')); $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', $payment_provider . ' ID') - ->assertVisible('.row:nth-child(2) a'); + ->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', 2) + $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') - ->assertSeeIn('tbody tr:last-child td.email', 'jeroen@jeroen.jeroen'); + ->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(3) td:first-child a') + ->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', 2) + $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.email', 'jeroen@jeroen.jeroen') ->assertSeeIn('tbody tr:first-child td.price', '12,34 CHF'); + + if (!$browser->isPhone()) { + $browser->assertSeeIn('tbody tr:first-child td.email', 'jeroen@jeroen.jeroen'); + } }); $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/Admin/UserTest.php b/src/tests/Browser/Admin/UserTest.php index eef99650..0755a234 100644 --- a/src/tests/Browser/Admin/UserTest.php +++ b/src/tests/Browser/Admin/UserTest.php @@ -1,399 +1,439 @@ 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(); } /** * {@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(); 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('jeroen@jeroen.jeroen', 'jeroen', 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 of America'); + ->assertSeeIn('.row:nth-child(7) #country', 'United States'); }); // Some tabs are loaded in background, wait a second $browser->pause(500) ->assertElementsCount('@nav a', 5); // 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('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.'); }); }); } /** * 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(); // 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 of America'); + ->assertSeeIn('.row:nth-child(9) #country', 'United States'); }); // Some tabs are loaded in background, wait a second $browser->pause(500) ->assertElementsCount('@nav a', 5); // 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 Users tab - $browser->assertSeeIn('@nav #tab-users', 'Users (3)') + $browser->assertSeeIn('@nav #tab-users', 'Users (4)') ->click('@nav #tab-users') ->with('@user-users table', function (Browser $browser) { - $browser->assertElementsCount('tbody tr', 3) + $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 a', 'ned@kolab.org') + ->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(3) td:first-child a') + $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', 5); // 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('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.'); }); }); } /** * 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/DomainTest.php b/src/tests/Browser/DomainTest.php index a13078bc..2462458a 100644 --- a/src/tests/Browser/DomainTest.php +++ b/src/tests/Browser/DomainTest.php @@ -1,134 +1,163 @@ browse(function ($browser) { $browser->visit('/domain/123')->on(new Home()); }); } /** * Test domain info page (non-existing domain id) */ public function testDomainInfo404(): void { $this->browse(function ($browser) { // FIXME: I couldn't make loginAs() method working // Note: Here we're also testing that unauthenticated request // is passed to logon form and then "redirected" to the requested page $browser->visit('/domain/123') ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123') ->assertErrorPage(404); }); } /** * Test domain info page (existing domain) * * @depends testDomainInfo404 */ public function testDomainInfo(): void { $this->browse(function ($browser) { // Unconfirmed domain $domain = Domain::where('namespace', 'kolab.org')->first(); if ($domain->isConfirmed()) { $domain->status ^= Domain::STATUS_CONFIRMED; $domain->save(); } $browser->visit('/domain/' . $domain->id) ->on(new DomainInfo()) ->whenAvailable('@verify', function ($browser) use ($domain) { $browser->assertSeeIn('pre', $domain->namespace) ->assertSeeIn('pre', $domain->hash()) ->click('button') ->assertToast(Toast::TYPE_ERROR, 'Domain ownership verification failed.'); // Make sure the domain is confirmed now $domain->status |= Domain::STATUS_CONFIRMED; $domain->save(); $browser->click('button') ->assertToast(Toast::TYPE_SUCCESS, 'Domain verified successfully.'); }) ->whenAvailable('@config', function ($browser) use ($domain) { $browser->assertSeeIn('pre', $domain->namespace); }) ->assertMissing('@verify'); // Check that confirmed domain page contains only the config box $browser->visit('/domain/' . $domain->id) ->on(new DomainInfo()) ->assertMissing('@verify') ->assertPresent('@config'); }); } /** * Test domains list page (unauthenticated) */ public function testDomainListUnauth(): void { // Test that the page requires authentication $this->browse(function ($browser) { $browser->visit('/logout') ->visit('/domains') ->on(new Home()); }); } /** * Test domains list page * * @depends testDomainListUnauth */ public function testDomainList(): void { $this->browse(function ($browser) { // Login the user $browser->visit('/login') ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) // On dashboard click the "Domains" link ->on(new Dashboard()) ->assertSeeIn('@links a.link-domains', 'Domains') ->click('@links a.link-domains') // On Domains List page click the domain entry ->on(new DomainList()) + ->waitFor('@table tbody tr') ->assertVisible('@table tbody tr:first-child td:first-child svg.fa-globe.text-success') ->assertText('@table tbody tr:first-child td:first-child svg title', 'Active') ->assertSeeIn('@table tbody tr:first-child td:first-child', 'kolab.org') + ->assertMissing('@table tfoot') ->click('@table tbody tr:first-child td:first-child a') // On Domain Info page verify that's the clicked domain ->on(new DomainInfo()) ->whenAvailable('@config', function ($browser) { $browser->assertSeeIn('pre', 'kolab.org'); }); }); // TODO: Test domains list acting as Ned (John's "delegatee") } + + /** + * Test domains list page (user with no domains) + */ + public function testDomainListEmpty(): void + { + $this->browse(function ($browser) { + // Login the user + $browser->visit('/login') + ->on(new Home()) + ->submitLogon('jack@kolab.org', 'simple123', true) + ->on(new Dashboard()) + ->assertVisible('@links a.link-profile') + ->assertMissing('@links a.link-domains') + ->assertMissing('@links a.link-users') + ->assertMissing('@links a.link-wallet'); +/* + // On dashboard click the "Domains" link + ->assertSeeIn('@links a.link-domains', 'Domains') + ->click('@links a.link-domains') + // On Domains List page click the domain entry + ->on(new DomainList()) + ->assertMissing('@table tbody') + ->assertSeeIn('tfoot td', 'There are no domains in this account.'); +*/ + }); + } } diff --git a/src/tests/Browser/LogonTest.php b/src/tests/Browser/LogonTest.php index 1d4eb6df..6ca98b9c 100644 --- a/src/tests/Browser/LogonTest.php +++ b/src/tests/Browser/LogonTest.php @@ -1,207 +1,211 @@ browse(function (Browser $browser) { $browser->visit(new Home()) ->within(new Menu(), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'webmail']); }); if ($browser->isDesktop()) { $browser->within(new Menu('footer'), function ($browser) { $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'tos', 'webmail']); }); } else { $browser->assertMissing('#footer-menu .navbar-nav'); } }); } /** * 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('john@kolab.org', 'wrong'); // Error message $browser->assertToast(Toast::TYPE_ERROR, 'Invalid username or password.'); // Checks if we're still on the logon page $browser->on(new Home()); }); } /** * Successful logon test */ public function testLogonSuccessful(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) - ->submitLogon('john@kolab.org', 'simple123', true); - - // Checks if we're really on Dashboard page - $browser->on(new Dashboard()) + ->submitLogon('john@kolab.org', 'simple123', true) + // Checks if we're really on Dashboard page + ->on(new Dashboard()) + ->assertVisible('@links a.link-profile') + ->assertVisible('@links a.link-domains') + ->assertVisible('@links a.link-users') + ->assertVisible('@links a.link-wallet') ->within(new Menu(), function ($browser) { $browser->assertMenuItems(['support', 'contact', 'webmail', 'logout']); }); if ($browser->isDesktop()) { $browser->within(new Menu('footer'), function ($browser) { $browser->assertMenuItems(['support', 'contact', 'webmail', 'logout']); }); } else { $browser->assertMissing('#footer-menu .navbar-nav'); } $browser->assertUser('john@kolab.org'); // Assert no "Account status" for this account $browser->assertMissing('@status'); // Goto /domains and assert that the link on logo element // leads to the dashboard $browser->visit('/domains') ->waitForText('Domains') ->click('a.navbar-brand') ->on(new Dashboard()); // 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(['signup', 'explore', 'blog', 'support', 'webmail']); }); // 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('john@kolab.org', 'simple123', 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(['signup', 'explore', 'blog', 'support', 'webmail']); }); // Success toast message $browser->assertToast(Toast::TYPE_SUCCESS, 'Successfully logged out'); }); } /** * Test 2-Factor Authentication * * @depends testLogoutByURL */ public function test2FA(): void { $this->browse(function (Browser $browser) { // Test missing 2fa code $browser->on(new Home()) ->type('@email-input', 'ned@kolab.org') ->type('@password-input', 'simple123') ->press('form button') ->waitFor('@second-factor-input.is-invalid + .invalid-feedback') ->assertSeeIn( '@second-factor-input.is-invalid + .invalid-feedback', 'Second factor code is required.' ) ->assertFocused('@second-factor-input') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); // Test invalid code $browser->type('@second-factor-input', '123456') ->press('form button') ->waitUntilMissing('@second-factor-input.is-invalid') ->waitFor('@second-factor-input.is-invalid + .invalid-feedback') ->assertSeeIn( '@second-factor-input.is-invalid + .invalid-feedback', 'Second factor code is invalid.' ) ->assertFocused('@second-factor-input') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); $code = \App\Auth\SecondFactor::code('ned@kolab.org'); // Test valid (TOTP) code $browser->type('@second-factor-input', $code) ->press('form button') ->waitUntilMissing('@second-factor-input.is-invalid') - ->waitForLocation('/dashboard')->on(new Dashboard()); + ->waitForLocation('/dashboard') + ->on(new Dashboard()); }); } } diff --git a/src/tests/Browser/Pages/PaymentStripe.php b/src/tests/Browser/Pages/PaymentStripe.php index 761c6333..f00067c0 100644 --- a/src/tests/Browser/Pages/PaymentStripe.php +++ b/src/tests/Browser/Pages/PaymentStripe.php @@ -1,67 +1,67 @@ waitFor('.App-Payment'); } /** * Get the element shortcuts for the page. * * @return array */ public function elements(): array { return [ '@form' => '.App-Payment > form', - '@title' => '.App-Overview .ProductSummary-Info .Text', - '@amount' => '#ProductSummary-TotalAmount', + '@title' => '.App-Overview .ProductSummary', + '@amount' => '#ProductSummary-totalAmount', '@description' => '#ProductSummary-Description', '@email-input' => '.App-Payment #email', '@cardnumber-input' => '.App-Payment #cardNumber', '@cardexpiry-input' => '.App-Payment #cardExpiry', '@cardcvc-input' => '.App-Payment #cardCvc', '@name-input' => '.App-Payment #billingName', '@submit-button' => '.App-Payment form button.SubmitButton', ]; } /** * Submit payment form. * * @param \Laravel\Dusk\Browser $browser The browser object * * @return void */ public function submitValidCreditCard($browser) { $browser->type('@name-input', 'Test') ->typeSlowly('@cardnumber-input', '4242424242424242', 50) ->type('@cardexpiry-input', '12/' . (intval(date('y')) + 1)) ->type('@cardcvc-input', '123') ->press('@submit-button'); } } diff --git a/src/tests/Browser/Pages/UserInfo.php b/src/tests/Browser/Pages/UserInfo.php index 2a283c03..53046e82 100644 --- a/src/tests/Browser/Pages/UserInfo.php +++ b/src/tests/Browser/Pages/UserInfo.php @@ -1,46 +1,47 @@ waitFor('@form'); + $browser->waitFor('@form') + ->waitUntilMissing('.app-loader'); } /** * Get the element shortcuts for the page. * * @return array */ public function elements(): array { return [ '@app' => '#app', '@form' => '#user-info form', '@packages' => '#user-packages', '@skus' => '#user-skus', '@status' => '#status-box', ]; } } diff --git a/src/tests/Browser/PasswordResetTest.php b/src/tests/Browser/PasswordResetTest.php index f3545a18..64762b8d 100644 --- a/src/tests/Browser/PasswordResetTest.php +++ b/src/tests/Browser/PasswordResetTest.php @@ -1,274 +1,276 @@ deleteTestUser('passwordresettestdusk@' . \config('app.domain')); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('passwordresettestdusk@' . \config('app.domain')); parent::tearDown(); } /** * Test the link from logon to password-reset page */ public function testPasswordResetLinkOnLogon(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()); $browser->assertSeeLink('Forgot password?'); $browser->clickLink('Forgot password?'); $browser->on(new PasswordReset()); $browser->assertVisible('@step1'); }); } /** * Test 1st step of password-reset */ public function testPasswordResetStep1(): void { $user = $this->getTestUser('passwordresettestdusk@' . \config('app.domain')); $user->setSetting('external_email', 'external@domain.tld'); $this->browse(function (Browser $browser) { $browser->visit(new PasswordReset()); $browser->assertVisible('@step1'); // Here we expect email input and submit button $browser->with('@step1', function ($step) { $step->assertVisible('#reset_email'); $step->assertFocused('#reset_email'); $step->assertVisible('[type=submit]'); }); // Submit empty form $browser->with('@step1', function ($step) { $step->click('[type=submit]'); $step->assertFocused('#reset_email'); }); // Submit invalid email // We expect email input to have is-invalid class added, with .invalid-feedback element $browser->with('@step1', function ($step) use ($browser) { $step->type('#reset_email', '@test'); $step->click('[type=submit]'); $step->waitFor('#reset_email.is-invalid'); $step->waitFor('#reset_email + .invalid-feedback'); $browser->waitFor('.toast-error'); $browser->click('.toast-error'); // remove the toast }); // Submit valid data $browser->with('@step1', function ($step) { $step->type('#reset_email', 'passwordresettestdusk@' . \config('app.domain')); $step->click('[type=submit]'); $step->assertMissing('#reset_email.is-invalid'); $step->assertMissing('#reset_email + .invalid-feedback'); }); $browser->waitUntilMissing('@step2 #reset_code[value=""]'); $browser->waitFor('@step2'); $browser->assertMissing('@step1'); }); } /** * Test 2nd Step of the password reset process * * @depends testPasswordResetStep1 */ public function testPasswordResetStep2(): void { $user = $this->getTestUser('passwordresettestdusk@' . \config('app.domain')); $user->setSetting('external_email', 'external@domain.tld'); $this->browse(function (Browser $browser) { $browser->assertVisible('@step2'); // Here we expect one text input, Back and Continue buttons $browser->with('@step2', function ($step) { $step->assertVisible('#reset_short_code'); $step->assertFocused('#reset_short_code'); $step->assertVisible('[type=button]'); $step->assertVisible('[type=submit]'); }); // Test Back button functionality $browser->click('@step2 [type=button]'); $browser->waitFor('@step1'); $browser->assertFocused('@step1 #reset_email'); $browser->assertMissing('@step2'); // Submit valid Step 1 data (again) $browser->with('@step1', function ($step) { $step->type('#reset_email', 'passwordresettestdusk@' . \config('app.domain')); $step->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) use ($browser) { $step->type('#reset_short_code', 'XXXXX'); $step->click('[type=submit]'); $browser->waitFor('.toast-error'); - $step->assertVisible('#reset_short_code.is-invalid'); - $step->assertVisible('#reset_short_code + .invalid-feedback'); - $step->assertFocused('#reset_short_code'); + $step->waitFor('#reset_short_code.is-invalid') + ->assertVisible('#reset_short_code.is-invalid') + ->assertVisible('#reset_short_code + .invalid-feedback') + ->assertFocused('#reset_short_code'); $browser->click('.toast-error'); // remove the toast }); // 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('#reset_code'); $this->assertNotEmpty($code); $code = VerificationCode::find($code); $step->type('#reset_short_code', $code->short_code); $step->click('[type=submit]'); $step->assertMissing('#reset_short_code.is-invalid'); $step->assertMissing('#reset_short_code + .invalid-feedback'); }); $browser->waitFor('@step3'); $browser->assertMissing('@step2'); }); } /** * Test 3rd Step of the password reset process * * @depends testPasswordResetStep2 */ public function testPasswordResetStep3(): void { $user = $this->getTestUser('passwordresettestdusk@' . \config('app.domain')); $user->setSetting('external_email', 'external@domain.tld'); $this->browse(function (Browser $browser) { $browser->assertVisible('@step3'); // Here we expect 2 text inputs, Back and Continue buttons $browser->with('@step3', function ($step) { $step->assertVisible('#reset_password'); $step->assertVisible('#reset_confirm'); $step->assertVisible('[type=button]'); $step->assertVisible('[type=submit]'); $step->assertFocused('#reset_password'); }); // Test Back button $browser->click('@step3 [type=button]'); $browser->waitFor('@step2'); $browser->assertFocused('@step2 #reset_short_code'); $browser->assertMissing('@step3'); $browser->assertMissing('@step1'); // TODO: Test form reset when going back // Because the verification code is removed in tearDown() // we'll start from the beginning (Step 1) $browser->click('@step2 [type=button]'); $browser->waitFor('@step1'); $browser->assertFocused('@step1 #reset_email'); $browser->assertMissing('@step3'); $browser->assertMissing('@step2'); // Submit valid data $browser->with('@step1', function ($step) { $step->type('#reset_email', 'passwordresettestdusk@' . \config('app.domain')); $step->click('[type=submit]'); }); $browser->waitFor('@step2'); $browser->waitUntilMissing('@step2 #reset_code[value=""]'); // Submit valid code again $browser->with('@step2', function ($step) { $code = $step->value('#reset_code'); $this->assertNotEmpty($code); $code = VerificationCode::find($code); $step->type('#reset_short_code', $code->short_code); $step->click('[type=submit]'); }); $browser->waitFor('@step3'); // Submit invalid data $browser->with('@step3', function ($step) use ($browser) { $step->assertFocused('#reset_password'); $step->type('#reset_password', '12345678'); $step->type('#reset_confirm', '123456789'); $step->click('[type=submit]'); $browser->waitFor('.toast-error'); - $step->assertVisible('#reset_password.is-invalid'); - $step->assertVisible('#reset_password + .invalid-feedback'); - $step->assertFocused('#reset_password'); + $step->waitFor('#reset_password.is-invalid') + ->assertVisible('#reset_password.is-invalid') + ->assertVisible('#reset_password + .invalid-feedback') + ->assertFocused('#reset_password'); $browser->click('.toast-error'); // remove the toast }); // Submit valid data $browser->with('@step3', function ($step) { $step->type('#reset_confirm', '12345678'); $step->click('[type=submit]'); }); $browser->waitUntilMissing('@step3'); // At this point we should be auto-logged-in to dashboard $browser->on(new Dashboard()); // FIXME: Is it enough to be sure user is logged in? }); } } diff --git a/src/tests/Browser/SignupTest.php b/src/tests/Browser/SignupTest.php index d7f2aebe..d8ba4bb2 100644 --- a/src/tests/Browser/SignupTest.php +++ b/src/tests/Browser/SignupTest.php @@ -1,536 +1,545 @@ deleteTestUser('signuptestdusk@' . \config('app.domain')); $this->deleteTestUser('admin@user-domain-signup.com'); $this->deleteTestDomain('user-domain-signup.com'); } public function tearDown(): void { $this->deleteTestUser('signuptestdusk@' . \config('app.domain')); $this->deleteTestUser('admin@user-domain-signup.com'); $this->deleteTestDomain('user-domain-signup.com'); 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([ 'data' => [ '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'], 'signup'); }); $browser->waitFor('@step0 .plan-selector > .plan-box'); // 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'], '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', ''); // 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')); + ->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'); + ->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); } } diff --git a/src/tests/Browser/StatusTest.php b/src/tests/Browser/StatusTest.php index 3ae338cf..0a312d6c 100644 --- a/src/tests/Browser/StatusTest.php +++ b/src/tests/Browser/StatusTest.php @@ -1,268 +1,270 @@ first(); if ($domain->isConfirmed()) { $domain->status ^= Domain::STATUS_CONFIRMED; $domain->save(); } $john = $this->getTestUser('john@kolab.org'); $john->created_at = Carbon::now(); if ($john->isImapReady()) { $john->status ^= User::STATUS_IMAP_READY; } $john->save(); $this->browse(function ($browser) use ($john, $domain) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->on(new Dashboard()) ->with(new Status(), function ($browser) use ($john) { $browser->assertSeeIn('@body', 'We are preparing your account') - ->assertProgress(28, 'Creating a mailbox...', 'pending') + ->assertProgress(71, 'Creating a mailbox...', 'pending') ->assertMissing('#status-verify') ->assertMissing('#status-link') ->assertMissing('@refresh-button') ->assertMissing('@refresh-text'); $john->status |= User::STATUS_IMAP_READY; $john->save(); // Wait for auto-refresh, expect domain-confirmed step $browser->pause(6000) ->assertSeeIn('@body', 'Your account is almost ready') ->assertProgress(85, 'Verifying an ownership of a custom domain...', 'failed') ->assertMissing('@refresh-button') ->assertMissing('@refresh-text') ->assertMissing('#status-verify') ->assertVisible('#status-link'); }) // check if the link to domain info page works ->click('#status-link') ->on(new DomainInfo()) ->back() ->on(new Dashboard()) ->with(new Status(), function ($browser) { $browser->assertMissing('@refresh-button') ->assertProgress(85, 'Verifying an ownership of a custom domain...', 'failed'); }); // Confirm the domain and wait until the whole status box disappears $domain->status |= Domain::STATUS_CONFIRMED; $domain->save(); // This should take less than 10 seconds $browser->waitUntilMissing('@status', 10); }); // Test the Refresh button if ($domain->isConfirmed()) { $domain->status ^= Domain::STATUS_CONFIRMED; $domain->save(); } $john->created_at = Carbon::now()->subSeconds(3600); if ($john->isImapReady()) { $john->status ^= User::STATUS_IMAP_READY; } $john->save(); $this->browse(function ($browser) use ($john, $domain) { $browser->visit(new Dashboard()) ->with(new Status(), function ($browser) use ($john, $domain) { $browser->assertSeeIn('@body', 'We are preparing your account') - ->assertProgress(28, 'Creating a mailbox...', 'failed') + ->assertProgress(71, 'Creating a mailbox...', 'failed') ->assertVisible('@refresh-button') ->assertVisible('@refresh-text'); if ($john->refresh()->isImapReady()) { $john->status ^= User::STATUS_IMAP_READY; $john->save(); } $domain->status |= Domain::STATUS_CONFIRMED; $domain->save(); $browser->click('@refresh-button') ->assertToast(Toast::TYPE_SUCCESS, 'Setup process finished successfully.'); }) ->assertMissing('@status'); }); } /** * Test domain status on domains list and domain info page * * @depends testDashboard */ public function testDomainStatus(): void { $domain = Domain::where('namespace', 'kolab.org')->first(); $domain->created_at = Carbon::now(); $domain->status = Domain::STATUS_NEW | Domain::STATUS_ACTIVE | Domain::STATUS_LDAP_READY; $domain->save(); $this->browse(function ($browser) use ($domain) { // Test auto-refresh $browser->on(new Dashboard()) ->click('@links a.link-domains') ->on(new DomainList()) + ->waitFor('@table tbody tr') // Assert domain status icon ->assertVisible('@table tbody tr:first-child td:first-child svg.fa-globe.text-danger') ->assertText('@table tbody tr:first-child td:first-child svg title', 'Not Ready') ->click('@table tbody tr:first-child td:first-child a') ->on(new DomainInfo()) ->with(new Status(), function ($browser) { $browser->assertSeeIn('@body', 'We are preparing the domain') ->assertProgress(50, 'Verifying a custom domain...', 'pending') ->assertMissing('@refresh-button') ->assertMissing('@refresh-text') ->assertMissing('#status-link') ->assertMissing('#status-verify'); }); $domain->status |= Domain::STATUS_VERIFIED; $domain->save(); // This should take less than 10 seconds $browser->waitFor('@status.process-failed') ->with(new Status(), function ($browser) { $browser->assertSeeIn('@body', 'The domain is almost ready') ->assertProgress(75, 'Verifying an ownership of a custom domain...', 'failed') ->assertMissing('@refresh-button') ->assertMissing('@refresh-text') ->assertMissing('#status-link') ->assertVisible('#status-verify'); }); $domain->status |= Domain::STATUS_CONFIRMED; $domain->save(); // Test Verify button $browser->click('@status #status-verify') ->assertToast(Toast::TYPE_SUCCESS, 'Domain verified successfully.') ->waitUntilMissing('@status') ->assertMissing('@verify') ->assertVisible('@config'); }); } /** * Test user status on users list and user info page * * @depends testDashboard */ public function testUserStatus(): void { $john = $this->getTestUser('john@kolab.org'); $john->created_at = Carbon::now(); if ($john->isImapReady()) { $john->status ^= User::STATUS_IMAP_READY; } $john->save(); $domain = Domain::where('namespace', 'kolab.org')->first(); if ($domain->isConfirmed()) { $domain->status ^= Domain::STATUS_CONFIRMED; $domain->save(); } $this->browse(function ($browser) use ($john, $domain) { $browser->visit(new Dashboard()) ->click('@links a.link-users') ->on(new UserList()) + ->waitFor('@table tbody tr') // Assert user status icons ->assertVisible('@table tbody tr:first-child td:first-child svg.fa-user.text-success') ->assertText('@table tbody tr:first-child td:first-child svg title', 'Active') ->assertVisible('@table tbody tr:nth-child(3) td:first-child svg.fa-user.text-danger') ->assertText('@table tbody tr:nth-child(3) td:first-child svg title', 'Not Ready') ->click('@table tbody tr:nth-child(3) td:first-child a') ->on(new UserInfo()) ->with('@form', function (Browser $browser) { // Assert state in the user edit form $browser->assertSeeIn('div.row:nth-child(1) label', 'Status') ->assertSeeIn('div.row:nth-child(1) #status', 'Not Ready'); }) ->with(new Status(), function ($browser) use ($john) { $browser->assertSeeIn('@body', 'We are preparing the user account') - ->assertProgress(28, 'Creating a mailbox...', 'pending') + ->assertProgress(71, 'Creating a mailbox...', 'pending') ->assertMissing('#status-verify') ->assertMissing('#status-link') ->assertMissing('@refresh-button') ->assertMissing('@refresh-text'); $john->status |= User::STATUS_IMAP_READY; $john->save(); // Wait for auto-refresh, expect domain-confirmed step $browser->pause(6000) ->assertSeeIn('@body', 'The user account is almost ready') ->assertProgress(85, 'Verifying an ownership of a custom domain...', 'failed') ->assertMissing('@refresh-button') ->assertMissing('@refresh-text') ->assertMissing('#status-verify') ->assertVisible('#status-link'); }) ->assertSeeIn('#status', 'Active'); // Confirm the domain and wait until the whole status box disappears $domain->status |= Domain::STATUS_CONFIRMED; $domain->save(); // This should take less than 10 seconds $browser->waitUntilMissing('@status', 10); }); } } diff --git a/src/tests/Browser/UserProfileTest.php b/src/tests/Browser/UserProfileTest.php index 40dd59e7..bbd5d2a2 100644 --- a/src/tests/Browser/UserProfileTest.php +++ b/src/tests/Browser/UserProfileTest.php @@ -1,191 +1,194 @@ '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', 'phone' => '+1 509-248-1111', 'organization' => 'Kolab Developers', ]; /** * {@inheritDoc} */ public function setUp(): void { parent::setUp(); User::where('email', 'john@kolab.org')->first()->setSettings($this->profile); $this->deleteTestUser('profile-delete@kolabnow.com'); } /** * {@inheritDoc} */ public function tearDown(): void { User::where('email', 'john@kolab.org')->first()->setSettings($this->profile); $this->deleteTestUser('profile-delete@kolabnow.com'); parent::tearDown(); } /** * Test profile page (unauthenticated) */ public function testProfileUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $browser->visit('/profile')->on(new Home()); }); } /** * Test profile page */ public function testProfile(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->on(new Dashboard()) ->assertSeeIn('@links .link-profile', 'Your profile') ->click('@links .link-profile') ->on(new UserProfile()) ->assertSeeIn('#user-profile .button-delete', 'Delete account') ->whenAvailable('@form', function (Browser $browser) { + $user = User::where('email', 'john@kolab.org')->first(); // Assert form content - $browser->assertFocused('div.row:nth-child(1) input') - ->assertSeeIn('div.row:nth-child(1) label', 'First name') - ->assertValue('div.row:nth-child(1) input[type=text]', $this->profile['first_name']) - ->assertSeeIn('div.row:nth-child(2) label', 'Last name') - ->assertValue('div.row:nth-child(2) input[type=text]', $this->profile['last_name']) - ->assertSeeIn('div.row:nth-child(3) label', 'Organization') - ->assertValue('div.row:nth-child(3) input[type=text]', $this->profile['organization']) - ->assertSeeIn('div.row:nth-child(4) label', 'Phone') - ->assertValue('div.row:nth-child(4) input[type=text]', $this->profile['phone']) - ->assertSeeIn('div.row:nth-child(5) label', 'External email') - ->assertValue('div.row:nth-child(5) input[type=text]', $this->profile['external_email']) - ->assertSeeIn('div.row:nth-child(6) label', 'Address') - ->assertValue('div.row:nth-child(6) textarea', $this->profile['billing_address']) - ->assertSeeIn('div.row:nth-child(7) label', 'Country') - ->assertValue('div.row:nth-child(7) select', $this->profile['country']) - ->assertSeeIn('div.row:nth-child(8) label', 'Password') - ->assertValue('div.row:nth-child(8) input[type=password]', '') - ->assertSeeIn('div.row:nth-child(9) label', 'Confirm password') + $browser->assertFocused('div.row:nth-child(2) input') + ->assertSeeIn('div.row:nth-child(1) label', 'Customer No.') + ->assertSeeIn('div.row:nth-child(1) .form-control-plaintext', $user->id) + ->assertSeeIn('div.row:nth-child(2) label', 'First name') + ->assertValue('div.row:nth-child(2) input[type=text]', $this->profile['first_name']) + ->assertSeeIn('div.row:nth-child(3) label', 'Last name') + ->assertValue('div.row:nth-child(3) input[type=text]', $this->profile['last_name']) + ->assertSeeIn('div.row:nth-child(4) label', 'Organization') + ->assertValue('div.row:nth-child(4) input[type=text]', $this->profile['organization']) + ->assertSeeIn('div.row:nth-child(5) label', 'Phone') + ->assertValue('div.row:nth-child(5) input[type=text]', $this->profile['phone']) + ->assertSeeIn('div.row:nth-child(6) label', 'External email') + ->assertValue('div.row:nth-child(6) input[type=text]', $this->profile['external_email']) + ->assertSeeIn('div.row:nth-child(7) label', 'Address') + ->assertValue('div.row:nth-child(7) textarea', $this->profile['billing_address']) + ->assertSeeIn('div.row:nth-child(8) label', 'Country') + ->assertValue('div.row:nth-child(8) select', $this->profile['country']) + ->assertSeeIn('div.row:nth-child(9) label', 'Password') ->assertValue('div.row:nth-child(9) input[type=password]', '') + ->assertSeeIn('div.row:nth-child(10) label', 'Confirm password') + ->assertValue('div.row:nth-child(10) input[type=password]', '') ->assertSeeIn('button[type=submit]', 'Submit'); + // Test form error handling + $browser->type('#phone', 'aaaaaa') + ->type('#external_email', 'bbbbb') + ->click('button[type=submit]') + ->waitFor('#phone + .invalid-feedback') + ->assertSeeIn('#phone + .invalid-feedback', 'The phone format is invalid.') + ->assertSeeIn( + '#external_email + .invalid-feedback', + 'The external email must be a valid email address.' + ) + ->assertFocused('#phone') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + ->clearToasts(); + // Clear all fields and submit // FIXME: Should any of these fields be required? - $browser->type('#first_name', '') - ->type('#last_name', '') - ->type('#organization', '') - ->type('#phone', '') - ->type('#external_email', '') - ->type('#billing_address', '') - ->select('#country', '') - ->click('button[type=submit]'); + $browser->vueClear('#first_name') + ->vueClear('#last_name') + ->vueClear('#organization') + ->vueClear('#phone') + ->vueClear('#external_email') + ->vueClear('#billing_address') + ->click('button[type=submit]') + ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); }) - ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); - - // Test error handling - $browser->with('@form', function (Browser $browser) { - $browser->type('#phone', 'aaaaaa') - ->type('#external_email', 'bbbbb') - ->click('button[type=submit]') - ->waitFor('#phone + .invalid-feedback') - ->assertSeeIn('#phone + .invalid-feedback', 'The phone format is invalid.') - ->assertSeeIn( - '#external_email + .invalid-feedback', - 'The external email must be a valid email address.' - ) - ->assertFocused('#phone') - ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); - }); + // On success we're redirected to Dashboard + ->on(new Dashboard()); }); } /** * Test profile of non-controller user */ public function testProfileNonController(): void { // Test acting as non-controller $this->browse(function (Browser $browser) { $browser->visit('/logout') ->visit(new Home()) ->submitLogon('jack@kolab.org', 'simple123', true) ->on(new Dashboard()) ->assertSeeIn('@links .link-profile', 'Your profile') ->click('@links .link-profile') ->on(new UserProfile()) ->assertMissing('#user-profile .button-delete') ->whenAvailable('@form', function (Browser $browser) { // TODO: decide on what fields the non-controller user should be able // to see/change }); // Test that /profile/delete page is not accessible $browser->visit('/profile/delete') ->assertErrorPage(403); }); } /** * Test profile delete page */ public function testProfileDelete(): void { $user = $this->getTestUser('profile-delete@kolabnow.com', ['password' => 'simple123']); $this->browse(function (Browser $browser) use ($user) { $browser->visit('/logout') ->on(new Home()) ->submitLogon('profile-delete@kolabnow.com', 'simple123', true) ->on(new Dashboard()) ->clearToasts() ->assertSeeIn('@links .link-profile', 'Your profile') ->click('@links .link-profile') ->on(new UserProfile()) ->click('#user-profile .button-delete') ->waitForLocation('/profile/delete') ->assertSeeIn('#user-delete .card-title', 'Delete this account?') ->assertSeeIn('#user-delete .button-cancel', 'Cancel') ->assertSeeIn('#user-delete .card-text', 'This operation is irreversible') ->assertFocused('#user-delete .button-cancel') ->click('#user-delete .button-cancel') ->waitForLocation('/profile') ->on(new UserProfile()); // Test deleting the user $browser->click('#user-profile .button-delete') ->waitForLocation('/profile/delete') ->click('#user-delete .button-delete') ->waitForLocation('/login') ->assertToast(Toast::TYPE_SUCCESS, 'User deleted successfully.'); $this->assertTrue($user->fresh()->trashed()); }); } // TODO: Test that Ned (John's "delegatee") can delete himself // TODO: Test that Ned (John's "delegatee") can/can't delete John ? } diff --git a/src/tests/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php index 361d6d2f..419b777f 100644 --- a/src/tests/Browser/UsersTest.php +++ b/src/tests/Browser/UsersTest.php @@ -1,560 +1,586 @@ 'John', 'last_name' => 'Doe', 'organization' => 'Kolab Developers', ]; /** * {@inheritDoc} */ public function setUp(): void { parent::setUp(); $this->deleteTestUser('julia.roberts@kolab.org'); $john = User::where('email', 'john@kolab.org')->first(); $john->setSettings($this->profile); UserAlias::where('user_id', $john->id) ->where('alias', 'john.test@kolab.org')->delete(); Entitlement::where('entitleable_id', $john->id)->whereIn('cost', [25, 100])->delete(); $wallet = $john->wallets()->first(); $wallet->discount()->dissociate(); $wallet->save(); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('julia.roberts@kolab.org'); $john = User::where('email', 'john@kolab.org')->first(); $john->setSettings($this->profile); UserAlias::where('user_id', $john->id) ->where('alias', 'john.test@kolab.org')->delete(); Entitlement::where('entitleable_id', $john->id)->whereIn('cost', [25, 100])->delete(); $wallet = $john->wallets()->first(); $wallet->discount()->dissociate(); $wallet->save(); parent::tearDown(); } /** * Test user info page (unauthenticated) */ public function testInfoUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $user = User::where('email', 'john@kolab.org')->first(); $browser->visit('/user/' . $user->id)->on(new Home()); }); } /** * Test users list page (unauthenticated) */ public function testListUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $browser->visit('/users')->on(new Home()); }); } /** * Test users list page */ public function testList(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->on(new Dashboard()) ->assertSeeIn('@links .link-users', 'User accounts') ->click('@links .link-users') ->on(new UserList()) ->whenAvailable('@table', function (Browser $browser) { - $browser->assertElementsCount('tbody tr', 4) + $browser->waitFor('tbody tr') + ->assertElementsCount('tbody tr', 4) ->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org') ->assertSeeIn('tbody tr:nth-child(2) a', 'joe@kolab.org') ->assertSeeIn('tbody tr:nth-child(3) a', 'john@kolab.org') ->assertSeeIn('tbody tr:nth-child(4) a', 'ned@kolab.org') ->assertVisible('tbody tr:nth-child(1) button.button-delete') ->assertVisible('tbody tr:nth-child(2) button.button-delete') ->assertVisible('tbody tr:nth-child(3) button.button-delete') - ->assertVisible('tbody tr:nth-child(4) button.button-delete'); + ->assertVisible('tbody tr:nth-child(4) button.button-delete') + ->assertMissing('tfoot'); }); }); } /** * Test user account editing page (not profile page) * * @depends testList */ public function testInfo(): void { $this->browse(function (Browser $browser) { $browser->on(new UserList()) ->click('@table tr:nth-child(3) a') ->on(new UserInfo()) ->assertSeeIn('#user-info .card-title', 'User account') ->with('@form', function (Browser $browser) { // Assert form content $browser->assertSeeIn('div.row:nth-child(1) label', 'Status') ->assertSeeIn('div.row:nth-child(1) #status', 'Active') ->assertFocused('div.row:nth-child(2) input') ->assertSeeIn('div.row:nth-child(2) label', 'First name') ->assertValue('div.row:nth-child(2) input[type=text]', $this->profile['first_name']) ->assertSeeIn('div.row:nth-child(3) label', 'Last name') ->assertValue('div.row:nth-child(3) input[type=text]', $this->profile['last_name']) ->assertSeeIn('div.row:nth-child(4) label', 'Organization') ->assertValue('div.row:nth-child(4) input[type=text]', $this->profile['organization']) ->assertSeeIn('div.row:nth-child(5) label', 'Email') ->assertValue('div.row:nth-child(5) input[type=text]', 'john@kolab.org') ->assertDisabled('div.row:nth-child(5) input[type=text]') ->assertSeeIn('div.row:nth-child(6) label', 'Email aliases') ->assertVisible('div.row:nth-child(6) .list-input') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertListInputValue(['john.doe@kolab.org']) ->assertValue('@input', ''); }) ->assertSeeIn('div.row:nth-child(7) label', 'Password') ->assertValue('div.row:nth-child(7) input[type=password]', '') ->assertSeeIn('div.row:nth-child(8) label', 'Confirm password') ->assertValue('div.row:nth-child(8) input[type=password]', '') - ->assertSeeIn('button[type=submit]', 'Submit'); - - // Clear some fields and submit - $browser->type('#first_name', '') - ->type('#last_name', '') + ->assertSeeIn('button[type=submit]', 'Submit') + // Clear some fields and submit + ->vueClear('#first_name') + ->vueClear('#last_name') ->click('button[type=submit]'); }) - ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); + ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.') + ->on(new UserList()) + ->click('@table tr:nth-child(3) a') + ->on(new UserInfo()) + ->assertSeeIn('#user-info .card-title', 'User account') + ->with('@form', function (Browser $browser) { + // Test error handling (password) + $browser->type('#password', 'aaaaaa') + ->vueClear('#password_confirmation') + ->click('button[type=submit]') + ->waitFor('#password + .invalid-feedback') + ->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.') + ->assertFocused('#password') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); - // Test error handling (password) - $browser->with('@form', function (Browser $browser) { - $browser->type('#password', 'aaaaaa') - ->type('#password_confirmation', '') - ->click('button[type=submit]') - ->waitFor('#password + .invalid-feedback') - ->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.') - ->assertFocused('#password') - ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); - }); + // TODO: Test password change - // TODO: Test password change + // Test form error handling (aliases) + $browser->vueClear('#password') + ->vueClear('#password_confirmation') + ->with(new ListInput('#aliases'), function (Browser $browser) { + $browser->addListEntry('invalid address'); + }) + ->click('button[type=submit]') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); - // Test form error handling (aliases) - $browser->with('@form', function (Browser $browser) { - // TODO: For some reason, clearing the input value - // with ->type('#password', '') does not work, maybe some dusk/vue intricacy - // For now we just use the default password - $browser->type('#password', 'simple123') - ->type('#password_confirmation', 'simple123') - ->with(new ListInput('#aliases'), function (Browser $browser) { - $browser->addListEntry('invalid address'); - }) - ->click('button[type=submit]') - ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); - }) - ->with('@form', function (Browser $browser) { - $browser->with(new ListInput('#aliases'), function (Browser $browser) { - $browser->assertFormError(2, 'The specified alias is invalid.', false); - }); - }); + $browser->with(new ListInput('#aliases'), function (Browser $browser) { + $browser->assertFormError(2, 'The specified alias is invalid.', false); + }); - // Test adding aliases - $browser->with('@form', function (Browser $browser) { - $browser->with(new ListInput('#aliases'), function (Browser $browser) { - $browser->removeListEntry(2) - ->addListEntry('john.test@kolab.org'); + // Test adding aliases + $browser->with(new ListInput('#aliases'), function (Browser $browser) { + $browser->removeListEntry(2) + ->addListEntry('john.test@kolab.org'); + }) + ->click('button[type=submit]') + ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); }) - ->click('button[type=submit]') - ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); - }); + ->on(new UserList()) + ->click('@table tr:nth-child(3) a') + ->on(new UserInfo()); $john = User::where('email', 'john@kolab.org')->first(); $alias = UserAlias::where('user_id', $john->id)->where('alias', 'john.test@kolab.org')->first(); $this->assertTrue(!empty($alias)); // Test subscriptions $browser->with('@form', function (Browser $browser) { $browser->assertSeeIn('div.row:nth-child(9) label', 'Subscriptions') ->assertVisible('@skus.row:nth-child(9)') ->with('@skus', function ($browser) { $browser->assertElementsCount('tbody tr', 5) // Mailbox SKU ->assertSeeIn('tbody tr:nth-child(1) td.name', 'User Mailbox') ->assertSeeIn('tbody tr:nth-child(1) td.price', '4,44 CHF/month') ->assertChecked('tbody tr:nth-child(1) td.selection input') ->assertDisabled('tbody tr:nth-child(1) td.selection input') ->assertTip( 'tbody tr:nth-child(1) td.buttons button', 'Just a mailbox' ) // Storage SKU ->assertSeeIn('tbody tr:nth-child(2) td.name', 'Storage Quota') ->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month') ->assertChecked('tbody tr:nth-child(2) td.selection input') ->assertDisabled('tbody tr:nth-child(2) td.selection input') ->assertTip( 'tbody tr:nth-child(2) td.buttons button', 'Some wiggle room' ) ->with(new QuotaInput('tbody tr:nth-child(2) .range-input'), function ($browser) { $browser->assertQuotaValue(2)->setQuotaValue(3); }) ->assertSeeIn('tr:nth-child(2) td.price', '0,25 CHF/month') // groupware SKU ->assertSeeIn('tbody tr:nth-child(3) td.name', 'Groupware Features') ->assertSeeIn('tbody tr:nth-child(3) td.price', '5,55 CHF/month') ->assertChecked('tbody tr:nth-child(3) td.selection input') ->assertEnabled('tbody tr:nth-child(3) td.selection input') ->assertTip( 'tbody tr:nth-child(3) td.buttons button', 'Groupware functions like Calendar, Tasks, Notes, etc.' ) // ActiveSync SKU ->assertSeeIn('tbody tr:nth-child(4) td.name', 'Activesync') ->assertSeeIn('tbody tr:nth-child(4) td.price', '1,00 CHF/month') ->assertNotChecked('tbody tr:nth-child(4) td.selection input') ->assertEnabled('tbody tr:nth-child(4) td.selection input') ->assertTip( 'tbody tr:nth-child(4) td.buttons button', 'Mobile synchronization' ) // 2FA SKU ->assertSeeIn('tbody tr:nth-child(5) td.name', '2-Factor Authentication') ->assertSeeIn('tbody tr:nth-child(5) td.price', '0,00 CHF/month') ->assertNotChecked('tbody tr:nth-child(5) td.selection input') ->assertEnabled('tbody tr:nth-child(5) td.selection input') ->assertTip( 'tbody tr:nth-child(5) td.buttons button', 'Two factor authentication for webmail and administration panel' ) ->click('tbody tr:nth-child(4) td.selection input'); }) ->assertMissing('@skus table + .hint') ->click('button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); - }); + }) + ->on(new UserList()) + ->click('@table tr:nth-child(3) a') + ->on(new UserInfo()); $expected = ['activesync', 'groupware', 'mailbox', 'storage', 'storage', 'storage']; $this->assertUserEntitlements($john, $expected); // Test subscriptions interaction $browser->with('@form', function (Browser $browser) { $browser->with('@skus', function ($browser) { // Uncheck 'groupware', expect activesync unchecked $browser->click('#sku-input-groupware') ->assertNotChecked('#sku-input-groupware') ->assertNotChecked('#sku-input-activesync') ->assertEnabled('#sku-input-activesync') ->assertNotReadonly('#sku-input-activesync') // Check 'activesync', expect an alert ->click('#sku-input-activesync') ->assertDialogOpened('Activesync requires Groupware Features.') ->acceptDialog() ->assertNotChecked('#sku-input-activesync') // Check '2FA', expect 'activesync' unchecked and readonly ->click('#sku-input-2fa') ->assertChecked('#sku-input-2fa') ->assertNotChecked('#sku-input-activesync') ->assertReadonly('#sku-input-activesync') // Uncheck '2FA' ->click('#sku-input-2fa') ->assertNotChecked('#sku-input-2fa') ->assertNotReadonly('#sku-input-activesync'); }); }); }); } /** * Test user adding page * * @depends testList */ public function testNewUser(): void { $this->browse(function (Browser $browser) { $browser->visit(new UserList()) ->assertSeeIn('button.create-user', 'Create user') ->click('button.create-user') ->on(new UserInfo()) ->assertSeeIn('#user-info .card-title', 'New user account') ->with('@form', function (Browser $browser) { // Assert form content $browser->assertFocused('div.row:nth-child(1) input') ->assertSeeIn('div.row:nth-child(1) label', 'First name') ->assertValue('div.row:nth-child(1) input[type=text]', '') ->assertSeeIn('div.row:nth-child(2) label', 'Last name') ->assertValue('div.row:nth-child(2) input[type=text]', '') ->assertSeeIn('div.row:nth-child(3) label', 'Organization') ->assertValue('div.row:nth-child(3) input[type=text]', '') ->assertSeeIn('div.row:nth-child(4) label', 'Email') ->assertValue('div.row:nth-child(4) input[type=text]', '') ->assertEnabled('div.row:nth-child(4) input[type=text]') ->assertSeeIn('div.row:nth-child(5) label', 'Email aliases') ->assertVisible('div.row:nth-child(5) .list-input') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertListInputValue([]) ->assertValue('@input', ''); }) ->assertSeeIn('div.row:nth-child(6) label', 'Password') ->assertValue('div.row:nth-child(6) input[type=password]', '') ->assertSeeIn('div.row:nth-child(7) label', 'Confirm password') ->assertValue('div.row:nth-child(7) input[type=password]', '') ->assertSeeIn('div.row:nth-child(8) label', 'Package') // assert packages list widget, select "Lite Account" ->with('@packages', function ($browser) { $browser->assertElementsCount('tbody tr', 2) ->assertSeeIn('tbody tr:nth-child(1)', 'Groupware Account') ->assertSeeIn('tbody tr:nth-child(2)', 'Lite Account') ->assertSeeIn('tbody tr:nth-child(1) .price', '9,99 CHF/month') ->assertSeeIn('tbody tr:nth-child(2) .price', '4,44 CHF/month') ->assertChecked('tbody tr:nth-child(1) input') ->click('tbody tr:nth-child(2) input') ->assertNotChecked('tbody tr:nth-child(1) input') ->assertChecked('tbody tr:nth-child(2) input'); }) ->assertMissing('@packages table + .hint') ->assertSeeIn('button[type=submit]', 'Submit'); // Test browser-side required fields and error handling $browser->click('button[type=submit]') ->assertFocused('#email') ->type('#email', 'invalid email') ->click('button[type=submit]') ->assertFocused('#password') ->type('#password', 'simple123') ->click('button[type=submit]') ->assertFocused('#password_confirmation') ->type('#password_confirmation', 'simple') ->click('button[type=submit]') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->assertSeeIn('#email + .invalid-feedback', 'The specified email is invalid.') ->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.'); }); // Test form error handling (aliases) $browser->with('@form', function (Browser $browser) { $browser->type('#email', 'julia.roberts@kolab.org') ->type('#password_confirmation', 'simple123') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->addListEntry('invalid address'); }) ->click('button[type=submit]') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->assertFormError(1, 'The specified alias is invalid.', false); }); }); // Successful account creation $browser->with('@form', function (Browser $browser) { $browser->type('#first_name', 'Julia') ->type('#last_name', 'Roberts') ->type('#organization', 'Test Org') ->with(new ListInput('#aliases'), function (Browser $browser) { $browser->removeListEntry(1) ->addListEntry('julia.roberts2@kolab.org'); }) ->click('button[type=submit]'); }) ->assertToast(Toast::TYPE_SUCCESS, 'User created successfully.') // check redirection to users list - ->waitForLocation('/users') ->on(new UserList()) ->whenAvailable('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 5) ->assertSeeIn('tbody tr:nth-child(4) a', 'julia.roberts@kolab.org'); }); $julia = User::where('email', 'julia.roberts@kolab.org')->first(); $alias = UserAlias::where('user_id', $julia->id)->where('alias', 'julia.roberts2@kolab.org')->first(); $this->assertTrue(!empty($alias)); $this->assertUserEntitlements($julia, ['mailbox', 'storage', 'storage']); $this->assertSame('Julia', $julia->getSetting('first_name')); $this->assertSame('Roberts', $julia->getSetting('last_name')); $this->assertSame('Test Org', $julia->getSetting('organization')); + + // Some additional tests for the list input widget + $browser->click('tbody tr:nth-child(4) a') + ->on(new UserInfo()) + ->with(new ListInput('#aliases'), function (Browser $browser) { + $browser->assertListInputValue(['julia.roberts2@kolab.org']) + ->addListEntry('invalid address') + ->type('.input-group:nth-child(2) input', '@kolab.org'); + }) + ->click('button[type=submit]') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + ->with(new ListInput('#aliases'), function (Browser $browser) { + $browser->assertVisible('.input-group:nth-child(2) input.is-invalid') + ->assertVisible('.input-group:nth-child(3) input.is-invalid') + ->type('.input-group:nth-child(2) input', 'julia.roberts3@kolab.org') + ->type('.input-group:nth-child(3) input', 'julia.roberts4@kolab.org'); + }) + ->click('button[type=submit]') + ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.'); + + $julia = User::where('email', 'julia.roberts@kolab.org')->first(); + $aliases = $julia->aliases()->orderBy('alias')->get()->pluck('alias')->all(); + $this->assertSame(['julia.roberts3@kolab.org', 'julia.roberts4@kolab.org'], $aliases); }); } /** * Test user delete * * @depends testNewUser */ public function testDeleteUser(): void { // First create a new user $john = $this->getTestUser('john@kolab.org'); $julia = $this->getTestUser('julia.roberts@kolab.org'); $package_kolab = \App\Package::where('title', 'kolab')->first(); $john->assignPackage($package_kolab, $julia); // Test deleting non-controller user $this->browse(function (Browser $browser) { $browser->visit(new UserList()) ->whenAvailable('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 5) ->assertSeeIn('tbody tr:nth-child(4) a', 'julia.roberts@kolab.org') ->click('tbody tr:nth-child(4) button.button-delete'); }) ->with(new Dialog('#delete-warning'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Delete julia.roberts@kolab.org') ->assertFocused('@button-cancel') ->assertSeeIn('@button-cancel', 'Cancel') ->assertSeeIn('@button-action', 'Delete') ->click('@button-cancel'); }) ->whenAvailable('@table', function (Browser $browser) { $browser->click('tbody tr:nth-child(4) button.button-delete'); }) ->with(new Dialog('#delete-warning'), function (Browser $browser) { $browser->click('@button-action'); }) ->assertToast(Toast::TYPE_SUCCESS, 'User deleted successfully.') ->with('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 4) ->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org') ->assertSeeIn('tbody tr:nth-child(2) a', 'joe@kolab.org') ->assertSeeIn('tbody tr:nth-child(3) a', 'john@kolab.org') ->assertSeeIn('tbody tr:nth-child(4) a', 'ned@kolab.org'); }); $julia = User::where('email', 'julia.roberts@kolab.org')->first(); $this->assertTrue(empty($julia)); // Test clicking Delete on the controller record redirects to /profile/delete $browser ->with('@table', function (Browser $browser) { $browser->click('tbody tr:nth-child(3) button.button-delete'); }) ->waitForLocation('/profile/delete'); }); // Test that non-controller user cannot see/delete himself on the users list // Note: Access to /profile/delete page is tested in UserProfileTest.php $this->browse(function (Browser $browser) { $browser->visit('/logout') ->on(new Home()) ->submitLogon('jack@kolab.org', 'simple123', true) ->visit(new UserList()) ->whenAvailable('@table', function (Browser $browser) { - $browser->assertElementsCount('tbody tr', 0); + $browser->assertElementsCount('tbody tr', 0) + ->assertSeeIn('tfoot td', 'There are no users in this account.'); }); }); // Test that controller user (Ned) can see/delete all the users ??? $this->browse(function (Browser $browser) { $browser->visit('/logout') ->on(new Home()) ->submitLogon('ned@kolab.org', 'simple123', true) ->visit(new UserList()) ->whenAvailable('@table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 4) ->assertElementsCount('tbody button.button-delete', 4); }); // TODO: Test the delete action in details }); // TODO: Test what happens with the logged in user session after he's been deleted by another user } /** * Test discounted sku/package prices in the UI */ public function testDiscountedPrices(): void { // Add 10% discount $discount = Discount::where('code', 'TEST')->first(); $john = User::where('email', 'john@kolab.org')->first(); $wallet = $john->wallet(); $wallet->discount()->associate($discount); $wallet->save(); // SKUs on user edit page $this->browse(function (Browser $browser) { $browser->visit('/logout') ->on(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->visit(new UserList()) + ->waitFor('@table tr:nth-child(2)') ->click('@table tr:nth-child(2) a') ->on(new UserInfo()) ->with('@form', function (Browser $browser) { $browser->whenAvailable('@skus', function (Browser $browser) { $quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input'); $browser->waitFor('tbody tr') ->assertElementsCount('tbody tr', 5) // Mailbox SKU ->assertSeeIn('tbody tr:nth-child(1) td.price', '3,99 CHF/month¹') // Storage SKU ->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month¹') ->with($quota_input, function (Browser $browser) { $browser->setQuotaValue(100); }) ->assertSeeIn('tr:nth-child(2) td.price', '21,56 CHF/month¹') // groupware SKU ->assertSeeIn('tbody tr:nth-child(3) td.price', '4,99 CHF/month¹') // ActiveSync SKU ->assertSeeIn('tbody tr:nth-child(4) td.price', '0,90 CHF/month¹') // 2FA SKU ->assertSeeIn('tbody tr:nth-child(5) td.price', '0,00 CHF/month¹'); }) ->assertSeeIn('@skus table + .hint', '¹ applied discount: 10% - Test voucher'); }); }); // Packages on new user page $this->browse(function (Browser $browser) { $browser->visit(new UserList()) ->click('button.create-user') ->on(new UserInfo()) ->with('@form', function (Browser $browser) { $browser->whenAvailable('@packages', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 2) ->assertSeeIn('tbody tr:nth-child(1) .price', '8,99 CHF/month¹') // Groupware ->assertSeeIn('tbody tr:nth-child(2) .price', '3,99 CHF/month¹'); // Lite }) ->assertSeeIn('@packages table + .hint', '¹ applied discount: 10% - Test voucher'); }); }); } } diff --git a/src/tests/Browser/WalletTest.php b/src/tests/Browser/WalletTest.php index 1cf0525c..febfbe0a 100644 --- a/src/tests/Browser/WalletTest.php +++ b/src/tests/Browser/WalletTest.php @@ -1,256 +1,256 @@ deleteTestUser('wallets-controller@kolabnow.com'); $john = $this->getTestUser('john@kolab.org'); Wallet::where('user_id', $john->id)->update(['balance' => -1234]); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('wallets-controller@kolabnow.com'); $john = $this->getTestUser('john@kolab.org'); Wallet::where('user_id', $john->id)->update(['balance' => 0]); 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 { // Test that the page requires authentication $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->on(new Dashboard()) ->assertSeeIn('@links .link-wallet .name', 'Wallet') ->assertSeeIn('@links .link-wallet .badge', '-12,34 CHF'); }); } /** * Test wallet page * * @depends testDashboard */ public function testWallet(): void { $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 */ public function testReceipts(): void { $user = $this->getTestUser('wallets-controller@kolabnow.com', ['password' => 'simple123']); $wallet = $user->wallets()->first(); $wallet->payments()->delete(); // Log out John and log in the test user $this->browse(function (Browser $browser) { $browser->visit('/logout') ->waitForLocation('/login') ->on(new Home()) ->submitLogon('wallets-controller@kolabnow.com', 'simple123', true); }); // Assert Receipts tab content when there's no receipts available $this->browse(function (Browser $browser) { $browser->on(new Dashboard()) ->click('@links .link-wallet') ->on(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.') ->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, ]); $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, ]); $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.3\n", $content); + $this->assertStringStartsWith("%PDF-1.", $content); $browser->removeDownloadedFile($filename); }); }); } /** * Test History tab */ public function testHistory(): void { $user = $this->getTestUser('wallets-controller@kolabnow.com', ['password' => 'simple123']); // Log out John and log in the test user $this->browse(function (Browser $browser) { $browser->visit('/logout') ->waitForLocation('/login') ->on(new Home()) ->submitLogon('wallets-controller@kolabnow.com', 'simple123', true); }); $package_kolab = \App\Package::where('title', 'kolab')->first(); $user->assignPackage($package_kolab); $wallet = $user->wallets()->first(); // 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 Dashboard()) ->click('@links .link-wallet') ->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"); } } // Load sub-transactions $browser->click("$debitEntry td.selection button") ->waitUntilMissing('.app-loader') ->assertElementsCount("$debitEntry td.description ul li", 2) ->assertMissing("$debitEntry td.selection button"); }); }); } } diff --git a/src/tests/Feature/Backends/LDAPTest.php b/src/tests/Feature/Backends/LDAPTest.php index 2f2990ac..72659e10 100644 --- a/src/tests/Feature/Backends/LDAPTest.php +++ b/src/tests/Feature/Backends/LDAPTest.php @@ -1,153 +1,260 @@ ldap_config = [ + 'ldap.hosts' => \config('ldap.hosts'), + ]; + $this->deleteTestUser('user-ldap-test@' . \config('app.domain')); + $this->deleteTestDomain('testldap.com'); } /** * {@inheritDoc} */ public function tearDown(): void { + \config($this->ldap_config); + $this->deleteTestUser('user-ldap-test@' . \config('app.domain')); + $this->deleteTestDomain('testldap.com'); parent::tearDown(); } + /** + * Test handling connection errors + * + * @group ldap + */ + public function testConnectException(): void + { + \config(['ldap.hosts' => 'non-existing.host']); + + $this->expectException(\Exception::class); + + LDAP::connect(); + } + /** * Test creating/updating/deleting a domain record * * @group ldap */ public function testDomain(): void { - $this->markTestIncomplete(); + Queue::fake(); + + $domain = $this->getTestDomain('testldap.com', [ + 'type' => Domain::TYPE_EXTERNAL, + 'status' => Domain::STATUS_NEW | Domain::STATUS_ACTIVE, + ]); + + // Create the domain + LDAP::createDomain($domain); + + $ldap_domain = LDAP::getDomain($domain->namespace); + + $expected = [ + 'associateddomain' => $domain->namespace, + 'inetdomainstatus' => $domain->status, + 'objectclass' => [ + 'top', + 'domainrelatedobject', + 'inetdomain' + ], + ]; + + foreach ($expected as $attr => $value) { + $this->assertEquals($value, isset($ldap_domain[$attr]) ? $ldap_domain[$attr] : null); + } + + // TODO: Test other attributes, aci, roles/ous + + // Update the domain + $domain->status |= User::STATUS_LDAP_READY; + + LDAP::updateDomain($domain); + + $expected['inetdomainstatus'] = $domain->status; + + $ldap_domain = LDAP::getDomain($domain->namespace); + + foreach ($expected as $attr => $value) { + $this->assertEquals($value, isset($ldap_domain[$attr]) ? $ldap_domain[$attr] : null); + } + + // Delete the domain + LDAP::deleteDomain($domain); + + $this->assertSame(null, LDAP::getDomain($domain->namespace)); } /** * Test creating/editing/deleting a user record * * @group ldap */ public function testUser(): void { Queue::fake(); $user = $this->getTestUser('user-ldap-test@' . \config('app.domain')); LDAP::createUser($user); $ldap_user = LDAP::getUser($user->email); $expected = [ 'objectclass' => [ 'top', 'inetorgperson', 'inetuser', 'kolabinetorgperson', 'mailrecipient', 'person', 'organizationalPerson', ], 'mail' => $user->email, 'uid' => $user->email, - 'nsroledn' => null, + 'nsroledn' => [ + 'cn=imap-user,' . \config('ldap.hosted.root_dn') + ], 'cn' => 'unknown', 'displayname' => '', 'givenname' => '', 'sn' => 'unknown', 'inetuserstatus' => $user->status, 'mailquota' => null, 'o' => '', 'alias' => null, ]; foreach ($expected as $attr => $value) { $this->assertEquals($value, isset($ldap_user[$attr]) ? $ldap_user[$attr] : null); } // Add aliases, and change some user settings, and entitlements $user->setSettings([ 'first_name' => 'Firstname', 'last_name' => 'Lastname', 'organization' => 'Org', 'country' => 'PL', ]); $user->status |= User::STATUS_IMAP_READY; $user->save(); $aliases = ['t1-' . $user->email, 't2-' . $user->email]; $user->setAliases($aliases); $package_kolab = \App\Package::where('title', 'kolab')->first(); $user->assignPackage($package_kolab); LDAP::updateUser($user->fresh()); $expected['alias'] = $aliases; $expected['o'] = 'Org'; $expected['displayname'] = 'Lastname, Firstname'; $expected['givenname'] = 'Firstname'; $expected['cn'] = 'Firstname Lastname'; $expected['sn'] = 'Lastname'; $expected['inetuserstatus'] = $user->status; $expected['mailquota'] = 2097152; $expected['nsroledn'] = null; $ldap_user = LDAP::getUser($user->email); foreach ($expected as $attr => $value) { $this->assertEquals($value, isset($ldap_user[$attr]) ? $ldap_user[$attr] : null); } // Update entitlements $sku_activesync = \App\Sku::where('title', 'activesync')->first(); $sku_groupware = \App\Sku::where('title', 'groupware')->first(); $user->assignSku($sku_activesync, 1); Entitlement::where(['sku_id' => $sku_groupware->id, 'entitleable_id' => $user->id])->delete(); LDAP::updateUser($user->fresh()); $expected_roles = [ 'activesync-user', 'imap-user' ]; $ldap_user = LDAP::getUser($user->email); $this->assertCount(2, $ldap_user['nsroledn']); $ldap_roles = array_map( function ($role) { if (preg_match('/^cn=([a-z0-9-]+)/', $role, $m)) { return $m[1]; } else { return $role; } }, $ldap_user['nsroledn'] ); $this->assertSame($expected_roles, $ldap_roles); // Delete the user LDAP::deleteUser($user); $this->assertSame(null, LDAP::getUser($user->email)); } + + /** + * Test handling update of a non-existing domain + * + * @group ldap + */ + public function testUpdateDomainException(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessageMatches('/domain not found/'); + + $domain = new Domain([ + 'namespace' => 'testldap.com', + 'type' => Domain::TYPE_EXTERNAL, + 'status' => Domain::STATUS_NEW | Domain::STATUS_ACTIVE, + ]); + + LDAP::updateDomain($domain); + } + + /** + * Test handling update of a non-existing user + * + * @group ldap + */ + public function testUpdateUserException(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessageMatches('/user not found/'); + + $user = new User([ + 'email' => 'test-non-existing-ldap@kolab.org', + 'status' => User::STATUS_ACTIVE, + ]); + + LDAP::updateUser($user); + } } diff --git a/src/tests/Feature/BillingTest.php b/src/tests/Feature/BillingTest.php index e1ca24b3..ff4249d0 100644 --- a/src/tests/Feature/BillingTest.php +++ b/src/tests/Feature/BillingTest.php @@ -1,259 +1,262 @@ deleteTestUser('jane@kolabnow.com'); $this->deleteTestUser('jack@kolabnow.com'); \App\Package::where('title', 'kolab-kube')->delete(); $this->user = $this->getTestUser('jane@kolabnow.com'); $this->package = \App\Package::where('title', 'kolab')->first(); $this->user->assignPackage($this->package); $this->wallet = $this->user->wallets->first(); $this->wallet_id = $this->wallet->id; } public function tearDown(): void { $this->deleteTestUser('jane@kolabnow.com'); $this->deleteTestUser('jack@kolabnow.com'); \App\Package::where('title', 'kolab-kube')->delete(); parent::tearDown(); } /** * Test the expected results for a user that registers and is almost immediately gone. */ public function testTouchAndGo(): void { $this->assertCount(4, $this->wallet->entitlements); $this->assertEquals(0, $this->wallet->expectedCharges()); $this->user->delete(); $this->assertCount(0, $this->wallet->fresh()->entitlements->where('deleted_at', null)); $this->assertCount(4, $this->wallet->entitlements); } /** * Verify the last day before the end of a full month's trial. */ public function testNearFullTrial(): void { $this->backdateEntitlements( $this->wallet->entitlements, Carbon::now()->subMonthsWithoutOverflow(1)->addDays(1) ); $this->assertEquals(0, $this->wallet->expectedCharges()); } /** * Verify the exact end of the month's trial. */ public function testFullTrial(): void { - $this->backdateEntitlements($this->wallet->entitlements, Carbon::now()->subMonthsWithoutOverflow(1)); + $this->backdateEntitlements( + $this->wallet->entitlements, + Carbon::now()->subMonthsWithoutOverflow(1) + ); $this->assertEquals(999, $this->wallet->expectedCharges()); } /** * Verify that over-running the trial by a single day causes charges to be incurred. */ public function testOutRunTrial(): void { $this->backdateEntitlements( $this->wallet->entitlements, Carbon::now()->subMonthsWithoutOverflow(1)->subDays(1) ); $this->assertEquals(999, $this->wallet->expectedCharges()); } /** * Verify additional storage configuration entitlement created 'early' does incur additional * charges to the wallet. */ public function testAddtStorageEarly(): void { $this->backdateEntitlements( $this->wallet->entitlements, Carbon::now()->subMonthsWithoutOverflow(1)->subDays(1) ); $this->assertEquals(999, $this->wallet->expectedCharges()); $sku = \App\Sku::where(['title' => 'storage'])->first(); $entitlement = \App\Entitlement::create( [ 'wallet_id' => $this->wallet_id, 'sku_id' => $sku->id, 'cost' => $sku->cost, 'entitleable_id' => $this->user->id, 'entitleable_type' => \App\User::class ] ); $this->backdateEntitlements( [$entitlement], Carbon::now()->subMonthsWithoutOverflow(1)->subDays(1) ); $this->assertEquals(1024, $this->wallet->expectedCharges()); } /** * Verify additional storage configuration entitlement created 'late' does not incur additional * charges to the wallet. */ public function testAddtStorageLate(): void { $this->backdateEntitlements($this->wallet->entitlements, Carbon::now()->subMonthsWithoutOverflow(1)); $this->assertEquals(999, $this->wallet->expectedCharges()); $sku = \App\Sku::where(['title' => 'storage'])->first(); $entitlement = \App\Entitlement::create( [ 'wallet_id' => $this->wallet_id, 'sku_id' => $sku->id, 'cost' => $sku->cost, 'entitleable_id' => $this->user->id, 'entitleable_type' => \App\User::class ] ); $this->backdateEntitlements([$entitlement], Carbon::now()->subDays(14)); $this->assertEquals(999, $this->wallet->expectedCharges()); } public function testFifthWeek(): void { $targetDateA = Carbon::now()->subWeeks(5); $targetDateB = $targetDateA->copy()->addMonthsWithoutOverflow(1); $this->backdateEntitlements($this->wallet->entitlements, $targetDateA); $this->assertEquals(999, $this->wallet->expectedCharges()); $this->wallet->chargeEntitlements(); $this->assertEquals(-999, $this->wallet->balance); foreach ($this->wallet->entitlements()->get() as $entitlement) { $this->assertTrue($entitlement->created_at->isSameSecond($targetDateA)); $this->assertTrue($entitlement->updated_at->isSameSecond($targetDateB)); } } public function testSecondMonth(): void { $this->backdateEntitlements($this->wallet->entitlements, Carbon::now()->subMonthsWithoutOverflow(2)); $this->assertCount(4, $this->wallet->entitlements); $this->assertEquals(1998, $this->wallet->expectedCharges()); $sku = \App\Sku::where(['title' => 'storage'])->first(); $entitlement = \App\Entitlement::create( [ 'entitleable_id' => $this->user->id, 'entitleable_type' => \App\User::class, 'cost' => $sku->cost, 'sku_id' => $sku->id, 'wallet_id' => $this->wallet_id ] ); $this->backdateEntitlements([$entitlement], Carbon::now()->subMonthsWithoutOverflow(1)); $this->assertEquals(2023, $this->wallet->expectedCharges()); } public function testWithDiscountRate(): void { $package = \App\Package::create( [ 'title' => 'kolab-kube', 'name' => 'Kolab for Kuba Fans', 'description' => 'Kolab for Kube fans', 'discount_rate' => 50 ] ); $skus = [ \App\Sku::firstOrCreate(['title' => 'mailbox']), \App\Sku::firstOrCreate(['title' => 'storage']), \App\Sku::firstOrCreate(['title' => 'groupware']) ]; $package->skus()->saveMany($skus); $package->skus()->updateExistingPivot( \App\Sku::firstOrCreate(['title' => 'storage']), ['qty' => 2], false ); $user = $this->getTestUser('jack@kolabnow.com'); $user->assignPackage($package); $wallet = $user->wallets->first(); $wallet_id = $wallet->id; $this->backdateEntitlements($wallet->entitlements, Carbon::now()->subMonthsWithoutOverflow(1)); $this->assertEquals(500, $wallet->expectedCharges()); } /** * Test cost calculation with a wallet discount */ public function testWithWalletDiscount(): void { $discount = \App\Discount::where('code', 'TEST')->first(); $wallet = $this->user->wallets()->first(); $wallet->discount()->associate($discount); $this->backdateEntitlements($wallet->entitlements, Carbon::now()->subMonthsWithoutOverflow(1)); $this->assertEquals(898, $wallet->expectedCharges()); } } diff --git a/src/tests/Feature/Console/WalletChargeTest.php b/src/tests/Feature/Console/WalletChargeTest.php new file mode 100644 index 00000000..c56a868a --- /dev/null +++ b/src/tests/Feature/Console/WalletChargeTest.php @@ -0,0 +1,137 @@ +deleteTestUser('wallet-charge@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('wallet-charge@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test command run for a specified wallet + */ + public function testHandleSingle(): void + { + $user = $this->getTestUser('wallet-charge@kolabnow.com'); + $wallet = $user->wallets()->first(); + $wallet->balance = 0; + $wallet->save(); + + Queue::fake(); + + // Non-existing wallet ID + $this->artisan('wallet:charge 123') + ->assertExitCode(1); + + Queue::assertNothingPushed(); + + // The wallet has no entitlements, expect no charge and no check + $this->artisan('wallet:charge ' . $wallet->id) + ->assertExitCode(0); + + Queue::assertNothingPushed(); + + // The wallet has no entitlements, but has negative balance + $wallet->balance = -100; + $wallet->save(); + + $this->artisan('wallet:charge ' . $wallet->id) + ->assertExitCode(0); + + Queue::assertPushed(\App\Jobs\WalletCharge::class, 0); + Queue::assertPushed(\App\Jobs\WalletCheck::class, 1); + Queue::assertPushed(\App\Jobs\WalletCheck::class, function ($job) use ($wallet) { + $job_wallet = TestCase::getObjectProperty($job, 'wallet'); + return $job_wallet->id === $wallet->id; + }); + + Queue::fake(); + + // The wallet has entitlements to charge, and negative balance + $sku = \App\Sku::where('title', 'mailbox')->first(); + $entitlement = \App\Entitlement::create([ + 'wallet_id' => $wallet->id, + 'sku_id' => $sku->id, + 'cost' => 100, + 'entitleable_id' => $user->id, + 'entitleable_type' => \App\User::class, + ]); + \App\Entitlement::where('id', $entitlement->id)->update([ + 'created_at' => \Carbon\Carbon::now()->subMonths(1), + 'updated_at' => \Carbon\Carbon::now()->subMonths(1), + ]); + \App\User::where('id', $user->id)->update([ + 'created_at' => \Carbon\Carbon::now()->subMonths(1), + 'updated_at' => \Carbon\Carbon::now()->subMonths(1), + ]); + + $this->assertSame(100, $wallet->fresh()->chargeEntitlements(false)); + + $this->artisan('wallet:charge ' . $wallet->id) + ->assertExitCode(0); + + Queue::assertPushed(\App\Jobs\WalletCharge::class, 1); + Queue::assertPushed(\App\Jobs\WalletCharge::class, function ($job) use ($wallet) { + $job_wallet = TestCase::getObjectProperty($job, 'wallet'); + return $job_wallet->id === $wallet->id; + }); + + Queue::assertPushed(\App\Jobs\WalletCheck::class, 1); + Queue::assertPushed(\App\Jobs\WalletCheck::class, function ($job) use ($wallet) { + $job_wallet = TestCase::getObjectProperty($job, 'wallet'); + return $job_wallet->id === $wallet->id; + }); + } + + /** + * Test command run for all wallets + */ + public function testHandleAll(): void + { + $user = $this->getTestUser('john@kolab.org'); + $wallet = $user->wallets()->first(); + $wallet->balance = 0; + $wallet->save(); + + // backdate john's entitlements and set balance=0 for all wallets + $this->backdateEntitlements($user->entitlements, \Carbon\Carbon::now()->subWeeks(5)); + \App\Wallet::where('balance', '<', '0')->update(['balance' => 0]); + + Queue::fake(); + + // Non-existing wallet ID + $this->artisan('wallet:charge')->assertExitCode(0); + + Queue::assertPushed(\App\Jobs\WalletCheck::class, 1); + Queue::assertPushed(\App\Jobs\WalletCheck::class, function ($job) use ($wallet) { + $job_wallet = TestCase::getObjectProperty($job, 'wallet'); + return $job_wallet->id === $wallet->id; + }); + + Queue::assertPushed(\App\Jobs\WalletCharge::class, 1); + Queue::assertPushed(\App\Jobs\WalletCharge::class, function ($job) use ($wallet) { + $job_wallet = TestCase::getObjectProperty($job, 'wallet'); + return $job_wallet->id === $wallet->id; + }); + } +} diff --git a/src/tests/Feature/Controller/Admin/DomainsTest.php b/src/tests/Feature/Controller/Admin/DomainsTest.php index 32210530..5dcaa7a8 100644 --- a/src/tests/Feature/Controller/Admin/DomainsTest.php +++ b/src/tests/Feature/Controller/Admin/DomainsTest.php @@ -1,87 +1,159 @@ deleteTestDomain('domainscontroller.com'); } /** * {@inheritDoc} */ public function tearDown(): void { + $this->deleteTestDomain('domainscontroller.com'); + parent::tearDown(); } /** * 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 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/UsersTest.php b/src/tests/Feature/Controller/Admin/UsersTest.php index b9ba51c2..9070c0a0 100644 --- a/src/tests/Feature/Controller/Admin/UsersTest.php +++ b/src/tests/Feature/Controller/Admin/UsersTest.php @@ -1,254 +1,341 @@ deleteTestUser('UsersControllerTest1@userscontroller.com'); + $this->deleteTestUser('test@testsearch.com'); + $this->deleteTestDomain('testsearch.com'); $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 users searching (/api/v4/users) */ public function testIndex(): void { $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // 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']); + + // 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 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 8759b2c4..c949dbc9 100644 --- a/src/tests/Feature/Controller/Admin/WalletsTest.php +++ b/src/tests/Feature/Controller/Admin/WalletsTest.php @@ -1,228 +1,228 @@ '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['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; 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); $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); $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/AuthTest.php b/src/tests/Feature/Controller/AuthTest.php index 6ce25227..71711486 100644 --- a/src/tests/Feature/Controller/AuthTest.php +++ b/src/tests/Feature/Controller/AuthTest.php @@ -1,166 +1,195 @@ deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestDomain('userscontroller.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestDomain('userscontroller.com'); parent::tearDown(); } /** * Test fetching current user info (/api/auth/info) */ public function testInfo(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $response = $this->actingAs($user)->get("api/auth/info"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals($user->id, $json['id']); $this->assertEquals($user->email, $json['email']); $this->assertEquals(User::STATUS_NEW | User::STATUS_ACTIVE, $json['status']); $this->assertTrue(is_array($json['statusInfo'])); $this->assertTrue(is_array($json['settings'])); $this->assertTrue(is_array($json['aliases'])); + $this->assertTrue(!isset($json['access_token'])); // Note: Details of the content are tested in testUserResponse() + + // Test token refresh via the info request + // First we log in as we need the token (actingAs() will not work) + $post = ['email' => 'john@kolab.org', 'password' => 'simple123']; + $response = $this->post("api/auth/login", $post); + $json = $response->json(); + $response = $this->withHeaders(['Authorization' => 'Bearer ' . $json['access_token']]) + ->get("api/auth/info?refresh_token=1"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertEquals('john@kolab.org', $json['email']); + $this->assertTrue(is_array($json['statusInfo'])); + $this->assertTrue(is_array($json['settings'])); + $this->assertTrue(is_array($json['aliases'])); + $this->assertTrue(!empty($json['access_token'])); + $this->assertTrue(!empty($json['expires_in'])); } /** * Test /api/auth/login */ public function testLogin(): string { // Request with no data $response = $this->post("api/auth/login", []); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('email', $json['errors']); $this->assertArrayHasKey('password', $json['errors']); // Request with invalid password $post = ['email' => 'john@kolab.org', 'password' => 'wrong']; $response = $this->post("api/auth/login", $post); $response->assertStatus(401); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame('Invalid username or password.', $json['message']); // Valid user+password $post = ['email' => 'john@kolab.org', 'password' => 'simple123']; $response = $this->post("api/auth/login", $post); $json = $response->json(); $response->assertStatus(200); $this->assertTrue(!empty($json['access_token'])); $this->assertEquals(\config('jwt.ttl') * 60, $json['expires_in']); $this->assertEquals('bearer', $json['token_type']); + // Valid user+password (upper-case) + $post = ['email' => 'John@Kolab.org', 'password' => 'simple123']; + $response = $this->post("api/auth/login", $post); + $json = $response->json(); + + $response->assertStatus(200); + $this->assertTrue(!empty($json['access_token'])); + $this->assertEquals(\config('jwt.ttl') * 60, $json['expires_in']); + $this->assertEquals('bearer', $json['token_type']); + // TODO: We have browser tests for 2FA but we should probably also test it here return $json['access_token']; } /** * Test /api/auth/logout * * @depends testLogin */ public function testLogout($token): void { // Request with no token, testing that it requires auth $response = $this->post("api/auth/logout"); $response->assertStatus(401); // Test the same using JSON mode $response = $this->json('POST', "api/auth/logout", []); $response->assertStatus(401); // Request with valid token $response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->post("api/auth/logout"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('success', $json['status']); $this->assertEquals('Successfully logged out.', $json['message']); // Check if it really destroyed the token? $response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->get("api/auth/info"); $response->assertStatus(401); } /** * Test /api/auth/refresh */ public function testRefresh(): void { // Request with no token, testing that it requires auth $response = $this->post("api/auth/refresh"); $response->assertStatus(401); // Test the same using JSON mode $response = $this->json('POST', "api/auth/refresh", []); $response->assertStatus(401); // Login the user to get a valid token $post = ['email' => 'john@kolab.org', 'password' => 'simple123']; $response = $this->post("api/auth/login", $post); $response->assertStatus(200); $json = $response->json(); $token = $json['access_token']; // Request with a valid token $response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->post("api/auth/refresh"); $response->assertStatus(200); $json = $response->json(); $this->assertTrue(!empty($json['access_token'])); $this->assertTrue($json['access_token'] != $token); $this->assertEquals(\config('jwt.ttl') * 60, $json['expires_in']); $this->assertEquals('bearer', $json['token_type']); $new_token = $json['access_token']; // TODO: Shall we invalidate the old token? // And if the new token is working $response = $this->withHeaders(['Authorization' => 'Bearer ' . $new_token])->get("api/auth/info"); $response->assertStatus(200); } } diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php index dc4769c0..4033397d 100644 --- a/src/tests/Feature/Controller/UsersTest.php +++ b/src/tests/Feature/Controller/UsersTest.php @@ -1,1007 +1,1068 @@ deleteTestUser('jane@kolabnow.com'); $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestUser('UsersControllerTest2@userscontroller.com'); $this->deleteTestUser('UsersControllerTest3@userscontroller.com'); $this->deleteTestUser('UserEntitlement2A@UserEntitlement.com'); $this->deleteTestUser('john2.doe2@kolab.org'); + $this->deleteTestUser('deleted@kolab.org'); + $this->deleteTestUser('deleted@kolabnow.com'); $this->deleteTestDomain('userscontroller.com'); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->discount()->dissociate(); $wallet->settings()->whereIn('key', ['mollie_id', 'stripe_id'])->delete(); $wallet->save(); $user->status |= User::STATUS_IMAP_READY; $user->save(); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('jane@kolabnow.com'); $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestUser('UsersControllerTest2@userscontroller.com'); $this->deleteTestUser('UsersControllerTest3@userscontroller.com'); $this->deleteTestUser('UserEntitlement2A@UserEntitlement.com'); $this->deleteTestUser('john2.doe2@kolab.org'); + $this->deleteTestUser('deleted@kolab.org'); + $this->deleteTestUser('deleted@kolabnow.com'); $this->deleteTestDomain('userscontroller.com'); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->discount()->dissociate(); $wallet->settings()->whereIn('key', ['mollie_id', 'stripe_id'])->delete(); $wallet->save(); $user->status |= User::STATUS_IMAP_READY; $user->save(); parent::tearDown(); } /** * Test user deleting (DELETE /api/v4/users/) */ public function testDestroy(): void { // First create some users/accounts to delete $package_kolab = \App\Package::where('title', 'kolab')->first(); $package_domain = \App\Package::where('title', 'domain-hosting')->first(); $john = $this->getTestUser('john@kolab.org'); $user1 = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $user2 = $this->getTestUser('UsersControllerTest2@userscontroller.com'); $user3 = $this->getTestUser('UsersControllerTest3@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $user1->assignPackage($package_kolab); $domain->assignPackage($package_domain, $user1); $user1->assignPackage($package_kolab, $user2); $user1->assignPackage($package_kolab, $user3); // Test unauth access $response = $this->delete("api/v4/users/{$user2->id}"); $response->assertStatus(401); // Test access to other user/account $response = $this->actingAs($john)->delete("api/v4/users/{$user2->id}"); $response->assertStatus(403); $response = $this->actingAs($john)->delete("api/v4/users/{$user1->id}"); $response->assertStatus(403); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame("Access denied", $json['message']); $this->assertCount(2, $json); // Test that non-controller cannot remove himself $response = $this->actingAs($user3)->delete("api/v4/users/{$user3->id}"); $response->assertStatus(403); // Test removing a non-controller user $response = $this->actingAs($user1)->delete("api/v4/users/{$user3->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('success', $json['status']); $this->assertEquals('User deleted successfully.', $json['message']); // Test removing self (an account with users) $response = $this->actingAs($user1)->delete("api/v4/users/{$user1->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('success', $json['status']); $this->assertEquals('User deleted successfully.', $json['message']); } /** * Test user deleting (DELETE /api/v4/users/) */ public function testDestroyByController(): void { // Create an account with additional controller - $user2 $package_kolab = \App\Package::where('title', 'kolab')->first(); $package_domain = \App\Package::where('title', 'domain-hosting')->first(); $user1 = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $user2 = $this->getTestUser('UsersControllerTest2@userscontroller.com'); $user3 = $this->getTestUser('UsersControllerTest3@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $user1->assignPackage($package_kolab); $domain->assignPackage($package_domain, $user1); $user1->assignPackage($package_kolab, $user2); $user1->assignPackage($package_kolab, $user3); $user1->wallets()->first()->addController($user2); // TODO/FIXME: // For now controller can delete himself, as well as // the whole account he has control to, including the owner // Probably he should not be able to do none of those // However, this is not 0-regression scenario as we // do not fully support additional controllers. //$response = $this->actingAs($user2)->delete("api/v4/users/{$user2->id}"); //$response->assertStatus(403); $response = $this->actingAs($user2)->delete("api/v4/users/{$user3->id}"); $response->assertStatus(200); $response = $this->actingAs($user2)->delete("api/v4/users/{$user1->id}"); $response->assertStatus(200); // Note: More detailed assertions in testDestroy() above $this->assertTrue($user1->fresh()->trashed()); $this->assertTrue($user2->fresh()->trashed()); $this->assertTrue($user3->fresh()->trashed()); } /** * Test user listing (GET /api/v4/users) */ public function testIndex(): void { // Test unauth access $response = $this->get("api/v4/users"); $response->assertStatus(401); $jack = $this->getTestUser('jack@kolab.org'); $joe = $this->getTestUser('joe@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $response = $this->actingAs($jack)->get("/api/v4/users"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(0, $json); $response = $this->actingAs($john)->get("/api/v4/users"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(4, $json); $this->assertSame($jack->email, $json[0]['email']); $this->assertSame($joe->email, $json[1]['email']); $this->assertSame($john->email, $json[2]['email']); $this->assertSame($ned->email, $json[3]['email']); // Values below are tested by Unit tests $this->assertArrayHasKey('isDeleted', $json[0]); $this->assertArrayHasKey('isSuspended', $json[0]); $this->assertArrayHasKey('isActive', $json[0]); $this->assertArrayHasKey('isLdapReady', $json[0]); $this->assertArrayHasKey('isImapReady', $json[0]); $response = $this->actingAs($ned)->get("/api/v4/users"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(4, $json); $this->assertSame($jack->email, $json[0]['email']); $this->assertSame($joe->email, $json[1]['email']); $this->assertSame($john->email, $json[2]['email']); $this->assertSame($ned->email, $json[3]['email']); } /** * Test fetching user data/profile (GET /api/v4/users/) */ public function testShow(): void { $userA = $this->getTestUser('UserEntitlement2A@UserEntitlement.com'); // Test getting profile of self $response = $this->actingAs($userA)->get("/api/v4/users/{$userA->id}"); $json = $response->json(); $response->assertStatus(200); $this->assertEquals($userA->id, $json['id']); $this->assertEquals($userA->email, $json['email']); $this->assertTrue(is_array($json['statusInfo'])); $this->assertTrue(is_array($json['settings'])); $this->assertTrue(is_array($json['aliases'])); $this->assertSame([], $json['skus']); // Values below are tested by Unit tests $this->assertArrayHasKey('isDeleted', $json); $this->assertArrayHasKey('isSuspended', $json); $this->assertArrayHasKey('isActive', $json); $this->assertArrayHasKey('isLdapReady', $json); $this->assertArrayHasKey('isImapReady', $json); $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); // Test unauthorized access to a profile of other user $response = $this->actingAs($jack)->get("/api/v4/users/{$userA->id}"); $response->assertStatus(403); // Test authorized access to a profile of other user // Ned: Additional account controller $response = $this->actingAs($ned)->get("/api/v4/users/{$john->id}"); $response->assertStatus(200); $response = $this->actingAs($ned)->get("/api/v4/users/{$jack->id}"); $response->assertStatus(200); // John: Account owner $response = $this->actingAs($john)->get("/api/v4/users/{$jack->id}"); $response->assertStatus(200); $response = $this->actingAs($john)->get("/api/v4/users/{$ned->id}"); $response->assertStatus(200); $json = $response->json(); $storage_sku = Sku::where('title', 'storage')->first(); $groupware_sku = Sku::where('title', 'groupware')->first(); $mailbox_sku = Sku::where('title', 'mailbox')->first(); $secondfactor_sku = Sku::where('title', '2fa')->first(); $this->assertCount(5, $json['skus']); $this->assertSame(2, $json['skus'][$storage_sku->id]['count']); $this->assertSame(1, $json['skus'][$groupware_sku->id]['count']); $this->assertSame(1, $json['skus'][$mailbox_sku->id]['count']); $this->assertSame(1, $json['skus'][$secondfactor_sku->id]['count']); } /** * Test fetching user status (GET /api/v4/users//status) * and forcing setup process update (?refresh=1) * * @group imap * @group dns */ public function testStatus(): void { $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); // Test unauthorized access $response = $this->actingAs($jack)->get("/api/v4/users/{$john->id}/status"); $response->assertStatus(403); if ($john->isImapReady()) { $john->status ^= User::STATUS_IMAP_READY; $john->save(); } // Get user status $response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status"); $response->assertStatus(200); $json = $response->json(); $this->assertFalse($json['isImapReady']); $this->assertFalse($json['isReady']); $this->assertCount(7, $json['process']); $this->assertSame('user-imap-ready', $json['process'][2]['label']); $this->assertSame(false, $json['process'][2]['state']); $this->assertTrue(empty($json['status'])); $this->assertTrue(empty($json['message'])); // Make sure the domain is confirmed (other test might unset that status) $domain = $this->getTestDomain('kolab.org'); $domain->status |= Domain::STATUS_CONFIRMED; $domain->save(); // Now "reboot" the process and verify the user in imap synchronously $response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status?refresh=1"); $response->assertStatus(200); $json = $response->json(); $this->assertTrue($json['isImapReady']); $this->assertTrue($json['isReady']); $this->assertCount(7, $json['process']); $this->assertSame('user-imap-ready', $json['process'][2]['label']); $this->assertSame(true, $json['process'][2]['state']); $this->assertSame('success', $json['status']); $this->assertSame('Setup process finished successfully.', $json['message']); } /** * Test UsersController::statusInfo() */ public function testStatusInfo(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $user->created_at = Carbon::now(); $user->status = User::STATUS_NEW; $user->save(); $result = UsersController::statusInfo($user); $this->assertFalse($result['isReady']); $this->assertCount(3, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('user-ldap-ready', $result['process'][1]['label']); $this->assertSame(false, $result['process'][1]['state']); $this->assertSame('user-imap-ready', $result['process'][2]['label']); $this->assertSame(false, $result['process'][2]['state']); $this->assertSame('running', $result['processState']); $user->created_at = Carbon::now()->subSeconds(181); $user->save(); $result = UsersController::statusInfo($user); $this->assertSame('failed', $result['processState']); $user->status |= User::STATUS_LDAP_READY | User::STATUS_IMAP_READY; $user->save(); $result = UsersController::statusInfo($user); $this->assertTrue($result['isReady']); $this->assertCount(3, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('user-ldap-ready', $result['process'][1]['label']); $this->assertSame(true, $result['process'][1]['state']); $this->assertSame('user-imap-ready', $result['process'][2]['label']); $this->assertSame(true, $result['process'][2]['state']); $this->assertSame('done', $result['processState']); $domain->status |= Domain::STATUS_VERIFIED; $domain->type = Domain::TYPE_EXTERNAL; $domain->save(); $result = UsersController::statusInfo($user); $this->assertFalse($result['isReady']); $this->assertCount(7, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('user-ldap-ready', $result['process'][1]['label']); $this->assertSame(true, $result['process'][1]['state']); $this->assertSame('user-imap-ready', $result['process'][2]['label']); $this->assertSame(true, $result['process'][2]['state']); $this->assertSame('domain-new', $result['process'][3]['label']); $this->assertSame(true, $result['process'][3]['state']); $this->assertSame('domain-ldap-ready', $result['process'][4]['label']); $this->assertSame(false, $result['process'][4]['state']); $this->assertSame('domain-verified', $result['process'][5]['label']); $this->assertSame(true, $result['process'][5]['state']); $this->assertSame('domain-confirmed', $result['process'][6]['label']); $this->assertSame(false, $result['process'][6]['state']); } /** * Test user creation (POST /api/v4/users) */ public function testStore(): void { $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); + $deleted_priv = $this->getTestUser('deleted@kolab.org'); + $deleted_priv->delete(); // Test empty request $response = $this->actingAs($john)->post("/api/v4/users", []); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame("The email field is required.", $json['errors']['email']); $this->assertSame("The password field is required.", $json['errors']['password'][0]); $this->assertCount(2, $json); // Test access by user not being a wallet controller $post = ['first_name' => 'Test']; $response = $this->actingAs($jack)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(403); $this->assertSame('error', $json['status']); $this->assertSame("Access denied", $json['message']); $this->assertCount(2, $json); // Test some invalid data $post = ['password' => '12345678', 'email' => 'invalid']; $response = $this->actingAs($john)->post("/api/v4/users", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertSame('The password confirmation does not match.', $json['errors']['password'][0]); $this->assertSame('The specified email is invalid.', $json['errors']['email']); // Test existing user email $post = [ 'password' => 'simple', 'password_confirmation' => 'simple', 'first_name' => 'John2', 'last_name' => 'Doe2', 'email' => 'jack.daniels@kolab.org', ]; $response = $this->actingAs($john)->post("/api/v4/users", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertSame('The specified email is not available.', $json['errors']['email']); $package_kolab = \App\Package::where('title', 'kolab')->first(); $package_domain = \App\Package::where('title', 'domain-hosting')->first(); $post = [ 'password' => 'simple', 'password_confirmation' => 'simple', 'first_name' => 'John2', 'last_name' => 'Doe2', 'email' => 'john2.doe2@kolab.org', 'organization' => 'TestOrg', - 'aliases' => ['useralias1@kolab.org', 'useralias2@kolab.org'], + 'aliases' => ['useralias1@kolab.org', 'deleted@kolab.org'], ]; // Missing package $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertSame("Package is required.", $json['errors']['package']); $this->assertCount(2, $json); // Invalid package $post['package'] = $package_domain->id; $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertSame("Invalid package selected.", $json['errors']['package']); $this->assertCount(2, $json); // Test full and valid data $post['package'] = $package_kolab->id; $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("User created successfully.", $json['message']); $this->assertCount(2, $json); $user = User::where('email', 'john2.doe2@kolab.org')->first(); $this->assertInstanceOf(User::class, $user); $this->assertSame('John2', $user->getSetting('first_name')); $this->assertSame('Doe2', $user->getSetting('last_name')); $this->assertSame('TestOrg', $user->getSetting('organization')); $aliases = $user->aliases()->orderBy('alias')->get(); $this->assertCount(2, $aliases); - $this->assertSame('useralias1@kolab.org', $aliases[0]->alias); - $this->assertSame('useralias2@kolab.org', $aliases[1]->alias); + $this->assertSame('deleted@kolab.org', $aliases[0]->alias); + $this->assertSame('useralias1@kolab.org', $aliases[1]->alias); // Assert the new user entitlements $this->assertUserEntitlements($user, ['groupware', 'mailbox', 'storage', 'storage']); // Assert the wallet to which the new user should be assigned to $wallet = $user->wallet(); $this->assertSame($john->wallets()->first()->id, $wallet->id); // Test acting as account controller (not owner) /* // FIXME: How do we know to which wallet the new user should be assigned to? $this->deleteTestUser('john2.doe2@kolab.org'); $response = $this->actingAs($ned)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); */ $this->markTestIncomplete(); } /** * Test user update (PUT /api/v4/users/) */ public function testUpdate(): void { $userA = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $domain = $this->getTestDomain( 'userscontroller.com', ['status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL] ); // Test unauthorized update of other user profile $response = $this->actingAs($jack)->get("/api/v4/users/{$userA->id}", []); $response->assertStatus(403); // Test authorized update of account owner by account controller $response = $this->actingAs($ned)->get("/api/v4/users/{$john->id}", []); $response->assertStatus(200); // Test updating of self (empty request) $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully.", $json['message']); $this->assertCount(2, $json); // Test some invalid data $post = ['password' => '12345678', 'currency' => 'invalid']; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertSame('The password confirmation does not match.', $json['errors']['password'][0]); $this->assertSame('The currency must be 3 characters.', $json['errors']['currency'][0]); // Test full profile update including password $post = [ 'password' => 'simple', 'password_confirmation' => 'simple', 'first_name' => 'John2', 'last_name' => 'Doe2', 'organization' => 'TestOrg', 'phone' => '+123 123 123', 'external_email' => 'external@gmail.com', 'billing_address' => 'billing', 'country' => 'CH', 'currency' => 'CHF', 'aliases' => ['useralias1@' . \config('app.domain'), 'useralias2@' . \config('app.domain')] ]; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully.", $json['message']); $this->assertCount(2, $json); $this->assertTrue($userA->password != $userA->fresh()->password); unset($post['password'], $post['password_confirmation'], $post['aliases']); foreach ($post as $key => $value) { $this->assertSame($value, $userA->getSetting($key)); } $aliases = $userA->aliases()->orderBy('alias')->get(); $this->assertCount(2, $aliases); $this->assertSame('useralias1@' . \config('app.domain'), $aliases[0]->alias); $this->assertSame('useralias2@' . \config('app.domain'), $aliases[1]->alias); // Test unsetting values $post = [ 'first_name' => '', 'last_name' => '', 'organization' => '', 'phone' => '', 'external_email' => '', 'billing_address' => '', 'country' => '', 'currency' => '', 'aliases' => ['useralias2@' . \config('app.domain')] ]; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully.", $json['message']); $this->assertCount(2, $json); unset($post['aliases']); foreach ($post as $key => $value) { $this->assertNull($userA->getSetting($key)); } $aliases = $userA->aliases()->get(); $this->assertCount(1, $aliases); $this->assertSame('useralias2@' . \config('app.domain'), $aliases[0]->alias); - // Test error on setting an alias to other user's domain - // and missing password confirmation + // Test error on some invalid aliases missing password confirmation $post = [ 'password' => 'simple123', - 'aliases' => ['useralias2@' . \config('app.domain'), 'useralias1@kolab.org'] + 'aliases' => [ + 'useralias2@' . \config('app.domain'), + 'useralias1@kolab.org', + '@kolab.org', + ] ]; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); - $this->assertCount(1, $json['errors']['aliases']); + $this->assertCount(2, $json['errors']['aliases']); $this->assertSame("The specified domain is not available.", $json['errors']['aliases'][1]); + $this->assertSame("The specified alias is invalid.", $json['errors']['aliases'][2]); $this->assertSame("The password confirmation does not match.", $json['errors']['password'][0]); // Test authorized update of other user $response = $this->actingAs($ned)->get("/api/v4/users/{$jack->id}", []); $response->assertStatus(200); // TODO: Test error on aliases with invalid/non-existing/other-user's domain // Create entitlements and additional user for following tests $owner = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $user = $this->getTestUser('UsersControllerTest2@userscontroller.com'); $package_domain = Package::where('title', 'domain-hosting')->first(); $package_kolab = Package::where('title', 'kolab')->first(); $package_lite = Package::where('title', 'lite')->first(); $sku_mailbox = Sku::where('title', 'mailbox')->first(); $sku_storage = Sku::where('title', 'storage')->first(); $sku_groupware = Sku::where('title', 'groupware')->first(); $domain = $this->getTestDomain( 'userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ] ); $domain->assignPackage($package_domain, $owner); $owner->assignPackage($package_kolab); $owner->assignPackage($package_lite, $user); // Non-controller cannot update his own entitlements $post = ['skus' => []]; $response = $this->actingAs($user)->put("/api/v4/users/{$user->id}", $post); $response->assertStatus(422); // Test updating entitlements $post = [ 'skus' => [ $sku_mailbox->id => 1, $sku_storage->id => 3, $sku_groupware->id => 1, ], ]; $response = $this->actingAs($owner)->put("/api/v4/users/{$user->id}", $post); $response->assertStatus(200); $storage_cost = $user->entitlements() ->where('sku_id', $sku_storage->id) ->orderBy('cost') ->pluck('cost')->all(); $this->assertUserEntitlements( $user, ['groupware', 'mailbox', 'storage', 'storage', 'storage'] ); $this->assertSame([0, 0, 25], $storage_cost); } /** * Test UsersController::updateEntitlements() */ public function testUpdateEntitlements(): void { $jane = $this->getTestUser('jane@kolabnow.com'); $kolab = \App\Package::where('title', 'kolab')->first(); $storage = \App\Sku::where('title', 'storage')->first(); $activesync = \App\Sku::where('title', 'activesync')->first(); $groupware = \App\Sku::where('title', 'groupware')->first(); $mailbox = \App\Sku::where('title', 'mailbox')->first(); // standard package, 1 mailbox, 1 groupware, 2 storage $jane->assignPackage($kolab); // add 2 storage, 1 activesync $post = [ 'skus' => [ $mailbox->id => 1, $groupware->id => 1, $storage->id => 4, $activesync->id => 1 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(200); $this->assertUserEntitlements( $jane, [ 'activesync', 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage' ] ); // add 2 storage, remove 1 activesync $post = [ 'skus' => [ $mailbox->id => 1, $groupware->id => 1, $storage->id => 6, $activesync->id => 0 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(200); $this->assertUserEntitlements( $jane, [ 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage' ] ); // add mailbox $post = [ 'skus' => [ $mailbox->id => 2, $groupware->id => 1, $storage->id => 6, $activesync->id => 0 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(500); $this->assertUserEntitlements( $jane, [ 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage' ] ); // remove mailbox $post = [ 'skus' => [ $mailbox->id => 0, $groupware->id => 1, $storage->id => 6, $activesync->id => 0 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(500); $this->assertUserEntitlements( $jane, [ 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage' ] ); // less than free storage $post = [ 'skus' => [ $mailbox->id => 1, $groupware->id => 1, $storage->id => 1, $activesync->id => 0 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(200); $this->assertUserEntitlements( $jane, [ 'groupware', 'mailbox', 'storage', 'storage' ] ); } /** * Test user data response used in show and info actions */ public function testUserResponse(): void { $provider = \config('services.payment_provider') ?: 'mollie'; $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->setSettings(['mollie_id' => null, 'stripe_id' => null]); $result = $this->invokeMethod(new UsersController(), 'userResponse', [$user]); $this->assertEquals($user->id, $result['id']); $this->assertEquals($user->email, $result['email']); $this->assertEquals($user->status, $result['status']); $this->assertTrue(is_array($result['statusInfo'])); $this->assertTrue(is_array($result['aliases'])); $this->assertCount(1, $result['aliases']); $this->assertSame('john.doe@kolab.org', $result['aliases'][0]); $this->assertTrue(is_array($result['settings'])); $this->assertSame('US', $result['settings']['country']); $this->assertSame('USD', $result['settings']['currency']); $this->assertTrue(is_array($result['accounts'])); $this->assertTrue(is_array($result['wallets'])); $this->assertCount(0, $result['accounts']); $this->assertCount(1, $result['wallets']); $this->assertSame($wallet->id, $result['wallet']['id']); $this->assertArrayNotHasKey('discount', $result['wallet']); + $this->assertTrue($result['statusInfo']['enableDomains']); + $this->assertTrue($result['statusInfo']['enableWallets']); + $this->assertTrue($result['statusInfo']['enableUsers']); + + // Ned is John's wallet controller $ned = $this->getTestUser('ned@kolab.org'); $ned_wallet = $ned->wallets()->first(); $result = $this->invokeMethod(new UsersController(), 'userResponse', [$ned]); $this->assertEquals($ned->id, $result['id']); $this->assertEquals($ned->email, $result['email']); $this->assertTrue(is_array($result['accounts'])); $this->assertTrue(is_array($result['wallets'])); $this->assertCount(1, $result['accounts']); $this->assertCount(1, $result['wallets']); $this->assertSame($wallet->id, $result['wallet']['id']); $this->assertSame($wallet->id, $result['accounts'][0]['id']); $this->assertSame($ned_wallet->id, $result['wallets'][0]['id']); $this->assertSame($provider, $result['wallet']['provider']); $this->assertSame($provider, $result['wallets'][0]['provider']); + $this->assertTrue($result['statusInfo']['enableDomains']); + $this->assertTrue($result['statusInfo']['enableWallets']); + $this->assertTrue($result['statusInfo']['enableUsers']); + // Test discount in a response $discount = Discount::where('code', 'TEST')->first(); $wallet->discount()->associate($discount); $wallet->save(); $mod_provider = $provider == 'mollie' ? 'stripe' : 'mollie'; $wallet->setSetting($mod_provider . '_id', 123); $user->refresh(); $result = $this->invokeMethod(new UsersController(), 'userResponse', [$user]); $this->assertEquals($user->id, $result['id']); $this->assertSame($discount->id, $result['wallet']['discount_id']); $this->assertSame($discount->discount, $result['wallet']['discount']); $this->assertSame($discount->description, $result['wallet']['discount_description']); $this->assertSame($mod_provider, $result['wallet']['provider']); $this->assertSame($discount->id, $result['wallets'][0]['discount_id']); $this->assertSame($discount->discount, $result['wallets'][0]['discount']); $this->assertSame($discount->description, $result['wallets'][0]['discount_description']); $this->assertSame($mod_provider, $result['wallets'][0]['provider']); + + // Jack is not a John's wallet controller + $jack = $this->getTestUser('jack@kolab.org'); + $result = $this->invokeMethod(new UsersController(), 'userResponse', [$jack]); + + $this->assertFalse($result['statusInfo']['enableDomains']); + $this->assertFalse($result['statusInfo']['enableWallets']); + $this->assertFalse($result['statusInfo']['enableUsers']); } /** * List of alias validation cases for testValidateEmail() * * @return array Arguments for testValidateEmail() */ public function dataValidateEmail(): array { $this->refreshApplication(); $public_domains = Domain::getPublicDomains(); $domain = reset($public_domains); $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); return [ // Invalid format ["$domain", $john, true, 'The specified alias is invalid.'], [".@$domain", $john, true, 'The specified alias is invalid.'], ["test123456@localhost", $john, true, 'The specified domain is invalid.'], ["test123456@unknown-domain.org", $john, true, 'The specified domain is invalid.'], ["$domain", $john, false, 'The specified email is invalid.'], [".@$domain", $john, false, 'The specified email is invalid.'], // forbidden local part on public domains ["admin@$domain", $john, true, 'The specified alias is not available.'], ["administrator@$domain", $john, true, 'The specified alias is not available.'], // forbidden (other user's domain) ["testtest@kolab.org", $user, true, 'The specified domain is not available.'], // existing alias of other user, to be a user email ["jack.daniels@kolab.org", $john, false, 'The specified email is not available.'], // existing alias of other user, to be an alias, user in the same group account ["jack.daniels@kolab.org", $john, true, null], // existing user ["jack@kolab.org", $john, true, 'The specified alias is not available.'], // valid (user domain) ["admin@kolab.org", $john, true, null], // valid (public domain) ["test.test@$domain", $john, true, null], ]; } /** * User email/alias validation. * * Note: Technically these include unit tests, but let's keep it here for now. * FIXME: Shall we do a http request for each case? * * @dataProvider dataValidateEmail */ public function testValidateEmail($alias, $user, $is_alias, $expected_result): void { $args = [$alias, $user, $is_alias]; $result = $this->invokeMethod(new UsersController(), 'validateEmail', $args); $this->assertSame($expected_result, $result); } + + /** + * User email/alias validation - more cases. + * + * Note: Technically these include unit tests, but let's keep it here for now. + * FIXME: Shall we do a http request for each case? + */ + public function testValidateEmail2(): void + { + Queue::fake(); + + $john = $this->getTestUser('john@kolab.org'); + $jack = $this->getTestUser('jack@kolab.org'); + $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); + $deleted_priv = $this->getTestUser('deleted@kolab.org'); + $deleted_priv->setAliases(['deleted-alias@kolab.org']); + $deleted_priv->delete(); + $deleted_pub = $this->getTestUser('deleted@kolabnow.com'); + $deleted_pub->setAliases(['deleted-alias@kolabnow.com']); + $deleted_pub->delete(); + + // An alias that was a user email before is allowed, but only for custom domains + $result = UsersController::validateEmail('deleted@kolab.org', $john, true); + $this->assertSame(null, $result); + + $result = UsersController::validateEmail('deleted-alias@kolab.org', $john, true); + $this->assertSame(null, $result); + + $result = UsersController::validateEmail('deleted@kolabnow.com', $john, true); + $this->assertSame('The specified alias is not available.', $result); + + $result = UsersController::validateEmail('deleted-alias@kolabnow.com', $john, true); + $this->assertSame('The specified alias is not available.', $result); + } } diff --git a/src/tests/Feature/Controller/WalletsTest.php b/src/tests/Feature/Controller/WalletsTest.php index 8e0a5437..3aae3d01 100644 --- a/src/tests/Feature/Controller/WalletsTest.php +++ b/src/tests/Feature/Controller/WalletsTest.php @@ -1,333 +1,336 @@ deleteTestUser('wallets-controller@kolabnow.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('wallets-controller@kolabnow.com'); parent::tearDown(); } /** * Test for getWalletNotice() method */ public function testGetWalletNotice(): void { $user = $this->getTestUser('wallets-controller@kolabnow.com'); $package = \App\Package::where('title', 'kolab')->first(); $user->assignPackage($package); $wallet = $user->wallets()->first(); $controller = new WalletsController(); $method = new \ReflectionMethod($controller, 'getWalletNotice'); $method->setAccessible(true); // User/entitlements created today, balance=0 $notice = $method->invoke($controller, $wallet); $this->assertSame('You are in your free trial period.', $notice); $wallet->owner->created_at = Carbon::now()->subDays(15); $wallet->owner->save(); $notice = $method->invoke($controller, $wallet); $this->assertSame('Your free trial is about to end, top up to continue.', $notice); // User/entitlements created today, balance=-10 CHF $wallet->balance = -1000; $notice = $method->invoke($controller, $wallet); $this->assertSame('You are out of credit, top up your balance now.', $notice); - // User/entitlements created today, balance=-9,99 CHF (monthly cost) + // User/entitlements created slightly more than a month ago, balance=9,99 CHF (monthly) + $wallet->owner->created_at = Carbon::now()->subMonthsWithoutOverflow(1)->subDays(1); + $wallet->owner->save(); + $wallet->balance = 999; $notice = $method->invoke($controller, $wallet); $this->assertRegExp('/\((1 month|4 weeks)\)/', $notice); // Old entitlements, 100% discount $this->backdateEntitlements($wallet->entitlements, Carbon::now()->subDays(40)); $discount = \App\Discount::where('discount', 100)->first(); $wallet->discount()->associate($discount); $notice = $method->invoke($controller, $wallet->refresh()); $this->assertSame(null, $notice); } /** * Test fetching pdf receipt */ public function testReceiptDownload(): void { $user = $this->getTestUser('wallets-controller@kolabnow.com'); $john = $this->getTestUser('john@klab.org'); $wallet = $user->wallets()->first(); // Unauth access not allowed $response = $this->get("api/v4/wallets/{$wallet->id}/receipts/2020-05"); $response->assertStatus(401); $response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/receipts/2020-05"); $response->assertStatus(403); // Invalid receipt id (current month) $receiptId = date('Y-m'); $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts/{$receiptId}"); $response->assertStatus(404); // Invalid receipt id $receiptId = '1000-03'; $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts/{$receiptId}"); $response->assertStatus(404); // Valid receipt id $year = intval(date('Y')) - 1; $receiptId = "$year-12"; $filename = \config('app.name') . " Receipt for $year-12"; $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts/{$receiptId}"); $response->assertStatus(200); $response->assertHeader('content-type', 'application/pdf'); $response->assertHeader('content-disposition', 'attachment; filename="' . $filename . '"'); $response->assertHeader('content-length'); $length = $response->headers->get('content-length'); $content = $response->content(); - $this->assertStringStartsWith("%PDF-1.3\n", $content); + $this->assertStringStartsWith("%PDF-1.", $content); $this->assertEquals(strlen($content), $length); } /** * Test fetching list of receipts */ public function testReceipts(): void { $user = $this->getTestUser('wallets-controller@kolabnow.com'); $john = $this->getTestUser('john@klab.org'); $wallet = $user->wallets()->first(); $wallet->payments()->delete(); // Unauth access not allowed $response = $this->get("api/v4/wallets/{$wallet->id}/receipts"); $response->assertStatus(401); $response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/receipts"); $response->assertStatus(403); // Empty list expected $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame([], $json['list']); $this->assertSame(1, $json['page']); $this->assertSame(0, $json['count']); $this->assertSame(false, $json['hasMore']); // Insert a payment to the database $date = Carbon::create(intval(date('Y')) - 1, 4, 30); $payment = Payment::create([ 'id' => 'AAA1', 'status' => PaymentProvider::STATUS_PAID, 'type' => PaymentProvider::TYPE_ONEOFF, 'description' => 'Paid in April', 'wallet_id' => $wallet->id, 'provider' => 'stripe', 'amount' => 1111, ]); $payment->updated_at = $date; $payment->save(); $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame([$date->format('Y-m')], $json['list']); $this->assertSame(1, $json['page']); $this->assertSame(1, $json['count']); $this->assertSame(false, $json['hasMore']); } /** * Test fetching a wallet (GET /api/v4/wallets/:id) */ public function testShow(): void { $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $wallet = $john->wallets()->first(); // Accessing a wallet of someone else $response = $this->actingAs($jack)->get("api/v4/wallets/{$wallet->id}"); $response->assertStatus(403); // Accessing non-existing wallet $response = $this->actingAs($jack)->get("api/v4/wallets/aaa"); $response->assertStatus(404); // Wallet owner $response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame($wallet->id, $json['id']); $this->assertSame('CHF', $json['currency']); $this->assertSame($wallet->balance, $json['balance']); $this->assertTrue(empty($json['description'])); $this->assertTrue(!empty($json['notice'])); } /** * Test fetching wallet transactions */ public function testTransactions(): void { $package_kolab = \App\Package::where('title', 'kolab')->first(); $user = $this->getTestUser('wallets-controller@kolabnow.com'); $user->assignPackage($package_kolab); $john = $this->getTestUser('john@klab.org'); $wallet = $user->wallets()->first(); // Unauth access not allowed $response = $this->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(401); $response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(403); // Expect empty list $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame([], $json['list']); $this->assertSame(1, $json['page']); $this->assertSame(0, $json['count']); $this->assertSame(false, $json['hasMore']); // Create some sample transactions $transactions = $this->createTestTransactions($wallet); $transactions = array_reverse($transactions); $pages = array_chunk($transactions, 10 /* page size*/); // Get the first page $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame(1, $json['page']); $this->assertSame(10, $json['count']); $this->assertSame(true, $json['hasMore']); $this->assertCount(10, $json['list']); foreach ($pages[0] as $idx => $transaction) { $this->assertSame($transaction->id, $json['list'][$idx]['id']); $this->assertSame($transaction->type, $json['list'][$idx]['type']); $this->assertSame($transaction->shortDescription(), $json['list'][$idx]['description']); $this->assertFalse($json['list'][$idx]['hasDetails']); $this->assertFalse(array_key_exists('user', $json['list'][$idx])); } $search = null; // Get the second page $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?page=2"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame(2, $json['page']); $this->assertSame(2, $json['count']); $this->assertSame(false, $json['hasMore']); $this->assertCount(2, $json['list']); foreach ($pages[1] as $idx => $transaction) { $this->assertSame($transaction->id, $json['list'][$idx]['id']); $this->assertSame($transaction->type, $json['list'][$idx]['type']); $this->assertSame($transaction->shortDescription(), $json['list'][$idx]['description']); $this->assertSame( $transaction->type == Transaction::WALLET_DEBIT, $json['list'][$idx]['hasDetails'] ); $this->assertFalse(array_key_exists('user', $json['list'][$idx])); if ($transaction->type == Transaction::WALLET_DEBIT) { $search = $transaction->id; } } // Get a non-existing page $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?page=3"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame(3, $json['page']); $this->assertSame(0, $json['count']); $this->assertSame(false, $json['hasMore']); $this->assertCount(0, $json['list']); // Sub-transaction searching $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?transaction=123"); $response->assertStatus(404); $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?transaction={$search}"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame(1, $json['page']); $this->assertSame(2, $json['count']); $this->assertSame(false, $json['hasMore']); $this->assertCount(2, $json['list']); $this->assertSame(Transaction::ENTITLEMENT_BILLED, $json['list'][0]['type']); $this->assertSame(Transaction::ENTITLEMENT_BILLED, $json['list'][1]['type']); // Test that John gets 404 if he tries to access // someone else's transaction ID on his wallet's endpoint $wallet = $john->wallets()->first(); $response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/transactions?transaction={$search}"); $response->assertStatus(404); } } diff --git a/src/tests/Feature/Documents/ReceiptTest.php b/src/tests/Feature/Documents/ReceiptTest.php index 60e373c1..62e1859c 100644 --- a/src/tests/Feature/Documents/ReceiptTest.php +++ b/src/tests/Feature/Documents/ReceiptTest.php @@ -1,312 +1,312 @@ deleteTestUser('receipt-test@kolabnow.com'); parent::tearDown(); } /** * Test receipt HTML output (without VAT) */ public function testHtmlOutput(): void { $appName = \config('app.name'); $wallet = $this->getTestData(); $receipt = new Receipt($wallet, 2020, 5); $html = $receipt->htmlOutput(); $this->assertStringStartsWith('', $html); $dom = new \DOMDocument('1.0', 'UTF-8'); $dom->loadHTML($html); // Title $title = $dom->getElementById('title'); $this->assertSame("Receipt for May 2020", $title->textContent); // Company name/address $header = $dom->getElementById('header'); $companyOutput = $this->getNodeContent($header->getElementsByTagName('td')[0]); $companyExpected = \config('app.company.name') . "\n" . \config('app.company.address'); $this->assertSame($companyExpected, $companyOutput); // The main table content $content = $dom->getElementById('content'); $records = $content->getElementsByTagName('tr'); $this->assertCount(5, $records); $headerCells = $records[0]->getElementsByTagName('th'); $this->assertCount(3, $headerCells); $this->assertSame('Date', $this->getNodeContent($headerCells[0])); $this->assertSame('Description', $this->getNodeContent($headerCells[1])); $this->assertSame('Amount', $this->getNodeContent($headerCells[2])); $cells = $records[1]->getElementsByTagName('td'); $this->assertCount(3, $cells); $this->assertSame('2020-05-01', $this->getNodeContent($cells[0])); $this->assertSame("$appName Services", $this->getNodeContent($cells[1])); $this->assertSame('12,34 CHF', $this->getNodeContent($cells[2])); $cells = $records[2]->getElementsByTagName('td'); $this->assertCount(3, $cells); $this->assertSame('2020-05-10', $this->getNodeContent($cells[0])); $this->assertSame("$appName Services", $this->getNodeContent($cells[1])); $this->assertSame('0,01 CHF', $this->getNodeContent($cells[2])); $cells = $records[3]->getElementsByTagName('td'); $this->assertCount(3, $cells); $this->assertSame('2020-05-31', $this->getNodeContent($cells[0])); $this->assertSame("$appName Services", $this->getNodeContent($cells[1])); $this->assertSame('1,00 CHF', $this->getNodeContent($cells[2])); $summaryCells = $records[4]->getElementsByTagName('td'); $this->assertCount(2, $summaryCells); $this->assertSame('Total', $this->getNodeContent($summaryCells[0])); $this->assertSame('13,35 CHF', $this->getNodeContent($summaryCells[1])); // Customer data $customer = $dom->getElementById('customer'); $customerCells = $customer->getElementsByTagName('td'); $customerOutput = $this->getNodeContent($customerCells[0]); $customerExpected = "Firstname Lastname\nTest Unicode Straße 150\n10115 Berlin"; $this->assertSame($customerExpected, $this->getNodeContent($customerCells[0])); $customerIdents = $this->getNodeContent($customerCells[1]); - $this->assertTrue(strpos($customerIdents, "Account ID {$wallet->id}") !== false); + //$this->assertTrue(strpos($customerIdents, "Account ID {$wallet->id}") !== false); $this->assertTrue(strpos($customerIdents, "Customer No. {$wallet->owner->id}") !== false); // Company details in the footer $footer = $dom->getElementById('footer'); $footerOutput = $footer->textContent; $this->assertStringStartsWith(\config('app.company.details'), $footerOutput); $this->assertTrue(strpos($footerOutput, \config('app.company.email')) !== false); } /** * Test receipt HTML output (with VAT) */ public function testHtmlOutputVat(): void { \config(['app.vat.rate' => 7.7]); \config(['app.vat.countries' => 'ch']); $appName = \config('app.name'); $wallet = $this->getTestData('CH'); $receipt = new Receipt($wallet, 2020, 5); $html = $receipt->htmlOutput(); $this->assertStringStartsWith('', $html); $dom = new \DOMDocument('1.0', 'UTF-8'); $dom->loadHTML($html); // The main table content $content = $dom->getElementById('content'); $records = $content->getElementsByTagName('tr'); $this->assertCount(7, $records); $cells = $records[1]->getElementsByTagName('td'); $this->assertCount(3, $cells); $this->assertSame('2020-05-01', $this->getNodeContent($cells[0])); $this->assertSame("$appName Services", $this->getNodeContent($cells[1])); $this->assertSame('11,39 CHF', $this->getNodeContent($cells[2])); $cells = $records[2]->getElementsByTagName('td'); $this->assertCount(3, $cells); $this->assertSame('2020-05-10', $this->getNodeContent($cells[0])); $this->assertSame("$appName Services", $this->getNodeContent($cells[1])); $this->assertSame('0,01 CHF', $this->getNodeContent($cells[2])); $cells = $records[3]->getElementsByTagName('td'); $this->assertCount(3, $cells); $this->assertSame('2020-05-31', $this->getNodeContent($cells[0])); $this->assertSame("$appName Services", $this->getNodeContent($cells[1])); $this->assertSame('0,92 CHF', $this->getNodeContent($cells[2])); $subtotalCells = $records[4]->getElementsByTagName('td'); $this->assertCount(2, $subtotalCells); $this->assertSame('Subtotal', $this->getNodeContent($subtotalCells[0])); $this->assertSame('12,32 CHF', $this->getNodeContent($subtotalCells[1])); $vatCells = $records[5]->getElementsByTagName('td'); $this->assertCount(2, $vatCells); $this->assertSame('VAT (7.7%)', $this->getNodeContent($vatCells[0])); $this->assertSame('1,03 CHF', $this->getNodeContent($vatCells[1])); $totalCells = $records[6]->getElementsByTagName('td'); $this->assertCount(2, $totalCells); $this->assertSame('Total', $this->getNodeContent($totalCells[0])); $this->assertSame('13,35 CHF', $this->getNodeContent($totalCells[1])); } /** * Test receipt PDF output */ public function testPdfOutput(): void { $wallet = $this->getTestData(); $receipt = new Receipt($wallet, 2020, 5); $pdf = $receipt->PdfOutput(); - $this->assertStringStartsWith("%PDF-1.3\n", $pdf); + $this->assertStringStartsWith("%PDF-1.", $pdf); $this->assertTrue(strlen($pdf) > 5000); // TODO: Test the content somehow } /** * Prepare data for a test * * @param string $country User country code * * @return \App\Wallet */ protected function getTestData(string $country = null): Wallet { Bus::fake(); $user = $this->getTestUser('receipt-test@kolabnow.com'); $user->setSettings([ 'first_name' => 'Firstname', 'last_name' => 'Lastname', 'billing_address' => "Test Unicode Straße 150\n10115 Berlin", 'country' => $country ]); $wallet = $user->wallets()->first(); // Create two payments out of the 2020-05 period // and three in it, plus one in the period but unpaid, // and one with amount 0 $payment = Payment::create([ 'id' => 'AAA1', 'status' => PaymentProvider::STATUS_PAID, 'type' => PaymentProvider::TYPE_ONEOFF, 'description' => 'Paid in April', 'wallet_id' => $wallet->id, 'provider' => 'stripe', 'amount' => 1111, ]); $payment->updated_at = Carbon::create(2020, 4, 30, 12, 0, 0); $payment->save(); $payment = Payment::create([ 'id' => 'AAA2', 'status' => PaymentProvider::STATUS_PAID, 'type' => PaymentProvider::TYPE_ONEOFF, 'description' => 'Paid in June', 'wallet_id' => $wallet->id, 'provider' => 'stripe', 'amount' => 2222, ]); $payment->updated_at = Carbon::create(2020, 6, 1, 0, 0, 0); $payment->save(); $payment = Payment::create([ 'id' => 'AAA3', 'status' => PaymentProvider::STATUS_PAID, 'type' => PaymentProvider::TYPE_ONEOFF, 'description' => 'Auto-Payment Setup', 'wallet_id' => $wallet->id, 'provider' => 'stripe', 'amount' => 0, ]); $payment->updated_at = Carbon::create(2020, 5, 1, 0, 0, 0); $payment->save(); $payment = Payment::create([ 'id' => 'AAA4', 'status' => PaymentProvider::STATUS_OPEN, 'type' => PaymentProvider::TYPE_ONEOFF, 'description' => 'Payment not yet paid', 'wallet_id' => $wallet->id, 'provider' => 'stripe', 'amount' => 999, ]); $payment->updated_at = Carbon::create(2020, 5, 1, 0, 0, 0); $payment->save(); // ... so we expect the last three on the receipt $payment = Payment::create([ 'id' => 'AAA5', 'status' => PaymentProvider::STATUS_PAID, 'type' => PaymentProvider::TYPE_ONEOFF, 'description' => 'Payment OK', 'wallet_id' => $wallet->id, 'provider' => 'stripe', 'amount' => 1234, ]); $payment->updated_at = Carbon::create(2020, 5, 1, 0, 0, 0); $payment->save(); $payment = Payment::create([ 'id' => 'AAA6', 'status' => PaymentProvider::STATUS_PAID, 'type' => PaymentProvider::TYPE_ONEOFF, 'description' => 'Payment OK', 'wallet_id' => $wallet->id, 'provider' => 'stripe', 'amount' => 1, ]); $payment->updated_at = Carbon::create(2020, 5, 10, 0, 0, 0); $payment->save(); $payment = Payment::create([ 'id' => 'AAA7', 'status' => PaymentProvider::STATUS_PAID, 'type' => PaymentProvider::TYPE_RECURRING, 'description' => 'Payment OK', 'wallet_id' => $wallet->id, 'provider' => 'stripe', 'amount' => 100, ]); $payment->updated_at = Carbon::create(2020, 5, 31, 23, 59, 0); $payment->save(); // Make sure some config is set so we can test it's put into the receipt if (empty(\config('app.company.name'))) { \config(['app.company.name' => 'Company Co.']); } if (empty(\config('app.company.email'))) { \config(['app.company.email' => 'email@domina.tld']); } if (empty(\config('app.company.details'))) { \config(['app.company.details' => 'VAT No. 123456789']); } if (empty(\config('app.company.address'))) { \config(['app.company.address' => "Test Street 12\n12345 Some Place"]); } return $wallet; } /** * Extract text from a HTML element replacing
with \n * * @param \DOMElement $node The HTML element * * @return string The content */ protected function getNodeContent(\DOMElement $node) { $content = []; foreach ($node->childNodes as $child) { if ($child->nodeName == 'br') { $content[] = "\n"; } else { $content[] = $child->textContent; } } return trim(implode($content)); } } diff --git a/src/tests/Feature/DomainOwnerTest.php b/src/tests/Feature/DomainOwnerTest.php index ea0a5c84..42d5d556 100644 --- a/src/tests/Feature/DomainOwnerTest.php +++ b/src/tests/Feature/DomainOwnerTest.php @@ -1,46 +1,52 @@ deleteTestUser('jane@kolab.org'); } + /** + * {@inheritDoc} + */ public function tearDown(): void { $this->deleteTestUser('jane@kolab.org'); parent::tearDown(); } public function testJohnCreateJane(): void { $john = User::where('email', 'john@kolab.org')->first(); $jane = User::create( [ 'name' => 'Jane Doe', 'email' => 'jane@kolab.org', 'password' => 'simple123', 'email_verified_at' => now() ] ); $package = Package::where('title', 'kolab')->first(); $john->assignPackage($package, $jane); // assert jane has a mailbox entitlement $this->assertTrue($jane->entitlements->count() == 4); } } diff --git a/src/tests/Feature/DomainTest.php b/src/tests/Feature/DomainTest.php index 5bce4235..9fdec367 100644 --- a/src/tests/Feature/DomainTest.php +++ b/src/tests/Feature/DomainTest.php @@ -1,193 +1,199 @@ domains as $domain) { $this->deleteTestDomain($domain); } } + /** + * {@inheritDoc} + */ public function tearDown(): void { foreach ($this->domains as $domain) { $this->deleteTestDomain($domain); } parent::tearDown(); } /** * 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\DomainCreate::class, 1); Queue::assertPushed( \App\Jobs\DomainCreate::class, function ($job) use ($domain) { $job_domain = TestCase::getObjectProperty($job, 'domain'); return $job_domain->id === $domain->id && $job_domain->namespace === $domain->namespace; } ); $job = new \App\Jobs\DomainCreate($domain); $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); } /** * 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\DomainDelete($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()); } } diff --git a/src/tests/Feature/EntitlementTest.php b/src/tests/Feature/EntitlementTest.php index 3886e056..7f86d0a4 100644 --- a/src/tests/Feature/EntitlementTest.php +++ b/src/tests/Feature/EntitlementTest.php @@ -1,168 +1,186 @@ 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(); } 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 User::AddEntitlement() */ public function testUserAddEntitlement(): 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); } public function testAddExistingEntitlement(): void { $this->markTestIncomplete(); } public function testEntitlementFunctions(): void { $user = $this->getTestUser('entitlement-test@kolabnow.com'); $package = \App\Package::where('title', 'kolab')->first(); $user->assignPackage($package); $wallet = $user->wallets()->first(); $this->assertNotNull($wallet); $sku = \App\Sku::where('title', 'mailbox')->first(); $this->assertNotNull($sku); $entitlement = Entitlement::where('wallet_id', $wallet->id) ->where('sku_id', $sku->id)->first(); $this->assertNotNull($entitlement); $eSKU = $entitlement->sku; $this->assertSame($sku->id, $eSKU->id); $eWallet = $entitlement->wallet; $this->assertSame($wallet->id, $eWallet->id); $eEntitleable = $entitlement->entitleable; $this->assertEquals($user->id, $eEntitleable->id); $this->assertTrue($eEntitleable instanceof \App\User); } 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(); - $this->backdateEntitlements($user->entitlements, Carbon::now()->subWeeks(7)); + $backdate = Carbon::now()->subWeeks(7); + $this->backdateEntitlements($user->entitlements, $backdate); $charge = $wallet->chargeEntitlements(); - $this->assertTrue($wallet->balance < 0); + $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. - $this->assertTrue($wallet->fresh()->balance < $balance); + // 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/PaymentEmailTest.php b/src/tests/Feature/Jobs/PaymentEmailTest.php index 99df3fe7..9ef6cfca 100644 --- a/src/tests/Feature/Jobs/PaymentEmailTest.php +++ b/src/tests/Feature/Jobs/PaymentEmailTest.php @@ -1,120 +1,120 @@ deleteTestUser('PaymentEmail@UserAccount.com'); } /** * {@inheritDoc} * * @return void */ public function tearDown(): void { $this->deleteTestUser('PaymentEmail@UserAccount.com'); parent::tearDown(); } /** * Test job handle * * @return void */ public function testHandle() { $user = $this->getTestUser('PaymentEmail@UserAccount.com'); $user->setSetting('external_email', 'ext@email.tld'); $wallet = $user->wallets()->first(); $payment = new Payment(); $payment->id = 'test-payment'; $payment->wallet_id = $wallet->id; $payment->amount = 100; $payment->status = PaymentProvider::STATUS_PAID; $payment->description = 'test'; $payment->provider = 'stripe'; $payment->type = PaymentProvider::TYPE_ONEOFF; $payment->save(); Mail::fake(); // Assert that no jobs were pushed... Mail::assertNothingSent(); $job = new PaymentEmail($payment); $job->handle(); // Assert the email sending job was pushed once Mail::assertSent(PaymentSuccess::class, 1); // Assert the mail was sent to the user's email - Mail::assertSent(PaymentSuccess::class, function ($mail) use ($user) { - return $mail->hasTo($user->email) && $mail->hasCc('ext@email.tld'); + Mail::assertSent(PaymentSuccess::class, function ($mail) { + return $mail->hasTo('ext@email.tld') && !$mail->hasCc('ext@email.tld'); }); $payment->status = PaymentProvider::STATUS_FAILED; $payment->save(); $job = new PaymentEmail($payment); $job->handle(); // Assert the email sending job was pushed once Mail::assertSent(PaymentFailure::class, 1); // Assert the mail was sent to the user's email - Mail::assertSent(PaymentFailure::class, function ($mail) use ($user) { - return $mail->hasTo($user->email) && $mail->hasCc('ext@email.tld'); + Mail::assertSent(PaymentFailure::class, function ($mail) { + return $mail->hasTo('ext@email.tld') && !$mail->hasCc('ext@email.tld'); }); $payment->status = PaymentProvider::STATUS_EXPIRED; $payment->save(); $job = new PaymentEmail($payment); $job->handle(); // Assert the email sending job was pushed twice Mail::assertSent(PaymentFailure::class, 2); // None of statuses below should trigger an email Mail::fake(); $states = [ PaymentProvider::STATUS_OPEN, PaymentProvider::STATUS_CANCELED, PaymentProvider::STATUS_PENDING, PaymentProvider::STATUS_AUTHORIZED, ]; foreach ($states as $state) { $payment->status = $state; $payment->save(); $job = new PaymentEmail($payment); $job->handle(); } // Assert that no mailables were sent... Mail::assertNothingSent(); } } diff --git a/src/tests/Feature/Jobs/PaymentMandateDisabledEmailTest.php b/src/tests/Feature/Jobs/PaymentMandateDisabledEmailTest.php index fa466a74..f2c90556 100644 --- a/src/tests/Feature/Jobs/PaymentMandateDisabledEmailTest.php +++ b/src/tests/Feature/Jobs/PaymentMandateDisabledEmailTest.php @@ -1,63 +1,63 @@ deleteTestUser('PaymentEmail@UserAccount.com'); } /** * {@inheritDoc} * * @return void */ public function tearDown(): void { $this->deleteTestUser('PaymentEmail@UserAccount.com'); parent::tearDown(); } /** * Test job handle * * @return void */ public function testHandle() { $user = $this->getTestUser('PaymentEmail@UserAccount.com'); $user->setSetting('external_email', 'ext@email.tld'); $wallet = $user->wallets()->first(); Mail::fake(); // Assert that no jobs were pushed... Mail::assertNothingSent(); $job = new PaymentMandateDisabledEmail($wallet); $job->handle(); // Assert the email sending job was pushed once Mail::assertSent(PaymentMandateDisabled::class, 1); // Assert the mail was sent to the user's email - Mail::assertSent(PaymentMandateDisabled::class, function ($mail) use ($user) { - return $mail->hasTo($user->email) && $mail->hasCc('ext@email.tld'); + Mail::assertSent(PaymentMandateDisabled::class, function ($mail) { + return $mail->hasTo('ext@email.tld') && !$mail->hasCc('ext@email.tld'); }); } } diff --git a/src/tests/Feature/Jobs/UserUpdateTest.php b/src/tests/Feature/Jobs/UserUpdateTest.php index 120a0ffd..30961521 100644 --- a/src/tests/Feature/Jobs/UserUpdateTest.php +++ b/src/tests/Feature/Jobs/UserUpdateTest.php @@ -1,83 +1,86 @@ deleteTestUser('new-job-user@' . \config('app.domain')); } + /** + * {@inheritDoc} + */ public function tearDown(): void { $this->deleteTestUser('new-job-user@' . \config('app.domain')); parent::tearDown(); } /** * Test job handle * * @group ldap */ public function testHandle(): void { // Ignore any jobs created here (e.g. on setAliases() use) Queue::fake(); $user = $this->getTestUser('new-job-user@' . \config('app.domain')); // Create the user in LDAP $job = new \App\Jobs\UserCreate($user); $job->handle(); // Test setting two aliases $aliases = [ 'new-job-user1@' . \config('app.domain'), 'new-job-user2@' . \config('app.domain'), ]; $user->setAliases($aliases); $job = new UserUpdate($user->fresh()); $job->handle(); $ldap_user = LDAP::getUser('new-job-user@' . \config('app.domain')); $this->assertSame($aliases, $ldap_user['alias']); // Test updating aliases list $aliases = [ 'new-job-user1@' . \config('app.domain'), ]; $user->setAliases($aliases); $job = new UserUpdate($user->fresh()); $job->handle(); $ldap_user = LDAP::getUser('new-job-user@' . \config('app.domain')); $this->assertSame($aliases, (array) $ldap_user['alias']); // Test unsetting aliases list $aliases = []; $user->setAliases($aliases); $job = new UserUpdate($user->fresh()); $job->handle(); $ldap_user = LDAP::getUser('new-job-user@' . \config('app.domain')); $this->assertTrue(empty($ldap_user['alias'])); } } diff --git a/src/tests/Feature/Jobs/UserVerifyTest.php b/src/tests/Feature/Jobs/UserVerifyTest.php index 2876bc8b..acb35a14 100644 --- a/src/tests/Feature/Jobs/UserVerifyTest.php +++ b/src/tests/Feature/Jobs/UserVerifyTest.php @@ -1,62 +1,68 @@ getTestUser('ned@kolab.org'); $ned->status |= User::STATUS_IMAP_READY; $ned->save(); } + /** + * {@inheritDoc} + */ public function tearDown(): void { $ned = $this->getTestUser('ned@kolab.org'); $ned->status |= User::STATUS_IMAP_READY; $ned->save(); parent::tearDown(); } /** * Test job handle * * @group imap */ public function testHandle(): void { Queue::fake(); $user = $this->getTestUser('ned@kolab.org'); if ($user->isImapReady()) { $user->status ^= User::STATUS_IMAP_READY; $user->save(); } $this->assertFalse($user->isImapReady()); for ($i = 0; $i < 10; $i++) { $job = new UserVerify($user); $job->handle(); if ($user->fresh()->isImapReady()) { $this->assertTrue(true); return; } sleep(1); } $this->assertTrue(false, "Unable to verify the IMAP account is set up in time"); } } diff --git a/src/tests/Feature/Jobs/WalletCheckTest.php b/src/tests/Feature/Jobs/WalletCheckTest.php new file mode 100644 index 00000000..9dbfb406 --- /dev/null +++ b/src/tests/Feature/Jobs/WalletCheckTest.php @@ -0,0 +1,275 @@ +getTestUser('ned@kolab.org'); + if ($ned->isSuspended()) { + $ned->status -= User::STATUS_SUSPENDED; + $ned->save(); + } + + $this->deleteTestUser('wallet-check@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $ned = $this->getTestUser('ned@kolab.org'); + if ($ned->isSuspended()) { + $ned->status -= User::STATUS_SUSPENDED; + $ned->save(); + } + + $this->deleteTestUser('wallet-check@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test job handle, initial negative-balance notification + */ + public function testHandleInitial(): void + { + Mail::fake(); + + $user = $this->getTestUser('ned@kolab.org'); + $user->setSetting('external_email', 'external@test.com'); + $wallet = $user->wallets()->first(); + $now = Carbon::now(); + + // Balance is not negative, double-update+save for proper resetting of the state + $wallet->balance = -100; + $wallet->save(); + $wallet->balance = 0; + $wallet->save(); + + $job = new WalletCheck($wallet); + $job->handle(); + + Mail::assertNothingSent(); + + // Balance is negative now + $wallet->balance = -100; + $wallet->save(); + + $job = new WalletCheck($wallet); + $job->handle(); + + Mail::assertNothingSent(); + + // Balance turned negative 2 hours ago, expect mail sent + $wallet->setSetting('balance_negative_since', $now->subHours(2)->toDateTimeString()); + $wallet->setSetting('balance_warning_initial', null); + + $job = new WalletCheck($wallet); + $job->handle(); + + // Assert the mail was sent to the user's email, but not to his external email + Mail::assertSent(\App\Mail\NegativeBalance::class, 1); + Mail::assertSent(\App\Mail\NegativeBalance::class, function ($mail) use ($user) { + return $mail->hasTo($user->email) && !$mail->hasCc('external@test.com'); + }); + + // Run the job again to make sure the notification is not sent again + Mail::fake(); + $job = new WalletCheck($wallet); + $job->handle(); + + Mail::assertNothingSent(); + + // Test the migration scenario where a negative wallet has no balance_negative_since set yet + Mail::fake(); + $wallet->setSetting('balance_negative_since', null); + $wallet->setSetting('balance_warning_initial', null); + + $job = new WalletCheck($wallet); + $job->handle(); + + // Assert the mail was sent to the user's email, but not to his external email + Mail::assertSent(\App\Mail\NegativeBalance::class, 1); + Mail::assertSent(\App\Mail\NegativeBalance::class, function ($mail) use ($user) { + return $mail->hasTo($user->email) && !$mail->hasCc('external@test.com'); + }); + + $wallet->refresh(); + $today_regexp = '/' . Carbon::now()->toDateString() . ' [0-9]{2}:[0-9]{2}:[0-9]{2}/'; + $this->assertRegExp($today_regexp, $wallet->getSetting('balance_negative_since')); + $this->assertRegExp($today_regexp, $wallet->getSetting('balance_warning_initial')); + } + + /** + * Test job handle, reminder notification + * + * @depends testHandleInitial + */ + public function testHandleReminder(): void + { + Mail::fake(); + + $user = $this->getTestUser('ned@kolab.org'); + $user->setSetting('external_email', 'external@test.com'); + $wallet = $user->wallets()->first(); + $now = Carbon::now(); + + // Balance turned negative 7+1 days ago, expect mail sent + $wallet->setSetting('balance_negative_since', $now->subDays(7 + 1)->toDateTimeString()); + + $job = new WalletCheck($wallet); + $job->handle(); + + // Assert the mail was sent to the user's email, but not to his external email + Mail::assertSent(\App\Mail\NegativeBalanceReminder::class, 1); + Mail::assertSent(\App\Mail\NegativeBalanceReminder::class, function ($mail) use ($user) { + return $mail->hasTo($user->email) && !$mail->hasCc('external@test.com'); + }); + + // Run the job again to make sure the notification is not sent again + Mail::fake(); + $job = new WalletCheck($wallet); + $job->handle(); + + Mail::assertNothingSent(); + } + + /** + * Test job handle, account suspending + * + * @depends testHandleReminder + */ + public function testHandleSuspended(): void + { + Mail::fake(); + + $user = $this->getTestUser('ned@kolab.org'); + $user->setSetting('external_email', 'external@test.com'); + $wallet = $user->wallets()->first(); + $now = Carbon::now(); + + // Balance turned negative 7+14+1 days ago, expect mail sent + $days = 7 + 14 + 1; + $wallet->setSetting('balance_negative_since', $now->subDays($days)->toDateTimeString()); + + $job = new WalletCheck($wallet); + $job->handle(); + + // Assert the mail was sent to the user's email, but not to his external email + Mail::assertSent(\App\Mail\NegativeBalanceSuspended::class, 1); + Mail::assertSent(\App\Mail\NegativeBalanceSuspended::class, function ($mail) use ($user) { + return $mail->hasTo($user->email) && !$mail->hasCc('external@test.com'); + }); + + // Check that it has been suspended + $this->assertTrue($user->fresh()->isSuspended()); + + // TODO: Test that group account members/domain are also being suspended + /* + foreach ($wallet->entitlements()->fresh()->get() as $entitlement) { + if ( + $entitlement->entitleable_type == \App\Domain::class + || $entitlement->entitleable_type == \App\User::class + ) { + $this->assertTrue($entitlement->entitleable->isSuspended()); + } + } + */ + + // Run the job again to make sure the notification is not sent again + Mail::fake(); + $job = new WalletCheck($wallet); + $job->handle(); + + Mail::assertNothingSent(); + } + + /** + * Test job handle, final warning before delete + * + * @depends testHandleSuspended + */ + public function testHandleBeforeDelete(): void + { + Mail::fake(); + + $user = $this->getTestUser('ned@kolab.org'); + $user->setSetting('external_email', 'external@test.com'); + $wallet = $user->wallets()->first(); + $now = Carbon::now(); + + // Balance turned negative 7+14+21-3+1 days ago, expect mail sent + $days = 7 + 14 + 21 - 3 + 1; + $wallet->setSetting('balance_negative_since', $now->subDays($days)->toDateTimeString()); + + $job = new WalletCheck($wallet); + $job->handle(); + + // Assert the mail was sent to the user's email, and his external email + Mail::assertSent(\App\Mail\NegativeBalanceBeforeDelete::class, 1); + Mail::assertSent(\App\Mail\NegativeBalanceBeforeDelete::class, function ($mail) use ($user) { + return $mail->hasTo($user->email) && $mail->hasCc('external@test.com'); + }); + + // Check that it has not been deleted yet + $this->assertFalse($user->fresh()->isDeleted()); + + // Run the job again to make sure the notification is not sent again + Mail::fake(); + $job = new WalletCheck($wallet); + $job->handle(); + + Mail::assertNothingSent(); + } + + /** + * Test job handle, account delete + * + * @depends testHandleBeforeDelete + */ + public function testHandleDelete(): void + { + Mail::fake(); + + $user = $this->getTestUser('wallet-check@kolabnow.com'); + $wallet = $user->wallets()->first(); + $wallet->balance = -100; + $wallet->save(); + $now = Carbon::now(); + + $package = \App\Package::where('title', 'kolab')->first(); + $user->assignPackage($package); + + $this->assertFalse($user->isDeleted()); + $this->assertCount(4, $user->entitlements()->get()); + + // Balance turned negative 7+14+21+1 days ago, expect mail sent + $days = 7 + 14 + 21 + 1; + $wallet->setSetting('balance_negative_since', $now->subDays($days)->toDateTimeString()); + + $job = new WalletCheck($wallet); + $job->handle(); + + Mail::assertNothingSent(); + + // Check that it has not been deleted + $this->assertTrue($user->fresh()->trashed()); + $this->assertCount(0, $user->entitlements()->get()); + + // TODO: Test it deletes all members of the group account + } +} diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php index 8cf3d562..ce228d12 100644 --- a/src/tests/Feature/UserTest.php +++ b/src/tests/Feature/UserTest.php @@ -1,525 +1,532 @@ deleteTestUser('user-test@' . \config('app.domain')); $this->deleteTestUser('UserAccountA@UserAccount.com'); $this->deleteTestUser('UserAccountB@UserAccount.com'); $this->deleteTestUser('UserAccountC@UserAccount.com'); $this->deleteTestDomain('UserAccount.com'); } public function tearDown(): void { $this->deleteTestUser('user-test@' . \config('app.domain')); $this->deleteTestUser('UserAccountA@UserAccount.com'); $this->deleteTestUser('UserAccountB@UserAccount.com'); $this->deleteTestUser('UserAccountC@UserAccount.com'); $this->deleteTestDomain('UserAccount.com'); parent::tearDown(); } /** * Tests for User::assignPackage() */ public function testAssignPackage(): void { $this->markTestIncomplete(); } /** * Tests for User::assignPlan() */ public function testAssignPlan(): void { $this->markTestIncomplete(); } /** * Tests for User::assignSku() */ public function testAssignSku(): void { $this->markTestIncomplete(); } /** * Verify user creation process */ public function testUserCreateJob(): void { // Fake the queue, assert that no jobs were pushed... Queue::fake(); Queue::assertNothingPushed(); $user = User::create([ 'email' => 'user-test@' . \config('app.domain') ]); Queue::assertPushed(\App\Jobs\UserCreate::class, 1); Queue::assertPushed(\App\Jobs\UserCreate::class, function ($job) use ($user) { $job_user = TestCase::getObjectProperty($job, 'user'); return $job_user->id === $user->id && $job_user->email === $user->email; }); Queue::assertPushedWithChain(\App\Jobs\UserCreate::class, [ \App\Jobs\UserVerify::class, ]); /* FIXME: Looks like we can't really do detailed assertions on chained jobs Another thing to consider is if we maybe should run these jobs independently (not chained) and make sure there's no race-condition in status update Queue::assertPushed(\App\Jobs\UserVerify::class, 1); Queue::assertPushed(\App\Jobs\UserVerify::class, function ($job) use ($user) { $job_user = TestCase::getObjectProperty($job, 'user'); return $job_user->id === $user->id && $job_user->email === $user->email; }); */ } /** * Verify a wallet assigned a controller is among the accounts of the assignee. */ public function testListUserAccounts(): void { $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $this->assertTrue($userA->wallets()->count() == 1); $userA->wallets()->each( function ($wallet) use ($userB) { $wallet->addController($userB); } ); $this->assertTrue($userB->accounts()->get()[0]->id === $userA->wallets()->get()[0]->id); } public function testAccounts(): void { $this->markTestIncomplete(); } public function testCanDelete(): void { $this->markTestIncomplete(); } public function testCanRead(): void { $this->markTestIncomplete(); } public function testCanUpdate(): void { $this->markTestIncomplete(); } /** * Tests for User::domains() */ public function testDomains(): void { $user = $this->getTestUser('john@kolab.org'); $domains = []; foreach ($user->domains() as $domain) { $domains[] = $domain->namespace; } $this->assertContains(\config('app.domain'), $domains); $this->assertContains('kolab.org', $domains); // Jack is not the wallet controller, so for him the list should not // include John's domains, kolab.org specifically $user = $this->getTestUser('jack@kolab.org'); $domains = []; foreach ($user->domains() as $domain) { $domains[] = $domain->namespace; } $this->assertContains(\config('app.domain'), $domains); $this->assertNotContains('kolab.org', $domains); } public function testUserQuota(): void { // TODO: This test does not test much, probably could be removed // or moved to somewhere else, or extended with // other entitlements() related cases. $user = $this->getTestUser('john@kolab.org'); $storage_sku = \App\Sku::where('title', 'storage')->first(); $count = 0; foreach ($user->entitlements()->get() as $entitlement) { if ($entitlement->sku_id == $storage_sku->id) { $count += 1; } } $this->assertTrue($count == 2); } /** * Test user deletion */ public function testDelete(): void { Queue::fake(); $user = $this->getTestUser('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\UserDelete($id); $job->handle(); $this->assertTrue(User::withTrashed()->where('id', $id)->first()->isDeleted()); $user->forceDelete(); $this->assertCount(0, User::withTrashed()->where('id', $id)->get()); // Test an account with users $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $userC = $this->getTestUser('UserAccountC@UserAccount.com'); $package_kolab = \App\Package::where('title', 'kolab')->first(); $package_domain = \App\Package::where('title', 'domain-hosting')->first(); $domain = $this->getTestDomain('UserAccount.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $userA->assignPackage($package_kolab); $domain->assignPackage($package_domain, $userA); $userA->assignPackage($package_kolab, $userB); $userA->assignPackage($package_kolab, $userC); $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); $entitlementsC = \App\Entitlement::where('entitleable_id', $userC->id); $entitlementsDomain = \App\Entitlement::where('entitleable_id', $domain->id); $this->assertSame(4, $entitlementsA->count()); $this->assertSame(4, $entitlementsB->count()); $this->assertSame(4, $entitlementsC->count()); $this->assertSame(1, $entitlementsDomain->count()); // Delete non-controller user $userC->delete(); $this->assertTrue($userC->fresh()->trashed()); $this->assertFalse($userC->fresh()->isDeleted()); $this->assertSame(0, $entitlementsC->count()); // Delete the controller (and expect "sub"-users to be deleted too) $userA->delete(); $this->assertSame(0, $entitlementsA->count()); $this->assertSame(0, $entitlementsB->count()); $this->assertSame(0, $entitlementsDomain->count()); $this->assertTrue($userA->fresh()->trashed()); $this->assertTrue($userB->fresh()->trashed()); $this->assertTrue($domain->fresh()->trashed()); $this->assertFalse($userA->isDeleted()); $this->assertFalse($userB->isDeleted()); $this->assertFalse($domain->isDeleted()); } /** * Tests for User::emailExists() */ public function testEmailExists(): void { $this->markTestIncomplete(); } /** * 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)); } /** * 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\UserUpdate::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\UserUpdate::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\UserUpdate::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\UserUpdate::class, 4); $this->assertCount(0, $user->aliases()->get()); + // The test below fail since we removed validation code from the UserAliasObserver + $this->markTestIncomplete(); + // Test sanity checks in UserAliasObserver Queue::fake(); // Existing user $user->setAliases(['john@kolab.org']); $this->assertCount(0, $user->aliases()->get()); // Existing alias (in another account) $user->setAliases(['john.doe@kolab.org']); $this->assertCount(0, $user->aliases()->get()); Queue::assertNothingPushed(); // Existing user (in the same group account) $ned = $this->getTestUser('ned@kolab.org'); $ned->setAliases(['john@kolab.org']); $this->assertCount(0, $ned->aliases()->get()); // Existing alias (in the same group account) $ned = $this->getTestUser('ned@kolab.org'); $ned->setAliases(['john.doe@kolab.org']); $this->assertSame('john.doe@kolab.org', $ned->aliases()->first()->alias); // Existing alias (in another account, public domain) $user->setAliases(['alias@kolabnow.com']); $ned->setAliases(['alias@kolabnow.com']); $this->assertCount(0, $ned->aliases()->get()); // cleanup $ned->setAliases([]); } /** * Tests for UserSettingsTrait::setSettings() and getSetting() */ public function testUserSettings(): void { Queue::fake(); Queue::assertNothingPushed(); $user = $this->getTestUser('UserAccountA@UserAccount.com'); Queue::assertPushed(\App\Jobs\UserUpdate::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\UserUpdate::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\UserUpdate::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\UserUpdate::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\UserUpdate::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\UserUpdate::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 c0161ee8..7dc96664 100644 --- a/src/tests/Feature/WalletTest.php +++ b/src/tests/Feature/WalletTest.php @@ -1,256 +1,284 @@ users as $user) { $this->deleteTestUser($user); } } public function tearDown(): void { foreach ($this->users as $user) { $this->deleteTestUser($user); } 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()->toDateString(), $until->toDateString()); + $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()->addDays($daysInLastMonth)->toDateString(), + 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); } } diff --git a/src/tests/TestCase.php b/src/tests/TestCase.php index 169ca95c..7f3e2d29 100644 --- a/src/tests/TestCase.php +++ b/src/tests/TestCase.php @@ -1,31 +1,35 @@ created_at = $targetDate; $entitlement->updated_at = $targetDate; $entitlement->save(); + + $owner = $entitlement->wallet->owner; + $owner->created_at = $targetDate; + $owner->save(); } } /** * 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')); } } diff --git a/src/tests/Unit/Mail/HelperTest.php b/src/tests/Unit/Mail/HelperTest.php new file mode 100644 index 00000000..a8fdb980 --- /dev/null +++ b/src/tests/Unit/Mail/HelperTest.php @@ -0,0 +1,84 @@ +deleteTestUser('mail-helper-test@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('mail-helper-test@kolabnow.com'); + parent::tearDown(); + } + + /** + * Test Helper::userEmails() + */ + public function testUserEmails(): void + { + $user = $this->getTestUser('mail-helper-test@kolabnow.com'); + + // User with no mailbox and no external email + list($to, $cc) = Helper::userEmails($user); + + $this->assertSame(null, $to); + $this->assertSame([], $cc); + + list($to, $cc) = Helper::userEmails($user, true); + + $this->assertSame(null, $to); + $this->assertSame([], $cc); + + // User with no mailbox but with external email + $user->setSetting('external_email', 'external@test.com'); + list($to, $cc) = Helper::userEmails($user); + + $this->assertSame('external@test.com', $to); + $this->assertSame([], $cc); + + list($to, $cc) = Helper::userEmails($user, true); + + $this->assertSame('external@test.com', $to); + $this->assertSame([], $cc); + + // User with mailbox and external email + $sku = \App\Sku::where('title', 'mailbox')->first(); + $user->assignSku($sku); + + list($to, $cc) = Helper::userEmails($user); + + $this->assertSame($user->email, $to); + $this->assertSame([], $cc); + + list($to, $cc) = Helper::userEmails($user, true); + + $this->assertSame($user->email, $to); + $this->assertSame(['external@test.com'], $cc); + + // User with mailbox, but no external email + $user->setSetting('external_email', null); + list($to, $cc) = Helper::userEmails($user); + + $this->assertSame($user->email, $to); + $this->assertSame([], $cc); + + list($to, $cc) = Helper::userEmails($user, true); + + $this->assertSame($user->email, $to); + $this->assertSame([], $cc); + } +} diff --git a/src/tests/Unit/Mail/NegativeBalanceTest.php b/src/tests/Unit/Mail/NegativeBalanceBeforeDeleteTest.php similarity index 61% copy from src/tests/Unit/Mail/NegativeBalanceTest.php copy to src/tests/Unit/Mail/NegativeBalanceBeforeDeleteTest.php index 5089511a..f3963900 100644 --- a/src/tests/Unit/Mail/NegativeBalanceTest.php +++ b/src/tests/Unit/Mail/NegativeBalanceBeforeDeleteTest.php @@ -1,53 +1,62 @@ getTestUser('ned@kolab.org'); + $wallet = $user->wallets->first(); + $wallet->balance = -100; + $wallet->save(); + + $threshold = WalletCheck::threshold($wallet, WalletCheck::THRESHOLD_DELETE); \config([ 'app.support_url' => 'https://kolab.org/support', ]); - $mail = $this->fakeMail(new NegativeBalance($user)); + $mail = $this->fakeMail(new NegativeBalanceBeforeDelete($wallet, $user)); $html = $mail['html']; $plain = $mail['plain']; $walletUrl = \App\Utils::serviceUrl('/wallet'); $walletLink = sprintf('%s', $walletUrl, $walletUrl); $supportUrl = \config('app.support_url'); $supportLink = sprintf('%s', $supportUrl, $supportUrl); $appName = \config('app.name'); - $this->assertMailSubject("$appName Payment Reminder", $mail['message']); + $this->assertMailSubject("$appName Final Warning", $mail['message']); $this->assertStringStartsWith('', $html); $this->assertTrue(strpos($html, $user->name(true)) > 0); $this->assertTrue(strpos($html, $walletLink) > 0); $this->assertTrue(strpos($html, $supportLink) > 0); - $this->assertTrue(strpos($html, "behind on paying for your $appName account") > 0); + $this->assertTrue(strpos($html, "This is a final reminder to settle your $appName") > 0); + $this->assertTrue(strpos($html, $threshold->toDateString()) > 0); $this->assertTrue(strpos($html, "$appName Support") > 0); $this->assertTrue(strpos($html, "$appName Team") > 0); $this->assertStringStartsWith('Dear ' . $user->name(true), $plain); $this->assertTrue(strpos($plain, $walletUrl) > 0); $this->assertTrue(strpos($plain, $supportUrl) > 0); - $this->assertTrue(strpos($plain, "behind on paying for your $appName account") > 0); + $this->assertTrue(strpos($plain, "This is a final reminder to settle your $appName") > 0); + $this->assertTrue(strpos($plain, $threshold->toDateString()) > 0); $this->assertTrue(strpos($plain, "$appName Support") > 0); $this->assertTrue(strpos($plain, "$appName Team") > 0); } } diff --git a/src/tests/Unit/Mail/NegativeBalanceTest.php b/src/tests/Unit/Mail/NegativeBalanceReminderTest.php similarity index 64% copy from src/tests/Unit/Mail/NegativeBalanceTest.php copy to src/tests/Unit/Mail/NegativeBalanceReminderTest.php index 5089511a..53755cbc 100644 --- a/src/tests/Unit/Mail/NegativeBalanceTest.php +++ b/src/tests/Unit/Mail/NegativeBalanceReminderTest.php @@ -1,53 +1,62 @@ getTestUser('ned@kolab.org'); + $wallet = $user->wallets->first(); + $wallet->balance = -100; + $wallet->save(); + + $threshold = WalletCheck::threshold($wallet, WalletCheck::THRESHOLD_SUSPEND); \config([ 'app.support_url' => 'https://kolab.org/support', ]); - $mail = $this->fakeMail(new NegativeBalance($user)); + $mail = $this->fakeMail(new NegativeBalanceReminder($wallet, $user)); $html = $mail['html']; $plain = $mail['plain']; $walletUrl = \App\Utils::serviceUrl('/wallet'); $walletLink = sprintf('%s', $walletUrl, $walletUrl); $supportUrl = \config('app.support_url'); $supportLink = sprintf('%s', $supportUrl, $supportUrl); $appName = \config('app.name'); $this->assertMailSubject("$appName Payment Reminder", $mail['message']); $this->assertStringStartsWith('', $html); $this->assertTrue(strpos($html, $user->name(true)) > 0); $this->assertTrue(strpos($html, $walletLink) > 0); $this->assertTrue(strpos($html, $supportLink) > 0); - $this->assertTrue(strpos($html, "behind on paying for your $appName account") > 0); + $this->assertTrue(strpos($html, "you are behind on paying for your $appName account") > 0); + $this->assertTrue(strpos($html, $threshold->toDateString()) > 0); $this->assertTrue(strpos($html, "$appName Support") > 0); $this->assertTrue(strpos($html, "$appName Team") > 0); $this->assertStringStartsWith('Dear ' . $user->name(true), $plain); $this->assertTrue(strpos($plain, $walletUrl) > 0); $this->assertTrue(strpos($plain, $supportUrl) > 0); - $this->assertTrue(strpos($plain, "behind on paying for your $appName account") > 0); + $this->assertTrue(strpos($plain, "you are behind on paying for your $appName account") > 0); + $this->assertTrue(strpos($plain, $threshold->toDateString()) > 0); $this->assertTrue(strpos($plain, "$appName Support") > 0); $this->assertTrue(strpos($plain, "$appName Team") > 0); } } diff --git a/src/tests/Unit/Mail/NegativeBalanceTest.php b/src/tests/Unit/Mail/NegativeBalanceSuspendedTest.php similarity index 62% copy from src/tests/Unit/Mail/NegativeBalanceTest.php copy to src/tests/Unit/Mail/NegativeBalanceSuspendedTest.php index 5089511a..7190ffab 100644 --- a/src/tests/Unit/Mail/NegativeBalanceTest.php +++ b/src/tests/Unit/Mail/NegativeBalanceSuspendedTest.php @@ -1,53 +1,62 @@ getTestUser('ned@kolab.org'); + $wallet = $user->wallets->first(); + $wallet->balance = -100; + $wallet->save(); + + $threshold = WalletCheck::threshold($wallet, WalletCheck::THRESHOLD_DELETE); \config([ 'app.support_url' => 'https://kolab.org/support', ]); - $mail = $this->fakeMail(new NegativeBalance($user)); + $mail = $this->fakeMail(new NegativeBalanceSuspended($wallet, $user)); $html = $mail['html']; $plain = $mail['plain']; $walletUrl = \App\Utils::serviceUrl('/wallet'); $walletLink = sprintf('%s', $walletUrl, $walletUrl); $supportUrl = \config('app.support_url'); $supportLink = sprintf('%s', $supportUrl, $supportUrl); $appName = \config('app.name'); - $this->assertMailSubject("$appName Payment Reminder", $mail['message']); + $this->assertMailSubject("$appName Account Suspended", $mail['message']); $this->assertStringStartsWith('', $html); $this->assertTrue(strpos($html, $user->name(true)) > 0); $this->assertTrue(strpos($html, $walletLink) > 0); $this->assertTrue(strpos($html, $supportLink) > 0); - $this->assertTrue(strpos($html, "behind on paying for your $appName account") > 0); + $this->assertTrue(strpos($html, "Your $appName account has been suspended") > 0); + $this->assertTrue(strpos($html, $threshold->toDateString()) > 0); $this->assertTrue(strpos($html, "$appName Support") > 0); $this->assertTrue(strpos($html, "$appName Team") > 0); $this->assertStringStartsWith('Dear ' . $user->name(true), $plain); $this->assertTrue(strpos($plain, $walletUrl) > 0); $this->assertTrue(strpos($plain, $supportUrl) > 0); - $this->assertTrue(strpos($plain, "behind on paying for your $appName account") > 0); + $this->assertTrue(strpos($plain, "Your $appName account has been suspended") > 0); + $this->assertTrue(strpos($plain, $threshold->toDateString()) > 0); $this->assertTrue(strpos($plain, "$appName Support") > 0); $this->assertTrue(strpos($plain, "$appName Team") > 0); } } diff --git a/src/tests/Unit/Mail/NegativeBalanceTest.php b/src/tests/Unit/Mail/NegativeBalanceTest.php index 5089511a..01285fa4 100644 --- a/src/tests/Unit/Mail/NegativeBalanceTest.php +++ b/src/tests/Unit/Mail/NegativeBalanceTest.php @@ -1,53 +1,55 @@ 'https://kolab.org/support', ]); - $mail = $this->fakeMail(new NegativeBalance($user)); + $mail = $this->fakeMail(new NegativeBalance($wallet, $user)); $html = $mail['html']; $plain = $mail['plain']; $walletUrl = \App\Utils::serviceUrl('/wallet'); $walletLink = sprintf('%s', $walletUrl, $walletUrl); $supportUrl = \config('app.support_url'); $supportLink = sprintf('%s', $supportUrl, $supportUrl); $appName = \config('app.name'); - $this->assertMailSubject("$appName Payment Reminder", $mail['message']); + $this->assertMailSubject("$appName Payment Required", $mail['message']); $this->assertStringStartsWith('', $html); $this->assertTrue(strpos($html, $user->name(true)) > 0); $this->assertTrue(strpos($html, $walletLink) > 0); $this->assertTrue(strpos($html, $supportLink) > 0); - $this->assertTrue(strpos($html, "behind on paying for your $appName account") > 0); + $this->assertTrue(strpos($html, "your $appName account balance has run into the nega") > 0); $this->assertTrue(strpos($html, "$appName Support") > 0); $this->assertTrue(strpos($html, "$appName Team") > 0); $this->assertStringStartsWith('Dear ' . $user->name(true), $plain); $this->assertTrue(strpos($plain, $walletUrl) > 0); $this->assertTrue(strpos($plain, $supportUrl) > 0); - $this->assertTrue(strpos($plain, "behind on paying for your $appName account") > 0); + $this->assertTrue(strpos($plain, "your $appName account balance has run into the nega") > 0); $this->assertTrue(strpos($plain, "$appName Support") > 0); $this->assertTrue(strpos($plain, "$appName Team") > 0); } } diff --git a/src/tests/Unit/Mail/PasswordResetTest.php b/src/tests/Unit/Mail/PasswordResetTest.php index 99be9a14..fb0f3dd8 100644 --- a/src/tests/Unit/Mail/PasswordResetTest.php +++ b/src/tests/Unit/Mail/PasswordResetTest.php @@ -1,50 +1,50 @@ 123456789, 'mode' => 'password-reset', 'code' => 'code', 'short_code' => 'short-code', ]); $code->user = new User([ 'name' => 'User Name', ]); $mail = $this->fakeMail(new PasswordReset($code)); $html = $mail['html']; $plain = $mail['plain']; - $url = Utils::serviceUrl('/login/reset/' . $code->short_code . '-' . $code->code); + $url = Utils::serviceUrl('/password-reset/' . $code->short_code . '-' . $code->code); $link = "$url"; $appName = \config('app.name'); $this->assertMailSubject("$appName Password Reset", $mail['message']); $this->assertStringStartsWith('', $html); $this->assertTrue(strpos($html, $link) > 0); $this->assertTrue(strpos($html, $code->user->name(true)) > 0); $this->assertStringStartsWith("Dear " . $code->user->name(true), $plain); $this->assertTrue(strpos($plain, $link) > 0); } } diff --git a/src/tests/Unit/UtilsTest.php b/src/tests/Unit/UtilsTest.php index 7f10ef3b..9771f36e 100644 --- a/src/tests/Unit/UtilsTest.php +++ b/src/tests/Unit/UtilsTest.php @@ -1,83 +1,104 @@ assertIsArray($result); $this->assertCount(0, $result); $set = ["a1"]; $result = \App\Utils::powerSet($set); $this->assertIsArray($result); $this->assertCount(1, $result); $this->assertTrue(in_array(["a1"], $result)); $set = ["a1", "a2"]; $result = \App\Utils::powerSet($set); $this->assertIsArray($result); $this->assertCount(3, $result); $this->assertTrue(in_array(["a1"], $result)); $this->assertTrue(in_array(["a2"], $result)); $this->assertTrue(in_array(["a1", "a2"], $result)); $set = ["a1", "a2", "a3"]; $result = \App\Utils::powerSet($set); $this->assertIsArray($result); $this->assertCount(7, $result); $this->assertTrue(in_array(["a1"], $result)); $this->assertTrue(in_array(["a2"], $result)); $this->assertTrue(in_array(["a3"], $result)); $this->assertTrue(in_array(["a1", "a2"], $result)); $this->assertTrue(in_array(["a1", "a3"], $result)); $this->assertTrue(in_array(["a2", "a3"], $result)); $this->assertTrue(in_array(["a1", "a2", "a3"], $result)); } + /** + * Test for Utils::serviceUrl() + */ + public function testServiceUrl(): void + { + $public_href = 'https://public.url/cockpit'; + $local_href = 'https://local.url/cockpit'; + + \config([ + 'app.url' => $local_href, + 'app.public_url' => '', + ]); + + $this->assertSame($local_href, Utils::serviceUrl('')); + $this->assertSame($local_href . '/unknown', Utils::serviceUrl('unknown')); + $this->assertSame($local_href . '/unknown', Utils::serviceUrl('/unknown')); + + \config([ + 'app.url' => $local_href, + 'app.public_url' => $public_href, + ]); + + $this->assertSame($public_href, Utils::serviceUrl('')); + $this->assertSame($public_href . '/unknown', Utils::serviceUrl('unknown')); + $this->assertSame($public_href . '/unknown', Utils::serviceUrl('/unknown')); + } + /** * Test for Utils::uuidInt() - * - * @return void */ - public function testUuidInt() + public function testUuidInt(): void { $result = Utils::uuidInt(); $this->assertTrue(is_int($result)); $this->assertTrue($result > 0); } /** * Test for Utils::uuidStr() - * - * @return void */ - public function testUuidStr() + public function testUuidStr(): void { $result = Utils::uuidStr(); $this->assertTrue(is_string($result)); $this->assertTrue(strlen($result) === 36); $this->assertTrue(preg_match('/[^a-f0-9-]/i', $result) === 0); } } diff --git a/src/webpack.mix.js b/src/webpack.mix.js index d3339196..17fdb91c 100644 --- a/src/webpack.mix.js +++ b/src/webpack.mix.js @@ -1,26 +1,29 @@ const mix = require('laravel-mix'); /* |-------------------------------------------------------------------------- | 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. | */ mix.webpackConfig({ + output: { + publicPath: process.env.MIX_ASSET_PATH + }, resolve: { alias: { 'jquery$': 'jquery/dist/jquery.slim.js', } } }) mix.js('resources/js/user.js', 'public/js') .js('resources/js/admin.js', 'public/js') .js('resources/js/meet.js', 'public/js') .sass('resources/sass/app.scss', 'public/css') .sass('resources/sass/document.scss', 'public/css');