Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F262102
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
215 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/app/Backends/IMAP.php b/src/app/Backends/IMAP.php
index 30693090..5f5bbd91 100644
--- a/src/app/Backends/IMAP.php
+++ b/src/app/Backends/IMAP.php
@@ -1,652 +1,650 @@
<?php
namespace App\Backends;
use App\Domain;
use App\Group;
use App\Resource;
use App\SharedFolder;
use App\User;
class IMAP
{
/** @const array Group settings used by the backend */
public const GROUP_SETTINGS = [];
/** @const array Resource settings used by the backend */
public const RESOURCE_SETTINGS = [
'folder',
'invitation_policy',
];
/** @const array Shared folder settings used by the backend */
public const SHARED_FOLDER_SETTINGS = [
'folder',
'acl',
];
/** @const array User settings used by the backend */
public const USER_SETTINGS = [];
/** @const array Maps Kolab permissions to IMAP permissions */
private const ACL_MAP = [
'read-only' => 'lrs',
'read-write' => 'lrswitedn',
'full' => 'lrswipkxtecdn',
];
/**
* Delete a group.
*
* @param \App\Group $group Group
*
* @return bool True if a group was deleted successfully, False otherwise
* @throws \Exception
*/
public static function deleteGroup(Group $group): bool
{
$domainName = explode('@', $group->email, 2)[1];
// Cleanup ACL
// FIXME: Since all groups in Kolab4 have email address,
// should we consider using it in ACL instead of the name?
// Also we need to decide what to do and configure IMAP appropriately,
// right now groups in ACL does not work for me at all.
\App\Jobs\IMAP\AclCleanupJob::dispatch($group->name, $domainName);
return true;
}
/**
* Create a mailbox.
*
* @param \App\User $user User
*
* @return bool True if a mailbox was created successfully, False otherwise
* @throws \Exception
*/
public static function createUser(User $user): bool
{
$config = self::getConfig();
$imap = self::initIMAP($config);
$mailbox = self::toUTF7('user/' . $user->email);
// Mailbox already exists
if (self::folderExists($imap, $mailbox)) {
$imap->closeConnection();
return true;
}
// Create the mailbox
if (!$imap->createFolder($mailbox)) {
\Log::error("Failed to create mailbox {$mailbox}");
$imap->closeConnection();
return false;
}
// Wait until it's propagated (for Cyrus Murder setup)
// FIXME: Do we still need this?
if (strpos($imap->conn->data['GREETING'] ?? '', 'Cyrus IMAP Murder') !== false) {
$tries = 30;
while ($tries-- > 0) {
$folders = $imap->listMailboxes('', $mailbox);
if (is_array($folders) && count($folders)) {
break;
}
sleep(1);
$imap->closeConnection();
$imap = self::initIMAP($config);
}
}
// Set quota
$quota = $user->countEntitlementsBySku('storage') * 1048576;
if ($quota) {
$imap->setQuota($mailbox, ['storage' => $quota]);
}
$imap->closeConnection();
return true;
}
/**
* Delete a mailbox.
*
* @param \App\User $user User
*
* @return bool True if a mailbox was deleted successfully, False otherwise
* @throws \Exception
*/
public static function deleteUser(User $user): bool
{
$config = self::getConfig();
$imap = self::initIMAP($config);
$mailbox = self::toUTF7('user/' . $user->email);
// To delete the mailbox cyrus-admin needs extra permissions
$imap->setACL($mailbox, $config['user'], 'c');
// Delete the mailbox (no need to delete subfolders?)
$result = $imap->deleteFolder($mailbox);
$imap->closeConnection();
// Cleanup ACL
\App\Jobs\IMAP\AclCleanupJob::dispatch($user->email);
return $result;
}
/**
* Update a mailbox (quota).
*
* @param \App\User $user User
*
* @return bool True if a mailbox was updated successfully, False otherwise
* @throws \Exception
*/
public static function updateUser(User $user): bool
{
$config = self::getConfig();
$imap = self::initIMAP($config);
$mailbox = self::toUTF7('user/' . $user->email);
$result = true;
// Set quota
$quota = $user->countEntitlementsBySku('storage') * 1048576;
if ($quota) {
$result = $imap->setQuota($mailbox, ['storage' => $quota]);
}
$imap->closeConnection();
return $result;
}
/**
* Create a resource.
*
* @param \App\Resource $resource Resource
*
* @return bool True if a resource was created successfully, False otherwise
* @throws \Exception
*/
public static function createResource(Resource $resource): bool
{
$config = self::getConfig();
$imap = self::initIMAP($config);
$settings = $resource->getSettings(['invitation_policy', 'folder']);
$mailbox = self::toUTF7($settings['folder']);
// Mailbox already exists
if (self::folderExists($imap, $mailbox)) {
$imap->closeConnection();
return true;
}
// Create the shared folder
if (!$imap->createFolder($mailbox)) {
\Log::error("Failed to create mailbox {$mailbox}");
$imap->closeConnection();
return false;
}
// Set folder type
$imap->setMetadata($mailbox, ['/shared/vendor/kolab/folder-type' => 'event']);
// Set ACL
if (!empty($settings['invitation_policy'])) {
if (preg_match('/^manual:(\S+@\S+)$/', $settings['invitation_policy'], $m)) {
self::aclUpdate($imap, $mailbox, ["{$m[1]}, full"]);
}
}
$imap->closeConnection();
return true;
}
/**
* Update a resource.
*
* @param \App\Resource $resource Resource
* @param array $props Old resource properties
*
* @return bool True if a resource was updated successfully, False otherwise
* @throws \Exception
*/
public static function updateResource(Resource $resource, array $props = []): bool
{
$config = self::getConfig();
$imap = self::initIMAP($config);
$settings = $resource->getSettings(['invitation_policy', 'folder']);
$folder = $settings['folder'];
$mailbox = self::toUTF7($folder);
// Rename the mailbox (only possible if we have the old folder)
if (!empty($props['folder']) && $props['folder'] != $folder) {
$oldMailbox = self::toUTF7($props['folder']);
if (!$imap->renameFolder($oldMailbox, $mailbox)) {
\Log::error("Failed to rename mailbox {$oldMailbox} to {$mailbox}");
$imap->closeConnection();
return false;
}
}
// ACL
$acl = [];
if (!empty($settings['invitation_policy'])) {
if (preg_match('/^manual:(\S+@\S+)$/', $settings['invitation_policy'], $m)) {
$acl = ["{$m[1]}, full"];
}
}
self::aclUpdate($imap, $mailbox, $acl);
$imap->closeConnection();
return true;
}
/**
* Delete a resource.
*
* @param \App\Resource $resource Resource
*
* @return bool True if a resource was deleted successfully, False otherwise
* @throws \Exception
*/
public static function deleteResource(Resource $resource): bool
{
$config = self::getConfig();
$imap = self::initIMAP($config);
$settings = $resource->getSettings(['folder']);
$mailbox = self::toUTF7($settings['folder']);
// To delete the mailbox cyrus-admin needs extra permissions
$imap->setACL($mailbox, $config['user'], 'c');
// Delete the mailbox (no need to delete subfolders?)
$result = $imap->deleteFolder($mailbox);
$imap->closeConnection();
return $result;
}
/**
* Create a shared folder.
*
* @param \App\SharedFolder $folder Shared folder
*
* @return bool True if a falder was created successfully, False otherwise
* @throws \Exception
*/
public static function createSharedFolder(SharedFolder $folder): bool
{
$config = self::getConfig();
$imap = self::initIMAP($config);
$settings = $folder->getSettings(['acl', 'folder']);
$acl = !empty($settings['acl']) ? json_decode($settings['acl'], true) : null;
$mailbox = self::toUTF7($settings['folder']);
// Mailbox already exists
if (self::folderExists($imap, $mailbox)) {
$imap->closeConnection();
return true;
}
// Create the mailbox
if (!$imap->createFolder($mailbox)) {
\Log::error("Failed to create mailbox {$mailbox}");
$imap->closeConnection();
return false;
}
// Set folder type
$imap->setMetadata($mailbox, ['/shared/vendor/kolab/folder-type' => $folder->type]);
// Set ACL
self::aclUpdate($imap, $mailbox, $acl);
$imap->closeConnection();
return true;
}
/**
* Update a shared folder.
*
* @param \App\SharedFolder $folder Shared folder
* @param array $props Old folder properties
*
* @return bool True if a falder was updated successfully, False otherwise
* @throws \Exception
*/
public static function updateSharedFolder(SharedFolder $folder, array $props = []): bool
{
$config = self::getConfig();
$imap = self::initIMAP($config);
$settings = $folder->getSettings(['acl', 'folder']);
$acl = !empty($settings['acl']) ? json_decode($settings['acl'], true) : null;
$folder = $settings['folder'];
$mailbox = self::toUTF7($folder);
// Rename the mailbox
if (!empty($props['folder']) && $props['folder'] != $folder) {
$oldMailbox = self::toUTF7($props['folder']);
if (!$imap->renameFolder($oldMailbox, $mailbox)) {
\Log::error("Failed to rename mailbox {$oldMailbox} to {$mailbox}");
$imap->closeConnection();
return false;
}
}
// Note: Shared folder type does not change
// ACL
self::aclUpdate($imap, $mailbox, $acl);
$imap->closeConnection();
return true;
}
/**
* Delete a shared folder.
*
* @param \App\SharedFolder $folder Shared folder
*
* @return bool True if a falder was deleted successfully, False otherwise
* @throws \Exception
*/
public static function deleteSharedFolder(SharedFolder $folder): bool
{
$config = self::getConfig();
$imap = self::initIMAP($config);
$settings = $folder->getSettings(['folder']);
$mailbox = self::toUTF7($settings['folder']);
// To delete the mailbox cyrus-admin needs extra permissions
$imap->setACL($mailbox, $config['user'], 'c');
// Delete the mailbox
$result = $imap->deleteFolder($mailbox);
$imap->closeConnection();
return $result;
}
/**
* Check if a shared folder is set up.
*
* @param string $folder Folder name, e.g. shared/Resources/Name@domain.tld
*
* @return bool True if a folder exists and is set up, False otherwise
*/
public static function verifySharedFolder(string $folder): bool
{
$config = self::getConfig();
$imap = self::initIMAP($config);
// Convert the folder from UTF8 to UTF7-IMAP
if (\preg_match('#^(shared/|shared/Resources/)(.+)(@[^@]+)$#', $folder, $matches)) {
$folderName = self::toUTF7($matches[2]);
$folder = $matches[1] . $folderName . $matches[3];
}
// FIXME: just listMailboxes() does not return shared folders at all
$metadata = $imap->getMetadata($folder, ['/shared/vendor/kolab/folder-type']);
$imap->closeConnection();
// Note: We have to use error code to distinguish an error from "no mailbox" response
if ($imap->errornum === \rcube_imap_generic::ERROR_NO) {
return false;
}
if ($imap->errornum !== \rcube_imap_generic::ERROR_OK) {
throw new \Exception("Failed to get folder metadata from IMAP");
}
return true;
}
/**
* Convert UTF8 string to UTF7-IMAP encoding
*/
public static function toUTF7(string $string): string
{
return \mb_convert_encoding($string, 'UTF7-IMAP', 'UTF8');
}
/**
* Check if an account is set up
*
* @param string $username User login (email address)
*
* @return bool True if an account exists and is set up, False otherwise
*/
public static function verifyAccount(string $username): bool
{
$config = self::getConfig();
- $imap = self::initIMAP($config, $username);
-
- # List the mailbox so we don't verify if shared folders are existing.
- $folders = $imap->listMailboxes('', "INBOX");
-
- \Log::debug("Verify account output" . var_export($folders, true));
+ $imap = self::initIMAP($config);
- $imap->closeConnection();
+ $mailbox = self::toUTF7('user/' . $username);
- if (!is_array($folders)) {
- return false;
+ // Mailbox already exists
+ if (self::folderExists($imap, $mailbox)) {
+ $imap->closeConnection();
+ return true;
}
- return count($folders) > 0;
+ $imap->closeConnection();
+ return false;
}
/**
* Check if we can connect to the imap server
*
* @return bool True on success
*/
public static function healthcheck(): bool
{
$config = self::getConfig();
$imap = self::initIMAP($config);
$imap->closeConnection();
return true;
}
/**
* Remove ACL for a specified user/group anywhere in the IMAP
*
* @param string $ident ACL identifier (user email or e.g. group name)
* @param string $domain ACL domain
*/
public static function aclCleanup(string $ident, string $domain = ''): void
{
$config = self::getConfig();
$imap = self::initIMAP($config);
if (strpos($ident, '@')) {
$domain = explode('@', $ident, 2)[1];
}
$callback = function ($folder) use ($imap, $ident) {
$acl = $imap->getACL($folder);
if (is_array($acl) && isset($acl[$ident])) {
$imap->deleteACL($folder, $ident);
}
};
$folders = $imap->listMailboxes('', "user/*@{$domain}");
if (!is_array($folders)) {
$imap->closeConnection();
throw new \Exception("Failed to get IMAP folders");
}
array_walk($folders, $callback);
$folders = $imap->listMailboxes('', "shared/*@{$domain}");
if (!is_array($folders)) {
$imap->closeConnection();
throw new \Exception("Failed to get IMAP folders");
}
array_walk($folders, $callback);
$imap->closeConnection();
}
/**
* Convert Kolab ACL into IMAP user->rights array
*/
private static function aclToImap($acl): array
{
if (empty($acl)) {
return [];
}
return \collect($acl)
->mapWithKeys(function ($item, $key) {
list($user, $rights) = explode(',', $item, 2);
return [trim($user) => self::ACL_MAP[trim($rights)]];
})
->all();
}
/**
* Update folder ACL
*/
private static function aclUpdate($imap, $mailbox, $acl, bool $isNew = false)
{
$imapAcl = $isNew ? [] : $imap->getACL($mailbox);
if (is_array($imapAcl)) {
foreach (self::aclToImap($acl) as $user => $rights) {
if (empty($imapAcl[$user]) || implode('', $imapAcl[$user]) !== $rights) {
$imap->setACL($mailbox, $user, $rights);
}
unset($imapAcl[$user]);
}
foreach ($imapAcl as $user => $rights) {
$imap->deleteACL($mailbox, $user);
}
}
}
/**
* Check if an IMAP folder exists
*/
private static function folderExists($imap, string $folder): bool
{
$folders = $imap->listMailboxes('', $folder);
if (!is_array($folders)) {
$imap->closeConnection();
throw new \Exception("Failed to get IMAP folders");
}
return count($folders) > 0;
}
/**
* Initialize connection to IMAP
*/
private static function initIMAP(array $config, string $login_as = null)
{
$imap = new \rcube_imap_generic();
if (\config('app.debug')) {
$imap->setDebug(true, 'App\Backends\IMAP::logDebug');
}
if ($login_as) {
$config['options']['auth_cid'] = $config['user'];
$config['options']['auth_pw'] = $config['password'];
$config['options']['auth_type'] = 'PLAIN';
$config['user'] = $login_as;
}
$imap->connect($config['host'], $config['user'], $config['password'], $config['options']);
if (!$imap->connected()) {
$message = sprintf("Login failed for %s against %s. %s", $config['user'], $config['host'], $imap->error);
\Log::error($message);
throw new \Exception("Connection to IMAP failed");
}
return $imap;
}
/**
* Get LDAP configuration for specified access level
*/
private static function getConfig()
{
$uri = \parse_url(\config('imap.uri'));
$default_port = 143;
$ssl_mode = null;
if (isset($uri['scheme'])) {
if (preg_match('/^(ssl|imaps)/', $uri['scheme'])) {
$default_port = 993;
$ssl_mode = 'ssl';
} elseif ($uri['scheme'] === 'tls') {
$ssl_mode = 'tls';
}
}
$config = [
'host' => $uri['host'],
'user' => \config('imap.admin_login'),
'password' => \config('imap.admin_password'),
'options' => [
'port' => !empty($uri['port']) ? $uri['port'] : $default_port,
'ssl_mode' => $ssl_mode,
'socket_options' => [
'ssl' => [
'verify_peer' => \config('imap.verify_peer'),
'verify_peer_name' => \config('imap.verify_peer'),
'verify_host' => \config('imap.verify_host')
],
],
],
];
return $config;
}
/**
* Debug logging callback
*/
public static function logDebug($conn, $msg): void
{
$msg = '[IMAP] ' . $msg;
\Log::debug($msg);
}
}
diff --git a/src/app/Backends/LDAP.php b/src/app/Backends/LDAP.php
index d0883ca5..a8bbf422 100644
--- a/src/app/Backends/LDAP.php
+++ b/src/app/Backends/LDAP.php
@@ -1,1410 +1,1410 @@
<?php
namespace App\Backends;
use App\Domain;
use App\Group;
use App\Resource;
use App\SharedFolder;
use App\User;
class LDAP
{
/** @const array Group settings used by the backend */
public const GROUP_SETTINGS = [
'sender_policy',
];
/** @const array Resource settings used by the backend */
public const RESOURCE_SETTINGS = [
'folder',
'invitation_policy',
];
/** @const array Shared folder settings used by the backend */
public const SHARED_FOLDER_SETTINGS = [
'folder',
'acl',
];
/** @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;
}
}
/**
* Validates that ldap is available as configured.
*
* @throws \Exception
*/
public static function healthcheck(): void
{
$config = self::getConfig('admin');
$ldap = self::initLDAP($config);
$mgmtRootDN = \config('ldap.admin.root_dn');
$hostedRootDN = \config('ldap.hosted.root_dn');
$result = $ldap->search($mgmtRootDN, '', 'base');
if (!$result || $result->count() != 1) {
self::throwException($ldap, "Failed to find the configured management domain $mgmtRootDN");
}
$result = $ldap->search($hostedRootDN, '', 'base');
if (!$result || $result->count() != 1) {
self::throwException($ldap, "Failed to find the configured hosted domain $hostedRootDN");
}
}
/**
* 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);
$mgmtRootDN = \config('ldap.admin.root_dn');
$hostedRootDN = \config('ldap.hosted.root_dn');
$domainBaseDN = "ou={$domain->namespace},{$hostedRootDN}";
$aci = [
'(targetattr = "*")'
. '(version 3.0; acl "Deny Unauthorized"; deny (all)'
. '(userdn != "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN
. ' || ldap:///ou=People,' . $domainBaseDN . '??sub?(objectclass=inetorgperson)") '
. 'AND NOT roledn = "ldap:///cn=kolab-admin,' . $mgmtRootDN . '";)',
'(targetattr != "userPassword")'
. '(version 3.0;acl "Search Access";allow (read,compare,search)'
. '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN
. ' || ldap:///ou=People,' . $domainBaseDN . '??sub?(objectclass=inetorgperson)");)',
'(targetattr = "*")'
. '(version 3.0;acl "Kolab Administrators";allow (all)'
. '(roledn = "ldap:///cn=kolab-admin,' . $domainBaseDN
. ' || ldap:///cn=kolab-admin,' . $mgmtRootDN . '");)'
];
$entry = [
'aci' => $aci,
'associateddomain' => $domain->namespace,
'inetdomainbasedn' => $domainBaseDN,
'objectclass' => [
'top',
'domainrelatedobject',
'inetdomain'
],
];
$dn = "associateddomain={$domain->namespace},{$config['domain_base_dn']}";
self::setDomainAttributes($domain, $entry);
if (!$ldap->get_entry($dn)) {
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)) {
self::addEntry(
$ldap,
$domainBaseDN,
$entry,
"Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")"
);
}
foreach (['Groups', 'People', 'Resources', 'Shared Folders'] as $item) {
$itemDN = "ou={$item},{$domainBaseDN}";
if (!$ldap->get_entry($itemDN)) {
$itemEntry = [
'ou' => $item,
'description' => $item,
'objectclass' => [
'top',
'organizationalunit'
]
];
self::addEntry(
$ldap,
$itemDN,
$itemEntry,
"Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")"
);
}
}
foreach (['kolab-admin'] as $item) {
$itemDN = "cn={$item},{$domainBaseDN}";
if (!$ldap->get_entry($itemDN)) {
$itemEntry = [
'cn' => $item,
'description' => "{$item} role",
'objectclass' => [
'top',
'ldapsubentry',
'nsmanagedroledefinition',
'nsroledefinition',
'nssimpleroledefinition'
]
];
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);
$domainName = explode('@', $group->email, 2)[1];
$cn = $ldap->quote_string($group->name);
$dn = "cn={$cn}," . self::baseDN($ldap, $domainName, 'Groups');
$entry = [
'mail' => $group->email,
'objectclass' => [
'top',
'groupofuniquenames',
'kolabgroupofuniquenames'
],
];
self::setGroupAttributes($ldap, $group, $entry);
self::addEntry(
$ldap,
$dn,
$entry,
"Failed to create group {$group->email} in LDAP (" . __LINE__ . ")"
);
if (empty(self::$ldap)) {
$ldap->close();
}
}
/**
* Create a resource in LDAP.
*
* @param \App\Resource $resource The resource to create.
*
* @throws \Exception
*/
public static function createResource(Resource $resource): void
{
$config = self::getConfig('admin');
$ldap = self::initLDAP($config);
$domainName = explode('@', $resource->email, 2)[1];
$cn = $ldap->quote_string($resource->name);
$dn = "cn={$cn}," . self::baseDN($ldap, $domainName, 'Resources');
$entry = [
'mail' => $resource->email,
'objectclass' => [
'top',
'kolabresource',
'kolabsharedfolder',
'mailrecipient',
],
'kolabfoldertype' => 'event',
];
self::setResourceAttributes($ldap, $resource, $entry);
self::addEntry(
$ldap,
$dn,
$entry,
"Failed to create resource {$resource->email} in LDAP (" . __LINE__ . ")"
);
if (empty(self::$ldap)) {
$ldap->close();
}
}
/**
* Create a shared folder in LDAP.
*
* @param \App\SharedFolder $folder The shared folder to create.
*
* @throws \Exception
*/
public static function createSharedFolder(SharedFolder $folder): void
{
$config = self::getConfig('admin');
$ldap = self::initLDAP($config);
$domainName = explode('@', $folder->email, 2)[1];
$cn = $ldap->quote_string($folder->name);
$dn = "cn={$cn}," . self::baseDN($ldap, $domainName, 'Shared Folders');
$entry = [
'mail' => $folder->email,
'objectclass' => [
'top',
'kolabsharedfolder',
'mailrecipient',
],
];
self::setSharedFolderAttributes($ldap, $folder, $entry);
self::addEntry(
$ldap,
$dn,
$entry,
"Failed to create shared folder {$folder->id} 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);
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);
$domainBaseDN = self::baseDN($ldap, $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 resource from LDAP.
*
* @param \App\Resource $resource The resource to delete.
*
* @throws \Exception
*/
public static function deleteResource(Resource $resource): void
{
$config = self::getConfig('admin');
$ldap = self::initLDAP($config);
if (self::getResourceEntry($ldap, $resource->email, $dn)) {
$result = $ldap->delete_entry($dn);
if (!$result) {
self::throwException(
$ldap,
"Failed to delete resource {$resource->email} from LDAP (" . __LINE__ . ")"
);
}
}
if (empty(self::$ldap)) {
$ldap->close();
}
}
/**
* Delete a shared folder from LDAP.
*
* @param \App\SharedFolder $folder The shared folder to delete.
*
* @throws \Exception
*/
public static function deleteSharedFolder(SharedFolder $folder): void
{
$config = self::getConfig('admin');
$ldap = self::initLDAP($config);
if (self::getSharedFolderEntry($ldap, $folder->email, $dn)) {
$result = $ldap->delete_entry($dn);
if (!$result) {
self::throwException(
$ldap,
"Failed to delete shared folder {$folder->id} 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 resource data from LDAP.
*
* @param string $email The resource email.
*
* @return array|false|null
* @throws \Exception
*/
public static function getResource(string $email)
{
$config = self::getConfig('admin');
$ldap = self::initLDAP($config);
$resource = self::getResourceEntry($ldap, $email, $dn);
if (empty(self::$ldap)) {
$ldap->close();
}
return $resource;
}
/**
* Get a shared folder data from LDAP.
*
* @param string $email The resource email.
*
* @return array|false|null
* @throws \Exception
*/
public static function getSharedFolder(string $email)
{
$config = self::getConfig('admin');
$ldap = self::initLDAP($config);
$folder = self::getSharedFolderEntry($ldap, $email, $dn);
if (empty(self::$ldap)) {
$ldap->close();
}
return $folder;
}
/**
* 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);
$newEntry = $oldEntry = self::getGroupEntry($ldap, $group->email, $dn);
if (empty($oldEntry)) {
self::throwException(
$ldap,
"Failed to update group {$group->email} in LDAP (group not found)"
);
}
self::setGroupAttributes($ldap, $group, $newEntry);
$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 resource in LDAP.
*
* @param \App\Resource $resource The resource to update
*
* @throws \Exception
*/
public static function updateResource(Resource $resource): void
{
$config = self::getConfig('admin');
$ldap = self::initLDAP($config);
$newEntry = $oldEntry = self::getResourceEntry($ldap, $resource->email, $dn);
if (empty($oldEntry)) {
self::throwException(
$ldap,
"Failed to update resource {$resource->email} in LDAP (resource not found)"
);
}
self::setResourceAttributes($ldap, $resource, $newEntry);
$result = $ldap->modify_entry($dn, $oldEntry, $newEntry);
if (!is_array($result)) {
self::throwException(
$ldap,
"Failed to update resource {$resource->email} in LDAP (" . __LINE__ . ")"
);
}
if (empty(self::$ldap)) {
$ldap->close();
}
}
/**
* Update a shared folder in LDAP.
*
* @param \App\SharedFolder $folder The shared folder to update
*
* @throws \Exception
*/
public static function updateSharedFolder(SharedFolder $folder): void
{
$config = self::getConfig('admin');
$ldap = self::initLDAP($config);
$newEntry = $oldEntry = self::getSharedFolderEntry($ldap, $folder->email, $dn);
if (empty($oldEntry)) {
self::throwException(
$ldap,
"Failed to update shared folder {$folder->id} in LDAP (folder not found)"
);
}
self::setSharedFolderAttributes($ldap, $folder, $newEntry);
$result = $ldap->modify_entry($dn, $oldEntry, $newEntry);
if (!is_array($result)) {
self::throwException(
$ldap,
"Failed to update shared folder {$folder->id} 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($ldap, $groupDomain);
$validMembers = [];
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 == $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
if ($group->members !== $validMembers) {
$group->members = $validMembers;
Group::withoutEvents(function () use ($group) {
$group->save();
});
}
}
/**
* Set common resource attributes
*/
private static function setResourceAttributes($ldap, Resource $resource, &$entry)
{
$entry['cn'] = $resource->name;
$entry['owner'] = null;
$entry['kolabinvitationpolicy'] = null;
$entry['acl'] = '';
$settings = $resource->getSettings(['invitation_policy', 'folder']);
$entry['kolabtargetfolder'] = $settings['folder'] ?? '';
// Here's how Wallace's resources module works:
// - if policy is ACT_MANUAL and owner mail specified: a tentative response is sent, event saved,
// and mail sent to the owner to accept/decline the request.
// - if policy is ACT_ACCEPT_AND_NOTIFY and owner mail specified: an accept response is sent,
// event saved, and notification (not confirmation) mail sent to the owner.
// - if there's no owner (policy irrelevant): an accept response is sent, event saved.
// - if policy is ACT_REJECT: a decline response is sent
// - note that the notification email is being send if COND_NOTIFY policy is set or saving failed.
// - all above assume there's no conflict, if there's a conflict the decline response is sent automatically
// (notification is sent if policy = ACT_ACCEPT_AND_NOTIFY).
// - the only supported policies are: 'ACT_MANUAL', 'ACT_ACCEPT' (defined but not used anywhere),
// 'ACT_REJECT', 'ACT_ACCEPT_AND_NOTIFY'.
// For now we ignore the notifications feature
if (!empty($settings['invitation_policy'])) {
if ($settings['invitation_policy'] === 'accept') {
$entry['kolabinvitationpolicy'] = 'ACT_ACCEPT';
} elseif ($settings['invitation_policy'] === 'reject') {
$entry['kolabinvitationpolicy'] = 'ACT_REJECT';
} elseif (preg_match('/^manual:(\S+@\S+)$/', $settings['invitation_policy'], $m)) {
if (self::getUserEntry($ldap, $m[1], $userDN)) {
$entry['owner'] = $userDN;
$entry['acl'] = $m[1] . ', full';
$entry['kolabinvitationpolicy'] = 'ACT_MANUAL';
} else {
$entry['kolabinvitationpolicy'] = 'ACT_ACCEPT';
}
}
}
}
/**
* Set common shared folder attributes
*/
private static function setSharedFolderAttributes($ldap, SharedFolder $folder, &$entry)
{
$settings = $folder->getSettings(['acl', 'folder']);
$entry['cn'] = $folder->name;
$entry['kolabfoldertype'] = $folder->type;
$entry['kolabtargetfolder'] = $settings['folder'] ?? '';
$entry['acl'] = !empty($settings['acl']) ? json_decode($settings['acl'], true) : '';
$entry['alias'] = $folder->aliases()->pluck('alias')->all();
}
/**
* Set common user attributes
*/
private static function setUserAttributes(User $user, array &$entry)
{
$isDegraded = $user->isDegraded(true);
$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')->all();
$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 ($isDegraded) {
$entry['nsroledn'][] = "cn=degraded-user,{$hostedRootDN}";
$entry['mailquota'] = \config('app.storage.min_qty') * 1048576;
} else {
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 null|array Group entry, False on error, NULL if not found
+ * @return null|array Group entry, NULL if not found
*/
private static function getGroupEntry($ldap, $email, &$dn = null)
{
$domainName = explode('@', $email, 2)[1];
$base_dn = self::baseDN($ldap, $domainName, 'Groups');
$attrs = ['dn', 'cn', 'mail', 'uniquemember', 'objectclass', 'kolaballowsmtpsender'];
// 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.
return self::searchEntry($ldap, $base_dn, "(mail=$email)", $attrs, $dn);
}
/**
* Get a resource entry from LDAP.
*
* @param \Net_LDAP3 $ldap Ldap connection
* @param string $email Resource email (mail)
* @param string $dn Reference to the resource DN
*
* @return null|array Resource entry, NULL if not found
*/
private static function getResourceEntry($ldap, $email, &$dn = null)
{
$domainName = explode('@', $email, 2)[1];
$base_dn = self::baseDN($ldap, $domainName, 'Resources');
$attrs = ['dn', 'cn', 'mail', 'objectclass', 'kolabtargetfolder',
'kolabfoldertype', 'kolabinvitationpolicy', 'owner', 'acl'];
// For resources we're using search() instead of get_entry() because
// a resource name is not constant, so e.g. on update we might have
// the new name, but not the old one. Email address is constant.
return self::searchEntry($ldap, $base_dn, "(mail=$email)", $attrs, $dn);
}
/**
* Get a shared folder entry from LDAP.
*
* @param \Net_LDAP3 $ldap Ldap connection
* @param string $email Resource email (mail)
* @param string $dn Reference to the shared folder DN
*
* @return null|array Shared folder entry, NULL if not found
*/
private static function getSharedFolderEntry($ldap, $email, &$dn = null)
{
$domainName = explode('@', $email, 2)[1];
$base_dn = self::baseDN($ldap, $domainName, 'Shared Folders');
$attrs = ['dn', 'cn', 'mail', 'objectclass', 'kolabtargetfolder', 'kolabfoldertype', 'acl', 'alias'];
// For shared folders we're using search() instead of get_entry() because
// a folder name is not constant, so e.g. on update we might have
// the new name, but not the old one. Email address is constant.
return self::searchEntry($ldap, $base_dn, "(mail=$email)", $attrs, $dn);
}
/**
* 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 ?array User entry, NULL if not found
+ * @return null|array User entry, NULL if not found
*/
private static function getUserEntry($ldap, $email, &$dn = null, $full = false)
{
$domainName = explode('@', $email, 2)[1];
$dn = "uid={$email}," . self::baseDN($ldap, $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);
}
}
/**
* Find a single entry in LDAP by using search.
*
* @param \Net_LDAP3 $ldap Ldap connection
* @param string $base_dn Base DN
* @param string $filter Search filter
* @param array $attrs Result attributes
* @param string $dn Reference to a DN of the found entry
*
* @return null|array LDAP entry, NULL if not found
*/
private static function searchEntry($ldap, $base_dn, $filter, $attrs, &$dn = null)
{
$result = $ldap->search($base_dn, $filter, 'sub', $attrs);
if ($result && $result->count() == 1) {
$entries = $result->entries(true);
$dn = key($entries);
$entry = $entries[$dn];
$entry['dn'] = $dn;
return $entry;
}
return null;
}
/**
* 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)) {
$ldap->close();
}
throw new \Exception($message);
}
/**
* Create a base DN string for a specified object.
* Note: It makes sense with an existing domain only.
*
* @param \Net_LDAP3 $ldap Ldap connection
* @param string $domainName Domain namespace
* @param ?string $ouName Optional name of the sub-tree (OU)
*
* @return string Full base DN
*/
private static function baseDN($ldap, string $domainName, string $ouName = null): string
{
$dn = $ldap->domain_root_dn($domainName);
if ($ouName) {
$dn = "ou={$ouName},{$dn}";
}
return $dn;
}
}
diff --git a/src/app/Utils.php b/src/app/Utils.php
index e3284a01..6e553fe4 100644
--- a/src/app/Utils.php
+++ b/src/app/Utils.php
@@ -1,578 +1,578 @@
<?php
namespace App;
use Carbon\Carbon;
use Illuminate\Support\Facades\Auth;
-use Ramsey\Uuid\Uuid;
+use Illuminate\Support\Str;
/**
* Small utility functions for App.
*/
class Utils
{
// Note: Removed '0', 'O', '1', 'I' as problematic with some fonts
public const CHARS = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ';
/**
* Exchange rates for unit tests
*/
private static $testRates;
/**
* Count the number of lines in a file.
*
* Useful for progress bars.
*
* @param string $file The filepath to count the lines of.
*
* @return int
*/
public static function countLines($file)
{
$fh = fopen($file, 'rb');
$numLines = 0;
while (!feof($fh)) {
$numLines += substr_count(fread($fh, 8192), "\n");
}
fclose($fh);
return $numLines;
}
/**
* Return the country ISO code for an IP address.
*
* @param string $ip IP address
* @param string $fallback Fallback country code
*
* @return string
*/
public static function countryForIP($ip, $fallback = 'CH')
{
if (strpos($ip, ':') === false) {
$net = \App\IP4Net::getNet($ip);
} else {
$net = \App\IP6Net::getNet($ip);
}
return $net && $net->country ? $net->country : $fallback;
}
/**
* Return the country ISO code for the current request.
*/
public static function countryForRequest()
{
$request = \request();
$ip = $request->ip();
return self::countryForIP($ip);
}
/**
* Return the number of days in the month prior to this one.
*
* @return int
*/
public static function daysInLastMonth()
{
$start = new Carbon('first day of last month');
$end = new Carbon('last day of last month');
return $start->diffInDays($end) + 1;
}
/**
* Download a file from the interwebz and store it locally.
*
* @param string $source The source location
* @param string $target The target location
* @param bool $force Force the download (and overwrite target)
*
* @return void
*/
public static function downloadFile($source, $target, $force = false)
{
if (is_file($target) && !$force) {
return;
}
\Log::info("Retrieving {$source}");
$fp = fopen($target, 'w');
$curl = curl_init();
curl_setopt($curl, CURLOPT_URL, $source);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($curl, CURLOPT_FILE, $fp);
curl_exec($curl);
if (curl_errno($curl)) {
\Log::error("Request error on {$source}: " . curl_error($curl));
curl_close($curl);
fclose($fp);
unlink($target);
return;
}
curl_close($curl);
fclose($fp);
}
/**
* Converts an email address to lower case. Keeps the LMTP shared folder
* addresses character case intact.
*
* @param string $email Email address
*
* @return string Email address
*/
public static function emailToLower(string $email): string
{
// For LMTP shared folder address lower case the domain part only
if (str_starts_with($email, 'shared+shared/')) {
$pos = strrpos($email, '@');
$domain = substr($email, $pos + 1);
$local = substr($email, 0, strlen($email) - strlen($domain) - 1);
return $local . '@' . strtolower($domain);
}
return strtolower($email);
}
/**
* Generate a passphrase. Not intended for use in production, so limited to environments that are not production.
*
* @return string
*/
public static function generatePassphrase()
{
if (\config('app.env') == 'production') {
throw new \Exception("Thou shall not pass!");
}
if (\config('app.passphrase')) {
return \config('app.passphrase');
}
$alphaLow = 'abcdefghijklmnopqrstuvwxyz';
$alphaUp = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
$num = '0123456789';
$stdSpecial = '~`!@#$%^&*()-_+=[{]}\\|\'";:/?.>,<';
$source = $alphaLow . $alphaUp . $num . $stdSpecial;
$result = '';
for ($x = 0; $x < 16; $x++) {
$result .= substr($source, rand(0, (strlen($source) - 1)), 1);
}
return $result;
}
/**
* Find an object that is the recipient for the specified address.
*
* @param string $address
*
* @return array
*/
public static function findObjectsByRecipientAddress($address)
{
$address = \App\Utils::normalizeAddress($address);
list($local, $domainName) = explode('@', $address);
$domain = \App\Domain::where('namespace', $domainName)->first();
if (!$domain) {
return [];
}
$user = \App\User::where('email', $address)->first();
if ($user) {
return [$user];
}
$userAliases = \App\UserAlias::where('alias', $address)->get();
if (count($userAliases) > 0) {
$users = [];
foreach ($userAliases as $userAlias) {
$users[] = $userAlias->user;
}
return $users;
}
$userAliases = \App\UserAlias::where('alias', "catchall@{$domain->namespace}")->get();
if (count($userAliases) > 0) {
$users = [];
foreach ($userAliases as $userAlias) {
$users[] = $userAlias->user;
}
return $users;
}
return [];
}
/**
* Retrieve the network ID and Type from a client address
*
* @param string $clientAddress The IPv4 or IPv6 address.
*
* @return array An array of ID and class or null and null.
*/
public static function getNetFromAddress($clientAddress)
{
if (strpos($clientAddress, ':') === false) {
$net = \App\IP4Net::getNet($clientAddress);
if ($net) {
return [$net->id, \App\IP4Net::class];
}
} else {
$net = \App\IP6Net::getNet($clientAddress);
if ($net) {
return [$net->id, \App\IP6Net::class];
}
}
return [null, null];
}
/**
* Calculate the broadcast address provided a net number and a prefix.
*
* @param string $net A valid IPv6 network number.
* @param int $prefix The network prefix.
*
* @return string
*/
public static function ip6Broadcast($net, $prefix)
{
$netHex = bin2hex(inet_pton($net));
// Overwriting first address string to make sure notation is optimal
$net = inet_ntop(hex2bin($netHex));
// Calculate the number of 'flexible' bits
$flexbits = 128 - $prefix;
// Build the hexadecimal string of the last address
$lastAddrHex = $netHex;
// We start at the end of the string (which is always 32 characters long)
$pos = 31;
while ($flexbits > 0) {
// Get the character at this position
$orig = substr($lastAddrHex, $pos, 1);
// Convert it to an integer
$origval = hexdec($orig);
// OR it with (2^flexbits)-1, with flexbits limited to 4 at a time
$newval = $origval | (pow(2, min(4, $flexbits)) - 1);
// Convert it back to a hexadecimal character
$new = dechex($newval);
// And put that character back in the string
$lastAddrHex = substr_replace($lastAddrHex, $new, $pos, 1);
// We processed one nibble, move to previous position
$flexbits -= 4;
$pos -= 1;
}
// Convert the hexadecimal string to a binary string
$lastaddrbin = hex2bin($lastAddrHex);
// And create an IPv6 address from the binary string
$lastaddrstr = inet_ntop($lastaddrbin);
return $lastaddrstr;
}
/**
* Normalize an email address.
*
* This means to lowercase and strip components separated with recipient delimiters.
*
* @param ?string $address The address to normalize
* @param bool $asArray Return an array with local and domain part
*
* @return string|array Normalized email address as string or array
*/
public static function normalizeAddress(?string $address, bool $asArray = false)
{
if ($address === null || $address === '') {
return $asArray ? ['', ''] : '';
}
$address = self::emailToLower($address);
if (strpos($address, '@') === false) {
return $asArray ? [$address, ''] : $address;
}
list($local, $domain) = explode('@', $address);
if (strpos($local, '+') !== false) {
$local = explode('+', $local)[0];
}
return $asArray ? [$local, $domain] : "{$local}@{$domain}";
}
/**
* Provide all unique combinations of elements in $input, with order and duplicates irrelevant.
*
* @param array $input The input array of elements.
*
* @return array[]
*/
public static function powerSet(array $input): array
{
$output = [];
for ($x = 0; $x < count($input); $x++) {
self::combine($input, $x + 1, 0, [], 0, $output);
}
return $output;
}
/**
* Returns the current user's email address or null.
*
* @return string
*/
public static function userEmailOrNull(): ?string
{
$user = Auth::user();
if (!$user) {
return null;
}
return $user->email;
}
/**
* Returns a random string consisting of a quantity of segments of a certain length joined.
*
* Example:
*
* ```php
* $roomName = strtolower(\App\Utils::randStr(3, 3, '-');
* // $roomName == '3qb-7cs-cjj'
* ```
*
* @param int $length The length of each segment
* @param int $qty The quantity of segments
* @param string $join The string to use to join the segments
*
* @return string
*/
public static function randStr($length, $qty = 1, $join = '')
{
$chars = env('SHORTCODE_CHARS', self::CHARS);
$randStrs = [];
for ($x = 0; $x < $qty; $x++) {
$randStrs[$x] = [];
for ($y = 0; $y < $length; $y++) {
$randStrs[$x][] = $chars[rand(0, strlen($chars) - 1)];
}
shuffle($randStrs[$x]);
$randStrs[$x] = implode('', $randStrs[$x]);
}
return implode($join, $randStrs);
}
/**
* Returns a UUID in the form of an integer.
*
- * @return integer
+ * @return int
*/
public static function uuidInt(): int
{
- $hex = Uuid::uuid4();
+ $hex = self::uuidStr();
$bin = pack('h*', str_replace('-', '', $hex));
$ids = unpack('L', $bin);
$id = array_shift($ids);
return $id;
}
/**
* Returns a UUID in the form of a string.
*
* @return string
*/
public static function uuidStr(): string
{
- return Uuid::uuid4()->toString();
+ return (string) Str::uuid();
}
private static function combine($input, $r, $index, $data, $i, &$output): void
{
$n = count($input);
// Current cobination is ready
if ($index == $r) {
$output[] = array_slice($data, 0, $r);
return;
}
// When no more elements are there to put in data[]
if ($i >= $n) {
return;
}
// current is included, put next at next location
$data[$index] = $input[$i];
self::combine($input, $r, $index + 1, $data, $i + 1, $output);
// current is excluded, replace it with next (Note that i+1
// is passed, but index is not changed)
self::combine($input, $r, $index, $data, $i + 1, $output);
}
/**
* Create self URL
*
* @param string $route Route/Path/URL
* @param int|null $tenantId Current tenant
*
* @todo Move this to App\Http\Controllers\Controller
*
* @return string Full URL
*/
public static function serviceUrl(string $route, $tenantId = null): string
{
if (preg_match('|^https?://|i', $route)) {
return $route;
}
$url = \App\Tenant::getConfig($tenantId, 'app.public_url');
if (!$url) {
$url = \App\Tenant::getConfig($tenantId, 'app.url');
}
return rtrim(trim($url, '/') . '/' . ltrim($route, '/'), '/');
}
/**
* Create a configuration/environment data to be passed to
* the UI
*
* @todo Move this to App\Http\Controllers\Controller
*
* @return array Configuration data
*/
public static function uiEnv(): array
{
$countries = include resource_path('countries.php');
$req_domain = preg_replace('/:[0-9]+$/', '', request()->getHttpHost());
$sys_domain = \config('app.domain');
$opts = [
'app.name',
'app.url',
'app.domain',
'app.theme',
'app.webmail_url',
'app.support_email',
'app.company.copyright',
'mail.from.address'
];
$env = \app('config')->getMany($opts);
$env['countries'] = $countries ?: [];
$env['view'] = 'root';
$env['jsapp'] = 'user.js';
if ($req_domain == "admin.$sys_domain") {
$env['jsapp'] = 'admin.js';
} elseif ($req_domain == "reseller.$sys_domain") {
$env['jsapp'] = 'reseller.js';
}
$env['paymentProvider'] = \config('services.payment_provider');
$env['stripePK'] = \config('services.stripe.public_key');
$env['languages'] = \App\Http\Controllers\ContentController::locales();
$env['menu'] = \App\Http\Controllers\ContentController::menu();
return $env;
}
/**
* Set test exchange rates.
*
* @param array $rates: Exchange rates
*/
public static function setTestExchangeRates(array $rates): void
{
self::$testRates = $rates;
}
/**
* Retrieve an exchange rate.
*
* @param string $sourceCurrency: Currency from which to convert
* @param string $targetCurrency: Currency to convert to
*
* @return float Exchange rate
*/
public static function exchangeRate(string $sourceCurrency, string $targetCurrency): float
{
if (strcasecmp($sourceCurrency, $targetCurrency) == 0) {
return 1.0;
}
if (isset(self::$testRates[$targetCurrency])) {
return floatval(self::$testRates[$targetCurrency]);
}
$currencyFile = resource_path("exchangerates-$sourceCurrency.php");
//Attempt to find the reverse exchange rate, if we don't have the file for the source currency
if (!file_exists($currencyFile)) {
$rates = include resource_path("exchangerates-$targetCurrency.php");
if (!isset($rates[$sourceCurrency])) {
throw new \Exception("Failed to find the reverse exchange rate for " . $sourceCurrency);
}
return 1.0 / floatval($rates[$sourceCurrency]);
}
$rates = include $currencyFile;
if (!isset($rates[$targetCurrency])) {
throw new \Exception("Failed to find exchange rate for " . $targetCurrency);
}
return floatval($rates[$targetCurrency]);
}
}
diff --git a/src/config/app.php b/src/config/app.php
index 615fc5b7..37b706ad 100644
--- a/src/config/app.php
+++ b/src/config/app.php
@@ -1,281 +1,279 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Name
|--------------------------------------------------------------------------
|
| This value is the name of your application. This value is used when the
| framework needs to place the application's name in a notification or
| any other location as required by the application or its packages.
|
*/
'name' => env('APP_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
| Application Environment
|--------------------------------------------------------------------------
|
| This value determines the "environment" your application is currently
| running in. This may determine how you prefer to configure various
| services the application utilizes. Set this in your ".env" file.
|
*/
'env' => env('APP_ENV', 'production'),
/*
|--------------------------------------------------------------------------
| Application Debug Mode
|--------------------------------------------------------------------------
|
| When your application is in debug mode, detailed error messages with
| stack traces will be shown on every error that occurs within your
| application. If disabled, a simple generic error page is shown.
|
*/
'debug' => env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Application URL
|--------------------------------------------------------------------------
|
| This URL is used by the console to properly generate URLs when using
| the Artisan command line tool. You should set this to the root of
| your application so that it is used when running Artisan tasks.
*/
'url' => env('APP_URL', 'http://localhost'),
'passphrase' => env('APP_PASSPHRASE', null),
'public_url' => env('APP_PUBLIC_URL', env('APP_URL', 'http://localhost')),
'asset_url' => env('ASSET_URL'),
'support_url' => env('SUPPORT_URL', null),
'support_email' => env('SUPPORT_EMAIL', null),
'webmail_url' => env('WEBMAIL_URL', null),
'theme' => env('APP_THEME', 'default'),
'tenant_id' => env('APP_TENANT_ID', null),
'currency' => \strtoupper(env('APP_CURRENCY', 'CHF')),
- 'backends' => env('BACKENDS', 'imap,ldap'),
-
/*
|--------------------------------------------------------------------------
| Application Domain
|--------------------------------------------------------------------------
|
| System domain used for user signup (kolab identity)
*/
'domain' => env('APP_DOMAIN', 'domain.tld'),
'website_domain' => env('APP_WEBSITE_DOMAIN', env('APP_DOMAIN', 'domain.tld')),
'services_domain' => env(
'APP_SERVICES_DOMAIN',
"services." . env('APP_WEBSITE_DOMAIN', env('APP_DOMAIN', 'domain.tld'))
),
/*
|--------------------------------------------------------------------------
| Application Timezone
|--------------------------------------------------------------------------
|
| Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. We have gone
| ahead and set this to a sensible default for you out of the box.
|
*/
'timezone' => 'UTC',
/*
|--------------------------------------------------------------------------
| Application Locale Configuration
|--------------------------------------------------------------------------
|
| The application locale determines the default locale that will be used
| by the translation service provider. You are free to set this value
| to any of the locales which will be supported by the application.
|
*/
'locale' => env('APP_LOCALE', 'en'),
/*
|--------------------------------------------------------------------------
| Application Fallback Locale
|--------------------------------------------------------------------------
|
| The fallback locale determines the locale to use when the current one
| is not available. You may change the value to correspond to any of
| the language folders that are provided through your application.
|
*/
'fallback_locale' => 'en',
/*
|--------------------------------------------------------------------------
| Faker Locale
|--------------------------------------------------------------------------
|
| This locale will be used by the Faker PHP library when generating fake
| data for your database seeds. For example, this will be used to get
| localized telephone numbers, street address information and more.
|
*/
'faker_locale' => 'en_US',
/*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
|
| This key is used by the Illuminate encrypter service and should be set
| to a random, 32 character string, otherwise these encrypted strings
| will not be safe. Please do this before deploying an application!
|
*/
'key' => env('APP_KEY'),
'cipher' => 'AES-256-CBC',
/*
|--------------------------------------------------------------------------
| Autoloaded Service Providers
|--------------------------------------------------------------------------
|
| The service providers listed here will be automatically loaded on the
| request to your application. Feel free to add your own services to
| this array to grant expanded functionality to your applications.
|
*/
'providers' => [
/*
* Laravel Framework Service Providers...
*/
Illuminate\Auth\AuthServiceProvider::class,
Illuminate\Broadcasting\BroadcastServiceProvider::class,
Illuminate\Bus\BusServiceProvider::class,
Illuminate\Cache\CacheServiceProvider::class,
Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class,
Illuminate\Cookie\CookieServiceProvider::class,
Illuminate\Database\DatabaseServiceProvider::class,
Illuminate\Encryption\EncryptionServiceProvider::class,
Illuminate\Filesystem\FilesystemServiceProvider::class,
Illuminate\Foundation\Providers\FoundationServiceProvider::class,
Illuminate\Hashing\HashServiceProvider::class,
Illuminate\Mail\MailServiceProvider::class,
Illuminate\Notifications\NotificationServiceProvider::class,
Illuminate\Pagination\PaginationServiceProvider::class,
Illuminate\Pipeline\PipelineServiceProvider::class,
Illuminate\Queue\QueueServiceProvider::class,
Illuminate\Redis\RedisServiceProvider::class,
Illuminate\Auth\Passwords\PasswordResetServiceProvider::class,
Illuminate\Session\SessionServiceProvider::class,
Illuminate\Translation\TranslationServiceProvider::class,
Illuminate\Validation\ValidationServiceProvider::class,
Illuminate\View\ViewServiceProvider::class,
/*
* Application Service Providers...
*/
App\Providers\AppServiceProvider::class,
App\Providers\AuthServiceProvider::class,
// App\Providers\BroadcastServiceProvider::class,
App\Providers\EventServiceProvider::class,
App\Providers\HorizonServiceProvider::class,
App\Providers\PassportServiceProvider::class,
App\Providers\RouteServiceProvider::class,
],
/*
|--------------------------------------------------------------------------
| Class Aliases
|--------------------------------------------------------------------------
|
| This array of class aliases will be registered when this application
| is started. However, feel free to register as many as you wish as
| the aliases are "lazy" loaded so they don't hinder performance.
|
*/
'aliases' => \Illuminate\Support\Facades\Facade::defaultAliases()->toArray(),
'headers' => [
'csp' => env('APP_HEADER_CSP', ""),
'xfo' => env('APP_HEADER_XFO', ""),
],
// Locations of knowledge base articles
'kb' => [
// An article about suspended accounts
'account_suspended' => env('KB_ACCOUNT_SUSPENDED'),
// An article about a way to delete an owned account
'account_delete' => env('KB_ACCOUNT_DELETE'),
// An article about the payment system
'payment_system' => env('KB_PAYMENT_SYSTEM'),
],
'company' => [
'name' => env('COMPANY_NAME'),
'address' => env('COMPANY_ADDRESS'),
'details' => env('COMPANY_DETAILS'),
'email' => env('COMPANY_EMAIL'),
'logo' => env('COMPANY_LOGO'),
'footer' => env('COMPANY_FOOTER', env('COMPANY_DETAILS')),
'copyright' => env('COMPANY_COPYRIGHT', env('COMPANY_NAME', 'Apheleia IT AG')),
],
'storage' => [
'min_qty' => (int) env('STORAGE_MIN_QTY', 5), // in GB
],
'vat' => [
'countries' => env('VAT_COUNTRIES'),
'rate' => (float) env('VAT_RATE'),
],
'password_policy' => env('PASSWORD_POLICY') ?: 'min:6,max:255',
'payment' => [
'methods_oneoff' => env('PAYMENT_METHODS_ONEOFF', 'creditcard,paypal,banktransfer,bitcoin'),
'methods_recurring' => env('PAYMENT_METHODS_RECURRING', 'creditcard'),
],
'with_ldap' => (bool) env('APP_LDAP', true),
'with_imap' => (bool) env('APP_IMAP', false),
'with_admin' => (bool) env('APP_WITH_ADMIN', false),
'with_files' => (bool) env('APP_WITH_FILES', false),
'with_reseller' => (bool) env('APP_WITH_RESELLER', false),
'with_services' => (bool) env('APP_WITH_SERVICES', false),
'signup' => [
'email_limit' => (int) env('SIGNUP_LIMIT_EMAIL', 0),
'ip_limit' => (int) env('SIGNUP_LIMIT_IP', 0),
],
'woat_ns1' => env('WOAT_NS1', 'ns01.' . env('APP_DOMAIN')),
'woat_ns2' => env('WOAT_NS2', 'ns02.' . env('APP_DOMAIN')),
'ratelimit_whitelist' => explode(',', env('RATELIMIT_WHITELIST', ''))
];
diff --git a/src/phpstan.neon b/src/phpstan.neon
index a2dc644e..2d21275b 100644
--- a/src/phpstan.neon
+++ b/src/phpstan.neon
@@ -1,21 +1,20 @@
includes:
- ./vendor/nunomaduro/larastan/extension.neon
parameters:
ignoreErrors:
- '#Access to an undefined property [a-zA-Z\\]+::\$pivot#'
- - '#Access to undefined constant static\(App\\[a-zA-Z]+\)::STATUS_[A-Z_]+#'
- '#Call to an undefined [a-zA-Z0-9<>\\ ]+::withEnvTenantContext\(\)#'
- '#Call to an undefined [a-zA-Z0-9<>\\ ]+::withObjectTenantContext\(\)#'
- '#Call to an undefined [a-zA-Z0-9<>\\ ]+::withSubjectTenantContext\(\)#'
- '#Call to an undefined method Tests\\Browser::#'
level: 4
parallel:
processTimeout: 300.0
paths:
- app/
- config/
- database/
- resources/
- routes/
- tests/
- resources/
diff --git a/src/tests/Feature/Backends/IMAPTest.php b/src/tests/Feature/Backends/IMAPTest.php
index c82caf4b..e2596b17 100644
--- a/src/tests/Feature/Backends/IMAPTest.php
+++ b/src/tests/Feature/Backends/IMAPTest.php
@@ -1,277 +1,276 @@
<?php
namespace Tests\Feature\Backends;
use App\Backends\IMAP;
use App\Backends\LDAP;
use Tests\TestCase;
class IMAPTest extends TestCase
{
private $imap;
private $user;
private $group;
private $resource;
private $folder;
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
if ($this->imap) {
$this->imap->closeConnection();
$this->imap = null;
}
if ($this->user) {
$this->deleteTestUser($this->user->email);
}
if ($this->group) {
$this->deleteTestGroup($this->group->email);
}
if ($this->resource) {
$this->deleteTestResource($this->resource->email);
}
if ($this->folder) {
$this->deleteTestSharedFolder($this->folder->email);
}
parent::tearDown();
}
/**
* Test aclCleanup()
*
* @group imap
* @group ldap
*/
public function testAclCleanup(): void
{
$this->user = $user = $this->getTestUser('test-' . time() . '@kolab.org');
$this->group = $group = $this->getTestGroup('test-group-' . time() . '@kolab.org');
// SETACL requires that the user/group exists in LDAP
LDAP::createUser($user);
// LDAP::createGroup($group);
// First, set some ACLs that we'll expect to be removed later
$imap = $this->getImap();
$this->assertTrue($imap->setACL('user/john@kolab.org', $user->email, 'lrs'));
$this->assertTrue($imap->setACL('shared/Resources/Conference Room #1@kolab.org', $user->email, 'lrs'));
/*
$this->assertTrue($imap->setACL('user/john@kolab.org', $group->name, 'lrs'));
$this->assertTrue($imap->setACL('shared/Resources/Conference Room #1@kolab.org', $group->name, 'lrs'));
*/
// Cleanup ACL of a user
IMAP::aclCleanup($user->email);
$acl = $imap->getACL('user/john@kolab.org');
$this->assertTrue(is_array($acl) && !isset($acl[$user->email]));
$acl = $imap->getACL('shared/Resources/Conference Room #1@kolab.org');
$this->assertTrue(is_array($acl) && !isset($acl[$user->email]));
/*
// Cleanup ACL of a group
IMAP::aclCleanup($group->name, 'kolab.org');
$acl = $imap->getACL('user/john@kolab.org');
$this->assertTrue(is_array($acl) && !isset($acl[$user->email]));
$acl = $imap->getACL('shared/Resources/Conference Room #1@kolab.org');
$this->assertTrue(is_array($acl) && !isset($acl[$user->email]));
*/
}
/**
* Test creating/updating/deleting an IMAP account
*
* @group imap
*/
public function testUsers(): void
{
$this->user = $user = $this->getTestUser('test-' . time() . '@' . \config('app.domain'));
$storage = \App\Sku::withEnvTenantContext()->where('title', 'storage')->first();
$user->assignSku($storage, 1, $user->wallets->first());
$expectedQuota = [
'user/' . $user->email => [
'storage' => [
'used' => 0,
'total' => 1048576
]
]
];
// Create the mailbox
$result = IMAP::createUser($user);
$this->assertTrue($result);
- // $this->assertTrue(IMAP::verifyAccount($user->email));
+ $this->assertTrue(IMAP::verifyAccount($user->email));
$imap = $this->getImap();
$quota = $imap->getQuota('user/' . $user->email);
$this->assertSame($expectedQuota, $quota['all']);
// Update the mailbox (increase quota)
$user->assignSku($storage, 1, $user->wallets->first());
$expectedQuota['user/' . $user->email]['storage']['total'] = 1048576 * 2;
$result = IMAP::updateUser($user);
$this->assertTrue($result);
$quota = $imap->getQuota('user/' . $user->email);
$this->assertSame($expectedQuota, $quota['all']);
// Delete the mailbox
$result = IMAP::deleteUser($user);
$this->assertTrue($result);
- // $this->expectException(\Exception::class);
$result = IMAP::verifyAccount($user->email);
$this->assertFalse($result);
}
/**
* Test creating/updating/deleting a resource
*
* @group imap
*/
public function testResources(): void
{
$this->resource = $resource = $this->getTestResource(
'test-resource-' . time() . '@kolab.org',
['name' => 'Resource ©' . time()]
);
$resource->setSetting('invitation_policy', 'manual:john@kolab.org');
// Create the resource
$this->assertTrue(IMAP::createResource($resource));
$this->assertTrue(IMAP::verifySharedFolder($imapFolder = $resource->getSetting('folder')));
$imap = $this->getImap();
$expectedAcl = ['john@kolab.org' => str_split('lrswipkxtecdn')];
$this->assertSame($expectedAcl, $imap->getACL(IMAP::toUTF7($imapFolder)));
// Update the resource (rename)
$resource->name = 'Resource1 ©' . time();
$resource->save();
$newImapFolder = $resource->getSetting('folder');
$this->assertTrue(IMAP::updateResource($resource, ['folder' => $imapFolder]));
$this->assertTrue($imapFolder != $newImapFolder);
$this->assertTrue(IMAP::verifySharedFolder($newImapFolder));
$this->assertSame($expectedAcl, $imap->getACL(IMAP::toUTF7($newImapFolder)));
// Update the resource (acl change)
$resource->setSetting('invitation_policy', 'accept');
$this->assertTrue(IMAP::updateResource($resource));
$this->assertSame([], $imap->getACL(IMAP::toUTF7($newImapFolder)));
// Delete the resource
$this->assertTrue(IMAP::deleteResource($resource));
$this->assertFalse(IMAP::verifySharedFolder($newImapFolder));
}
/**
* Test creating/updating/deleting a shared folder
*
* @group imap
*/
public function testSharedFolders(): void
{
$this->folder = $folder = $this->getTestSharedFolder(
'test-folder-' . time() . '@kolab.org',
['name' => 'SharedFolder ©' . time()]
);
$folder->setSetting('acl', json_encode(['john@kolab.org, full', 'jack@kolab.org, read-only']));
// Create the shared folder
$this->assertTrue(IMAP::createSharedFolder($folder));
$this->assertTrue(IMAP::verifySharedFolder($imapFolder = $folder->getSetting('folder')));
$imap = $this->getImap();
$expectedAcl = [
'john@kolab.org' => str_split('lrswipkxtecdn'),
'jack@kolab.org' => str_split('lrs')
];
$this->assertSame($expectedAcl, $imap->getACL(IMAP::toUTF7($imapFolder)));
// Update shared folder (acl)
$folder->setSetting('acl', json_encode(['jack@kolab.org, read-only']));
$this->assertTrue(IMAP::updateSharedFolder($folder));
$expectedAcl = ['jack@kolab.org' => str_split('lrs')];
$this->assertSame($expectedAcl, $imap->getACL(IMAP::toUTF7($imapFolder)));
// Update the shared folder (rename)
$folder->name = 'SharedFolder1 ©' . time();
$folder->save();
$newImapFolder = $folder->getSetting('folder');
$this->assertTrue(IMAP::updateSharedFolder($folder, ['folder' => $imapFolder]));
$this->assertTrue($imapFolder != $newImapFolder);
$this->assertTrue(IMAP::verifySharedFolder($newImapFolder));
$this->assertSame($expectedAcl, $imap->getACL(IMAP::toUTF7($newImapFolder)));
// Delete the shared folder
$this->assertTrue(IMAP::deleteSharedFolder($folder));
$this->assertFalse(IMAP::verifySharedFolder($newImapFolder));
}
/**
* Test verifying IMAP account existence (existing account)
*
* @group imap
*/
public function testVerifyAccountExisting(): void
{
// existing user
$result = IMAP::verifyAccount('john@kolab.org');
$this->assertTrue($result);
// non-existing user
$result = IMAP::verifyAccount('non-existing@domain.tld');
$this->assertFalse($result);
}
/**
* Test verifying IMAP shared folder existence
*
* @group imap
*/
public function testVerifySharedFolder(): void
{
// non-existing
$result = IMAP::verifySharedFolder('shared/Resources/UnknownResource@kolab.org');
$this->assertFalse($result);
// existing
$result = IMAP::verifySharedFolder('shared/Calendar@kolab.org');
$this->assertTrue($result);
}
/**
* Get configured/initialized rcube_imap_generic instance
*/
private function getImap()
{
if ($this->imap) {
return $this->imap;
}
$class = new \ReflectionClass(IMAP::class);
$init = $class->getMethod('initIMAP');
$config = $class->getMethod('getConfig');
$init->setAccessible(true);
$config->setAccessible(true);
$config = $config->invoke(null);
return $this->imap = $init->invokeArgs(null, [$config]);
}
}
diff --git a/src/tests/Feature/Controller/ResourcesTest.php b/src/tests/Feature/Controller/ResourcesTest.php
index d6290980..0d89ea57 100644
--- a/src/tests/Feature/Controller/ResourcesTest.php
+++ b/src/tests/Feature/Controller/ResourcesTest.php
@@ -1,560 +1,564 @@
<?php
namespace Tests\Feature\Controller;
use App\Resource;
use App\Http\Controllers\API\V4\ResourcesController;
use Carbon\Carbon;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class ResourcesTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestResource('resource-test@kolab.org');
Resource::where('name', 'Test Resource')->delete();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestResource('resource-test@kolab.org');
Resource::where('name', 'Test Resource')->delete();
parent::tearDown();
}
/**
* Test resource deleting (DELETE /api/v4/resources/<id>)
*/
public function testDestroy(): void
{
// First create some groups to delete
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$resource = $this->getTestResource('resource-test@kolab.org');
$resource->assignToWallet($john->wallets->first());
// Test unauth access
$response = $this->delete("api/v4/resources/{$resource->id}");
$response->assertStatus(401);
// Test non-existing resource
$response = $this->actingAs($john)->delete("api/v4/resources/abc");
$response->assertStatus(404);
// Test access to other user's resource
$response = $this->actingAs($jack)->delete("api/v4/resources/{$resource->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 resource
$response = $this->actingAs($john)->delete("api/v4/resources/{$resource->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertEquals('success', $json['status']);
$this->assertEquals("Resource deleted successfully.", $json['message']);
}
/**
* Test resources listing (GET /api/v4/resources)
*/
public function testIndex(): void
{
$jack = $this->getTestUser('jack@kolab.org');
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
// Test unauth access
$response = $this->get("api/v4/resources");
$response->assertStatus(401);
// Test a user with no resources
$response = $this->actingAs($jack)->get("/api/v4/resources");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(4, $json);
$this->assertSame(0, $json['count']);
$this->assertSame(false, $json['hasMore']);
$this->assertSame("0 resources have been found.", $json['message']);
$this->assertSame([], $json['list']);
// Test a user with two resources
$response = $this->actingAs($john)->get("/api/v4/resources");
$response->assertStatus(200);
$json = $response->json();
$resource = Resource::where('name', 'Conference Room #1')->first();
$this->assertCount(4, $json);
$this->assertSame(2, $json['count']);
$this->assertSame(false, $json['hasMore']);
$this->assertSame("2 resources have been found.", $json['message']);
$this->assertCount(2, $json['list']);
$this->assertSame($resource->id, $json['list'][0]['id']);
$this->assertSame($resource->email, $json['list'][0]['email']);
$this->assertSame($resource->name, $json['list'][0]['name']);
$this->assertArrayHasKey('isDeleted', $json['list'][0]);
$this->assertArrayHasKey('isActive', $json['list'][0]);
$this->assertArrayHasKey('isLdapReady', $json['list'][0]);
- $this->assertArrayHasKey('isImapReady', $json['list'][0]);
+ if (\config('app.with_imap')) {
+ $this->assertArrayHasKey('isImapReady', $json['list'][0]);
+ }
// Test that another wallet controller has access to resources
$response = $this->actingAs($ned)->get("/api/v4/resources");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(4, $json);
$this->assertSame(2, $json['count']);
$this->assertSame(false, $json['hasMore']);
$this->assertSame("2 resources have been found.", $json['message']);
$this->assertCount(2, $json['list']);
$this->assertSame($resource->email, $json['list'][0]['email']);
}
/**
* Test resource config update (POST /api/v4/resources/<resource>/config)
*/
public function testSetConfig(): void
{
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$resource = $this->getTestResource('resource-test@kolab.org');
$resource->assignToWallet($john->wallets->first());
// Test unknown resource id
$post = ['invitation_policy' => 'reject'];
$response = $this->actingAs($john)->post("/api/v4/resources/123/config", $post);
$json = $response->json();
$response->assertStatus(404);
// Test access by user not being a wallet controller
$post = ['invitation_policy' => 'reject'];
$response = $this->actingAs($jack)->post("/api/v4/resources/{$resource->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/resources/{$resource->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']);
$resource->refresh();
$this->assertNull($resource->getSetting('test'));
$this->assertNull($resource->getSetting('invitation_policy'));
// Test some valid data
$post = ['invitation_policy' => 'reject'];
$response = $this->actingAs($john)->post("/api/v4/resources/{$resource->id}/config", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(2, $json);
$this->assertSame('success', $json['status']);
$this->assertSame("Resource settings updated successfully.", $json['message']);
$this->assertSame(['invitation_policy' => 'reject'], $resource->fresh()->getConfig());
// Test input validation
$post = ['invitation_policy' => 'aaa'];
$response = $this->actingAs($john)->post("/api/v4/resources/{$resource->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 specified invitation policy is invalid.",
$json['errors']['invitation_policy']
);
$this->assertSame(['invitation_policy' => 'reject'], $resource->fresh()->getConfig());
}
/**
* Test fetching resource data/profile (GET /api/v4/resources/<resource>)
*/
public function testShow(): void
{
$jack = $this->getTestUser('jack@kolab.org');
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$resource = $this->getTestResource('resource-test@kolab.org');
$resource->assignToWallet($john->wallets->first());
$resource->setSetting('invitation_policy', 'reject');
// Test unauthorized access to a profile of other user
$response = $this->get("/api/v4/resources/{$resource->id}");
$response->assertStatus(401);
// Test unauthorized access to a resource of another user
$response = $this->actingAs($jack)->get("/api/v4/resources/{$resource->id}");
$response->assertStatus(403);
// John: Account owner - non-existing resource
$response = $this->actingAs($john)->get("/api/v4/resources/abc");
$response->assertStatus(404);
// John: Account owner
$response = $this->actingAs($john)->get("/api/v4/resources/{$resource->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame($resource->id, $json['id']);
$this->assertSame($resource->email, $json['email']);
$this->assertSame($resource->name, $json['name']);
$this->assertTrue(!empty($json['statusInfo']));
$this->assertArrayHasKey('isDeleted', $json);
$this->assertArrayHasKey('isActive', $json);
$this->assertArrayHasKey('isLdapReady', $json);
- $this->assertArrayHasKey('isImapReady', $json);
+ if (\config('app.with_imap')) {
+ $this->assertArrayHasKey('isImapReady', $json);
+ }
$this->assertSame(['invitation_policy' => 'reject'], $json['config']);
$this->assertCount(1, $json['skus']);
}
/**
* Test fetching SKUs list for a resource (GET /resources/<id>/skus)
*/
public function testSkus(): void
{
Queue::fake();
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$resource = $this->getTestResource('resource-test@kolab.org');
$resource->assignToWallet($john->wallets->first());
// Unauth access not allowed
$response = $this->get("api/v4/resources/{$resource->id}/skus");
$response->assertStatus(401);
// Unauthorized access not allowed
$response = $this->actingAs($jack)->get("api/v4/resources/{$resource->id}/skus");
$response->assertStatus(403);
$response = $this->actingAs($john)->get("api/v4/resources/{$resource->id}/skus");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(1, $json);
$this->assertSkuElement('resource', $json[0], [
'prio' => 0,
'type' => 'resource',
'handler' => 'Resource',
'enabled' => true,
'readonly' => true,
]);
}
/**
* Test fetching a resource status (GET /api/v4/resources/<resource>/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');
$resource = $this->getTestResource('resource-test@kolab.org');
$resource->assignToWallet($john->wallets->first());
// Test unauthorized access
$response = $this->get("/api/v4/resources/abc/status");
$response->assertStatus(401);
// Test unauthorized access
$response = $this->actingAs($jack)->get("/api/v4/resources/{$resource->id}/status");
$response->assertStatus(403);
$resource->status = Resource::STATUS_NEW | Resource::STATUS_ACTIVE;
$resource->save();
// Get resource status
$response = $this->actingAs($john)->get("/api/v4/resources/{$resource->id}/status");
$response->assertStatus(200);
$json = $response->json();
$this->assertFalse($json['isLdapReady']);
- $this->assertFalse($json['isImapReady']);
$this->assertFalse($json['isReady']);
$this->assertFalse($json['isDeleted']);
$this->assertTrue($json['isActive']);
if (\config('app.with_imap')) {
+ $this->assertFalse($json['isImapReady']);
$this->assertCount(7, $json['process']);
} else {
$this->assertCount(6, $json['process']);
}
$this->assertSame('resource-new', $json['process'][0]['label']);
$this->assertSame(true, $json['process'][0]['state']);
$this->assertSame('resource-ldap-ready', $json['process'][1]['label']);
$this->assertSame(false, $json['process'][1]['state']);
$this->assertTrue(empty($json['status']));
$this->assertTrue(empty($json['message']));
$this->assertSame('running', $json['processState']);
// 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();
$resource->status |= Resource::STATUS_IMAP_READY;
$resource->save();
// Now "reboot" the process
Queue::fake();
$response = $this->actingAs($john)->get("/api/v4/resources/{$resource->id}/status?refresh=1");
$response->assertStatus(200);
$json = $response->json();
$this->assertFalse($json['isLdapReady']);
- $this->assertTrue($json['isImapReady']);
$this->assertFalse($json['isReady']);
if (\config('app.with_imap')) {
+ $this->assertTrue($json['isImapReady']);
$this->assertCount(7, $json['process']);
} else {
$this->assertCount(6, $json['process']);
}
$this->assertSame('resource-ldap-ready', $json['process'][1]['label']);
$this->assertSame(false, $json['process'][1]['state']);
if (\config('app.with_imap')) {
$this->assertSame('resource-imap-ready', $json['process'][2]['label']);
}
$this->assertSame(true, $json['process'][2]['state']);
$this->assertSame('success', $json['status']);
$this->assertSame('Setup process has been pushed. Please wait.', $json['message']);
$this->assertSame('waiting', $json['processState']);
Queue::assertPushed(\App\Jobs\Resource\CreateJob::class, 1);
// Test a case when a domain is not ready
Queue::fake();
$domain->status ^= \App\Domain::STATUS_CONFIRMED;
$domain->save();
$response = $this->actingAs($john)->get("/api/v4/resources/{$resource->id}/status?refresh=1");
$response->assertStatus(200);
$json = $response->json();
$this->assertFalse($json['isLdapReady']);
$this->assertFalse($json['isReady']);
if (\config('app.with_imap')) {
$this->assertCount(7, $json['process']);
} else {
$this->assertCount(6, $json['process']);
}
$this->assertSame('resource-ldap-ready', $json['process'][1]['label']);
$this->assertSame(false, $json['process'][1]['state']);
$this->assertSame('success', $json['status']);
$this->assertSame('Setup process has been pushed. Please wait.', $json['message']);
$this->assertSame('waiting', $json['processState']);
Queue::assertPushed(\App\Jobs\Resource\CreateJob::class, 1);
}
/**
* Test ResourcesController::statusInfo()
*/
public function testStatusInfo(): void
{
$john = $this->getTestUser('john@kolab.org');
$resource = $this->getTestResource('resource-test@kolab.org');
$resource->assignToWallet($john->wallets->first());
$resource->status = Resource::STATUS_NEW | Resource::STATUS_ACTIVE;
$resource->save();
$domain = $this->getTestDomain('kolab.org');
$domain->status |= \App\Domain::STATUS_CONFIRMED;
$domain->save();
$result = ResourcesController::statusInfo($resource);
$this->assertFalse($result['isReady']);
if (\config('app.with_imap')) {
$this->assertCount(7, $result['process']);
} else {
$this->assertCount(6, $result['process']);
}
$this->assertSame('resource-new', $result['process'][0]['label']);
$this->assertSame(true, $result['process'][0]['state']);
$this->assertSame('resource-ldap-ready', $result['process'][1]['label']);
$this->assertSame(false, $result['process'][1]['state']);
$this->assertSame('running', $result['processState']);
$resource->created_at = Carbon::now()->subSeconds(181);
$resource->save();
$result = ResourcesController::statusInfo($resource);
$this->assertSame('failed', $result['processState']);
$resource->status |= Resource::STATUS_LDAP_READY | Resource::STATUS_IMAP_READY;
$resource->save();
$result = ResourcesController::statusInfo($resource);
$this->assertTrue($result['isReady']);
if (\config('app.with_imap')) {
$this->assertCount(7, $result['process']);
} else {
$this->assertCount(6, $result['process']);
}
$this->assertSame('resource-new', $result['process'][0]['label']);
$this->assertSame(true, $result['process'][0]['state']);
$this->assertSame('resource-ldap-ready', $result['process'][1]['label']);
$this->assertSame(true, $result['process'][1]['state']);
$this->assertSame('resource-ldap-ready', $result['process'][1]['label']);
$this->assertSame(true, $result['process'][1]['state']);
$this->assertSame('done', $result['processState']);
}
/**
* Test resource creation (POST /api/v4/resources)
*/
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/resources", []);
$response->assertStatus(401);
// Test non-controller user
$response = $this->actingAs($jack)->post("/api/v4/resources", []);
$response->assertStatus(403);
// Test empty request
$response = $this->actingAs($john)->post("/api/v4/resources", []);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertSame("The name field is required.", $json['errors']['name'][0]);
$this->assertCount(2, $json);
$this->assertCount(1, $json['errors']);
// Test too long name
$post = ['domain' => 'kolab.org', 'name' => str_repeat('A', 192)];
$response = $this->actingAs($john)->post("/api/v4/resources", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json);
$this->assertSame("The name may not be greater than 191 characters.", $json['errors']['name'][0]);
$this->assertCount(1, $json['errors']);
// Test successful resource creation
$post['name'] = 'Test Resource';
$response = $this->actingAs($john)->post("/api/v4/resources", $post);
$json = $response->json();
$response->assertStatus(200);
$this->assertSame('success', $json['status']);
$this->assertSame("Resource created successfully.", $json['message']);
$this->assertCount(2, $json);
$resource = Resource::where('name', $post['name'])->first();
$this->assertInstanceOf(Resource::class, $resource);
$this->assertTrue($john->resources()->get()->contains($resource));
// Resource name must be unique within a domain
$response = $this->actingAs($john)->post("/api/v4/resources", $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 resource update (PUT /api/v4/resources/<resource>)
*/
public function testUpdate(): void
{
Queue::fake();
$jack = $this->getTestUser('jack@kolab.org');
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$resource = $this->getTestResource('resource-test@kolab.org');
$resource->assignToWallet($john->wallets->first());
// Test unauthorized update
$response = $this->get("/api/v4/resources/{$resource->id}", []);
$response->assertStatus(401);
// Test unauthorized update
$response = $this->actingAs($jack)->get("/api/v4/resources/{$resource->id}", []);
$response->assertStatus(403);
// Name change
$post = [
'name' => 'Test Res',
];
$response = $this->actingAs($john)->put("/api/v4/resources/{$resource->id}", $post);
$json = $response->json();
$response->assertStatus(200);
$this->assertSame('success', $json['status']);
$this->assertSame("Resource updated successfully.", $json['message']);
$this->assertCount(2, $json);
$resource->refresh();
$this->assertSame($post['name'], $resource->name);
}
}
diff --git a/src/tests/Feature/Controller/SharedFoldersTest.php b/src/tests/Feature/Controller/SharedFoldersTest.php
index 4fa4e99f..70388614 100644
--- a/src/tests/Feature/Controller/SharedFoldersTest.php
+++ b/src/tests/Feature/Controller/SharedFoldersTest.php
@@ -1,646 +1,650 @@
<?php
namespace Tests\Feature\Controller;
use App\SharedFolder;
use App\Http\Controllers\API\V4\SharedFoldersController;
use Carbon\Carbon;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class SharedFoldersTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestSharedFolder('folder-test@kolab.org');
SharedFolder::where('name', 'like', 'Test_Folder')->forceDelete();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestSharedFolder('folder-test@kolab.org');
SharedFolder::where('name', 'like', 'Test_Folder')->forceDelete();
parent::tearDown();
}
/**
* Test resource deleting (DELETE /api/v4/resources/<id>)
*/
public function testDestroy(): void
{
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$folder = $this->getTestSharedFolder('folder-test@kolab.org');
$folder->assignToWallet($john->wallets->first());
// Test unauth access
$response = $this->delete("api/v4/shared-folders/{$folder->id}");
$response->assertStatus(401);
// Test non-existing folder
$response = $this->actingAs($john)->delete("api/v4/shared-folders/abc");
$response->assertStatus(404);
// Test access to other user's folder
$response = $this->actingAs($jack)->delete("api/v4/shared-folders/{$folder->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 folder
$response = $this->actingAs($john)->delete("api/v4/shared-folders/{$folder->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertEquals('success', $json['status']);
$this->assertEquals("Shared folder deleted successfully.", $json['message']);
}
/**
* Test shared folders listing (GET /api/v4/shared-folders)
*/
public function testIndex(): void
{
$jack = $this->getTestUser('jack@kolab.org');
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
// Test unauth access
$response = $this->get("api/v4/shared-folders");
$response->assertStatus(401);
// Test a user with no shared folders
$response = $this->actingAs($jack)->get("/api/v4/shared-folders");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(4, $json);
$this->assertSame(0, $json['count']);
$this->assertSame(false, $json['hasMore']);
$this->assertSame("0 shared folders have been found.", $json['message']);
$this->assertSame([], $json['list']);
// Test a user with two shared folders
$response = $this->actingAs($john)->get("/api/v4/shared-folders");
$response->assertStatus(200);
$json = $response->json();
$folder = SharedFolder::where('name', 'Calendar')->first();
$this->assertCount(4, $json);
$this->assertSame(2, $json['count']);
$this->assertSame(false, $json['hasMore']);
$this->assertSame("2 shared folders have been found.", $json['message']);
$this->assertCount(2, $json['list']);
$this->assertSame($folder->id, $json['list'][0]['id']);
$this->assertSame($folder->email, $json['list'][0]['email']);
$this->assertSame($folder->name, $json['list'][0]['name']);
$this->assertSame($folder->type, $json['list'][0]['type']);
$this->assertArrayHasKey('isDeleted', $json['list'][0]);
$this->assertArrayHasKey('isActive', $json['list'][0]);
$this->assertArrayHasKey('isLdapReady', $json['list'][0]);
- $this->assertArrayHasKey('isImapReady', $json['list'][0]);
+ if (\config('app.with_imap')) {
+ $this->assertArrayHasKey('isImapReady', $json['list'][0]);
+ }
// Test that another wallet controller has access to shared folders
$response = $this->actingAs($ned)->get("/api/v4/shared-folders");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(4, $json);
$this->assertSame(2, $json['count']);
$this->assertSame(false, $json['hasMore']);
$this->assertSame("2 shared folders have been found.", $json['message']);
$this->assertCount(2, $json['list']);
$this->assertSame($folder->email, $json['list'][0]['email']);
}
/**
* Test shared folder config update (POST /api/v4/shared-folders/<folder>/config)
*/
public function testSetConfig(): void
{
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$folder = $this->getTestSharedFolder('folder-test@kolab.org');
$folder->assignToWallet($john->wallets->first());
// Test unknown resource id
$post = ['acl' => ['john@kolab.org, full']];
$response = $this->actingAs($john)->post("/api/v4/shared-folders/123/config", $post);
$json = $response->json();
$response->assertStatus(404);
// Test access by user not being a wallet controller
$response = $this->actingAs($jack)->post("/api/v4/shared-folders/{$folder->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/shared-folders/{$folder->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']);
$folder->refresh();
$this->assertNull($folder->getSetting('test'));
$this->assertNull($folder->getSetting('acl'));
// Test some valid data
$post = ['acl' => ['john@kolab.org, full']];
$response = $this->actingAs($john)->post("/api/v4/shared-folders/{$folder->id}/config", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(2, $json);
$this->assertSame('success', $json['status']);
$this->assertSame("Shared folder settings updated successfully.", $json['message']);
$this->assertSame(['acl' => $post['acl']], $folder->fresh()->getConfig());
// Test input validation
$post = ['acl' => ['john@kolab.org, full', 'test, full']];
$response = $this->actingAs($john)->post("/api/v4/shared-folders/{$folder->id}/config", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertCount(2, $json);
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertCount(1, $json['errors']['acl']);
$this->assertSame(
"The specified email address is invalid.",
$json['errors']['acl'][1]
);
$this->assertSame(['acl' => ['john@kolab.org, full']], $folder->fresh()->getConfig());
}
/**
* Test fetching shared folder data/profile (GET /api/v4/shared-folders/<folder>)
*/
public function testShow(): void
{
$jack = $this->getTestUser('jack@kolab.org');
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$folder = $this->getTestSharedFolder('folder-test@kolab.org');
$folder->assignToWallet($john->wallets->first());
$folder->setSetting('acl', '["anyone, full"]');
$folder->setAliases(['folder-alias@kolab.org']);
// Test unauthenticated access
$response = $this->get("/api/v4/shared-folders/{$folder->id}");
$response->assertStatus(401);
// Test unauthorized access to a shared folder of another user
$response = $this->actingAs($jack)->get("/api/v4/shared-folders/{$folder->id}");
$response->assertStatus(403);
// John: Account owner - non-existing folder
$response = $this->actingAs($john)->get("/api/v4/shared-folders/abc");
$response->assertStatus(404);
// John: Account owner
$response = $this->actingAs($john)->get("/api/v4/shared-folders/{$folder->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame($folder->id, $json['id']);
$this->assertSame($folder->email, $json['email']);
$this->assertSame($folder->name, $json['name']);
$this->assertSame($folder->type, $json['type']);
$this->assertSame(['folder-alias@kolab.org'], $json['aliases']);
$this->assertTrue(!empty($json['statusInfo']));
$this->assertArrayHasKey('isDeleted', $json);
$this->assertArrayHasKey('isActive', $json);
$this->assertArrayHasKey('isLdapReady', $json);
- $this->assertArrayHasKey('isImapReady', $json);
+ if (\config('app.with_imap')) {
+ $this->assertArrayHasKey('isImapReady', $json);
+ }
$this->assertSame(['acl' => ['anyone, full']], $json['config']);
$this->assertCount(1, $json['skus']);
}
/**
* Test fetching SKUs list for a shared folder (GET /shared-folders/<id>/skus)
*/
public function testSkus(): void
{
Queue::fake();
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$folder = $this->getTestSharedFolder('folder-test@kolab.org');
$folder->assignToWallet($john->wallets->first());
// Unauth access not allowed
$response = $this->get("api/v4/shared-folders/{$folder->id}/skus");
$response->assertStatus(401);
// Unauthorized access not allowed
$response = $this->actingAs($jack)->get("api/v4/shared-folders/{$folder->id}/skus");
$response->assertStatus(403);
// Non-existing folder
$response = $this->actingAs($john)->get("api/v4/shared-folders/non-existing/skus");
$response->assertStatus(404);
$response = $this->actingAs($john)->get("api/v4/shared-folders/{$folder->id}/skus");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(1, $json);
$this->assertSkuElement('shared-folder', $json[0], [
'prio' => 0,
'type' => 'sharedFolder',
'handler' => 'SharedFolder',
'enabled' => true,
'readonly' => true,
]);
}
/**
* Test fetching a shared folder status (GET /api/v4/shared-folders/<folder>/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');
$folder = $this->getTestSharedFolder('folder-test@kolab.org');
$folder->assignToWallet($john->wallets->first());
// Test unauthorized access
$response = $this->get("/api/v4/shared-folders/abc/status");
$response->assertStatus(401);
// Test unauthorized access
$response = $this->actingAs($jack)->get("/api/v4/shared-folders/{$folder->id}/status");
$response->assertStatus(403);
$folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE;
$folder->save();
// Get resource status
$response = $this->actingAs($john)->get("/api/v4/shared-folders/{$folder->id}/status");
$response->assertStatus(200);
$json = $response->json();
$this->assertFalse($json['isLdapReady']);
- $this->assertFalse($json['isImapReady']);
$this->assertFalse($json['isReady']);
$this->assertFalse($json['isDeleted']);
$this->assertTrue($json['isActive']);
if (\config('app.with_imap')) {
+ $this->assertFalse($json['isImapReady']);
$this->assertCount(7, $json['process']);
} else {
$this->assertCount(6, $json['process']);
}
$this->assertSame('shared-folder-new', $json['process'][0]['label']);
$this->assertSame(true, $json['process'][0]['state']);
$this->assertSame('shared-folder-ldap-ready', $json['process'][1]['label']);
$this->assertSame(false, $json['process'][1]['state']);
$this->assertTrue(empty($json['status']));
$this->assertTrue(empty($json['message']));
$this->assertSame('running', $json['processState']);
// 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();
$folder->status |= SharedFolder::STATUS_IMAP_READY;
$folder->save();
// Now "reboot" the process
Queue::fake();
$response = $this->actingAs($john)->get("/api/v4/shared-folders/{$folder->id}/status?refresh=1");
$response->assertStatus(200);
$json = $response->json();
$this->assertFalse($json['isLdapReady']);
- $this->assertTrue($json['isImapReady']);
$this->assertFalse($json['isReady']);
if (\config('app.with_imap')) {
+ $this->assertTrue($json['isImapReady']);
$this->assertCount(7, $json['process']);
} else {
$this->assertCount(6, $json['process']);
}
$this->assertSame('shared-folder-ldap-ready', $json['process'][1]['label']);
$this->assertSame(false, $json['process'][1]['state']);
if (\config('app.with_imap')) {
$this->assertSame('shared-folder-imap-ready', $json['process'][2]['label']);
$this->assertSame(true, $json['process'][2]['state']);
}
$this->assertSame('success', $json['status']);
$this->assertSame('Setup process has been pushed. Please wait.', $json['message']);
$this->assertSame('waiting', $json['processState']);
Queue::assertPushed(\App\Jobs\SharedFolder\CreateJob::class, 1);
// Test a case when a domain is not ready
Queue::fake();
$domain->status ^= \App\Domain::STATUS_CONFIRMED;
$domain->save();
$response = $this->actingAs($john)->get("/api/v4/shared-folders/{$folder->id}/status?refresh=1");
$response->assertStatus(200);
$json = $response->json();
$this->assertFalse($json['isLdapReady']);
$this->assertFalse($json['isReady']);
if (\config('app.with_imap')) {
$this->assertCount(7, $json['process']);
} else {
$this->assertCount(6, $json['process']);
}
$this->assertSame('shared-folder-ldap-ready', $json['process'][1]['label']);
$this->assertSame(false, $json['process'][1]['state']);
$this->assertSame('success', $json['status']);
$this->assertSame('Setup process has been pushed. Please wait.', $json['message']);
$this->assertSame('waiting', $json['processState']);
Queue::assertPushed(\App\Jobs\SharedFolder\CreateJob::class, 1);
}
/**
* Test SharedFoldersController::statusInfo()
*/
public function testStatusInfo(): void
{
$john = $this->getTestUser('john@kolab.org');
$folder = $this->getTestSharedFolder('folder-test@kolab.org');
$folder->assignToWallet($john->wallets->first());
$folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE;
$folder->save();
$domain = $this->getTestDomain('kolab.org');
$domain->status |= \App\Domain::STATUS_CONFIRMED;
$domain->save();
$result = SharedFoldersController::statusInfo($folder);
$this->assertFalse($result['isReady']);
if (\config('app.with_imap')) {
$this->assertCount(7, $result['process']);
} else {
$this->assertCount(6, $result['process']);
}
$this->assertSame('shared-folder-new', $result['process'][0]['label']);
$this->assertSame(true, $result['process'][0]['state']);
$this->assertSame('shared-folder-ldap-ready', $result['process'][1]['label']);
$this->assertSame(false, $result['process'][1]['state']);
$this->assertSame('running', $result['processState']);
$folder->created_at = Carbon::now()->subSeconds(181);
$folder->save();
$result = SharedFoldersController::statusInfo($folder);
$this->assertSame('failed', $result['processState']);
$folder->status |= SharedFolder::STATUS_LDAP_READY | SharedFolder::STATUS_IMAP_READY;
$folder->save();
$result = SharedFoldersController::statusInfo($folder);
$this->assertTrue($result['isReady']);
if (\config('app.with_imap')) {
$this->assertCount(7, $result['process']);
} else {
$this->assertCount(6, $result['process']);
}
$this->assertSame('shared-folder-new', $result['process'][0]['label']);
$this->assertSame(true, $result['process'][0]['state']);
$this->assertSame('shared-folder-ldap-ready', $result['process'][1]['label']);
$this->assertSame(true, $result['process'][1]['state']);
$this->assertSame('shared-folder-ldap-ready', $result['process'][1]['label']);
$this->assertSame(true, $result['process'][1]['state']);
$this->assertSame('done', $result['processState']);
}
/**
* Test shared folder creation (POST /api/v4/shared-folders)
*/
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/shared-folders", []);
$response->assertStatus(401);
// Test non-controller user
$response = $this->actingAs($jack)->post("/api/v4/shared-folders", []);
$response->assertStatus(403);
// Test empty request
$response = $this->actingAs($john)->post("/api/v4/shared-folders", []);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertSame("The name field is required.", $json['errors']['name'][0]);
$this->assertSame("The type field is required.", $json['errors']['type'][0]);
$this->assertCount(2, $json);
$this->assertCount(2, $json['errors']);
// Test too long name, invalid alias domain
$post = [
'domain' => 'kolab.org',
'name' => str_repeat('A', 192),
'type' => 'unknown',
'aliases' => ['folder-alias@unknown.org'],
];
$response = $this->actingAs($john)->post("/api/v4/shared-folders", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json);
$this->assertSame(["The name may not be greater than 191 characters."], $json['errors']['name']);
$this->assertSame(["The specified type is invalid."], $json['errors']['type']);
$this->assertSame(["The specified domain is invalid."], $json['errors']['aliases']);
$this->assertCount(3, $json['errors']);
// Test successful folder creation
$post['name'] = 'Test Folder';
$post['type'] = 'event';
$post['aliases'] = [];
$response = $this->actingAs($john)->post("/api/v4/shared-folders", $post);
$json = $response->json();
$response->assertStatus(200);
$this->assertSame('success', $json['status']);
$this->assertSame("Shared folder created successfully.", $json['message']);
$this->assertCount(2, $json);
$folder = SharedFolder::where('name', $post['name'])->first();
$this->assertInstanceOf(SharedFolder::class, $folder);
$this->assertSame($post['type'], $folder->type);
$this->assertTrue($john->sharedFolders()->get()->contains($folder));
$this->assertSame([], $folder->aliases()->pluck('alias')->all());
// Shared folder name must be unique within a domain
$post['type'] = 'mail';
$response = $this->actingAs($john)->post("/api/v4/shared-folders", $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]);
$folder->forceDelete();
// Test successful folder creation with aliases
$post['name'] = 'Test Folder';
$post['type'] = 'mail';
$post['aliases'] = ['folder-alias@kolab.org'];
$response = $this->actingAs($john)->post("/api/v4/shared-folders", $post);
$json = $response->json();
$response->assertStatus(200);
$folder = SharedFolder::where('name', $post['name'])->first();
$this->assertSame(['folder-alias@kolab.org'], $folder->aliases()->pluck('alias')->all());
$folder->forceDelete();
// Test handling subfolders and lmtp alias email
$post['name'] = 'Test/Folder';
$post['type'] = 'mail';
$post['aliases'] = ['shared+shared/Test/Folder@kolab.org'];
$response = $this->actingAs($john)->post("/api/v4/shared-folders", $post);
$json = $response->json();
$response->assertStatus(200);
$folder = SharedFolder::where('name', $post['name'])->first();
$this->assertSame(['shared+shared/Test/Folder@kolab.org'], $folder->aliases()->pluck('alias')->all());
}
/**
* Test shared folder update (PUT /api/v4/shared-folders/<folder)
*/
public function testUpdate(): void
{
Queue::fake();
$jack = $this->getTestUser('jack@kolab.org');
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$folder = $this->getTestSharedFolder('folder-test@kolab.org');
$folder->assignToWallet($john->wallets->first());
// Test unauthorized update
$response = $this->get("/api/v4/shared-folders/{$folder->id}", []);
$response->assertStatus(401);
// Test unauthorized update
$response = $this->actingAs($jack)->get("/api/v4/shared-folders/{$folder->id}", []);
$response->assertStatus(403);
// Name change
$post = [
'name' => 'Test Res',
];
$response = $this->actingAs($john)->put("/api/v4/shared-folders/{$folder->id}", $post);
$json = $response->json();
$response->assertStatus(200);
$this->assertSame('success', $json['status']);
$this->assertSame("Shared folder updated successfully.", $json['message']);
$this->assertCount(2, $json);
$folder->refresh();
$this->assertSame($post['name'], $folder->name);
// Aliases with error
$post['aliases'] = ['folder-alias1@kolab.org', 'folder-alias2@unknown.com'];
$response = $this->actingAs($john)->put("/api/v4/shared-folders/{$folder->id}", $post);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json);
$this->assertCount(1, $json['errors']);
$this->assertCount(1, $json['errors']['aliases']);
$this->assertSame("The specified domain is invalid.", $json['errors']['aliases'][1]);
$this->assertSame([], $folder->aliases()->pluck('alias')->all());
// Aliases with success expected
$post['aliases'] = ['folder-alias1@kolab.org', 'folder-alias2@kolab.org'];
$response = $this->actingAs($john)->put("/api/v4/shared-folders/{$folder->id}", $post);
$json = $response->json();
$response->assertStatus(200);
$this->assertSame('success', $json['status']);
$this->assertSame("Shared folder updated successfully.", $json['message']);
$this->assertCount(2, $json);
$this->assertSame($post['aliases'], $folder->aliases()->pluck('alias')->all());
// All aliases removal
$post['aliases'] = [];
$response = $this->actingAs($john)->put("/api/v4/shared-folders/{$folder->id}", $post);
$response->assertStatus(200);
$this->assertSame($post['aliases'], $folder->aliases()->pluck('alias')->all());
}
}
diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php
index 9e306d4b..b5c85e45 100644
--- a/src/tests/Feature/Controller/UsersTest.php
+++ b/src/tests/Feature/Controller/UsersTest.php
@@ -1,1669 +1,1669 @@
<?php
namespace Tests\Feature\Controller;
use App\Discount;
use App\Domain;
use App\Http\Controllers\API\V4\UsersController;
use App\Package;
use App\Sku;
use App\Tenant;
use App\User;
use App\Wallet;
use Carbon\Carbon;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
use Tests\TestCase;
class UsersTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->clearBetaEntitlements();
$this->deleteTestUser('jane@kolabnow.com');
$this->deleteTestUser('UsersControllerTest1@userscontroller.com');
$this->deleteTestUser('UsersControllerTest2@userscontroller.com');
$this->deleteTestUser('UsersControllerTest3@userscontroller.com');
$this->deleteTestUser('UserEntitlement2A@UserEntitlement.com');
$this->deleteTestUser('john2.doe2@kolab.org');
$this->deleteTestUser('deleted@kolab.org');
$this->deleteTestUser('deleted@kolabnow.com');
$this->deleteTestDomain('userscontroller.com');
$this->deleteTestGroup('group-test@kolabnow.com');
$this->deleteTestGroup('group-test@kolab.org');
$this->deleteTestSharedFolder('folder-test@kolabnow.com');
$this->deleteTestResource('resource-test@kolabnow.com');
Sku::where('title', 'test')->delete();
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
$wallet->discount()->dissociate();
$wallet->settings()->whereIn('key', ['mollie_id', 'stripe_id'])->delete();
$wallet->save();
$user->settings()->whereIn('key', ['greylist_enabled', 'guam_enabled'])->delete();
$user->status |= User::STATUS_IMAP_READY;
$user->save();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->clearBetaEntitlements();
$this->deleteTestUser('jane@kolabnow.com');
$this->deleteTestUser('UsersControllerTest1@userscontroller.com');
$this->deleteTestUser('UsersControllerTest2@userscontroller.com');
$this->deleteTestUser('UsersControllerTest3@userscontroller.com');
$this->deleteTestUser('UserEntitlement2A@UserEntitlement.com');
$this->deleteTestUser('john2.doe2@kolab.org');
$this->deleteTestUser('deleted@kolab.org');
$this->deleteTestUser('deleted@kolabnow.com');
$this->deleteTestDomain('userscontroller.com');
$this->deleteTestGroup('group-test@kolabnow.com');
$this->deleteTestGroup('group-test@kolab.org');
$this->deleteTestSharedFolder('folder-test@kolabnow.com');
$this->deleteTestResource('resource-test@kolabnow.com');
Sku::where('title', 'test')->delete();
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
$wallet->discount()->dissociate();
$wallet->settings()->whereIn('key', ['mollie_id', 'stripe_id'])->delete();
$wallet->save();
$user->settings()->whereIn('key', ['greylist_enabled', 'guam_enabled'])->delete();
$user->status |= User::STATUS_IMAP_READY;
$user->save();
parent::tearDown();
}
/**
* Test user deleting (DELETE /api/v4/users/<id>)
*/
public function testDestroy(): void
{
// First create some users/accounts to delete
$package_kolab = \App\Package::where('title', 'kolab')->first();
$package_domain = \App\Package::where('title', 'domain-hosting')->first();
$john = $this->getTestUser('john@kolab.org');
$user1 = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$user2 = $this->getTestUser('UsersControllerTest2@userscontroller.com');
$user3 = $this->getTestUser('UsersControllerTest3@userscontroller.com');
$domain = $this->getTestDomain('userscontroller.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_PUBLIC,
]);
$user1->assignPackage($package_kolab);
$domain->assignPackage($package_domain, $user1);
$user1->assignPackage($package_kolab, $user2);
$user1->assignPackage($package_kolab, $user3);
// Test unauth access
$response = $this->delete("api/v4/users/{$user2->id}");
$response->assertStatus(401);
// Test access to other user/account
$response = $this->actingAs($john)->delete("api/v4/users/{$user2->id}");
$response->assertStatus(403);
$response = $this->actingAs($john)->delete("api/v4/users/{$user1->id}");
$response->assertStatus(403);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertSame("Access denied", $json['message']);
$this->assertCount(2, $json);
// Test that non-controller cannot remove himself
$response = $this->actingAs($user3)->delete("api/v4/users/{$user3->id}");
$response->assertStatus(403);
// Test removing a non-controller user
$response = $this->actingAs($user1)->delete("api/v4/users/{$user3->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertEquals('success', $json['status']);
$this->assertEquals('User deleted successfully.', $json['message']);
// Test removing self (an account with users)
$response = $this->actingAs($user1)->delete("api/v4/users/{$user1->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertEquals('success', $json['status']);
$this->assertEquals('User deleted successfully.', $json['message']);
}
/**
* Test user deleting (DELETE /api/v4/users/<id>)
*/
public function testDestroyByController(): void
{
// Create an account with additional controller - $user2
$package_kolab = \App\Package::where('title', 'kolab')->first();
$package_domain = \App\Package::where('title', 'domain-hosting')->first();
$user1 = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$user2 = $this->getTestUser('UsersControllerTest2@userscontroller.com');
$user3 = $this->getTestUser('UsersControllerTest3@userscontroller.com');
$domain = $this->getTestDomain('userscontroller.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_PUBLIC,
]);
$user1->assignPackage($package_kolab);
$domain->assignPackage($package_domain, $user1);
$user1->assignPackage($package_kolab, $user2);
$user1->assignPackage($package_kolab, $user3);
$user1->wallets()->first()->addController($user2);
// TODO/FIXME:
// For now controller can delete himself, as well as
// the whole account he has control to, including the owner
// Probably he should not be able to do none of those
// However, this is not 0-regression scenario as we
// do not fully support additional controllers.
//$response = $this->actingAs($user2)->delete("api/v4/users/{$user2->id}");
//$response->assertStatus(403);
$response = $this->actingAs($user2)->delete("api/v4/users/{$user3->id}");
$response->assertStatus(200);
$response = $this->actingAs($user2)->delete("api/v4/users/{$user1->id}");
$response->assertStatus(200);
// Note: More detailed assertions in testDestroy() above
$this->assertTrue($user1->fresh()->trashed());
$this->assertTrue($user2->fresh()->trashed());
$this->assertTrue($user3->fresh()->trashed());
}
/**
* Test user listing (GET /api/v4/users)
*/
public function testIndex(): void
{
// Test unauth access
$response = $this->get("api/v4/users");
$response->assertStatus(401);
$jack = $this->getTestUser('jack@kolab.org');
$joe = $this->getTestUser('joe@kolab.org');
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$response = $this->actingAs($jack)->get("/api/v4/users");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(false, $json['hasMore']);
$this->assertSame(0, $json['count']);
$this->assertCount(0, $json['list']);
$response = $this->actingAs($john)->get("/api/v4/users");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(false, $json['hasMore']);
$this->assertSame(4, $json['count']);
$this->assertCount(4, $json['list']);
$this->assertSame($jack->email, $json['list'][0]['email']);
$this->assertSame($joe->email, $json['list'][1]['email']);
$this->assertSame($john->email, $json['list'][2]['email']);
$this->assertSame($ned->email, $json['list'][3]['email']);
// Values below are tested by Unit tests
$this->assertArrayHasKey('isDeleted', $json['list'][0]);
$this->assertArrayHasKey('isDegraded', $json['list'][0]);
$this->assertArrayHasKey('isAccountDegraded', $json['list'][0]);
$this->assertArrayHasKey('isSuspended', $json['list'][0]);
$this->assertArrayHasKey('isActive', $json['list'][0]);
$this->assertArrayHasKey('isReady', $json['list'][0]);
if (\config('app.with_ldap')) {
$this->assertArrayHasKey('isLdapReady', $json['list'][0]);
}
if (\config('app.with_imap')) {
$this->assertArrayHasKey('isImapReady', $json['list'][0]);
}
$response = $this->actingAs($ned)->get("/api/v4/users");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(false, $json['hasMore']);
$this->assertSame(4, $json['count']);
$this->assertCount(4, $json['list']);
$this->assertSame($jack->email, $json['list'][0]['email']);
$this->assertSame($joe->email, $json['list'][1]['email']);
$this->assertSame($john->email, $json['list'][2]['email']);
$this->assertSame($ned->email, $json['list'][3]['email']);
// Search by user email
$response = $this->actingAs($john)->get("/api/v4/users?search=jack@k");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(false, $json['hasMore']);
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($jack->email, $json['list'][0]['email']);
// Search by alias
$response = $this->actingAs($john)->get("/api/v4/users?search=monster");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(false, $json['hasMore']);
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($joe->email, $json['list'][0]['email']);
// Search by name
$response = $this->actingAs($john)->get("/api/v4/users?search=land");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(false, $json['hasMore']);
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($ned->email, $json['list'][0]['email']);
// TODO: Test paging
}
/**
* Test fetching user data/profile (GET /api/v4/users/<user-id>)
*/
public function testShow(): void
{
$userA = $this->getTestUser('UserEntitlement2A@UserEntitlement.com');
// Test getting profile of self
$response = $this->actingAs($userA)->get("/api/v4/users/{$userA->id}");
$json = $response->json();
$response->assertStatus(200);
$this->assertEquals($userA->id, $json['id']);
$this->assertEquals($userA->email, $json['email']);
$this->assertTrue(is_array($json['statusInfo']));
$this->assertTrue(is_array($json['settings']));
$this->assertTrue($json['config']['greylist_enabled']);
$this->assertFalse($json['config']['guam_enabled']);
$this->assertSame([], $json['skus']);
$this->assertSame([], $json['aliases']);
// Values below are tested by Unit tests
$this->assertArrayHasKey('isDeleted', $json);
$this->assertArrayHasKey('isDegraded', $json);
$this->assertArrayHasKey('isAccountDegraded', $json);
$this->assertArrayHasKey('isSuspended', $json);
$this->assertArrayHasKey('isActive', $json);
$this->assertArrayHasKey('isReady', $json);
if (\config('app.with_ldap')) {
$this->assertArrayHasKey('isLdapReady', $json);
}
if (\config('app.with_imap')) {
$this->assertArrayHasKey('isImapReady', $json);
}
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
// Test unauthorized access to a profile of other user
$response = $this->actingAs($jack)->get("/api/v4/users/{$userA->id}");
$response->assertStatus(403);
// Test authorized access to a profile of other user
// Ned: Additional account controller
$response = $this->actingAs($ned)->get("/api/v4/users/{$john->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(['john.doe@kolab.org'], $json['aliases']);
$response = $this->actingAs($ned)->get("/api/v4/users/{$jack->id}");
$response->assertStatus(200);
// John: Account owner
$response = $this->actingAs($john)->get("/api/v4/users/{$jack->id}");
$response->assertStatus(200);
$response = $this->actingAs($john)->get("/api/v4/users/{$ned->id}");
$response->assertStatus(200);
$json = $response->json();
$storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first();
$groupware_sku = Sku::withEnvTenantContext()->where('title', 'groupware')->first();
$mailbox_sku = Sku::withEnvTenantContext()->where('title', 'mailbox')->first();
$secondfactor_sku = Sku::withEnvTenantContext()->where('title', '2fa')->first();
$this->assertCount(5, $json['skus']);
$this->assertSame(5, $json['skus'][$storage_sku->id]['count']);
$this->assertSame([0,0,0,0,0], $json['skus'][$storage_sku->id]['costs']);
$this->assertSame(1, $json['skus'][$groupware_sku->id]['count']);
$this->assertSame([490], $json['skus'][$groupware_sku->id]['costs']);
$this->assertSame(1, $json['skus'][$mailbox_sku->id]['count']);
$this->assertSame([500], $json['skus'][$mailbox_sku->id]['costs']);
$this->assertSame(1, $json['skus'][$secondfactor_sku->id]['count']);
$this->assertSame([0], $json['skus'][$secondfactor_sku->id]['costs']);
$this->assertSame([], $json['aliases']);
}
/**
* Test fetching SKUs list for a user (GET /users/<id>/skus)
*/
public function testSkus(): void
{
$user = $this->getTestUser('john@kolab.org');
// Unauth access not allowed
$response = $this->get("api/v4/users/{$user->id}/skus");
$response->assertStatus(401);
// Create an sku for another tenant, to make sure it is not included in the result
$nsku = Sku::create([
'title' => 'test',
'name' => 'Test',
'description' => '',
'active' => true,
'cost' => 100,
'handler_class' => 'Mailbox',
]);
$tenant = Tenant::whereNotIn('id', [\config('app.tenant_id')])->first();
$nsku->tenant_id = $tenant->id;
$nsku->save();
$response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(5, $json);
$this->assertSkuElement('mailbox', $json[0], [
'prio' => 100,
'type' => 'user',
'handler' => 'Mailbox',
'enabled' => true,
'readonly' => true,
]);
$this->assertSkuElement('storage', $json[1], [
'prio' => 90,
'type' => 'user',
'handler' => 'Storage',
'enabled' => true,
'readonly' => true,
'range' => [
'min' => 5,
'max' => 100,
'unit' => 'GB',
]
]);
$this->assertSkuElement('groupware', $json[2], [
'prio' => 80,
'type' => 'user',
'handler' => 'Groupware',
'enabled' => false,
'readonly' => false,
]);
$this->assertSkuElement('activesync', $json[3], [
'prio' => 70,
'type' => 'user',
'handler' => 'Activesync',
'enabled' => false,
'readonly' => false,
'required' => ['Groupware'],
]);
$this->assertSkuElement('2fa', $json[4], [
'prio' => 60,
'type' => 'user',
'handler' => 'Auth2F',
'enabled' => false,
'readonly' => false,
'forbidden' => ['Activesync'],
]);
// Test inclusion of beta SKUs
$sku = Sku::withEnvTenantContext()->where('title', 'beta')->first();
$user->assignSku($sku);
$response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(6, $json);
$this->assertSkuElement('beta', $json[5], [
'prio' => 10,
'type' => 'user',
'handler' => 'Beta',
'enabled' => false,
'readonly' => false,
]);
}
/**
* Test fetching user status (GET /api/v4/users/<user-id>/status)
* and forcing setup process update (?refresh=1)
*
* @group imap
* @group dns
*/
public function testStatus(): void
{
Queue::fake();
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
// Test unauthorized access
$response = $this->actingAs($jack)->get("/api/v4/users/{$john->id}/status");
$response->assertStatus(403);
$john->status &= ~User::STATUS_IMAP_READY;
$john->status &= ~User::STATUS_LDAP_READY;
$john->save();
// Get user status
$response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status");
$response->assertStatus(200);
$json = $response->json();
$this->assertFalse($json['isReady']);
if (\config('app.with_ldap')) {
$this->assertFalse($json['isLdapReady']);
} else {
$this->assertArrayNotHasKey('isLdapReady', $json);
}
if (\config('app.with_imap')) {
$this->assertFalse($json['isImapReady']);
$this->assertSame('user-imap-ready', $json['process'][2]['label']);
$this->assertFalse($json['process'][2]['state']);
} else {
$this->assertArrayNotHasKey('isImapReady', $json);
}
$this->assertTrue(empty($json['status']));
$this->assertTrue(empty($json['message']));
// Make sure the domain is confirmed (other test might unset that status)
$domain = $this->getTestDomain('kolab.org');
$domain->status |= Domain::STATUS_CONFIRMED;
$domain->save();
// Now "reboot" the process
Queue::fake();
$response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status?refresh=1");
$response->assertStatus(200);
$json = $response->json();
- $this->assertFalse($json['isImapReady']);
$this->assertFalse($json['isLdapReady']);
$this->assertFalse($json['isReady']);
if (\config('app.with_imap')) {
+ $this->assertFalse($json['isImapReady']);
$this->assertSame('user-imap-ready', $json['process'][2]['label']);
$this->assertSame(false, $json['process'][2]['state']);
}
$this->assertSame('success', $json['status']);
$this->assertSame('Setup process has been pushed. Please wait.', $json['message']);
Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1);
}
/**
* Test UsersController::statusInfo()
*/
public function testStatusInfo(): void
{
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$domain = $this->getTestDomain('userscontroller.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_PUBLIC,
]);
$user->created_at = Carbon::now();
$user->status = User::STATUS_NEW;
$user->save();
$result = UsersController::statusInfo($user);
$this->assertFalse($result['isReady']);
$this->assertSame([], $result['skus']);
if (\config('app.with_imap')) {
$this->assertCount(3, $result['process']);
} else {
$this->assertCount(2, $result['process']);
}
$this->assertSame('user-new', $result['process'][0]['label']);
$this->assertSame(true, $result['process'][0]['state']);
$this->assertSame('user-ldap-ready', $result['process'][1]['label']);
$this->assertSame(false, $result['process'][1]['state']);
if (\config('app.with_imap')) {
$this->assertSame('user-imap-ready', $result['process'][2]['label']);
$this->assertSame(false, $result['process'][2]['state']);
}
$this->assertSame('running', $result['processState']);
$this->assertTrue($result['enableRooms']);
$this->assertFalse($result['enableBeta']);
$user->created_at = Carbon::now()->subSeconds(181);
$user->save();
$result = UsersController::statusInfo($user);
$this->assertSame('failed', $result['processState']);
$user->status |= User::STATUS_LDAP_READY | User::STATUS_IMAP_READY;
$user->save();
$result = UsersController::statusInfo($user);
$this->assertTrue($result['isReady']);
if (\config('app.with_imap')) {
$this->assertCount(3, $result['process']);
} else {
$this->assertCount(2, $result['process']);
}
$this->assertSame('user-new', $result['process'][0]['label']);
$this->assertSame(true, $result['process'][0]['state']);
$this->assertSame('user-ldap-ready', $result['process'][1]['label']);
$this->assertSame(true, $result['process'][1]['state']);
if (\config('app.with_imap')) {
$this->assertSame('user-imap-ready', $result['process'][2]['label']);
$this->assertSame(true, $result['process'][2]['state']);
}
$this->assertSame('done', $result['processState']);
$domain->status |= Domain::STATUS_VERIFIED;
$domain->type = Domain::TYPE_EXTERNAL;
$domain->save();
$result = UsersController::statusInfo($user);
$this->assertFalse($result['isReady']);
$this->assertSame([], $result['skus']);
if (\config('app.with_imap')) {
$this->assertCount(7, $result['process']);
$this->assertSame('user-new', $result['process'][0]['label']);
$this->assertSame(true, $result['process'][0]['state']);
$this->assertSame('user-ldap-ready', $result['process'][1]['label']);
$this->assertSame(true, $result['process'][1]['state']);
$this->assertSame('user-imap-ready', $result['process'][2]['label']);
$this->assertSame(true, $result['process'][2]['state']);
$this->assertSame('domain-new', $result['process'][3]['label']);
$this->assertSame(true, $result['process'][3]['state']);
$this->assertSame('domain-ldap-ready', $result['process'][4]['label']);
$this->assertSame(false, $result['process'][4]['state']);
$this->assertSame('domain-verified', $result['process'][5]['label']);
$this->assertSame(true, $result['process'][5]['state']);
$this->assertSame('domain-confirmed', $result['process'][6]['label']);
$this->assertSame(false, $result['process'][6]['state']);
} else {
$this->assertCount(6, $result['process']);
$this->assertSame('user-new', $result['process'][0]['label']);
$this->assertSame(true, $result['process'][0]['state']);
$this->assertSame('user-ldap-ready', $result['process'][1]['label']);
$this->assertSame(true, $result['process'][1]['state']);
$this->assertSame('domain-new', $result['process'][2]['label']);
$this->assertSame(true, $result['process'][2]['state']);
$this->assertSame('domain-ldap-ready', $result['process'][3]['label']);
$this->assertSame(false, $result['process'][3]['state']);
$this->assertSame('domain-verified', $result['process'][4]['label']);
$this->assertSame(true, $result['process'][4]['state']);
$this->assertSame('domain-confirmed', $result['process'][5]['label']);
$this->assertSame(false, $result['process'][5]['state']);
}
// Test 'skus' property
$user->assignSku(Sku::withEnvTenantContext()->where('title', 'beta')->first());
$result = UsersController::statusInfo($user);
$this->assertSame(['beta'], $result['skus']);
$this->assertTrue($result['enableBeta']);
$user->assignSku(Sku::withEnvTenantContext()->where('title', 'groupware')->first());
$result = UsersController::statusInfo($user);
$this->assertSame(['beta', 'groupware'], $result['skus']);
// Degraded user
$user->status |= User::STATUS_DEGRADED;
$user->save();
$result = UsersController::statusInfo($user);
$this->assertTrue($result['enableBeta']);
$this->assertFalse($result['enableRooms']);
// User in a tenant without 'room' SKU
$user->status = User::STATUS_LDAP_READY | User::STATUS_IMAP_READY | User::STATUS_ACTIVE;
$user->tenant_id = Tenant::where('title', 'Sample Tenant')->first()->id;
$user->save();
$result = UsersController::statusInfo($user);
$this->assertTrue($result['enableBeta']);
$this->assertFalse($result['enableRooms']);
}
/**
* Test user config update (POST /api/v4/users/<user>/config)
*/
public function testSetConfig(): void
{
$jack = $this->getTestUser('jack@kolab.org');
$john = $this->getTestUser('john@kolab.org');
$john->setSetting('greylist_enabled', null);
$john->setSetting('guam_enabled', null);
$john->setSetting('password_policy', null);
$john->setSetting('max_password_age', null);
// Test unknown user id
$post = ['greylist_enabled' => 1];
$response = $this->actingAs($john)->post("/api/v4/users/123/config", $post);
$json = $response->json();
$response->assertStatus(404);
// Test access by user not being a wallet controller
$post = ['greylist_enabled' => 1];
$response = $this->actingAs($jack)->post("/api/v4/users/{$john->id}/config", $post);
$json = $response->json();
$response->assertStatus(403);
$this->assertSame('error', $json['status']);
$this->assertSame("Access denied", $json['message']);
$this->assertCount(2, $json);
// Test some invalid data
$post = ['grey' => 1, 'password_policy' => 'min:1,max:255'];
$response = $this->actingAs($john)->post("/api/v4/users/{$john->id}/config", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json);
$this->assertCount(2, $json['errors']);
$this->assertSame("The requested configuration parameter is not supported.", $json['errors']['grey']);
$this->assertSame("Minimum password length cannot be less than 6.", $json['errors']['password_policy']);
$this->assertNull($john->fresh()->getSetting('greylist_enabled'));
// Test some valid data
$post = [
'greylist_enabled' => 1,
'guam_enabled' => 1,
'password_policy' => 'min:10,max:255,upper,lower,digit,special',
'max_password_age' => 6,
];
$response = $this->actingAs($john)->post("/api/v4/users/{$john->id}/config", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(2, $json);
$this->assertSame('success', $json['status']);
$this->assertSame('User settings updated successfully.', $json['message']);
$this->assertSame('true', $john->getSetting('greylist_enabled'));
$this->assertSame('true', $john->getSetting('guam_enabled'));
$this->assertSame('min:10,max:255,upper,lower,digit,special', $john->getSetting('password_policy'));
$this->assertSame('6', $john->getSetting('max_password_age'));
// Test some valid data, acting as another account controller
$ned = $this->getTestUser('ned@kolab.org');
$post = ['greylist_enabled' => 0, 'guam_enabled' => 0, 'password_policy' => 'min:10,max:255,upper,last:1'];
$response = $this->actingAs($ned)->post("/api/v4/users/{$john->id}/config", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(2, $json);
$this->assertSame('success', $json['status']);
$this->assertSame('User settings updated successfully.', $json['message']);
$this->assertSame('false', $john->fresh()->getSetting('greylist_enabled'));
$this->assertSame(null, $john->fresh()->getSetting('guam_enabled'));
$this->assertSame('min:10,max:255,upper,last:1', $john->fresh()->getSetting('password_policy'));
}
/**
* Test user creation (POST /api/v4/users)
*/
public function testStore(): void
{
Queue::fake();
$jack = $this->getTestUser('jack@kolab.org');
$john = $this->getTestUser('john@kolab.org');
$john->setSetting('password_policy', 'min:8,max:100,digit');
$deleted_priv = $this->getTestUser('deleted@kolab.org');
$deleted_priv->delete();
// Test empty request
$response = $this->actingAs($john)->post("/api/v4/users", []);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertSame("The email field is required.", $json['errors']['email']);
$this->assertSame("The password field is required.", $json['errors']['password'][0]);
$this->assertCount(2, $json);
// Test access by user not being a wallet controller
$post = ['first_name' => 'Test'];
$response = $this->actingAs($jack)->post("/api/v4/users", $post);
$json = $response->json();
$response->assertStatus(403);
$this->assertSame('error', $json['status']);
$this->assertSame("Access denied", $json['message']);
$this->assertCount(2, $json);
// Test some invalid data
$post = ['password' => '12345678', 'email' => 'invalid'];
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json);
$this->assertSame('The password confirmation does not match.', $json['errors']['password'][0]);
$this->assertSame('The specified email is invalid.', $json['errors']['email']);
// Test existing user email
$post = [
'password' => 'simple123',
'password_confirmation' => 'simple123',
'first_name' => 'John2',
'last_name' => 'Doe2',
'email' => 'jack.daniels@kolab.org',
];
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json);
$this->assertSame('The specified email is not available.', $json['errors']['email']);
$package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first();
$package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$post = [
'password' => 'simple123',
'password_confirmation' => 'simple123',
'first_name' => 'John2',
'last_name' => 'Doe2',
'email' => 'john2.doe2@kolab.org',
'organization' => 'TestOrg',
'aliases' => ['useralias1@kolab.org', 'deleted@kolab.org'],
];
// Missing package
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertSame("Package is required.", $json['errors']['package']);
$this->assertCount(2, $json);
// Invalid package
$post['package'] = $package_domain->id;
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertSame("Invalid package selected.", $json['errors']['package']);
$this->assertCount(2, $json);
// Test password policy checking
$post['package'] = $package_kolab->id;
$post['password'] = 'password';
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertSame("The password confirmation does not match.", $json['errors']['password'][0]);
$this->assertSame("Specified password does not comply with the policy.", $json['errors']['password'][1]);
$this->assertCount(2, $json);
// Test password confirmation
$post['password_confirmation'] = 'password';
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertSame("Specified password does not comply with the policy.", $json['errors']['password'][0]);
$this->assertCount(2, $json);
// Test full and valid data
$post['password'] = 'password123';
$post['password_confirmation'] = 'password123';
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$json = $response->json();
$response->assertStatus(200);
$this->assertSame('success', $json['status']);
$this->assertSame("User created successfully.", $json['message']);
$this->assertCount(2, $json);
$user = User::where('email', 'john2.doe2@kolab.org')->first();
$this->assertInstanceOf(User::class, $user);
$this->assertSame('John2', $user->getSetting('first_name'));
$this->assertSame('Doe2', $user->getSetting('last_name'));
$this->assertSame('TestOrg', $user->getSetting('organization'));
/** @var \App\UserAlias[] $aliases */
$aliases = $user->aliases()->orderBy('alias')->get();
$this->assertCount(2, $aliases);
$this->assertSame('deleted@kolab.org', $aliases[0]->alias);
$this->assertSame('useralias1@kolab.org', $aliases[1]->alias);
// Assert the new user entitlements
$this->assertEntitlements($user, ['groupware', 'mailbox',
'storage', 'storage', 'storage', 'storage', 'storage']);
// Assert the wallet to which the new user should be assigned to
$wallet = $user->wallet();
$this->assertSame($john->wallets->first()->id, $wallet->id);
// Attempt to create a user previously deleted
$user->delete();
$post['package'] = $package_kolab->id;
$post['aliases'] = [];
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$json = $response->json();
$response->assertStatus(200);
$this->assertSame('success', $json['status']);
$this->assertSame("User created successfully.", $json['message']);
$this->assertCount(2, $json);
$user = User::where('email', 'john2.doe2@kolab.org')->first();
$this->assertInstanceOf(User::class, $user);
$this->assertSame('John2', $user->getSetting('first_name'));
$this->assertSame('Doe2', $user->getSetting('last_name'));
$this->assertSame('TestOrg', $user->getSetting('organization'));
$this->assertCount(0, $user->aliases()->get());
$this->assertEntitlements($user, ['groupware', 'mailbox',
'storage', 'storage', 'storage', 'storage', 'storage']);
// Test password reset link "mode"
$code = new \App\VerificationCode(['mode' => 'password-reset', 'active' => false]);
$john->verificationcodes()->save($code);
$post = [
'first_name' => 'John2',
'last_name' => 'Doe2',
'email' => 'deleted@kolab.org',
'organization' => '',
'aliases' => [],
'passwordLinkCode' => $code->short_code . '-' . $code->code,
'package' => $package_kolab->id,
];
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$json = $response->json();
$response->assertStatus(200);
$this->assertSame('success', $json['status']);
$this->assertSame("User created successfully.", $json['message']);
$this->assertCount(2, $json);
$user = $this->getTestUser('deleted@kolab.org');
$code->refresh();
$this->assertSame($user->id, $code->user_id);
$this->assertTrue($code->active);
$this->assertTrue(is_string($user->password) && strlen($user->password) >= 60);
// Test acting as account controller not owner, which is not yet supported
$john->wallets->first()->addController($user);
$response = $this->actingAs($user)->post("/api/v4/users", []);
$response->assertStatus(403);
}
/**
* Test user update (PUT /api/v4/users/<user-id>)
*/
public function testUpdate(): void
{
$userA = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$userA->setSetting('password_policy', 'min:8,digit');
$jack = $this->getTestUser('jack@kolab.org');
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$domain = $this->getTestDomain(
'userscontroller.com',
['status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL]
);
// Test unauthorized update of other user profile
$response = $this->actingAs($jack)->get("/api/v4/users/{$userA->id}", []);
$response->assertStatus(403);
// Test authorized update of account owner by account controller
$response = $this->actingAs($ned)->get("/api/v4/users/{$john->id}", []);
$response->assertStatus(200);
// Test updating of self (empty request)
$response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", []);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame("User data updated successfully.", $json['message']);
$this->assertTrue(!empty($json['statusInfo']));
$this->assertCount(3, $json);
// Test some invalid data
$post = ['password' => '1234567', 'currency' => 'invalid'];
$response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json);
$this->assertSame("The password confirmation does not match.", $json['errors']['password'][0]);
$this->assertSame("Specified password does not comply with the policy.", $json['errors']['password'][1]);
$this->assertSame("The currency must be 3 characters.", $json['errors']['currency'][0]);
// Test full profile update including password
$post = [
'password' => 'simple123',
'password_confirmation' => 'simple123',
'first_name' => 'John2',
'last_name' => 'Doe2',
'organization' => 'TestOrg',
'phone' => '+123 123 123',
'external_email' => 'external@gmail.com',
'billing_address' => 'billing',
'country' => 'CH',
'currency' => 'CHF',
'aliases' => ['useralias1@' . \config('app.domain'), 'useralias2@' . \config('app.domain')]
];
$response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post);
$json = $response->json();
$response->assertStatus(200);
$this->assertSame('success', $json['status']);
$this->assertSame("User data updated successfully.", $json['message']);
$this->assertTrue(!empty($json['statusInfo']));
$this->assertCount(3, $json);
$this->assertTrue($userA->password != $userA->fresh()->password);
unset($post['password'], $post['password_confirmation'], $post['aliases']);
foreach ($post as $key => $value) {
$this->assertSame($value, $userA->getSetting($key));
}
$aliases = $userA->aliases()->orderBy('alias')->get();
$this->assertCount(2, $aliases);
$this->assertSame('useralias1@' . \config('app.domain'), $aliases[0]->alias);
$this->assertSame('useralias2@' . \config('app.domain'), $aliases[1]->alias);
// Test unsetting values
$post = [
'first_name' => '',
'last_name' => '',
'organization' => '',
'phone' => '',
'external_email' => '',
'billing_address' => '',
'country' => '',
'currency' => '',
'aliases' => ['useralias2@' . \config('app.domain')]
];
$response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post);
$json = $response->json();
$response->assertStatus(200);
$this->assertSame('success', $json['status']);
$this->assertSame("User data updated successfully.", $json['message']);
$this->assertTrue(!empty($json['statusInfo']));
$this->assertCount(3, $json);
unset($post['aliases']);
foreach ($post as $key => $value) {
$this->assertNull($userA->getSetting($key));
}
$aliases = $userA->aliases()->get();
$this->assertCount(1, $aliases);
$this->assertSame('useralias2@' . \config('app.domain'), $aliases[0]->alias);
// Test error on some invalid aliases missing password confirmation
$post = [
'password' => 'simple123',
'aliases' => [
'useralias2@' . \config('app.domain'),
'useralias1@kolab.org',
'@kolab.org',
]
];
$response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json['errors']);
$this->assertCount(2, $json['errors']['aliases']);
$this->assertSame("The specified domain is not available.", $json['errors']['aliases'][1]);
$this->assertSame("The specified alias is invalid.", $json['errors']['aliases'][2]);
$this->assertSame("The password confirmation does not match.", $json['errors']['password'][0]);
// Test authorized update of other user
$response = $this->actingAs($ned)->put("/api/v4/users/{$jack->id}", []);
$response->assertStatus(200);
$json = $response->json();
$this->assertTrue(empty($json['statusInfo']));
// TODO: Test error on aliases with invalid/non-existing/other-user's domain
// Create entitlements and additional user for following tests
$owner = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$user = $this->getTestUser('UsersControllerTest2@userscontroller.com');
$package_domain = Package::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$package_kolab = Package::withEnvTenantContext()->where('title', 'kolab')->first();
$package_lite = Package::withEnvTenantContext()->where('title', 'lite')->first();
$sku_mailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first();
$sku_storage = Sku::withEnvTenantContext()->where('title', 'storage')->first();
$sku_groupware = Sku::withEnvTenantContext()->where('title', 'groupware')->first();
$domain = $this->getTestDomain(
'userscontroller.com',
[
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_EXTERNAL,
]
);
$domain->assignPackage($package_domain, $owner);
$owner->assignPackage($package_kolab);
$owner->assignPackage($package_lite, $user);
// Non-controller cannot update his own entitlements
$post = ['skus' => []];
$response = $this->actingAs($user)->put("/api/v4/users/{$user->id}", $post);
$response->assertStatus(422);
// Test updating entitlements
$post = [
'skus' => [
$sku_mailbox->id => 1,
$sku_storage->id => 6,
$sku_groupware->id => 1,
],
];
$response = $this->actingAs($owner)->put("/api/v4/users/{$user->id}", $post);
$response->assertStatus(200);
$json = $response->json();
$storage_cost = $user->entitlements()
->where('sku_id', $sku_storage->id)
->orderBy('cost')
->pluck('cost')->all();
$this->assertEntitlements(
$user,
['groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage']
);
$this->assertSame([0, 0, 0, 0, 0, 25], $storage_cost);
$this->assertTrue(empty($json['statusInfo']));
// Test password reset link "mode"
$code = new \App\VerificationCode(['mode' => 'password-reset', 'active' => false]);
$owner->verificationcodes()->save($code);
$post = ['passwordLinkCode' => $code->short_code . '-' . $code->code];
$response = $this->actingAs($owner)->put("/api/v4/users/{$user->id}", $post);
$json = $response->json();
$response->assertStatus(200);
$code->refresh();
$this->assertSame($user->id, $code->user_id);
$this->assertTrue($code->active);
$this->assertSame($user->password, $user->fresh()->password);
}
/**
* Test UsersController::updateEntitlements()
*/
public function testUpdateEntitlements(): void
{
$jane = $this->getTestUser('jane@kolabnow.com');
$kolab = Package::withEnvTenantContext()->where('title', 'kolab')->first();
$storage = Sku::withEnvTenantContext()->where('title', 'storage')->first();
$activesync = Sku::withEnvTenantContext()->where('title', 'activesync')->first();
$groupware = Sku::withEnvTenantContext()->where('title', 'groupware')->first();
$mailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first();
// standard package, 1 mailbox, 1 groupware, 2 storage
$jane->assignPackage($kolab);
// add 2 storage, 1 activesync
$post = [
'skus' => [
$mailbox->id => 1,
$groupware->id => 1,
$storage->id => 7,
$activesync->id => 1
]
];
$response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post);
$response->assertStatus(200);
$this->assertEntitlements(
$jane,
[
'activesync',
'groupware',
'mailbox',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage'
]
);
// add 2 storage, remove 1 activesync
$post = [
'skus' => [
$mailbox->id => 1,
$groupware->id => 1,
$storage->id => 9,
$activesync->id => 0
]
];
$response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post);
$response->assertStatus(200);
$this->assertEntitlements(
$jane,
[
'groupware',
'mailbox',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage'
]
);
// add mailbox
$post = [
'skus' => [
$mailbox->id => 2,
$groupware->id => 1,
$storage->id => 9,
$activesync->id => 0
]
];
$response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post);
$response->assertStatus(500);
$this->assertEntitlements(
$jane,
[
'groupware',
'mailbox',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage'
]
);
// remove mailbox
$post = [
'skus' => [
$mailbox->id => 0,
$groupware->id => 1,
$storage->id => 9,
$activesync->id => 0
]
];
$response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post);
$response->assertStatus(500);
$this->assertEntitlements(
$jane,
[
'groupware',
'mailbox',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage'
]
);
// less than free storage
$post = [
'skus' => [
$mailbox->id => 1,
$groupware->id => 1,
$storage->id => 1,
$activesync->id => 0
]
];
$response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post);
$response->assertStatus(200);
$this->assertEntitlements(
$jane,
[
'groupware',
'mailbox',
'storage',
'storage',
'storage',
'storage',
'storage'
]
);
}
/**
* Test user data response used in show and info actions
*/
public function testUserResponse(): void
{
$provider = \config('services.payment_provider') ?: 'mollie';
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
$wallet->setSettings(['mollie_id' => null, 'stripe_id' => null]);
$result = $this->invokeMethod(new UsersController(), 'userResponse', [$user]);
$this->assertEquals($user->id, $result['id']);
$this->assertEquals($user->email, $result['email']);
$this->assertEquals($user->status, $result['status']);
$this->assertTrue(is_array($result['statusInfo']));
$this->assertTrue(is_array($result['settings']));
$this->assertSame('US', $result['settings']['country']);
$this->assertSame('USD', $result['settings']['currency']);
$this->assertTrue(is_array($result['accounts']));
$this->assertTrue(is_array($result['wallets']));
$this->assertCount(0, $result['accounts']);
$this->assertCount(1, $result['wallets']);
$this->assertSame($wallet->id, $result['wallet']['id']);
$this->assertArrayNotHasKey('discount', $result['wallet']);
$this->assertTrue($result['statusInfo']['enableDomains']);
$this->assertTrue($result['statusInfo']['enableWallets']);
$this->assertTrue($result['statusInfo']['enableUsers']);
$this->assertTrue($result['statusInfo']['enableSettings']);
// Ned is John's wallet controller
$ned = $this->getTestUser('ned@kolab.org');
$ned_wallet = $ned->wallets()->first();
$result = $this->invokeMethod(new UsersController(), 'userResponse', [$ned]);
$this->assertEquals($ned->id, $result['id']);
$this->assertEquals($ned->email, $result['email']);
$this->assertTrue(is_array($result['accounts']));
$this->assertTrue(is_array($result['wallets']));
$this->assertCount(1, $result['accounts']);
$this->assertCount(1, $result['wallets']);
$this->assertSame($wallet->id, $result['wallet']['id']);
$this->assertSame($wallet->id, $result['accounts'][0]['id']);
$this->assertSame($ned_wallet->id, $result['wallets'][0]['id']);
$this->assertSame($provider, $result['wallet']['provider']);
$this->assertSame($provider, $result['wallets'][0]['provider']);
$this->assertTrue($result['statusInfo']['enableDomains']);
$this->assertTrue($result['statusInfo']['enableWallets']);
$this->assertTrue($result['statusInfo']['enableUsers']);
$this->assertTrue($result['statusInfo']['enableSettings']);
// Test discount in a response
$discount = Discount::where('code', 'TEST')->first();
$wallet->discount()->associate($discount);
$wallet->save();
$mod_provider = $provider == 'mollie' ? 'stripe' : 'mollie';
$wallet->setSetting($mod_provider . '_id', 123);
$user->refresh();
$result = $this->invokeMethod(new UsersController(), 'userResponse', [$user]);
$this->assertEquals($user->id, $result['id']);
$this->assertSame($discount->id, $result['wallet']['discount_id']);
$this->assertSame($discount->discount, $result['wallet']['discount']);
$this->assertSame($discount->description, $result['wallet']['discount_description']);
$this->assertSame($mod_provider, $result['wallet']['provider']);
$this->assertSame($discount->id, $result['wallets'][0]['discount_id']);
$this->assertSame($discount->discount, $result['wallets'][0]['discount']);
$this->assertSame($discount->description, $result['wallets'][0]['discount_description']);
$this->assertSame($mod_provider, $result['wallets'][0]['provider']);
// Jack is not a John's wallet controller
$jack = $this->getTestUser('jack@kolab.org');
$result = $this->invokeMethod(new UsersController(), 'userResponse', [$jack]);
$this->assertFalse($result['statusInfo']['enableDomains']);
$this->assertFalse($result['statusInfo']['enableWallets']);
$this->assertFalse($result['statusInfo']['enableUsers']);
$this->assertFalse($result['statusInfo']['enableSettings']);
}
/**
* User email address validation.
*
* Note: Technically these include unit tests, but let's keep it here for now.
* FIXME: Shall we do a http request for each case?
*/
public function testValidateEmail(): void
{
Queue::fake();
$public_domains = Domain::getPublicDomains();
$domain = reset($public_domains);
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$folder = $this->getTestSharedFolder('folder-event@kolab.org');
$folder->setAliases(['folder-alias1@kolab.org']);
$folder_del = $this->getTestSharedFolder('folder-test@kolabnow.com');
$folder_del->setAliases(['folder-alias2@kolabnow.com']);
$folder_del->delete();
$pub_group = $this->getTestGroup('group-test@kolabnow.com');
$pub_group->delete();
$priv_group = $this->getTestGroup('group-test@kolab.org');
$resource = $this->getTestResource('resource-test@kolabnow.com');
$resource->delete();
$cases = [
// valid (user domain)
["admin@kolab.org", $john, null],
// valid (public domain)
["test.test@$domain", $john, null],
// Invalid format
["$domain", $john, 'The specified email is invalid.'],
[".@$domain", $john, 'The specified email is invalid.'],
["test123456@localhost", $john, 'The specified domain is invalid.'],
["test123456@unknown-domain.org", $john, 'The specified domain is invalid.'],
["$domain", $john, 'The specified email is invalid.'],
[".@$domain", $john, 'The specified email is invalid.'],
// forbidden local part on public domains
["admin@$domain", $john, 'The specified email is not available.'],
["administrator@$domain", $john, 'The specified email is not available.'],
// forbidden (other user's domain)
["testtest@kolab.org", $user, 'The specified domain is not available.'],
// existing alias of other user
["jack.daniels@kolab.org", $john, 'The specified email is not available.'],
// An existing shared folder or folder alias
["folder-event@kolab.org", $john, 'The specified email is not available.'],
["folder-alias1@kolab.org", $john, 'The specified email is not available.'],
// A soft-deleted shared folder or folder alias
["folder-test@kolabnow.com", $john, 'The specified email is not available.'],
["folder-alias2@kolabnow.com", $john, 'The specified email is not available.'],
// A group
["group-test@kolab.org", $john, 'The specified email is not available.'],
// A soft-deleted group
["group-test@kolabnow.com", $john, 'The specified email is not available.'],
// A resource
["resource-test1@kolab.org", $john, 'The specified email is not available.'],
// A soft-deleted resource
["resource-test@kolabnow.com", $john, 'The specified email is not available.'],
];
foreach ($cases as $idx => $case) {
list($email, $user, $expected) = $case;
$deleted = null;
$result = UsersController::validateEmail($email, $user, $deleted);
$this->assertSame($expected, $result, "Case {$email}");
$this->assertNull($deleted, "Case {$email}");
}
}
/**
* User email validation - tests for $deleted argument
*
* Note: Technically these include unit tests, but let's keep it here for now.
* FIXME: Shall we do a http request for each case?
*/
public function testValidateEmailDeleted(): void
{
Queue::fake();
$john = $this->getTestUser('john@kolab.org');
$deleted_priv = $this->getTestUser('deleted@kolab.org');
$deleted_priv->delete();
$deleted_pub = $this->getTestUser('deleted@kolabnow.com');
$deleted_pub->delete();
$result = UsersController::validateEmail('deleted@kolab.org', $john, $deleted);
$this->assertSame(null, $result);
$this->assertSame($deleted_priv->id, $deleted->id);
$result = UsersController::validateEmail('deleted@kolabnow.com', $john, $deleted);
$this->assertSame('The specified email is not available.', $result);
$this->assertSame(null, $deleted);
$result = UsersController::validateEmail('jack@kolab.org', $john, $deleted);
$this->assertSame('The specified email is not available.', $result);
$this->assertSame(null, $deleted);
$pub_group = $this->getTestGroup('group-test@kolabnow.com');
$priv_group = $this->getTestGroup('group-test@kolab.org');
// A group in a public domain, existing
$result = UsersController::validateEmail($pub_group->email, $john, $deleted);
$this->assertSame('The specified email is not available.', $result);
$this->assertNull($deleted);
$pub_group->delete();
// A group in a public domain, deleted
$result = UsersController::validateEmail($pub_group->email, $john, $deleted);
$this->assertSame('The specified email is not available.', $result);
$this->assertNull($deleted);
// A group in a private domain, existing
$result = UsersController::validateEmail($priv_group->email, $john, $deleted);
$this->assertSame('The specified email is not available.', $result);
$this->assertNull($deleted);
$priv_group->delete();
// A group in a private domain, deleted
$result = UsersController::validateEmail($priv_group->email, $john, $deleted);
$this->assertSame(null, $result);
$this->assertSame($priv_group->id, $deleted->id);
// TODO: Test the same with a resource and shared folder
}
/**
* User email alias validation.
*
* Note: Technically these include unit tests, but let's keep it here for now.
* FIXME: Shall we do a http request for each case?
*/
public function testValidateAlias(): void
{
Queue::fake();
$public_domains = Domain::getPublicDomains();
$domain = reset($public_domains);
$john = $this->getTestUser('john@kolab.org');
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$deleted_priv = $this->getTestUser('deleted@kolab.org');
$deleted_priv->setAliases(['deleted-alias@kolab.org']);
$deleted_priv->delete();
$deleted_pub = $this->getTestUser('deleted@kolabnow.com');
$deleted_pub->setAliases(['deleted-alias@kolabnow.com']);
$deleted_pub->delete();
$folder = $this->getTestSharedFolder('folder-event@kolab.org');
$folder->setAliases(['folder-alias1@kolab.org']);
$folder_del = $this->getTestSharedFolder('folder-test@kolabnow.com');
$folder_del->setAliases(['folder-alias2@kolabnow.com']);
$folder_del->delete();
$group_priv = $this->getTestGroup('group-test@kolab.org');
$group = $this->getTestGroup('group-test@kolabnow.com');
$group->delete();
$resource = $this->getTestResource('resource-test@kolabnow.com');
$resource->delete();
$cases = [
// Invalid format
["$domain", $john, 'The specified alias is invalid.'],
[".@$domain", $john, 'The specified alias is invalid.'],
["test123456@localhost", $john, 'The specified domain is invalid.'],
["test123456@unknown-domain.org", $john, 'The specified domain is invalid.'],
["$domain", $john, 'The specified alias is invalid.'],
[".@$domain", $john, 'The specified alias is invalid.'],
// forbidden local part on public domains
["admin@$domain", $john, 'The specified alias is not available.'],
["administrator@$domain", $john, 'The specified alias is not available.'],
// forbidden (other user's domain)
["testtest@kolab.org", $user, 'The specified domain is not available.'],
// existing alias of other user, to be an alias, user in the same group account
["jack.daniels@kolab.org", $john, null],
// existing user
["jack@kolab.org", $john, 'The specified alias is not available.'],
// valid (user domain)
["admin@kolab.org", $john, null],
// valid (public domain)
["test.test@$domain", $john, null],
// An alias that was a user email before is allowed, but only for custom domains
["deleted@kolab.org", $john, null],
["deleted-alias@kolab.org", $john, null],
["deleted@kolabnow.com", $john, 'The specified alias is not available.'],
["deleted-alias@kolabnow.com", $john, 'The specified alias is not available.'],
// An existing shared folder or folder alias
["folder-event@kolab.org", $john, 'The specified alias is not available.'],
["folder-alias1@kolab.org", $john, null],
// A soft-deleted shared folder or folder alias
["folder-test@kolabnow.com", $john, 'The specified alias is not available.'],
["folder-alias2@kolabnow.com", $john, 'The specified alias is not available.'],
// A group with the same email address exists
["group-test@kolab.org", $john, 'The specified alias is not available.'],
// A soft-deleted group
["group-test@kolabnow.com", $john, 'The specified alias is not available.'],
// A resource
["resource-test1@kolab.org", $john, 'The specified alias is not available.'],
// A soft-deleted resource
["resource-test@kolabnow.com", $john, 'The specified alias is not available.'],
];
foreach ($cases as $idx => $case) {
list($alias, $user, $expected) = $case;
$result = UsersController::validateAlias($alias, $user);
$this->assertSame($expected, $result, "Case {$alias}");
}
}
}
diff --git a/src/tests/Feature/Jobs/Group/UpdateTest.php b/src/tests/Feature/Jobs/Group/UpdateTest.php
index 14145bba..a3916559 100644
--- a/src/tests/Feature/Jobs/Group/UpdateTest.php
+++ b/src/tests/Feature/Jobs/Group/UpdateTest.php
@@ -1,80 +1,81 @@
<?php
namespace Tests\Feature\Jobs\Group;
use App\Backends\LDAP;
use App\Group;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class UpdateTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestGroup('group@kolab.org');
}
public function tearDown(): void
{
$this->deleteTestGroup('group@kolab.org');
parent::tearDown();
}
/**
* Test job handle
*
* @group ldap
*/
public function testHandle(): void
{
Queue::fake();
// Test non-existing group ID
$job = new \App\Jobs\Group\UpdateJob(123);
$job->handle();
$this->assertTrue($job->hasFailed());
$this->assertSame("Group 123 could not be found in the database.", $job->failureMessage);
// Create the group
$group = $this->getTestGroup('group@kolab.org', ['members' => []]);
LDAP::createGroup($group);
// Test if group properties (members) actually changed in LDAP
$group->members = ['test1@gmail.com'];
$group->status |= Group::STATUS_LDAP_READY;
$group->save();
$job = new \App\Jobs\Group\UpdateJob($group->id);
$job->handle();
$ldapGroup = LDAP::getGroup($group->email);
$root_dn = \config('ldap.hosted.root_dn');
$this->assertSame('uid=test1@gmail.com,ou=People,ou=kolab.org,' . $root_dn, $ldapGroup['uniquemember']);
// Test that suspended group is removed from LDAP
$group->suspend();
$job = new \App\Jobs\Group\UpdateJob($group->id);
$job->handle();
$this->assertNull(LDAP::getGroup($group->email));
// Test that unsuspended group is added back to LDAP
$group->unsuspend();
$job = new \App\Jobs\Group\UpdateJob($group->id);
$job->handle();
+ /** @var array */
$ldapGroup = LDAP::getGroup($group->email);
$this->assertSame($group->email, $ldapGroup['mail']);
$this->assertSame('uid=test1@gmail.com,ou=People,ou=kolab.org,' . $root_dn, $ldapGroup['uniquemember']);
}
}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sun, Jun 29, 3:49 PM (1 d, 14 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
201480
Default Alt Text
(215 KB)
Attached To
Mode
R2 kolab
Attached
Detach File
Event Timeline
Log In to Comment