diff --git a/src/app/Backends/LDAP.php b/src/app/Backends/LDAP.php index 9f258d6d..6f54b930 100644 --- a/src/app/Backends/LDAP.php +++ b/src/app/Backends/LDAP.php @@ -1,994 +1,990 @@ close(); self::$ldap = null; } } /** * Create a domain in LDAP. * * @param \App\Domain $domain The domain to create. * * @throws \Exception */ 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}"; + $domainBaseDN = self::baseDN($domain->namespace); $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)) { - $result = $ldap->add_entry($dn, $entry); - - if (!$result) { - self::throwException( - $ldap, - "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" - ); - } + self::addEntry( + $ldap, + $dn, + $entry, + "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" + ); } // 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)) { - $result = $ldap->add_entry($domainBaseDN, $entry); - - if (!$result) { - self::throwException( - $ldap, - "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" - ); - } + self::addEntry( + $ldap, + $domainBaseDN, + $entry, + "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" + ); } foreach (['Groups', 'People', 'Resources', 'Shared Folders'] as $item) { - if (!$ldap->get_entry("ou={$item},{$domainBaseDN}")) { - $result = $ldap->add_entry( - "ou={$item},{$domainBaseDN}", - [ - 'ou' => $item, - 'description' => $item, - 'objectclass' => [ - 'top', - 'organizationalunit' - ] + $itemDN = self::baseDN($domain->namespace, $item); + if (!$ldap->get_entry($itemDN)) { + $itemEntry = [ + 'ou' => $item, + 'description' => $item, + 'objectclass' => [ + 'top', + 'organizationalunit' ] - ); + ]; - if (!$result) { - self::throwException( - $ldap, - "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" - ); - } + self::addEntry( + $ldap, + $itemDN, + $itemEntry, + "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" + ); } } foreach (['kolab-admin'] as $item) { - if (!$ldap->get_entry("cn={$item},{$domainBaseDN}")) { - $result = $ldap->add_entry( - "cn={$item},{$domainBaseDN}", - [ - 'cn' => $item, - 'description' => "{$item} role", - 'objectclass' => [ - 'top', - 'ldapsubentry', - 'nsmanagedroledefinition', - 'nsroledefinition', - 'nssimpleroledefinition' - ] + $itemDN = "cn={$item},{$domainBaseDN}"; + if (!$ldap->get_entry($itemDN)) { + $itemEntry = [ + 'cn' => $item, + 'description' => "{$item} role", + 'objectclass' => [ + 'top', + 'ldapsubentry', + 'nsmanagedroledefinition', + 'nsroledefinition', + 'nssimpleroledefinition' ] - ); + ]; - if (!$result) { - self::throwException( - $ldap, - "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" - ); - } + self::addEntry( + $ldap, + $itemDN, + $itemEntry, + "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" + ); } } // TODO: Assign kolab-admin role to the owner? if (empty(self::$ldap)) { $ldap->close(); } } /** * Create a group in LDAP. * * @param \App\Group $group The group to create. * * @throws \Exception */ public static function createGroup(Group $group): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); - list($cn, $domainName) = explode('@', $group->email); - - $domain = $group->domain(); - - if (empty($domain)) { - self::throwException( - $ldap, - "Failed to create group {$group->email} in LDAP (" . __LINE__ . ")" - ); - } - - $hostedRootDN = \config('ldap.hosted.root_dn'); - - $domainBaseDN = "ou={$domain->namespace},{$hostedRootDN}"; - - $groupBaseDN = "ou=Groups,{$domainBaseDN}"; - - $dn = "cn={$cn},{$groupBaseDN}"; + $domainName = explode('@', $group->email, 2)[1]; + $cn = $ldap->quote_string($group->name); + $dn = "cn={$cn}," . self::baseDN($domainName, 'Groups'); $entry = [ - 'cn' => $cn, 'mail' => $group->email, 'objectclass' => [ 'top', 'groupofuniquenames', 'kolabgroupofuniquenames' ], - 'uniquemember' => [] ]; self::setGroupAttributes($ldap, $group, $entry); - $result = $ldap->add_entry($dn, $entry); - - if (!$result) { - self::throwException( - $ldap, - "Failed to create group {$group->email} in LDAP (" . __LINE__ . ")" - ); - } + self::addEntry( + $ldap, + $dn, + $entry, + "Failed to create group {$group->email} in LDAP (" . __LINE__ . ")" + ); 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. * * @throws \Exception */ 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)) { if (empty($dn)) { self::throwException($ldap, "Failed to create user {$user->email} in LDAP (" . __LINE__ . ")"); } self::setUserAttributes($user, $entry); - $result = $ldap->add_entry($dn, $entry); - - if (!$result) { - self::throwException( - $ldap, - "Failed to create user {$user->email} in LDAP (" . __LINE__ . ")" - ); - } + self::addEntry( + $ldap, + $dn, + $entry, + "Failed to create user {$user->email} in LDAP (" . __LINE__ . ")" + ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Delete a domain from LDAP. * * @param \App\Domain $domain The domain to delete * * @throws \Exception */ public static function deleteDomain(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}"; + $domainBaseDN = self::baseDN($domain->namespace); if ($ldap->get_entry($domainBaseDN)) { $result = $ldap->delete_entry_recursive($domainBaseDN); if (!$result) { self::throwException( $ldap, "Failed to delete domain {$domain->namespace} from LDAP (" . __LINE__ . ")" ); } } 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 (" . __LINE__ . ")" ); } } } if (empty(self::$ldap)) { $ldap->close(); } } /** * Delete a group from LDAP. * * @param \App\Group $group The group to delete. * * @throws \Exception */ public static function deleteGroup(Group $group): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); if (self::getGroupEntry($ldap, $group->email, $dn)) { $result = $ldap->delete_entry($dn); if (!$result) { self::throwException( $ldap, "Failed to delete group {$group->email} from LDAP (" . __LINE__ . ")" ); } } if (empty(self::$ldap)) { $ldap->close(); } } /** * Delete a user from LDAP. * * @param \App\User $user The user account to delete. * * @throws \Exception */ public static function deleteUser(User $user): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); if (self::getUserEntry($ldap, $user->email, $dn)) { $result = $ldap->delete_entry($dn); if (!$result) { self::throwException( $ldap, "Failed to delete user {$user->email} from LDAP (" . __LINE__ . ")" ); } } if (empty(self::$ldap)) { $ldap->close(); } } /** * Get a domain data from LDAP. * * @param string $namespace The domain name * * @return array|false|null * @throws \Exception */ public static function getDomain(string $namespace) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $ldapDomain = $ldap->find_domain($namespace); if ($ldapDomain) { $domain = $ldap->get_entry($ldapDomain['dn']); } if (empty(self::$ldap)) { $ldap->close(); } return $domain ?? null; } /** * Get a group data from LDAP. * * @param string $email The group email. * * @return array|false|null * @throws \Exception */ public static function getGroup(string $email) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $group = self::getGroupEntry($ldap, $email, $dn); if (empty(self::$ldap)) { $ldap->close(); } return $group; } /** * 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 (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Update a group in LDAP. * * @param \App\Group $group The group to update * * @throws \Exception */ public static function updateGroup(Group $group): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); - list($cn, $domainName) = explode('@', $group->email); - - $domain = $group->domain(); + $newEntry = $oldEntry = self::getGroupEntry($ldap, $group->email, $dn); - if (empty($domain)) { + if (empty($oldEntry)) { self::throwException( $ldap, "Failed to update group {$group->email} in LDAP (group not found)" ); } - $hostedRootDN = \config('ldap.hosted.root_dn'); - - $domainBaseDN = "ou={$domain->namespace},{$hostedRootDN}"; - - $groupBaseDN = "ou=Groups,{$domainBaseDN}"; - - $dn = "cn={$cn},{$groupBaseDN}"; - - $entry = [ - 'cn' => $cn, - 'mail' => $group->email, - 'objectclass' => [ - 'top', - 'groupofuniquenames', - 'kolabgroupofuniquenames' - ], - 'uniquemember' => [] - ]; - - $oldEntry = $ldap->get_entry($dn); - - self::setGroupAttributes($ldap, $group, $entry); + self::setGroupAttributes($ldap, $group, $newEntry); - $result = $ldap->modify_entry($dn, $oldEntry, $entry); + $result = $ldap->modify_entry($dn, $oldEntry, $newEntry); if (!is_array($result)) { self::throwException( $ldap, "Failed to update group {$group->email} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Update a user in LDAP. * * @param \App\User $user The user account to update. * * @throws \Exception */ 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) { self::throwException( $ldap, "Failed to update user {$user->email} in LDAP (user not found)" ); } self::setUserAttributes($user, $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 (" . __LINE__ . ")" ); } 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); $connected = $ldap->connect(); if (!$connected) { throw new \Exception("Failed to connect to LDAP"); } $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; } /** * Convert group member addresses in to valid entries. */ private static function setGroupAttributes($ldap, Group $group, &$entry) { $settings = $group->getSettings(['sender_policy']); $entry['kolaballowsmtpsender'] = json_decode($settings['sender_policy'] ?: '[]', true); + $entry['cn'] = $group->name; + $entry['uniquemember'] = []; + $groupDomain = explode('@', $group->email, 2)[1]; + $domainBaseDN = self::baseDN($groupDomain); $validMembers = []; - $domain = $group->domain(); - - $hostedRootDN = \config('ldap.hosted.root_dn'); - - $domainBaseDN = "ou={$domain->namespace},{$hostedRootDN}"; - foreach ($group->members as $member) { list($local, $domainName) = explode('@', $member); $memberDN = "uid={$member},ou=People,{$domainBaseDN}"; $memberEntry = $ldap->get_entry($memberDN); // if the member is in the local domain but doesn't exist, drop it - if ($domainName == $domain->namespace && !$memberEntry) { + if ($domainName == $groupDomain && !$memberEntry) { continue; } // add the member if not in the local domain if (!$memberEntry) { $memberEntry = [ 'cn' => $member, 'mail' => $member, 'objectclass' => [ 'top', 'inetorgperson', 'organizationalperson', 'person' ], 'sn' => 'unknown' ]; $ldap->add_entry($memberDN, $memberEntry); } $entry['uniquemember'][] = $memberDN; $validMembers[] = $member; } // Update members in sql (some might have been removed), // skip model events to not invoke another update job - $group->members = $validMembers; - Group::withoutEvents(function () use ($group) { - $group->save(); - }); + if ($group->members !== $validMembers) { + $group->members = $validMembers; + Group::withoutEvents(function () use ($group) { + $group->save(); + }); + } } /** * Set common user attributes */ private static function setUserAttributes(User $user, array &$entry) { $settings = $user->getSettings(['first_name', 'last_name', 'organization']); $firstName = $settings['first_name']; $lastName = $settings['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'] = $settings['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'); $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}"; } } /** * 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 group entry from LDAP. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $email Group email (mail) * @param string $dn Reference to group DN * * @return false|null|array Group entry, False on error, NULL if not found */ private static function getGroupEntry($ldap, $email, &$dn = null) { - list($_local, $_domain) = explode('@', $email, 2); + $domainName = explode('@', $email, 2)[1]; + $base_dn = self::baseDN($domainName, 'Groups'); - $domain = $ldap->find_domain($_domain); + $attrs = ['dn', 'cn', 'mail', 'uniquemember', 'objectclass', 'kolaballowsmtpsender']; - if (!$domain) { - return $domain; - } + // For groups we're using search() instead of get_entry() because + // a group name is not constant, so e.g. on update we might have + // the new name, but not the old one. Email address is constant. + $result = $ldap->search($base_dn, "(mail=$email)", "sub", $attrs); - $base_dn = $ldap->domain_root_dn($_domain); - $dn = "cn={$_local},ou=Groups,{$base_dn}"; + if ($result && $result->count() == 1) { + $entries = $result->entries(true); + $dn = key($entries); + $entry = $entries[$dn]; + $entry['dn'] = $dn; - $entry = $ldap->get_entry($dn); + return $entry; + } - return $entry ?: null; + return null; } /** * 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 + * @return ?array User entry, NULL if not found */ private static function getUserEntry($ldap, $email, &$dn = null, $full = false) { - list($_local, $_domain) = explode('@', $email, 2); - - $domain = $ldap->find_domain($_domain); + $domainName = explode('@', $email, 2)[1]; - if (!$domain) { - return $domain; - } - - $base_dn = $ldap->domain_root_dn($_domain); - $dn = "uid={$email},ou=People,{$base_dn}"; + $dn = "uid={$email}," . self::baseDN($domainName, 'People'); $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); } + /** + * A wrapper for Net_LDAP3::add_entry() with error handler + * + * @param \Net_LDAP3 $ldap Ldap connection + * @param string $dn Entry DN + * @param array $entry Entry attributes + * @param ?string $errorMsg A message to throw as an exception on error + * + * @throws \Exception + */ + private static function addEntry($ldap, string $dn, array $entry, $errorMsg = null) + { + // try/catch because Laravel converts warnings into exceptions + // and we want more human-friendly error message than that + try { + $result = $ldap->add_entry($dn, $entry); + } catch (\Exception $e) { + $result = false; + } + + if (!$result) { + if (!$errorMsg) { + $errorMsg = "LDAP Error (" . __LINE__ . ")"; + } + + if (isset($e)) { + $errorMsg .= ": " . $e->getMessage(); + } + + self::throwException($ldap, $errorMsg); + } + } + /** * 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); } + + /** + * Create a base DN string for specified object + * + * @param string $domainName Domain namespace + * @param ?string $ouName Optional name of the sub-tree (OU) + * + * @return string Full base DN + */ + private static function baseDN(string $domainName, string $ouName = null): string + { + $hostedRootDN = \config('ldap.hosted.root_dn'); + + $dn = "ou={$domainName},{$hostedRootDN}"; + + if ($ouName) { + $dn = "ou={$ouName},{$dn}"; + } + + return $dn; + } } diff --git a/src/app/Console/Commands/GroupsCommand.php b/src/app/Console/Commands/GroupsCommand.php new file mode 100644 index 00000000..05988a7b --- /dev/null +++ b/src/app/Console/Commands/GroupsCommand.php @@ -0,0 +1,12 @@ +id)) { throw new \Exception("Group not yet exists"); } if ($this->entitlements()->count()) { throw new \Exception("Group already assigned to a wallet"); } $sku = \App\Sku::withObjectTenantContext($this)->where('title', 'group')->first(); $exists = $wallet->entitlements()->where('sku_id', $sku->id)->count(); \App\Entitlement::create([ 'wallet_id' => $wallet->id, 'sku_id' => $sku->id, 'cost' => $exists >= $sku->units_free ? $sku->cost : 0, 'fee' => $exists >= $sku->units_free ? $sku->fee : 0, 'entitleable_id' => $this->id, 'entitleable_type' => Group::class ]); return $this; } /** * Returns group domain. * * @return ?\App\Domain The domain group belongs to, NULL if it does not exist */ public function domain(): ?Domain { list($local, $domainName) = explode('@', $this->email); return Domain::where('namespace', $domainName)->first(); } /** * Find whether an email address exists as a group (including deleted groups). * * @param string $email Email address * @param bool $return_group Return Group instance instead of boolean * * @return \App\Group|bool True or Group model object if found, False otherwise */ public static function emailExists(string $email, bool $return_group = false) { if (strpos($email, '@') === false) { return false; } $email = \strtolower($email); $group = self::withTrashed()->where('email', $email)->first(); if ($group) { return $return_group ? $group : true; } return false; } /** * Group members propert accessor. Converts internal comma-separated list into an array * * @param string $members Comma-separated list of email addresses * * @return array Email addresses of the group members, as an array */ public function getMembersAttribute($members): array { return $members ? explode(',', $members) : []; } /** * Returns whether this group is active. * * @return bool */ public function isActive(): bool { return ($this->status & self::STATUS_ACTIVE) > 0; } /** * Returns whether this group is deleted. * * @return bool */ public function isDeleted(): bool { return ($this->status & self::STATUS_DELETED) > 0; } /** * Returns whether this group is new. * * @return bool */ public function isNew(): bool { return ($this->status & self::STATUS_NEW) > 0; } /** * Returns whether this group is registered in LDAP. * * @return bool */ public function isLdapReady(): bool { return ($this->status & self::STATUS_LDAP_READY) > 0; } /** * Returns whether this group is suspended. * * @return bool */ public function isSuspended(): bool { return ($this->status & self::STATUS_SUSPENDED) > 0; } /** * Ensure the email is appropriately cased. * * @param string $email Group email address */ public function setEmailAttribute(string $email) { $this->attributes['email'] = strtolower($email); } /** * Ensure the members are appropriately formatted. * * @param array $members Email addresses of the group members */ public function setMembersAttribute(array $members): void { $members = array_unique(array_filter(array_map('strtolower', $members))); sort($members); $this->attributes['members'] = implode(',', $members); } /** * Group status mutator * * @throws \Exception */ public function setStatusAttribute($status) { $new_status = 0; $allowed_values = [ self::STATUS_NEW, self::STATUS_ACTIVE, self::STATUS_SUSPENDED, self::STATUS_DELETED, self::STATUS_LDAP_READY, ]; foreach ($allowed_values as $value) { if ($status & $value) { $new_status |= $value; $status ^= $value; } } if ($status > 0) { throw new \Exception("Invalid group status: {$status}"); } $this->attributes['status'] = $new_status; } /** * Suspend this group. * * @return void */ public function suspend(): void { if ($this->isSuspended()) { return; } $this->status |= Group::STATUS_SUSPENDED; $this->save(); } /** * Unsuspend this group. * * @return void */ public function unsuspend(): void { if (!$this->isSuspended()) { return; } $this->status ^= Group::STATUS_SUSPENDED; $this->save(); } } diff --git a/src/app/Http/Controllers/API/V4/Admin/GroupsController.php b/src/app/Http/Controllers/API/V4/Admin/GroupsController.php index d087261f..303add4f 100644 --- a/src/app/Http/Controllers/API/V4/Admin/GroupsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/GroupsController.php @@ -1,118 +1,119 @@ input('search')); $owner = trim(request()->input('owner')); $result = collect([]); if ($owner) { if ($owner = User::find($owner)) { foreach ($owner->wallets as $wallet) { $wallet->entitlements()->where('entitleable_type', Group::class)->get() ->each(function ($entitlement) use ($result) { $result->push($entitlement->entitleable); }); } - $result = $result->sortBy('namespace')->values(); + $result = $result->sortBy('name')->values(); } } elseif (!empty($search)) { if ($group = Group::where('email', $search)->first()) { $result->push($group); } } // Process the result $result = $result->map(function ($group) { $data = [ 'id' => $group->id, 'email' => $group->email, + 'name' => $group->name, ]; $data = array_merge($data, self::groupStatuses($group)); return $data; }); $result = [ 'list' => $result, 'count' => count($result), 'message' => \trans('app.search-foundxdistlists', ['x' => count($result)]), ]; return response()->json($result); } /** * Create a new group. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { return $this->errorResponse(404); } /** * Suspend a group * * @param \Illuminate\Http\Request $request The API request. * @param string $id Group identifier * * @return \Illuminate\Http\JsonResponse The response */ public function suspend(Request $request, $id) { $group = Group::find($id); if (!$this->checkTenant($group)) { return $this->errorResponse(404); } $group->suspend(); return response()->json([ 'status' => 'success', 'message' => \trans('app.distlist-suspend-success'), ]); } /** * Un-Suspend a group * * @param \Illuminate\Http\Request $request The API request. * @param string $id Group identifier * * @return \Illuminate\Http\JsonResponse The response */ public function unsuspend(Request $request, $id) { $group = Group::find($id); if (!$this->checkTenant($group)) { return $this->errorResponse(404); } $group->unsuspend(); return response()->json([ 'status' => 'success', 'message' => \trans('app.distlist-unsuspend-success'), ]); } } diff --git a/src/app/Http/Controllers/API/V4/GroupsController.php b/src/app/Http/Controllers/API/V4/GroupsController.php index 636b6b42..5022bf8d 100644 --- a/src/app/Http/Controllers/API/V4/GroupsController.php +++ b/src/app/Http/Controllers/API/V4/GroupsController.php @@ -1,541 +1,570 @@ errorResponse(404); } /** * Delete a group. * * @param int $id Group identifier * * @return \Illuminate\Http\JsonResponse The response */ public function destroy($id) { $group = Group::find($id); if (!$this->checkTenant($group)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canDelete($group)) { return $this->errorResponse(403); } $group->delete(); return response()->json([ 'status' => 'success', 'message' => \trans('app.distlist-delete-success'), ]); } /** * Show the form for editing the specified group. * * @param int $id Group identifier * * @return \Illuminate\Http\JsonResponse */ public function edit($id) { return $this->errorResponse(404); } /** * Listing of groups belonging to the authenticated user. * * The group-entitlements billed to the current user wallet(s) * * @return \Illuminate\Http\JsonResponse */ public function index() { $user = $this->guard()->user(); - $result = $user->groups()->orderBy('email')->get() + $result = $user->groups()->orderBy('name')->orderBy('email')->get() ->map(function (Group $group) { $data = [ 'id' => $group->id, 'email' => $group->email, + 'name' => $group->name, ]; $data = array_merge($data, self::groupStatuses($group)); return $data; }); return response()->json($result); } /** * Set the group configuration. * * @param int $id Group identifier * * @return \Illuminate\Http\JsonResponse|void */ public function setConfig($id) { $group = Group::find($id); if (!$this->checkTenant($group)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canUpdate($group)) { return $this->errorResponse(403); } $errors = $group->setConfig(request()->input()); if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } return response()->json([ 'status' => 'success', 'message' => \trans('app.distlist-setconfig-success'), ]); } /** * Display information of a group specified by $id. * * @param int $id The group to show information for. * * @return \Illuminate\Http\JsonResponse */ public function show($id) { $group = Group::find($id); if (!$this->checkTenant($group)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($group)) { return $this->errorResponse(403); } $response = $group->toArray(); $response = array_merge($response, self::groupStatuses($group)); $response['statusInfo'] = self::statusInfo($group); // Group configuration, e.g. sender_policy $response['config'] = $group->getConfig(); return response()->json($response); } /** * Fetch group status (and reload setup process) * * @param int $id Group identifier * * @return \Illuminate\Http\JsonResponse */ public function status($id) { $group = Group::find($id); if (!$this->checkTenant($group)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($group)) { return $this->errorResponse(403); } $response = self::statusInfo($group); if (!empty(request()->input('refresh'))) { $updated = false; $async = false; $last_step = 'none'; foreach ($response['process'] as $idx => $step) { $last_step = $step['label']; if (!$step['state']) { $exec = $this->execProcessStep($group, $step['label']); if (!$exec) { if ($exec === null) { $async = true; } break; } $updated = true; } } if ($updated) { $response = self::statusInfo($group); } $success = $response['isReady']; $suffix = $success ? 'success' : 'error-' . $last_step; $response['status'] = $success ? 'success' : 'error'; $response['message'] = \trans('app.process-' . $suffix); if ($async && !$success) { $response['processState'] = 'waiting'; $response['status'] = 'success'; $response['message'] = \trans('app.process-async'); } } $response = array_merge($response, self::groupStatuses($group)); return response()->json($response); } /** * Group status (extended) information * * @param \App\Group $group Group object * * @return array Status information */ public static function statusInfo(Group $group): array { $process = []; $steps = [ 'distlist-new' => true, 'distlist-ldap-ready' => $group->isLdapReady(), ]; // Create a process check list foreach ($steps as $step_name => $state) { $step = [ 'label' => $step_name, 'title' => \trans("app.process-{$step_name}"), 'state' => $state, ]; $process[] = $step; } $domain = $group->domain(); // If that is not a public domain, add domain specific steps if ($domain && !$domain->isPublic()) { $domain_status = DomainsController::statusInfo($domain); $process = array_merge($process, $domain_status['process']); } $all = count($process); $checked = count(array_filter($process, function ($v) { return $v['state']; })); $state = $all === $checked ? 'done' : 'running'; // After 180 seconds assume the process is in failed state, // this should unlock the Refresh button in the UI if ($all !== $checked && $group->created_at->diffInSeconds(Carbon::now()) > 180) { $state = 'failed'; } return [ 'process' => $process, 'processState' => $state, 'isReady' => $all === $checked, ]; } /** * Create a new group record. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { $current_user = $this->guard()->user(); $owner = $current_user->wallet()->owner; if ($owner->id != $current_user->id) { return $this->errorResponse(403); } - $email = request()->input('email'); - $members = request()->input('members'); + $email = $request->input('email'); + $members = $request->input('members'); $errors = []; + $rules = [ + 'name' => 'required|string|max:191', + ]; // Validate group address if ($error = GroupsController::validateGroupEmail($email, $owner)) { $errors['email'] = $error; + } else { + list(, $domainName) = explode('@', $email); + $rules['name'] = ['required', 'string', new GroupName($owner, $domainName)]; + } + + // Validate the group name + $v = Validator::make($request->all(), $rules); + + if ($v->fails()) { + $errors = array_merge($errors, $v->errors()->toArray()); } // Validate members' email addresses if (empty($members) || !is_array($members)) { $errors['members'] = \trans('validation.listmembersrequired'); } else { foreach ($members as $i => $member) { if (is_string($member) && !empty($member)) { if ($error = GroupsController::validateMemberEmail($member, $owner)) { $errors['members'][$i] = $error; } elseif (\strtolower($member) === \strtolower($email)) { $errors['members'][$i] = \trans('validation.memberislist'); } } else { unset($members[$i]); } } } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } DB::beginTransaction(); // Create the group $group = new Group(); + $group->name = $request->input('name'); $group->email = $email; $group->members = $members; $group->save(); $group->assignToWallet($owner->wallets->first()); DB::commit(); return response()->json([ 'status' => 'success', 'message' => \trans('app.distlist-create-success'), ]); } /** * Update a group. * * @param \Illuminate\Http\Request $request The API request. * @param string $id Group identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { $group = Group::find($id); if (!$this->checkTenant($group)) { return $this->errorResponse(404); } $current_user = $this->guard()->user(); if (!$current_user->canUpdate($group)) { return $this->errorResponse(403); } $owner = $group->wallet()->owner; - - // It is possible to update members property only for now - $members = request()->input('members'); + $name = $request->input('name'); + $members = $request->input('members'); $errors = []; + // Validate the group name + if ($name !== null && $name != $group->name) { + list(, $domainName) = explode('@', $group->email); + $rules = ['name' => ['required', 'string', new GroupName($owner, $domainName)]]; + + $v = Validator::make($request->all(), $rules); + + if ($v->fails()) { + $errors = array_merge($errors, $v->errors()->toArray()); + } else { + $group->name = $name; + } + } + // Validate members' email addresses if (empty($members) || !is_array($members)) { $errors['members'] = \trans('validation.listmembersrequired'); } else { foreach ((array) $members as $i => $member) { if (is_string($member) && !empty($member)) { if ($error = GroupsController::validateMemberEmail($member, $owner)) { $errors['members'][$i] = $error; } elseif (\strtolower($member) === $group->email) { $errors['members'][$i] = \trans('validation.memberislist'); } } else { unset($members[$i]); } } } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } $group->members = $members; $group->save(); return response()->json([ 'status' => 'success', 'message' => \trans('app.distlist-update-success'), ]); } /** * Execute (synchronously) specified step in a group setup process. * * @param \App\Group $group Group object * @param string $step Step identifier (as in self::statusInfo()) * * @return bool|null True if the execution succeeded, False if not, Null when * the job has been sent to the worker (result unknown) */ public static function execProcessStep(Group $group, string $step): ?bool { try { if (strpos($step, 'domain-') === 0) { return DomainsController::execProcessStep($group->domain(), $step); } switch ($step) { case 'distlist-ldap-ready': // Group not in LDAP, create it $job = new \App\Jobs\Group\CreateJob($group->id); $job->handle(); $group->refresh(); return $group->isLdapReady(); } } catch (\Exception $e) { \Log::error($e); } return false; } /** * Prepare group statuses for the UI * * @param \App\Group $group Group object * * @return array Statuses array */ protected static function groupStatuses(Group $group): array { return [ 'isLdapReady' => $group->isLdapReady(), 'isSuspended' => $group->isSuspended(), 'isActive' => $group->isActive(), 'isDeleted' => $group->isDeleted() || $group->trashed(), ]; } /** * Validate an email address for use as a group email * * @param string $email Email address * @param \App\User $user The group owner * * @return ?string Error message on validation error */ public static function validateGroupEmail($email, \App\User $user): ?string { if (empty($email)) { return \trans('validation.required', ['attribute' => 'email']); } if (strpos($email, '@') === false) { return \trans('validation.entryinvalid', ['attribute' => 'email']); } list($login, $domain) = explode('@', \strtolower($email)); if (strlen($login) === 0 || strlen($domain) === 0) { return \trans('validation.entryinvalid', ['attribute' => 'email']); } // Check if domain exists $domain = Domain::where('namespace', $domain)->first(); if (empty($domain)) { return \trans('validation.domaininvalid'); } $wallet = $domain->wallet(); // The domain must be owned by the user if (!$wallet || !$user->wallets()->find($wallet->id)) { return \trans('validation.domainnotavailable'); } // Validate login part alone $v = Validator::make( ['email' => $login], ['email' => [new \App\Rules\UserEmailLocal(true)]] ); if ($v->fails()) { return $v->errors()->toArray()['email'][0]; } // Check if a user with specified address already exists if (User::emailExists($email)) { return \trans('validation.entryexists', ['attribute' => 'email']); } // Check if an alias with specified address already exists. if (User::aliasExists($email)) { return \trans('validation.entryexists', ['attribute' => 'email']); } if (Group::emailExists($email)) { return \trans('validation.entryexists', ['attribute' => 'email']); } return null; } /** * Validate an email address for use as a group member * * @param string $email Email address * @param \App\User $user The group owner * * @return ?string Error message on validation error */ public static function validateMemberEmail($email, \App\User $user): ?string { $v = Validator::make( ['email' => $email], ['email' => [new \App\Rules\ExternalEmail()]] ); if ($v->fails()) { return $v->errors()->toArray()['email'][0]; } // A local domain user must exist if (!User::where('email', \strtolower($email))->first()) { list($login, $domain) = explode('@', \strtolower($email)); $domain = Domain::where('namespace', $domain)->first(); // We return an error only if the domain belongs to the group owner if ($domain && ($wallet = $domain->wallet()) && $user->wallets()->find($wallet->id)) { return \trans('validation.notalocaluser'); } } return null; } } diff --git a/src/app/Http/Controllers/API/V4/Reseller/GroupsController.php b/src/app/Http/Controllers/API/V4/Reseller/GroupsController.php index 6891a00b..b08509b2 100644 --- a/src/app/Http/Controllers/API/V4/Reseller/GroupsController.php +++ b/src/app/Http/Controllers/API/V4/Reseller/GroupsController.php @@ -1,57 +1,58 @@ input('search')); $owner = trim(request()->input('owner')); $result = collect([]); if ($owner) { if ($owner = User::withSubjectTenantContext()->find($owner)) { foreach ($owner->wallets as $wallet) { $wallet->entitlements()->where('entitleable_type', Group::class)->get() ->each(function ($entitlement) use ($result) { $result->push($entitlement->entitleable); }); } - $result = $result->sortBy('namespace')->values(); + $result = $result->sortBy('name')->values(); } } elseif (!empty($search)) { if ($group = Group::withSubjectTenantContext()->where('email', $search)->first()) { $result->push($group); } } // Process the result $result = $result->map(function ($group) { $data = [ 'id' => $group->id, 'email' => $group->email, + 'name' => $group->name, ]; $data = array_merge($data, self::groupStatuses($group)); return $data; }); $result = [ 'list' => $result, 'count' => count($result), 'message' => \trans('app.search-foundxdistlists', ['x' => count($result)]), ]; return response()->json($result); } } diff --git a/src/app/Observers/GroupObserver.php b/src/app/Observers/GroupObserver.php index 7c5c9481..4262805f 100644 --- a/src/app/Observers/GroupObserver.php +++ b/src/app/Observers/GroupObserver.php @@ -1,133 +1,137 @@ status |= Group::STATUS_NEW | Group::STATUS_ACTIVE; + + if (!isset($group->name) && isset($group->email)) { + $group->name = explode('@', $group->email)[0]; + } } /** * Handle the group "created" event. * * @param \App\Group $group The group * * @return void */ public function created(Group $group) { \App\Jobs\Group\CreateJob::dispatch($group->id); } /** * Handle the group "deleting" event. * * @param \App\Group $group The group * * @return void */ public function deleting(Group $group) { // Entitlements do not have referential integrity on the entitled object, so this is our // way of doing an onDelete('cascade') without the foreign key. \App\Entitlement::where('entitleable_id', $group->id) ->where('entitleable_type', Group::class) ->delete(); } /** * Handle the group "deleted" event. * * @param \App\Group $group The group * * @return void */ public function deleted(Group $group) { if ($group->isForceDeleting()) { return; } \App\Jobs\Group\DeleteJob::dispatch($group->id); } /** * Handle the group "updated" event. * * @param \App\Group $group The group * * @return void */ public function updated(Group $group) { \App\Jobs\Group\UpdateJob::dispatch($group->id); } /** * Handle the group "restoring" event. * * @param \App\Group $group The group * * @return void */ public function restoring(Group $group) { // Make sure it's not DELETED/LDAP_READY/SUSPENDED anymore if ($group->isDeleted()) { $group->status ^= Group::STATUS_DELETED; } if ($group->isLdapReady()) { $group->status ^= Group::STATUS_LDAP_READY; } if ($group->isSuspended()) { $group->status ^= Group::STATUS_SUSPENDED; } $group->status |= Group::STATUS_ACTIVE; // Note: $group->save() is invoked between 'restoring' and 'restored' events } /** * Handle the group "restored" event. * * @param \App\Group $group The group * * @return void */ public function restored(Group $group) { // Restore group entitlements \App\Entitlement::restoreEntitlementsFor($group); \App\Jobs\Group\CreateJob::dispatch($group->id); } /** * Handle the group "force deleting" event. * * @param \App\Group $group The group * * @return void */ public function forceDeleted(Group $group) { // A group can be force-deleted separately from the owner // we have to force-delete entitlements \App\Entitlement::where('entitleable_id', $group->id) ->where('entitleable_type', Group::class) ->forceDelete(); } } diff --git a/src/app/Rules/GroupName.php b/src/app/Rules/GroupName.php new file mode 100644 index 00000000..384e163a --- /dev/null +++ b/src/app/Rules/GroupName.php @@ -0,0 +1,72 @@ +owner = $owner; + $this->domain = Str::lower($domain); + } + + /** + * Determine if the validation rule passes. + * + * @param string $attribute Attribute name + * @param mixed $name The value to validate + * + * @return bool + */ + public function passes($attribute, $name): bool + { + if (empty($name) || !is_string($name)) { + $this->message = \trans('validation.nameinvalid'); + return false; + } + + // Check the max length, according to the database column length + if (strlen($name) > 191) { + $this->message = \trans('validation.nametoolong'); + return false; + } + + // Check if the name is unique in the domain + // FIXME: Maybe just using the whole groups table would be faster than groups()? + $exists = $this->owner->groups() + ->where('groups.name', $name) + ->where('groups.email', 'like', '%@' . $this->domain) + ->exists(); + + if ($exists) { + $this->message = \trans('validation.nameexists'); + return false; + } + + return true; + } + + /** + * Get the validation error message. + * + * @return string + */ + public function message(): ?string + { + return $this->message; + } +} diff --git a/src/config/ldap.php b/src/config/ldap.php index b870ddd1..08342ecb 100644 --- a/src/config/ldap.php +++ b/src/config/ldap.php @@ -1,35 +1,32 @@ explode(' ', env('LDAP_HOSTS', '127.0.0.1')), 'port' => env('LDAP_PORT', 636), 'use_tls' => (boolean)env('LDAP_USE_TLS', false), 'use_ssl' => (boolean)env('LDAP_USE_SSL', true), 'admin' => [ 'bind_dn' => env('LDAP_ADMIN_BIND_DN', null), 'bind_pw' => env('LDAP_ADMIN_BIND_PW', null), 'root_dn' => env('LDAP_ADMIN_ROOT_DN', null), ], 'hosted' => [ 'bind_dn' => env('LDAP_HOSTED_BIND_DN', null), 'bind_pw' => env('LDAP_HOSTED_BIND_PW', null), 'root_dn' => env('LDAP_HOSTED_ROOT_DN', null), ], 'domain_owner' => [ // probably proxy credentials? ], - 'user_base_dn' => env('LDAP_USER_BASE_DN', null), - 'base_dn' => env('LDAP_BASE_DN', null), 'root_dn' => env('LDAP_ROOT_DN', null), - 'unique_attribute' => env('LDAP_UNIQUE_ATTRIBUTE', 'nsuniqueid'), 'service_bind_dn' => env('LDAP_SERVICE_BIND_DN', null), 'service_bind_pw' => env('LDAP_SERVICE_BIND_PW', null), 'login_filter' => env('LDAP_LOGIN_FILTER', '(&(objectclass=kolabinetorgperson)(uid=%s))'), 'filter' => env('LDAP_FILTER', '(&(objectclass=kolabinetorgperson)(uid=%s))'), 'domain_name_attribute' => env('LDAP_DOMAIN_NAME_ATTRIBUTE', 'associateddomain'), 'domain_base_dn' => env('LDAP_DOMAIN_BASE_DN', null), 'domain_filter' => env('LDAP_DOMAIN_FILTER', '(associateddomain=%s)') ]; diff --git a/src/database/migrations/2021_11_10_100000_add_group_name_column.php b/src/database/migrations/2021_11_10_100000_add_group_name_column.php new file mode 100644 index 00000000..e730937c --- /dev/null +++ b/src/database/migrations/2021_11_10_100000_add_group_name_column.php @@ -0,0 +1,52 @@ +string('name')->nullable()->after('email'); + } + ); + + // Fill the name with the local part of the email address + DB::table('groups')->update([ + 'name' => DB::raw("SUBSTRING_INDEX(`email`, '@', 1)") + ]); + + Schema::table( + 'groups', + function (Blueprint $table) { + $table->string('name')->nullable(false)->change(); + } + ); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table( + 'groups', + function (Blueprint $table) { + $table->dropColumn('name'); + } + ); + } +} diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php index 5e7e7514..369850fc 100644 --- a/src/resources/lang/en/ui.php +++ b/src/resources/lang/en/ui.php @@ -1,435 +1,436 @@ [ 'faq' => "FAQ", ], 'btn' => [ 'add' => "Add", 'accept' => "Accept", 'back' => "Back", 'cancel' => "Cancel", 'close' => "Close", 'continue' => "Continue", 'delete' => "Delete", 'deny' => "Deny", 'download' => "Download", 'edit' => "Edit", 'file' => "Choose file...", 'moreinfo' => "More information", 'refresh' => "Refresh", 'reset' => "Reset", 'resend' => "Resend", 'save' => "Save", 'search' => "Search", 'signup' => "Sign Up", 'submit' => "Submit", 'suspend' => "Suspend", 'unsuspend' => "Unsuspend", 'verify' => "Verify", ], 'dashboard' => [ 'beta' => "beta", 'distlists' => "Distribution lists", 'chat' => "Video chat", 'domains' => "Domains", 'invitations' => "Invitations", 'profile' => "Your profile", 'users' => "User accounts", 'wallet' => "Wallet", 'webmail' => "Webmail", 'stats' => "Stats", ], 'distlist' => [ 'list-title' => "Distribution list | Distribution lists", 'create' => "Create list", 'delete' => "Delete list", 'email' => "Email", 'list-empty' => "There are no distribution lists in this account.", + 'name' => "Name", 'new' => "New distribution list", 'recipients' => "Recipients", 'sender-policy' => "Sender Access List", 'sender-policy-text' => "With this list you can specify who can send mail to the distribution list." . " You can put a complete email address (jane@kolab.org), domain (kolab.org) or suffix (.org) that the sender email address is compared to." . " If the list is empty, mail from anyone is allowed.", ], 'domain' => [ 'delete' => "Delete domain", 'delete-domain' => "Delete {domain}", 'delete-text' => "Do you really want to delete this domain permanently?" . " This is only possible if there are no users, aliases or other objects in this domain." . " Please note that this action cannot be undone.", 'dns-verify' => "Domain DNS verification sample:", 'dns-config' => "Domain DNS configuration sample:", 'namespace' => "Namespace", 'spf-whitelist' => "SPF Whitelist", 'spf-whitelist-text' => "The Sender Policy Framework allows a sender domain to disclose, through DNS, " . "which systems are allowed to send emails with an envelope sender address within said domain.", 'spf-whitelist-ex' => "Here you can specify a list of allowed servers, for example: .ess.barracuda.com.", 'verify' => "Domain verification", 'verify-intro' => "In order to confirm that you're the actual holder of the domain, we need to run a verification process before finally activating it for email delivery.", 'verify-dns' => "The domain must have one of the following entries in DNS:", 'verify-dns-txt' => "TXT entry with value:", 'verify-dns-cname' => "or CNAME entry:", 'verify-outro' => "When this is done press the button below to start the verification.", 'verify-sample' => "Here's a sample zone file for your domain:", 'config' => "Domain configuration", 'config-intro' => "In order to let {app} receive email traffic for your domain you need to adjust the DNS settings, more precisely the MX entries, accordingly.", 'config-sample' => "Edit your domain's zone file and replace existing MX entries with the following values:", 'config-hint' => "If you don't know how to set DNS entries for your domain, please contact the registration service where you registered the domain or your web hosting provider.", 'create' => "Create domain", 'new' => "New domain", ], 'error' => [ '400' => "Bad request", '401' => "Unauthorized", '403' => "Access denied", '404' => "Not found", '405' => "Method not allowed", '500' => "Internal server error", 'unknown' => "Unknown Error", 'server' => "Server Error", 'form' => "Form validation error", ], 'form' => [ 'amount' => "Amount", 'code' => "Confirmation Code", 'config' => "Configuration", 'date' => "Date", 'description' => "Description", 'details' => "Details", 'disabled' => "disabled", 'domain' => "Domain", 'email' => "Email Address", 'enabled' => "enabled", 'firstname' => "First Name", 'general' => "General", 'lastname' => "Last Name", 'none' => "none", 'or' => "or", 'password' => "Password", 'password-confirm' => "Confirm Password", 'phone' => "Phone", 'settings' => "Settings", 'status' => "Status", 'surname' => "Surname", 'user' => "User", 'primary-email' => "Primary Email", 'id' => "ID", 'created' => "Created", 'deleted' => "Deleted", ], 'invitation' => [ 'create' => "Create invite(s)", 'create-title' => "Invite for a signup", 'create-email' => "Enter an email address of the person you want to invite.", 'create-csv' => "To send multiple invitations at once, provide a CSV (comma separated) file, or alternatively a plain-text file, containing one email address per line.", 'empty-list' => "There are no invitations in the database.", 'title' => "Signup invitations", 'search' => "Email address or domain", 'send' => "Send invite(s)", 'status-completed' => "User signed up", 'status-failed' => "Sending failed", 'status-sent' => "Sent", 'status-new' => "Not sent yet", ], 'lang' => [ 'en' => "English", 'de' => "German", 'fr' => "French", 'it' => "Italian", ], 'login' => [ '2fa' => "Second factor code", '2fa_desc' => "Second factor code is optional for users with no 2-Factor Authentication setup.", 'forgot_password' => "Forgot password?", 'header' => "Please sign in", 'sign_in' => "Sign in", 'webmail' => "Webmail" ], 'meet' => [ 'title' => "Voice & Video Conferencing", 'welcome' => "Welcome to our beta program for Voice & Video Conferencing.", 'url' => "You have a room of your own at the URL below. This room is only open when you yourself are in attendance. Use this URL to invite people to join you.", 'notice' => "This is a work in progress and more features will be added over time. Current features include:", 'sharing' => "Screen Sharing", 'sharing-text' => "Share your screen for presentations or show-and-tell.", 'security' => "Room Security", 'security-text' => "Increase the room security by setting a password that attendees will need to know" . " before they can enter, or lock the door so attendees will have to knock, and a moderator can accept or deny those requests.", 'qa' => "Raise Hand (Q&A)", 'qa-text' => "Silent audience members can raise their hand to facilitate a Question & Answer session with the panel members.", 'moderation' => "Moderator Delegation", 'moderation-text' => "Delegate moderator authority for the session, so that a speaker is not needlessly" . " interrupted with attendees knocking and other moderator duties.", 'eject' => "Eject Attendees", 'eject-text' => "Eject attendees from the session in order to force them to reconnect, or address policy" . " violations. Click the user icon for effective dismissal.", 'silent' => "Silent Audience Members", 'silent-text' => "For a webinar-style session, configure the room to force all new attendees to be silent audience members.", 'interpreters' => "Language Specific Audio Channels", 'interpreters-text' => "Designate a participant to interpret the original audio to a target language, for sessions" . " with multi-lingual attendees. The interpreter is expected to be able to relay the original audio, and override it.", 'beta-notice' => "Keep in mind that this is still in beta and might come with some issues." . " Should you encounter any on your way, let us know by contacting support.", // Room options dialog 'options' => "Room options", 'password' => "Password", 'password-none' => "none", 'password-clear' => "Clear password", 'password-set' => "Set password", 'password-text' => "You can add a password to your meeting. Participants will have to provide the password before they are allowed to join the meeting.", 'lock' => "Locked room", 'lock-text' => "When the room is locked participants have to be approved by a moderator before they could join the meeting.", 'nomedia' => "Subscribers only", 'nomedia-text' => "Forces all participants to join as subscribers (with camera and microphone turned off)." . " Moderators will be able to promote them to publishers throughout the session.", // Room menu 'partcnt' => "Number of participants", 'menu-audio-mute' => "Mute audio", 'menu-audio-unmute' => "Unmute audio", 'menu-video-mute' => "Mute video", 'menu-video-unmute' => "Unmute video", 'menu-screen' => "Share screen", 'menu-hand-lower' => "Lower hand", 'menu-hand-raise' => "Raise hand", 'menu-channel' => "Interpreted language channel", 'menu-chat' => "Chat", 'menu-fullscreen' => "Full screen", 'menu-fullscreen-exit' => "Exit full screen", 'menu-leave' => "Leave session", // Room setup screen 'setup-title' => "Set up your session", 'mic' => "Microphone", 'cam' => "Camera", 'nick' => "Nickname", 'nick-placeholder' => "Your name", 'join' => "JOIN", 'joinnow' => "JOIN NOW", 'imaowner' => "I'm the owner", // Room 'qa' => "Q & A", 'leave-title' => "Room closed", 'leave-body' => "The session has been closed by the room owner.", 'media-title' => "Media setup", 'join-request' => "Join request", 'join-requested' => "{user} requested to join.", // Status messages 'status-init' => "Checking the room...", 'status-323' => "The room is closed. Please, wait for the owner to start the session.", 'status-324' => "The room is closed. It will be open for others after you join.", 'status-325' => "The room is ready. Please, provide a valid password.", 'status-326' => "The room is locked. Please, enter your name and try again.", 'status-327' => "Waiting for permission to join the room.", 'status-404' => "The room does not exist.", 'status-429' => "Too many requests. Please, wait.", 'status-500' => "Failed to connect to the room. Server error.", // Other menus 'media-setup' => "Media setup", 'perm' => "Permissions", 'perm-av' => "Audio & Video publishing", 'perm-mod' => "Moderation", 'lang-int' => "Language interpreter", 'menu-options' => "Options", ], 'menu' => [ 'cockpit' => "Cockpit", 'login' => "Login", 'logout' => "Logout", 'signup' => "Signup", 'toggle' => "Toggle navigation", ], 'msg' => [ 'initializing' => "Initializing...", 'loading' => "Loading...", 'loading-failed' => "Failed to load data.", 'notfound' => "Resource not found.", 'info' => "Information", 'error' => "Error", 'warning' => "Warning", 'success' => "Success", ], 'nav' => [ 'more' => "Load more", 'step' => "Step {i}/{n}", ], 'password' => [ 'reset' => "Password Reset", 'reset-step1' => "Enter your email address to reset your password.", 'reset-step1-hint' => "You may need to check your spam folder or unblock {email}.", 'reset-step2' => "We sent out a confirmation code to your external email address." . " Enter the code we sent you, or click the link in the message.", ], 'signup' => [ 'email' => "Existing Email Address", 'login' => "Login", 'title' => "Sign Up", 'step1' => "Sign up to start your free month.", 'step2' => "We sent out a confirmation code to your email address. Enter the code we sent you, or click the link in the message.", 'step3' => "Create your Kolab identity (you can choose additional addresses later).", 'voucher' => "Voucher Code", ], 'status' => [ 'prepare-account' => "We are preparing your account.", 'prepare-domain' => "We are preparing the domain.", 'prepare-distlist' => "We are preparing the distribution list.", 'prepare-user' => "We are preparing the user account.", 'prepare-hint' => "Some features may be missing or readonly at the moment.", 'prepare-refresh' => "The process never ends? Press the \"Refresh\" button, please.", 'ready-account' => "Your account is almost ready.", 'ready-domain' => "The domain is almost ready.", 'ready-distlist' => "The distribution list is almost ready.", 'ready-user' => "The user account is almost ready.", 'verify' => "Verify your domain to finish the setup process.", 'verify-domain' => "Verify domain", 'deleted' => "Deleted", 'suspended' => "Suspended", 'notready' => "Not Ready", 'active' => "Active", ], 'support' => [ 'title' => "Contact Support", 'id' => "Customer number or email address you have with us", 'id-pl' => "e.g. 12345678 or john@kolab.org", 'id-hint' => "Leave blank if you are not a customer yet", 'name' => "Name", 'name-pl' => "how we should call you in our reply", 'email' => "Working email address", 'email-pl' => "make sure we can reach you at this address", 'summary' => "Issue Summary", 'summary-pl' => "one sentence that summarizes your issue", 'expl' => "Issue Explanation", ], 'user' => [ '2fa-hint1' => "This will remove 2-Factor Authentication entitlement as well as the user-configured factors.", '2fa-hint2' => "Please, make sure to confirm the user identity properly.", 'add-beta' => "Enable beta program", 'address' => "Address", 'aliases' => "Aliases", 'aliases-email' => "Email Aliases", 'aliases-none' => "This user has no email aliases.", 'add-bonus' => "Add bonus", 'add-bonus-title' => "Add a bonus to the wallet", 'add-penalty' => "Add penalty", 'add-penalty-title' => "Add a penalty to the wallet", 'auto-payment' => "Auto-payment", 'auto-payment-text' => "Fill up by {amount} when under {balance} using {method}", 'country' => "Country", 'create' => "Create user", 'custno' => "Customer No.", 'delete' => "Delete user", 'delete-account' => "Delete this account?", 'delete-email' => "Delete {email}", 'delete-text' => "Do you really want to delete this user permanently?" . " This will delete all account data and withdraw the permission to access the email account." . " Please note that this action cannot be undone.", 'discount' => "Discount", 'discount-hint' => "applied discount", 'discount-title' => "Account discount", 'distlists' => "Distribution lists", 'distlists-none' => "There are no distribution lists in this account.", 'domains' => "Domains", 'domains-none' => "There are no domains in this account.", 'ext-email' => "External Email", 'finances' => "Finances", 'greylisting' => "Greylisting", 'greylisting-text' => "Greylisting is a method of defending users against spam. Any incoming mail from an unrecognized sender " . "is temporarily rejected. The originating server should try again after a delay. " . "This time the email will be accepted. Spammers usually do not reattempt mail delivery.", 'list-title' => "User accounts", 'managed-by' => "Managed by", 'new' => "New user account", 'org' => "Organization", 'package' => "Package", 'price' => "Price", 'profile-title' => "Your profile", 'profile-delete' => "Delete account", 'profile-delete-title' => "Delete this account?", 'profile-delete-text1' => "This will delete the account as well as all domains, users and aliases associated with this account.", 'profile-delete-warning' => "This operation is irreversible", 'profile-delete-text2' => "As you will not be able to recover anything after this point, please make sure that you have migrated all data before proceeding.", 'profile-delete-support' => "As we always strive to improve, we would like to ask for 2 minutes of your time. " . "The best tool for improvement is feedback from users, and we would like to ask " . "for a few words about your reasons for leaving our service. Please send your feedback to {email}.", 'profile-delete-contact' => "Also feel free to contact {app} Support with any questions or concerns that you may have in this context.", 'reset-2fa' => "Reset 2-Factor Auth", 'reset-2fa-title' => "2-Factor Authentication Reset", 'title' => "User account", 'search' => "User email address or name", 'search-pl' => "User ID, email or domain", 'skureq' => "{sku} requires {list}.", 'subscription' => "Subscription", 'subscriptions' => "Subscriptions", 'subscriptions-none' => "This user has no subscriptions.", 'users' => "Users", 'users-none' => "There are no users in this account.", ], 'wallet' => [ 'add-credit' => "Add credit", 'auto-payment-cancel' => "Cancel auto-payment", 'auto-payment-change' => "Change auto-payment", 'auto-payment-failed' => "The setup of automatic payments failed. Restart the process to enable automatic top-ups.", 'auto-payment-hint' => "Here is how it works: Every time your account runs low, we will charge your preferred payment method for an amount you choose." . " You can cancel or change the auto-payment option at any time.", 'auto-payment-setup' => "Set up auto-payment", 'auto-payment-disabled' => "The configured auto-payment has been disabled. Top up your wallet or raise the auto-payment amount.", 'auto-payment-info' => "Auto-payment is set to fill up your account by {amount} every time your account balance gets under {balance}.", 'auto-payment-inprogress' => "The setup of the automatic payment is still in progress.", 'auto-payment-next' => "Next, you will be redirected to the checkout page, where you can provide your credit card details.", 'auto-payment-disabled-next' => "The auto-payment is disabled. Immediately after you submit new settings we'll enable it and attempt to top up your wallet.", 'auto-payment-update' => "Update auto-payment", 'banktransfer-hint' => "Please note that a bank transfer can take several days to complete.", 'currency-conv' => "Here is how it works: You specify the amount by which you want to top up your wallet in {wc}." . " We will then convert this to {pc}, and on the next page you will be provided with the bank-details to transfer the amount in {pc}.", 'fill-up' => "Fill up by", 'history' => "History", 'month' => "month", 'noperm' => "Only account owners can access a wallet.", 'payment-amount-hint' => "Choose the amount by which you want to top up your wallet.", 'payment-method' => "Method of payment: {method}", 'payment-warning' => "You will be charged for {price}.", 'pending-payments' => "Pending Payments", 'pending-payments-warning' => "You have payments that are still in progress. See the \"Pending Payments\" tab below.", 'pending-payments-none' => "There are no pending payments for this account.", 'receipts' => "Receipts", 'receipts-hint' => "Here you can download receipts (in PDF format) for payments in specified period. Select the period and press the Download button.", 'receipts-none' => "There are no receipts for payments in this account. Please, note that you can download receipts after the month ends.", 'title' => "Account balance", 'top-up' => "Top up your wallet", 'transactions' => "Transactions", 'transactions-none' => "There are no transactions for this account.", 'when-below' => "when account balance is below", ], ]; diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php index 9cd67c45..f578c1da 100644 --- a/src/resources/lang/en/validation.php +++ b/src/resources/lang/en/validation.php @@ -1,174 +1,177 @@ 'The :attribute must be accepted.', 'active_url' => 'The :attribute is not a valid URL.', 'after' => 'The :attribute must be a date after :date.', 'after_or_equal' => 'The :attribute must be a date after or equal to :date.', 'alpha' => 'The :attribute may only contain letters.', 'alpha_dash' => 'The :attribute may only contain letters, numbers, dashes and underscores.', 'alpha_num' => 'The :attribute may only contain letters and numbers.', 'array' => 'The :attribute must be an array.', 'before' => 'The :attribute must be a date before :date.', 'before_or_equal' => 'The :attribute must be a date before or equal to :date.', 'between' => [ 'numeric' => 'The :attribute must be between :min and :max.', 'file' => 'The :attribute must be between :min and :max kilobytes.', 'string' => 'The :attribute must be between :min and :max characters.', 'array' => 'The :attribute must have between :min and :max items.', ], 'boolean' => 'The :attribute field must be true or false.', 'confirmed' => 'The :attribute confirmation does not match.', 'date' => 'The :attribute is not a valid date.', 'date_equals' => 'The :attribute must be a date equal to :date.', 'date_format' => 'The :attribute does not match the format :format.', 'different' => 'The :attribute and :other must be different.', 'digits' => 'The :attribute must be :digits digits.', 'digits_between' => 'The :attribute must be between :min and :max digits.', 'dimensions' => 'The :attribute has invalid image dimensions.', 'distinct' => 'The :attribute field has a duplicate value.', 'email' => 'The :attribute must be a valid email address.', 'ends_with' => 'The :attribute must end with one of the following: :values', 'exists' => 'The selected :attribute is invalid.', 'file' => 'The :attribute must be a file.', 'filled' => 'The :attribute field must have a value.', 'gt' => [ 'numeric' => 'The :attribute must be greater than :value.', 'file' => 'The :attribute must be greater than :value kilobytes.', 'string' => 'The :attribute must be greater than :value characters.', 'array' => 'The :attribute must have more than :value items.', ], 'gte' => [ 'numeric' => 'The :attribute must be greater than or equal :value.', 'file' => 'The :attribute must be greater than or equal :value kilobytes.', 'string' => 'The :attribute must be greater than or equal :value characters.', 'array' => 'The :attribute must have :value items or more.', ], 'image' => 'The :attribute must be an image.', 'in' => 'The selected :attribute is invalid.', 'in_array' => 'The :attribute field does not exist in :other.', 'integer' => 'The :attribute must be an integer.', 'ip' => 'The :attribute must be a valid IP address.', 'ipv4' => 'The :attribute must be a valid IPv4 address.', 'ipv6' => 'The :attribute must be a valid IPv6 address.', 'json' => 'The :attribute must be a valid JSON string.', 'lt' => [ 'numeric' => 'The :attribute must be less than :value.', 'file' => 'The :attribute must be less than :value kilobytes.', 'string' => 'The :attribute must be less than :value characters.', 'array' => 'The :attribute must have less than :value items.', ], 'lte' => [ 'numeric' => 'The :attribute must be less than or equal :value.', 'file' => 'The :attribute must be less than or equal :value kilobytes.', 'string' => 'The :attribute must be less than or equal :value characters.', 'array' => 'The :attribute must not have more than :value items.', ], 'max' => [ 'numeric' => 'The :attribute may not be greater than :max.', 'file' => 'The :attribute may not be greater than :max kilobytes.', 'string' => 'The :attribute may not be greater than :max characters.', 'array' => 'The :attribute may not have more than :max items.', ], 'mimes' => 'The :attribute must be a file of type: :values.', 'mimetypes' => 'The :attribute must be a file of type: :values.', 'min' => [ 'numeric' => 'The :attribute must be at least :min.', 'file' => 'The :attribute must be at least :min kilobytes.', 'string' => 'The :attribute must be at least :min characters.', 'array' => 'The :attribute must have at least :min items.', ], 'not_in' => 'The selected :attribute is invalid.', 'not_regex' => 'The :attribute format is invalid.', 'numeric' => 'The :attribute must be a number.', 'present' => 'The :attribute field must be present.', 'regex' => 'The :attribute format is invalid.', 'required' => 'The :attribute field is required.', 'required_if' => 'The :attribute field is required when :other is :value.', 'required_unless' => 'The :attribute field is required unless :other is in :values.', 'required_with' => 'The :attribute field is required when :values is present.', 'required_with_all' => 'The :attribute field is required when :values are present.', 'required_without' => 'The :attribute field is required when :values is not present.', 'required_without_all' => 'The :attribute field is required when none of :values are present.', 'same' => 'The :attribute and :other must match.', 'size' => [ 'numeric' => 'The :attribute must be :size.', 'file' => 'The :attribute must be :size kilobytes.', 'string' => 'The :attribute must be :size characters.', 'array' => 'The :attribute must contain :size items.', ], 'starts_with' => 'The :attribute must start with one of the following: :values', 'string' => 'The :attribute must be a string.', 'timezone' => 'The :attribute must be a valid zone.', 'unique' => 'The :attribute has already been taken.', 'uploaded' => 'The :attribute failed to upload.', 'url' => 'The :attribute format is invalid.', 'uuid' => 'The :attribute must be a valid UUID.', '2fareq' => 'Second factor code is required.', '2fainvalid' => 'Second factor code is invalid.', 'emailinvalid' => 'The specified email address is invalid.', 'domaininvalid' => 'The specified domain is invalid.', 'domainnotavailable' => 'The specified domain is not available.', 'logininvalid' => 'The specified login is invalid.', 'loginexists' => 'The specified login is not available.', 'domainexists' => 'The specified domain is not available.', 'noemailorphone' => 'The specified text is neither a valid email address nor a phone number.', 'packageinvalid' => 'Invalid package selected.', 'packagerequired' => 'Package is required.', 'usernotexists' => 'Unable to find user.', 'voucherinvalid' => 'The voucher code is invalid or expired.', 'noextemail' => 'This user has no external email address.', 'entryinvalid' => 'The specified :attribute is invalid.', 'entryexists' => 'The specified :attribute is not available.', 'minamount' => 'Minimum amount for a single payment is :amount.', 'minamountdebt' => 'The specified amount does not cover the balance on the account.', 'notalocaluser' => 'The specified email address does not exist.', 'memberislist' => 'A recipient cannot be the same as the list address.', 'listmembersrequired' => 'At least one recipient is required.', 'spf-entry-invalid' => 'The entry format is invalid. Expected a domain name starting with a dot.', 'sp-entry-invalid' => 'The entry format is invalid. Expected an email, domain, or part of it.', 'invalid-config-parameter' => 'The requested configuration parameter is not supported.', + 'nameexists' => 'The specified name is not available.', + 'nameinvalid' => 'The specified name is invalid.', + 'nametoolong' => 'The specified name is too long.', /* |-------------------------------------------------------------------------- | Custom Validation Language Lines |-------------------------------------------------------------------------- | | Here you may specify custom validation messages for attributes using the | convention "attribute.rule" to name the lines. This makes it quick to | specify a specific custom language line for a given attribute rule. | */ 'custom' => [ 'attribute-name' => [ 'rule-name' => 'custom-message', ], ], /* |-------------------------------------------------------------------------- | Custom Validation Attributes |-------------------------------------------------------------------------- | | The following language lines are used to swap our attribute placeholder | with something more reader friendly such as "E-Mail Address" instead | of "email". This simply helps us make our message more expressive. | */ 'attributes' => [], ]; diff --git a/src/resources/vue/Admin/Distlist.vue b/src/resources/vue/Admin/Distlist.vue index c28ba5e5..4134631f 100644 --- a/src/resources/vue/Admin/Distlist.vue +++ b/src/resources/vue/Admin/Distlist.vue @@ -1,110 +1,116 @@ diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue index e05d6ff6..1b10669d 100644 --- a/src/resources/vue/Admin/User.vue +++ b/src/resources/vue/Admin/User.vue @@ -1,754 +1,758 @@ diff --git a/src/resources/vue/Distlist/Info.vue b/src/resources/vue/Distlist/Info.vue index 99899f5b..f65f9413 100644 --- a/src/resources/vue/Distlist/Info.vue +++ b/src/resources/vue/Distlist/Info.vue @@ -1,144 +1,153 @@ diff --git a/src/resources/vue/Distlist/List.vue b/src/resources/vue/Distlist/List.vue index 69b1627e..c6fd582f 100644 --- a/src/resources/vue/Distlist/List.vue +++ b/src/resources/vue/Distlist/List.vue @@ -1,56 +1,60 @@ diff --git a/src/tests/Browser/Admin/DistlistTest.php b/src/tests/Browser/Admin/DistlistTest.php index 0a5c7f4a..52163718 100644 --- a/src/tests/Browser/Admin/DistlistTest.php +++ b/src/tests/Browser/Admin/DistlistTest.php @@ -1,136 +1,138 @@ deleteTestGroup('group-test@kolab.org'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestGroup('group-test@kolab.org'); parent::tearDown(); } /** * Test distlist info page (unauthenticated) */ public function testDistlistUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $user = $this->getTestUser('john@kolab.org'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($user->wallets->first()); $browser->visit('/distlist/' . $group->id)->on(new Home()); }); } /** * Test distribution list info page */ public function testInfo(): void { Queue::fake(); $this->browse(function (Browser $browser) { $user = $this->getTestUser('john@kolab.org'); - $group = $this->getTestGroup('group-test@kolab.org'); + $group = $this->getTestGroup('group-test@kolab.org', ['name' => 'Test Group']); $group->assignToWallet($user->wallets->first()); $group->members = ['test1@gmail.com', 'test2@gmail.com']; $group->save(); $group->setConfig(['sender_policy' => ['test1.com', 'test2.com']]); $distlist_page = new DistlistPage($group->id); $user_page = new UserPage($user->id); // Goto the distlist page $browser->visit(new Home()) ->submitLogon('jeroen@jeroen.jeroen', \App\Utils::generatePassphrase(), true) ->on(new Dashboard()) ->visit($user_page) ->on($user_page) ->click('@nav #tab-distlists') ->pause(1000) ->click('@user-distlists table tbody tr:first-child td a') ->on($distlist_page) ->assertSeeIn('@distlist-info .card-title', $group->email) ->with('@distlist-info form', function (Browser $browser) use ($group) { - $browser->assertElementsCount('.row', 3) + $browser->assertElementsCount('.row', 4) ->assertSeeIn('.row:nth-child(1) label', 'ID (Created)') ->assertSeeIn('.row:nth-child(1) #distlistid', "{$group->id} ({$group->created_at})") ->assertSeeIn('.row:nth-child(2) label', 'Status') ->assertSeeIn('.row:nth-child(2) #status.text-danger', 'Not Ready') - ->assertSeeIn('.row:nth-child(3) label', 'Recipients') - ->assertSeeIn('.row:nth-child(3) #members', $group->members[0]) - ->assertSeeIn('.row:nth-child(3) #members', $group->members[1]); + ->assertSeeIn('.row:nth-child(3) label', 'Name') + ->assertSeeIn('.row:nth-child(3) #name', $group->name) + ->assertSeeIn('.row:nth-child(4) label', 'Recipients') + ->assertSeeIn('.row:nth-child(4) #members', $group->members[0]) + ->assertSeeIn('.row:nth-child(4) #members', $group->members[1]); }) ->assertElementsCount('ul.nav-tabs', 1) ->assertSeeIn('ul.nav-tabs .nav-link', 'Settings') ->with('@distlist-settings form', function (Browser $browser) { $browser->assertElementsCount('.row', 1) ->assertSeeIn('.row:nth-child(1) label', 'Sender Access List') ->assertSeeIn('.row:nth-child(1) #sender_policy', 'test1.com, test2.com'); }); // Test invalid group identifier $browser->visit('/distlist/abc')->assertErrorPage(404); }); } /** * Test suspending/unsuspending a distribution list * * @depends testInfo */ public function testSuspendAndUnsuspend(): void { Queue::fake(); $this->browse(function (Browser $browser) { $user = $this->getTestUser('john@kolab.org'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($user->wallets->first()); $group->status = Group::STATUS_ACTIVE | Group::STATUS_LDAP_READY; $group->save(); $browser->visit(new DistlistPage($group->id)) ->assertVisible('@distlist-info #button-suspend') ->assertMissing('@distlist-info #button-unsuspend') ->assertSeeIn('@distlist-info #status.text-success', 'Active') ->click('@distlist-info #button-suspend') ->assertToast(Toast::TYPE_SUCCESS, 'Distribution list suspended successfully.') ->assertSeeIn('@distlist-info #status.text-warning', 'Suspended') ->assertMissing('@distlist-info #button-suspend') ->click('@distlist-info #button-unsuspend') ->assertToast(Toast::TYPE_SUCCESS, 'Distribution list unsuspended successfully.') ->assertSeeIn('@distlist-info #status.text-success', 'Active') ->assertVisible('@distlist-info #button-suspend') ->assertMissing('@distlist-info #button-unsuspend'); }); } } diff --git a/src/tests/Browser/Admin/UserTest.php b/src/tests/Browser/Admin/UserTest.php index 8ca7c95a..34ba5986 100644 --- a/src/tests/Browser/Admin/UserTest.php +++ b/src/tests/Browser/Admin/UserTest.php @@ -1,544 +1,545 @@ 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(); Entitlement::where('cost', '>=', 5000)->delete(); $this->deleteTestGroup('group-test@kolab.org'); $this->clearMeetEntitlements(); } /** * {@inheritDoc} */ public function tearDown(): void { $john = $this->getTestUser('john@kolab.org'); $john->setSettings([ 'phone' => null, 'external_email' => 'john.doe.external@gmail.com', ]); if ($john->isSuspended()) { User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]); } $wallet = $john->wallets()->first(); $wallet->discount()->dissociate(); $wallet->save(); Entitlement::where('cost', '>=', 5000)->delete(); $this->deleteTestGroup('group-test@kolab.org'); $this->clearMeetEntitlements(); parent::tearDown(); } /** * Test user info page (unauthenticated) */ public function testUserUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $jack = $this->getTestUser('jack@kolab.org'); $browser->visit('/user/' . $jack->id)->on(new Home()); }); } /** * Test user info page */ public function testUserInfo(): void { $this->browse(function (Browser $browser) { $jack = $this->getTestUser('jack@kolab.org'); $page = new UserPage($jack->id); $browser->visit(new Home()) ->submitLogon('jeroen@jeroen.jeroen', \App\Utils::generatePassphrase(), 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)') ->assertSeeIn('.row:nth-child(2) #userid', "{$jack->id} ({$jack->created_at})") ->assertSeeIn('.row:nth-child(3) label', 'Status') ->assertSeeIn('.row:nth-child(3) #status span.text-success', 'Active') ->assertSeeIn('.row:nth-child(4) label', 'First Name') ->assertSeeIn('.row:nth-child(4) #first_name', 'Jack') ->assertSeeIn('.row:nth-child(5) label', 'Last Name') ->assertSeeIn('.row:nth-child(5) #last_name', 'Daniels') ->assertSeeIn('.row:nth-child(6) label', 'External Email') ->assertMissing('.row:nth-child(6) #external_email a') ->assertSeeIn('.row:nth-child(7) label', 'Country') ->assertSeeIn('.row:nth-child(7) #country', 'United States'); }); // Some tabs are loaded in background, wait a second $browser->pause(500) ->assertElementsCount('@nav a', 7); // 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', '5,00 CHF') ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 5 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', '4,90 CHF') ->assertMissing('table tfoot') ->assertMissing('#reset2fa'); }); // Assert Domains tab $browser->assertSeeIn('@nav #tab-domains', 'Domains (0)') ->click('@nav #tab-domains') ->with('@user-domains', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no domains in this account.'); }); // Assert Users tab $browser->assertSeeIn('@nav #tab-users', 'Users (0)') ->click('@nav #tab-users') ->with('@user-users', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no users in this account.'); }); // Assert Distribution lists tab $browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (0)') ->click('@nav #tab-distlists') ->with('@user-distlists', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no distribution lists in this account.'); }); // Assert Settings tab $browser->assertSeeIn('@nav #tab-settings', 'Settings') ->click('@nav #tab-settings') ->whenAvailable('@user-settings form', function (Browser $browser) { $browser->assertElementsCount('.row', 1) ->assertSeeIn('.row:first-child label', 'Greylisting') ->assertSeeIn('.row:first-child .text-success', 'enabled'); }); }); } /** * Test user info page (continue) * * @depends testUserInfo */ public function testUserInfo2(): void { $this->browse(function (Browser $browser) { $john = $this->getTestUser('john@kolab.org'); $page = new UserPage($john->id); $discount = Discount::where('code', 'TEST')->first(); $wallet = $john->wallet(); $wallet->discount()->associate($discount); $wallet->debit(2010); $wallet->save(); - $group = $this->getTestGroup('group-test@kolab.org'); + $group = $this->getTestGroup('group-test@kolab.org', ['name' => 'Test Group']); $group->assignToWallet($john->wallets->first()); $john->setSetting('greylist_enabled', null); // 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)') ->assertSeeIn('.row:nth-child(1) #userid', "{$john->id} ({$john->created_at})") ->assertSeeIn('.row:nth-child(2) label', 'Status') ->assertSeeIn('.row:nth-child(2) #status span.text-success', 'Active') ->assertSeeIn('.row:nth-child(3) label', 'First Name') ->assertSeeIn('.row:nth-child(3) #first_name', 'John') ->assertSeeIn('.row:nth-child(4) label', 'Last Name') ->assertSeeIn('.row:nth-child(4) #last_name', 'Doe') ->assertSeeIn('.row:nth-child(5) label', 'Organization') ->assertSeeIn('.row:nth-child(5) #organization', 'Kolab Developers') ->assertSeeIn('.row:nth-child(6) label', 'Phone') ->assertSeeIn('.row:nth-child(6) #phone', $john->getSetting('phone')) ->assertSeeIn('.row:nth-child(7) label', 'External Email') ->assertSeeIn('.row:nth-child(7) #external_email a', $ext_email) ->assertAttribute('.row:nth-child(7) #external_email a', 'href', "mailto:$ext_email") ->assertSeeIn('.row:nth-child(8) label', 'Address') ->assertSeeIn('.row:nth-child(8) #billing_address', $john->getSetting('billing_address')) ->assertSeeIn('.row:nth-child(9) label', 'Country') ->assertSeeIn('.row:nth-child(9) #country', 'United States'); }); // Some tabs are loaded in background, wait a second $browser->pause(500) ->assertElementsCount('@nav a', 7); // 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', '4,50 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 5 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,41 CHF/month¹') ->assertMissing('table tfoot') ->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher'); }); // Assert Domains tab $browser->assertSeeIn('@nav #tab-domains', 'Domains (1)') ->click('@nav #tab-domains') ->with('@user-domains table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 1) ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'kolab.org') ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success') ->assertMissing('tfoot'); }); // Assert Distribution lists tab $browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (1)') ->click('@nav #tab-distlists') ->with('@user-distlists table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 1) - ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'group-test@kolab.org') + ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'Test Group') ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-danger') + ->assertSeeIn('tbody tr:nth-child(1) td:last-child a', 'group-test@kolab.org') ->assertMissing('tfoot'); }); // Assert Users tab $browser->assertSeeIn('@nav #tab-users', 'Users (4)') ->click('@nav #tab-users') ->with('@user-users table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 4) ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'jack@kolab.org') ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success') ->assertSeeIn('tbody tr:nth-child(2) td:first-child a', 'joe@kolab.org') ->assertVisible('tbody tr:nth-child(2) td:first-child svg.text-success') ->assertSeeIn('tbody tr:nth-child(3) td:first-child span', 'john@kolab.org') ->assertVisible('tbody tr:nth-child(3) td:first-child svg.text-success') ->assertSeeIn('tbody tr:nth-child(4) td:first-child a', 'ned@kolab.org') ->assertVisible('tbody tr:nth-child(4) td:first-child svg.text-success') ->assertMissing('tfoot'); }); }); // Now we go to Ned's info page, he's a controller on John's wallet $this->browse(function (Browser $browser) { $ned = $this->getTestUser('ned@kolab.org'); $beta_sku = Sku::withEnvTenantContext()->where('title', 'beta')->first(); $storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first(); $wallet = $ned->wallet(); // Add an extra storage and beta entitlement with different prices Entitlement::create([ 'wallet_id' => $wallet->id, 'sku_id' => $beta_sku->id, 'cost' => 5010, 'entitleable_id' => $ned->id, 'entitleable_type' => User::class ]); Entitlement::create([ 'wallet_id' => $wallet->id, 'sku_id' => $storage_sku->id, 'cost' => 5000, 'entitleable_id' => $ned->id, 'entitleable_type' => User::class ]); $page = new UserPage($ned->id); $ned->setSetting('greylist_enabled', 'false'); $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)') ->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', 7); // 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 (6)') ->click('@nav #tab-subscriptions') ->with('@user-subscriptions', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 6) ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox') ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '4,50 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 6 GB') ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '45,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,41 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(4) td:first-child', 'Activesync') ->assertSeeIn('table tbody tr:nth-child(4) td:last-child', '0,00 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¹') ->assertSeeIn('table tbody tr:nth-child(6) td:first-child', 'Private Beta (invitation only)') ->assertSeeIn('table tbody tr:nth-child(6) td:last-child', '45,09 CHF/month¹') ->assertMissing('table tfoot') ->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher') ->assertSeeIn('#reset2fa', 'Reset 2-Factor Auth') ->assertMissing('#addbetasku'); }); // We don't expect John's domains here $browser->assertSeeIn('@nav #tab-domains', 'Domains (0)') ->click('@nav #tab-domains') ->with('@user-domains', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no domains in this account.'); }); // We don't expect John's users here $browser->assertSeeIn('@nav #tab-users', 'Users (0)') ->click('@nav #tab-users') ->with('@user-users', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no users in this account.'); }); // We don't expect John's distribution lists here $browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (0)') ->click('@nav #tab-distlists') ->with('@user-distlists', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no distribution lists in this account.'); }); // Assert Settings tab $browser->assertSeeIn('@nav #tab-settings', 'Settings') ->click('@nav #tab-settings') ->whenAvailable('@user-settings form', function (Browser $browser) { $browser->assertElementsCount('.row', 1) ->assertSeeIn('.row:first-child label', 'Greylisting') ->assertSeeIn('.row:first-child .text-danger', 'disabled'); }); }); } /** * 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::withEnvTenantContext()->where('title', '2fa')->first(); $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)'); }); } /** * Test adding the beta SKU for the user */ public function testAddBetaSku(): void { $this->browse(function (Browser $browser) { $this->deleteTestUser('userstest1@kolabnow.com'); $user = $this->getTestUser('userstest1@kolabnow.com'); $sku = Sku::withEnvTenantContext()->where('title', 'beta')->first(); $browser->visit(new UserPage($user->id)) ->click('@nav #tab-subscriptions') ->waitFor('@user-subscriptions #addbetasku') ->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (0)') ->assertSeeIn('#addbetasku', 'Enable beta program') ->click('#addbetasku') ->assertToast(Toast::TYPE_SUCCESS, 'The subscription added successfully.') ->waitFor('#sku' . $sku->id) ->assertSeeIn("#sku{$sku->id} td:first-child", 'Private Beta (invitation only)') ->assertSeeIn("#sku{$sku->id} td:last-child", '0,00 CHF/month') ->assertMissing('#addbetasku') ->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (1)'); }); } } diff --git a/src/tests/Browser/DistlistTest.php b/src/tests/Browser/DistlistTest.php index 14207cdb..966a0517 100644 --- a/src/tests/Browser/DistlistTest.php +++ b/src/tests/Browser/DistlistTest.php @@ -1,315 +1,326 @@ deleteTestGroup('group-test@kolab.org'); $this->clearBetaEntitlements(); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestGroup('group-test@kolab.org'); $this->clearBetaEntitlements(); parent::tearDown(); } /** * Test distlist info page (unauthenticated) */ public function testInfoUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $browser->visit('/distlist/abc')->on(new Home()); }); } /** * Test distlist list page (unauthenticated) */ public function testListUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $browser->visit('/distlists')->on(new Home()); }); } /** * Test distlist list page */ public function testList(): void { // Log on the user $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->on(new Dashboard()) ->assertMissing('@links .link-distlists'); }); // Test that Distribution lists page is not accessible without the 'distlist' entitlement $this->browse(function (Browser $browser) { $browser->visit('/distlists') ->assertErrorPage(403); }); // Create a single group, add beta+distlist entitlements $john = $this->getTestUser('john@kolab.org'); $this->addDistlistEntitlement($john); - $group = $this->getTestGroup('group-test@kolab.org'); + $group = $this->getTestGroup('group-test@kolab.org', ['name' => 'Test Group']); $group->assignToWallet($john->wallets->first()); // Test distribution lists page $this->browse(function (Browser $browser) { $browser->visit(new Dashboard()) ->assertSeeIn('@links .link-distlists', 'Distribution lists') ->click('@links .link-distlists') ->on(new DistlistList()) ->whenAvailable('@table', function (Browser $browser) { $browser->waitFor('tbody tr') + ->assertSeeIn('thead tr th:nth-child(1)', 'Name') + ->assertSeeIn('thead tr th:nth-child(2)', 'Email') ->assertElementsCount('tbody tr', 1) - ->assertSeeIn('tbody tr:nth-child(1) a', 'group-test@kolab.org') - ->assertText('tbody tr:nth-child(1) svg.text-danger title', 'Not Ready') + ->assertSeeIn('tbody tr:nth-child(1) td:nth-child(1) a', 'Test Group') + ->assertText('tbody tr:nth-child(1) td:nth-child(1) svg.text-danger title', 'Not Ready') + ->assertSeeIn('tbody tr:nth-child(1) td:nth-child(2) a', 'group-test@kolab.org') ->assertMissing('tfoot'); }); }); } /** * Test distlist creation/editing/deleting * * @depends testList */ public function testCreateUpdateDelete(): void { // Test that the page is not available accessible without the 'distlist' entitlement $this->browse(function (Browser $browser) { $browser->visit('/distlist/new') ->assertErrorPage(403); }); // Add beta+distlist entitlements $john = $this->getTestUser('john@kolab.org'); $this->addDistlistEntitlement($john); $this->browse(function (Browser $browser) { // Create a group $browser->visit(new DistlistList()) ->assertSeeIn('button.create-list', 'Create list') ->click('button.create-list') ->on(new DistlistInfo()) ->assertSeeIn('#distlist-info .card-title', 'New distribution list') ->assertSeeIn('@nav #tab-general', 'General') ->assertMissing('@nav #tab-settings') ->with('@general', function (Browser $browser) { // Assert form content $browser->assertMissing('#status') - ->assertSeeIn('div.row:nth-child(1) label', 'Email') + ->assertFocused('#name') + ->assertSeeIn('div.row:nth-child(1) label', 'Name') ->assertValue('div.row:nth-child(1) input[type=text]', '') - ->assertSeeIn('div.row:nth-child(2) label', 'Recipients') - ->assertVisible('div.row:nth-child(2) .list-input') + ->assertSeeIn('div.row:nth-child(2) label', 'Email') + ->assertValue('div.row:nth-child(2) input[type=text]', '') + ->assertSeeIn('div.row:nth-child(3) label', 'Recipients') + ->assertVisible('div.row:nth-child(3) .list-input') ->with(new ListInput('#members'), function (Browser $browser) { $browser->assertListInputValue([]) ->assertValue('@input', ''); }) ->assertSeeIn('button[type=submit]', 'Submit'); }) // Test error conditions + ->type('#name', str_repeat('A', 192)) ->type('#email', 'group-test@kolabnow.com') ->click('@general button[type=submit]') - ->waitFor('#email + .invalid-feedback') - ->assertSeeIn('#email + .invalid-feedback', 'The specified domain is not available.') - ->assertFocused('#email') ->waitFor('#members + .invalid-feedback') + ->assertSeeIn('#email + .invalid-feedback', 'The specified domain is not available.') + ->assertSeeIn('#name + .invalid-feedback', 'The name may not be greater than 191 characters.') ->assertSeeIn('#members + .invalid-feedback', 'At least one recipient is required.') + ->assertFocused('#name') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') // Test successful group creation + ->type('#name', 'Test Group') ->type('#email', 'group-test@kolab.org') ->with(new ListInput('#members'), function (Browser $browser) { $browser->addListEntry('test1@gmail.com') ->addListEntry('test2@gmail.com'); }) ->click('@general button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'Distribution list created successfully.') ->on(new DistlistList()) ->assertElementsCount('@table tbody tr', 1); // Test group update - $browser->click('@table tr:nth-child(1) a') + $browser->click('@table tr:nth-child(1) td:first-child a') ->on(new DistlistInfo()) ->assertSeeIn('#distlist-info .card-title', 'Distribution list') ->with('@general', function (Browser $browser) { // Assert form content - $browser->assertSeeIn('div.row:nth-child(1) label', 'Status') + $browser->assertFocused('#name') + ->assertSeeIn('div.row:nth-child(1) label', 'Status') ->assertSeeIn('div.row:nth-child(1) span.text-danger', 'Not Ready') - ->assertSeeIn('div.row:nth-child(2) label', 'Email') - ->assertValue('div.row:nth-child(2) input[type=text]:disabled', 'group-test@kolab.org') - ->assertSeeIn('div.row:nth-child(3) label', 'Recipients') - ->assertVisible('div.row:nth-child(3) .list-input') + ->assertSeeIn('div.row:nth-child(2) label', 'Name') + ->assertValue('div.row:nth-child(2) input[type=text]', 'Test Group') + ->assertSeeIn('div.row:nth-child(3) label', 'Email') + ->assertValue('div.row:nth-child(3) input[type=text]:disabled', 'group-test@kolab.org') + ->assertSeeIn('div.row:nth-child(4) label', 'Recipients') + ->assertVisible('div.row:nth-child(4) .list-input') ->with(new ListInput('#members'), function (Browser $browser) { $browser->assertListInputValue(['test1@gmail.com', 'test2@gmail.com']) ->assertValue('@input', ''); }) ->assertSeeIn('button[type=submit]', 'Submit'); }) // Test error handling ->with(new ListInput('#members'), function (Browser $browser) { $browser->addListEntry('invalid address'); }) ->click('@general button[type=submit]') ->waitFor('#members + .invalid-feedback') ->assertSeeIn('#members + .invalid-feedback', 'The specified email address is invalid.') ->assertVisible('#members .input-group:nth-child(4) input.is-invalid') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') // Test successful update ->with(new ListInput('#members'), function (Browser $browser) { $browser->removeListEntry(3)->removeListEntry(2); }) ->click('@general button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'Distribution list updated successfully.') ->assertMissing('.invalid-feedback') ->on(new DistlistList()) ->assertElementsCount('@table tbody tr', 1); $group = Group::where('email', 'group-test@kolab.org')->first(); $this->assertSame(['test1@gmail.com'], $group->members); // Test group deletion - $browser->click('@table tr:nth-child(1) a') + $browser->click('@table tr:nth-child(1) td:first-child a') ->on(new DistlistInfo()) ->assertSeeIn('button.button-delete', 'Delete list') ->click('button.button-delete') ->assertToast(Toast::TYPE_SUCCESS, 'Distribution list deleted successfully.') ->on(new DistlistList()) ->assertElementsCount('@table tbody tr', 0) ->assertVisible('@table tfoot'); $this->assertNull(Group::where('email', 'group-test@kolab.org')->first()); }); } /** * Test distribution list status * * @depends testList */ public function testStatus(): void { $john = $this->getTestUser('john@kolab.org'); $this->addDistlistEntitlement($john); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($john->wallets->first()); $group->status = Group::STATUS_NEW | Group::STATUS_ACTIVE; $group->save(); $this->assertFalse($group->isLdapReady()); $this->browse(function ($browser) use ($group) { // Test auto-refresh $browser->visit('/distlist/' . $group->id) ->on(new DistlistInfo()) ->with(new Status(), function ($browser) { $browser->assertSeeIn('@body', 'We are preparing the distribution list') ->assertProgress(83, 'Creating a distribution list...', 'pending') ->assertMissing('@refresh-button') ->assertMissing('@refresh-text') ->assertMissing('#status-link') ->assertMissing('#status-verify'); }); $group->status |= Group::STATUS_LDAP_READY; $group->save(); // Test Verify button $browser->waitUntilMissing('@status', 10); }); // TODO: Test all group statuses on the list } /** * Test distribution list settings */ public function testSettings(): void { $john = $this->getTestUser('john@kolab.org'); $this->addDistlistEntitlement($john); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($john->wallets->first()); $group->status = Group::STATUS_NEW | Group::STATUS_ACTIVE; $group->save(); $this->browse(function ($browser) use ($group) { // Test auto-refresh $browser->visit('/distlist/' . $group->id) ->on(new DistlistInfo()) ->assertSeeIn('@nav #tab-general', 'General') ->assertSeeIn('@nav #tab-settings', 'Settings') ->click('@nav #tab-settings') ->with('@settings form', function (Browser $browser) { // Assert form content $browser->assertSeeIn('div.row:nth-child(1) label', 'Sender Access List') ->assertVisible('div.row:nth-child(1) .list-input') ->with(new ListInput('#sender-policy'), function (Browser $browser) { $browser->assertListInputValue([]) ->assertValue('@input', ''); }) ->assertSeeIn('button[type=submit]', 'Submit'); }) // Test error handling ->with(new ListInput('#sender-policy'), function (Browser $browser) { $browser->addListEntry('test.com'); }) ->click('@settings button[type=submit]') ->assertToast(Toast::TYPE_SUCCESS, 'Distribution list settings updated successfully.') ->assertMissing('.invalid-feedback') ->refresh() ->on(new DistlistInfo()) ->click('@nav #tab-settings') ->with('@settings form', function (Browser $browser) { $browser->with(new ListInput('#sender-policy'), function (Browser $browser) { $browser->assertListInputValue(['test.com']) ->assertValue('@input', ''); }); }); }); } /** * Register the beta + distlist entitlements for the user */ private function addDistlistEntitlement($user): void { // Add beta+distlist entitlements $beta_sku = Sku::where('title', 'beta')->first(); $distlist_sku = Sku::where('title', 'distlist')->first(); $user->assignSku($beta_sku); $user->assignSku($distlist_sku); } } diff --git a/src/tests/Browser/Reseller/DistlistTest.php b/src/tests/Browser/Reseller/DistlistTest.php index 6adf7f9c..bc15aaee 100644 --- a/src/tests/Browser/Reseller/DistlistTest.php +++ b/src/tests/Browser/Reseller/DistlistTest.php @@ -1,136 +1,138 @@ deleteTestGroup('group-test@kolab.org'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestGroup('group-test@kolab.org'); parent::tearDown(); } /** * Test distlist info page (unauthenticated) */ public function testDistlistUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $user = $this->getTestUser('john@kolab.org'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($user->wallets->first()); $browser->visit('/distlist/' . $group->id)->on(new Home()); }); } /** * Test distribution list info page */ public function testInfo(): void { Queue::fake(); $this->browse(function (Browser $browser) { $user = $this->getTestUser('john@kolab.org'); - $group = $this->getTestGroup('group-test@kolab.org'); + $group = $this->getTestGroup('group-test@kolab.org', ['name' => 'Test Group']); $group->assignToWallet($user->wallets->first()); $group->members = ['test1@gmail.com', 'test2@gmail.com']; $group->save(); $group->setConfig(['sender_policy' => ['test1.com', 'test2.com']]); $distlist_page = new DistlistPage($group->id); $user_page = new UserPage($user->id); // Goto the distlist page $browser->visit(new Home()) ->submitLogon('reseller@' . \config('app.domain'), \App\Utils::generatePassphrase(), true) ->on(new Dashboard()) ->visit($user_page) ->on($user_page) ->click('@nav #tab-distlists') ->pause(1000) ->click('@user-distlists table tbody tr:first-child td a') ->on($distlist_page) ->assertSeeIn('@distlist-info .card-title', $group->email) ->with('@distlist-info form', function (Browser $browser) use ($group) { - $browser->assertElementsCount('.row', 3) + $browser->assertElementsCount('.row', 4) ->assertSeeIn('.row:nth-child(1) label', 'ID (Created)') ->assertSeeIn('.row:nth-child(1) #distlistid', "{$group->id} ({$group->created_at})") ->assertSeeIn('.row:nth-child(2) label', 'Status') ->assertSeeIn('.row:nth-child(2) #status.text-danger', 'Not Ready') - ->assertSeeIn('.row:nth-child(3) label', 'Recipients') - ->assertSeeIn('.row:nth-child(3) #members', $group->members[0]) - ->assertSeeIn('.row:nth-child(3) #members', $group->members[1]); + ->assertSeeIn('.row:nth-child(3) label', 'Name') + ->assertSeeIn('.row:nth-child(3) #name', $group->name) + ->assertSeeIn('.row:nth-child(4) label', 'Recipients') + ->assertSeeIn('.row:nth-child(4) #members', $group->members[0]) + ->assertSeeIn('.row:nth-child(4) #members', $group->members[1]); }) ->assertElementsCount('ul.nav-tabs', 1) ->assertSeeIn('ul.nav-tabs .nav-link', 'Settings') ->with('@distlist-settings form', function (Browser $browser) { $browser->assertElementsCount('.row', 1) ->assertSeeIn('.row:nth-child(1) label', 'Sender Access List') ->assertSeeIn('.row:nth-child(1) #sender_policy', 'test1.com, test2.com'); }); // Test invalid group identifier $browser->visit('/distlist/abc')->assertErrorPage(404); }); } /** * Test suspending/unsuspending a distribution list * * @depends testInfo */ public function testSuspendAndUnsuspend(): void { Queue::fake(); $this->browse(function (Browser $browser) { $user = $this->getTestUser('john@kolab.org'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($user->wallets->first()); $group->status = Group::STATUS_ACTIVE | Group::STATUS_LDAP_READY; $group->save(); $browser->visit(new DistlistPage($group->id)) ->assertVisible('@distlist-info #button-suspend') ->assertMissing('@distlist-info #button-unsuspend') ->assertSeeIn('@distlist-info #status.text-success', 'Active') ->click('@distlist-info #button-suspend') ->assertToast(Toast::TYPE_SUCCESS, 'Distribution list suspended successfully.') ->assertSeeIn('@distlist-info #status.text-warning', 'Suspended') ->assertMissing('@distlist-info #button-suspend') ->click('@distlist-info #button-unsuspend') ->assertToast(Toast::TYPE_SUCCESS, 'Distribution list unsuspended successfully.') ->assertSeeIn('@distlist-info #status.text-success', 'Active') ->assertVisible('@distlist-info #button-suspend') ->assertMissing('@distlist-info #button-unsuspend'); }); } } diff --git a/src/tests/Browser/Reseller/UserTest.php b/src/tests/Browser/Reseller/UserTest.php index 75b908c9..e3cef7e6 100644 --- a/src/tests/Browser/Reseller/UserTest.php +++ b/src/tests/Browser/Reseller/UserTest.php @@ -1,508 +1,509 @@ getTestUser('john@kolab.org'); $john->setSettings([ 'phone' => '+48123123123', 'external_email' => 'john.doe.external@gmail.com', ]); if ($john->isSuspended()) { User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]); } $wallet = $john->wallets()->first(); $wallet->discount()->dissociate(); $wallet->save(); $this->deleteTestGroup('group-test@kolab.org'); $this->clearMeetEntitlements(); } /** * {@inheritDoc} */ public function tearDown(): void { $john = $this->getTestUser('john@kolab.org'); $john->setSettings([ 'phone' => null, 'external_email' => 'john.doe.external@gmail.com', ]); if ($john->isSuspended()) { User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]); } $wallet = $john->wallets()->first(); $wallet->discount()->dissociate(); $wallet->save(); $this->deleteTestGroup('group-test@kolab.org'); $this->clearMeetEntitlements(); parent::tearDown(); } /** * Test user info page (unauthenticated) */ public function testUserUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $jack = $this->getTestUser('jack@kolab.org'); $browser->visit('/user/' . $jack->id)->on(new Home()); }); } /** * Test user info page */ public function testUserInfo(): void { $this->browse(function (Browser $browser) { $jack = $this->getTestUser('jack@kolab.org'); $page = new UserPage($jack->id); $browser->visit(new Home()) ->submitLogon('reseller@' . \config('app.domain'), \App\Utils::generatePassphrase(), 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)') ->assertSeeIn('.row:nth-child(2) #userid', "{$jack->id} ({$jack->created_at})") ->assertSeeIn('.row:nth-child(3) label', 'Status') ->assertSeeIn('.row:nth-child(3) #status span.text-success', 'Active') ->assertSeeIn('.row:nth-child(4) label', 'First Name') ->assertSeeIn('.row:nth-child(4) #first_name', 'Jack') ->assertSeeIn('.row:nth-child(5) label', 'Last Name') ->assertSeeIn('.row:nth-child(5) #last_name', 'Daniels') ->assertSeeIn('.row:nth-child(6) label', 'External Email') ->assertMissing('.row:nth-child(6) #external_email a') ->assertSeeIn('.row:nth-child(7) label', 'Country') ->assertSeeIn('.row:nth-child(7) #country', 'United States'); }); // Some tabs are loaded in background, wait a second $browser->pause(500) ->assertElementsCount('@nav a', 7); // 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', '5,00 CHF/month') ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 5 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,90 CHF/month') ->assertMissing('table tfoot') ->assertMissing('#reset2fa'); }); // Assert Domains tab $browser->assertSeeIn('@nav #tab-domains', 'Domains (0)') ->click('@nav #tab-domains') ->with('@user-domains', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no domains in this account.'); }); // Assert Users tab $browser->assertSeeIn('@nav #tab-users', 'Users (0)') ->click('@nav #tab-users') ->with('@user-users', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no users in this account.'); }); // Assert Distribution lists tab $browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (0)') ->click('@nav #tab-distlists') ->with('@user-distlists', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no distribution lists in this account.'); }); }); } /** * Test user info page (continue) * * @depends testUserInfo */ public function testUserInfo2(): void { $this->browse(function (Browser $browser) { $john = $this->getTestUser('john@kolab.org'); $page = new UserPage($john->id); $discount = Discount::where('code', 'TEST')->first(); $wallet = $john->wallet(); $wallet->discount()->associate($discount); $wallet->debit(2010); $wallet->save(); - $group = $this->getTestGroup('group-test@kolab.org'); + $group = $this->getTestGroup('group-test@kolab.org', ['name' => 'Test Group']); $group->assignToWallet($john->wallets->first()); // Click the managed-by link on Jack's page $browser->click('@user-info #manager a') ->on($page); // Assert main info box content $browser->assertSeeIn('@user-info .card-title', $john->email) ->with('@user-info form', function (Browser $browser) use ($john) { $ext_email = $john->getSetting('external_email'); $browser->assertElementsCount('.row', 9) ->assertSeeIn('.row:nth-child(1) label', 'ID (Created)') ->assertSeeIn('.row:nth-child(1) #userid', "{$john->id} ({$john->created_at})") ->assertSeeIn('.row:nth-child(2) label', 'Status') ->assertSeeIn('.row:nth-child(2) #status span.text-success', 'Active') ->assertSeeIn('.row:nth-child(3) label', 'First Name') ->assertSeeIn('.row:nth-child(3) #first_name', 'John') ->assertSeeIn('.row:nth-child(4) label', 'Last Name') ->assertSeeIn('.row:nth-child(4) #last_name', 'Doe') ->assertSeeIn('.row:nth-child(5) label', 'Organization') ->assertSeeIn('.row:nth-child(5) #organization', 'Kolab Developers') ->assertSeeIn('.row:nth-child(6) label', 'Phone') ->assertSeeIn('.row:nth-child(6) #phone', $john->getSetting('phone')) ->assertSeeIn('.row:nth-child(7) label', 'External Email') ->assertSeeIn('.row:nth-child(7) #external_email a', $ext_email) ->assertAttribute('.row:nth-child(7) #external_email a', 'href', "mailto:$ext_email") ->assertSeeIn('.row:nth-child(8) label', 'Address') ->assertSeeIn('.row:nth-child(8) #billing_address', $john->getSetting('billing_address')) ->assertSeeIn('.row:nth-child(9) label', 'Country') ->assertSeeIn('.row:nth-child(9) #country', 'United States'); }); // Some tabs are loaded in background, wait a second $browser->pause(500) ->assertElementsCount('@nav a', 7); // 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', '4,50 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 5 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,41 CHF/month¹') ->assertMissing('table tfoot') ->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher'); }); // Assert Domains tab $browser->assertSeeIn('@nav #tab-domains', 'Domains (1)') ->click('@nav #tab-domains') ->with('@user-domains table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 1) ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'kolab.org') ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success') ->assertMissing('tfoot'); }); // Assert Distribution lists tab $browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (1)') ->click('@nav #tab-distlists') ->with('@user-distlists table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 1) - ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'group-test@kolab.org') + ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'Test Group') ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-danger') + ->assertSeeIn('tbody tr:nth-child(1) td:last-child a', 'group-test@kolab.org') ->assertMissing('tfoot'); }); // Assert Users tab $browser->assertSeeIn('@nav #tab-users', 'Users (4)') ->click('@nav #tab-users') ->with('@user-users table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 4) ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'jack@kolab.org') ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success') ->assertSeeIn('tbody tr:nth-child(2) td:first-child a', 'joe@kolab.org') ->assertVisible('tbody tr:nth-child(2) td:first-child svg.text-success') ->assertSeeIn('tbody tr:nth-child(3) td:first-child span', 'john@kolab.org') ->assertVisible('tbody tr:nth-child(3) td:first-child svg.text-success') ->assertSeeIn('tbody tr:nth-child(4) td:first-child a', 'ned@kolab.org') ->assertVisible('tbody tr:nth-child(4) td:first-child svg.text-success') ->assertMissing('tfoot'); }); }); // Now we go to Ned's info page, he's a controller on John's wallet $this->browse(function (Browser $browser) { $ned = $this->getTestUser('ned@kolab.org'); $ned->setSetting('greylist_enabled', 'false'); $page = new UserPage($ned->id); $browser->click('@user-users tbody tr:nth-child(4) td:first-child a') ->on($page); // Assert main info box content $browser->assertSeeIn('@user-info .card-title', $ned->email) ->with('@user-info form', function (Browser $browser) use ($ned) { $browser->assertSeeIn('.row:nth-child(2) label', 'ID (Created)') ->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', 7); // 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', '4,50 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 5 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,41 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(4) td:first-child', 'Activesync') ->assertSeeIn('table tbody tr:nth-child(4) td:last-child', '0,00 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(5) td:first-child', '2-Factor Authentication') ->assertSeeIn('table tbody tr:nth-child(5) td:last-child', '0,00 CHF/month¹') ->assertMissing('table tfoot') ->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher') ->assertSeeIn('#reset2fa', 'Reset 2-Factor Auth'); }); // We don't expect John's domains here $browser->assertSeeIn('@nav #tab-domains', 'Domains (0)') ->click('@nav #tab-domains') ->with('@user-domains', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no domains in this account.'); }); // We don't expect John's users here $browser->assertSeeIn('@nav #tab-users', 'Users (0)') ->click('@nav #tab-users') ->with('@user-users', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no users in this account.'); }); // We don't expect John's distribution lists here $browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (0)') ->click('@nav #tab-distlists') ->with('@user-distlists', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no distribution lists in this account.'); }); // Assert Settings tab $browser->assertSeeIn('@nav #tab-settings', 'Settings') ->click('@nav #tab-settings') ->whenAvailable('@user-settings form', function (Browser $browser) { $browser->assertElementsCount('.row', 1) ->assertSeeIn('.row:first-child label', 'Greylisting') ->assertSeeIn('.row:first-child .text-danger', 'disabled'); }); }); } /** * 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::withEnvTenantContext()->where(['title' => '2fa'])->first(); $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)'); }); } /** * Test adding the beta SKU for the user */ public function testAddBetaSku(): void { $this->browse(function (Browser $browser) { $this->deleteTestUser('userstest1@kolabnow.com'); $user = $this->getTestUser('userstest1@kolabnow.com'); $sku = Sku::withEnvTenantContext()->where('title', 'beta')->first(); $browser->visit(new UserPage($user->id)) ->click('@nav #tab-subscriptions') ->waitFor('@user-subscriptions #addbetasku') ->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (0)') ->assertSeeIn('#addbetasku', 'Enable beta program') ->click('#addbetasku') ->assertToast(Toast::TYPE_SUCCESS, 'The subscription added successfully.') ->waitFor('#sku' . $sku->id) ->assertSeeIn("#sku{$sku->id} td:first-child", 'Private Beta (invitation only)') ->assertSeeIn("#sku{$sku->id} td:last-child", '0,00 CHF/month') ->assertMissing('#addbetasku') ->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (1)'); }); } } diff --git a/src/tests/Feature/Backends/LDAPTest.php b/src/tests/Feature/Backends/LDAPTest.php index bc70a893..1137ebc8 100644 --- a/src/tests/Feature/Backends/LDAPTest.php +++ b/src/tests/Feature/Backends/LDAPTest.php @@ -1,389 +1,413 @@ ldap_config = [ 'ldap.hosts' => \config('ldap.hosts'), ]; $this->deleteTestUser('user-ldap-test@' . \config('app.domain')); $this->deleteTestDomain('testldap.com'); $this->deleteTestGroup('group@kolab.org'); // TODO: Remove group members } /** * {@inheritDoc} */ public function tearDown(): void { \config($this->ldap_config); $this->deleteTestUser('user-ldap-test@' . \config('app.domain')); $this->deleteTestDomain('testldap.com'); $this->deleteTestGroup('group@kolab.org'); // TODO: Remove group members 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 { 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/updating/deleting a group record * * @group ldap */ public function testGroup(): void { Queue::fake(); $root_dn = \config('ldap.hosted.root_dn'); $group = $this->getTestGroup('group@kolab.org', [ 'members' => ['member1@testldap.com', 'member2@testldap.com'] ]); $group->setSetting('sender_policy', '["test.com"]'); // Create the group LDAP::createGroup($group); $ldap_group = LDAP::getGroup($group->email); $expected = [ 'cn' => 'group', 'dn' => 'cn=group,ou=Groups,ou=kolab.org,' . $root_dn, 'mail' => $group->email, 'objectclass' => [ 'top', 'groupofuniquenames', 'kolabgroupofuniquenames' ], 'kolaballowsmtpsender' => 'test.com', 'uniquemember' => [ 'uid=member1@testldap.com,ou=People,ou=kolab.org,' . $root_dn, 'uid=member2@testldap.com,ou=People,ou=kolab.org,' . $root_dn, ], ]; foreach ($expected as $attr => $value) { $this->assertEquals($value, isset($ldap_group[$attr]) ? $ldap_group[$attr] : null, "Group $attr attribute"); } // Update members $group->members = ['member3@testldap.com']; $group->save(); $group->setSetting('sender_policy', '["test.com","-"]'); LDAP::updateGroup($group); // TODO: Should we force this to be always an array? $expected['uniquemember'] = 'uid=member3@testldap.com,ou=People,ou=kolab.org,' . $root_dn; $expected['kolaballowsmtpsender'] = ['test.com', '-']; $ldap_group = LDAP::getGroup($group->email); foreach ($expected as $attr => $value) { $this->assertEquals($value, isset($ldap_group[$attr]) ? $ldap_group[$attr] : null, "Group $attr attribute"); } $this->assertSame(['member3@testldap.com'], $group->fresh()->members); // Update members (add non-existing local member, expect it to be aot-removed from the group) + // Update group name and sender_policy $group->members = ['member3@testldap.com', 'member-local@kolab.org']; + $group->name = 'Te(=ść)1'; $group->save(); $group->setSetting('sender_policy', null); LDAP::updateGroup($group); // TODO: Should we force this to be always an array? $expected['uniquemember'] = 'uid=member3@testldap.com,ou=People,ou=kolab.org,' . $root_dn; $expected['kolaballowsmtpsender'] = null; + $expected['dn'] = 'cn=Te(\\3dść)1,ou=Groups,ou=kolab.org,' . $root_dn; + $expected['cn'] = 'Te(=ść)1'; $ldap_group = LDAP::getGroup($group->email); foreach ($expected as $attr => $value) { $this->assertEquals($value, isset($ldap_group[$attr]) ? $ldap_group[$attr] : null, "Group $attr attribute"); } $this->assertSame(['member3@testldap.com'], $group->fresh()->members); // We called save() twice, and setSettings() three times, // this is making sure that there's no job executed by the LDAP backend Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 5); // Delete the domain LDAP::deleteGroup($group); $this->assertSame(null, LDAP::getGroup($group->email)); } /** * 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' => [ '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::withEnvTenantContext()->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'] = 5242880; $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::withEnvTenantContext()->where('title', 'activesync')->first(); $sku_groupware = \App\Sku::withEnvTenantContext()->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 errors on a group creation + * + * @group ldap + */ + public function testCreateGroupException(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessageMatches('/Failed to create group/'); + + $group = new Group([ + 'name' => 'test', + 'email' => 'test@testldap.com', + 'status' => Group::STATUS_NEW | Group::STATUS_ACTIVE, + ]); + + LDAP::createGroup($group); + } + /** * Test handling errors on user creation * * @group ldap */ public function testCreateUserException(): void { $this->expectException(\Exception::class); $this->expectExceptionMessageMatches('/Failed to create user/'); $user = new User([ 'email' => 'test-non-existing-ldap@non-existing.org', 'status' => User::STATUS_ACTIVE, ]); LDAP::createUser($user); } /** * 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 group * * @group ldap */ public function testUpdateGroupException(): void { $this->expectException(\Exception::class); $this->expectExceptionMessageMatches('/group not found/'); $group = new Group([ + 'name' => 'test', 'email' => 'test@testldap.com', 'status' => Group::STATUS_NEW | Group::STATUS_ACTIVE, ]); LDAP::updateGroup($group); } /** * 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/Controller/Admin/GroupsTest.php b/src/tests/Feature/Controller/Admin/GroupsTest.php index 369aa68a..127a5965 100644 --- a/src/tests/Feature/Controller/Admin/GroupsTest.php +++ b/src/tests/Feature/Controller/Admin/GroupsTest.php @@ -1,225 +1,227 @@ deleteTestGroup('group-test@kolab.org'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestGroup('group-test@kolab.org'); parent::tearDown(); } /** * Test groups searching (/api/v4/groups) */ public function testIndex(): void { $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($user->wallets->first()); // Non-admin user $response = $this->actingAs($user)->get("api/v4/groups"); $response->assertStatus(403); // Search with no search criteria $response = $this->actingAs($admin)->get("api/v4/groups"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search with no matches expected $response = $this->actingAs($admin)->get("api/v4/groups?search=john@kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search by email $response = $this->actingAs($admin)->get("api/v4/groups?search={$group->email}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($group->email, $json['list'][0]['email']); // Search by owner $response = $this->actingAs($admin)->get("api/v4/groups?owner={$user->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($group->email, $json['list'][0]['email']); + $this->assertSame($group->name, $json['list'][0]['name']); // Search by owner (Ned is a controller on John's wallets, // here we expect only domains assigned to Ned's wallet(s)) $ned = $this->getTestUser('ned@kolab.org'); $response = $this->actingAs($admin)->get("api/v4/groups?owner={$ned->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertCount(0, $json['list']); } /** * Test fetching group info */ public function testShow(): void { $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $user = $this->getTestUser('john@kolab.org'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($user->wallets->first()); // Only admins can access it $response = $this->actingAs($user)->get("api/v4/groups/{$group->id}"); $response->assertStatus(403); $response = $this->actingAs($admin)->get("api/v4/groups/{$group->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals($group->id, $json['id']); $this->assertEquals($group->email, $json['email']); + $this->assertEquals($group->name, $json['name']); $this->assertEquals($group->status, $json['status']); } /** * Test fetching domain status (GET /api/v4/domains//status) */ public function testStatus(): void { Queue::fake(); // disable jobs $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($user->wallets->first()); // This end-point does not exist for admins $response = $this->actingAs($admin)->get("/api/v4/groups/{$group->id}/status"); $response->assertStatus(404); } /** * Test group creating (POST /api/v4/groups) */ public function testStore(): void { $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // Test unauthorized access to admin API $response = $this->actingAs($user)->post("/api/v4/groups", []); $response->assertStatus(403); // Admin can't create groups $response = $this->actingAs($admin)->post("/api/v4/groups", []); $response->assertStatus(404); } /** * Test group suspending (POST /api/v4/groups//suspend) */ public function testSuspend(): void { Queue::fake(); // disable jobs $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($user->wallets->first()); // Test unauthorized access to admin API $response = $this->actingAs($user)->post("/api/v4/groups/{$group->id}/suspend", []); $response->assertStatus(403); // Test non-existing group ID $response = $this->actingAs($admin)->post("/api/v4/groups/abc/suspend", []); $response->assertStatus(404); $this->assertFalse($group->fresh()->isSuspended()); // Test suspending the group $response = $this->actingAs($admin)->post("/api/v4/groups/{$group->id}/suspend", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("Distribution list suspended successfully.", $json['message']); $this->assertCount(2, $json); $this->assertTrue($group->fresh()->isSuspended()); } /** * Test user un-suspending (POST /api/v4/users//unsuspend) */ public function testUnsuspend(): void { Queue::fake(); // disable jobs $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($user->wallets->first()); $group->status |= Group::STATUS_SUSPENDED; $group->save(); // Test unauthorized access to admin API $response = $this->actingAs($user)->post("/api/v4/groups/{$group->id}/unsuspend", []); $response->assertStatus(403); // Invalid group ID $response = $this->actingAs($admin)->post("/api/v4/groups/abc/unsuspend", []); $response->assertStatus(404); $this->assertTrue($group->fresh()->isSuspended()); // Test suspending the group $response = $this->actingAs($admin)->post("/api/v4/groups/{$group->id}/unsuspend", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("Distribution list unsuspended successfully.", $json['message']); $this->assertCount(2, $json); $this->assertFalse($group->fresh()->isSuspended()); } } diff --git a/src/tests/Feature/Controller/GroupsTest.php b/src/tests/Feature/Controller/GroupsTest.php index d5bc9327..1fa31fc8 100644 --- a/src/tests/Feature/Controller/GroupsTest.php +++ b/src/tests/Feature/Controller/GroupsTest.php @@ -1,571 +1,601 @@ deleteTestGroup('group-test@kolab.org'); + $this->deleteTestGroup('group-test2@kolab.org'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestGroup('group-test@kolab.org'); + $this->deleteTestGroup('group-test2@kolab.org'); parent::tearDown(); } /** * Test group deleting (DELETE /api/v4/groups/) */ public function testDestroy(): void { // First create some groups to delete $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($john->wallets->first()); // Test unauth access $response = $this->delete("api/v4/groups/{$group->id}"); $response->assertStatus(401); // Test non-existing group $response = $this->actingAs($john)->delete("api/v4/groups/abc"); $response->assertStatus(404); // Test access to other user's group $response = $this->actingAs($jack)->delete("api/v4/groups/{$group->id}"); $response->assertStatus(403); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame("Access denied", $json['message']); $this->assertCount(2, $json); // Test removing a group $response = $this->actingAs($john)->delete("api/v4/groups/{$group->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('success', $json['status']); $this->assertEquals("Distribution list deleted successfully.", $json['message']); } /** * Test groups listing (GET /api/v4/groups) */ public function testIndex(): void { $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($john->wallets->first()); // Test unauth access $response = $this->get("api/v4/groups"); $response->assertStatus(401); // Test a user with no groups $response = $this->actingAs($jack)->get("/api/v4/groups"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(0, $json); // Test a user with a single group $response = $this->actingAs($john)->get("/api/v4/groups"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(1, $json); $this->assertSame($group->id, $json[0]['id']); $this->assertSame($group->email, $json[0]['email']); + $this->assertSame($group->name, $json[0]['name']); $this->assertArrayHasKey('isDeleted', $json[0]); $this->assertArrayHasKey('isSuspended', $json[0]); $this->assertArrayHasKey('isActive', $json[0]); $this->assertArrayHasKey('isLdapReady', $json[0]); // Test that another wallet controller has access to groups $response = $this->actingAs($ned)->get("/api/v4/groups"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(1, $json); $this->assertSame($group->email, $json[0]['email']); } /** * Test group config update (POST /api/v4/groups//config) */ public function testSetConfig(): void { $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($john->wallets->first()); // Test unknown group id $post = ['sender_policy' => []]; $response = $this->actingAs($john)->post("/api/v4/groups/123/config", $post); $json = $response->json(); $response->assertStatus(404); // Test access by user not being a wallet controller $post = ['sender_policy' => []]; $response = $this->actingAs($jack)->post("/api/v4/groups/{$group->id}/config", $post); $json = $response->json(); $response->assertStatus(403); $this->assertSame('error', $json['status']); $this->assertSame("Access denied", $json['message']); $this->assertCount(2, $json); // Test some invalid data $post = ['test' => 1]; $response = $this->actingAs($john)->post("/api/v4/groups/{$group->id}/config", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertCount(1, $json['errors']); $this->assertSame('The requested configuration parameter is not supported.', $json['errors']['test']); $group->refresh(); $this->assertNull($group->getSetting('test')); $this->assertNull($group->getSetting('sender_policy')); // Test some valid data $post = ['sender_policy' => ['domain.com']]; $response = $this->actingAs($john)->post("/api/v4/groups/{$group->id}/config", $post); $response->assertStatus(200); $json = $response->json(); $this->assertCount(2, $json); $this->assertSame('success', $json['status']); $this->assertSame('Distribution list settings updated successfully.', $json['message']); $this->assertSame(['sender_policy' => $post['sender_policy']], $group->fresh()->getConfig()); // Test input validation $post = ['sender_policy' => [5]]; $response = $this->actingAs($john)->post("/api/v4/groups/{$group->id}/config", $post); $response->assertStatus(422); $json = $response->json(); $this->assertCount(2, $json); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame( 'The entry format is invalid. Expected an email, domain, or part of it.', $json['errors']['sender_policy'][0] ); $this->assertSame(['sender_policy' => ['domain.com']], $group->fresh()->getConfig()); } /** * Test fetching group data/profile (GET /api/v4/groups/) */ public function testShow(): void { $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($john->wallets->first()); $group->setSetting('sender_policy', '["test"]'); // Test unauthorized access to a profile of other user $response = $this->get("/api/v4/groups/{$group->id}"); $response->assertStatus(401); // Test unauthorized access to a group of another user $response = $this->actingAs($jack)->get("/api/v4/groups/{$group->id}"); $response->assertStatus(403); // John: Group owner - non-existing group $response = $this->actingAs($john)->get("/api/v4/groups/abc"); $response->assertStatus(404); // John: Group owner $response = $this->actingAs($john)->get("/api/v4/groups/{$group->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame($group->id, $json['id']); $this->assertSame($group->email, $json['email']); + $this->assertSame($group->name, $json['name']); $this->assertSame($group->members, $json['members']); $this->assertTrue(!empty($json['statusInfo'])); $this->assertArrayHasKey('isDeleted', $json); $this->assertArrayHasKey('isSuspended', $json); $this->assertArrayHasKey('isActive', $json); $this->assertArrayHasKey('isLdapReady', $json); $this->assertSame(['sender_policy' => ['test']], $json['config']); } /** * Test fetching group status (GET /api/v4/groups//status) * and forcing setup process update (?refresh=1) */ public function testStatus(): void { Queue::fake(); $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($john->wallets->first()); // Test unauthorized access $response = $this->get("/api/v4/groups/abc/status"); $response->assertStatus(401); // Test unauthorized access $response = $this->actingAs($jack)->get("/api/v4/groups/{$group->id}/status"); $response->assertStatus(403); $group->status = Group::STATUS_NEW | Group::STATUS_ACTIVE; $group->save(); // Get group status $response = $this->actingAs($john)->get("/api/v4/groups/{$group->id}/status"); $response->assertStatus(200); $json = $response->json(); $this->assertFalse($json['isLdapReady']); $this->assertFalse($json['isReady']); $this->assertFalse($json['isSuspended']); $this->assertTrue($json['isActive']); $this->assertFalse($json['isDeleted']); $this->assertCount(6, $json['process']); $this->assertSame('distlist-new', $json['process'][0]['label']); $this->assertSame(true, $json['process'][0]['state']); $this->assertSame('distlist-ldap-ready', $json['process'][1]['label']); $this->assertSame(false, $json['process'][1]['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 |= \App\Domain::STATUS_CONFIRMED; $domain->save(); // Now "reboot" the process and the group $response = $this->actingAs($john)->get("/api/v4/groups/{$group->id}/status?refresh=1"); $response->assertStatus(200); $json = $response->json(); $this->assertTrue($json['isLdapReady']); $this->assertTrue($json['isReady']); $this->assertCount(6, $json['process']); $this->assertSame('distlist-ldap-ready', $json['process'][1]['label']); $this->assertSame(true, $json['process'][1]['state']); $this->assertSame('success', $json['status']); $this->assertSame('Setup process finished successfully.', $json['message']); // Test a case when a domain is not ready $domain->status ^= \App\Domain::STATUS_CONFIRMED; $domain->save(); $response = $this->actingAs($john)->get("/api/v4/groups/{$group->id}/status?refresh=1"); $response->assertStatus(200); $json = $response->json(); $this->assertTrue($json['isLdapReady']); $this->assertTrue($json['isReady']); $this->assertCount(6, $json['process']); $this->assertSame('distlist-ldap-ready', $json['process'][1]['label']); $this->assertSame(true, $json['process'][1]['state']); $this->assertSame('success', $json['status']); $this->assertSame('Setup process finished successfully.', $json['message']); } /** * Test GroupsController::statusInfo() */ public function testStatusInfo(): void { $john = $this->getTestUser('john@kolab.org'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($john->wallets->first()); $group->status = Group::STATUS_NEW | Group::STATUS_ACTIVE; $group->save(); $result = GroupsController::statusInfo($group); $this->assertFalse($result['isReady']); $this->assertCount(6, $result['process']); $this->assertSame('distlist-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('distlist-ldap-ready', $result['process'][1]['label']); $this->assertSame(false, $result['process'][1]['state']); $this->assertSame('running', $result['processState']); $group->created_at = Carbon::now()->subSeconds(181); $group->save(); $result = GroupsController::statusInfo($group); $this->assertSame('failed', $result['processState']); $group->status |= Group::STATUS_LDAP_READY; $group->save(); $result = GroupsController::statusInfo($group); $this->assertTrue($result['isReady']); $this->assertCount(6, $result['process']); $this->assertSame('distlist-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('distlist-ldap-ready', $result['process'][1]['label']); $this->assertSame(true, $result['process'][2]['state']); $this->assertSame('done', $result['processState']); } /** * Test group creation (POST /api/v4/groups) */ public function testStore(): void { Queue::fake(); $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); // Test unauth request $response = $this->post("/api/v4/groups", []); $response->assertStatus(401); // Test non-controller user $response = $this->actingAs($jack)->post("/api/v4/groups", []); $response->assertStatus(403); // Test empty request $response = $this->actingAs($john)->post("/api/v4/groups", []); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame("The email field is required.", $json['errors']['email']); + $this->assertSame("At least one recipient is required.", $json['errors']['members']); + $this->assertSame("The name field is required.", $json['errors']['name'][0]); $this->assertCount(2, $json); + $this->assertCount(3, $json['errors']); - // Test missing members + // Test missing members and name $post = ['email' => 'group-test@kolab.org']; $response = $this->actingAs($john)->post("/api/v4/groups", $post); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertSame("At least one recipient is required.", $json['errors']['members']); + $this->assertSame("The name field is required.", $json['errors']['name'][0]); $this->assertCount(2, $json); + $this->assertCount(2, $json['errors']); - // Test invalid email - $post = ['email' => 'invalid']; + // Test invalid email and too long name + $post = ['email' => 'invalid', 'name' => str_repeat('A', 192)]; $response = $this->actingAs($john)->post("/api/v4/groups", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); - $this->assertSame('The specified email is invalid.', $json['errors']['email']); + $this->assertSame("The specified email is invalid.", $json['errors']['email']); + $this->assertSame("The name may not be greater than 191 characters.", $json['errors']['name'][0]); + $this->assertCount(3, $json['errors']); // Test successful group creation $post = [ + 'name' => 'Test Group', 'email' => 'group-test@kolab.org', 'members' => ['test1@domain.tld', 'test2@domain.tld'] ]; $response = $this->actingAs($john)->post("/api/v4/groups", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("Distribution list created successfully.", $json['message']); $this->assertCount(2, $json); $group = Group::where('email', 'group-test@kolab.org')->first(); $this->assertInstanceOf(Group::class, $group); $this->assertSame($post['email'], $group->email); $this->assertSame($post['members'], $group->members); $this->assertTrue($john->groups()->get()->contains($group)); + + // Group name must be unique within a domain + $post['email'] = 'group-test2@kolab.org'; + $post['members'] = ['test1@domain.tld']; + + $response = $this->actingAs($john)->post("/api/v4/groups", $post); + $response->assertStatus(422); + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(2, $json); + $this->assertCount(1, $json['errors']); + $this->assertSame("The specified name is not available.", $json['errors']['name'][0]); } /** * Test group update (PUT /api/v4/groups/) */ public function testUpdate(): void { Queue::fake(); $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($john->wallets->first()); // Test unauthorized update $response = $this->get("/api/v4/groups/{$group->id}", []); $response->assertStatus(401); // Test unauthorized update $response = $this->actingAs($jack)->get("/api/v4/groups/{$group->id}", []); $response->assertStatus(403); // Test updating - missing members $response = $this->actingAs($john)->put("/api/v4/groups/{$group->id}", []); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame("At least one recipient is required.", $json['errors']['members']); $this->assertCount(2, $json); // Test some invalid data $post = ['members' => ['test@domain.tld', 'invalid']]; $response = $this->actingAs($john)->put("/api/v4/groups/{$group->id}", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertSame('The specified email address is invalid.', $json['errors']['members'][1]); - // Valid data - members changed + // Valid data - members and name changed $post = [ + 'name' => 'Test Gr', 'members' => ['member1@test.domain', 'member2@test.domain'] ]; $response = $this->actingAs($john)->put("/api/v4/groups/{$group->id}", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("Distribution list updated successfully.", $json['message']); $this->assertCount(2, $json); - $this->assertSame($group->fresh()->members, $post['members']); + + $group->refresh(); + + $this->assertSame($post['name'], $group->name); + $this->assertSame($post['members'], $group->members); } /** * Group email address validation. */ public function testValidateGroupEmail(): void { $john = $this->getTestUser('john@kolab.org'); $group = $this->getTestGroup('group-test@kolab.org'); // Invalid email $result = GroupsController::validateGroupEmail('', $john); $this->assertSame("The email field is required.", $result); $result = GroupsController::validateGroupEmail('kolab.org', $john); $this->assertSame("The specified email is invalid.", $result); $result = GroupsController::validateGroupEmail('.@kolab.org', $john); $this->assertSame("The specified email is invalid.", $result); $result = GroupsController::validateGroupEmail('test123456@localhost', $john); $this->assertSame("The specified domain is invalid.", $result); $result = GroupsController::validateGroupEmail('test123456@unknown-domain.org', $john); $this->assertSame("The specified domain is invalid.", $result); // forbidden public domain $result = GroupsController::validateGroupEmail('testtest@kolabnow.com', $john); $this->assertSame("The specified domain is not available.", $result); // existing alias $result = GroupsController::validateGroupEmail('jack.daniels@kolab.org', $john); $this->assertSame("The specified email is not available.", $result); // existing user $result = GroupsController::validateGroupEmail('ned@kolab.org', $john); $this->assertSame("The specified email is not available.", $result); // existing group $result = GroupsController::validateGroupEmail('group-test@kolab.org', $john); $this->assertSame("The specified email is not available.", $result); // valid $result = GroupsController::validateGroupEmail('admin@kolab.org', $john); $this->assertSame(null, $result); } /** * Group member email address validation. */ public function testValidateMemberEmail(): void { $john = $this->getTestUser('john@kolab.org'); // Invalid format $result = GroupsController::validateMemberEmail('kolab.org', $john); $this->assertSame("The specified email address is invalid.", $result); $result = GroupsController::validateMemberEmail('.@kolab.org', $john); $this->assertSame("The specified email address is invalid.", $result); $result = GroupsController::validateMemberEmail('test123456@localhost', $john); $this->assertSame("The specified email address is invalid.", $result); // Test local non-existing user $result = GroupsController::validateMemberEmail('unknown@kolab.org', $john); $this->assertSame("The specified email address does not exist.", $result); // Test local existing user $result = GroupsController::validateMemberEmail('ned@kolab.org', $john); $this->assertSame(null, $result); // Test existing user, but not in the same account $result = GroupsController::validateMemberEmail('jeroen@jeroen.jeroen', $john); $this->assertSame(null, $result); // Valid address $result = GroupsController::validateMemberEmail('test@google.com', $john); $this->assertSame(null, $result); } } diff --git a/src/tests/Feature/Controller/Reseller/GroupsTest.php b/src/tests/Feature/Controller/Reseller/GroupsTest.php index 22b213c3..6d2e37e0 100644 --- a/src/tests/Feature/Controller/Reseller/GroupsTest.php +++ b/src/tests/Feature/Controller/Reseller/GroupsTest.php @@ -1,278 +1,280 @@ deleteTestGroup('group-test@kolab.org'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestGroup('group-test@kolab.org'); parent::tearDown(); } /** * Test groups searching (/api/v4/groups) */ public function testIndex(): void { $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($user->wallets->first()); // Non-admin user $response = $this->actingAs($user)->get("api/v4/groups"); $response->assertStatus(403); // Admin user $response = $this->actingAs($admin)->get("api/v4/groups"); $response->assertStatus(403); // Search with no search criteria $response = $this->actingAs($reseller1)->get("api/v4/groups"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search with no matches expected $response = $this->actingAs($reseller1)->get("api/v4/groups?search=john@kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); // Search by email $response = $this->actingAs($reseller1)->get("api/v4/groups?search={$group->email}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($group->email, $json['list'][0]['email']); // Search by owner $response = $this->actingAs($reseller1)->get("api/v4/groups?owner={$user->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($group->email, $json['list'][0]['email']); + $this->assertSame($group->name, $json['list'][0]['name']); // Search by owner (Ned is a controller on John's wallets, // here we expect only domains assigned to Ned's wallet(s)) $ned = $this->getTestUser('ned@kolab.org'); $response = $this->actingAs($reseller1)->get("api/v4/groups?owner={$ned->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertCount(0, $json['list']); $response = $this->actingAs($reseller2)->get("api/v4/groups?search=kolab.org"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); $response = $this->actingAs($reseller2)->get("api/v4/groups?owner={$user->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(0, $json['count']); $this->assertSame([], $json['list']); } /** * Test fetching group info */ public function testShow(): void { $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $user = $this->getTestUser('john@kolab.org'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($user->wallets->first()); // Only resellers can access it $response = $this->actingAs($user)->get("api/v4/groups/{$group->id}"); $response->assertStatus(403); $response = $this->actingAs($admin)->get("api/v4/groups/{$group->id}"); $response->assertStatus(403); $response = $this->actingAs($reseller2)->get("api/v4/groups/{$group->id}"); $response->assertStatus(404); $response = $this->actingAs($reseller1)->get("api/v4/groups/{$group->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals($group->id, $json['id']); $this->assertEquals($group->email, $json['email']); + $this->assertEquals($group->name, $json['name']); $this->assertEquals($group->status, $json['status']); } /** * Test fetching group status (GET /api/v4/domains//status) */ public function testStatus(): void { Queue::fake(); // disable jobs $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($user->wallets->first()); // This end-point does not exist for admins $response = $this->actingAs($reseller1)->get("/api/v4/groups/{$group->id}/status"); $response->assertStatus(404); } /** * Test group creating (POST /api/v4/groups) */ public function testStore(): void { $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); // Test unauthorized access to reseller API $response = $this->actingAs($user)->post("/api/v4/groups", []); $response->assertStatus(403); // Reseller or admin can't create groups $response = $this->actingAs($admin)->post("/api/v4/groups", []); $response->assertStatus(403); $response = $this->actingAs($reseller1)->post("/api/v4/groups", []); $response->assertStatus(404); } /** * Test group suspending (POST /api/v4/groups//suspend) */ public function testSuspend(): void { Queue::fake(); // disable jobs $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($user->wallets->first()); // Test unauthorized access to reseller API $response = $this->actingAs($user)->post("/api/v4/groups/{$group->id}/suspend", []); $response->assertStatus(403); // Test unauthorized access to reseller API $response = $this->actingAs($admin)->post("/api/v4/groups/{$group->id}/suspend", []); $response->assertStatus(403); // Test non-existing group ID $response = $this->actingAs($reseller1)->post("/api/v4/groups/abc/suspend", []); $response->assertStatus(404); $this->assertFalse($group->fresh()->isSuspended()); // Test suspending the group $response = $this->actingAs($reseller1)->post("/api/v4/groups/{$group->id}/suspend", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("Distribution list suspended successfully.", $json['message']); $this->assertCount(2, $json); $this->assertTrue($group->fresh()->isSuspended()); $response = $this->actingAs($reseller2)->post("/api/v4/groups/{$group->id}/suspend", []); $response->assertStatus(404); } /** * Test user un-suspending (POST /api/v4/users//unsuspend) */ public function testUnsuspend(): void { Queue::fake(); // disable jobs $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); $group = $this->getTestGroup('group-test@kolab.org'); $group->assignToWallet($user->wallets->first()); $group->status |= Group::STATUS_SUSPENDED; $group->save(); // Test unauthorized access to reseller API $response = $this->actingAs($user)->post("/api/v4/groups/{$group->id}/unsuspend", []); $response->assertStatus(403); // Test unauthorized access to reseller API $response = $this->actingAs($admin)->post("/api/v4/groups/{$group->id}/unsuspend", []); $response->assertStatus(403); // Invalid group ID $response = $this->actingAs($reseller1)->post("/api/v4/groups/abc/unsuspend", []); $response->assertStatus(404); $this->assertTrue($group->fresh()->isSuspended()); // Test suspending the group $response = $this->actingAs($reseller1)->post("/api/v4/groups/{$group->id}/unsuspend", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("Distribution list unsuspended successfully.", $json['message']); $this->assertCount(2, $json); $this->assertFalse($group->fresh()->isSuspended()); $response = $this->actingAs($reseller2)->post("/api/v4/groups/{$group->id}/unsuspend", []); $response->assertStatus(404); } } diff --git a/src/tests/Feature/GroupTest.php b/src/tests/Feature/GroupTest.php index 7786d7da..044acc80 100644 --- a/src/tests/Feature/GroupTest.php +++ b/src/tests/Feature/GroupTest.php @@ -1,398 +1,398 @@ deleteTestUser('user-test@kolabnow.com'); $this->deleteTestGroup('group-test@kolabnow.com'); } public function tearDown(): void { $this->deleteTestUser('user-test@kolabnow.com'); $this->deleteTestGroup('group-test@kolabnow.com'); parent::tearDown(); } /** * Tests for Group::assignToWallet() */ public function testAssignToWallet(): void { $user = $this->getTestUser('user-test@kolabnow.com'); $group = $this->getTestGroup('group-test@kolabnow.com'); $result = $group->assignToWallet($user->wallets->first()); $this->assertSame($group, $result); $this->assertSame(1, $group->entitlements()->count()); // Can't be done twice on the same group $this->expectException(\Exception::class); $result->assignToWallet($user->wallets->first()); } /** * Test Group::getConfig() and setConfig() methods */ public function testConfigTrait(): void { $group = $this->getTestGroup('group-test@kolabnow.com'); $group->setSetting('sender_policy', '["test","-"]'); $this->assertSame(['sender_policy' => ['test']], $group->getConfig()); $result = $group->setConfig(['sender_policy' => [], 'unknown' => false]); $this->assertSame(['sender_policy' => []], $group->getConfig()); $this->assertSame('[]', $group->getSetting('sender_policy')); $this->assertSame(['unknown' => "The requested configuration parameter is not supported."], $result); $result = $group->setConfig(['sender_policy' => ['test']]); $this->assertSame(['sender_policy' => ['test']], $group->getConfig()); $this->assertSame('["test","-"]', $group->getSetting('sender_policy')); $this->assertSame([], $result); } - - /** - * Test group status assignment and is*() methods - */ - public function testStatus(): void - { - $group = new Group(); - - $this->assertSame(false, $group->isNew()); - $this->assertSame(false, $group->isActive()); - $this->assertSame(false, $group->isDeleted()); - $this->assertSame(false, $group->isLdapReady()); - $this->assertSame(false, $group->isSuspended()); - - $group->status = Group::STATUS_NEW; - - $this->assertSame(true, $group->isNew()); - $this->assertSame(false, $group->isActive()); - $this->assertSame(false, $group->isDeleted()); - $this->assertSame(false, $group->isLdapReady()); - $this->assertSame(false, $group->isSuspended()); - - $group->status |= Group::STATUS_ACTIVE; - - $this->assertSame(true, $group->isNew()); - $this->assertSame(true, $group->isActive()); - $this->assertSame(false, $group->isDeleted()); - $this->assertSame(false, $group->isLdapReady()); - $this->assertSame(false, $group->isSuspended()); - - $group->status |= Group::STATUS_LDAP_READY; - - $this->assertSame(true, $group->isNew()); - $this->assertSame(true, $group->isActive()); - $this->assertSame(false, $group->isDeleted()); - $this->assertSame(true, $group->isLdapReady()); - $this->assertSame(false, $group->isSuspended()); - - $group->status |= Group::STATUS_DELETED; - - $this->assertSame(true, $group->isNew()); - $this->assertSame(true, $group->isActive()); - $this->assertSame(true, $group->isDeleted()); - $this->assertSame(true, $group->isLdapReady()); - $this->assertSame(false, $group->isSuspended()); - - $group->status |= Group::STATUS_SUSPENDED; - - $this->assertSame(true, $group->isNew()); - $this->assertSame(true, $group->isActive()); - $this->assertSame(true, $group->isDeleted()); - $this->assertSame(true, $group->isLdapReady()); - $this->assertSame(true, $group->isSuspended()); - - // Unknown status value - $this->expectException(\Exception::class); - $group->status = 111; - } - /** * Test creating a group */ public function testCreate(): void { Queue::fake(); $group = Group::create(['email' => 'GROUP-test@kolabnow.com']); $this->assertSame('group-test@kolabnow.com', $group->email); + $this->assertSame('group-test', $group->name); $this->assertMatchesRegularExpression('/^[0-9]{1,20}$/', $group->id); $this->assertSame([], $group->members); $this->assertTrue($group->isNew()); $this->assertTrue($group->isActive()); Queue::assertPushed( \App\Jobs\Group\CreateJob::class, function ($job) use ($group) { $groupEmail = TestCase::getObjectProperty($job, 'groupEmail'); $groupId = TestCase::getObjectProperty($job, 'groupId'); return $groupEmail === $group->email && $groupId === $group->id; } ); } /** * Test group deletion and force-deletion */ public function testDelete(): void { Queue::fake(); $user = $this->getTestUser('user-test@kolabnow.com'); $group = $this->getTestGroup('group-test@kolabnow.com'); $group->assignToWallet($user->wallets->first()); $entitlements = \App\Entitlement::where('entitleable_id', $group->id); $this->assertSame(1, $entitlements->count()); $group->delete(); $this->assertTrue($group->fresh()->trashed()); $this->assertSame(0, $entitlements->count()); $this->assertSame(1, $entitlements->withTrashed()->count()); $group->forceDelete(); $this->assertSame(0, $entitlements->withTrashed()->count()); $this->assertCount(0, Group::withTrashed()->where('id', $group->id)->get()); Queue::assertPushed(\App\Jobs\Group\DeleteJob::class, 1); Queue::assertPushed( \App\Jobs\Group\DeleteJob::class, function ($job) use ($group) { $groupEmail = TestCase::getObjectProperty($job, 'groupEmail'); $groupId = TestCase::getObjectProperty($job, 'groupId'); return $groupEmail === $group->email && $groupId === $group->id; } ); } /** * Tests for Group::emailExists() */ public function testEmailExists(): void { Queue::fake(); $group = $this->getTestGroup('group-test@kolabnow.com'); $this->assertFalse(Group::emailExists('unknown@domain.tld')); $this->assertTrue(Group::emailExists($group->email)); $result = Group::emailExists($group->email, true); $this->assertSame($result->id, $group->id); $group->delete(); $this->assertTrue(Group::emailExists($group->email)); $result = Group::emailExists($group->email, true); $this->assertSame($result->id, $group->id); } /* * Test group restoring */ public function testRestore(): void { Queue::fake(); $user = $this->getTestUser('user-test@kolabnow.com'); $group = $this->getTestGroup('group-test@kolabnow.com'); $group->assignToWallet($user->wallets->first()); $entitlements = \App\Entitlement::where('entitleable_id', $group->id); $this->assertSame(1, $entitlements->count()); $group->delete(); $this->assertTrue($group->fresh()->trashed()); $this->assertSame(0, $entitlements->count()); $this->assertSame(1, $entitlements->withTrashed()->count()); Queue::fake(); $group->restore(); $group->refresh(); $this->assertFalse($group->trashed()); $this->assertFalse($group->isDeleted()); $this->assertFalse($group->isSuspended()); $this->assertFalse($group->isLdapReady()); $this->assertTrue($group->isActive()); $this->assertSame(1, $entitlements->count()); $entitlements->get()->each(function ($ent) { $this->assertTrue($ent->updated_at->greaterThan(\Carbon\Carbon::now()->subSeconds(5))); }); Queue::assertPushed(\App\Jobs\Group\CreateJob::class, 1); Queue::assertPushed( \App\Jobs\Group\CreateJob::class, function ($job) use ($group) { $groupEmail = TestCase::getObjectProperty($job, 'groupEmail'); $groupId = TestCase::getObjectProperty($job, 'groupId'); return $groupEmail === $group->email && $groupId === $group->id; } ); } /** * Tests for GroupSettingsTrait functionality and GroupSettingObserver */ public function testSettings(): void { Queue::fake(); Queue::assertNothingPushed(); $group = $this->getTestGroup('group-test@kolabnow.com'); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 0); // Add a setting $group->setSetting('unknown', 'test'); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 0); // Add a setting that is synced to LDAP $group->setSetting('sender_policy', '[]'); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1); // Note: We test both current group as well as fresh group object // to make sure cache works as expected $this->assertSame('test', $group->getSetting('unknown')); $this->assertSame('[]', $group->fresh()->getSetting('sender_policy')); Queue::fake(); // Update a setting $group->setSetting('unknown', 'test1'); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 0); // Update a setting that is synced to LDAP $group->setSetting('sender_policy', '["-"]'); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1); $this->assertSame('test1', $group->getSetting('unknown')); $this->assertSame('["-"]', $group->fresh()->getSetting('sender_policy')); Queue::fake(); // Delete a setting (null) $group->setSetting('unknown', null); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 0); // Delete a setting that is synced to LDAP $group->setSetting('sender_policy', null); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1); $this->assertSame(null, $group->getSetting('unknown')); $this->assertSame(null, $group->fresh()->getSetting('sender_policy')); } + /** + * Test group status assignment and is*() methods + */ + public function testStatus(): void + { + $group = new Group(); + + $this->assertSame(false, $group->isNew()); + $this->assertSame(false, $group->isActive()); + $this->assertSame(false, $group->isDeleted()); + $this->assertSame(false, $group->isLdapReady()); + $this->assertSame(false, $group->isSuspended()); + + $group->status = Group::STATUS_NEW; + + $this->assertSame(true, $group->isNew()); + $this->assertSame(false, $group->isActive()); + $this->assertSame(false, $group->isDeleted()); + $this->assertSame(false, $group->isLdapReady()); + $this->assertSame(false, $group->isSuspended()); + + $group->status |= Group::STATUS_ACTIVE; + + $this->assertSame(true, $group->isNew()); + $this->assertSame(true, $group->isActive()); + $this->assertSame(false, $group->isDeleted()); + $this->assertSame(false, $group->isLdapReady()); + $this->assertSame(false, $group->isSuspended()); + + $group->status |= Group::STATUS_LDAP_READY; + + $this->assertSame(true, $group->isNew()); + $this->assertSame(true, $group->isActive()); + $this->assertSame(false, $group->isDeleted()); + $this->assertSame(true, $group->isLdapReady()); + $this->assertSame(false, $group->isSuspended()); + + $group->status |= Group::STATUS_DELETED; + + $this->assertSame(true, $group->isNew()); + $this->assertSame(true, $group->isActive()); + $this->assertSame(true, $group->isDeleted()); + $this->assertSame(true, $group->isLdapReady()); + $this->assertSame(false, $group->isSuspended()); + + $group->status |= Group::STATUS_SUSPENDED; + + $this->assertSame(true, $group->isNew()); + $this->assertSame(true, $group->isActive()); + $this->assertSame(true, $group->isDeleted()); + $this->assertSame(true, $group->isLdapReady()); + $this->assertSame(true, $group->isSuspended()); + + // Unknown status value + $this->expectException(\Exception::class); + $group->status = 111; + } + /** * Tests for Group::suspend() */ public function testSuspend(): void { Queue::fake(); $group = $this->getTestGroup('group-test@kolabnow.com'); $group->suspend(); $this->assertTrue($group->isSuspended()); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1); Queue::assertPushed( \App\Jobs\Group\UpdateJob::class, function ($job) use ($group) { $groupEmail = TestCase::getObjectProperty($job, 'groupEmail'); $groupId = TestCase::getObjectProperty($job, 'groupId'); return $groupEmail === $group->email && $groupId === $group->id; } ); } /** * Test updating a group */ public function testUpdate(): void { Queue::fake(); $group = $this->getTestGroup('group-test@kolabnow.com'); $group->status |= Group::STATUS_DELETED; $group->save(); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1); Queue::assertPushed( \App\Jobs\Group\UpdateJob::class, function ($job) use ($group) { $groupEmail = TestCase::getObjectProperty($job, 'groupEmail'); $groupId = TestCase::getObjectProperty($job, 'groupId'); return $groupEmail === $group->email && $groupId === $group->id; } ); } /** * Tests for Group::unsuspend() */ public function testUnsuspend(): void { Queue::fake(); $group = $this->getTestGroup('group-test@kolabnow.com'); $group->status = Group::STATUS_SUSPENDED; $group->unsuspend(); $this->assertFalse($group->isSuspended()); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1); Queue::assertPushed( \App\Jobs\Group\UpdateJob::class, function ($job) use ($group) { $groupEmail = TestCase::getObjectProperty($job, 'groupEmail'); $groupId = TestCase::getObjectProperty($job, 'groupId'); return $groupEmail === $group->email && $groupId === $group->id; } ); } }