Page MenuHomePhorge

No OneTemporary

Size
311 KB
Referenced Files
None
Subscribers
None
This file is larger than 256 KB, so syntax highlighting was skipped.
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 @@
<?php
namespace App\Backends;
use App\Domain;
use App\Group;
use App\User;
class LDAP
{
/** @const array Group settings used by the backend */
public const GROUP_SETTINGS = [
'sender_policy',
];
/** @const array User settings used by the backend */
public const USER_SETTINGS = [
'first_name',
'last_name',
'organization',
];
/** @var ?\Net_LDAP3 LDAP connection object */
protected static $ldap;
/**
* Starts a new LDAP connection that will be used by all methods
* until you call self::disconnect() explicitely. Normally every
* method uses a separate connection.
*
* @throws \Exception
*/
public static function connect(): void
{
if (empty(self::$ldap)) {
$config = self::getConfig('admin');
self::$ldap = self::initLDAP($config);
}
}
/**
* Close the connection created by self::connect()
*/
public static function disconnect(): void
{
if (!empty(self::$ldap)) {
self::$ldap->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 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Console\ObjectListCommand;
+
+class GroupsCommand extends ObjectListCommand
+{
+ protected $objectClass = \App\Group::class;
+ protected $objectName = 'group';
+ protected $objectTitle = 'email';
+}
diff --git a/src/app/Group.php b/src/app/Group.php
index 5a34fe0b..6746d79d 100644
--- a/src/app/Group.php
+++ b/src/app/Group.php
@@ -1,265 +1,267 @@
<?php
namespace App;
use App\Traits\BelongsToTenantTrait;
use App\Traits\EntitleableTrait;
use App\Traits\GroupConfigTrait;
use App\Traits\SettingsTrait;
use App\Traits\UuidIntKeyTrait;
use App\Wallet;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* The eloquent definition of a Group.
*
* @property int $id The group identifier
* @property string $email An email address
* @property string $members A comma-separated list of email addresses
+ * @property string $name The group name
* @property int $status The group status
* @property int $tenant_id Tenant identifier
*/
class Group extends Model
{
use BelongsToTenantTrait;
use EntitleableTrait;
use GroupConfigTrait;
use SettingsTrait;
use SoftDeletes;
use UuidIntKeyTrait;
// we've simply never heard of this group
public const STATUS_NEW = 1 << 0;
// group has been activated
public const STATUS_ACTIVE = 1 << 1;
// group has been suspended.
public const STATUS_SUSPENDED = 1 << 2;
// group has been deleted
public const STATUS_DELETED = 1 << 3;
// group has been created in LDAP
public const STATUS_LDAP_READY = 1 << 4;
protected $fillable = [
'email',
+ 'members',
+ 'name',
'status',
- 'members'
];
/**
* Assign the group to a wallet.
*
* @param \App\Wallet $wallet The wallet
*
* @return \App\Group Self
* @throws \Exception
*/
public function assignToWallet(Wallet $wallet): Group
{
if (empty($this->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 @@
<?php
namespace App\Http\Controllers\API\V4\Admin;
use App\Group;
use App\User;
use Illuminate\Http\Request;
class GroupsController extends \App\Http\Controllers\API\V4\GroupsController
{
/**
* Search for groups
*
* @return \Illuminate\Http\JsonResponse
*/
public function index()
{
$search = trim(request()->input('search'));
$owner = trim(request()->input('owner'));
$result = collect([]);
if ($owner) {
if ($owner = User::find($owner)) {
foreach ($owner->wallets as $wallet) {
$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 @@
<?php
namespace App\Http\Controllers\API\V4;
use App\Http\Controllers\Controller;
use App\Domain;
use App\Group;
+use App\Rules\GroupName;
use App\User;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
class GroupsController extends Controller
{
/**
* Show the form for creating a new group.
*
* @return \Illuminate\Http\JsonResponse
*/
public function create()
{
return $this->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 @@
<?php
namespace App\Http\Controllers\API\V4\Reseller;
use App\Group;
use App\User;
class GroupsController extends \App\Http\Controllers\API\V4\Admin\GroupsController
{
/**
* Search for groups
*
* @return \Illuminate\Http\JsonResponse
*/
public function index()
{
$search = trim(request()->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 @@
<?php
namespace App\Observers;
use App\Group;
use Illuminate\Support\Facades\DB;
class GroupObserver
{
/**
* Handle the group "created" event.
*
* @param \App\Group $group The group
*
* @return void
*/
public function creating(Group $group): void
{
$group->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 @@
+<?php
+
+namespace App\Rules;
+
+use Illuminate\Contracts\Validation\Rule;
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Support\Str;
+
+class GroupName implements Rule
+{
+ private $message;
+ private $owner;
+ private $domain;
+
+ /**
+ * Class constructor.
+ *
+ * @param \App\User $owner The account owner
+ * @param string $domain The domain name of the group
+ */
+ public function __construct($owner, $domain)
+ {
+ $this->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 @@
<?php
return [
'hosts' => 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 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Support\Facades\DB;
+
+// phpcs:ignore
+class AddGroupNameColumn extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::table(
+ 'groups',
+ function (Blueprint $table) {
+ $table->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 @@
<?php
/**
* This file will be converted to a Vue-i18n compatible JSON format on build time
*
* Note: The Laravel localization features do not work here. Vue-i18n rules are different
*/
return [
'app' => [
'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: <var>.ess.barracuda.com</var>.",
'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 <b>must have one of the following entries</b> 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 &amp; 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 <b>{amount}</b> when under <b>{balance}</b> 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 <a href=\"{href}\">{email}</a>.",
'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 <b>set</b> to fill up your account by <b>{amount}</b> every time your account balance gets under <b>{balance}</b>.",
'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 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Validation Language Lines
|--------------------------------------------------------------------------
|
| The following language lines contain the default error messages used by
| the validator class. Some of these rules have multiple versions such
| as the size rules. Feel free to tweak each of these messages here.
|
*/
'accepted' => '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 @@
<template>
<div v-if="list.id" class="container">
<div class="card" id="distlist-info">
<div class="card-body">
<div class="card-title">{{ list.email }}</div>
<div class="card-text">
<form class="read-only short">
<div class="row plaintext">
<label for="distlistid" class="col-sm-4 col-form-label">
{{ $t('form.id') }} <span class="text-muted">({{ $t('form.created') }})</span>
</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="distlistid">
{{ list.id }} <span class="text-muted">({{ list.created_at }})</span>
</span>
</div>
</div>
<div class="row plaintext">
<label for="status" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label>
<div class="col-sm-8">
<span :class="$root.distlistStatusClass(list) + ' form-control-plaintext'" id="status">{{ $root.distlistStatusText(list) }}</span>
</div>
</div>
+ <div class="row plaintext">
+ <label for="name" class="col-sm-4 col-form-label">{{ $t('distlist.name') }}</label>
+ <div class="col-sm-8">
+ <span class="form-control-plaintext" id="name">{{ list.name }}</span>
+ </div>
+ </div>
<div class="row plaintext">
<label for="members" class="col-sm-4 col-form-label">{{ $t('distlist.recipients') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="members">
<span v-for="member in list.members" :key="member">{{ member }}<br></span>
</span>
</div>
</div>
</form>
<div class="mt-2">
<button v-if="!list.isSuspended" id="button-suspend" class="btn btn-warning" type="button" @click="suspendList">
{{ $t('btn.suspend') }}
</button>
<button v-if="list.isSuspended" id="button-unsuspend" class="btn btn-warning" type="button" @click="unsuspendList">
{{ $t('btn.unsuspend') }}
</button>
</div>
</div>
</div>
</div>
<ul class="nav nav-tabs mt-3" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="tab-settings" href="#distlist-settings" role="tab" aria-controls="distlist-settings" aria-selected="false" @click="$root.tab">
{{ $t('form.settings') }}
</a>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane show active" id="distlist-settings" role="tabpanel" aria-labelledby="tab-settings">
<div class="card-body">
<div class="card-text">
<form class="read-only short">
<div class="row plaintext">
<label for="sender_policy" class="col-sm-4 col-form-label">{{ $t('distlist.sender-policy') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="sender_policy">
{{ list.config.sender_policy && list.config.sender_policy.length ? list.config.sender_policy.join(', ') : $t('form.none') }}
</span>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
list: { members: [], config: {} }
}
},
created() {
this.$root.startLoading()
axios.get('/api/v4/groups/' + this.$route.params.list)
.then(response => {
this.$root.stopLoading()
this.list = response.data
})
.catch(this.$root.errorHandler)
},
methods: {
suspendList() {
axios.post('/api/v4/groups/' + this.list.id + '/suspend', {})
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.list = Object.assign({}, this.list, { isSuspended: true })
}
})
},
unsuspendList() {
axios.post('/api/v4/groups/' + this.list.id + '/unsuspend', {})
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.list = Object.assign({}, this.list, { isSuspended: false })
}
})
}
}
}
</script>
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 @@
<template>
<div class="container">
<div class="card" id="user-info">
<div class="card-body">
<h1 class="card-title">{{ user.email }}</h1>
<div class="card-text">
<form class="read-only short">
<div v-if="user.wallet.user_id != user.id" class="row plaintext">
<label for="manager" class="col-sm-4 col-form-label">{{ $t('user.managed-by') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="manager">
<router-link :to="{ path: '/user/' + user.wallet.user_id }">{{ user.wallet.user_email }}</router-link>
</span>
</div>
</div>
<div class="row plaintext">
<label for="userid" class="col-sm-4 col-form-label">ID <span class="text-muted">({{ $t('form.created') }})</span></label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="userid">
{{ user.id }} <span class="text-muted">({{ user.created_at }})</span>
</span>
</div>
</div>
<div class="row plaintext">
<label for="status" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="status">
<span :class="$root.userStatusClass(user)">{{ $root.userStatusText(user) }}</span>
</span>
</div>
</div>
<div class="row plaintext" v-if="user.first_name">
<label for="first_name" class="col-sm-4 col-form-label">{{ $t('form.firstname') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="first_name">{{ user.first_name }}</span>
</div>
</div>
<div class="row plaintext" v-if="user.last_name">
<label for="last_name" class="col-sm-4 col-form-label">{{ $t('form.lastname') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="last_name">{{ user.last_name }}</span>
</div>
</div>
<div class="row plaintext" v-if="user.organization">
<label for="organization" class="col-sm-4 col-form-label">{{ $t('user.org') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="organization">{{ user.organization }}</span>
</div>
</div>
<div class="row plaintext" v-if="user.phone">
<label for="phone" class="col-sm-4 col-form-label">{{ $t('form.phone') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="phone">{{ user.phone }}</span>
</div>
</div>
<div class="row plaintext">
<label for="external_email" class="col-sm-4 col-form-label">{{ $t('user.ext-email') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="external_email">
<a v-if="user.external_email" :href="'mailto:' + user.external_email">{{ user.external_email }}</a>
<button type="button" class="btn btn-secondary btn-sm" @click="emailEdit">{{ $t('btn.edit') }}</button>
</span>
</div>
</div>
<div class="row plaintext" v-if="user.billing_address">
<label for="billing_address" class="col-sm-4 col-form-label">{{ $t('user.address') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" style="white-space:pre" id="billing_address">{{ user.billing_address }}</span>
</div>
</div>
<div class="row plaintext">
<label for="country" class="col-sm-4 col-form-label">{{ $t('user.country') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="country">{{ user.country }}</span>
</div>
</div>
</form>
<div class="mt-2">
<button v-if="!user.isSuspended" id="button-suspend" class="btn btn-warning" type="button" @click="suspendUser">
{{ $t('btn.suspend') }}
</button>
<button v-if="user.isSuspended" id="button-unsuspend" class="btn btn-warning" type="button" @click="unsuspendUser">
{{ $t('btn.unsuspend') }}
</button>
</div>
</div>
</div>
</div>
<ul class="nav nav-tabs mt-3" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="tab-finances" href="#user-finances" role="tab" aria-controls="user-finances" aria-selected="true">
{{ $t('user.finances') }}
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-aliases" href="#user-aliases" role="tab" aria-controls="user-aliases" aria-selected="false">
{{ $t('user.aliases') }} ({{ user.aliases.length }})
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-subscriptions" href="#user-subscriptions" role="tab" aria-controls="user-subscriptions" aria-selected="false">
{{ $t('user.subscriptions') }} ({{ skus.length }})
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-domains" href="#user-domains" role="tab" aria-controls="user-domains" aria-selected="false">
{{ $t('user.domains') }} ({{ domains.length }})
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-users" href="#user-users" role="tab" aria-controls="user-users" aria-selected="false">
{{ $t('user.users') }} ({{ users.length }})
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-distlists" href="#user-distlists" role="tab" aria-controls="user-distlists" aria-selected="false">
{{ $t('user.distlists') }} ({{ distlists.length }})
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-settings" href="#user-settings" role="tab" aria-controls="user-settings" aria-selected="false">
Settings
</a>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane show active" id="user-finances" role="tabpanel" aria-labelledby="tab-finances">
<div class="card-body">
<h2 class="card-title">
{{ $t('wallet.title') }}
<span :class="wallet.balance < 0 ? 'text-danger' : 'text-success'"><strong>{{ $root.price(wallet.balance, wallet.currency) }}</strong></span>
</h2>
<div class="card-text">
<form class="read-only short">
<div class="row">
<label class="col-sm-4 col-form-label">{{ $t('user.discount') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="discount">
<span>{{ wallet.discount ? (wallet.discount + '% - ' + wallet.discount_description) : 'none' }}</span>
<button type="button" class="btn btn-secondary btn-sm" @click="discountEdit">{{ $t('btn.edit') }}</button>
</span>
</div>
</div>
<div class="row" v-if="wallet.mandate && wallet.mandate.id">
<label class="col-sm-4 col-form-label">{{ $t('user.auto-payment') }}</label>
<div class="col-sm-8">
<span id="autopayment" :class="'form-control-plaintext' + (wallet.mandateState ? ' text-danger' : '')"
v-html="$t('user.auto-payment-text', {
amount: wallet.mandate.amount + ' ' + wallet.currency,
balance: wallet.mandate.balance + ' ' + wallet.currency,
method: wallet.mandate.method
})"
>
<span v-if="wallet.mandateState">({{ wallet.mandateState }})</span>.
</span>
</div>
</div>
<div class="row" v-if="wallet.providerLink">
<label class="col-sm-4 col-form-label">{{ capitalize(wallet.provider) }} {{ $t('form.id') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" v-html="wallet.providerLink"></span>
</div>
</div>
</form>
<div class="mt-2">
<button id="button-award" class="btn btn-success" type="button" @click="awardDialog">{{ $t('user.add-bonus') }}</button>
<button id="button-penalty" class="btn btn-danger" type="button" @click="penalizeDialog">{{ $t('user.add-penalty') }}</button>
</div>
</div>
<h2 class="card-title mt-4">{{ $t('wallet.transactions') }}</h2>
<transaction-log v-if="wallet.id && !walletReload" class="card-text" :wallet-id="wallet.id" :is-admin="true"></transaction-log>
</div>
</div>
<div class="tab-pane" id="user-aliases" role="tabpanel" aria-labelledby="tab-aliases">
<div class="card-body">
<div class="card-text">
<table class="table table-sm table-hover mb-0">
<thead>
<tr>
<th scope="col">{{ $t('form.email') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(alias, index) in user.aliases" :id="'alias' + index" :key="index">
<td>{{ alias }}</td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
<td>{{ $t('user.aliases-none') }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
<div class="tab-pane" id="user-subscriptions" role="tabpanel" aria-labelledby="tab-subscriptions">
<div class="card-body">
<div class="card-text">
<table class="table table-sm table-hover mb-0">
<thead>
<tr>
<th scope="col">{{ $t('user.subscription') }}</th>
<th scope="col">{{ $t('user.price') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(sku, sku_id) in skus" :id="'sku' + sku.id" :key="sku_id">
<td>{{ sku.name }}</td>
<td>{{ sku.price }}</td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
<td colspan="2">{{ $t('user.subscriptions-none') }}</td>
</tr>
</tfoot>
</table>
<small v-if="discount > 0" class="hint">
<hr class="m-0">
&sup1; {{ $t('user.discount-hint') }}: {{ discount }}% - {{ discount_description }}
</small>
<div class="mt-2">
<button type="button" class="btn btn-danger" id="reset2fa" v-if="has2FA" @click="reset2FADialog">
{{ $t('user.reset-2fa') }}
</button>
<button type="button" class="btn btn-secondary" id="addbetasku" v-if="!hasBeta" @click="addBetaSku">
{{ $t('user.add-beta') }}
</button>
</div>
</div>
</div>
</div>
<div class="tab-pane" id="user-domains" role="tabpanel" aria-labelledby="tab-domains">
<div class="card-body">
<div class="card-text">
<table class="table table-sm table-hover mb-0">
<thead>
<tr>
<th scope="col">{{ $t('domain.namespace') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="domain in domains" :id="'domain' + domain.id" :key="domain.id" @click="$root.clickRecord">
<td>
<svg-icon icon="globe" :class="$root.domainStatusClass(domain)" :title="$root.domainStatusText(domain)"></svg-icon>
<router-link :to="{ path: '/domain/' + domain.id }">{{ domain.namespace }}</router-link>
</td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
<td>{{ $t('user.domains-none') }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
<div class="tab-pane" id="user-users" role="tabpanel" aria-labelledby="tab-users">
<div class="card-body">
<div class="card-text">
<table class="table table-sm table-hover mb-0">
<thead>
<tr>
<th scope="col">{{ $t('form.primary-email') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="item in users" :id="'user' + item.id" :key="item.id" @click="$root.clickRecord">
<td>
<svg-icon icon="user" :class="$root.userStatusClass(item)" :title="$root.userStatusText(item)"></svg-icon>
<router-link v-if="item.id != user.id" :to="{ path: '/user/' + item.id }">{{ item.email }}</router-link>
<span v-else>{{ item.email }}</span>
</td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
<td>{{ $t('user.users-none') }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
<div class="tab-pane" id="user-distlists" role="tabpanel" aria-labelledby="tab-distlists">
<div class="card-body">
<div class="card-text">
<table class="table table-sm table-hover mb-0">
<thead>
<tr>
+ <th scope="col">{{ $t('distlist.name') }}</th>
<th scope="col">{{ $t('form.email') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="list in distlists" :key="list.id" @click="$root.clickRecord">
<td>
<svg-icon icon="users" :class="$root.distlistStatusClass(list)" :title="$root.distlistStatusText(list)"></svg-icon>
+ <router-link :to="{ path: '/distlist/' + list.id }">{{ list.name }}</router-link>
+ </td>
+ <td>
<router-link :to="{ path: '/distlist/' + list.id }">{{ list.email }}</router-link>
</td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
- <td>{{ $t('user.distlists-none') }}</td>
+ <td colspan="2">{{ $t('user.distlists-none') }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
<div class="tab-pane" id="user-settings" role="tabpanel" aria-labelledby="tab-settings">
<div class="card-body">
<div class="card-text">
<form class="read-only short">
<div class="row plaintext">
<label for="greylist_enabled" class="col-sm-4 col-form-label">{{ $t('user.greylisting') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="greylist_enabled">
<span v-if="user.config.greylist_enabled" class="text-success">{{ $t('form.enabled') }}</span>
<span v-else class="text-danger">{{ $t('form.disabled') }}</span>
</span>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
<div id="discount-dialog" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ $t('user.discount-title') }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></button>
</div>
<div class="modal-body">
<p>
<select v-model="wallet.discount_id" class="form-select">
<option value="">- {{ $t('form.none') }} -</option>
<option v-for="item in discounts" :value="item.id" :key="item.id">{{ item.label }}</option>
</select>
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary modal-cancel" data-bs-dismiss="modal">{{ $t('btn.cancel') }}</button>
<button type="button" class="btn btn-primary modal-action" @click="submitDiscount()">
<svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}
</button>
</div>
</div>
</div>
</div>
<div id="email-dialog" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ $t('user.ext-email') }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></button>
</div>
<div class="modal-body">
<p>
<input v-model="external_email" name="external_email" class="form-control">
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary modal-cancel" data-bs-dismiss="modal">{{ $t('btn.cancel') }}</button>
<button type="button" class="btn btn-primary modal-action" @click="submitEmail()">
<svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}
</button>
</div>
</div>
</div>
</div>
<div id="oneoff-dialog" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ $t(oneoff_negative ? 'user.add-penalty-title' : 'user.add-bonus-title') }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></button>
</div>
<div class="modal-body">
<form data-validation-prefix="oneoff_">
<div class="row mb-3">
<label for="oneoff_amount" class="col-form-label">{{ $t('form.amount') }}</label>
<div class="input-group">
<input type="text" class="form-control" id="oneoff_amount" v-model="oneoff_amount" required>
<span class="input-group-text">{{ wallet.currency }}</span>
</div>
</div>
<div class="row">
<label for="oneoff_description" class="col-form-label">{{ $t('form.description') }}</label>
<input class="form-control" id="oneoff_description" v-model="oneoff_description" required>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary modal-cancel" data-bs-dismiss="modal">{{ $t('btn.cancel') }}</button>
<button type="button" class="btn btn-primary modal-action" @click="submitOneOff()">
<svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}
</button>
</div>
</div>
</div>
</div>
<div id="reset-2fa-dialog" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ $t('user.reset-2fa-title') }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></button>
</div>
<div class="modal-body">
<p>{{ $t('user.2fa-hint1') }}</p>
<p>{{ $t('user.2fa-hint2') }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary modal-cancel" data-bs-dismiss="modal">{{ $t('btn.cancel') }}</button>
<button type="button" class="btn btn-danger modal-action" @click="reset2FA()">{{ $t('btn.reset') }}</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { Modal } from 'bootstrap'
import TransactionLog from '../Widgets/TransactionLog'
export default {
components: {
TransactionLog
},
beforeRouteUpdate (to, from, next) {
// An event called when the route that renders this component has changed,
// but this component is reused in the new route.
// Required to handle links from /user/XXX to /user/YYY
next()
this.$parent.routerReload()
},
data() {
return {
oneoff_amount: '',
oneoff_description: '',
oneoff_negative: false,
discount: 0,
discount_description: '',
discounts: [],
external_email: '',
has2FA: false,
hasBeta: false,
wallet: {},
walletReload: false,
distlists: [],
domains: [],
skus: [],
sku2FA: null,
users: [],
user: {
aliases: [],
config: {},
wallet: {},
skus: {},
}
}
},
created() {
const user_id = this.$route.params.user
this.$root.startLoading()
axios.get('/api/v4/users/' + user_id)
.then(response => {
this.$root.stopLoading()
this.user = response.data
const financesTab = '#user-finances'
const keys = ['first_name', 'last_name', 'external_email', 'billing_address', 'phone', 'organization']
let country = this.user.settings.country
if (country && country in window.config.countries) {
country = window.config.countries[country][1]
}
this.user.country = country
keys.forEach(key => { this.user[key] = this.user.settings[key] })
this.discount = this.user.wallet.discount
this.discount_description = this.user.wallet.discount_description
// TODO: currencies, multi-wallets, accounts
// Get more info about the wallet (e.g. payment provider related)
this.$root.addLoader(financesTab)
axios.get('/api/v4/wallets/' + this.user.wallets[0].id)
.then(response => {
this.$root.removeLoader(financesTab)
this.wallet = response.data
this.setMandateState()
})
.catch(error => {
this.$root.removeLoader(financesTab)
})
// Create subscriptions list
axios.get('/api/v4/users/' + user_id + '/skus')
.then(response => {
// "merge" SKUs with user entitlement-SKUs
response.data.forEach(sku => {
const userSku = this.user.skus[sku.id]
if (userSku) {
let cost = userSku.costs.reduce((sum, current) => sum + current)
let item = {
id: sku.id,
name: sku.name,
cost: cost,
price: this.$root.priceLabel(cost, this.discount)
}
if (sku.range) {
item.name += ' ' + userSku.count + ' ' + sku.range.unit
}
this.skus.push(item)
if (sku.handler == 'auth2f') {
this.has2FA = true
this.sku2FA = sku.id
} else if (sku.handler == 'beta') {
this.hasBeta = true
}
}
})
})
// Fetch users
// TODO: Multiple wallets
axios.get('/api/v4/users?owner=' + user_id)
.then(response => {
this.users = response.data.list;
})
// Fetch domains
axios.get('/api/v4/domains?owner=' + user_id)
.then(response => {
this.domains = response.data.list
})
// Fetch distribution lists
axios.get('/api/v4/groups?owner=' + user_id)
.then(response => {
this.distlists = response.data.list
})
})
.catch(this.$root.errorHandler)
},
mounted() {
$(this.$el).find('ul.nav-tabs a').on('click', this.$root.tab)
},
methods: {
addBetaSku() {
axios.post('/api/v4/users/' + this.user.id + '/skus/beta')
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.hasBeta = true
const sku = response.data.sku
this.skus.push({
id: sku.id,
name: sku.name,
cost: sku.cost,
price: this.$root.priceLabel(sku.cost, this.discount)
})
}
})
},
capitalize(str) {
return str.charAt(0).toUpperCase() + str.slice(1)
},
awardDialog() {
this.oneOffDialog(false)
},
discountEdit() {
if (!this.discount_dialog) {
const dialog = $('#discount-dialog')[0]
dialog.addEventListener('shown.bs.modal', e => {
$(dialog).find('select').focus()
// Note: Vue v-model is strict, convert null to a string
this.wallet.discount_id = this.wallet_discount_id || ''
})
this.discount_dialog = new Modal(dialog)
}
this.discount_dialog.show()
if (!this.discounts.length) {
// Fetch discounts
axios.get('/api/v4/users/' + this.user.id + '/discounts')
.then(response => {
this.discounts = response.data.list
})
}
},
emailEdit() {
this.external_email = this.user.external_email
this.$root.clearFormValidation($('#email-dialog'))
if (!this.email_dialog) {
const dialog = $('#email-dialog')[0]
dialog.addEventListener('shown.bs.modal', e => {
$(dialog).find('input').focus()
})
this.email_dialog = new Modal(dialog)
}
this.email_dialog.show()
},
setMandateState() {
let mandate = this.wallet.mandate
if (mandate && mandate.id) {
if (!mandate.isValid) {
this.wallet.mandateState = mandate.isPending ? 'pending' : 'invalid'
} else if (mandate.isDisabled) {
this.wallet.mandateState = 'disabled'
}
}
},
oneOffDialog(negative) {
this.oneoff_negative = negative
if (!this.oneoff_dialog) {
const dialog = $('#oneoff-dialog')[0]
dialog.addEventListener('shown.bs.modal', () => {
this.$root.clearFormValidation(dialog)
$(dialog).find('#oneoff_amount').focus()
})
this.oneoff_dialog = new Modal(dialog)
}
this.oneoff_dialog.show()
},
penalizeDialog() {
this.oneOffDialog(true)
},
reload() {
// this is to reload transaction log
this.walletReload = true
this.$nextTick(() => { this.walletReload = false })
},
reset2FA() {
new Modal('#reset-2fa-dialog').hide()
axios.post('/api/v4/users/' + this.user.id + '/reset2FA')
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.skus = this.skus.filter(sku => sku.id != this.sku2FA)
this.has2FA = false
}
})
},
reset2FADialog() {
new Modal('#reset-2fa-dialog').show()
},
submitDiscount() {
this.discount_dialog.hide()
axios.put('/api/v4/wallets/' + this.user.wallets[0].id, { discount: this.wallet.discount_id })
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.wallet = Object.assign({}, this.wallet, response.data)
// Update prices in Subscriptions tab
if (this.user.wallet.id == response.data.id) {
this.discount = this.wallet.discount
this.discount_description = this.wallet.discount_description
this.skus.forEach(sku => {
sku.price = this.$root.priceLabel(sku.cost, this.discount)
})
}
}
})
},
submitEmail() {
axios.put('/api/v4/users/' + this.user.id, { external_email: this.external_email })
.then(response => {
if (response.data.status == 'success') {
this.email_dialog.hide()
this.$toast.success(response.data.message)
this.user.external_email = this.external_email
this.external_email = null // required because of Vue
}
})
},
submitOneOff() {
let wallet_id = this.user.wallets[0].id
let post = {
amount: this.oneoff_amount,
description: this.oneoff_description
}
if (this.oneoff_negative && /^\d+(\.?\d+)?$/.test(post.amount)) {
post.amount *= -1
}
this.$root.clearFormValidation('#oneoff-dialog')
axios.post('/api/v4/wallets/' + wallet_id + '/one-off', post)
.then(response => {
if (response.data.status == 'success') {
this.oneoff_dialog.hide()
this.$toast.success(response.data.message)
this.wallet = Object.assign({}, this.wallet, {balance: response.data.balance})
this.oneoff_amount = ''
this.oneoff_description = ''
this.reload()
}
})
},
suspendUser() {
axios.post('/api/v4/users/' + this.user.id + '/suspend', {})
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.user = Object.assign({}, this.user, { isSuspended: true })
}
})
},
unsuspendUser() {
axios.post('/api/v4/users/' + this.user.id + '/unsuspend', {})
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.user = Object.assign({}, this.user, { isSuspended: false })
}
})
}
}
}
</script>
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 @@
<template>
<div class="container">
<status-component v-if="list_id !== 'new'" :status="status" @status-update="statusUpdate"></status-component>
<div class="card" id="distlist-info">
<div class="card-body">
<div class="card-title" v-if="list_id !== 'new'">
{{ $tc('distlist.list-title', 1) }}
<button class="btn btn-outline-danger button-delete float-end" @click="deleteList()" tag="button">
<svg-icon icon="trash-alt"></svg-icon> {{ $t('distlist.delete') }}
</button>
</div>
<div class="card-title" v-if="list_id === 'new'">{{ $t('distlist.new') }}</div>
<div class="card-text">
<ul class="nav nav-tabs mt-3" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="tab-general" href="#general" role="tab" aria-controls="general" aria-selected="true" @click="$root.tab">
{{ $t('form.general') }}
</a>
</li>
<li v-if="list_id !== 'new'" class="nav-item">
<a class="nav-link" id="tab-settings" href="#settings" role="tab" aria-controls="settings" aria-selected="false" @click="$root.tab">
{{ $t('form.settings') }}
</a>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane show active" id="general" role="tabpanel" aria-labelledby="tab-general">
<form @submit.prevent="submit" class="card-body">
<div v-if="list_id !== 'new'" class="row plaintext mb-3">
<label for="status" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label>
<div class="col-sm-8">
<span :class="$root.distlistStatusClass(list) + ' form-control-plaintext'" id="status">{{ $root.distlistStatusText(list) }}</span>
</div>
</div>
+ <div class="row mb-3">
+ <label for="name" class="col-sm-4 col-form-label">{{ $t('distlist.name') }}</label>
+ <div class="col-sm-8">
+ <input type="text" class="form-control" id="name" required v-model="list.name">
+ </div>
+ </div>
<div class="row mb-3">
<label for="email" class="col-sm-4 col-form-label">{{ $t('form.email') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="email" :disabled="list_id !== 'new'" required v-model="list.email">
</div>
</div>
<div class="row mb-3">
<label for="members-input" class="col-sm-4 col-form-label">{{ $t('distlist.recipients') }}</label>
<div class="col-sm-8">
<list-input id="members" :list="list.members"></list-input>
</div>
</div>
<button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}</button>
</form>
</div>
<div class="tab-pane" id="settings" role="tabpanel" aria-labelledby="tab-settings">
<form @submit.prevent="submitSettings" class="card-body">
- <div class="row checkbox mb-3">
+ <div class="row mb-3">
<label for="sender-policy-input" class="col-sm-4 col-form-label">{{ $t('distlist.sender-policy') }}</label>
<div class="col-sm-8 pt-2">
- <list-input id="sender-policy" :list="list.config.sender_policy"></list-input>
+ <list-input id="sender-policy" :list="list.config.sender_policy" class="mb-1"></list-input>
<small id="sender-policy-hint" class="text-muted">
{{ $t('distlist.sender-policy-text') }}
</small>
</div>
</div>
<button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}</button>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import ListInput from '../Widgets/ListInput'
import StatusComponent from '../Widgets/Status'
export default {
components: {
ListInput,
StatusComponent
},
data() {
return {
list_id: null,
list: { members: [], config: {} },
status: {}
}
},
created() {
this.list_id = this.$route.params.list
if (this.list_id != 'new') {
this.$root.startLoading()
axios.get('/api/v4/groups/' + this.list_id)
.then(response => {
this.$root.stopLoading()
this.list = response.data
this.status = response.data.statusInfo
})
.catch(this.$root.errorHandler)
}
},
+ mounted() {
+ $('#name').focus()
+ },
methods: {
deleteList() {
axios.delete('/api/v4/groups/' + this.list_id)
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.$router.push({ name: 'distlists' })
}
})
},
statusUpdate(list) {
this.list = Object.assign({}, this.list, list)
},
submit() {
this.$root.clearFormValidation($('#list-info form'))
let method = 'post'
let location = '/api/v4/groups'
if (this.list_id !== 'new') {
method = 'put'
location += '/' + this.list_id
}
axios[method](location, this.list)
.then(response => {
this.$toast.success(response.data.message)
this.$router.push({ name: 'distlists' })
})
},
submitSettings() {
this.$root.clearFormValidation($('#settings form'))
let post = this.list.config
axios.post('/api/v4/groups/' + this.list_id + '/config', post)
.then(response => {
this.$toast.success(response.data.message)
})
}
}
}
</script>
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 @@
<template>
<div class="container">
<div class="card" id="distlist-list">
<div class="card-body">
<div class="card-title">
{{ $tc('distlist.list-title', 2) }}
<router-link class="btn btn-success float-end create-list" :to="{ path: 'distlist/new' }" tag="button">
<svg-icon icon="users"></svg-icon> {{ $t('distlist.create') }}
</router-link>
</div>
<div class="card-text">
<table class="table table-sm table-hover">
<thead>
<tr>
+ <th scope="col">{{ $t('distlist.name') }}</th>
<th scope="col">{{ $t('distlist.email') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="list in lists" :key="list.id" @click="$root.clickRecord">
<td>
<svg-icon icon="users" :class="$root.distlistStatusClass(list)" :title="$root.distlistStatusText(list)"></svg-icon>
+ <router-link :to="{ path: 'distlist/' + list.id }">{{ list.name }}</router-link>
+ </td>
+ <td>
<router-link :to="{ path: 'distlist/' + list.id }">{{ list.email }}</router-link>
</td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
- <td>{{ $t('distlist.list-empty') }}</td>
+ <td colspan="2">{{ $t('distlist.list-empty') }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
lists: []
}
},
created() {
this.$root.startLoading()
axios.get('/api/v4/groups')
.then(response => {
this.$root.stopLoading()
this.lists = response.data
})
.catch(this.$root.errorHandler)
}
}
</script>
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 @@
<?php
namespace Tests\Browser\Admin;
use App\Group;
use Illuminate\Support\Facades\Queue;
use Tests\Browser;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Admin\Distlist as DistlistPage;
use Tests\Browser\Pages\Admin\User as UserPage;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
use Tests\TestCaseDusk;
class DistlistTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
self::useAdminUrl();
$this->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 @@
<?php
namespace Tests\Browser\Admin;
use App\Auth\SecondFactor;
use App\Discount;
use App\Entitlement;
use App\Sku;
use App\User;
use Tests\Browser;
use Tests\Browser\Components\Dialog;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Admin\User as UserPage;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
use Tests\TestCaseDusk;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class UserTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
self::useAdminUrl();
$john = $this->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 @@
<?php
namespace Tests\Browser;
use App\Group;
use App\Sku;
use Tests\Browser;
use Tests\Browser\Components\ListInput;
use Tests\Browser\Components\Status;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
use Tests\Browser\Pages\DistlistInfo;
use Tests\Browser\Pages\DistlistList;
use Tests\TestCaseDusk;
class DistlistTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->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 @@
<?php
namespace Tests\Browser\Reseller;
use App\Group;
use Illuminate\Support\Facades\Queue;
use Tests\Browser;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Admin\Distlist as DistlistPage;
use Tests\Browser\Pages\Admin\User as UserPage;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
use Tests\TestCaseDusk;
class DistlistTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
self::useResellerUrl();
$this->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 @@
<?php
namespace Tests\Browser\Reseller;
use App\Auth\SecondFactor;
use App\Discount;
use App\Sku;
use App\User;
use Tests\Browser;
use Tests\Browser\Components\Dialog;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Admin\User as UserPage;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
use Tests\TestCaseDusk;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class UserTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
self::useResellerUrl();
$john = $this->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 @@
<?php
namespace Tests\Feature\Backends;
use App\Backends\LDAP;
use App\Domain;
use App\Group;
use App\Entitlement;
use App\User;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class LDAPTest extends TestCase
{
private $ldap_config = [];
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->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 @@
<?php
namespace Tests\Feature\Controller\Admin;
use App\Group;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class GroupsTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
self::useAdminUrl();
$this->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/<domain-id>/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/<group-id>/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/<user-id>/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 @@
<?php
namespace Tests\Feature\Controller;
use App\Group;
use App\Http\Controllers\API\V4\GroupsController;
use Carbon\Carbon;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class GroupsTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->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/<id>)
*/
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/<group>/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/<group-id>)
*/
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/<group-id>/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/<group-id>)
*/
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 @@
<?php
namespace Tests\Feature\Controller\Reseller;
use App\Group;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class GroupsTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
self::useResellerUrl();
$this->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/<domain-id>/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/<group-id>/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/<user-id>/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 @@
<?php
namespace Tests\Feature;
use App\Group;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class GroupTest extends TestCase
{
public function setUp(): void
{
parent::setUp();
$this->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;
}
);
}
}

File Metadata

Mime Type
text/x-diff
Expires
Thu, Dec 18, 1:29 PM (1 d, 15 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
418804
Default Alt Text
(311 KB)

Event Timeline