Page MenuHomePhorge

No OneTemporary

Authored By
Unknown
Size
632 KB
Referenced Files
None
Subscribers
None
This file is larger than 256 KB, so syntax highlighting was skipped.
diff --git a/src/.env.example b/src/.env.example
index e1ed5106..f015f1bf 100644
--- a/src/.env.example
+++ b/src/.env.example
@@ -1,187 +1,189 @@
APP_NAME=Kolab
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=https://kolab.local
#APP_PASSPHRASE=
APP_PUBLIC_URL=https://kolab.local
APP_DOMAIN=kolab.local
APP_WEBSITE_DOMAIN=kolab.local
APP_THEME=default
APP_TENANT_ID=5
APP_LOCALE=en
APP_LOCALES=
APP_WITH_ADMIN=1
APP_WITH_RESELLER=1
APP_WITH_SERVICES=1
APP_WITH_FILES=1
+APP_LDAP=1
+
APP_HEADER_CSP="connect-src 'self'; child-src 'self'; font-src 'self'; form-action 'self' data:; frame-ancestors 'self'; img-src blob: data: 'self' *; media-src 'self'; object-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-eval' 'unsafe-inline'; default-src 'self';"
APP_HEADER_XFO=sameorigin
SIGNUP_LIMIT_EMAIL=0
SIGNUP_LIMIT_IP=0
ASSET_URL=https://kolab.local
WEBMAIL_URL=/roundcubemail/
SUPPORT_URL=/support
SUPPORT_EMAIL=
LOG_CHANNEL=stack
LOG_SLOW_REQUESTS=5
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=mysql
DB_DATABASE=kolabdev
DB_HOST=mariadb
DB_PASSWORD=kolab
DB_PORT=3306
DB_USERNAME=kolabdev
BROADCAST_DRIVER=redis
CACHE_DRIVER=redis
QUEUE_CONNECTION=redis
SESSION_DRIVER=file
SESSION_LIFETIME=120
OPENEXCHANGERATES_API_KEY="from openexchangerates.org"
MFA_DSN=mysql://roundcube:Welcome2KolabSystems@mariadb/roundcube
MFA_TOTP_DIGITS=6
MFA_TOTP_INTERVAL=30
MFA_TOTP_DIGEST=sha1
IMAP_URI=ssl://kolab:11993
IMAP_HOST=172.18.0.5
IMAP_ADMIN_LOGIN=cyrus-admin
IMAP_ADMIN_PASSWORD=Welcome2KolabSystems
IMAP_VERIFY_HOST=false
IMAP_VERIFY_PEER=false
LDAP_BASE_DN="dc=mgmt,dc=com"
LDAP_DOMAIN_BASE_DN="ou=Domains,dc=mgmt,dc=com"
LDAP_HOSTS=kolab
LDAP_PORT=389
LDAP_SERVICE_BIND_DN="uid=kolab-service,ou=Special Users,dc=mgmt,dc=com"
LDAP_SERVICE_BIND_PW="Welcome2KolabSystems"
LDAP_USE_SSL=false
LDAP_USE_TLS=false
# Administrative
LDAP_ADMIN_BIND_DN="cn=Directory Manager"
LDAP_ADMIN_BIND_PW="Welcome2KolabSystems"
LDAP_ADMIN_ROOT_DN="dc=mgmt,dc=com"
# Hosted (public registration)
LDAP_HOSTED_BIND_DN="uid=hosted-kolab-service,ou=Special Users,dc=mgmt,dc=com"
LDAP_HOSTED_BIND_PW="Welcome2KolabSystems"
LDAP_HOSTED_ROOT_DN="dc=hosted,dc=com"
COTURN_PUBLIC_IP='172.18.0.1'
COTURN_STATIC_SECRET="Welcome2KolabSystems"
MEET_WEBHOOK_TOKEN=Welcome2KolabSystems
MEET_SERVER_TOKEN=Welcome2KolabSystems
MEET_SERVER_URLS=https://kolab.local/meetmedia/api/
MEET_SERVER_VERIFY_TLS=false
MEET_WEBRTC_LISTEN_IP='172.18.0.1'
MEET_PUBLIC_DOMAIN=kolab.local
MEET_TURN_SERVER='turn:172.18.0.1:3478'
MEET_LISTENING_HOST=172.18.0.1
PGP_ENABLE=true
PGP_BINARY=/usr/bin/gpg
PGP_AGENT=/usr/bin/gpg-agent
PGP_GPGCONF=/usr/bin/gpgconf
PGP_LENGTH=
# Set these to IP addresses you serve WOAT with.
# Have the domain owner point _woat.<hosted-domain> NS RRs refer to ns0{1,2}.<provider-domain>
WOAT_NS1=ns01.domain.tld
WOAT_NS2=ns02.domain.tld
REDIS_HOST=redis
REDIS_PASSWORD=null
REDIS_PORT=6379
OCTANE_HTTP_HOST=127.0.0.1
SWOOLE_PACKAGE_MAX_LENGTH=10485760
PAYMENT_PROVIDER=
MOLLIE_KEY=
STRIPE_KEY=
STRIPE_PUBLIC_KEY=
STRIPE_WEBHOOK_SECRET=
MAIL_DRIVER=log
MAIL_MAILER=smtp
MAIL_HOST=smtp.mailtrap.io
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="noreply@example.com"
MAIL_FROM_NAME="Example.com"
MAIL_REPLYTO_ADDRESS="replyto@example.com"
MAIL_REPLYTO_NAME=null
DNS_TTL=3600
DNS_SPF="v=spf1 mx -all"
DNS_STATIC="%s. MX 10 ext-mx01.mykolab.com."
DNS_COPY_FROM=null
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
PUSHER_APP_CLUSTER=mt1
MIX_ASSET_PATH='/'
MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
# Generate with ./artisan passport:client --password
#PASSPORT_PROXY_OAUTH_CLIENT_ID=
#PASSPORT_PROXY_OAUTH_CLIENT_SECRET=
# Generate with ./artisan passport:client --password
#PASSPORT_COMPANIONAPP_OAUTH_CLIENT_ID=
#PASSPORT_COMPANIONAPP_OAUTH_CLIENT_SECRET=
PASSPORT_PRIVATE_KEY=
PASSPORT_PUBLIC_KEY=
PASSWORD_POLICY=
COMPANY_NAME=
COMPANY_ADDRESS=
COMPANY_DETAILS=
COMPANY_EMAIL=
COMPANY_LOGO=
COMPANY_FOOTER=
VAT_COUNTRIES=CH,LI
VAT_RATE=7.7
KB_ACCOUNT_DELETE=
KB_ACCOUNT_SUSPENDED=
KB_PAYMENT_SYSTEM=
KOLAB_SSL_CERTIFICATE=/etc/pki/tls/certs/kolab.hosted.com.cert
KOLAB_SSL_CERTIFICATE_FULLCHAIN=/etc/pki/tls/certs/kolab.hosted.com.chain.pem
KOLAB_SSL_CERTIFICATE_KEY=/etc/pki/tls/certs/kolab.hosted.com.key
PROXY_SSL_CERTIFICATE=/etc/certs/imap.hosted.com.cert
PROXY_SSL_CERTIFICATE_KEY=/etc/certs/imap.hosted.com.key
diff --git a/src/app/Backends/IMAP.php b/src/app/Backends/IMAP.php
index d27cd136..30693090 100644
--- a/src/app/Backends/IMAP.php
+++ b/src/app/Backends/IMAP.php
@@ -1,161 +1,652 @@
<?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',
+ ];
+
/**
- * Check if an account is set up
+ * Delete a group.
*
- * @param string $username User login (email address)
+ * @param \App\Group $group Group
*
- * @return bool True if an account exists and is set up, False otherwise
+ * @return bool True if a group was deleted successfully, False otherwise
+ * @throws \Exception
*/
- public static function verifyAccount(string $username): bool
+ 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, $username);
+ $imap = self::initIMAP($config);
- $folders = $imap->listMailboxes('', '*');
+ $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();
- if (!is_array($folders)) {
- throw new \Exception("Failed to get IMAP folders");
+ 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]);
}
- return count($folders) > 0;
+ $imap->closeConnection();
+
+ return $result;
}
/**
- * Check if we can connect to the imap server
+ * Create a resource.
*
- * @return bool True on success
+ * @param \App\Resource $resource Resource
+ *
+ * @return bool True if a resource was created successfully, False otherwise
+ * @throws \Exception
*/
- public static function healthcheck(): bool
+ 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 = \mb_convert_encoding($matches[2], 'UTF7-IMAP', 'UTF8');
+ $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->closeConnection();
+
+ if (!is_array($folders)) {
+ return false;
+ }
+
+ return count($folders) > 0;
+ }
+
+ /**
+ * 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/Roundcube.php b/src/app/Backends/Roundcube.php
index 7d88048d..94b1464f 100644
--- a/src/app/Backends/Roundcube.php
+++ b/src/app/Backends/Roundcube.php
@@ -1,269 +1,281 @@
<?php
namespace App\Backends;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class Roundcube
{
private const FILESTORE_TABLE = 'filestore';
private const USERS_TABLE = 'users';
private const IDENTITIES_TABLE = 'identities';
/** @var array List of GnuPG files to store */
private static $enigma_files = ['pubring.gpg', 'secring.gpg', 'pubring.kbx'];
/**
* Return connection to the Roundcube database
*
* @return \Illuminate\Database\ConnectionInterface
*/
public static function dbh()
{
if (!\config('database.connections.roundcube')) {
\Log::warning("Roundcube database not configured");
return DB::connection(\config('database.default'));
}
return DB::connection('roundcube');
}
/**
* Remove all files from the Enigma filestore.
*
* @param string $email User email address
*/
public static function enigmaCleanup(string $email): void
{
self::dbh()->table(self::FILESTORE_TABLE)
->where('user_id', self::userId($email))
->where('context', 'enigma')
->delete();
}
/**
* List all files from the Enigma filestore.
*
* @param string $email User email address
*
* @return array List of Enigma filestore records
*/
public static function enigmaList(string $email): array
{
return self::dbh()->table(self::FILESTORE_TABLE)
->where('user_id', self::userId($email))
->where('context', 'enigma')
->orderBy('filename')
->get()
->all();
}
/**
* Synchronize Enigma filestore from/to specified directory
*
* @param string $email User email address
* @param string $homedir Directory location
*/
public static function enigmaSync(string $email, string $homedir): void
{
$db = self::dbh();
$debug = \config('app.debug');
$user_id = self::userId($email);
$root = \config('filesystems.disks.pgp.root');
$fs = Storage::disk('pgp');
$files = [];
$result = $db->table(self::FILESTORE_TABLE)->select('file_id', 'filename', 'mtime')
->where('user_id', $user_id)
->where('context', 'enigma')
->get();
foreach ($result as $record) {
$file = $homedir . '/' . $record->filename;
$mtime = $fs->exists($file) ? $fs->lastModified($file) : 0;
$files[] = $record->filename;
if ($mtime < $record->mtime) {
$record = $db->table(self::FILESTORE_TABLE)->select('file_id', 'data', 'mtime')
->where('file_id', $record->file_id)
->first();
$data = $record ? base64_decode($record->data) : false;
if ($data === false) {
\Log::error("Failed to sync $file ({$record->file_id}). Decode error.");
continue;
}
if ($fs->put($file, $data, true)) {
// Note: Laravel Filesystem API does not provide touch method
touch("$root/$file", $record->mtime);
if ($debug) {
\Log::debug("[SYNC] Fetched file: $file");
}
}
}
}
// Remove files not in database
foreach (array_diff(self::enigmaFilesList($homedir), $files) as $file) {
$file = $homedir . '/' . $file;
if ($fs->delete($file)) {
if ($debug) {
\Log::debug("[SYNC] Removed file: $file");
}
}
}
// No records found, do initial sync if already have the keyring
if (empty($file)) {
self::enigmaSave(true, $homedir);
}
}
/**
* Save the keys database
*
* @param string $email User email address
* @param string $homedir Directory location
* @param bool $is_empty Set to Tre if it is a initial save
*/
public static function enigmaSave(string $email, string $homedir, bool $is_empty = false): void
{
$db = self::dbh();
$debug = \config('app.debug');
$user_id = self::userId($email);
$fs = Storage::disk('pgp');
$records = [];
if (!$is_empty) {
$records = $db->table(self::FILESTORE_TABLE)->select('file_id', 'filename', 'mtime')
->where('user_id', $user_id)
->where('context', 'enigma')
->get()
->keyBy('filename')
->all();
}
foreach (self::enigmaFilesList($homedir) as $filename) {
$file = $homedir . '/' . $filename;
$mtime = $fs->exists($file) ? $fs->lastModified($file) : 0;
$existing = !empty($records[$filename]) ? $records[$filename] : null;
unset($records[$filename]);
if ($mtime && (empty($existing) || $mtime > $existing->mtime)) {
$data = base64_encode($fs->get($file));
/*
if (empty($maxsize)) {
$maxsize = min($db->get_variable('max_allowed_packet', 1048500), 4*1024*1024) - 2000;
}
if (strlen($data) > $maxsize) {
\Log::error("Failed to save $file. Size exceeds max_allowed_packet.");
continue;
}
*/
$result = $db->table(self::FILESTORE_TABLE)->updateOrInsert(
['user_id' => $user_id, 'context' => 'enigma', 'filename' => $filename],
['mtime' => $mtime, 'data' => $data]
);
if ($debug) {
\Log::debug("[SYNC] Pushed file: $file");
}
}
}
// Delete removed files from database
foreach (array_keys($records) as $filename) {
$file = $homedir . '/' . $filename;
$result = $db->table(self::FILESTORE_TABLE)
->where('user_id', $user_id)
->where('context', 'enigma')
->where('filename', $filename)
->delete();
if ($debug) {
\Log::debug("[SYNC] Removed file: $file");
}
}
}
+ /**
+ * Delete a Roundcube user.
+ *
+ * @param string $email User email address
+ */
+ public static function deleteUser(string $email): void
+ {
+ $db = self::dbh();
+
+ $db->table(self::USERS_TABLE)->where('username', \strtolower($email))->delete();
+ }
+
/**
* Find the Roundcube user identifier for the specified user.
*
* @param string $email User email address
* @param bool $create Make sure the user record exists
*
* @returns ?int Roundcube user identifier
*/
public static function userId(string $email, bool $create = true): ?int
{
$db = self::dbh();
$user = $db->table(self::USERS_TABLE)->select('user_id')
->where('username', \strtolower($email))
->first();
// Create a user record, without it we can't use the Roundcube storage
if (empty($user)) {
if (!$create) {
return null;
}
$uri = \parse_url(\config('imap.uri'));
$user_id = (int) $db->table(self::USERS_TABLE)->insertGetId(
[
'username' => $email,
'mail_host' => $uri['host'],
'created' => now()->toDateTimeString(),
],
'user_id'
);
$username = \App\User::where('email', $email)->first()->name();
$db->table(self::IDENTITIES_TABLE)->insert([
'user_id' => $user_id,
'email' => $email,
'name' => $username,
'changed' => now()->toDateTimeString(),
'standard' => 1,
]);
return $user_id;
}
return (int) $user->user_id;
}
/**
* Returns list of Enigma user homedir files to backup/sync
*/
private static function enigmaFilesList(string $homedir)
{
$files = [];
$fs = Storage::disk('pgp');
foreach (self::$enigma_files as $file) {
if ($fs->exists($homedir . '/' . $file)) {
$files[] = $file;
}
}
foreach ($fs->files($homedir . '/private-keys-v1.d') as $file) {
if (preg_match('/\.key$/', $file)) {
$files[] = substr($file, strlen($homedir . '/'));
}
}
return $files;
}
}
diff --git a/src/app/Console/Commands/Job/DomainCreate.php b/src/app/Console/Commands/Job/DomainCreate.php
index 9c6f5716..e03805e3 100644
--- a/src/app/Console/Commands/Job/DomainCreate.php
+++ b/src/app/Console/Commands/Job/DomainCreate.php
@@ -1,40 +1,39 @@
<?php
namespace App\Console\Commands\Job;
use App\Console\Command;
-use App\Domain;
class DomainCreate extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'job:domaincreate {domain}';
/**
* The console command description.
*
* @var string
*/
- protected $description = "Execute the DomainCreate job (again).";
+ protected $description = "Execute the domain creation job (again).";
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$domain = $this->getDomain($this->argument('domain'));
if (!$domain) {
return 1;
}
$job = new \App\Jobs\Domain\CreateJob($domain->id);
$job->handle();
}
}
diff --git a/src/app/Console/Commands/Job/DomainUpdate.php b/src/app/Console/Commands/Job/DomainUpdate.php
index 4b207e42..1477725b 100644
--- a/src/app/Console/Commands/Job/DomainUpdate.php
+++ b/src/app/Console/Commands/Job/DomainUpdate.php
@@ -1,40 +1,39 @@
<?php
namespace App\Console\Commands\Job;
use App\Console\Command;
-use App\Domain;
class DomainUpdate extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'job:domainupdate {domain}';
/**
* The console command description.
*
* @var string
*/
- protected $description = "Execute the DomainUpdate job (again).";
+ protected $description = "Execute the domain update job (again).";
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$domain = $this->getDomain($this->argument('domain'));
if (!$domain) {
return 1;
}
$job = new \App\Jobs\Domain\UpdateJob($domain->id);
$job->handle();
}
}
diff --git a/src/app/Console/Commands/Job/DomainUpdate.php b/src/app/Console/Commands/Job/ResourceCreate.php
similarity index 56%
copy from src/app/Console/Commands/Job/DomainUpdate.php
copy to src/app/Console/Commands/Job/ResourceCreate.php
index 4b207e42..fe57b5f2 100644
--- a/src/app/Console/Commands/Job/DomainUpdate.php
+++ b/src/app/Console/Commands/Job/ResourceCreate.php
@@ -1,40 +1,39 @@
<?php
namespace App\Console\Commands\Job;
use App\Console\Command;
-use App\Domain;
-class DomainUpdate extends Command
+class ResourceCreate extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
- protected $signature = 'job:domainupdate {domain}';
+ protected $signature = 'job:resourcecreate {resource}';
/**
* The console command description.
*
* @var string
*/
- protected $description = "Execute the DomainUpdate job (again).";
+ protected $description = "Execute the resource creation job (again).";
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
- $domain = $this->getDomain($this->argument('domain'));
+ $resource = $this->getResource($this->argument('resource'));
- if (!$domain) {
+ if (!$resource) {
return 1;
}
- $job = new \App\Jobs\Domain\UpdateJob($domain->id);
+ $job = new \App\Jobs\Resource\CreateJob($resource->id);
$job->handle();
}
}
diff --git a/src/app/Console/Commands/Job/DomainUpdate.php b/src/app/Console/Commands/Job/ResourceUpdate.php
similarity index 56%
copy from src/app/Console/Commands/Job/DomainUpdate.php
copy to src/app/Console/Commands/Job/ResourceUpdate.php
index 4b207e42..15a0ffac 100644
--- a/src/app/Console/Commands/Job/DomainUpdate.php
+++ b/src/app/Console/Commands/Job/ResourceUpdate.php
@@ -1,40 +1,39 @@
<?php
namespace App\Console\Commands\Job;
use App\Console\Command;
-use App\Domain;
-class DomainUpdate extends Command
+class ResourceUpdate extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
- protected $signature = 'job:domainupdate {domain}';
+ protected $signature = 'job:resourceupdate {resource}';
/**
* The console command description.
*
* @var string
*/
- protected $description = "Execute the DomainUpdate job (again).";
+ protected $description = "Execute the resource update job (again).";
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
- $domain = $this->getDomain($this->argument('domain'));
+ $resource = $this->getResource($this->argument('resource'));
- if (!$domain) {
+ if (!$resource) {
return 1;
}
- $job = new \App\Jobs\Domain\UpdateJob($domain->id);
+ $job = new \App\Jobs\Resource\UpdateJob($resource->id);
$job->handle();
}
}
diff --git a/src/app/Console/Commands/Job/DomainUpdate.php b/src/app/Console/Commands/Job/SharedFolderCreate.php
similarity index 55%
copy from src/app/Console/Commands/Job/DomainUpdate.php
copy to src/app/Console/Commands/Job/SharedFolderCreate.php
index 4b207e42..79b63595 100644
--- a/src/app/Console/Commands/Job/DomainUpdate.php
+++ b/src/app/Console/Commands/Job/SharedFolderCreate.php
@@ -1,40 +1,39 @@
<?php
namespace App\Console\Commands\Job;
use App\Console\Command;
-use App\Domain;
-class DomainUpdate extends Command
+class SharedFolderCreate extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
- protected $signature = 'job:domainupdate {domain}';
+ protected $signature = 'job:sharedfoldercreate {folder}';
/**
* The console command description.
*
* @var string
*/
- protected $description = "Execute the DomainUpdate job (again).";
+ protected $description = "Execute the shared folder creation job (again).";
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
- $domain = $this->getDomain($this->argument('domain'));
+ $folder = $this->getSharedFolder($this->argument('folder'));
- if (!$domain) {
+ if (!$folder) {
return 1;
}
- $job = new \App\Jobs\Domain\UpdateJob($domain->id);
+ $job = new \App\Jobs\SharedFolder\CreateJob($folder->id);
$job->handle();
}
}
diff --git a/src/app/Console/Commands/Job/DomainUpdate.php b/src/app/Console/Commands/Job/SharedFolderUpdate.php
similarity index 56%
copy from src/app/Console/Commands/Job/DomainUpdate.php
copy to src/app/Console/Commands/Job/SharedFolderUpdate.php
index 4b207e42..3b238557 100644
--- a/src/app/Console/Commands/Job/DomainUpdate.php
+++ b/src/app/Console/Commands/Job/SharedFolderUpdate.php
@@ -1,40 +1,39 @@
<?php
namespace App\Console\Commands\Job;
use App\Console\Command;
-use App\Domain;
-class DomainUpdate extends Command
+class SharedFolderUpdate extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
- protected $signature = 'job:domainupdate {domain}';
+ protected $signature = 'job:sharedfolderupdate {folder}';
/**
* The console command description.
*
* @var string
*/
- protected $description = "Execute the DomainUpdate job (again).";
+ protected $description = "Execute the shared folder update job (again).";
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
- $domain = $this->getDomain($this->argument('domain'));
+ $folder = $this->getSharedFolder($this->argument('folder'));
- if (!$domain) {
+ if (!$folder) {
return 1;
}
- $job = new \App\Jobs\Domain\UpdateJob($domain->id);
+ $job = new \App\Jobs\SharedFolder\UpdateJob($folder->id);
$job->handle();
}
}
diff --git a/src/app/Console/Commands/Job/UserCreate.php b/src/app/Console/Commands/Job/UserCreate.php
index d6c4f750..7cd979ec 100644
--- a/src/app/Console/Commands/Job/UserCreate.php
+++ b/src/app/Console/Commands/Job/UserCreate.php
@@ -1,40 +1,39 @@
<?php
namespace App\Console\Commands\Job;
use App\Console\Command;
-use App\User;
class UserCreate extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'job:usercreate {user}';
/**
* The console command description.
*
* @var string
*/
- protected $description = "Execute the UserCreate job (again).";
+ protected $description = "Execute the user creation job (again).";
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$user = $this->getUser($this->argument('user'));
if (!$user) {
return 1;
}
$job = new \App\Jobs\User\CreateJob($user->id);
$job->handle();
}
}
diff --git a/src/app/Console/Commands/Job/UserUpdate.php b/src/app/Console/Commands/Job/UserUpdate.php
index 2b8fe851..58367804 100644
--- a/src/app/Console/Commands/Job/UserUpdate.php
+++ b/src/app/Console/Commands/Job/UserUpdate.php
@@ -1,40 +1,39 @@
<?php
namespace App\Console\Commands\Job;
use App\Console\Command;
-use App\User;
class UserUpdate extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'job:userupdate {user}';
/**
* The console command description.
*
* @var string
*/
- protected $description = "Execute the UserUpdate job (again).";
+ protected $description = "Execute the user update job (again).";
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$user = $this->getUser($this->argument('user'));
if (!$user) {
return 1;
}
$job = new \App\Jobs\User\UpdateJob($user->id);
$job->handle();
}
}
diff --git a/src/app/Http/Controllers/API/V4/DomainsController.php b/src/app/Http/Controllers/API/V4/DomainsController.php
index 718a03d8..1d3e7c31 100644
--- a/src/app/Http/Controllers/API/V4/DomainsController.php
+++ b/src/app/Http/Controllers/API/V4/DomainsController.php
@@ -1,333 +1,329 @@
<?php
namespace App\Http\Controllers\API\V4;
use App\Domain;
use App\Http\Controllers\RelationController;
-use App\Backends\LDAP;
use App\Rules\UserEmailDomain;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
class DomainsController extends RelationController
{
/** @var string Resource localization label */
protected $label = 'domain';
/** @var string Resource model name */
protected $model = Domain::class;
/** @var array Common object properties in the API response */
protected $objectProps = ['namespace', 'type'];
/** @var array Resource listing order (column names) */
protected $order = ['namespace'];
/** @var array Resource relation method arguments */
protected $relationArgs = [true, false];
/**
* Confirm ownership of the specified domain (via DNS check).
*
* @param int $id Domain identifier
*
* @return \Illuminate\Http\JsonResponse|void
*/
public function confirm($id)
{
$domain = Domain::find($id);
if (!$this->checkTenant($domain)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canRead($domain)) {
return $this->errorResponse(403);
}
if (!$domain->confirm()) {
return response()->json([
'status' => 'error',
'message' => \trans('app.domain-verify-error'),
]);
}
return response()->json([
'status' => 'success',
'statusInfo' => self::statusInfo($domain),
'message' => \trans('app.domain-verify-success'),
]);
}
/**
* Remove the specified domain.
*
* @param string $id Domain identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function destroy($id)
{
$domain = Domain::withEnvTenantContext()->find($id);
if (empty($domain)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canDelete($domain)) {
return $this->errorResponse(403);
}
// It is possible to delete domain only if there are no users/aliases/groups using it.
if (!$domain->isEmpty()) {
$response = ['status' => 'error', 'message' => \trans('app.domain-notempty-error')];
return response()->json($response, 422);
}
$domain->delete();
return response()->json([
'status' => 'success',
'message' => \trans('app.domain-delete-success'),
]);
}
/**
* Create a domain.
*
* @param \Illuminate\Http\Request $request
*
* @return \Illuminate\Http\JsonResponse
*/
public function store(Request $request)
{
$current_user = $this->guard()->user();
$owner = $current_user->wallet()->owner;
if ($owner->id != $current_user->id) {
return $this->errorResponse(403);
}
// Validate the input
$v = Validator::make(
$request->all(),
[
'namespace' => ['required', 'string', new UserEmailDomain()]
]
);
if ($v->fails()) {
return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
}
$namespace = \strtolower(request()->input('namespace'));
// Domain already exists
if ($domain = Domain::withTrashed()->where('namespace', $namespace)->first()) {
// Check if the domain is soft-deleted and belongs to the same user
$deleteBeforeCreate = $domain->trashed() && ($wallet = $domain->wallet())
&& $wallet->owner && $wallet->owner->id == $owner->id;
if (!$deleteBeforeCreate) {
$errors = ['namespace' => \trans('validation.domainnotavailable')];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
}
if (empty($request->package) || !($package = \App\Package::withEnvTenantContext()->find($request->package))) {
$errors = ['package' => \trans('validation.packagerequired')];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
if (!$package->isDomain()) {
$errors = ['package' => \trans('validation.packageinvalid')];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
DB::beginTransaction();
// Force-delete the existing domain if it is soft-deleted and belongs to the same user
if (!empty($deleteBeforeCreate)) {
$domain->forceDelete();
}
// Create the domain
$domain = Domain::create([
'namespace' => $namespace,
'type' => \App\Domain::TYPE_EXTERNAL,
]);
$domain->assignPackage($package, $owner);
DB::commit();
return response()->json([
'status' => 'success',
'message' => \trans('app.domain-create-success'),
]);
}
/**
* Get the information about the specified domain.
*
* @param string $id Domain identifier
*
* @return \Illuminate\Http\JsonResponse|void
*/
public function show($id)
{
$domain = Domain::find($id);
if (!$this->checkTenant($domain)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canRead($domain)) {
return $this->errorResponse(403);
}
$response = $this->objectToClient($domain, true);
// Add hash information to the response
$response['hash_text'] = $domain->hash(Domain::HASH_TEXT);
$response['hash_cname'] = $domain->hash(Domain::HASH_CNAME);
$response['hash_code'] = $domain->hash(Domain::HASH_CODE);
// Add DNS/MX configuration for the domain
$response['dns'] = self::getDNSConfig($domain);
$response['mx'] = self::getMXConfig($domain->namespace);
// Domain configuration, e.g. spf whitelist
$response['config'] = $domain->getConfig();
// Status info
$response['statusInfo'] = self::statusInfo($domain);
// Entitlements/Wallet info
SkusController::objectEntitlements($domain, $response);
return response()->json($response);
}
/**
* Provide DNS MX information to configure specified domain for
*/
protected static function getMXConfig(string $namespace): array
{
$entries = [];
// copy MX entries from an existing domain
if ($master = \config('dns.copyfrom')) {
// TODO: cache this lookup
foreach ((array) dns_get_record($master, DNS_MX) as $entry) {
$entries[] = sprintf(
"@\t%s\t%s\tMX\t%d %s.",
\config('dns.ttl', $entry['ttl']),
$entry['class'],
$entry['pri'],
$entry['target']
);
}
} elseif ($static = \config('dns.static')) {
$entries[] = strtr($static, array('\n' => "\n", '%s' => $namespace));
}
// display SPF settings
if ($spf = \config('dns.spf')) {
$entries[] = ';';
foreach (['TXT', 'SPF'] as $type) {
$entries[] = sprintf(
"@\t%s\tIN\t%s\t\"%s\"",
\config('dns.ttl'),
$type,
$spf
);
}
}
return $entries;
}
/**
* Provide sample DNS config for domain confirmation
*/
protected static function getDNSConfig(Domain $domain): array
{
$serial = date('Ymd01');
$hash_txt = $domain->hash(Domain::HASH_TEXT);
$hash_cname = $domain->hash(Domain::HASH_CNAME);
$hash = $domain->hash(Domain::HASH_CODE);
return [
"@ IN SOA ns1.dnsservice.com. hostmaster.{$domain->namespace}. (",
" {$serial} 10800 3600 604800 86400 )",
";",
"@ IN A <some-ip>",
"www IN A <some-ip>",
";",
"{$hash_cname}.{$domain->namespace}. IN CNAME {$hash}.{$domain->namespace}.",
"@ 3600 TXT \"{$hash_txt}\"",
];
}
/**
* Domain status (extended) information.
*
* @param \App\Domain $domain Domain object
*
* @return array Status information
*/
public static function statusInfo($domain): array
{
// If that is not a public domain, add domain specific steps
return self::processStateInfo(
$domain,
[
'domain-new' => true,
'domain-ldap-ready' => $domain->isLdapReady(),
'domain-verified' => $domain->isVerified(),
'domain-confirmed' => [$domain->isConfirmed(), "/domain/{$domain->id}"],
]
);
}
/**
* Execute (synchronously) specified step in a domain setup process.
*
* @param \App\Domain $domain Domain object
* @param string $step Step identifier (as in self::statusInfo())
*
- * @return bool True if the execution succeeded, False otherwise
+ * @return bool|null True if the execution succeeded, False if not, Null when
+ * the job has been sent to the worker (result unknown)
*/
- public static function execProcessStep(Domain $domain, string $step): bool
+ public static function execProcessStep(Domain $domain, string $step): ?bool
{
try {
switch ($step) {
case 'domain-ldap-ready':
- // Domain not in LDAP, create it
- if (!$domain->isLdapReady()) {
- LDAP::createDomain($domain);
- $domain->status |= Domain::STATUS_LDAP_READY;
- $domain->save();
- }
- return $domain->isLdapReady();
+ // Use worker to do the job
+ \App\Jobs\Domain\CreateJob::dispatch($domain->id);
+ return null;
case 'domain-verified':
// Domain existence not verified
$domain->verify();
return $domain->isVerified();
case 'domain-confirmed':
// Domain ownership confirmation
$domain->confirm();
return $domain->isConfirmed();
}
} catch (\Exception $e) {
\Log::error($e);
}
return false;
}
}
diff --git a/src/app/Http/Controllers/API/V4/NGINXController.php b/src/app/Http/Controllers/API/V4/NGINXController.php
index 5e003696..3dbd9718 100644
--- a/src/app/Http/Controllers/API/V4/NGINXController.php
+++ b/src/app/Http/Controllers/API/V4/NGINXController.php
@@ -1,410 +1,408 @@
<?php
namespace App\Http\Controllers\API\V4;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class NGINXController extends Controller
{
-
-
/**
* Authorize with the provided credentials.
*
* @param string $login The login name
* @param string $password The password
*
* @return \App\User The user
*
* @throws \Exception If the authorization fails.
*/
private function authorizeRequestCredentialsOnly($login, $password)
{
if (empty($login)) {
throw new \Exception("Empty login");
}
if (empty($password)) {
throw new \Exception("Empty password");
}
$user = \App\User::where('email', $login)->first();
if (!$user) {
throw new \Exception("User not found");
}
if (!Hash::check($password, $user->password)) {
throw new \Exception("Password mismatch");
}
return $user;
}
/**
* Authorize with the provided credentials.
*
* @param string $login The login name
* @param string $password The password
* @param string $clientIP The client ip
*
* @return \App\User The user
*
* @throws \Exception If the authorization fails.
*/
private function authorizeRequest($login, $password, $clientIP)
{
if (empty($login)) {
throw new \Exception("Empty login");
}
if (empty($password)) {
throw new \Exception("Empty password");
}
if (empty($clientIP)) {
throw new \Exception("No client ip");
}
$user = \App\User::where('email', $login)->first();
if (!$user) {
throw new \Exception("User not found");
}
// TODO: validate the user's domain is A-OK (active, confirmed, not suspended, ldapready)
// TODO: validate the user is A-OK (active, not suspended, ldapready, imapready)
// TODO: we could use User::findAndAuthenticate() with some modifications here
if (!Hash::check($password, $user->password)) {
$attempt = \App\AuthAttempt::recordAuthAttempt($user, $clientIP);
// Avoid setting a password failure reason if we previously accepted the location.
if (!$attempt->isAccepted()) {
$attempt->reason = \App\AuthAttempt::REASON_PASSWORD;
$attempt->save();
$attempt->notify();
}
throw new \Exception("Password mismatch");
}
// validate country of origin against restrictions, otherwise bye bye
if (!$user->validateLocation($clientIP)) {
\Log::info("Failed authentication attempt due to country code mismatch for user: {$login}");
$attempt = \App\AuthAttempt::recordAuthAttempt($user, $clientIP);
$attempt->deny(\App\AuthAttempt::REASON_GEOLOCATION);
$attempt->notify();
throw new \Exception("Country code mismatch");
}
// TODO: Apply some sort of limit for Auth-Login-Attempt -- docs say it is the number of
// attempts over the same authAttempt.
// Check 2fa
if (\App\CompanionApp::where('user_id', $user->id)->exists()) {
$authAttempt = \App\AuthAttempt::recordAuthAttempt($user, $clientIP);
if (!$authAttempt->waitFor2FA()) {
throw new \Exception("2fa failed");
}
}
return $user;
}
/**
* Convert domain.tld\username into username@domain for activesync
*
* @param string $username The original username.
*
* @return string The username in canonical form
*/
private function normalizeUsername($username)
{
$usernameParts = explode("\\", $username);
if (count($usernameParts) == 2) {
$username = $usernameParts[1];
if (!strpos($username, '@') && !empty($usernameParts[0])) {
$username .= '@' . $usernameParts[0];
}
}
return $username;
}
/**
* Authentication request from the ngx_http_auth_request_module
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\Response The response
*/
public function httpauth(Request $request)
{
/**
Php-Auth-Pw: simple123
Php-Auth-User: john@kolab.org
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: cross-site
Sec-Gpc: 1
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:93.0) Gecko/20100101 Firefox/93.0
X-Forwarded-For: 31.10.153.58
X-Forwarded-Proto: https
X-Original-Uri: /iRony/
X-Real-Ip: 31.10.153.58
*/
$username = $this->normalizeUsername($request->headers->get('Php-Auth-User', ""));
$password = $request->headers->get('Php-Auth-Pw', null);
if (empty($username)) {
//Allow unauthenticated requests
return response("");
}
if (empty($password)) {
\Log::debug("Authentication attempt failed: Empty password provided.");
return response("", 401);
}
try {
$this->authorizeRequest(
$username,
$password,
$request->headers->get('X-Real-Ip', null),
);
} catch (\Exception $e) {
\Log::debug("Authentication attempt failed: {$e->getMessage()}");
return response("", 403);
}
\Log::debug("Authentication attempt succeeded");
return response("");
}
/**
* Authentication request from the cyrus sasl
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\Response The response
*/
public function cyrussasl(Request $request)
{
$data = $request->getContent();
// Assumes "%u %r %p" as form data in the cyrus sasl config file
$array = explode(' ', rawurldecode($data));
if (count($array) != 3) {
\Log::debug("Authentication attempt failed: invalid data provided.");
return response("", 403);
}
$username = $array[0];
$realm = $array[1];
$password = $array[2];
if (!empty($realm)) {
$username = "$username@$realm";
}
if (empty($password)) {
\Log::debug("Authentication attempt failed: Empty password provided.");
return response("", 403);
}
try {
$this->authorizeRequestCredentialsOnly(
$username,
$password
);
} catch (\Exception $e) {
\Log::debug("Authentication attempt failed for $username: {$e->getMessage()}");
return response("", 403);
}
\Log::debug("Authentication attempt succeeded for $username");
return response("");
}
/**
* Authentication request.
*
* @todo: Separate IMAP(+STARTTLS) from IMAPS, same for SMTP/submission. =>
* I suppose that's not necessary given that we have the information avialable in the headers?
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\Response The response
*/
public function authenticate(Request $request)
{
/**
* Auth-Login-Attempt: 1
* Auth-Method: plain
* Auth-Pass: simple123
* Auth-Protocol: imap
* Auth-Ssl: on
* Auth-User: john@kolab.org
* Client-Ip: 127.0.0.1
* Host: 127.0.0.1
*
* Auth-SSL: on
* Auth-SSL-Verify: SUCCESS
* Auth-SSL-Subject: /CN=example.com
* Auth-SSL-Issuer: /CN=example.com
* Auth-SSL-Serial: C07AD56B846B5BFF
* Auth-SSL-Fingerprint: 29d6a80a123d13355ed16b4b04605e29cb55a5ad
*/
$password = $request->headers->get('Auth-Pass', null);
$username = $request->headers->get('Auth-User', null);
$ip = $request->headers->get('Client-Ip', null);
try {
$user = $this->authorizeRequest(
$username,
$password,
$ip,
);
} catch (\Exception $e) {
return $this->byebye($request, $e->getMessage());
}
// All checks passed
switch ($request->headers->get('Auth-Protocol')) {
case "imap":
return $this->authenticateIMAP($request, (bool) $user->getSetting('guam_enabled'), $password);
case "smtp":
return $this->authenticateSMTP($request, $password);
default:
return $this->byebye($request, "unknown protocol in request");
}
}
/**
* Authentication request for roundcube imap.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\Response The response
*/
public function authenticateRoundcube(Request $request)
{
/**
* Auth-Login-Attempt: 1
* Auth-Method: plain
* Auth-Pass: simple123
* Auth-Protocol: imap
* Auth-Ssl: on
* Auth-User: john@kolab.org
* Client-Ip: 127.0.0.1
* Host: 127.0.0.1
*
* Auth-SSL: on
* Auth-SSL-Verify: SUCCESS
* Auth-SSL-Subject: /CN=example.com
* Auth-SSL-Issuer: /CN=example.com
* Auth-SSL-Serial: C07AD56B846B5BFF
* Auth-SSL-Fingerprint: 29d6a80a123d13355ed16b4b04605e29cb55a5ad
*/
$password = $request->headers->get('Auth-Pass', null);
$username = $request->headers->get('Auth-User', null);
$ip = $request->headers->get('Proxy-Protocol-Addr', null);
try {
$user = $this->authorizeRequest(
$username,
$password,
$ip,
);
} catch (\Exception $e) {
return $this->byebye($request, $e->getMessage());
}
// All checks passed
switch ($request->headers->get('Auth-Protocol')) {
case "imap":
return $this->authenticateIMAP($request, false, $password);
default:
return $this->byebye($request, "unknown protocol in request");
}
}
/**
* Create an imap authentication response.
*
* @param \Illuminate\Http\Request $request The API request.
* @param bool $prefGuam Wether or not guam is enabled.
* @param string $password The password to include in the response.
*
* @return \Illuminate\Http\Response The response
*/
private function authenticateIMAP(Request $request, $prefGuam, $password)
{
if ($prefGuam) {
$port = \config('imap.guam_port');
} else {
$port = \config('imap.imap_port');
}
$response = response("")->withHeaders(
[
"Auth-Status" => "OK",
"Auth-Server" => \config('imap.host'),
"Auth-Port" => $port,
"Auth-Pass" => $password
]
);
return $response;
}
/**
* Create an smtp authentication response.
*
* @param \Illuminate\Http\Request $request The API request.
* @param string $password The password to include in the response.
*
* @return \Illuminate\Http\Response The response
*/
private function authenticateSMTP(Request $request, $password)
{
$response = response("")->withHeaders(
[
"Auth-Status" => "OK",
"Auth-Server" => \config('smtp.host'),
"Auth-Port" => \config('smtp.port'),
"Auth-Pass" => $password
]
);
return $response;
}
/**
* Create a failed-authentication response.
*
* @param \Illuminate\Http\Request $request The API request.
* @param string $reason The reason for the failure.
*
* @return \Illuminate\Http\Response The response
*/
private function byebye(Request $request, $reason = null)
{
\Log::debug("Byebye: {$reason}");
$response = response("")->withHeaders(
[
"Auth-Status" => "authentication failure",
"Auth-Wait" => 3
]
);
return $response;
}
}
diff --git a/src/app/Http/Controllers/API/V4/ResourcesController.php b/src/app/Http/Controllers/API/V4/ResourcesController.php
index 743eace2..9eaf3f36 100644
--- a/src/app/Http/Controllers/API/V4/ResourcesController.php
+++ b/src/app/Http/Controllers/API/V4/ResourcesController.php
@@ -1,194 +1,174 @@
<?php
namespace App\Http\Controllers\API\V4;
use App\Http\Controllers\RelationController;
use App\Resource;
use App\Rules\ResourceName;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
class ResourcesController extends RelationController
{
/** @var string Resource localization label */
protected $label = 'resource';
/** @var string Resource model name */
protected $model = Resource::class;
/** @var array Resource listing order (column names) */
protected $order = ['name'];
/** @var array Common object properties in the API response */
protected $objectProps = ['email', 'name'];
/**
* Resource status (extended) information
*
* @param \App\Resource $resource Resource object
*
* @return array Status information
*/
public static function statusInfo($resource): array
{
return self::processStateInfo(
$resource,
[
'resource-new' => true,
'resource-ldap-ready' => $resource->isLdapReady(),
'resource-imap-ready' => $resource->isImapReady(),
]
);
}
/**
* Create a new resource record.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function store(Request $request)
{
$current_user = $this->guard()->user();
$owner = $current_user->wallet()->owner;
if ($owner->id != $current_user->id) {
return $this->errorResponse(403);
}
$domain = request()->input('domain');
$rules = ['name' => ['required', 'string', new ResourceName($owner, $domain)]];
$v = Validator::make($request->all(), $rules);
if ($v->fails()) {
return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
}
DB::beginTransaction();
// Create the resource
$resource = new Resource();
$resource->name = request()->input('name');
$resource->domainName = $domain;
$resource->save();
$resource->assignToWallet($owner->wallets->first());
DB::commit();
return response()->json([
'status' => 'success',
'message' => \trans('app.resource-create-success'),
]);
}
/**
* Update a resource.
*
* @param \Illuminate\Http\Request $request The API request.
* @param string $id Resource identifier
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function update(Request $request, $id)
{
$resource = Resource::find($id);
if (!$this->checkTenant($resource)) {
return $this->errorResponse(404);
}
$current_user = $this->guard()->user();
if (!$current_user->canUpdate($resource)) {
return $this->errorResponse(403);
}
$owner = $resource->wallet()->owner;
$name = $request->input('name');
$errors = [];
// Validate the resource name
if ($name !== null && $name != $resource->name) {
$domainName = explode('@', $resource->email, 2)[1];
$rules = ['name' => ['required', 'string', new ResourceName($owner, $domainName)]];
$v = Validator::make($request->all(), $rules);
if ($v->fails()) {
$errors = $v->errors()->toArray();
} else {
$resource->name = $name;
}
}
if (!empty($errors)) {
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
// SkusController::updateEntitlements($resource, $request->skus);
$resource->save();
return response()->json([
'status' => 'success',
'message' => \trans('app.resource-update-success'),
]);
}
/**
* Execute (synchronously) specified step in a resource setup process.
*
* @param \App\Resource $resource Resource object
* @param string $step Step identifier (as in self::statusInfo())
*
* @return bool|null True if the execution succeeded, False if not, Null when
* the job has been sent to the worker (result unknown)
*/
public static function execProcessStep(Resource $resource, string $step): ?bool
{
try {
if (strpos($step, 'domain-') === 0) {
return DomainsController::execProcessStep($resource->domain(), $step);
}
switch ($step) {
case 'resource-ldap-ready':
- // Resource not in LDAP, create it
- $job = new \App\Jobs\Resource\CreateJob($resource->id);
- $job->handle();
-
- $resource->refresh();
-
- return $resource->isLdapReady();
-
case 'resource-imap-ready':
- // Resource not in IMAP? Verify again
- // Do it synchronously if the imap admin credentials are available
- // otherwise let the worker do the job
- if (!\config('imap.admin_password')) {
- \App\Jobs\Resource\VerifyJob::dispatch($resource->id);
-
- return null;
- }
-
- $job = new \App\Jobs\Resource\VerifyJob($resource->id);
- $job->handle();
-
- $resource->refresh();
-
- return $resource->isImapReady();
+ // Use worker to do the job, frontend might not have the IMAP admin credentials
+ \App\Jobs\Resource\CreateJob::dispatch($resource->id);
+ return null;
}
} catch (\Exception $e) {
\Log::error($e);
}
return false;
}
}
diff --git a/src/app/Http/Controllers/API/V4/SharedFoldersController.php b/src/app/Http/Controllers/API/V4/SharedFoldersController.php
index b0da67c3..43926d19 100644
--- a/src/app/Http/Controllers/API/V4/SharedFoldersController.php
+++ b/src/app/Http/Controllers/API/V4/SharedFoldersController.php
@@ -1,287 +1,267 @@
<?php
namespace App\Http\Controllers\API\V4;
use App\Http\Controllers\RelationController;
use App\SharedFolder;
use App\Rules\SharedFolderName;
use App\Rules\SharedFolderType;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
class SharedFoldersController extends RelationController
{
/** @var string Resource localization label */
protected $label = 'shared-folder';
/** @var string Resource model name */
protected $model = SharedFolder::class;
/** @var array Resource listing order (column names) */
protected $order = ['name'];
/** @var array Common object properties in the API response */
protected $objectProps = ['email', 'name', 'type'];
/**
* SharedFolder status (extended) information
*
* @param \App\SharedFolder $folder SharedFolder object
*
* @return array Status information
*/
public static function statusInfo($folder): array
{
return self::processStateInfo(
$folder,
[
'shared-folder-new' => true,
'shared-folder-ldap-ready' => $folder->isLdapReady(),
'shared-folder-imap-ready' => $folder->isImapReady(),
]
);
}
/**
* Create a new shared folder record.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function store(Request $request)
{
$current_user = $this->guard()->user();
$owner = $current_user->walletOwner();
if (empty($owner) || $owner->id != $current_user->id) {
return $this->errorResponse(403);
}
if ($error_response = $this->validateFolderRequest($request, null, $owner)) {
return $error_response;
}
DB::beginTransaction();
// Create the shared folder
$folder = new SharedFolder();
$folder->name = $request->input('name');
$folder->type = $request->input('type');
$folder->domainName = $request->input('domain');
$folder->save();
if (!empty($request->aliases) && $folder->type === 'mail') {
$folder->setAliases($request->aliases);
}
$folder->assignToWallet($owner->wallets->first());
DB::commit();
return response()->json([
'status' => 'success',
'message' => \trans('app.shared-folder-create-success'),
]);
}
/**
* Update a shared folder.
*
* @param \Illuminate\Http\Request $request The API request.
* @param string $id Shared folder identifier
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function update(Request $request, $id)
{
$folder = SharedFolder::find($id);
if (!$this->checkTenant($folder)) {
return $this->errorResponse(404);
}
$current_user = $this->guard()->user();
if (!$current_user->canUpdate($folder)) {
return $this->errorResponse(403);
}
if ($error_response = $this->validateFolderRequest($request, $folder, $folder->walletOwner())) {
return $error_response;
}
$name = $request->input('name');
DB::beginTransaction();
// SkusController::updateEntitlements($folder, $request->skus);
if ($name && $name != $folder->name) {
$folder->name = $name;
}
$folder->save();
if (isset($request->aliases) && $folder->type === 'mail') {
$folder->setAliases($request->aliases);
}
DB::commit();
return response()->json([
'status' => 'success',
'message' => \trans('app.shared-folder-update-success'),
]);
}
/**
* Execute (synchronously) specified step in a shared folder setup process.
*
* @param \App\SharedFolder $folder Shared folder object
* @param string $step Step identifier (as in self::statusInfo())
*
* @return bool|null True if the execution succeeded, False if not, Null when
* the job has been sent to the worker (result unknown)
*/
public static function execProcessStep(SharedFolder $folder, string $step): ?bool
{
try {
if (strpos($step, 'domain-') === 0) {
return DomainsController::execProcessStep($folder->domain(), $step);
}
switch ($step) {
case 'shared-folder-ldap-ready':
- // Shared folder not in LDAP, create it
- $job = new \App\Jobs\SharedFolder\CreateJob($folder->id);
- $job->handle();
-
- $folder->refresh();
-
- return $folder->isLdapReady();
-
case 'shared-folder-imap-ready':
- // Shared folder not in IMAP? Verify again
- // Do it synchronously if the imap admin credentials are available
- // otherwise let the worker do the job
- if (!\config('imap.admin_password')) {
- \App\Jobs\SharedFolder\VerifyJob::dispatch($folder->id);
-
- return null;
- }
-
- $job = new \App\Jobs\SharedFolder\VerifyJob($folder->id);
- $job->handle();
-
- $folder->refresh();
-
- return $folder->isImapReady();
+ // Use worker to do the job, frontend might not have the IMAP admin credentials
+ \App\Jobs\SharedFolder\CreateJob::dispatch($folder->id);
+ return null;
}
} catch (\Exception $e) {
\Log::error($e);
}
return false;
}
/**
* Validate shared folder input
*
* @param \Illuminate\Http\Request $request The API request.
* @param \App\SharedFolder|null $folder Shared folder
* @param \App\User|null $owner Account owner
*
* @return \Illuminate\Http\JsonResponse|null The error response on error
*/
protected function validateFolderRequest(Request $request, $folder, $owner)
{
$errors = [];
if (empty($folder)) {
$name = $request->input('name');
$domain = $request->input('domain');
$rules = [
'name' => ['required', 'string', new SharedFolderName($owner, $domain)],
'type' => ['required', 'string', new SharedFolderType()],
];
} else {
// On update validate the folder name (if changed)
$name = $request->input('name');
$domain = explode('@', $folder->email, 2)[1];
if ($name !== null && $name != $folder->name) {
$rules = ['name' => ['required', 'string', new SharedFolderName($owner, $domain)]];
}
}
if (!empty($rules)) {
$v = Validator::make($request->all(), $rules);
if ($v->fails()) {
$errors = $v->errors()->toArray();
}
}
// Validate aliases input
if (isset($request->aliases)) {
$aliases = [];
$existing_aliases = $owner->aliases()->get()->pluck('alias')->toArray();
foreach ($request->aliases as $idx => $alias) {
if (is_string($alias) && !empty($alias)) {
// Alias cannot be the same as the email address
if (!empty($folder) && Str::lower($alias) == Str::lower($folder->email)) {
continue;
}
// validate new aliases
if (
!in_array($alias, $existing_aliases)
&& ($error = self::validateAlias($alias, $owner, $name, $domain))
) {
if (!isset($errors['aliases'])) {
$errors['aliases'] = [];
}
$errors['aliases'][$idx] = $error;
continue;
}
$aliases[] = $alias;
}
}
$request->aliases = $aliases;
}
if (!empty($errors)) {
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
return null;
}
/**
* Email address validation for use as a shared folder alias.
*
* @param string $alias Email address
* @param \App\User $owner The account owner
* @param string $folderName Folder name
* @param string $domain Folder domain
*
* @return ?string Error message on validation error
*/
public static function validateAlias(string $alias, \App\User $owner, string $folderName, string $domain): ?string
{
$lmtp_alias = "shared+shared/{$folderName}@{$domain}";
if ($alias === $lmtp_alias) {
return null;
}
return UsersController::validateAlias($alias, $owner);
}
}
diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php
index 8d7ca29a..ca253cbd 100644
--- a/src/app/Http/Controllers/API/V4/UsersController.php
+++ b/src/app/Http/Controllers/API/V4/UsersController.php
@@ -1,722 +1,702 @@
<?php
namespace App\Http\Controllers\API\V4;
use App\Http\Controllers\RelationController;
use App\Domain;
use App\Rules\Password;
use App\Rules\UserEmailDomain;
use App\Rules\UserEmailLocal;
use App\Sku;
use App\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
class UsersController extends RelationController
{
/** @const array List of user setting keys available for modification in UI */
public const USER_SETTINGS = [
'billing_address',
'country',
'currency',
'external_email',
'first_name',
'last_name',
'organization',
'phone',
];
/**
* On user create it is filled with a user or group object to force-delete
* before the creation of a new user record is possible.
*
* @var \App\User|\App\Group|null
*/
protected $deleteBeforeCreate;
/** @var string Resource localization label */
protected $label = 'user';
/** @var string Resource model name */
protected $model = User::class;
/** @var array Common object properties in the API response */
protected $objectProps = ['email'];
/** @var ?\App\VerificationCode Password reset code to activate on user create/update */
protected $passCode;
/**
* Listing of users.
*
* The user-entitlements billed to the current user wallet(s)
*
* @return \Illuminate\Http\JsonResponse
*/
public function index()
{
$user = $this->guard()->user();
$search = trim(request()->input('search'));
$page = intval(request()->input('page')) ?: 1;
$pageSize = 20;
$hasMore = false;
$result = $user->users();
// Search by user email, alias or name
if (strlen($search) > 0) {
// thanks to cloning we skip some extra queries in $user->users()
$allUsers1 = clone $result;
$allUsers2 = clone $result;
$result->whereLike('email', $search)
->union(
$allUsers1->join('user_aliases', 'users.id', '=', 'user_aliases.user_id')
->whereLike('alias', $search)
)
->union(
$allUsers2->join('user_settings', 'users.id', '=', 'user_settings.user_id')
->whereLike('value', $search)
->whereIn('key', ['first_name', 'last_name'])
);
}
$result = $result->orderBy('email')
->limit($pageSize + 1)
->offset($pageSize * ($page - 1))
->get();
if (count($result) > $pageSize) {
$result->pop();
$hasMore = true;
}
// Process the result
$result = $result->map(
function ($user) {
return $this->objectToClient($user);
}
);
$result = [
'list' => $result,
'count' => count($result),
'hasMore' => $hasMore,
];
return response()->json($result);
}
/**
* Display information on the user account specified by $id.
*
* @param string $id The account to show information for.
*
* @return \Illuminate\Http\JsonResponse
*/
public function show($id)
{
$user = User::find($id);
if (!$this->checkTenant($user)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canRead($user)) {
return $this->errorResponse(403);
}
$response = $this->userResponse($user);
$response['skus'] = \App\Entitlement::objectEntitlementsSummary($user);
$response['config'] = $user->getConfig();
$response['aliases'] = $user->aliases()->pluck('alias')->all();
$code = $user->verificationcodes()->where('active', true)
->where('expires_at', '>', \Carbon\Carbon::now())
->first();
if ($code) {
$response['passwordLinkCode'] = $code->short_code . '-' . $code->code;
}
return response()->json($response);
}
/**
* User status (extended) information
*
* @param \App\User $user User object
*
* @return array Status information
*/
public static function statusInfo($user): array
{
$process = self::processStateInfo(
$user,
[
'user-new' => true,
'user-ldap-ready' => $user->isLdapReady(),
'user-imap-ready' => $user->isImapReady(),
]
);
// Check if the user is a controller of his wallet
$isController = $user->canDelete($user);
$isDegraded = $user->isDegraded();
$hasMeet = !$isDegraded && Sku::withObjectTenantContext($user)->where('title', 'room')->exists();
$hasCustomDomain = $user->wallet()->entitlements()
->where('entitleable_type', Domain::class)
->count() > 0;
// Get user's entitlements titles
$skus = $user->entitlements()->select('skus.title')
->join('skus', 'skus.id', '=', 'entitlements.sku_id')
->get()
->pluck('title')
->sort()
->unique()
->values()
->all();
$hasBeta = in_array('beta', $skus);
$result = [
'skus' => $skus,
'enableBeta' => in_array('beta', $skus),
// TODO: This will change when we enable all users to create domains
'enableDomains' => $isController && $hasCustomDomain,
// TODO: Make 'enableDistlists' working for wallet controllers that aren't account owners
'enableDistlists' => $isController && $hasCustomDomain && $hasBeta,
'enableFiles' => !$isDegraded && $hasBeta && \config('app.with_files'),
// TODO: Make 'enableFolders' working for wallet controllers that aren't account owners
'enableFolders' => $isController && $hasCustomDomain && $hasBeta,
// TODO: Make 'enableResources' working for wallet controllers that aren't account owners
'enableResources' => $isController && $hasCustomDomain && $hasBeta,
'enableRooms' => $hasMeet,
'enableSettings' => $isController,
'enableUsers' => $isController,
'enableWallets' => $isController,
'enableCompanionapps' => $hasBeta,
];
return array_merge($process, $result);
}
/**
* Create a new user record.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function store(Request $request)
{
$current_user = $this->guard()->user();
$owner = $current_user->walletOwner();
if ($owner->id != $current_user->id) {
return $this->errorResponse(403);
}
$this->deleteBeforeCreate = null;
if ($error_response = $this->validateUserRequest($request, null, $settings)) {
return $error_response;
}
if (empty($request->package) || !($package = \App\Package::withEnvTenantContext()->find($request->package))) {
$errors = ['package' => \trans('validation.packagerequired')];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
if ($package->isDomain()) {
$errors = ['package' => \trans('validation.packageinvalid')];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
DB::beginTransaction();
// @phpstan-ignore-next-line
if ($this->deleteBeforeCreate) {
$this->deleteBeforeCreate->forceDelete();
}
// Create user record
$user = User::create([
'email' => $request->email,
'password' => $request->password,
]);
$this->activatePassCode($user);
$owner->assignPackage($package, $user);
if (!empty($settings)) {
$user->setSettings($settings);
}
if (!empty($request->aliases)) {
$user->setAliases($request->aliases);
}
DB::commit();
return response()->json([
'status' => 'success',
'message' => \trans('app.user-create-success'),
]);
}
/**
* Update user data.
*
* @param \Illuminate\Http\Request $request The API request.
* @param string $id User identifier
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function update(Request $request, $id)
{
$user = User::withEnvTenantContext()->find($id);
if (empty($user)) {
return $this->errorResponse(404);
}
$current_user = $this->guard()->user();
// TODO: Decide what attributes a user can change on his own profile
if (!$current_user->canUpdate($user)) {
return $this->errorResponse(403);
}
if ($error_response = $this->validateUserRequest($request, $user, $settings)) {
return $error_response;
}
// Entitlements, only controller can do that
if ($request->skus !== null && !$current_user->canDelete($user)) {
return $this->errorResponse(422, "You have no permission to change entitlements");
}
DB::beginTransaction();
SkusController::updateEntitlements($user, $request->skus);
if (!empty($settings)) {
$user->setSettings($settings);
}
if (!empty($request->password)) {
$user->password = $request->password;
$user->save();
}
$this->activatePassCode($user);
if (isset($request->aliases)) {
$user->setAliases($request->aliases);
}
DB::commit();
$response = [
'status' => 'success',
'message' => \trans('app.user-update-success'),
];
// For self-update refresh the statusInfo in the UI
if ($user->id == $current_user->id) {
$response['statusInfo'] = self::statusInfo($user);
}
return response()->json($response);
}
/**
* Create a response data array for specified user.
*
* @param \App\User $user User object
*
* @return array Response data
*/
public static function userResponse(User $user): array
{
$response = array_merge($user->toArray(), self::objectState($user));
// Settings
$response['settings'] = [];
foreach ($user->settings()->whereIn('key', self::USER_SETTINGS)->get() as $item) {
$response['settings'][$item->key] = $item->value;
}
// Status info
$response['statusInfo'] = self::statusInfo($user);
// Add more info to the wallet object output
$map_func = function ($wallet) use ($user) {
$result = $wallet->toArray();
if ($wallet->discount) {
$result['discount'] = $wallet->discount->discount;
$result['discount_description'] = $wallet->discount->description;
}
if ($wallet->user_id != $user->id) {
$result['user_email'] = $wallet->owner->email;
}
$provider = \App\Providers\PaymentProvider::factory($wallet);
$result['provider'] = $provider->name();
return $result;
};
// Information about wallets and accounts for access checks
$response['wallets'] = $user->wallets->map($map_func)->toArray();
$response['accounts'] = $user->accounts->map($map_func)->toArray();
$response['wallet'] = $map_func($user->wallet());
return $response;
}
/**
* Prepare user statuses for the UI
*
* @param \App\User $user User object
*
* @return array Statuses array
*/
protected static function objectState($user): array
{
$state = parent::objectState($user);
$state['isAccountDegraded'] = $user->isDegraded(true);
return $state;
}
/**
* Validate user input
*
* @param \Illuminate\Http\Request $request The API request.
* @param \App\User|null $user User identifier
* @param array $settings User settings (from the request)
*
* @return \Illuminate\Http\JsonResponse|null The error response on error
*/
protected function validateUserRequest(Request $request, $user, &$settings = [])
{
$rules = [
'external_email' => 'nullable|email',
'phone' => 'string|nullable|max:64|regex:/^[0-9+() -]+$/',
'first_name' => 'string|nullable|max:128',
'last_name' => 'string|nullable|max:128',
'organization' => 'string|nullable|max:512',
'billing_address' => 'string|nullable|max:1024',
'country' => 'string|nullable|alpha|size:2',
'currency' => 'string|nullable|alpha|size:3',
'aliases' => 'array|nullable',
];
$controller = ($user ?: $this->guard()->user())->walletOwner();
// Handle generated password reset code
if ($code = $request->input('passwordLinkCode')) {
// Accept <short-code>-<code> input
if (strpos($code, '-')) {
$code = explode('-', $code)[1];
}
$this->passCode = $this->guard()->user()->verificationcodes()
->where('code', $code)->where('active', false)->first();
// Generate a password for a new user with password reset link
// FIXME: Should/can we have a user with no password set?
if ($this->passCode && empty($user)) {
$request->password = $request->password_confirmation = Str::random(16);
$ignorePassword = true;
}
}
if (empty($user) || !empty($request->password) || !empty($request->password_confirmation)) {
if (empty($ignorePassword)) {
$rules['password'] = ['required', 'confirmed', new Password($controller)];
}
}
$errors = [];
// Validate input
$v = Validator::make($request->all(), $rules);
if ($v->fails()) {
$errors = $v->errors()->toArray();
}
// For new user validate email address
if (empty($user)) {
$email = $request->email;
if (empty($email)) {
$errors['email'] = \trans('validation.required', ['attribute' => 'email']);
} elseif ($error = self::validateEmail($email, $controller, $this->deleteBeforeCreate)) {
$errors['email'] = $error;
}
}
// Validate aliases input
if (isset($request->aliases)) {
$aliases = [];
$existing_aliases = $user ? $user->aliases()->get()->pluck('alias')->toArray() : [];
foreach ($request->aliases as $idx => $alias) {
if (is_string($alias) && !empty($alias)) {
// Alias cannot be the same as the email address (new user)
if (!empty($email) && Str::lower($alias) == Str::lower($email)) {
continue;
}
// validate new aliases
if (
!in_array($alias, $existing_aliases)
&& ($error = self::validateAlias($alias, $controller))
) {
if (!isset($errors['aliases'])) {
$errors['aliases'] = [];
}
$errors['aliases'][$idx] = $error;
continue;
}
$aliases[] = $alias;
}
}
$request->aliases = $aliases;
}
if (!empty($errors)) {
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
// Update user settings
$settings = $request->only(array_keys($rules));
unset($settings['password'], $settings['aliases'], $settings['email']);
return null;
}
/**
* Execute (synchronously) specified step in a user setup process.
*
* @param \App\User $user User object
* @param string $step Step identifier (as in self::statusInfo())
*
* @return bool|null True if the execution succeeded, False if not, Null when
* the job has been sent to the worker (result unknown)
*/
public static function execProcessStep(User $user, string $step): ?bool
{
try {
if (strpos($step, 'domain-') === 0) {
list ($local, $domain) = explode('@', $user->email);
$domain = Domain::where('namespace', $domain)->first();
return DomainsController::execProcessStep($domain, $step);
}
switch ($step) {
case 'user-ldap-ready':
- // User not in LDAP, create it
- $job = new \App\Jobs\User\CreateJob($user->id);
- $job->handle();
-
- $user->refresh();
-
- return $user->isLdapReady();
-
case 'user-imap-ready':
- // User not in IMAP? Verify again
- // Do it synchronously if the imap admin credentials are available
- // otherwise let the worker do the job
- if (!\config('imap.admin_password')) {
- \App\Jobs\User\VerifyJob::dispatch($user->id);
-
- return null;
- }
-
- $job = new \App\Jobs\User\VerifyJob($user->id);
- $job->handle();
-
- $user->refresh();
-
- return $user->isImapReady();
+ // Use worker to do the job, frontend might not have the IMAP admin credentials
+ \App\Jobs\User\CreateJob::dispatch($user->id);
+ return null;
}
} catch (\Exception $e) {
\Log::error($e);
}
return false;
}
/**
* Email address validation for use as a user mailbox (login).
*
* @param string $email Email address
* @param \App\User $user The account owner
* @param null|\App\User|\App\Group $deleted Filled with an instance of a deleted user or group
* with the specified email address, if exists
*
* @return ?string Error message on validation error
*/
public static function validateEmail(string $email, \App\User $user, &$deleted = null): ?string
{
$deleted = null;
if (strpos($email, '@') === false) {
return \trans('validation.entryinvalid', ['attribute' => 'email']);
}
list($login, $domain) = explode('@', Str::lower($email));
if (strlen($login) === 0 || strlen($domain) === 0) {
return \trans('validation.entryinvalid', ['attribute' => 'email']);
}
// Check if domain exists
$domain = Domain::withObjectTenantContext($user)->where('namespace', $domain)->first();
if (empty($domain)) {
return \trans('validation.domaininvalid');
}
// Validate login part alone
$v = Validator::make(
['email' => $login],
['email' => ['required', new UserEmailLocal(!$domain->isPublic())]]
);
if ($v->fails()) {
return $v->errors()->toArray()['email'][0];
}
// Check if it is one of domains available to the user
if (!$domain->isPublic() && $user->id != $domain->walletOwner()->id) {
return \trans('validation.entryexists', ['attribute' => 'domain']);
}
// Check if a user/group/resource/shared folder with specified address already exists
if (
($existing = User::emailExists($email, true))
|| ($existing = \App\Group::emailExists($email, true))
|| ($existing = \App\Resource::emailExists($email, true))
|| ($existing = \App\SharedFolder::emailExists($email, true))
) {
// If this is a deleted user/group/resource/folder in the same custom domain
// we'll force delete it before creating the target user
if (!$domain->isPublic() && $existing->trashed()) {
$deleted = $existing;
} else {
return \trans('validation.entryexists', ['attribute' => 'email']);
}
}
// Check if an alias with specified address already exists.
if (User::aliasExists($email) || \App\SharedFolder::aliasExists($email)) {
return \trans('validation.entryexists', ['attribute' => 'email']);
}
return null;
}
/**
* Email address validation for use as an alias.
*
* @param string $email Email address
* @param \App\User $user The account owner
*
* @return ?string Error message on validation error
*/
public static function validateAlias(string $email, \App\User $user): ?string
{
if (strpos($email, '@') === false) {
return \trans('validation.entryinvalid', ['attribute' => 'alias']);
}
list($login, $domain) = explode('@', Str::lower($email));
if (strlen($login) === 0 || strlen($domain) === 0) {
return \trans('validation.entryinvalid', ['attribute' => 'alias']);
}
// Check if domain exists
$domain = Domain::withObjectTenantContext($user)->where('namespace', $domain)->first();
if (empty($domain)) {
return \trans('validation.domaininvalid');
}
// Validate login part alone
$v = Validator::make(
['alias' => $login],
['alias' => ['required', new UserEmailLocal(!$domain->isPublic())]]
);
if ($v->fails()) {
return $v->errors()->toArray()['alias'][0];
}
// Check if it is one of domains available to the user
if (!$domain->isPublic() && $user->id != $domain->walletOwner()->id) {
return \trans('validation.entryexists', ['attribute' => 'domain']);
}
// Check if a user with specified address already exists
if ($existing_user = User::emailExists($email, true)) {
// Allow an alias in a custom domain to an address that was a user before
if ($domain->isPublic() || !$existing_user->trashed()) {
return \trans('validation.entryexists', ['attribute' => 'alias']);
}
}
// Check if a group/resource/shared folder with specified address already exists
if (
\App\Group::emailExists($email)
|| \App\Resource::emailExists($email)
|| \App\SharedFolder::emailExists($email)
) {
return \trans('validation.entryexists', ['attribute' => 'alias']);
}
// Check if an alias with specified address already exists
if (User::aliasExists($email) || \App\SharedFolder::aliasExists($email)) {
// Allow assigning the same alias to a user in the same group account,
// but only for non-public domains
if ($domain->isPublic()) {
return \trans('validation.entryexists', ['attribute' => 'alias']);
}
}
return null;
}
/**
* Activate password reset code (if set), and assign it to a user.
*
* @param \App\User $user The user
*/
protected function activatePassCode(User $user): void
{
// Activate the password reset code
if ($this->passCode) {
$this->passCode->user_id = $user->id;
$this->passCode->active = true;
$this->passCode->save();
}
}
}
diff --git a/src/app/Http/Controllers/RelationController.php b/src/app/Http/Controllers/RelationController.php
index f9c2625f..c51b636a 100644
--- a/src/app/Http/Controllers/RelationController.php
+++ b/src/app/Http/Controllers/RelationController.php
@@ -1,392 +1,404 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Support\Str;
class RelationController extends ResourceController
{
/** @var array Common object properties in the API response */
protected $objectProps = [];
/** @var string Resource localization label */
protected $label = '';
/** @var string Resource model name */
protected $model = '';
/** @var array Resource listing order (column names) */
protected $order = [];
/** @var array Resource relation method arguments */
protected $relationArgs = [];
/**
* Delete a resource.
*
* @param string $id Resource identifier
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function destroy($id)
{
$resource = $this->model::find($id);
if (!$this->checkTenant($resource)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canDelete($resource)) {
return $this->errorResponse(403);
}
$resource->delete();
return response()->json([
'status' => 'success',
'message' => \trans("app.{$this->label}-delete-success"),
]);
}
/**
* Listing of resources belonging to the authenticated user.
*
* The resource entitlements billed to the current user wallet(s)
*
* @return \Illuminate\Http\JsonResponse
*/
public function index()
{
$user = $this->guard()->user();
$method = Str::plural(\lcfirst(\class_basename($this->model)));
$query = call_user_func_array([$user, $method], $this->relationArgs);
if (!empty($this->order)) {
foreach ($this->order as $col) {
$query->orderBy($col);
}
}
// TODO: Search and paging
$result = $query->get()
->map(function ($resource) {
return $this->objectToClient($resource);
});
$result = [
'list' => $result,
'count' => count($result),
'hasMore' => false,
'message' => \trans("app.search-foundx{$this->label}s", ['x' => count($result)]),
];
return response()->json($result);
}
/**
* Prepare resource statuses for the UI
*
* @param object $resource Resource object
*
* @return array Statuses array
*/
protected static function objectState($resource): array
{
$state = [];
$reflect = new \ReflectionClass(get_class($resource));
foreach (array_keys($reflect->getConstants()) as $const) {
if (strpos($const, 'STATUS_') === 0 && $const != 'STATUS_NEW') {
$method = Str::camel('is_' . strtolower(substr($const, 7)));
$state[$method] = $resource->{$method}();
}
}
if (empty($state['isDeleted']) && method_exists($resource, 'trashed')) {
$state['isDeleted'] = $resource->trashed();
}
return $state;
}
/**
* Prepare a resource object for the UI.
*
* @param object $object An object
* @param bool $full Include all object properties
*
* @return array Object information
*/
protected function objectToClient($object, bool $full = false): array
{
if ($full) {
$result = $object->toArray();
unset($result['tenant_id']);
} else {
$result = ['id' => $object->id];
foreach ($this->objectProps as $prop) {
$result[$prop] = $object->{$prop};
}
}
$result = array_merge($result, $this->objectState($object));
return $result;
}
/**
* Object status' process information.
*
* @param object $object The object to process
* @param array $steps The steps definition
*
* @return array Process state information
*/
protected static function processStateInfo($object, array $steps): array
{
$process = [];
+ $withLdap = \config('app.with_ldap');
+ $withImap = \config('app.with_imap');
// Create a process check list
foreach ($steps as $step_name => $state) {
+ // Remove LDAP related steps if the backend is disabled
+ if (!$withLdap && strpos($step_name, '-ldap-')) {
+ continue;
+ }
+
+ // Remove IMAP related steps if the backend is disabled
+ if (!$withImap && strpos($step_name, '-imap-')) {
+ continue;
+ }
+
$step = [
'label' => $step_name,
'title' => \trans("app.process-{$step_name}"),
];
if (is_array($state)) {
$step['link'] = $state[1];
$state = $state[0];
}
$step['state'] = $state;
$process[] = $step;
}
// Add domain specific steps
if (method_exists($object, 'domain')) {
$domain = $object->domain();
// If that is not a public domain
if ($domain && !$domain->isPublic()) {
$domain_status = API\V4\DomainsController::statusInfo($domain);
$process = array_merge($process, $domain_status['process']);
}
}
$all = count($process);
$checked = count(array_filter($process, function ($v) {
return $v['state'];
}));
$state = $all === $checked ? 'done' : 'running';
// After 180 seconds assume the process is in failed state,
// this should unlock the Refresh button in the UI
if ($all !== $checked && $object->created_at->diffInSeconds(\Carbon\Carbon::now()) > 180) {
$state = 'failed';
}
return [
'process' => $process,
'processState' => $state,
'isReady' => $all === $checked,
];
}
/**
* Object status' process information update.
*
* @param object $object The object to process
*
* @return array Process state information
*/
protected function processStateUpdate($object): array
{
$response = $this->statusInfo($object);
if (!empty(request()->input('refresh'))) {
$updated = false;
$async = false;
$last_step = 'none';
foreach ($response['process'] as $idx => $step) {
$last_step = $step['label'];
if (!$step['state']) {
$exec = $this->execProcessStep($object, $step['label']); // @phpstan-ignore-line
if (!$exec) {
if ($exec === null) {
$async = true;
}
break;
}
$updated = true;
}
}
if ($updated) {
$response = $this->statusInfo($object);
}
$success = $response['isReady'];
$suffix = $success ? 'success' : 'error-' . $last_step;
$response['status'] = $success ? 'success' : 'error';
$response['message'] = \trans('app.process-' . $suffix);
if ($async && !$success) {
$response['processState'] = 'waiting';
$response['status'] = 'success';
$response['message'] = \trans('app.process-async');
}
}
return $response;
}
/**
* Set the resource configuration.
*
* @param int $id Resource identifier
*
* @return \Illuminate\Http\JsonResponse|void
*/
public function setConfig($id)
{
$resource = $this->model::find($id);
if (!method_exists($this->model, 'setConfig')) {
return $this->errorResponse(404);
}
if (!$this->checkTenant($resource)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canUpdate($resource)) {
return $this->errorResponse(403);
}
$errors = $resource->setConfig(request()->input());
if (!empty($errors)) {
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
return response()->json([
'status' => 'success',
'message' => \trans("app.{$this->label}-setconfig-success"),
]);
}
/**
* Display information of a resource specified by $id.
*
* @param string $id The resource to show information for.
*
* @return \Illuminate\Http\JsonResponse
*/
public function show($id)
{
$resource = $this->model::find($id);
if (!$this->checkTenant($resource)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canRead($resource)) {
return $this->errorResponse(403);
}
$response = $this->objectToClient($resource, true);
if (!empty($statusInfo = $this->statusInfo($resource))) {
$response['statusInfo'] = $statusInfo;
}
// Resource configuration, e.g. sender_policy, invitation_policy, acl
if (method_exists($resource, 'getConfig')) {
$response['config'] = $resource->getConfig();
}
if (method_exists($resource, 'aliases')) {
$response['aliases'] = $resource->aliases()->pluck('alias')->all();
}
// Entitlements/Wallet info
if (method_exists($resource, 'wallet')) {
API\V4\SkusController::objectEntitlements($resource, $response);
}
return response()->json($response);
}
/**
* Get a list of SKUs available to the resource.
*
* @param int $id Resource identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function skus($id)
{
$resource = $this->model::find($id);
if (!$this->checkTenant($resource)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canRead($resource)) {
return $this->errorResponse(403);
}
return API\V4\SkusController::objectSkus($resource);
}
/**
* Fetch resource status (and reload setup process)
*
* @param int $id Resource identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function status($id)
{
$resource = $this->model::find($id);
if (!$this->checkTenant($resource)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canRead($resource)) {
return $this->errorResponse(403);
}
$response = $this->processStateUpdate($resource);
$response = array_merge($response, $this->objectState($resource));
return response()->json($response);
}
/**
* Resource status (extended) information
*
* @param object $resource Resource object
*
* @return array Status information
*/
public static function statusInfo($resource): array
{
return [];
}
}
diff --git a/src/app/Jobs/Domain/CreateJob.php b/src/app/Jobs/Domain/CreateJob.php
index cbe90e97..71386735 100644
--- a/src/app/Jobs/Domain/CreateJob.php
+++ b/src/app/Jobs/Domain/CreateJob.php
@@ -1,31 +1,31 @@
<?php
namespace App\Jobs\Domain;
use App\Jobs\DomainJob;
class CreateJob extends DomainJob
{
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$domain = $this->getDomain();
if (!$domain) {
return;
}
- if (!$domain->isLdapReady()) {
+ if (\config('app.with_ldap') && !$domain->isLdapReady()) {
\App\Backends\LDAP::createDomain($domain);
$domain->status |= \App\Domain::STATUS_LDAP_READY;
$domain->save();
-
- \App\Jobs\Domain\VerifyJob::dispatch($domain->id);
}
+
+ \App\Jobs\Domain\VerifyJob::dispatch($domain->id);
}
}
diff --git a/src/app/Jobs/Domain/DeleteJob.php b/src/app/Jobs/Domain/DeleteJob.php
index 5e070223..3de9a3e3 100644
--- a/src/app/Jobs/Domain/DeleteJob.php
+++ b/src/app/Jobs/Domain/DeleteJob.php
@@ -1,38 +1,37 @@
<?php
namespace App\Jobs\Domain;
use App\Jobs\DomainJob;
class DeleteJob extends DomainJob
{
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$domain = $this->getDomain();
if (!$domain) {
return;
}
// sanity checks
if ($domain->isDeleted()) {
$this->fail(new \Exception("Domain {$this->domainId} is already marked as deleted."));
return;
}
- \App\Backends\LDAP::deleteDomain($domain);
+ if (\config('app.with_ldap') && $domain->isLdapReady()) {
+ \App\Backends\LDAP::deleteDomain($domain);
- $domain->status |= \App\Domain::STATUS_DELETED;
-
- if ($domain->isLdapReady()) {
$domain->status ^= \App\Domain::STATUS_LDAP_READY;
}
+ $domain->status |= \App\Domain::STATUS_DELETED;
$domain->save();
}
}
diff --git a/src/app/Jobs/Domain/UpdateJob.php b/src/app/Jobs/Domain/UpdateJob.php
index de84fe08..95d00b75 100644
--- a/src/app/Jobs/Domain/UpdateJob.php
+++ b/src/app/Jobs/Domain/UpdateJob.php
@@ -1,29 +1,29 @@
<?php
namespace App\Jobs\Domain;
use App\Jobs\DomainJob;
class UpdateJob extends DomainJob
{
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$domain = $this->getDomain();
if (!$domain) {
return;
}
- if (!$domain->isLdapReady()) {
+ if (!\config('app.with_ldap') || !$domain->isLdapReady()) {
$this->delete();
return;
}
\App\Backends\LDAP::updateDomain($domain);
}
}
diff --git a/src/app/Jobs/Group/CreateJob.php b/src/app/Jobs/Group/CreateJob.php
index 4437a4a2..71abc690 100644
--- a/src/app/Jobs/Group/CreateJob.php
+++ b/src/app/Jobs/Group/CreateJob.php
@@ -1,29 +1,31 @@
<?php
namespace App\Jobs\Group;
use App\Jobs\GroupJob;
class CreateJob extends GroupJob
{
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$group = $this->getGroup();
if (!$group) {
return;
}
- if (!$group->isLdapReady()) {
+ if (\config('app.with_ldap') && !$group->isLdapReady()) {
\App\Backends\LDAP::createGroup($group);
$group->status |= \App\Group::STATUS_LDAP_READY;
- $group->save();
}
+
+ $group->status |= \App\Group::STATUS_ACTIVE;
+ $group->save();
}
}
diff --git a/src/app/Jobs/Group/DeleteJob.php b/src/app/Jobs/Group/DeleteJob.php
index 2d550693..cbf3a4d9 100644
--- a/src/app/Jobs/Group/DeleteJob.php
+++ b/src/app/Jobs/Group/DeleteJob.php
@@ -1,38 +1,45 @@
<?php
namespace App\Jobs\Group;
use App\Jobs\GroupJob;
class DeleteJob extends GroupJob
{
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$group = $this->getGroup();
if (!$group) {
return;
}
// sanity checks
if ($group->isDeleted()) {
$this->fail(new \Exception("Group {$this->groupId} is already marked as deleted."));
return;
}
- \App\Backends\LDAP::deleteGroup($group);
+ if (\config('app.with_ldap') && $group->isLdapReady()) {
+ \App\Backends\LDAP::deleteGroup($group);
- $group->status |= \App\Group::STATUS_DELETED;
-
- if ($group->isLdapReady()) {
$group->status ^= \App\Group::STATUS_LDAP_READY;
}
+/*
+ if (\config('app.with_imap') && $group->isImapReady()) {
+ if (!\App\Backends\IMAP::deleteGroup($group)) {
+ throw new \Exception("Failed to delete group {$this->groupId} from IMAP.");
+ }
+ $group->status ^= \App\Group::STATUS_IMAP_READY;
+ }
+*/
+ $group->status |= \App\Group::STATUS_DELETED;
$group->save();
}
}
diff --git a/src/app/Jobs/Group/UpdateJob.php b/src/app/Jobs/Group/UpdateJob.php
index f90cd63f..fea4e925 100644
--- a/src/app/Jobs/Group/UpdateJob.php
+++ b/src/app/Jobs/Group/UpdateJob.php
@@ -1,48 +1,48 @@
<?php
namespace App\Jobs\Group;
use App\Backends\LDAP;
use App\Jobs\GroupJob;
class UpdateJob extends GroupJob
{
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$group = $this->getGroup();
if (!$group) {
return;
}
// Cancel the update if the group is deleted or not yet in LDAP
- if (!$group->isLdapReady() || $group->isDeleted()) {
+ if (!\config('app.with_ldap') || !$group->isLdapReady() || $group->isDeleted()) {
$this->delete();
return;
}
LDAP::connect();
// Groups does not have an attribute for the status, therefore
// we remove suspended groups from LDAP.
// We do not remove STATUS_LDAP_READY flag because it is part of the
// setup process.
$inLdap = !empty(LDAP::getGroup($group->email));
if ($group->isSuspended() && $inLdap) {
LDAP::deleteGroup($group);
} elseif (!$group->isSuspended() && !$inLdap) {
LDAP::createGroup($group);
} else {
LDAP::updateGroup($group);
}
LDAP::disconnect();
}
}
diff --git a/src/app/Jobs/IMAP/AclCleanupJob.php b/src/app/Jobs/IMAP/AclCleanupJob.php
new file mode 100644
index 00000000..cbd6606c
--- /dev/null
+++ b/src/app/Jobs/IMAP/AclCleanupJob.php
@@ -0,0 +1,59 @@
+<?php
+
+namespace App\Jobs\IMAP;
+
+use App\Jobs\CommonJob;
+
+/**
+ * Remove ACL for a specified user/group anywhere in IMAP
+ */
+class AclCleanupJob extends CommonJob
+{
+ /**
+ * The ACL identifier
+ *
+ * @var string
+ */
+ protected $ident;
+
+ /**
+ * The ACL subject domain
+ *
+ * @var string
+ */
+ protected $domain;
+
+ /**
+ * The number of seconds the job can run before timing out.
+ *
+ * @var int
+ */
+ public $timeout = 60 * 60;
+
+
+ /**
+ * Create a new job instance.
+ *
+ * @param string $ident ACL identifier
+ * @param string $domain ACL domain
+ *
+ * @return void
+ */
+ public function __construct(string $ident, string $domain = '')
+ {
+ $this->ident = $ident;
+ $this->domain = $domain;
+ }
+
+ /**
+ * Execute the job.
+ *
+ * @return void
+ *
+ * @throws \Exception
+ */
+ public function handle()
+ {
+ \App\Backends\IMAP::aclCleanup($this->ident, $this->domain);
+ }
+}
diff --git a/src/app/Jobs/Resource/CreateJob.php b/src/app/Jobs/Resource/CreateJob.php
index 5cd2cc7e..92e0fcd1 100644
--- a/src/app/Jobs/Resource/CreateJob.php
+++ b/src/app/Jobs/Resource/CreateJob.php
@@ -1,61 +1,71 @@
<?php
namespace App\Jobs\Resource;
use App\Jobs\ResourceJob;
class CreateJob extends ResourceJob
{
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$resource = $this->getResource();
if (!$resource) {
return;
}
// sanity checks
if ($resource->isDeleted()) {
$this->fail(new \Exception("Resource {$this->resourceId} is marked as deleted."));
return;
}
if ($resource->trashed()) {
$this->fail(new \Exception("Resource {$this->resourceId} is actually deleted."));
return;
}
- if ($resource->isLdapReady()) {
- $this->fail(new \Exception("Resource {$this->resourceId} is already marked as ldap-ready."));
- return;
- }
+ $withLdap = \config('app.with_ldap');
// see if the domain is ready
$domain = $resource->domain();
if (!$domain) {
$this->fail(new \Exception("The domain for resource {$this->resourceId} does not exist."));
return;
}
if ($domain->isDeleted()) {
$this->fail(new \Exception("The domain for resource {$this->resourceId} is marked as deleted."));
return;
}
- if (!$domain->isLdapReady()) {
+ if ($withLdap && !$domain->isLdapReady()) {
$this->release(60);
return;
}
- \App\Backends\LDAP::createResource($resource);
+ if ($withLdap && !$resource->isLdapReady()) {
+ \App\Backends\LDAP::createResource($resource);
+
+ $resource->status |= \App\Resource::STATUS_LDAP_READY;
+ $resource->save();
+ }
+
+ if (\config('app.with_imap') && !$resource->isImapReady()) {
+ if (!\App\Backends\IMAP::createResource($resource)) {
+ throw new \Exception("Failed to create mailbox for resource {$this->resourceId}.");
+ }
+
+ $resource->status |= \App\Resource::STATUS_IMAP_READY;
+ }
- $resource->status |= \App\Resource::STATUS_LDAP_READY;
+ $resource->status |= \App\Resource::STATUS_ACTIVE;
$resource->save();
}
}
diff --git a/src/app/Jobs/Resource/DeleteJob.php b/src/app/Jobs/Resource/DeleteJob.php
index c109e2ac..9ffe9bbf 100644
--- a/src/app/Jobs/Resource/DeleteJob.php
+++ b/src/app/Jobs/Resource/DeleteJob.php
@@ -1,42 +1,46 @@
<?php
namespace App\Jobs\Resource;
use App\Jobs\ResourceJob;
class DeleteJob extends ResourceJob
{
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$resource = $this->getResource();
if (!$resource) {
return;
}
// sanity checks
if ($resource->isDeleted()) {
$this->fail(new \Exception("Resource {$this->resourceId} is already marked as deleted."));
return;
}
- \App\Backends\LDAP::deleteResource($resource);
+ if (\config('app.with_ldap') && $resource->isLdapReady()) {
+ \App\Backends\LDAP::deleteResource($resource);
- $resource->status |= \App\Resource::STATUS_DELETED;
-
- if ($resource->isLdapReady()) {
$resource->status ^= \App\Resource::STATUS_LDAP_READY;
+ $resource->save();
}
- if ($resource->isImapReady()) {
+ if (\config('app.with_imap') && $resource->isImapReady()) {
+ if (!\App\Backends\IMAP::deleteResource($resource)) {
+ throw new \Exception("Failed to delete mailbox for resource {$this->resourceId}.");
+ }
+
$resource->status ^= \App\Resource::STATUS_IMAP_READY;
}
+ $resource->status |= \App\Resource::STATUS_DELETED;
$resource->save();
}
}
diff --git a/src/app/Jobs/Resource/UpdateJob.php b/src/app/Jobs/Resource/UpdateJob.php
index 86b7d201..090631c9 100644
--- a/src/app/Jobs/Resource/UpdateJob.php
+++ b/src/app/Jobs/Resource/UpdateJob.php
@@ -1,30 +1,38 @@
<?php
namespace App\Jobs\Resource;
use App\Jobs\ResourceJob;
class UpdateJob extends ResourceJob
{
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$resource = $this->getResource();
if (!$resource) {
return;
}
- // Cancel the update if the resource is deleted or not yet in LDAP
- if (!$resource->isLdapReady() || $resource->isDeleted()) {
+ // Cancel the update if the resource is deleted
+ if ($resource->isDeleted()) {
$this->delete();
return;
}
- \App\Backends\LDAP::updateResource($resource);
+ if (\config('app.with_ldap') && $resource->isLdapReady()) {
+ \App\Backends\LDAP::updateResource($resource);
+ }
+
+ if (\config('app.with_imap') && $resource->isImapReady()) {
+ if (!\App\Backends\IMAP::updateResource($resource, $this->properties)) {
+ throw new \Exception("Failed to update mailbox for resource {$this->resourceId}.");
+ }
+ }
}
}
diff --git a/src/app/Jobs/ResourceJob.php b/src/app/Jobs/ResourceJob.php
index 74cc2a3d..219e937f 100644
--- a/src/app/Jobs/ResourceJob.php
+++ b/src/app/Jobs/ResourceJob.php
@@ -1,73 +1,82 @@
<?php
namespace App\Jobs;
/**
* The abstract \App\Jobs\ResourceJob implements the logic needed for all dispatchable Jobs related to
* \App\Resource objects.
*
* ```php
* $job = new \App\Jobs\Resource\CreateJob($resourceId);
* $job->handle();
* ```
*/
abstract class ResourceJob extends CommonJob
{
+ /**
+ * Old values of the resource properties on update (key -> value)
+ *
+ * @var array
+ */
+ protected $properties = [];
+
/**
* The ID for the \App\Resource. This is the shortest globally unique identifier and saves Redis space
* compared to a serialized version of the complete \App\Resource object.
*
* @var int
*/
protected $resourceId;
/**
* The \App\Resource email property, for legibility in the queue management.
*
* @var string
*/
protected $resourceEmail;
/**
* Create a new job instance.
*
- * @param int $resourceId The ID for the resource to process.
+ * @param int $resourceId The ID for the resource to process.
+ * @param array $properties Old values of the resource properties on update
*
* @return void
*/
- public function __construct(int $resourceId)
+ public function __construct(int $resourceId, array $properties = [])
{
$this->resourceId = $resourceId;
+ $this->properties = $properties;
$resource = $this->getResource();
if ($resource) {
$this->resourceEmail = $resource->email;
}
}
/**
* Get the \App\Resource entry associated with this job.
*
* @return \App\Resource|null
*
* @throws \Exception
*/
protected function getResource()
{
$resource = \App\Resource::withTrashed()->find($this->resourceId);
if (!$resource) {
// The record might not exist yet in case of a db replication environment
// This will release the job and delay another attempt for 5 seconds
if ($this instanceof Resource\CreateJob) {
$this->release(5);
return null;
}
$this->fail(new \Exception("Resource {$this->resourceId} could not be found in the database."));
}
return $resource;
}
}
diff --git a/src/app/Jobs/SharedFolder/CreateJob.php b/src/app/Jobs/SharedFolder/CreateJob.php
index 7e2cf586..0c44cb64 100644
--- a/src/app/Jobs/SharedFolder/CreateJob.php
+++ b/src/app/Jobs/SharedFolder/CreateJob.php
@@ -1,61 +1,72 @@
<?php
namespace App\Jobs\SharedFolder;
use App\Jobs\SharedFolderJob;
class CreateJob extends SharedFolderJob
{
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$folder = $this->getSharedFolder();
if (!$folder) {
return;
}
// sanity checks
if ($folder->isDeleted()) {
$this->fail(new \Exception("Shared folder {$this->folderId} is marked as deleted."));
return;
}
if ($folder->trashed()) {
$this->fail(new \Exception("Shared folder {$this->folderId} is actually deleted."));
return;
}
- if ($folder->isLdapReady()) {
- $this->fail(new \Exception("Shared folder {$this->folderId} is already marked as ldap-ready."));
- return;
- }
+ $withLdap = \config('app.with_ldap');
+ $withImap = \config('app.with_imap');
// see if the domain is ready
$domain = $folder->domain();
if (!$domain) {
$this->fail(new \Exception("The domain for shared folder {$this->folderId} does not exist."));
return;
}
if ($domain->isDeleted()) {
$this->fail(new \Exception("The domain for shared folder {$this->folderId} is marked as deleted."));
return;
}
- if (!$domain->isLdapReady()) {
+ if ($withLdap && !$domain->isLdapReady()) {
$this->release(60);
return;
}
- \App\Backends\LDAP::createSharedFolder($folder);
+ if ($withLdap && !$folder->isLdapReady()) {
+ \App\Backends\LDAP::createSharedFolder($folder);
+
+ $folder->status |= \App\SharedFolder::STATUS_LDAP_READY;
+ $folder->save();
+ }
+
+ if ($withImap && !$folder->isImapReady()) {
+ if (!\App\Backends\IMAP::createSharedFolder($folder)) {
+ throw new \Exception("Failed to create mailbox for shared folder {$this->folderId}.");
+ }
+
+ $folder->status |= \App\SharedFolder::STATUS_IMAP_READY;
+ }
- $folder->status |= \App\SharedFolder::STATUS_LDAP_READY;
+ $folder->status |= \App\SharedFolder::STATUS_ACTIVE;
$folder->save();
}
}
diff --git a/src/app/Jobs/SharedFolder/DeleteJob.php b/src/app/Jobs/SharedFolder/DeleteJob.php
index 361d25dc..8e4d5444 100644
--- a/src/app/Jobs/SharedFolder/DeleteJob.php
+++ b/src/app/Jobs/SharedFolder/DeleteJob.php
@@ -1,42 +1,47 @@
<?php
namespace App\Jobs\SharedFolder;
use App\Jobs\SharedFolderJob;
class DeleteJob extends SharedFolderJob
{
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$folder = $this->getSharedFolder();
if (!$folder) {
return;
}
// sanity checks
if ($folder->isDeleted()) {
$this->fail(new \Exception("Shared folder {$this->folderId} is already marked as deleted."));
return;
}
- \App\Backends\LDAP::deleteSharedFolder($folder);
+ if (\config('app.with_ldap') && $folder->isLdapReady()) {
+ \App\Backends\LDAP::deleteSharedFolder($folder);
- $folder->status |= \App\SharedFolder::STATUS_DELETED;
-
- if ($folder->isLdapReady()) {
$folder->status ^= \App\SharedFolder::STATUS_LDAP_READY;
+ // Already save in case of exception below
+ $folder->save();
}
if ($folder->isImapReady()) {
+ if (!\App\Backends\IMAP::deleteSharedFolder($folder)) {
+ throw new \Exception("Failed to delete mailbox for shared folder {$this->folderId}.");
+ }
+
$folder->status ^= \App\SharedFolder::STATUS_IMAP_READY;
}
+ $folder->status |= \App\SharedFolder::STATUS_DELETED;
$folder->save();
}
}
diff --git a/src/app/Jobs/SharedFolder/UpdateJob.php b/src/app/Jobs/SharedFolder/UpdateJob.php
index 7cd6e420..eb9682d7 100644
--- a/src/app/Jobs/SharedFolder/UpdateJob.php
+++ b/src/app/Jobs/SharedFolder/UpdateJob.php
@@ -1,30 +1,38 @@
<?php
namespace App\Jobs\SharedFolder;
use App\Jobs\SharedFolderJob;
class UpdateJob extends SharedFolderJob
{
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$folder = $this->getSharedFolder();
if (!$folder) {
return;
}
- // Cancel the update if the folder is deleted or not yet in LDAP
- if (!$folder->isLdapReady() || $folder->isDeleted()) {
+ // Cancel the update if the folder is deleted
+ if ($folder->isDeleted()) {
$this->delete();
return;
}
- \App\Backends\LDAP::updateSharedFolder($folder);
+ if (\config('app.with_ldap') && $folder->isLdapReady()) {
+ \App\Backends\LDAP::updateSharedFolder($folder);
+ }
+
+ if (\config('app.with_imap') && $folder->isImapReady()) {
+ if (!\App\Backends\IMAP::updateSharedFolder($folder, $this->properties)) {
+ throw new \Exception("Failed to update mailbox for shared folder {$this->folderId}.");
+ }
+ }
}
}
diff --git a/src/app/Jobs/SharedFolderJob.php b/src/app/Jobs/SharedFolderJob.php
index 304b3dd7..d90904a2 100644
--- a/src/app/Jobs/SharedFolderJob.php
+++ b/src/app/Jobs/SharedFolderJob.php
@@ -1,72 +1,81 @@
<?php
namespace App\Jobs;
/**
* The abstract \App\Jobs\SharedFolderJob implements the logic needed for all dispatchable Jobs related to
* \App\SharedFolder objects.
*
* ```php
* $job = new \App\Jobs\SharedFolder\CreateJob($folderId);
* $job->handle();
* ```
*/
abstract class SharedFolderJob extends CommonJob
{
/**
* The ID for the \App\SharedFolder. This is the shortest globally unique identifier and saves Redis space
* compared to a serialized version of the complete \App\SharedFolder object.
*
* @var int
*/
protected $folderId;
/**
* The \App\SharedFolder email property, for legibility in the queue management.
*
* @var string
*/
protected $folderEmail;
+ /**
+ * Old values of the shared folder properties on update (key -> value)
+ *
+ * @var array
+ */
+ protected $properties = [];
+
/**
* Create a new job instance.
*
- * @param int $folderId The ID for the shared folder to process.
+ * @param int $folderId The ID for the shared folder to process
+ * @param array $properties Old values of the shared folder properties on update (key -> value)
*
* @return void
*/
- public function __construct(int $folderId)
+ public function __construct(int $folderId, array $properties = [])
{
$this->folderId = $folderId;
+ $this->properties = $properties;
$folder = $this->getSharedFolder();
if ($folder) {
$this->folderEmail = $folder->email;
}
}
/**
* Get the \App\SharedFolder entry associated with this job.
*
* @return \App\SharedFolder|null
*
* @throws \Exception
*/
protected function getSharedFolder()
{
$folder = \App\SharedFolder::withTrashed()->find($this->folderId);
if (!$folder) {
// The record might not exist yet in case of a db replication environment
// This will release the job and delay another attempt for 5 seconds
if ($this instanceof SharedFolder\CreateJob) {
$this->release(5);
return null;
}
$this->fail(new \Exception("Shared folder {$this->folderId} could not be found in the database."));
}
return $folder;
}
}
diff --git a/src/app/Jobs/User/CreateJob.php b/src/app/Jobs/User/CreateJob.php
index b9cafcd5..96e4fc93 100644
--- a/src/app/Jobs/User/CreateJob.php
+++ b/src/app/Jobs/User/CreateJob.php
@@ -1,86 +1,102 @@
<?php
namespace App\Jobs\User;
use App\Jobs\UserJob;
/**
* Create the \App\User in LDAP.
*
* Throws exceptions for the following reasons:
*
* * The user is marked as deleted (`$user->isDeleted()`), or
* * the user is actually deleted (`$user->deleted_at`), or
* * the user is already marked as ready in LDAP (`$user->isLdapReady()`).
*
*/
class CreateJob extends UserJob
{
/**
* Execute the job.
*
* @return void
*
* @throws \Exception
*/
public function handle()
{
$user = $this->getUser();
if (!$user) {
return;
}
if ($user->role) {
// Admins/resellers don't reside in LDAP (for now)
return;
}
// sanity checks
if ($user->isDeleted()) {
$this->fail(new \Exception("User {$this->userId} is marked as deleted."));
return;
}
- if ($user->deleted_at) {
+ if ($user->trashed()) {
$this->fail(new \Exception("User {$this->userId} is actually deleted."));
return;
}
- if ($user->isLdapReady()) {
- $this->fail(new \Exception("User {$this->userId} is already marked as ldap-ready."));
- return;
- }
+ $withLdap = \config('app.with_ldap');
// see if the domain is ready
$domain = $user->domain();
if (!$domain) {
$this->fail(new \Exception("The domain for {$this->userId} does not exist."));
return;
}
if ($domain->isDeleted()) {
$this->fail(new \Exception("The domain for {$this->userId} is marked as deleted."));
return;
}
- if (!$domain->isLdapReady()) {
+ if ($withLdap && !$domain->isLdapReady()) {
$this->release(60);
return;
}
if (\config('abuse.suspend_enabled')) {
$code = \Artisan::call("user:abuse-check {$this->userId}");
if ($code == 2) {
\Log::info("Suspending user due to suspected abuse: {$this->userId} {$user->email}");
$user->status |= \App\User::STATUS_SUSPENDED;
}
}
- \App\Backends\LDAP::createUser($user);
+ if ($withLdap && !$user->isLdapReady()) {
+ \App\Backends\LDAP::createUser($user);
+
+ $user->status |= \App\User::STATUS_LDAP_READY;
+ $user->save();
+ }
+
+ if (!$user->isImapReady()) {
+ if (\config('app.with_imap')) {
+ if (!\App\Backends\IMAP::createUser($user)) {
+ throw new \Exception("Failed to create mailbox for user {$this->userId}.");
+ }
+ } else {
+ if (!\App\Backends\IMAP::verifyAccount($user->email)) {
+ throw new \Exception("Failed to find the mailbox for user {$this->userId}.");
+ }
+ }
+
+ $user->status |= \App\User::STATUS_IMAP_READY;
+ }
- $user->status |= \App\User::STATUS_LDAP_READY;
+ $user->status |= \App\User::STATUS_ACTIVE;
$user->save();
}
}
diff --git a/src/app/Jobs/User/DeleteJob.php b/src/app/Jobs/User/DeleteJob.php
index 3569bb10..440e83ae 100644
--- a/src/app/Jobs/User/DeleteJob.php
+++ b/src/app/Jobs/User/DeleteJob.php
@@ -1,47 +1,57 @@
<?php
namespace App\Jobs\User;
use App\Jobs\UserJob;
class DeleteJob extends UserJob
{
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$user = $this->getUser();
if (!$user) {
return;
}
if ($user->role) {
// Admins/resellers don't reside in LDAP (for now)
return;
}
// sanity checks
if ($user->isDeleted()) {
$this->fail(new \Exception("User {$this->userId} is already marked as deleted."));
return;
}
- \App\Backends\LDAP::deleteUser($user);
+ if (\config('app.with_ldap') && $user->isLdapReady()) {
+ \App\Backends\LDAP::deleteUser($user);
- $user->status |= \App\User::STATUS_DELETED;
-
- if ($user->isLdapReady()) {
$user->status ^= \App\User::STATUS_LDAP_READY;
+ $user->save();
}
if ($user->isImapReady()) {
+ if (\config('app.with_imap')) {
+ if (!\App\Backends\IMAP::deleteUser($user)) {
+ throw new \Exception("Failed to delete mailbox for user {$this->userId}.");
+ }
+ }
+
$user->status ^= \App\User::STATUS_IMAP_READY;
}
+ if (\config('database.connections.roundcube')) {
+ \App\Backends\Roundcube::deleteUser($user->email);
+ }
+
+ $user->status |= \App\User::STATUS_DELETED;
$user->save();
}
}
diff --git a/src/app/Jobs/User/UpdateJob.php b/src/app/Jobs/User/UpdateJob.php
index 40214986..8584ffcb 100644
--- a/src/app/Jobs/User/UpdateJob.php
+++ b/src/app/Jobs/User/UpdateJob.php
@@ -1,34 +1,37 @@
<?php
namespace App\Jobs\User;
use App\Jobs\UserJob;
class UpdateJob extends UserJob
{
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$user = $this->getUser();
if (!$user) {
return;
}
if ($user->role) {
// Admins/resellers don't reside in LDAP (for now)
return;
}
- if (!$user->isLdapReady()) {
- $this->delete();
- return;
+ if (\config('app.with_ldap') && $user->isLdapReady()) {
+ \App\Backends\LDAP::updateUser($user);
}
- \App\Backends\LDAP::updateUser($user);
+ if (\config('app.with_imap') && $user->isImapReady()) {
+ if (!\App\Backends\IMAP::updateUser($user)) {
+ throw new \Exception("Failed to update mailbox for user {$this->userId}.");
+ }
+ }
}
}
diff --git a/src/app/Observers/EntitlementObserver.php b/src/app/Observers/EntitlementObserver.php
index 47982233..2025bca3 100644
--- a/src/app/Observers/EntitlementObserver.php
+++ b/src/app/Observers/EntitlementObserver.php
@@ -1,182 +1,192 @@
<?php
namespace App\Observers;
use App\Entitlement;
use Carbon\Carbon;
/**
* This is an observer for the Entitlement model definition.
*/
class EntitlementObserver
{
/**
* Handle the "creating" event on an Entitlement.
*
* Ensures that the entry uses a custom ID (uuid).
*
* Ensures that the {@link \App\Wallet} to which it is to be billed is owned or controlled by
* the {@link \App\User} entitled.
*
* @param Entitlement $entitlement The entitlement being created.
*
* @return bool
*/
public function creating(Entitlement $entitlement): bool
{
// can't dispatch job here because it'll fail serialization
// Make sure the owner is at least a controller on the wallet
$wallet = \App\Wallet::find($entitlement->wallet_id);
if (!$wallet || !$wallet->owner) {
return false;
}
$sku = \App\Sku::find($entitlement->sku_id);
if (!$sku) {
return false;
}
$result = $sku->handler_class::preReq($entitlement, $wallet->owner);
if (!$result) {
return false;
}
return true;
}
/**
* Handle the entitlement "created" event.
*
* @param \App\Entitlement $entitlement The entitlement.
*
* @return void
*/
public function created(Entitlement $entitlement)
{
$entitlement->entitleable->updated_at = Carbon::now();
$entitlement->entitleable->save();
$entitlement->createTransaction(\App\Transaction::ENTITLEMENT_CREATED);
+
+ // Update the user IMAP mailbox quota
+ if ($entitlement->sku->title == 'storage') {
+ \App\Jobs\User\UpdateJob::dispatch($entitlement->entitleable_id);
+ }
}
/**
* Handle the entitlement "deleted" event.
*
* @param \App\Entitlement $entitlement The entitlement.
*
* @return void
*/
public function deleted(Entitlement $entitlement)
{
+ if (!$entitlement->entitleable->trashed()) {
+ $entitlement->entitleable->updated_at = Carbon::now();
+ $entitlement->entitleable->save();
+
+ $entitlement->createTransaction(\App\Transaction::ENTITLEMENT_DELETED);
+ }
+
// Remove all configured 2FA methods from Roundcube database
if ($entitlement->sku->title == '2fa') {
// FIXME: Should that be an async job?
$sf = new \App\Auth\SecondFactor($entitlement->entitleable);
$sf->removeFactors();
}
- if (!$entitlement->entitleable->trashed()) {
- $entitlement->entitleable->updated_at = Carbon::now();
- $entitlement->entitleable->save();
-
- $entitlement->createTransaction(\App\Transaction::ENTITLEMENT_DELETED);
+ // Update the user IMAP mailbox quota
+ if ($entitlement->sku->title == 'storage') {
+ \App\Jobs\User\UpdateJob::dispatch($entitlement->entitleable_id);
}
}
/**
* Handle the entitlement "deleting" event.
*
* @param \App\Entitlement $entitlement The entitlement.
*
* @return void
*/
public function deleting(Entitlement $entitlement)
{
if ($entitlement->trashed()) {
return;
}
// Start calculating the costs for the consumption of this entitlement if the
// existing consumption spans >= 14 days.
//
// Effect is that anything's free for the first 14 days
if ($entitlement->created_at >= Carbon::now()->subDays(14)) {
return;
}
$owner = $entitlement->wallet->owner;
if ($owner->isDegraded()) {
return;
}
$now = Carbon::now();
// Determine if we're still within the trial period
$trial = $entitlement->wallet->trialInfo();
if (
!empty($trial)
&& $entitlement->updated_at < $trial['end']
&& in_array($entitlement->sku_id, $trial['skus'])
) {
if ($trial['end'] >= $now) {
return;
}
$entitlement->updated_at = $trial['end'];
}
// get the discount rate applied to the wallet.
$discount = $entitlement->wallet->getDiscountRate();
// just in case this had not been billed yet, ever
$diffInMonths = $entitlement->updated_at->diffInMonths($now);
$cost = (int) ($entitlement->cost * $discount * $diffInMonths);
$fee = (int) ($entitlement->fee * $diffInMonths);
// this moves the hypothetical updated at forward to however many months past the original
$updatedAt = $entitlement->updated_at->copy()->addMonthsWithoutOverflow($diffInMonths);
// now we have the diff in days since the last "billed" period end.
// This may be an entitlement paid up until February 28th, 2020, with today being March
// 12th 2020. Calculating the costs for the entitlement is based on the daily price
// the price per day is based on the number of days in the last month
// or the current month if the period does not overlap with the previous month
// FIXME: This really should be simplified to $daysInMonth=30
$diffInDays = $updatedAt->diffInDays($now);
if ($now->day >= $diffInDays) {
$daysInMonth = $now->daysInMonth;
} else {
$daysInMonth = \App\Utils::daysInLastMonth();
}
$pricePerDay = $entitlement->cost / $daysInMonth;
$feePerDay = $entitlement->fee / $daysInMonth;
$cost += (int) (round($pricePerDay * $discount * $diffInDays, 0));
$fee += (int) (round($feePerDay * $diffInDays, 0));
$profit = $cost - $fee;
if ($profit != 0 && $owner->tenant && ($wallet = $owner->tenant->wallet())) {
$desc = "Charged user {$owner->email}";
$method = $profit > 0 ? 'credit' : 'debit';
$wallet->{$method}(abs($profit), $desc);
}
if ($cost == 0) {
return;
}
// FIXME: Shouldn't we create per-entitlement transaction record?
$entitlement->wallet->debit($cost);
}
}
diff --git a/src/app/Observers/GroupObserver.php b/src/app/Observers/GroupObserver.php
index 8d50b211..73f46746 100644
--- a/src/app/Observers/GroupObserver.php
+++ b/src/app/Observers/GroupObserver.php
@@ -1,102 +1,102 @@
<?php
namespace App\Observers;
use App\Group;
use Illuminate\Support\Facades\DB;
class GroupObserver
{
/**
* Handle the group "created" event.
*
* @param \App\Group $group The group
*
* @return void
*/
public function creating(Group $group): void
{
- $group->status |= Group::STATUS_NEW | Group::STATUS_ACTIVE;
+ $group->status |= Group::STATUS_NEW;
if (!isset($group->name) && isset($group->email)) {
$group->name = explode('@', $group->email)[0];
}
}
/**
* Handle the group "created" event.
*
* @param \App\Group $group The group
*
* @return void
*/
public function created(Group $group)
{
\App\Jobs\Group\CreateJob::dispatch($group->id);
}
/**
* Handle the group "deleted" event.
*
* @param \App\Group $group The group
*
* @return void
*/
public function deleted(Group $group)
{
if ($group->isForceDeleting()) {
return;
}
\App\Jobs\Group\DeleteJob::dispatch($group->id);
}
/**
* Handle the group "updated" event.
*
* @param \App\Group $group The group
*
* @return void
*/
public function updated(Group $group)
{
\App\Jobs\Group\UpdateJob::dispatch($group->id);
}
/**
* Handle the group "restoring" event.
*
* @param \App\Group $group The group
*
* @return void
*/
public function restoring(Group $group)
{
// Make sure it's not DELETED/LDAP_READY/SUSPENDED anymore
if ($group->isDeleted()) {
$group->status ^= Group::STATUS_DELETED;
}
if ($group->isLdapReady()) {
$group->status ^= Group::STATUS_LDAP_READY;
}
if ($group->isSuspended()) {
$group->status ^= Group::STATUS_SUSPENDED;
}
$group->status |= Group::STATUS_ACTIVE;
// Note: $group->save() is invoked between 'restoring' and 'restored' events
}
/**
* Handle the group "restored" event.
*
* @param \App\Group $group The group
*
* @return void
*/
public function restored(Group $group)
{
\App\Jobs\Group\CreateJob::dispatch($group->id);
}
}
diff --git a/src/app/Observers/GroupSettingObserver.php b/src/app/Observers/GroupSettingObserver.php
index 3d73dd8c..3ede8d91 100644
--- a/src/app/Observers/GroupSettingObserver.php
+++ b/src/app/Observers/GroupSettingObserver.php
@@ -1,51 +1,59 @@
<?php
namespace App\Observers;
-use App\Backends\LDAP;
use App\GroupSetting;
class GroupSettingObserver
{
/**
* Handle the group setting "created" event.
*
* @param \App\GroupSetting $groupSetting Settings object
*
* @return void
*/
public function created(GroupSetting $groupSetting)
{
- if (in_array($groupSetting->key, LDAP::GROUP_SETTINGS)) {
- \App\Jobs\Group\UpdateJob::dispatch($groupSetting->group_id);
- }
+ $this->dispatchUpdateJob($groupSetting);
}
/**
* Handle the group setting "updated" event.
*
* @param \App\GroupSetting $groupSetting Settings object
*
* @return void
*/
public function updated(GroupSetting $groupSetting)
{
- if (in_array($groupSetting->key, LDAP::GROUP_SETTINGS)) {
- \App\Jobs\Group\UpdateJob::dispatch($groupSetting->group_id);
- }
+ $this->dispatchUpdateJob($groupSetting);
}
/**
* Handle the group setting "deleted" event.
*
* @param \App\GroupSetting $groupSetting Settings object
*
* @return void
*/
public function deleted(GroupSetting $groupSetting)
{
- if (in_array($groupSetting->key, LDAP::GROUP_SETTINGS)) {
+ $this->dispatchUpdateJob($groupSetting);
+ }
+
+ /**
+ * Dispatch group update job (if needed).
+ *
+ * @param \App\GroupSetting $groupSetting Settings object
+ */
+ private function dispatchUpdateJob(GroupSetting $groupSetting): void
+ {
+ if (
+ (\config('app.with_ldap') && in_array($groupSetting->key, \App\Backends\LDAP::GROUP_SETTINGS))
+ || (\config('app.with_imap') && in_array($groupSetting->key, \App\Backends\IMAP::GROUP_SETTINGS))
+ ) {
\App\Jobs\Group\UpdateJob::dispatch($groupSetting->group_id);
}
}
}
diff --git a/src/app/Observers/ResourceObserver.php b/src/app/Observers/ResourceObserver.php
index b46f7d54..4bf48e62 100644
--- a/src/app/Observers/ResourceObserver.php
+++ b/src/app/Observers/ResourceObserver.php
@@ -1,92 +1,88 @@
<?php
namespace App\Observers;
use App\Resource;
class ResourceObserver
{
/**
* Handle the resource "creating" event.
*
* @param \App\Resource $resource The resource
*
* @return void
*/
public function creating(Resource $resource): void
{
- $resource->status |= Resource::STATUS_NEW | Resource::STATUS_ACTIVE;
+ $resource->status |= Resource::STATUS_NEW;
}
/**
* Handle the resource "created" event.
*
* @param \App\Resource $resource The resource
*
* @return void
*/
public function created(Resource $resource)
{
$domainName = explode('@', $resource->email, 2)[1];
$settings = [
'folder' => "shared/Resources/{$resource->name}@{$domainName}",
];
foreach ($settings as $key => $value) {
$settings[$key] = [
'key' => $key,
'value' => $value,
'resource_id' => $resource->id,
];
}
// Note: Don't use setSettings() here to bypass ResourceSetting observers
// Note: This is a single multi-insert query
$resource->settings()->insert(array_values($settings));
- // Create resource record in LDAP, then check if it is created in IMAP
- $chain = [
- new \App\Jobs\Resource\VerifyJob($resource->id),
- ];
-
- \App\Jobs\Resource\CreateJob::withChain($chain)->dispatch($resource->id);
+ // Create the resource in the backend (LDAP and IMAP)
+ \App\Jobs\Resource\CreateJob::dispatch($resource->id);
}
/**
* Handle the resource "deleted" event.
*
* @param \App\Resource $resource The resource
*
* @return void
*/
public function deleted(Resource $resource)
{
if ($resource->isForceDeleting()) {
return;
}
\App\Jobs\Resource\DeleteJob::dispatch($resource->id);
}
/**
* Handle the resource "updated" event.
*
* @param \App\Resource $resource The resource
*
* @return void
*/
public function updated(Resource $resource)
{
\App\Jobs\Resource\UpdateJob::dispatch($resource->id);
// Update the folder property if name changed
if ($resource->name != $resource->getOriginal('name')) {
$domainName = explode('@', $resource->email, 2)[1];
$folder = "shared/Resources/{$resource->name}@{$domainName}";
// Note: This does not invoke ResourceSetting observer events, good.
$resource->settings()->where('key', 'folder')->update(['value' => $folder]);
}
}
}
diff --git a/src/app/Observers/ResourceSettingObserver.php b/src/app/Observers/ResourceSettingObserver.php
index 4af7ca81..9448a5ae 100644
--- a/src/app/Observers/ResourceSettingObserver.php
+++ b/src/app/Observers/ResourceSettingObserver.php
@@ -1,51 +1,60 @@
<?php
namespace App\Observers;
-use App\Backends\LDAP;
use App\ResourceSetting;
class ResourceSettingObserver
{
/**
* Handle the resource setting "created" event.
*
* @param \App\ResourceSetting $resourceSetting Settings object
*
* @return void
*/
public function created(ResourceSetting $resourceSetting)
{
- if (in_array($resourceSetting->key, LDAP::RESOURCE_SETTINGS)) {
- \App\Jobs\Resource\UpdateJob::dispatch($resourceSetting->resource_id);
- }
+ $this->dispatchUpdateJob($resourceSetting);
}
/**
* Handle the resource setting "updated" event.
*
* @param \App\ResourceSetting $resourceSetting Settings object
*
* @return void
*/
public function updated(ResourceSetting $resourceSetting)
{
- if (in_array($resourceSetting->key, LDAP::RESOURCE_SETTINGS)) {
- \App\Jobs\Resource\UpdateJob::dispatch($resourceSetting->resource_id);
- }
+ $this->dispatchUpdateJob($resourceSetting);
}
/**
* Handle the resource setting "deleted" event.
*
* @param \App\ResourceSetting $resourceSetting Settings object
*
* @return void
*/
public function deleted(ResourceSetting $resourceSetting)
{
- if (in_array($resourceSetting->key, LDAP::RESOURCE_SETTINGS)) {
- \App\Jobs\Resource\UpdateJob::dispatch($resourceSetting->resource_id);
+ $this->dispatchUpdateJob($resourceSetting);
+ }
+
+ /**
+ * Dispatch resource update job (if needed)
+ *
+ * @param \App\ResourceSetting $resourceSetting Settings object
+ */
+ private function dispatchUpdateJob(ResourceSetting $resourceSetting): void
+ {
+ if (
+ (\config('app.with_ldap') && in_array($resourceSetting->key, \App\Backends\LDAP::RESOURCE_SETTINGS))
+ || (\config('app.with_imap') && in_array($resourceSetting->key, \App\Backends\IMAP::RESOURCE_SETTINGS))
+ ) {
+ $props = [$resourceSetting->key => $resourceSetting->getOriginal('value')];
+ \App\Jobs\Resource\UpdateJob::dispatch($resourceSetting->resource_id, $props);
}
}
}
diff --git a/src/app/Observers/SharedFolderObserver.php b/src/app/Observers/SharedFolderObserver.php
index 10af85e1..7afb2365 100644
--- a/src/app/Observers/SharedFolderObserver.php
+++ b/src/app/Observers/SharedFolderObserver.php
@@ -1,96 +1,92 @@
<?php
namespace App\Observers;
use App\SharedFolder;
class SharedFolderObserver
{
/**
* Handle the shared folder "creating" event.
*
* @param \App\SharedFolder $folder The folder
*
* @return void
*/
public function creating(SharedFolder $folder): void
{
if (empty($folder->type)) {
$folder->type = 'mail';
}
- $folder->status |= SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE;
+ $folder->status |= SharedFolder::STATUS_NEW;
}
/**
* Handle the shared folder "created" event.
*
* @param \App\SharedFolder $folder The folder
*
* @return void
*/
public function created(SharedFolder $folder)
{
$domainName = explode('@', $folder->email, 2)[1];
$settings = [
'folder' => "shared/{$folder->name}@{$domainName}",
];
foreach ($settings as $key => $value) {
$settings[$key] = [
'key' => $key,
'value' => $value,
'shared_folder_id' => $folder->id,
];
}
// Note: Don't use setSettings() here to bypass SharedFolderSetting observers
// Note: This is a single multi-insert query
$folder->settings()->insert(array_values($settings));
- // Create folder record in LDAP, then check if it is created in IMAP
- $chain = [
- new \App\Jobs\SharedFolder\VerifyJob($folder->id),
- ];
-
- \App\Jobs\SharedFolder\CreateJob::withChain($chain)->dispatch($folder->id);
+ // Create the shared folder in the backend (LDAP and IMAP)
+ \App\Jobs\SharedFolder\CreateJob::dispatch($folder->id);
}
/**
* Handle the shared folder "deleted" event.
*
* @param \App\SharedFolder $folder The folder
*
* @return void
*/
public function deleted(SharedFolder $folder)
{
if ($folder->isForceDeleting()) {
return;
}
\App\Jobs\SharedFolder\DeleteJob::dispatch($folder->id);
}
/**
* Handle the shared folder "updated" event.
*
* @param \App\SharedFolder $folder The folder
*
* @return void
*/
public function updated(SharedFolder $folder)
{
\App\Jobs\SharedFolder\UpdateJob::dispatch($folder->id);
// Update the folder property if name changed
if ($folder->name != $folder->getOriginal('name')) {
$domainName = explode('@', $folder->email, 2)[1];
$folderName = "shared/{$folder->name}@{$domainName}";
// Note: This does not invoke SharedFolderSetting observer events, good.
$folder->settings()->where('key', 'folder')->update(['value' => $folderName]);
}
}
}
diff --git a/src/app/Observers/SharedFolderSettingObserver.php b/src/app/Observers/SharedFolderSettingObserver.php
index 7accfb03..7632c1bd 100644
--- a/src/app/Observers/SharedFolderSettingObserver.php
+++ b/src/app/Observers/SharedFolderSettingObserver.php
@@ -1,51 +1,60 @@
<?php
namespace App\Observers;
-use App\Backends\LDAP;
use App\SharedFolderSetting;
class SharedFolderSettingObserver
{
/**
* Handle the shared folder setting "created" event.
*
* @param \App\SharedFolderSetting $folderSetting Settings object
*
* @return void
*/
public function created(SharedFolderSetting $folderSetting)
{
- if (in_array($folderSetting->key, LDAP::SHARED_FOLDER_SETTINGS)) {
- \App\Jobs\SharedFolder\UpdateJob::dispatch($folderSetting->shared_folder_id);
- }
+ $this->dispatchUpdateJob($folderSetting);
}
/**
* Handle the shared folder setting "updated" event.
*
* @param \App\SharedFolderSetting $folderSetting Settings object
*
* @return void
*/
public function updated(SharedFolderSetting $folderSetting)
{
- if (in_array($folderSetting->key, LDAP::SHARED_FOLDER_SETTINGS)) {
- \App\Jobs\SharedFolder\UpdateJob::dispatch($folderSetting->shared_folder_id);
- }
+ $this->dispatchUpdateJob($folderSetting);
}
/**
* Handle the shared folder setting "deleted" event.
*
* @param \App\SharedFolderSetting $folderSetting Settings object
*
* @return void
*/
public function deleted(SharedFolderSetting $folderSetting)
{
- if (in_array($folderSetting->key, LDAP::SHARED_FOLDER_SETTINGS)) {
- \App\Jobs\SharedFolder\UpdateJob::dispatch($folderSetting->shared_folder_id);
+ $this->dispatchUpdateJob($folderSetting);
+ }
+
+ /**
+ * Dispatch shared folder update job (if needed).
+ *
+ * @param \App\SharedFolderSetting $folderSetting Settings object
+ */
+ private function dispatchUpdateJob(SharedFolderSetting $folderSetting): void
+ {
+ if (
+ (\config('app.with_ldap') && in_array($folderSetting->key, \App\Backends\LDAP::SHARED_FOLDER_SETTINGS))
+ || (\config('app.with_imap') && in_array($folderSetting->key, \App\Backends\IMAP::SHARED_FOLDER_SETTINGS))
+ ) {
+ $props = [$folderSetting->key => $folderSetting->getOriginal('value')];
+ \App\Jobs\SharedFolder\UpdateJob::dispatch($folderSetting->shared_folder_id, $props);
}
}
}
diff --git a/src/app/Observers/UserObserver.php b/src/app/Observers/UserObserver.php
index a91ca44c..247629d2 100644
--- a/src/app/Observers/UserObserver.php
+++ b/src/app/Observers/UserObserver.php
@@ -1,339 +1,330 @@
<?php
namespace App\Observers;
use App\User;
use App\Wallet;
class UserObserver
{
/**
* Handle the "creating" event.
*
* Ensure that the user is created with a random, large integer.
*
* @param \App\User $user The user being created.
*
* @return void
*/
public function creating(User $user)
{
$user->email = \strtolower($user->email);
- // only users that are not imported get the benefit of the doubt.
- $user->status |= User::STATUS_NEW | User::STATUS_ACTIVE;
+ $user->status |= User::STATUS_NEW;
}
/**
* Handle the "created" event.
*
* Ensures the user has at least one wallet.
*
* Should ensure some basic settings are available as well.
*
* @param \App\User $user The user created.
*
* @return void
*/
public function created(User $user)
{
$settings = [
'country' => \App\Utils::countryForRequest(),
'currency' => \config('app.currency'),
/*
'first_name' => '',
'last_name' => '',
'billing_address' => '',
'organization' => '',
'phone' => '',
'external_email' => '',
*/
];
foreach ($settings as $key => $value) {
$settings[$key] = [
'key' => $key,
'value' => $value,
'user_id' => $user->id,
];
}
// Note: Don't use setSettings() here to bypass UserSetting observers
// Note: This is a single multi-insert query
$user->settings()->insert(array_values($settings));
$user->wallets()->create();
- // Create user record in LDAP, then check if the account is created in IMAP
- $chain = [
- new \App\Jobs\User\VerifyJob($user->id),
- ];
-
- \App\Jobs\User\CreateJob::withChain($chain)->dispatch($user->id);
+ // Create user record in the backend (LDAP and IMAP)
+ \App\Jobs\User\CreateJob::dispatch($user->id);
if (\App\Tenant::getConfig($user->tenant_id, 'pgp.enable')) {
\App\Jobs\PGP\KeyCreateJob::dispatch($user->id, $user->email);
}
}
/**
* Handle the "deleted" event.
*
* @param \App\User $user The user deleted.
*
* @return void
*/
public function deleted(User $user)
{
// Remove the user from existing groups
$wallet = $user->wallet();
if ($wallet && $wallet->owner) {
$wallet->owner->groups()->each(function ($group) use ($user) {
if (in_array($user->email, $group->members)) {
$group->members = array_diff($group->members, [$user->email]);
$group->save();
}
});
}
// TODO: Remove Permission records for the user
// TODO: Remove file permissions for the user
}
/**
* Handle the "deleting" event.
*
* @param User $user The user that is being deleted.
*
* @return void
*/
public function deleting(User $user)
{
// Remove owned users/domains/groups/resources/etc
self::removeRelatedObjects($user, $user->isForceDeleting());
// TODO: Especially in tests we're doing delete() on a already deleted user.
// Should we escape here - for performance reasons?
if (!$user->isForceDeleting()) {
\App\Jobs\User\DeleteJob::dispatch($user->id);
if (\App\Tenant::getConfig($user->tenant_id, 'pgp.enable')) {
\App\Jobs\PGP\KeyDeleteJob::dispatch($user->id, $user->email);
}
// Debit the reseller's wallet with the user negative balance
$balance = 0;
foreach ($user->wallets as $wallet) {
// Note: here we assume all user wallets are using the same currency.
// It might get changed in the future
$balance += $wallet->balance;
}
if ($balance < 0 && $user->tenant && ($wallet = $user->tenant->wallet())) {
$wallet->debit($balance * -1, "Deleted user {$user->email}");
}
}
}
/**
* Handle the user "restoring" event.
*
* @param \App\User $user The user
*
* @return void
*/
public function restoring(User $user)
{
// Make sure it's not DELETED/LDAP_READY/IMAP_READY/SUSPENDED anymore
if ($user->isDeleted()) {
$user->status ^= User::STATUS_DELETED;
}
if ($user->isLdapReady()) {
$user->status ^= User::STATUS_LDAP_READY;
}
if ($user->isImapReady()) {
$user->status ^= User::STATUS_IMAP_READY;
}
if ($user->isSuspended()) {
$user->status ^= User::STATUS_SUSPENDED;
}
$user->status |= User::STATUS_ACTIVE;
// Note: $user->save() is invoked between 'restoring' and 'restored' events
}
/**
* Handle the user "restored" event.
*
* @param \App\User $user The user
*
* @return void
*/
public function restored(User $user)
{
// We need at least the user domain so it can be created in ldap.
// FIXME: What if the domain is owned by someone else?
$domain = $user->domain();
if ($domain->trashed() && !$domain->isPublic()) {
// Note: Domain entitlements will be restored by the DomainObserver
$domain->restore();
}
// FIXME: Should we reset user aliases? or re-validate them in any way?
- // Create user record in LDAP, then run the verification process
- $chain = [
- new \App\Jobs\User\VerifyJob($user->id),
- ];
-
- \App\Jobs\User\CreateJob::withChain($chain)->dispatch($user->id);
+ // Create user record in the backend (LDAP and IMAP)
+ \App\Jobs\User\CreateJob::dispatch($user->id);
}
/**
* Handle the "updated" event.
*
* @param \App\User $user The user that is being updated.
*
* @return void
*/
public function updated(User $user)
{
\App\Jobs\User\UpdateJob::dispatch($user->id);
$oldStatus = $user->getOriginal('status');
$newStatus = $user->status;
if (($oldStatus & User::STATUS_DEGRADED) !== ($newStatus & User::STATUS_DEGRADED)) {
$wallets = [];
$isDegraded = $user->isDegraded();
// Charge all entitlements as if they were being deleted,
// but don't delete them. Just debit the wallet and update
// entitlements' updated_at timestamp. On un-degrade we still
// update updated_at, but with no debit (the cost is 0 on a degraded account).
foreach ($user->wallets as $wallet) {
$wallet->updateEntitlements($isDegraded);
// Remember time of the degradation for sending periodic reminders
// and reset it on un-degradation
$val = $isDegraded ? \Carbon\Carbon::now()->toDateTimeString() : null;
$wallet->setSetting('degraded_last_reminder', $val);
$wallets[] = $wallet->id;
}
// (Un-)degrade users by invoking an update job.
// LDAP backend will read the wallet owner's degraded status and
// set LDAP attributes accordingly.
// We do not change their status as their wallets have its own state
\App\Entitlement::whereIn('wallet_id', $wallets)
->where('entitleable_id', '!=', $user->id)
->where('entitleable_type', User::class)
->pluck('entitleable_id')
->unique()
->each(function ($user_id) {
\App\Jobs\User\UpdateJob::dispatch($user_id);
});
}
// Save the old password in the password history
$oldPassword = $user->getOriginal('password');
if ($oldPassword && $user->password != $oldPassword) {
self::saveOldPassword($user, $oldPassword);
}
}
/**
* Remove entitleables/transactions related to the user (in user's wallets)
*
* @param \App\User $user The user
* @param bool $force Force-delete mode
*/
private static function removeRelatedObjects(User $user, $force = false): void
{
$wallets = $user->wallets->pluck('id')->all();
\App\Entitlement::withTrashed()
->select('entitleable_id', 'entitleable_type')
->distinct()
->whereIn('wallet_id', $wallets)
->get()
->each(function ($entitlement) use ($user, $force) {
// Skip the current user (infinite recursion loop)
if ($entitlement->entitleable_type == User::class && $entitlement->entitleable_id == $user->id) {
return;
}
if (!$entitlement->entitleable) {
return;
}
// Objects need to be deleted one by one to make sure observers can do the proper cleanup
if ($force) {
$entitlement->entitleable->forceDelete();
} elseif (!$entitlement->entitleable->trashed()) {
$entitlement->entitleable->delete();
}
});
if ($force) {
// Remove "wallet" transactions, they have no foreign key constraint
\App\Transaction::where('object_type', Wallet::class)
->whereIn('object_id', $wallets)
->delete();
}
// regardless of force delete, we're always purging whitelists... just in case
\App\Policy\RateLimitWhitelist::where(
[
'whitelistable_id' => $user->id,
'whitelistable_type' => User::class
]
)->delete();
}
/**
* Store the old password in user password history. Make sure
* we do not store more passwords than we need in the history.
*
* @param \App\User $user The user
* @param string $password The old password
*/
private static function saveOldPassword(User $user, string $password): void
{
// Remember the timestamp of the last password change and unset the last warning date
$user->setSettings([
'password_expiration_warning' => null,
// Note: We could get this from user_passwords table, but only if the policy
// enables storing of old passwords there.
'password_update' => now()->format('Y-m-d H:i:s'),
]);
// Note: All this is kinda heavy and complicated because we don't want to store
// more old passwords than we need. However, except the complication/performance,
// there's one issue with it. E.g. the policy changes from 2 to 4, and we already
// removed the old passwords that were excessive before, but not now.
// Get the account password policy
$policy = new \App\Rules\Password($user->walletOwner());
$rules = $policy->rules();
// Password history disabled?
if (empty($rules['last']) || $rules['last']['param'] < 2) {
return;
}
// Store the old password
$user->passwords()->create(['password' => $password]);
// Remove passwords that we don't need anymore
$limit = $rules['last']['param'] - 1;
$ids = $user->passwords()->latest()->limit($limit)->pluck('id')->all();
if (count($ids) >= $limit) {
$user->passwords()->where('id', '<', $ids[count($ids) - 1])->delete();
}
}
}
diff --git a/src/app/Observers/UserSettingObserver.php b/src/app/Observers/UserSettingObserver.php
index bffd01a8..abea062d 100644
--- a/src/app/Observers/UserSettingObserver.php
+++ b/src/app/Observers/UserSettingObserver.php
@@ -1,51 +1,59 @@
<?php
namespace App\Observers;
-use App\Backends\LDAP;
use App\UserSetting;
class UserSettingObserver
{
/**
* Handle the user setting "created" event.
*
* @param \App\UserSetting $userSetting Settings object
*
* @return void
*/
public function created(UserSetting $userSetting)
{
- if (in_array($userSetting->key, LDAP::USER_SETTINGS)) {
- \App\Jobs\User\UpdateJob::dispatch($userSetting->user_id);
- }
+ $this->dispatchUpdateJob($userSetting);
}
/**
* Handle the user setting "updated" event.
*
* @param \App\UserSetting $userSetting Settings object
*
* @return void
*/
public function updated(UserSetting $userSetting)
{
- if (in_array($userSetting->key, LDAP::USER_SETTINGS)) {
- \App\Jobs\User\UpdateJob::dispatch($userSetting->user_id);
- }
+ $this->dispatchUpdateJob($userSetting);
}
/**
* Handle the user setting "deleted" event.
*
* @param \App\UserSetting $userSetting Settings object
*
* @return void
*/
public function deleted(UserSetting $userSetting)
{
- if (in_array($userSetting->key, LDAP::USER_SETTINGS)) {
+ $this->dispatchUpdateJob($userSetting);
+ }
+
+ /**
+ * Dispatch the user update job (if needed).
+ *
+ * @param \App\UserSetting $userSetting Settings object
+ */
+ private function dispatchUpdateJob(UserSetting $userSetting): void
+ {
+ if (
+ (\config('app.with_ldap') && in_array($userSetting->key, \App\Backends\LDAP::USER_SETTINGS))
+ || (\config('app.with_imap') && in_array($userSetting->key, \App\Backends\IMAP::USER_SETTINGS))
+ ) {
\App\Jobs\User\UpdateJob::dispatch($userSetting->user_id);
}
}
}
diff --git a/src/app/Traits/EntitleableTrait.php b/src/app/Traits/EntitleableTrait.php
index ee7a48e7..373c246f 100644
--- a/src/app/Traits/EntitleableTrait.php
+++ b/src/app/Traits/EntitleableTrait.php
@@ -1,310 +1,322 @@
<?php
namespace App\Traits;
use App\Entitlement;
use App\Sku;
use App\Wallet;
use Illuminate\Support\Str;
trait EntitleableTrait
{
/**
* Assign a package to an entitleable object. It should not have any existing entitlements.
*
* @param \App\Package $package The package
* @param \App\Wallet $wallet The wallet
*
* @return $this
*/
public function assignPackageAndWallet(\App\Package $package, Wallet $wallet)
{
// TODO: There should be some sanity checks here. E.g. not package can be
// assigned to any entitleable, but we don't really have package types.
foreach ($package->skus as $sku) {
for ($i = $sku->pivot->qty; $i > 0; $i--) {
Entitlement::create([
'wallet_id' => $wallet->id,
'sku_id' => $sku->id,
'cost' => $sku->pivot->cost(),
'fee' => $sku->pivot->fee(),
'entitleable_id' => $this->id,
'entitleable_type' => self::class
]);
}
}
return $this;
}
/**
* Assign a SKU to an entitleable object.
*
* @param \App\Sku $sku The sku to assign.
* @param int $count Count of entitlements to add
* @param ?\App\Wallet $wallet The wallet to use when objects's wallet is unknown
*
* @return $this
* @throws \Exception
*/
public function assignSku(Sku $sku, int $count = 1, $wallet = null)
{
if (!$wallet) {
$wallet = $this->wallet();
}
if (!$wallet) {
throw new \Exception("No wallet specified for the new entitlement");
}
$exists = $this->entitlements()->where('sku_id', $sku->id)->count();
while ($count > 0) {
Entitlement::create([
'wallet_id' => $wallet->id,
'sku_id' => $sku->id,
'cost' => $exists >= $sku->units_free ? $sku->cost : 0,
'fee' => $exists >= $sku->units_free ? $sku->fee : 0,
'entitleable_id' => $this->id,
'entitleable_type' => self::class
]);
$exists++;
$count--;
}
return $this;
}
/**
* Assign the object to a wallet.
*
* @param \App\Wallet $wallet The wallet
* @param ?string $title Optional SKU title
*
* @return $this
* @throws \Exception
*/
public function assignToWallet(Wallet $wallet, $title = null)
{
if (empty($this->id)) {
throw new \Exception("Object not yet exists");
}
if ($this->entitlements()->count()) {
throw new \Exception("Object already assigned to a wallet");
}
// Find the SKU title, e.g. \App\SharedFolder -> shared-folder
// Note: it does not work with User/Domain model (yet)
if (!$title) {
$title = Str::kebab(\class_basename(self::class));
}
$sku = $this->skuByTitle($title);
$exists = $wallet->entitlements()->where('sku_id', $sku->id)->count();
Entitlement::create([
'wallet_id' => $wallet->id,
'sku_id' => $sku->id,
'cost' => $exists >= $sku->units_free ? $sku->cost : 0,
'fee' => $exists >= $sku->units_free ? $sku->fee : 0,
'entitleable_id' => $this->id,
'entitleable_type' => self::class
]);
return $this;
}
/**
* Boot function from Laravel.
*/
protected static function bootEntitleableTrait()
{
// Soft-delete and force-delete object's entitlements on object's delete
static::deleting(function ($model) {
$force = $model->isForceDeleting();
$entitlements = $model->entitlements();
if ($force) {
$entitlements = $entitlements->withTrashed();
}
$list = $entitlements->get()
->map(function ($entitlement) use ($force) {
if ($force) {
$entitlement->forceDelete();
} else {
$entitlement->delete();
}
return $entitlement->id;
})
->all();
// Remove transactions, they have no foreign key constraint
if ($force && !empty($list)) {
\App\Transaction::where('object_type', \App\Entitlement::class)
->whereIn('object_id', $list)
->delete();
}
});
// Restore object's entitlements on restore
static::restored(function ($model) {
$model->restoreEntitlements();
});
}
+ /**
+ * Count entitlements for the specified SKU.
+ *
+ * @param string $title The SKU title
+ *
+ * @return int Numer of entitlements
+ */
+ public function countEntitlementsBySku(string $title): int
+ {
+ $sku = $this->skuByTitle($title);
+
+ if (!$sku) {
+ return 0;
+ }
+
+ return $this->entitlements()->where('sku_id', $sku->id)->count();
+ }
+
/**
* Entitlements for this object.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function entitlements()
{
return $this->hasMany(Entitlement::class, 'entitleable_id', 'id')
->where('entitleable_type', self::class);
}
/**
* Check if an entitlement for the specified SKU exists.
*
* @param string $title The SKU title
*
* @return bool True if specified SKU entitlement exists
*/
public function hasSku(string $title): bool
{
- $sku = $this->skuByTitle($title);
-
- if (!$sku) {
- return false;
- }
-
- return $this->entitlements()->where('sku_id', $sku->id)->count() > 0;
+ return $this->countEntitlementsBySku($title) > 0;
}
/**
* Remove a number of entitlements for the SKU.
*
* @param \App\Sku $sku The SKU
* @param int $count The number of entitlements to remove
*
* @return $this
*/
public function removeSku(Sku $sku, int $count = 1)
{
$entitlements = $this->entitlements()
->where('sku_id', $sku->id)
->orderBy('cost', 'desc')
->orderBy('created_at')
->get();
$entitlements_count = count($entitlements);
foreach ($entitlements as $entitlement) {
if ($entitlements_count <= $sku->units_free) {
continue;
}
if ($count > 0) {
$entitlement->delete();
$entitlements_count--;
$count--;
}
}
return $this;
}
/**
* Restore object entitlements.
*/
public function restoreEntitlements(): void
{
// We'll restore only these that were deleted last. So, first we get
// the maximum deleted_at timestamp and then use it to select
// entitlements for restore
$deleted_at = $this->entitlements()->withTrashed()->max('deleted_at');
if ($deleted_at) {
$threshold = (new \Carbon\Carbon($deleted_at))->subMinute();
// Restore object entitlements
$this->entitlements()->withTrashed()
->where('deleted_at', '>=', $threshold)
->update(['updated_at' => now(), 'deleted_at' => null]);
// Note: We're assuming that cost of entitlements was correct
// on deletion, so we don't have to re-calculate it again.
// TODO: We should probably re-calculate the cost
}
}
/**
* Find the SKU object by title. Use current object's tenant context.
*
* @param string $title SKU title.
*
* @return ?\App\Sku A SKU object
*/
protected function skuByTitle(string $title): ?Sku
{
return Sku::withObjectTenantContext($this)->where('title', $title)->first();
}
/**
* Returns entitleable object title (e.g. email or domain name).
*
* @return string|null An object title/name
*/
public function toString(): ?string
{
// This method should be overloaded by the model class
// if the object has not email attribute
return $this->email;
}
/**
* Returns the wallet by which the object is controlled
*
* @return ?\App\Wallet A wallet object
*/
public function wallet(): ?Wallet
{
$entitlement = $this->entitlements()->withTrashed()->orderBy('created_at', 'desc')->first();
if ($entitlement) {
return $entitlement->wallet;
}
// TODO: No entitlement should not happen, but in tests we have
// such cases, so we fallback to the user's wallet in this case
if ($this instanceof \App\User) {
return $this->wallets()->first();
}
return null;
}
/**
* Return the owner of the wallet (account) this entitleable is assigned to
*
* @return ?\App\User Account owner
*/
public function walletOwner(): ?\App\User
{
$wallet = $this->wallet();
if ($wallet) {
if ($this instanceof \App\User && $wallet->user_id == $this->id) {
return $this;
}
return $wallet->owner;
}
return null;
}
}
diff --git a/src/config/app.php b/src/config/app.php
index 8ebb1901..a78fcc28 100644
--- a/src/config/app.php
+++ b/src/config/app.php
@@ -1,270 +1,276 @@
<?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')),
/*
|--------------------------------------------------------------------------
| 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/include/rcube_imap_generic.php b/src/include/rcube_imap_generic.php
index 127c4cfa..adb0b7b8 100644
--- a/src/include/rcube_imap_generic.php
+++ b/src/include/rcube_imap_generic.php
@@ -1,4106 +1,4128 @@
<?php
/**
+-----------------------------------------------------------------------+
| This file is part of the Roundcube Webmail client |
| |
| Copyright (C) The Roundcube Dev Team |
| Copyright (C) Kolab Systems AG |
| |
| Licensed under the GNU General Public License version 3 or |
| any later version with exceptions for skins & plugins. |
| See the README file for a full license statement. |
| |
| PURPOSE: |
| Provide alternative IMAP library that doesn't rely on the standard |
| C-Client based version. This allows to function regardless |
| of whether or not the PHP build it's running on has IMAP |
| functionality built-in. |
| |
| Based on Iloha IMAP Library. See http://ilohamail.org/ for details |
+-----------------------------------------------------------------------+
| Author: Aleksander Machniak <alec@alec.pl> |
| Author: Ryo Chijiiwa <Ryo@IlohaMail.org> |
+-----------------------------------------------------------------------+
*/
/**
* PHP based wrapper class to connect to an IMAP server
*
* @package Framework
* @subpackage Storage
*/
class rcube_imap_generic
{
public $error;
public $errornum;
public $result;
public $resultcode;
public $selected;
public $data = array();
public $flags = array(
'SEEN' => '\\Seen',
'DELETED' => '\\Deleted',
'ANSWERED' => '\\Answered',
'DRAFT' => '\\Draft',
'FLAGGED' => '\\Flagged',
'FORWARDED' => '$Forwarded',
'MDNSENT' => '$MDNSent',
'*' => '\\*',
);
protected $fp;
protected $host;
protected $cmd_tag;
protected $cmd_num = 0;
protected $resourceid;
protected $prefs = array();
protected $logged = false;
protected $capability = array();
protected $capability_readed = false;
protected $debug = false;
protected $debug_handler = false;
const ERROR_OK = 0;
const ERROR_NO = -1;
const ERROR_BAD = -2;
const ERROR_BYE = -3;
const ERROR_UNKNOWN = -4;
const ERROR_COMMAND = -5;
const ERROR_READONLY = -6;
const COMMAND_NORESPONSE = 1;
const COMMAND_CAPABILITY = 2;
const COMMAND_LASTLINE = 4;
const COMMAND_ANONYMIZED = 8;
const DEBUG_LINE_LENGTH = 4098; // 4KB + 2B for \r\n
/**
* Send simple (one line) command to the connection stream
*
* @param string $string Command string
* @param bool $endln True if CRLF need to be added at the end of command
* @param bool $anonymized Don't write the given data to log but a placeholder
*
* @param int Number of bytes sent, False on error
*/
protected function putLine($string, $endln = true, $anonymized = false)
{
if (!$this->fp) {
return false;
}
if ($this->debug) {
// anonymize the sent command for logging
$cut = $endln ? 2 : 0;
if ($anonymized && preg_match('/^(A\d+ (?:[A-Z]+ )+)(.+)/', $string, $m)) {
$log = $m[1] . sprintf('****** [%d]', strlen($m[2]) - $cut);
}
else if ($anonymized) {
$log = sprintf('****** [%d]', strlen($string) - $cut);
}
else {
$log = rtrim($string);
}
$this->debug('C: ' . $log);
}
if ($endln) {
$string .= "\r\n";
}
$res = fwrite($this->fp, $string);
if ($res === false) {
$this->closeSocket();
}
return $res;
}
/**
* Send command to the connection stream with Command Continuation
* Requests (RFC3501 7.5) and LITERAL+ (RFC2088) support
*
* @param string $string Command string
* @param bool $endln True if CRLF need to be added at the end of command
* @param bool $anonymized Don't write the given data to log but a placeholder
*
* @return int|bool Number of bytes sent, False on error
*/
protected function putLineC($string, $endln=true, $anonymized=false)
{
if (!$this->fp) {
return false;
}
if ($endln) {
$string .= "\r\n";
}
$res = 0;
if ($parts = preg_split('/(\{[0-9]+\}\r\n)/m', $string, -1, PREG_SPLIT_DELIM_CAPTURE)) {
for ($i=0, $cnt=count($parts); $i<$cnt; $i++) {
if ($i+1 < $cnt && preg_match('/^\{([0-9]+)\}\r\n$/', $parts[$i+1], $matches)) {
// LITERAL+ support
if ($this->prefs['literal+']) {
$parts[$i+1] = sprintf("{%d+}\r\n", $matches[1]);
}
$bytes = $this->putLine($parts[$i].$parts[$i+1], false, $anonymized);
if ($bytes === false) {
return false;
}
$res += $bytes;
// don't wait if server supports LITERAL+ capability
if (!$this->prefs['literal+']) {
$line = $this->readLine(1000);
// handle error in command
if ($line[0] != '+') {
return false;
}
}
$i++;
}
else {
$bytes = $this->putLine($parts[$i], false, $anonymized);
if ($bytes === false) {
return false;
}
$res += $bytes;
}
}
}
return $res;
}
/**
* Reads line from the connection stream
*
* @param int $size Buffer size
*
* @return string Line of text response
*/
protected function readLine($size = 1024)
{
$line = '';
if (!$size) {
$size = 1024;
}
do {
if ($this->eof()) {
return $line ?: null;
}
$buffer = fgets($this->fp, $size);
if ($buffer === false) {
$this->closeSocket();
break;
}
if ($this->debug) {
$this->debug('S: '. rtrim($buffer));
}
$line .= $buffer;
}
while (substr($buffer, -1) != "\n");
return $line;
}
/**
* Reads more data from the connection stream when provided
* data contain string literal
*
* @param string $line Response text
* @param bool $escape Enables escaping
*
* @return string Line of text response
*/
protected function multLine($line, $escape = false)
{
$line = rtrim($line);
if (preg_match('/\{([0-9]+)\}$/', $line, $m)) {
$out = '';
$str = substr($line, 0, -strlen($m[0]));
$bytes = $m[1];
while (strlen($out) < $bytes) {
$line = $this->readBytes($bytes);
if ($line === null) {
break;
}
$out .= $line;
}
$line = $str . ($escape ? $this->escape($out) : $out);
}
return $line;
}
/**
* Reads specified number of bytes from the connection stream
*
* @param int $bytes Number of bytes to get
*
* @return string Response text
*/
protected function readBytes($bytes)
{
$data = '';
$len = 0;
while ($len < $bytes && !$this->eof()) {
$d = fread($this->fp, $bytes-$len);
if ($this->debug) {
$this->debug('S: '. $d);
}
$data .= $d;
$data_len = strlen($data);
if ($len == $data_len) {
break; // nothing was read -> exit to avoid apache lockups
}
$len = $data_len;
}
return $data;
}
/**
* Reads complete response to the IMAP command
*
* @param array $untagged Will be filled with untagged response lines
*
* @return string Response text
*/
protected function readReply(&$untagged = null)
{
do {
$line = trim($this->readLine(1024));
// store untagged response lines
if ($line[0] == '*') {
$untagged[] = $line;
}
}
while ($line[0] == '*');
if ($untagged) {
$untagged = implode("\n", $untagged);
}
return $line;
}
/**
* Response parser.
*
* @param string $string Response text
* @param string $err_prefix Error message prefix
*
* @return int Response status
*/
protected function parseResult($string, $err_prefix = '')
{
if (preg_match('/^[a-z0-9*]+ (OK|NO|BAD|BYE)(.*)$/i', trim($string), $matches)) {
$res = strtoupper($matches[1]);
$str = trim($matches[2]);
if ($res == 'OK') {
$this->errornum = self::ERROR_OK;
}
else if ($res == 'NO') {
$this->errornum = self::ERROR_NO;
}
else if ($res == 'BAD') {
$this->errornum = self::ERROR_BAD;
}
else if ($res == 'BYE') {
$this->closeSocket();
$this->errornum = self::ERROR_BYE;
}
if ($str) {
$str = trim($str);
// get response string and code (RFC5530)
if (preg_match("/^\[([a-z-]+)\]/i", $str, $m)) {
$this->resultcode = strtoupper($m[1]);
$str = trim(substr($str, strlen($m[1]) + 2));
}
else {
$this->resultcode = null;
// parse response for [APPENDUID 1204196876 3456]
if (preg_match("/^\[APPENDUID [0-9]+ ([0-9]+)\]/i", $str, $m)) {
$this->data['APPENDUID'] = $m[1];
}
// parse response for [COPYUID 1204196876 3456:3457 123:124]
else if (preg_match("/^\[COPYUID [0-9]+ ([0-9,:]+) ([0-9,:]+)\]/i", $str, $m)) {
$this->data['COPYUID'] = array($m[1], $m[2]);
}
}
$this->result = $str;
if ($this->errornum != self::ERROR_OK) {
$this->error = $err_prefix ? $err_prefix.$str : $str;
}
}
return $this->errornum;
}
return self::ERROR_UNKNOWN;
}
/**
* Checks connection stream state.
*
* @return bool True if connection is closed
*/
protected function eof()
{
if (!is_resource($this->fp)) {
return true;
}
// If a connection opened by fsockopen() wasn't closed
// by the server, feof() will hang.
$start = microtime(true);
if (feof($this->fp) ||
($this->prefs['timeout'] && (microtime(true) - $start > $this->prefs['timeout']))
) {
$this->closeSocket();
return true;
}
return false;
}
/**
* Closes connection stream.
*/
protected function closeSocket()
{
@fclose($this->fp);
$this->fp = null;
}
/**
* Error code/message setter.
*/
protected function setError($code, $msg = '')
{
$this->errornum = $code;
$this->error = $msg;
return $code;
}
/**
* Checks response status.
* Checks if command response line starts with specified prefix (or * BYE/BAD)
*
* @param string $string Response text
* @param string $match Prefix to match with (case-sensitive)
* @param bool $error Enables BYE/BAD checking
* @param bool $nonempty Enables empty response checking
*
* @return bool True any check is true or connection is closed.
*/
protected function startsWith($string, $match, $error = false, $nonempty = false)
{
if (!$this->fp) {
return true;
}
if (strncmp($string, $match, strlen($match)) == 0) {
return true;
}
if ($error && preg_match('/^\* (BYE|BAD) /i', $string, $m)) {
if (strtoupper($m[1]) == 'BYE') {
$this->closeSocket();
}
return true;
}
if ($nonempty && !strlen($string)) {
return true;
}
return false;
}
/**
* Capabilities checker
*/
protected function hasCapability($name)
{
if (empty($this->capability) || $name == '') {
return false;
}
if (in_array($name, $this->capability)) {
return true;
}
else if (strpos($name, '=')) {
return false;
}
$result = array();
foreach ($this->capability as $cap) {
$entry = explode('=', $cap);
if ($entry[0] == $name) {
$result[] = $entry[1];
}
}
return $result ?: false;
}
/**
* Capabilities checker
*
* @param string $name Capability name
*
* @return mixed Capability values array for key=value pairs, true/false for others
*/
public function getCapability($name)
{
$result = $this->hasCapability($name);
if (!empty($result)) {
return $result;
}
else if ($this->capability_readed) {
return false;
}
// get capabilities (only once) because initial
// optional CAPABILITY response may differ
$result = $this->execute('CAPABILITY');
if ($result[0] == self::ERROR_OK) {
$this->parseCapability($result[1]);
}
$this->capability_readed = true;
return $this->hasCapability($name);
}
/**
* Clears detected server capabilities
*/
public function clearCapability()
{
$this->capability = array();
$this->capability_readed = false;
}
/**
* DIGEST-MD5/CRAM-MD5/PLAIN Authentication
*
* @param string $user Username
* @param string $pass Password
* @param string $type Authentication type (PLAIN/CRAM-MD5/DIGEST-MD5)
*
* @return resource Connection resourse on success, error code on error
*/
protected function authenticate($user, $pass, $type = 'PLAIN')
{
if ($type == 'CRAM-MD5' || $type == 'DIGEST-MD5') {
if ($type == 'DIGEST-MD5' && !class_exists('Auth_SASL')) {
return $this->setError(self::ERROR_BYE,
"The Auth_SASL package is required for DIGEST-MD5 authentication");
}
$this->putLine($this->nextTag() . " AUTHENTICATE $type");
$line = trim($this->readReply());
if ($line[0] == '+') {
$challenge = substr($line, 2);
}
else {
return $this->parseResult($line);
}
if ($type == 'CRAM-MD5') {
// RFC2195: CRAM-MD5
$ipad = '';
$opad = '';
$xor = function($str1, $str2) {
$result = '';
$size = strlen($str1);
for ($i=0; $i<$size; $i++) {
$result .= chr(ord($str1[$i]) ^ ord($str2[$i]));
}
return $result;
};
// initialize ipad, opad
for ($i=0; $i<64; $i++) {
$ipad .= chr(0x36);
$opad .= chr(0x5C);
}
// pad $pass so it's 64 bytes
$pass = str_pad($pass, 64, chr(0));
// generate hash
$hash = md5($xor($pass, $opad) . pack("H*",
md5($xor($pass, $ipad) . base64_decode($challenge))));
$reply = base64_encode($user . ' ' . $hash);
// send result
$this->putLine($reply, true, true);
}
else {
// RFC2831: DIGEST-MD5
// proxy authorization
if (!empty($this->prefs['auth_cid'])) {
$authc = $this->prefs['auth_cid'];
$pass = $this->prefs['auth_pw'];
}
else {
$authc = $user;
$user = '';
}
$auth_sasl = new Auth_SASL;
$auth_sasl = $auth_sasl->factory('digestmd5');
$reply = base64_encode($auth_sasl->getResponse($authc, $pass,
base64_decode($challenge), $this->host, 'imap', $user));
// send result
$this->putLine($reply, true, true);
$line = trim($this->readReply());
if ($line[0] != '+') {
return $this->parseResult($line);
}
// check response
$challenge = substr($line, 2);
$challenge = base64_decode($challenge);
if (strpos($challenge, 'rspauth=') === false) {
return $this->setError(self::ERROR_BAD,
"Unexpected response from server to DIGEST-MD5 response");
}
$this->putLine('');
}
$line = $this->readReply();
$result = $this->parseResult($line);
}
else if ($type == 'GSSAPI') {
if (!extension_loaded('krb5')) {
return $this->setError(self::ERROR_BYE,
"The krb5 extension is required for GSSAPI authentication");
}
if (empty($this->prefs['gssapi_cn'])) {
return $this->setError(self::ERROR_BYE,
"The gssapi_cn parameter is required for GSSAPI authentication");
}
if (empty($this->prefs['gssapi_context'])) {
return $this->setError(self::ERROR_BYE,
"The gssapi_context parameter is required for GSSAPI authentication");
}
putenv('KRB5CCNAME=' . $this->prefs['gssapi_cn']);
try {
$ccache = new KRB5CCache();
$ccache->open($this->prefs['gssapi_cn']);
$gssapicontext = new GSSAPIContext();
$gssapicontext->acquireCredentials($ccache);
$token = '';
$success = $gssapicontext->initSecContext($this->prefs['gssapi_context'], null, null, null, $token);
$token = base64_encode($token);
}
catch (Exception $e) {
trigger_error($e->getMessage(), E_USER_WARNING);
return $this->setError(self::ERROR_BYE, "GSSAPI authentication failed");
}
$this->putLine($this->nextTag() . " AUTHENTICATE GSSAPI " . $token);
$line = trim($this->readReply());
if ($line[0] != '+') {
return $this->parseResult($line);
}
try {
$itoken = base64_decode(substr($line, 2));
if (!$gssapicontext->unwrap($itoken, $itoken)) {
throw new Exception("GSSAPI SASL input token unwrap failed");
}
if (strlen($itoken) < 4) {
throw new Exception("GSSAPI SASL input token invalid");
}
// Integrity/encryption layers are not supported. The first bit
// indicates that the server supports "no security layers".
// 0x00 should not occur, but support broken implementations.
$server_layers = ord($itoken[0]);
if ($server_layers && ($server_layers & 0x1) != 0x1) {
throw new Exception("Server requires GSSAPI SASL integrity/encryption");
}
// Construct output token. 0x01 in the first octet = SASL layer "none",
// zero in the following three octets = no data follows.
// See https://github.com/cyrusimap/cyrus-sasl/blob/e41cfb986c1b1935770de554872247453fdbb079/plugins/gssapi.c#L1284
if (!$gssapicontext->wrap(pack("CCCC", 0x1, 0, 0, 0), $otoken, true)) {
throw new Exception("GSSAPI SASL output token wrap failed");
}
}
catch (Exception $e) {
trigger_error($e->getMessage(), E_USER_WARNING);
return $this->setError(self::ERROR_BYE, "GSSAPI authentication failed");
}
$this->putLine(base64_encode($otoken));
$line = $this->readReply();
$result = $this->parseResult($line);
}
else if ($type == 'PLAIN') {
// proxy authorization
if (!empty($this->prefs['auth_cid'])) {
$authc = $this->prefs['auth_cid'];
$pass = $this->prefs['auth_pw'];
}
else {
$authc = $user;
$user = '';
}
$reply = base64_encode($user . chr(0) . $authc . chr(0) . $pass);
// RFC 4959 (SASL-IR): save one round trip
if ($this->getCapability('SASL-IR')) {
list($result, $line) = $this->execute("AUTHENTICATE PLAIN", array($reply),
self::COMMAND_LASTLINE | self::COMMAND_CAPABILITY | self::COMMAND_ANONYMIZED);
}
else {
$this->putLine($this->nextTag() . " AUTHENTICATE PLAIN");
$line = trim($this->readReply());
if ($line[0] != '+') {
return $this->parseResult($line);
}
// send result, get reply and process it
$this->putLine($reply, true, true);
$line = $this->readReply();
$result = $this->parseResult($line);
}
}
else if ($type == 'LOGIN') {
$this->putLine($this->nextTag() . " AUTHENTICATE LOGIN");
$line = trim($this->readReply());
if ($line[0] != '+') {
return $this->parseResult($line);
}
$this->putLine(base64_encode($user), true, true);
$line = trim($this->readReply());
if ($line[0] != '+') {
return $this->parseResult($line);
}
// send result, get reply and process it
$this->putLine(base64_encode($pass), true, true);
$line = $this->readReply();
$result = $this->parseResult($line);
}
if ($result === self::ERROR_OK) {
// optional CAPABILITY response
if ($line && preg_match('/\[CAPABILITY ([^]]+)\]/i', $line, $matches)) {
$this->parseCapability($matches[1], true);
}
return $this->fp;
}
return $this->setError($result, "AUTHENTICATE $type: $line");
}
/**
* LOGIN Authentication
*
* @param string $user Username
* @param string $pass Password
*
* @return resource Connection resourse on success, error code on error
*/
protected function login($user, $password)
{
// Prevent from sending credentials in plain text when connection is not secure
if ($this->getCapability('LOGINDISABLED')) {
return $this->setError(self::ERROR_BAD, "Login disabled by IMAP server");
}
list($code, $response) = $this->execute('LOGIN', array(
$this->escape($user), $this->escape($password)), self::COMMAND_CAPABILITY | self::COMMAND_ANONYMIZED);
// re-set capabilities list if untagged CAPABILITY response provided
if (preg_match('/\* CAPABILITY (.+)/i', $response, $matches)) {
$this->parseCapability($matches[1], true);
}
if ($code == self::ERROR_OK) {
return $this->fp;
}
return $code;
}
/**
* Detects hierarchy delimiter
*
* @return string The delimiter
*/
public function getHierarchyDelimiter()
{
if (isset($this->prefs['delimiter'])) {
return $this->prefs['delimiter'];
}
// try (LIST "" ""), should return delimiter (RFC2060 Sec 6.3.8)
list($code, $response) = $this->execute('LIST',
array($this->escape(''), $this->escape('')));
if ($code == self::ERROR_OK) {
$args = $this->tokenizeResponse($response, 4);
$delimiter = $args[3];
if (strlen($delimiter) > 0) {
return ($this->prefs['delimiter'] = $delimiter);
}
}
}
/**
* NAMESPACE handler (RFC 2342)
*
* @return array Namespace data hash (personal, other, shared)
*/
public function getNamespace()
{
if (array_key_exists('namespace', $this->prefs)) {
return $this->prefs['namespace'];
}
if (!$this->getCapability('NAMESPACE')) {
return self::ERROR_BAD;
}
list($code, $response) = $this->execute('NAMESPACE');
if ($code == self::ERROR_OK && preg_match('/^\* NAMESPACE /', $response)) {
$response = substr($response, 11);
$data = $this->tokenizeResponse($response);
}
if (!is_array($data)) {
return $code;
}
$this->prefs['namespace'] = array(
'personal' => $data[0],
'other' => $data[1],
'shared' => $data[2],
);
return $this->prefs['namespace'];
}
/**
* Connects to IMAP server and authenticates.
*
* @param string $host Server hostname or IP
* @param string $user User name
* @param string $password Password
* @param array $options Connection and class options
*
* @return bool True on success, False on failure
*/
public function connect($host, $user, $password, $options = array())
{
// configure
$this->set_prefs($options);
$this->host = $host;
$this->user = $user;
$this->logged = false;
$this->selected = null;
// check input
if (empty($host)) {
$this->setError(self::ERROR_BAD, "Empty host");
return false;
}
if (empty($user)) {
$this->setError(self::ERROR_NO, "Empty user");
return false;
}
if (empty($password) && empty($options['gssapi_cn'])) {
$this->setError(self::ERROR_NO, "Empty password");
return false;
}
// Connect
if (!$this->_connect($host)) {
return false;
}
// Send ID info
if (!empty($this->prefs['ident']) && $this->getCapability('ID')) {
$this->data['ID'] = $this->id($this->prefs['ident']);
}
$auth_method = $this->prefs['auth_type'];
$auth_methods = array();
$result = null;
// check for supported auth methods
if (!$auth_method || $auth_method == 'CHECK') {
if ($auth_caps = $this->getCapability('AUTH')) {
$auth_methods = $auth_caps;
}
// Use best (for security) supported authentication method
$all_methods = array('DIGEST-MD5', 'CRAM-MD5', 'CRAM_MD5', 'PLAIN', 'LOGIN');
if (!empty($this->prefs['gssapi_cn'])) {
array_unshift($all_methods, 'GSSAPI');
}
foreach ($all_methods as $auth_method) {
if (in_array($auth_method, $auth_methods)) {
break;
}
}
// Prefer LOGIN over AUTHENTICATE LOGIN for performance reasons
if ($auth_method == 'LOGIN' && !$this->getCapability('LOGINDISABLED')) {
$auth_method = 'IMAP';
}
}
// pre-login capabilities can be not complete
$this->capability_readed = false;
// Authenticate
switch ($auth_method) {
case 'CRAM_MD5':
$auth_method = 'CRAM-MD5';
case 'CRAM-MD5':
case 'DIGEST-MD5':
case 'GSSAPI':
case 'PLAIN':
case 'LOGIN':
$result = $this->authenticate($user, $password, $auth_method);
break;
case 'IMAP':
$result = $this->login($user, $password);
break;
default:
$this->setError(self::ERROR_BAD, "Configuration error. Unknown auth method: $auth_method");
}
// Connected and authenticated
if (is_resource($result)) {
if (!empty($this->prefs['force_caps'])) {
$this->clearCapability();
}
$this->logged = true;
return true;
}
$this->closeConnection();
return false;
}
/**
* Connects to IMAP server.
*
* @param string $host Server hostname or IP
*
* @return bool True on success, False on failure
*/
protected function _connect($host)
{
// initialize connection
$this->error = '';
$this->errornum = self::ERROR_OK;
if (!$this->prefs['port']) {
$this->prefs['port'] = 143;
}
// check for SSL
if (!empty($this->prefs['ssl_mode']) && $this->prefs['ssl_mode'] != 'tls') {
$host = $this->prefs['ssl_mode'] . '://' . $host;
}
if (empty($this->prefs['timeout']) || $this->prefs['timeout'] < 0) {
$this->prefs['timeout'] = max(0, intval(ini_get('default_socket_timeout')));
}
if ($this->debug) {
// set connection identifier for debug output
$this->resourceid = strtoupper(substr(md5(microtime() . $host . $this->user), 0, 4));
$_host = ($this->prefs['ssl_mode'] == 'tls' ? 'tls://' : '') . $host . ':' . $this->prefs['port'];
$this->debug("Connecting to $_host...");
}
if (!empty($this->prefs['socket_options'])) {
$context = stream_context_create($this->prefs['socket_options']);
$this->fp = stream_socket_client($host . ':' . $this->prefs['port'], $errno, $errstr,
$this->prefs['timeout'], STREAM_CLIENT_CONNECT, $context);
}
else {
$this->fp = @fsockopen($host, $this->prefs['port'], $errno, $errstr, $this->prefs['timeout']);
}
if (!$this->fp) {
$this->setError(self::ERROR_BAD, sprintf("Could not connect to %s:%d: %s",
$host, $this->prefs['port'], $errstr ?: "Unknown reason"));
return false;
}
if ($this->prefs['timeout'] > 0) {
stream_set_timeout($this->fp, $this->prefs['timeout']);
}
$line = trim(fgets($this->fp, 8192));
if ($this->debug && $line) {
$this->debug('S: '. $line);
}
// Connected to wrong port or connection error?
if (!preg_match('/^\* (OK|PREAUTH)/i', $line)) {
if ($line)
$error = sprintf("Wrong startup greeting (%s:%d): %s", $host, $this->prefs['port'], $line);
else
$error = sprintf("Empty startup greeting (%s:%d)", $host, $this->prefs['port']);
$this->setError(self::ERROR_BAD, $error);
$this->closeConnection();
return false;
}
$this->data['GREETING'] = trim(preg_replace('/\[[^\]]+\]\s*/', '', $line));
// RFC3501 [7.1] optional CAPABILITY response
if (preg_match('/\[CAPABILITY ([^]]+)\]/i', $line, $matches)) {
$this->parseCapability($matches[1], true);
}
// TLS connection
if ($this->prefs['ssl_mode'] == 'tls' && $this->getCapability('STARTTLS')) {
$res = $this->execute('STARTTLS');
if ($res[0] != self::ERROR_OK) {
$this->closeConnection();
return false;
}
if (isset($this->prefs['socket_options']['ssl']['crypto_method'])) {
$crypto_method = $this->prefs['socket_options']['ssl']['crypto_method'];
}
else {
// There is no flag to enable all TLS methods. Net_SMTP
// handles enabling TLS similarly.
$crypto_method = STREAM_CRYPTO_METHOD_TLS_CLIENT
| @STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT
| @STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT;
}
if (!stream_socket_enable_crypto($this->fp, true, $crypto_method)) {
$this->setError(self::ERROR_BAD, "Unable to negotiate TLS");
$this->closeConnection();
return false;
}
// Now we're secure, capabilities need to be reread
$this->clearCapability();
}
return true;
}
/**
* Initializes environment
*/
protected function set_prefs($prefs)
{
// set preferences
if (is_array($prefs)) {
$this->prefs = $prefs;
}
// set auth method
if (!empty($this->prefs['auth_type'])) {
$this->prefs['auth_type'] = strtoupper($this->prefs['auth_type']);
}
else {
$this->prefs['auth_type'] = 'CHECK';
}
// disabled capabilities
if (!empty($this->prefs['disabled_caps'])) {
$this->prefs['disabled_caps'] = array_map('strtoupper', (array)$this->prefs['disabled_caps']);
}
// additional message flags
if (!empty($this->prefs['message_flags'])) {
$this->flags = array_merge($this->flags, $this->prefs['message_flags']);
unset($this->prefs['message_flags']);
}
}
/**
* Checks connection status
*
* @return bool True if connection is active and user is logged in, False otherwise.
*/
public function connected()
{
return $this->fp && $this->logged;
}
/**
* Closes connection with logout.
*/
public function closeConnection()
{
if ($this->logged && $this->putLine($this->nextTag() . ' LOGOUT')) {
$this->readReply();
}
$this->closeSocket();
}
/**
* Executes SELECT command (if mailbox is already not in selected state)
*
* @param string $mailbox Mailbox name
* @param array $qresync_data QRESYNC data (RFC5162)
*
* @return boolean True on success, false on error
*/
public function select($mailbox, $qresync_data = null)
{
if (!strlen($mailbox)) {
return false;
}
if ($this->selected === $mailbox) {
return true;
}
$params = array($this->escape($mailbox));
// QRESYNC data items
// 0. the last known UIDVALIDITY,
// 1. the last known modification sequence,
// 2. the optional set of known UIDs, and
// 3. an optional parenthesized list of known sequence ranges and their
// corresponding UIDs.
if (!empty($qresync_data)) {
if (!empty($qresync_data[2])) {
$qresync_data[2] = self::compressMessageSet($qresync_data[2]);
}
$params[] = array('QRESYNC', $qresync_data);
}
list($code, $response) = $this->execute('SELECT', $params);
if ($code == self::ERROR_OK) {
$this->clear_mailbox_cache();
$response = explode("\r\n", $response);
foreach ($response as $line) {
if (preg_match('/^\* OK \[/i', $line)) {
$pos = strcspn($line, ' ]', 6);
$token = strtoupper(substr($line, 6, $pos));
$pos += 7;
switch ($token) {
case 'UIDNEXT':
case 'UIDVALIDITY':
case 'UNSEEN':
if ($len = strspn($line, '0123456789', $pos)) {
$this->data[$token] = (int) substr($line, $pos, $len);
}
break;
case 'HIGHESTMODSEQ':
if ($len = strspn($line, '0123456789', $pos)) {
$this->data[$token] = (string) substr($line, $pos, $len);
}
break;
case 'NOMODSEQ':
$this->data[$token] = true;
break;
case 'PERMANENTFLAGS':
$start = strpos($line, '(', $pos);
$end = strrpos($line, ')');
if ($start && $end) {
$flags = substr($line, $start + 1, $end - $start - 1);
$this->data[$token] = explode(' ', $flags);
}
break;
}
}
else if (preg_match('/^\* ([0-9]+) (EXISTS|RECENT|FETCH)/i', $line, $match)) {
$token = strtoupper($match[2]);
switch ($token) {
case 'EXISTS':
case 'RECENT':
$this->data[$token] = (int) $match[1];
break;
case 'FETCH':
// QRESYNC FETCH response (RFC5162)
$line = substr($line, strlen($match[0]));
$fetch_data = $this->tokenizeResponse($line, 1);
$data = array('id' => $match[1]);
for ($i=0, $size=count($fetch_data); $i<$size; $i+=2) {
$data[strtolower($fetch_data[$i])] = $fetch_data[$i+1];
}
$this->data['QRESYNC'][$data['uid']] = $data;
break;
}
}
// QRESYNC VANISHED response (RFC5162)
else if (preg_match('/^\* VANISHED [()EARLIER]*/i', $line, $match)) {
$line = substr($line, strlen($match[0]));
$v_data = $this->tokenizeResponse($line, 1);
$this->data['VANISHED'] = $v_data;
}
}
$this->data['READ-WRITE'] = $this->resultcode != 'READ-ONLY';
$this->selected = $mailbox;
return true;
}
return false;
}
/**
* Executes STATUS command
*
* @param string $mailbox Mailbox name
* @param array $items Additional requested item names. By default
* MESSAGES and UNSEEN are requested. Other defined
* in RFC3501: UIDNEXT, UIDVALIDITY, RECENT
*
* @return array Status item-value hash
* @since 0.5-beta
*/
public function status($mailbox, $items = array())
{
if (!strlen($mailbox)) {
return false;
}
if (!in_array('MESSAGES', $items)) {
$items[] = 'MESSAGES';
}
if (!in_array('UNSEEN', $items)) {
$items[] = 'UNSEEN';
}
list($code, $response) = $this->execute('STATUS',
array($this->escape($mailbox), '(' . implode(' ', $items) . ')'), 0, '/^\* STATUS /i');
if ($code == self::ERROR_OK && $response) {
$result = array();
$response = substr($response, 9); // remove prefix "* STATUS "
list($mbox, $items) = $this->tokenizeResponse($response, 2);
// Fix for #1487859. Some buggy server returns not quoted
// folder name with spaces. Let's try to handle this situation
if (!is_array($items) && ($pos = strpos($response, '(')) !== false) {
$response = substr($response, $pos);
$items = $this->tokenizeResponse($response, 1);
}
if (!is_array($items)) {
return $result;
}
for ($i=0, $len=count($items); $i<$len; $i += 2) {
$result[$items[$i]] = $items[$i+1];
}
$this->data['STATUS:'.$mailbox] = $result;
return $result;
}
return false;
}
/**
* Executes EXPUNGE command
*
* @param string $mailbox Mailbox name
* @param string|array $messages Message UIDs to expunge
*
* @return boolean True on success, False on error
*/
public function expunge($mailbox, $messages = null)
{
if (!$this->select($mailbox)) {
return false;
}
if (!$this->data['READ-WRITE']) {
$this->setError(self::ERROR_READONLY, "Mailbox is read-only");
return false;
}
// Clear internal status cache
$this->clear_status_cache($mailbox);
if (!empty($messages) && $messages != '*' && $this->hasCapability('UIDPLUS')) {
$messages = self::compressMessageSet($messages);
$result = $this->execute('UID EXPUNGE', array($messages), self::COMMAND_NORESPONSE);
}
else {
$result = $this->execute('EXPUNGE', null, self::COMMAND_NORESPONSE);
}
if ($result == self::ERROR_OK) {
$this->selected = null; // state has changed, need to reselect
return true;
}
return false;
}
/**
* Executes CLOSE command
*
* @return boolean True on success, False on error
* @since 0.5
*/
public function close()
{
$result = $this->execute('CLOSE', null, self::COMMAND_NORESPONSE);
if ($result == self::ERROR_OK) {
$this->selected = null;
return true;
}
return false;
}
/**
* Folder subscription (SUBSCRIBE)
*
* @param string $mailbox Mailbox name
*
* @return boolean True on success, False on error
*/
public function subscribe($mailbox)
{
$result = $this->execute('SUBSCRIBE', array($this->escape($mailbox)),
self::COMMAND_NORESPONSE);
return $result == self::ERROR_OK;
}
/**
* Folder unsubscription (UNSUBSCRIBE)
*
* @param string $mailbox Mailbox name
*
* @return boolean True on success, False on error
*/
public function unsubscribe($mailbox)
{
$result = $this->execute('UNSUBSCRIBE', array($this->escape($mailbox)),
self::COMMAND_NORESPONSE);
return $result == self::ERROR_OK;
}
/**
* Folder creation (CREATE)
*
* @param string $mailbox Mailbox name
* @param array $types Optional folder types (RFC 6154)
*
* @return bool True on success, False on error
*/
public function createFolder($mailbox, $types = null)
{
$args = array($this->escape($mailbox));
// RFC 6154: CREATE-SPECIAL-USE
if (!empty($types) && $this->getCapability('CREATE-SPECIAL-USE')) {
$args[] = '(USE (' . implode(' ', $types) . '))';
}
$result = $this->execute('CREATE', $args, self::COMMAND_NORESPONSE);
return $result == self::ERROR_OK;
}
/**
* Folder renaming (RENAME)
*
* @param string $mailbox Mailbox name
*
* @return bool True on success, False on error
*/
public function renameFolder($from, $to)
{
$result = $this->execute('RENAME', array($this->escape($from), $this->escape($to)),
self::COMMAND_NORESPONSE);
return $result == self::ERROR_OK;
}
/**
* Executes DELETE command
*
* @param string $mailbox Mailbox name
*
* @return boolean True on success, False on error
*/
public function deleteFolder($mailbox)
{
$result = $this->execute('DELETE', array($this->escape($mailbox)),
self::COMMAND_NORESPONSE);
return $result == self::ERROR_OK;
}
/**
* Removes all messages in a folder
*
* @param string $mailbox Mailbox name
*
* @return boolean True on success, False on error
*/
public function clearFolder($mailbox)
{
$res = false;
if ($this->countMessages($mailbox) > 0) {
$res = $this->flag($mailbox, '1:*', 'DELETED');
}
if ($res) {
if ($this->selected === $mailbox) {
$res = $this->close();
}
else {
$res = $this->expunge($mailbox);
}
}
return $res;
}
/**
* Returns list of mailboxes
*
* @param string $ref Reference name
* @param string $mailbox Mailbox name
* @param array $return_opts (see self::_listMailboxes)
* @param array $select_opts (see self::_listMailboxes)
*
* @return array|bool List of mailboxes or hash of options if STATUS/MYROGHTS response
* is requested, False on error.
*/
public function listMailboxes($ref, $mailbox, $return_opts = array(), $select_opts = array())
{
return $this->_listMailboxes($ref, $mailbox, false, $return_opts, $select_opts);
}
/**
* Returns list of subscribed mailboxes
*
* @param string $ref Reference name
* @param string $mailbox Mailbox name
* @param array $return_opts (see self::_listMailboxes)
*
* @return array|bool List of mailboxes or hash of options if STATUS/MYROGHTS response
* is requested, False on error.
*/
public function listSubscribed($ref, $mailbox, $return_opts = array())
{
return $this->_listMailboxes($ref, $mailbox, true, $return_opts, null);
}
/**
* IMAP LIST/LSUB command
*
* @param string $ref Reference name
* @param string $mailbox Mailbox name
* @param bool $subscribed Enables returning subscribed mailboxes only
* @param array $return_opts List of RETURN options (RFC5819: LIST-STATUS, RFC5258: LIST-EXTENDED)
* Possible: MESSAGES, RECENT, UIDNEXT, UIDVALIDITY, UNSEEN,
* MYRIGHTS, SUBSCRIBED, CHILDREN
* @param array $select_opts List of selection options (RFC5258: LIST-EXTENDED)
* Possible: SUBSCRIBED, RECURSIVEMATCH, REMOTE,
* SPECIAL-USE (RFC6154)
*
* @return array|bool List of mailboxes or hash of options if STATUS/MYROGHTS response
* is requested, False on error.
*/
protected function _listMailboxes($ref, $mailbox, $subscribed=false,
$return_opts=array(), $select_opts=array())
{
if (!strlen($mailbox)) {
$mailbox = '*';
}
$args = array();
$rets = array();
if (!empty($select_opts) && $this->getCapability('LIST-EXTENDED')) {
$select_opts = (array) $select_opts;
$args[] = '(' . implode(' ', $select_opts) . ')';
}
$lstatus = false;
$args[] = $this->escape($ref);
$args[] = $this->escape($mailbox);
if (!empty($return_opts) && $this->getCapability('LIST-EXTENDED')) {
$ext_opts = array('SUBSCRIBED', 'CHILDREN');
$rets = array_intersect($return_opts, $ext_opts);
$return_opts = array_diff($return_opts, $rets);
}
if (!empty($return_opts) && $this->getCapability('LIST-STATUS')) {
$lstatus = true;
$status_opts = array('MESSAGES', 'RECENT', 'UIDNEXT', 'UIDVALIDITY', 'UNSEEN');
$opts = array_diff($return_opts, $status_opts);
$status_opts = array_diff($return_opts, $opts);
if (!empty($status_opts)) {
$rets[] = 'STATUS (' . implode(' ', $status_opts) . ')';
}
if (!empty($opts)) {
$rets = array_merge($rets, $opts);
}
}
if (!empty($rets)) {
$args[] = 'RETURN (' . implode(' ', $rets) . ')';
}
list($code, $response) = $this->execute($subscribed ? 'LSUB' : 'LIST', $args);
if ($code == self::ERROR_OK) {
$folders = array();
$last = 0;
$pos = 0;
$response .= "\r\n";
while ($pos = strpos($response, "\r\n", $pos+1)) {
// literal string, not real end-of-command-line
if ($response[$pos-1] == '}') {
continue;
}
$line = substr($response, $last, $pos - $last);
$last = $pos + 2;
if (!preg_match('/^\* (LIST|LSUB|STATUS|MYRIGHTS) /i', $line, $m)) {
continue;
}
$cmd = strtoupper($m[1]);
$line = substr($line, strlen($m[0]));
// * LIST (<options>) <delimiter> <mailbox>
if ($cmd == 'LIST' || $cmd == 'LSUB') {
list($opts, $delim, $mailbox) = $this->tokenizeResponse($line, 3);
// Remove redundant separator at the end of folder name, UW-IMAP bug? (#1488879)
if ($delim) {
$mailbox = rtrim($mailbox, $delim);
}
// Add to result array
if (!$lstatus) {
$folders[] = $mailbox;
}
else {
$folders[$mailbox] = array();
}
// store folder options
if ($cmd == 'LIST') {
// Add to options array
if (empty($this->data['LIST'][$mailbox])) {
$this->data['LIST'][$mailbox] = $opts;
}
else if (!empty($opts)) {
$this->data['LIST'][$mailbox] = array_unique(array_merge(
$this->data['LIST'][$mailbox], $opts));
}
}
}
else if ($lstatus) {
// * STATUS <mailbox> (<result>)
if ($cmd == 'STATUS') {
list($mailbox, $status) = $this->tokenizeResponse($line, 2);
for ($i=0, $len=count($status); $i<$len; $i += 2) {
list($name, $value) = $this->tokenizeResponse($status, 2);
$folders[$mailbox][$name] = $value;
}
}
// * MYRIGHTS <mailbox> <acl>
else if ($cmd == 'MYRIGHTS') {
list($mailbox, $acl) = $this->tokenizeResponse($line, 2);
$folders[$mailbox]['MYRIGHTS'] = $acl;
}
}
}
return $folders;
}
return false;
}
/**
* Returns count of all messages in a folder
*
* @param string $mailbox Mailbox name
*
* @return int Number of messages, False on error
*/
public function countMessages($mailbox)
{
if ($this->selected === $mailbox && isset($this->data['EXISTS'])) {
return $this->data['EXISTS'];
}
if (isset($this->data["STATUS:".$mailbox])) {
$cache = $this->data["STATUS:".$mailbox];
if (!empty($cache) && isset($cache['MESSAGES'])) {
return (int) $cache['MESSAGES'];
}
}
// Try STATUS (should be faster than SELECT)
$counts = $this->status($mailbox);
if (is_array($counts)) {
return (int) $counts['MESSAGES'];
}
return false;
}
/**
* Returns count of messages with \Recent flag in a folder
*
* @param string $mailbox Mailbox name
*
* @return int Number of messages, False on error
*/
public function countRecent($mailbox)
{
if ($this->selected === $mailbox && isset($this->data['RECENT'])) {
return $this->data['RECENT'];
}
// Check internal cache
$cache = $this->data['STATUS:'.$mailbox];
if (!empty($cache) && isset($cache['RECENT'])) {
return (int) $cache['RECENT'];
}
// Try STATUS (should be faster than SELECT)
$counts = $this->status($mailbox, array('RECENT'));
if (is_array($counts)) {
return (int) $counts['RECENT'];
}
return false;
}
/**
* Returns count of messages without \Seen flag in a specified folder
*
* @param string $mailbox Mailbox name
*
* @return int Number of messages, False on error
*/
public function countUnseen($mailbox)
{
// Check internal cache
$cache = $this->data['STATUS:'.$mailbox];
if (!empty($cache) && isset($cache['UNSEEN'])) {
return (int) $cache['UNSEEN'];
}
// Try STATUS (should be faster than SELECT+SEARCH)
$counts = $this->status($mailbox);
if (is_array($counts)) {
return (int) $counts['UNSEEN'];
}
// Invoke SEARCH as a fallback
$index = $this->search($mailbox, 'ALL UNSEEN', false, array('COUNT'));
if (!$index->is_error()) {
return $index->count();
}
return false;
}
/**
* Executes ID command (RFC2971)
*
* @param array $items Client identification information key/value hash
*
* @return array Server identification information key/value hash
* @since 0.6
*/
public function id($items = array())
{
if (is_array($items) && !empty($items)) {
foreach ($items as $key => $value) {
$args[] = $this->escape($key, true);
$args[] = $this->escape($value, true);
}
}
list($code, $response) = $this->execute('ID',
array(!empty($args) ? '(' . implode(' ', (array) $args) . ')' : $this->escape(null)),
0, '/^\* ID /i');
if ($code == self::ERROR_OK && $response) {
$response = substr($response, 5); // remove prefix "* ID "
$items = $this->tokenizeResponse($response, 1);
$result = null;
for ($i=0, $len=count($items); $i<$len; $i += 2) {
$result[$items[$i]] = $items[$i+1];
}
return $result;
}
return false;
}
/**
* Executes ENABLE command (RFC5161)
*
* @param mixed $extension Extension name to enable (or array of names)
*
* @return array|bool List of enabled extensions, False on error
* @since 0.6
*/
public function enable($extension)
{
if (empty($extension)) {
return false;
}
if (!$this->hasCapability('ENABLE')) {
return false;
}
if (!is_array($extension)) {
$extension = array($extension);
}
if (!empty($this->extensions_enabled)) {
// check if all extensions are already enabled
$diff = array_diff($extension, $this->extensions_enabled);
if (empty($diff)) {
return $extension;
}
// Make sure the mailbox isn't selected, before enabling extension(s)
if ($this->selected !== null) {
$this->close();
}
}
list($code, $response) = $this->execute('ENABLE', $extension, 0, '/^\* ENABLED /i');
if ($code == self::ERROR_OK && $response) {
$response = substr($response, 10); // remove prefix "* ENABLED "
$result = (array) $this->tokenizeResponse($response);
$this->extensions_enabled = array_unique(array_merge((array)$this->extensions_enabled, $result));
return $this->extensions_enabled;
}
return false;
}
/**
* Executes SORT command
*
* @param string $mailbox Mailbox name
* @param string $field Field to sort by (ARRIVAL, CC, DATE, FROM, SIZE, SUBJECT, TO)
* @param string $criteria Searching criteria
* @param bool $return_uid Enables UID SORT usage
* @param string $encoding Character set
*
* @return rcube_result_index Response data
*/
public function sort($mailbox, $field = 'ARRIVAL', $criteria = '', $return_uid = false, $encoding = 'US-ASCII')
{
$old_sel = $this->selected;
$supported = array('ARRIVAL', 'CC', 'DATE', 'FROM', 'SIZE', 'SUBJECT', 'TO');
$field = strtoupper($field);
if ($field == 'INTERNALDATE') {
$field = 'ARRIVAL';
}
if (!in_array($field, $supported)) {
return new rcube_result_index($mailbox);
}
if (!$this->select($mailbox)) {
return new rcube_result_index($mailbox);
}
// return empty result when folder is empty and we're just after SELECT
if ($old_sel != $mailbox && !$this->data['EXISTS']) {
return new rcube_result_index($mailbox, '* SORT');
}
// RFC 5957: SORT=DISPLAY
if (($field == 'FROM' || $field == 'TO') && $this->getCapability('SORT=DISPLAY')) {
$field = 'DISPLAY' . $field;
}
$encoding = $encoding ? trim($encoding) : 'US-ASCII';
$criteria = $criteria ? 'ALL ' . trim($criteria) : 'ALL';
list($code, $response) = $this->execute($return_uid ? 'UID SORT' : 'SORT',
array("($field)", $encoding, $criteria));
if ($code != self::ERROR_OK) {
$response = null;
}
return new rcube_result_index($mailbox, $response);
}
/**
* Executes THREAD command
*
* @param string $mailbox Mailbox name
* @param string $algorithm Threading algorithm (ORDEREDSUBJECT, REFERENCES, REFS)
* @param string $criteria Searching criteria
* @param bool $return_uid Enables UIDs in result instead of sequence numbers
* @param string $encoding Character set
*
* @return rcube_result_thread Thread data
*/
public function thread($mailbox, $algorithm = 'REFERENCES', $criteria = '', $return_uid = false, $encoding = 'US-ASCII')
{
$old_sel = $this->selected;
if (!$this->select($mailbox)) {
return new rcube_result_thread($mailbox);
}
// return empty result when folder is empty and we're just after SELECT
if ($old_sel != $mailbox && !$this->data['EXISTS']) {
return new rcube_result_thread($mailbox, '* THREAD');
}
$encoding = $encoding ? trim($encoding) : 'US-ASCII';
$algorithm = $algorithm ? trim($algorithm) : 'REFERENCES';
$criteria = $criteria ? 'ALL '.trim($criteria) : 'ALL';
list($code, $response) = $this->execute($return_uid ? 'UID THREAD' : 'THREAD',
array($algorithm, $encoding, $criteria));
if ($code != self::ERROR_OK) {
$response = null;
}
return new rcube_result_thread($mailbox, $response);
}
/**
* Executes SEARCH command
*
* @param string $mailbox Mailbox name
* @param string $criteria Searching criteria
* @param bool $return_uid Enable UID in result instead of sequence ID
* @param array $items Return items (MIN, MAX, COUNT, ALL)
*
* @return rcube_result_index Result data
*/
public function search($mailbox, $criteria, $return_uid = false, $items = array())
{
$old_sel = $this->selected;
if (!$this->select($mailbox)) {
return new rcube_result_index($mailbox);
}
// return empty result when folder is empty and we're just after SELECT
if ($old_sel != $mailbox && !$this->data['EXISTS']) {
return new rcube_result_index($mailbox, '* SEARCH');
}
// If ESEARCH is supported always use ALL
// but not when items are specified or using simple id2uid search
if (empty($items) && preg_match('/[^0-9]/', $criteria)) {
$items = array('ALL');
}
$esearch = empty($items) ? false : $this->getCapability('ESEARCH');
$criteria = trim($criteria);
$params = '';
// RFC4731: ESEARCH
if (!empty($items) && $esearch) {
$params .= 'RETURN (' . implode(' ', $items) . ')';
}
if (!empty($criteria)) {
$params .= ($params ? ' ' : '') . $criteria;
}
else {
$params .= 'ALL';
}
list($code, $response) = $this->execute($return_uid ? 'UID SEARCH' : 'SEARCH',
array($params));
if ($code != self::ERROR_OK) {
$response = null;
}
return new rcube_result_index($mailbox, $response);
}
/**
* Simulates SORT command by using FETCH and sorting.
*
* @param string $mailbox Mailbox name
* @param string|array $message_set Searching criteria (list of messages to return)
* @param string $index_field Field to sort by (ARRIVAL, CC, DATE, FROM, SIZE, SUBJECT, TO)
* @param bool $skip_deleted Makes that DELETED messages will be skipped
* @param bool $uidfetch Enables UID FETCH usage
* @param bool $return_uid Enables returning UIDs instead of IDs
*
* @return rcube_result_index Response data
*/
public function index($mailbox, $message_set, $index_field='', $skip_deleted=true,
$uidfetch=false, $return_uid=false)
{
$msg_index = $this->fetchHeaderIndex($mailbox, $message_set,
$index_field, $skip_deleted, $uidfetch, $return_uid);
if (!empty($msg_index)) {
asort($msg_index); // ASC
$msg_index = array_keys($msg_index);
$msg_index = '* SEARCH ' . implode(' ', $msg_index);
}
else {
$msg_index = is_array($msg_index) ? '* SEARCH' : null;
}
return new rcube_result_index($mailbox, $msg_index);
}
/**
* Fetches specified header/data value for a set of messages.
*
* @param string $mailbox Mailbox name
* @param string|array $message_set Searching criteria (list of messages to return)
* @param string $index_field Field to sort by (ARRIVAL, CC, DATE, FROM, SIZE, SUBJECT, TO)
* @param bool $skip_deleted Makes that DELETED messages will be skipped
* @param bool $uidfetch Enables UID FETCH usage
* @param bool $return_uid Enables returning UIDs instead of IDs
*
* @return array|bool List of header values or False on failure
*/
public function fetchHeaderIndex($mailbox, $message_set, $index_field = '', $skip_deleted = true,
$uidfetch = false, $return_uid = false)
{
if (is_array($message_set)) {
if (!($message_set = $this->compressMessageSet($message_set))) {
return false;
}
}
else {
list($from_idx, $to_idx) = explode(':', $message_set);
if (empty($message_set) ||
(isset($to_idx) && $to_idx != '*' && (int)$from_idx > (int)$to_idx)
) {
return false;
}
}
$index_field = empty($index_field) ? 'DATE' : strtoupper($index_field);
$fields_a['DATE'] = 1;
$fields_a['INTERNALDATE'] = 4;
$fields_a['ARRIVAL'] = 4;
$fields_a['FROM'] = 1;
$fields_a['REPLY-TO'] = 1;
$fields_a['SENDER'] = 1;
$fields_a['TO'] = 1;
$fields_a['CC'] = 1;
$fields_a['SUBJECT'] = 1;
$fields_a['UID'] = 2;
$fields_a['SIZE'] = 2;
$fields_a['SEEN'] = 3;
$fields_a['RECENT'] = 3;
$fields_a['DELETED'] = 3;
if (!($mode = $fields_a[$index_field])) {
return false;
}
// Select the mailbox
if (!$this->select($mailbox)) {
return false;
}
// build FETCH command string
$key = $this->nextTag();
$cmd = $uidfetch ? 'UID FETCH' : 'FETCH';
$fields = array();
if ($return_uid) {
$fields[] = 'UID';
}
if ($skip_deleted) {
$fields[] = 'FLAGS';
}
if ($mode == 1) {
if ($index_field == 'DATE') {
$fields[] = 'INTERNALDATE';
}
$fields[] = "BODY.PEEK[HEADER.FIELDS ($index_field)]";
}
else if ($mode == 2) {
if ($index_field == 'SIZE') {
$fields[] = 'RFC822.SIZE';
}
else if (!$return_uid || $index_field != 'UID') {
$fields[] = $index_field;
}
}
else if ($mode == 3 && !$skip_deleted) {
$fields[] = 'FLAGS';
}
else if ($mode == 4) {
$fields[] = 'INTERNALDATE';
}
$request = "$key $cmd $message_set (" . implode(' ', $fields) . ")";
if (!$this->putLine($request)) {
$this->setError(self::ERROR_COMMAND, "Failed to send $cmd command");
return false;
}
$result = array();
do {
$line = rtrim($this->readLine(200));
$line = $this->multLine($line);
if (preg_match('/^\* ([0-9]+) FETCH/', $line, $m)) {
$id = $m[1];
$flags = null;
if ($return_uid) {
if (preg_match('/UID ([0-9]+)/', $line, $matches)) {
$id = (int) $matches[1];
}
else {
continue;
}
}
if ($skip_deleted && preg_match('/FLAGS \(([^)]+)\)/', $line, $matches)) {
$flags = explode(' ', strtoupper($matches[1]));
if (in_array('\\DELETED', $flags)) {
continue;
}
}
if ($mode == 1 && $index_field == 'DATE') {
if (preg_match('/BODY\[HEADER\.FIELDS \("*DATE"*\)\] (.*)/', $line, $matches)) {
$value = preg_replace(array('/^"*[a-z]+:/i'), '', $matches[1]);
$value = trim($value);
$result[$id] = rcube_utils::strtotime($value);
}
// non-existent/empty Date: header, use INTERNALDATE
if (empty($result[$id])) {
if (preg_match('/INTERNALDATE "([^"]+)"/', $line, $matches)) {
$result[$id] = rcube_utils::strtotime($matches[1]);
}
else {
$result[$id] = 0;
}
}
}
else if ($mode == 1) {
if (preg_match('/BODY\[HEADER\.FIELDS \("?(FROM|REPLY-TO|SENDER|TO|SUBJECT)"?\)\] (.*)/', $line, $matches)) {
$value = preg_replace(array('/^"*[a-z]+:/i', '/\s+$/sm'), array('', ''), $matches[2]);
$result[$id] = trim($value);
}
else {
$result[$id] = '';
}
}
else if ($mode == 2) {
if (preg_match('/' . $index_field . ' ([0-9]+)/', $line, $matches)) {
$result[$id] = trim($matches[1]);
}
else {
$result[$id] = 0;
}
}
else if ($mode == 3) {
if (!$flags && preg_match('/FLAGS \(([^)]+)\)/', $line, $matches)) {
$flags = explode(' ', $matches[1]);
}
$result[$id] = in_array("\\".$index_field, (array) $flags) ? 1 : 0;
}
else if ($mode == 4) {
if (preg_match('/INTERNALDATE "([^"]+)"/', $line, $matches)) {
$result[$id] = rcube_utils::strtotime($matches[1]);
}
else {
$result[$id] = 0;
}
}
}
}
while (!$this->startsWith($line, $key, true, true));
return $result;
}
/**
* Returns message sequence identifier
*
* @param string $mailbox Mailbox name
* @param int $uid Message unique identifier (UID)
*
* @return int Message sequence identifier
*/
public function UID2ID($mailbox, $uid)
{
if ($uid > 0) {
$index = $this->search($mailbox, "UID $uid");
if ($index->count() == 1) {
$arr = $index->get();
return (int) $arr[0];
}
}
}
/**
* Returns message unique identifier (UID)
*
* @param string $mailbox Mailbox name
* @param int $uid Message sequence identifier
*
* @return int Message unique identifier
*/
public function ID2UID($mailbox, $id)
{
if (empty($id) || $id < 0) {
return null;
}
if (!$this->select($mailbox)) {
return null;
}
if ($uid = $this->data['UID-MAP'][$id]) {
return $uid;
}
if (isset($this->data['EXISTS']) && $id > $this->data['EXISTS']) {
return null;
}
$index = $this->search($mailbox, $id, true);
if ($index->count() == 1) {
$arr = $index->get();
return $this->data['UID-MAP'][$id] = (int) $arr[0];
}
}
/**
* Sets flag of the message(s)
*
* @param string $mailbox Mailbox name
* @param string|array $messages Message UID(s)
* @param string $flag Flag name
*
* @return bool True on success, False on failure
*/
public function flag($mailbox, $messages, $flag)
{
return $this->modFlag($mailbox, $messages, $flag, '+');
}
/**
* Unsets flag of the message(s)
*
* @param string $mailbox Mailbox name
* @param string|array $messages Message UID(s)
* @param string $flag Flag name
*
* @return bool True on success, False on failure
*/
public function unflag($mailbox, $messages, $flag)
{
return $this->modFlag($mailbox, $messages, $flag, '-');
}
/**
* Changes flag of the message(s)
*
* @param string $mailbox Mailbox name
* @param string|array $messages Message UID(s)
* @param string $flag Flag name
* @param string $mod Modifier [+|-]. Default: "+".
*
* @return bool True on success, False on failure
*/
protected function modFlag($mailbox, $messages, $flag, $mod = '+')
{
if (!$flag) {
return false;
}
if (!$this->select($mailbox)) {
return false;
}
if (!$this->data['READ-WRITE']) {
$this->setError(self::ERROR_READONLY, "Mailbox is read-only");
return false;
}
if ($this->flags[strtoupper($flag)]) {
$flag = $this->flags[strtoupper($flag)];
}
// if PERMANENTFLAGS is not specified all flags are allowed
if (!empty($this->data['PERMANENTFLAGS'])
&& !in_array($flag, (array) $this->data['PERMANENTFLAGS'])
&& !in_array('\\*', (array) $this->data['PERMANENTFLAGS'])
) {
return false;
}
// Clear internal status cache
if ($flag == 'SEEN') {
unset($this->data['STATUS:'.$mailbox]['UNSEEN']);
}
if ($mod != '+' && $mod != '-') {
$mod = '+';
}
$result = $this->execute('UID STORE', array(
$this->compressMessageSet($messages), $mod . 'FLAGS.SILENT', "($flag)"),
self::COMMAND_NORESPONSE);
return $result == self::ERROR_OK;
}
/**
* Copies message(s) from one folder to another
*
* @param string|array $messages Message UID(s)
* @param string $from Mailbox name
* @param string $to Destination mailbox name
*
* @return bool True on success, False on failure
*/
public function copy($messages, $from, $to)
{
// Clear last COPYUID data
unset($this->data['COPYUID']);
if (!$this->select($from)) {
return false;
}
// Clear internal status cache
unset($this->data['STATUS:'.$to]);
$result = $this->execute('UID COPY', array(
$this->compressMessageSet($messages), $this->escape($to)),
self::COMMAND_NORESPONSE);
return $result == self::ERROR_OK;
}
/**
* Moves message(s) from one folder to another.
*
* @param string|array $messages Message UID(s)
* @param string $from Mailbox name
* @param string $to Destination mailbox name
*
* @return bool True on success, False on failure
*/
public function move($messages, $from, $to)
{
if (!$this->select($from)) {
return false;
}
if (!$this->data['READ-WRITE']) {
$this->setError(self::ERROR_READONLY, "Mailbox is read-only");
return false;
}
// use MOVE command (RFC 6851)
if ($this->hasCapability('MOVE')) {
// Clear last COPYUID data
unset($this->data['COPYUID']);
// Clear internal status cache
unset($this->data['STATUS:'.$to]);
$this->clear_status_cache($from);
$result = $this->execute('UID MOVE', array(
$this->compressMessageSet($messages), $this->escape($to)),
self::COMMAND_NORESPONSE);
return $result == self::ERROR_OK;
}
// use COPY + STORE +FLAGS.SILENT \Deleted + EXPUNGE
$result = $this->copy($messages, $from, $to);
if ($result) {
// Clear internal status cache
unset($this->data['STATUS:'.$from]);
$result = $this->flag($from, $messages, 'DELETED');
if ($messages == '*') {
// CLOSE+SELECT should be faster than EXPUNGE
$this->close();
}
else {
$this->expunge($from, $messages);
}
}
return $result;
}
/**
* FETCH command (RFC3501)
*
* @param string $mailbox Mailbox name
* @param mixed $message_set Message(s) sequence identifier(s) or UID(s)
* @param bool $is_uid True if $message_set contains UIDs
* @param array $query_items FETCH command data items
* @param string $mod_seq Modification sequence for CHANGEDSINCE (RFC4551) query
* @param bool $vanished Enables VANISHED parameter (RFC5162) for CHANGEDSINCE query
*
* @return array List of rcube_message_header elements, False on error
* @since 0.6
*/
public function fetch($mailbox, $message_set, $is_uid = false, $query_items = array(),
$mod_seq = null, $vanished = false)
{
if (!$this->select($mailbox)) {
return false;
}
$message_set = $this->compressMessageSet($message_set);
$result = array();
$key = $this->nextTag();
$cmd = ($is_uid ? 'UID ' : '') . 'FETCH';
$request = "$key $cmd $message_set (" . implode(' ', $query_items) . ")";
if ($mod_seq !== null && $this->hasCapability('CONDSTORE')) {
$request .= " (CHANGEDSINCE $mod_seq" . ($vanished ? " VANISHED" : '') .")";
}
if (!$this->putLine($request)) {
$this->setError(self::ERROR_COMMAND, "Failed to send $cmd command");
return false;
}
do {
$line = $this->readLine(4096);
if (!$line) {
break;
}
// Sample reply line:
// * 321 FETCH (UID 2417 RFC822.SIZE 2730 FLAGS (\Seen)
// INTERNALDATE "16-Nov-2008 21:08:46 +0100" BODYSTRUCTURE (...)
// BODY[HEADER.FIELDS ...
if (preg_match('/^\* ([0-9]+) FETCH/', $line, $m)) {
$id = intval($m[1]);
$result[$id] = new rcube_message_header;
$result[$id]->id = $id;
$result[$id]->subject = '';
$result[$id]->messageID = 'mid:' . $id;
$headers = null;
$lines = array();
$line = substr($line, strlen($m[0]) + 2);
$ln = 0;
// get complete entry
while (preg_match('/\{([0-9]+)\}\r\n$/', $line, $m)) {
$bytes = $m[1];
$out = '';
while (strlen($out) < $bytes) {
$out = $this->readBytes($bytes);
if ($out === null) {
break;
}
$line .= $out;
}
$str = $this->readLine(4096);
if ($str === false) {
break;
}
$line .= $str;
}
// Tokenize response and assign to object properties
while (list($name, $value) = $this->tokenizeResponse($line, 2)) {
if ($name == 'UID') {
$result[$id]->uid = intval($value);
}
else if ($name == 'RFC822.SIZE') {
$result[$id]->size = intval($value);
}
else if ($name == 'RFC822.TEXT') {
$result[$id]->body = $value;
}
else if ($name == 'INTERNALDATE') {
$result[$id]->internaldate = $value;
$result[$id]->date = $value;
$result[$id]->timestamp = rcube_utils::strtotime($value);
}
else if ($name == 'FLAGS') {
if (!empty($value)) {
foreach ((array)$value as $flag) {
$flag = str_replace(array('$', "\\"), '', $flag);
$flag = strtoupper($flag);
$result[$id]->flags[$flag] = true;
}
}
}
else if ($name == 'MODSEQ') {
$result[$id]->modseq = $value[0];
}
else if ($name == 'ENVELOPE') {
$result[$id]->envelope = $value;
}
else if ($name == 'BODYSTRUCTURE' || ($name == 'BODY' && count($value) > 2)) {
if (!is_array($value[0]) && (strtolower($value[0]) == 'message' && strtolower($value[1]) == 'rfc822')) {
$value = array($value);
}
$result[$id]->bodystructure = $value;
}
else if ($name == 'RFC822') {
$result[$id]->body = $value;
}
else if (stripos($name, 'BODY[') === 0) {
$name = str_replace(']', '', substr($name, 5));
if ($name == 'HEADER.FIELDS') {
// skip ']' after headers list
$this->tokenizeResponse($line, 1);
$headers = $this->tokenizeResponse($line, 1);
}
else if (strlen($name)) {
$result[$id]->bodypart[$name] = $value;
}
else {
$result[$id]->body = $value;
}
}
}
// create array with header field:data
if (!empty($headers)) {
$headers = explode("\n", trim($headers));
foreach ($headers as $resln) {
if (ord($resln[0]) <= 32) {
$lines[$ln] .= (empty($lines[$ln]) ? '' : "\n") . trim($resln);
}
else {
$lines[++$ln] = trim($resln);
}
}
foreach ($lines as $str) {
list($field, $string) = explode(':', $str, 2);
$field = strtolower($field);
$string = preg_replace('/\n[\t\s]*/', ' ', trim($string));
switch ($field) {
case 'date';
$string = substr($string, 0, 128);
$result[$id]->date = $string;
$result[$id]->timestamp = rcube_utils::strtotime($string);
break;
case 'to':
$result[$id]->to = preg_replace('/undisclosed-recipients:[;,]*/', '', $string);
break;
case 'from':
case 'subject':
$string = substr($string, 0, 2048);
case 'cc':
case 'bcc':
case 'references':
$result[$id]->{$field} = $string;
break;
case 'reply-to':
$result[$id]->replyto = $string;
break;
case 'content-transfer-encoding':
$result[$id]->encoding = substr($string, 0, 32);
break;
case 'content-type':
$ctype_parts = preg_split('/[; ]+/', $string);
$result[$id]->ctype = strtolower(array_shift($ctype_parts));
if (preg_match('/charset\s*=\s*"?([a-z0-9\-\.\_]+)"?/i', $string, $regs)) {
$result[$id]->charset = $regs[1];
}
break;
case 'in-reply-to':
$result[$id]->in_reply_to = str_replace(array("\n", '<', '>'), '', $string);
break;
case 'return-receipt-to':
case 'disposition-notification-to':
case 'x-confirm-reading-to':
$result[$id]->mdn_to = substr($string, 0, 2048);
break;
case 'message-id':
$result[$id]->messageID = substr($string, 0, 2048);
break;
case 'x-priority':
if (preg_match('/^(\d+)/', $string, $matches)) {
$result[$id]->priority = intval($matches[1]);
}
break;
default:
if (strlen($field) < 3) {
break;
}
if ($result[$id]->others[$field]) {
$string = array_merge((array)$result[$id]->others[$field], (array)$string);
}
$result[$id]->others[$field] = $string;
}
}
}
}
// VANISHED response (QRESYNC RFC5162)
// Sample: * VANISHED (EARLIER) 300:310,405,411
else if (preg_match('/^\* VANISHED [()EARLIER]*/i', $line, $match)) {
$line = substr($line, strlen($match[0]));
$v_data = $this->tokenizeResponse($line, 1);
$this->data['VANISHED'] = $v_data;
}
}
while (!$this->startsWith($line, $key, true));
return $result;
}
/**
* Returns message(s) data (flags, headers, etc.)
*
* @param string $mailbox Mailbox name
* @param mixed $message_set Message(s) sequence identifier(s) or UID(s)
* @param bool $is_uid True if $message_set contains UIDs
* @param bool $bodystr Enable to add BODYSTRUCTURE data to the result
* @param array $add_headers List of additional headers
*
* @return bool|array List of rcube_message_header elements, False on error
*/
public function fetchHeaders($mailbox, $message_set, $is_uid = false, $bodystr = false, $add_headers = array())
{
$query_items = array('UID', 'RFC822.SIZE', 'FLAGS', 'INTERNALDATE');
$headers = array('DATE', 'FROM', 'TO', 'SUBJECT', 'CONTENT-TYPE', 'CC', 'REPLY-TO',
'LIST-POST', 'DISPOSITION-NOTIFICATION-TO', 'X-PRIORITY');
if (!empty($add_headers)) {
$add_headers = array_map('strtoupper', $add_headers);
$headers = array_unique(array_merge($headers, $add_headers));
}
if ($bodystr) {
$query_items[] = 'BODYSTRUCTURE';
}
$query_items[] = 'BODY.PEEK[HEADER.FIELDS (' . implode(' ', $headers) . ')]';
return $this->fetch($mailbox, $message_set, $is_uid, $query_items);
}
/**
* Returns message data (flags, headers, etc.)
*
* @param string $mailbox Mailbox name
* @param int $id Message sequence identifier or UID
* @param bool $is_uid True if $id is an UID
* @param bool $bodystr Enable to add BODYSTRUCTURE data to the result
* @param array $add_headers List of additional headers
*
* @return bool|rcube_message_header Message data, False on error
*/
public function fetchHeader($mailbox, $id, $is_uid = false, $bodystr = false, $add_headers = array())
{
$a = $this->fetchHeaders($mailbox, $id, $is_uid, $bodystr, $add_headers);
if (is_array($a)) {
return array_shift($a);
}
return false;
}
/**
* Sort messages by specified header field
*
* @param array $messages Array of rcube_message_header objects
* @param string $field Name of the property to sort by
* @param string $flag Sorting order (ASC|DESC)
*
* @return array Sorted input array
*/
public static function sortHeaders($messages, $field, $flag)
{
$field = empty($field) ? 'uid' : strtolower($field);
$order = empty($flag) ? 'ASC' : strtoupper($flag);
$index = array();
reset($messages);
// Create an index
foreach ($messages as $key => $headers) {
switch ($field) {
case 'arrival':
$field = 'internaldate';
// no-break
case 'date':
case 'internaldate':
case 'timestamp':
$value = rcube_utils::strtotime($headers->$field);
if (!$value && $field != 'timestamp') {
$value = $headers->timestamp;
}
break;
default:
// @TODO: decode header value, convert to UTF-8
$value = $headers->$field;
if (is_string($value)) {
$value = str_replace('"', '', $value);
if ($field == 'subject') {
$value = preg_replace('/^(Re:\s*|Fwd:\s*|Fw:\s*)+/i', '', $value);
}
}
}
$index[$key] = $value;
}
$sort_order = $flag == 'ASC' ? SORT_ASC : SORT_DESC;
$sort_flags = SORT_STRING | SORT_FLAG_CASE;
if (in_array($field, array('arrival', 'date', 'internaldate', 'timestamp'))) {
$sort_flags = SORT_NUMERIC;
}
array_multisort($index, $sort_order, $sort_flags, $messages);
return $messages;
}
/**
* Fetch MIME headers of specified message parts
*
* @param string $mailbox Mailbox name
* @param int $uid Message UID
* @param array $parts Message part identifiers
* @param bool $mime Use MIME instad of HEADER
*
* @return array|bool Array containing headers string for each specified body
* False on failure.
*/
public function fetchMIMEHeaders($mailbox, $uid, $parts, $mime = true)
{
if (!$this->select($mailbox)) {
return false;
}
$result = false;
$parts = (array) $parts;
$key = $this->nextTag();
$peeks = array();
$type = $mime ? 'MIME' : 'HEADER';
// format request
foreach ($parts as $part) {
$peeks[] = "BODY.PEEK[$part.$type]";
}
$request = "$key UID FETCH $uid (" . implode(' ', $peeks) . ')';
// send request
if (!$this->putLine($request)) {
$this->setError(self::ERROR_COMMAND, "Failed to send UID FETCH command");
return false;
}
do {
$line = $this->readLine(1024);
if (preg_match('/^\* [0-9]+ FETCH [0-9UID( ]+/', $line, $m)) {
$line = ltrim(substr($line, strlen($m[0])));
while (preg_match('/^BODY\[([0-9\.]+)\.'.$type.'\]/', $line, $matches)) {
$line = substr($line, strlen($matches[0]));
$result[$matches[1]] = trim($this->multLine($line));
$line = $this->readLine(1024);
}
}
}
while (!$this->startsWith($line, $key, true));
return $result;
}
/**
* Fetches message part header
*/
public function fetchPartHeader($mailbox, $id, $is_uid = false, $part = null)
{
$part = empty($part) ? 'HEADER' : $part.'.MIME';
return $this->handlePartBody($mailbox, $id, $is_uid, $part);
}
/**
* Fetches body of the specified message part
*/
public function handlePartBody($mailbox, $id, $is_uid=false, $part='', $encoding=null, $print=null, $file=null, $formatted=false, $max_bytes=0)
{
if (!$this->select($mailbox)) {
return false;
}
$binary = true;
do {
if (!$initiated) {
switch ($encoding) {
case 'base64':
$mode = 1;
break;
case 'quoted-printable':
$mode = 2;
break;
case 'x-uuencode':
case 'x-uue':
case 'uue':
case 'uuencode':
$mode = 3;
break;
default:
$mode = 0;
}
// Use BINARY extension when possible (and safe)
$binary = $binary && $mode && preg_match('/^[0-9.]+$/', $part) && $this->hasCapability('BINARY');
$fetch_mode = $binary ? 'BINARY' : 'BODY';
$partial = $max_bytes ? sprintf('<0.%d>', $max_bytes) : '';
// format request
$key = $this->nextTag();
$cmd = ($is_uid ? 'UID ' : '') . 'FETCH';
$request = "$key $cmd $id ($fetch_mode.PEEK[$part]$partial)";
$result = false;
$found = false;
$initiated = true;
// send request
if (!$this->putLine($request)) {
$this->setError(self::ERROR_COMMAND, "Failed to send $cmd command");
return false;
}
if ($binary) {
// WARNING: Use $formatted argument with care, this may break binary data stream
$mode = -1;
}
}
$line = trim($this->readLine(1024));
if (!$line) {
break;
}
// handle UNKNOWN-CTE response - RFC 3516, try again with standard BODY request
if ($binary && !$found && preg_match('/^' . $key . ' NO \[(UNKNOWN-CTE|PARSE)\]/i', $line)) {
$binary = $initiated = false;
continue;
}
// skip irrelevant untagged responses (we have a result already)
if ($found || !preg_match('/^\* ([0-9]+) FETCH (.*)$/', $line, $m)) {
continue;
}
$line = $m[2];
// handle one line response
if ($line[0] == '(' && substr($line, -1) == ')') {
// tokenize content inside brackets
// the content can be e.g.: (UID 9844 BODY[2.4] NIL)
$tokens = $this->tokenizeResponse(preg_replace('/(^\(|\)$)/', '', $line));
for ($i=0; $i<count($tokens); $i+=2) {
if (preg_match('/^(BODY|BINARY)/i', $tokens[$i])) {
$result = $tokens[$i+1];
$found = true;
break;
}
}
if ($result !== false) {
if ($mode == 1) {
$result = base64_decode($result);
}
else if ($mode == 2) {
$result = quoted_printable_decode($result);
}
else if ($mode == 3) {
$result = convert_uudecode($result);
}
}
}
// response with string literal
else if (preg_match('/\{([0-9]+)\}$/', $line, $m)) {
$bytes = (int) $m[1];
$prev = '';
$found = true;
// empty body
if (!$bytes) {
$result = '';
}
else while ($bytes > 0) {
$line = $this->readLine(8192);
if ($line === null) {
break;
}
$len = strlen($line);
if ($len > $bytes) {
$line = substr($line, 0, $bytes);
$len = strlen($line);
}
$bytes -= $len;
// BASE64
if ($mode == 1) {
$line = preg_replace('|[^a-zA-Z0-9+=/]|', '', $line);
// create chunks with proper length for base64 decoding
$line = $prev.$line;
$length = strlen($line);
if ($length % 4) {
$length = floor($length / 4) * 4;
$prev = substr($line, $length);
$line = substr($line, 0, $length);
}
else {
$prev = '';
}
$line = base64_decode($line);
}
// QUOTED-PRINTABLE
else if ($mode == 2) {
$line = rtrim($line, "\t\r\0\x0B");
$line = quoted_printable_decode($line);
}
// UUENCODE
else if ($mode == 3) {
$line = rtrim($line, "\t\r\n\0\x0B");
if ($line == 'end' || preg_match('/^begin\s+[0-7]+\s+.+$/', $line)) {
continue;
}
$line = convert_uudecode($line);
}
// default
else if ($formatted) {
$line = rtrim($line, "\t\r\n\0\x0B") . "\n";
}
if ($file) {
if (fwrite($file, $line) === false) {
break;
}
}
else if ($print) {
echo $line;
}
else {
$result .= $line;
}
}
}
}
while (!$this->startsWith($line, $key, true) || !$initiated);
if ($result !== false) {
if ($file) {
return fwrite($file, $result);
}
else if ($print) {
echo $result;
return true;
}
return $result;
}
return false;
}
/**
* Handler for IMAP APPEND command
*
* @param string $mailbox Mailbox name
* @param string|array $message The message source string or array (of strings and file pointers)
* @param array $flags Message flags
* @param string $date Message internal date
* @param bool $binary Enable BINARY append (RFC3516)
*
* @return string|bool On success APPENDUID response (if available) or True, False on failure
*/
public function append($mailbox, &$message, $flags = array(), $date = null, $binary = false)
{
unset($this->data['APPENDUID']);
if ($mailbox === null || $mailbox === '') {
return false;
}
$binary = $binary && $this->getCapability('BINARY');
$literal_plus = !$binary && $this->prefs['literal+'];
$len = 0;
$msg = is_array($message) ? $message : array(&$message);
$chunk_size = 512000;
for ($i=0, $cnt=count($msg); $i<$cnt; $i++) {
if (is_resource($msg[$i])) {
$stat = fstat($msg[$i]);
if ($stat === false) {
return false;
}
$len += $stat['size'];
}
else {
if (!$binary) {
$msg[$i] = str_replace("\r", '', $msg[$i]);
$msg[$i] = str_replace("\n", "\r\n", $msg[$i]);
}
$len += strlen($msg[$i]);
}
}
if (!$len) {
return false;
}
// build APPEND command
$key = $this->nextTag();
$request = "$key APPEND " . $this->escape($mailbox) . ' (' . $this->flagsToStr($flags) . ')';
if (!empty($date)) {
$request .= ' ' . $this->escape($date);
}
$request .= ' ' . ($binary ? '~' : '') . '{' . $len . ($literal_plus ? '+' : '') . '}';
// send APPEND command
if (!$this->putLine($request)) {
$this->setError(self::ERROR_COMMAND, "Failed to send APPEND command");
return false;
}
// Do not wait when LITERAL+ is supported
if (!$literal_plus) {
$line = $this->readReply();
if ($line[0] != '+') {
$this->parseResult($line, 'APPEND: ');
return false;
}
}
foreach ($msg as $msg_part) {
// file pointer
if (is_resource($msg_part)) {
rewind($msg_part);
while (!feof($msg_part) && $this->fp) {
$buffer = fread($msg_part, $chunk_size);
$this->putLine($buffer, false);
}
fclose($msg_part);
}
// string
else {
$size = strlen($msg_part);
// Break up the data by sending one chunk (up to 512k) at a time.
// This approach reduces our peak memory usage
for ($offset = 0; $offset < $size; $offset += $chunk_size) {
$chunk = substr($msg_part, $offset, $chunk_size);
if (!$this->putLine($chunk, false)) {
return false;
}
}
}
}
if (!$this->putLine('')) { // \r\n
return false;
}
do {
$line = $this->readLine();
} while (!$this->startsWith($line, $key, true, true));
// Clear internal status cache
unset($this->data['STATUS:'.$mailbox]);
if ($this->parseResult($line, 'APPEND: ') != self::ERROR_OK) {
return false;
}
if (!empty($this->data['APPENDUID'])) {
return $this->data['APPENDUID'];
}
return true;
}
/**
* Handler for IMAP APPEND command.
*
* @param string $mailbox Mailbox name
* @param string $path Path to the file with message body
* @param string $headers Message headers
* @param array $flags Message flags
* @param string $date Message internal date
* @param bool $binary Enable BINARY append (RFC3516)
*
* @return string|bool On success APPENDUID response (if available) or True, False on failure
*/
public function appendFromFile($mailbox, $path, $headers=null, $flags = array(), $date = null, $binary = false)
{
// open message file
if (file_exists(realpath($path))) {
$fp = fopen($path, 'r');
}
if (!$fp) {
$this->setError(self::ERROR_UNKNOWN, "Couldn't open $path for reading");
return false;
}
$message = array();
if ($headers) {
$message[] = trim($headers, "\r\n") . "\r\n\r\n";
}
$message[] = $fp;
return $this->append($mailbox, $message, $flags, $date, $binary);
}
/**
* Returns QUOTA information
*
* @param string $mailbox Mailbox name
*
* @return array Quota information
*/
public function getQuota($mailbox = null)
{
if ($mailbox === null || $mailbox === '') {
$mailbox = 'INBOX';
}
// a0001 GETQUOTAROOT INBOX
// * QUOTAROOT INBOX user/sample
// * QUOTA user/sample (STORAGE 654 9765)
// a0001 OK Completed
list($code, $response) = $this->execute('GETQUOTAROOT', array($this->escape($mailbox)), 0, '/^\* QUOTA /i');
$result = false;
$min_free = PHP_INT_MAX;
$all = array();
if ($code == self::ERROR_OK) {
foreach (explode("\n", $response) as $line) {
list(, , $quota_root) = $this->tokenizeResponse($line, 3);
$quotas = $this->tokenizeResponse($line, 1);
if (empty($quotas)) {
continue;
}
foreach (array_chunk($quotas, 3) as $quota) {
list($type, $used, $total) = $quota;
$type = strtolower($type);
if ($type && $total) {
$all[$quota_root][$type]['used'] = intval($used);
$all[$quota_root][$type]['total'] = intval($total);
}
}
if (empty($all[$quota_root]['storage'])) {
continue;
}
$used = $all[$quota_root]['storage']['used'];
$total = $all[$quota_root]['storage']['total'];
$free = $total - $used;
// calculate lowest available space from all storage quotas
if ($free < $min_free) {
$min_free = $free;
$result['used'] = $used;
$result['total'] = $total;
$result['percent'] = min(100, round(($used/max(1,$total))*100));
$result['free'] = 100 - $result['percent'];
}
}
}
if (!empty($result)) {
$result['all'] = $all;
}
return $result;
}
+ /**
+ * Send the SETQUOTA command (RFC9208)
+ *
+ * @param string $root Quota root
+ * @param array $quota Quota limits e.g. ['storage' => 1024000']
+ *
+ * @return boolean True on success, False on failure
+ */
+ public function setQuota($root, $quota)
+ {
+ $fn = function ($key, $value) {
+ return strtoupper($key) . ' ' . $value;
+ };
+
+ $quota = implode(' ', array_map($fn, array_keys($quota), $quota));
+
+ $result = $this->execute('SETQUOTA', [$this->escape($root), "({$quota})"],
+ self::COMMAND_NORESPONSE);
+
+ return ($result == self::ERROR_OK);
+ }
+
/**
* Send the SETACL command (RFC4314)
*
* @param string $mailbox Mailbox name
* @param string $user User name
* @param mixed $acl ACL string or array
*
* @return boolean True on success, False on failure
*
* @since 0.5-beta
*/
public function setACL($mailbox, $user, $acl)
{
if (is_array($acl)) {
$acl = implode('', $acl);
}
$result = $this->execute('SETACL', array(
$this->escape($mailbox), $this->escape($user), strtolower($acl)),
self::COMMAND_NORESPONSE);
return ($result == self::ERROR_OK);
}
/**
* Send the DELETEACL command (RFC4314)
*
* @param string $mailbox Mailbox name
* @param string $user User name
*
* @return boolean True on success, False on failure
*
* @since 0.5-beta
*/
public function deleteACL($mailbox, $user)
{
$result = $this->execute('DELETEACL', array(
$this->escape($mailbox), $this->escape($user)),
self::COMMAND_NORESPONSE);
return ($result == self::ERROR_OK);
}
/**
* Send the GETACL command (RFC4314)
*
* @param string $mailbox Mailbox name
*
* @return array User-rights array on success, NULL on error
* @since 0.5-beta
*/
public function getACL($mailbox)
{
list($code, $response) = $this->execute('GETACL', array($this->escape($mailbox)), 0, '/^\* ACL /i');
if ($code == self::ERROR_OK && $response) {
// Parse server response (remove "* ACL ")
$response = substr($response, 6);
$ret = $this->tokenizeResponse($response);
$mbox = array_shift($ret);
$size = count($ret);
// Create user-rights hash array
// @TODO: consider implementing fixACL() method according to RFC4314.2.1.1
// so we could return only standard rights defined in RFC4314,
// excluding 'c' and 'd' defined in RFC2086.
if ($size % 2 == 0) {
for ($i=0; $i<$size; $i++) {
$ret[$ret[$i]] = str_split($ret[++$i]);
unset($ret[$i-1]);
unset($ret[$i]);
}
return $ret;
}
$this->setError(self::ERROR_COMMAND, "Incomplete ACL response");
}
}
/**
* Send the LISTRIGHTS command (RFC4314)
*
* @param string $mailbox Mailbox name
* @param string $user User name
*
* @return array List of user rights
* @since 0.5-beta
*/
public function listRights($mailbox, $user)
{
list($code, $response) = $this->execute('LISTRIGHTS',
array($this->escape($mailbox), $this->escape($user)), 0, '/^\* LISTRIGHTS /i');
if ($code == self::ERROR_OK && $response) {
// Parse server response (remove "* LISTRIGHTS ")
$response = substr($response, 13);
$ret_mbox = $this->tokenizeResponse($response, 1);
$ret_user = $this->tokenizeResponse($response, 1);
$granted = $this->tokenizeResponse($response, 1);
$optional = trim($response);
return array(
'granted' => str_split($granted),
'optional' => explode(' ', $optional),
);
}
}
/**
* Send the MYRIGHTS command (RFC4314)
*
* @param string $mailbox Mailbox name
*
* @return array MYRIGHTS response on success, NULL on error
* @since 0.5-beta
*/
public function myRights($mailbox)
{
list($code, $response) = $this->execute('MYRIGHTS', array($this->escape($mailbox)), 0, '/^\* MYRIGHTS /i');
if ($code == self::ERROR_OK && $response) {
// Parse server response (remove "* MYRIGHTS ")
$response = substr($response, 11);
$ret_mbox = $this->tokenizeResponse($response, 1);
$rights = $this->tokenizeResponse($response, 1);
return str_split($rights);
}
}
/**
* Send the SETMETADATA command (RFC5464)
*
* @param string $mailbox Mailbox name
* @param array $entries Entry-value array (use NULL value as NIL)
*
* @return boolean True on success, False on failure
* @since 0.5-beta
*/
public function setMetadata($mailbox, $entries)
{
if (!is_array($entries) || empty($entries)) {
$this->setError(self::ERROR_COMMAND, "Wrong argument for SETMETADATA command");
return false;
}
foreach ($entries as $name => $value) {
$entries[$name] = $this->escape($name) . ' ' . $this->escape($value, true);
}
$entries = implode(' ', $entries);
$result = $this->execute('SETMETADATA', array(
$this->escape($mailbox), '(' . $entries . ')'),
self::COMMAND_NORESPONSE);
return ($result == self::ERROR_OK);
}
/**
* Send the SETMETADATA command with NIL values (RFC5464)
*
* @param string $mailbox Mailbox name
* @param array $entries Entry names array
*
* @return boolean True on success, False on failure
*
* @since 0.5-beta
*/
public function deleteMetadata($mailbox, $entries)
{
if (!is_array($entries) && !empty($entries)) {
$entries = explode(' ', $entries);
}
if (empty($entries)) {
$this->setError(self::ERROR_COMMAND, "Wrong argument for SETMETADATA command");
return false;
}
foreach ($entries as $entry) {
$data[$entry] = null;
}
return $this->setMetadata($mailbox, $data);
}
/**
* Send the GETMETADATA command (RFC5464)
*
* @param string $mailbox Mailbox name
* @param array $entries Entries
* @param array $options Command options (with MAXSIZE and DEPTH keys)
*
* @return array GETMETADATA result on success, NULL on error
*
* @since 0.5-beta
*/
public function getMetadata($mailbox, $entries, $options=array())
{
if (!is_array($entries)) {
$entries = array($entries);
}
// create entries string
foreach ($entries as $idx => $name) {
$entries[$idx] = $this->escape($name);
}
$optlist = '';
$entlist = '(' . implode(' ', $entries) . ')';
// create options string
if (is_array($options)) {
$options = array_change_key_case($options, CASE_UPPER);
$opts = array();
if (!empty($options['MAXSIZE'])) {
$opts[] = 'MAXSIZE '.intval($options['MAXSIZE']);
}
if (!empty($options['DEPTH'])) {
$opts[] = 'DEPTH '.intval($options['DEPTH']);
}
if ($opts) {
$optlist = '(' . implode(' ', $opts) . ')';
}
}
$optlist .= ($optlist ? ' ' : '') . $entlist;
list($code, $response) = $this->execute('GETMETADATA', array(
$this->escape($mailbox), $optlist));
if ($code == self::ERROR_OK) {
$result = array();
$data = $this->tokenizeResponse($response);
// The METADATA response can contain multiple entries in a single
// response or multiple responses for each entry or group of entries
for ($i = 0, $size = count($data); $i < $size; $i++) {
if ($data[$i] === '*'
&& $data[++$i] === 'METADATA'
&& is_string($mbox = $data[++$i])
&& is_array($data[++$i])
) {
for ($x = 0, $size2 = count($data[$i]); $x < $size2; $x += 2) {
if ($data[$i][$x+1] !== null) {
$result[$mbox][$data[$i][$x]] = $data[$i][$x+1];
}
}
}
}
return $result;
}
}
/**
* Send the SETANNOTATION command (draft-daboo-imap-annotatemore)
*
* @param string $mailbox Mailbox name
* @param array $data Data array where each item is an array with
* three elements: entry name, attribute name, value
*
* @return boolean True on success, False on failure
* @since 0.5-beta
*/
public function setAnnotation($mailbox, $data)
{
if (!is_array($data) || empty($data)) {
$this->setError(self::ERROR_COMMAND, "Wrong argument for SETANNOTATION command");
return false;
}
foreach ($data as $entry) {
// ANNOTATEMORE drafts before version 08 require quoted parameters
$entries[] = sprintf('%s (%s %s)', $this->escape($entry[0], true),
$this->escape($entry[1], true), $this->escape($entry[2], true));
}
$entries = implode(' ', $entries);
$result = $this->execute('SETANNOTATION', array(
$this->escape($mailbox), $entries), self::COMMAND_NORESPONSE);
return ($result == self::ERROR_OK);
}
/**
* Send the SETANNOTATION command with NIL values (draft-daboo-imap-annotatemore)
*
* @param string $mailbox Mailbox name
* @param array $data Data array where each item is an array with
* two elements: entry name and attribute name
*
* @return boolean True on success, False on failure
*
* @since 0.5-beta
*/
public function deleteAnnotation($mailbox, $data)
{
if (!is_array($data) || empty($data)) {
$this->setError(self::ERROR_COMMAND, "Wrong argument for SETANNOTATION command");
return false;
}
return $this->setAnnotation($mailbox, $data);
}
/**
* Send the GETANNOTATION command (draft-daboo-imap-annotatemore)
*
* @param string $mailbox Mailbox name
* @param array $entries Entries names
* @param array $attribs Attribs names
*
* @return array Annotations result on success, NULL on error
*
* @since 0.5-beta
*/
public function getAnnotation($mailbox, $entries, $attribs)
{
if (!is_array($entries)) {
$entries = array($entries);
}
// create entries string
// ANNOTATEMORE drafts before version 08 require quoted parameters
foreach ($entries as $idx => $name) {
$entries[$idx] = $this->escape($name, true);
}
$entries = '(' . implode(' ', $entries) . ')';
if (!is_array($attribs)) {
$attribs = array($attribs);
}
// create attributes string
foreach ($attribs as $idx => $name) {
$attribs[$idx] = $this->escape($name, true);
}
$attribs = '(' . implode(' ', $attribs) . ')';
list($code, $response) = $this->execute('GETANNOTATION', array(
$this->escape($mailbox), $entries, $attribs));
if ($code == self::ERROR_OK) {
$result = array();
$data = $this->tokenizeResponse($response);
// Here we returns only data compatible with METADATA result format
if (!empty($data) && ($size = count($data))) {
for ($i=0; $i<$size; $i++) {
$entry = $data[$i];
if (isset($mbox) && is_array($entry)) {
$attribs = $entry;
$entry = $last_entry;
}
else if ($entry == '*') {
if ($data[$i+1] == 'ANNOTATION') {
$mbox = $data[$i+2];
unset($data[$i]); // "*"
unset($data[++$i]); // "ANNOTATION"
unset($data[++$i]); // Mailbox
}
// get rid of other untagged responses
else {
unset($mbox);
unset($data[$i]);
}
continue;
}
else if (isset($mbox)) {
$attribs = $data[++$i];
}
else {
unset($data[$i]);
continue;
}
if (!empty($attribs)) {
for ($x=0, $len=count($attribs); $x<$len;) {
$attr = $attribs[$x++];
$value = $attribs[$x++];
if ($attr == 'value.priv' && $value !== null) {
$result[$mbox]['/private' . $entry] = $value;
}
else if ($attr == 'value.shared' && $value !== null) {
$result[$mbox]['/shared' . $entry] = $value;
}
}
}
$last_entry = $entry;
unset($data[$i]);
}
}
return $result;
}
}
/**
* Returns BODYSTRUCTURE for the specified message.
*
* @param string $mailbox Folder name
* @param int $id Message sequence number or UID
* @param bool $is_uid True if $id is an UID
*
* @return array/bool Body structure array or False on error.
* @since 0.6
*/
public function getStructure($mailbox, $id, $is_uid = false)
{
$result = $this->fetch($mailbox, $id, $is_uid, array('BODYSTRUCTURE'));
if (is_array($result)) {
$result = array_shift($result);
return $result->bodystructure;
}
return false;
}
/**
* Returns data of a message part according to specified structure.
*
* @param array $structure Message structure (getStructure() result)
* @param string $part Message part identifier
*
* @return array Part data as hash array (type, encoding, charset, size)
*/
public static function getStructurePartData($structure, $part)
{
$part_a = self::getStructurePartArray($structure, $part);
$data = array();
if (empty($part_a)) {
return $data;
}
// content-type
if (is_array($part_a[0])) {
$data['type'] = 'multipart';
}
else {
$data['type'] = strtolower($part_a[0]);
$data['subtype'] = strtolower($part_a[1]);
$data['encoding'] = strtolower($part_a[5]);
// charset
if (is_array($part_a[2])) {
foreach ($part_a[2] as $key => $val) {
if (strcasecmp($val, 'charset') == 0) {
$data['charset'] = $part_a[2][$key+1];
break;
}
}
}
}
// size
$data['size'] = intval($part_a[6]);
return $data;
}
public static function getStructurePartArray($a, $part)
{
if (!is_array($a)) {
return false;
}
if (empty($part)) {
return $a;
}
$ctype = is_string($a[0]) && is_string($a[1]) ? $a[0] . '/' . $a[1] : '';
if (strcasecmp($ctype, 'message/rfc822') == 0) {
$a = $a[8];
}
if (strpos($part, '.') > 0) {
$orig_part = $part;
$pos = strpos($part, '.');
$rest = substr($orig_part, $pos+1);
$part = substr($orig_part, 0, $pos);
return self::getStructurePartArray($a[$part-1], $rest);
}
else if ($part > 0) {
return (is_array($a[$part-1])) ? $a[$part-1] : $a;
}
}
/**
* Creates next command identifier (tag)
*
* @return string Command identifier
* @since 0.5-beta
*/
public function nextTag()
{
$this->cmd_num++;
$this->cmd_tag = sprintf('A%04d', $this->cmd_num);
return $this->cmd_tag;
}
/**
* Sends IMAP command and parses result
*
* @param string $command IMAP command
* @param array $arguments Command arguments
* @param int $options Execution options
* @param string $filter Line filter (regexp)
*
* @return mixed Response code or list of response code and data
* @since 0.5-beta
*/
public function execute($command, $arguments = array(), $options = 0, $filter = null)
{
$tag = $this->nextTag();
$query = $tag . ' ' . $command;
$noresp = ($options & self::COMMAND_NORESPONSE);
$response = $noresp ? null : '';
if (!empty($arguments)) {
foreach ($arguments as $arg) {
$query .= ' ' . self::r_implode($arg);
}
}
// Send command
if (!$this->putLineC($query, true, ($options & self::COMMAND_ANONYMIZED))) {
preg_match('/^[A-Z0-9]+ ((UID )?[A-Z]+)/', $query, $matches);
$cmd = $matches[1] ?: 'UNKNOWN';
$this->setError(self::ERROR_COMMAND, "Failed to send $cmd command");
return $noresp ? self::ERROR_COMMAND : array(self::ERROR_COMMAND, '');
}
// Parse response
do {
$line = $this->readLine(4096);
if ($response !== null) {
// TODO: Better string literals handling with filter
if (!$filter || preg_match($filter, $line)) {
$response .= $line;
}
}
// parse untagged response for [COPYUID 1204196876 3456:3457 123:124] (RFC6851)
if ($line && $command == 'UID MOVE') {
if (preg_match("/^\* OK \[COPYUID [0-9]+ ([0-9,:]+) ([0-9,:]+)\]/i", $line, $m)) {
$this->data['COPYUID'] = array($m[1], $m[2]);
}
}
}
while (!$this->startsWith($line, $tag . ' ', true, true));
$code = $this->parseResult($line, $command . ': ');
// Remove last line from response
if ($response) {
if (!$filter) {
$line_len = min(strlen($response), strlen($line));
$response = substr($response, 0, -$line_len);
}
$response = rtrim($response, "\r\n");
}
// optional CAPABILITY response
if (($options & self::COMMAND_CAPABILITY) && $code == self::ERROR_OK
&& preg_match('/\[CAPABILITY ([^]]+)\]/i', $line, $matches)
) {
$this->parseCapability($matches[1], true);
}
// return last line only (without command tag, result and response code)
if ($line && ($options & self::COMMAND_LASTLINE)) {
$response = preg_replace("/^$tag (OK|NO|BAD|BYE|PREAUTH)?\s*(\[[a-z-]+\])?\s*/i", '', trim($line));
}
return $noresp ? $code : array($code, $response);
}
/**
* Splits IMAP response into string tokens
*
* @param string &$str The IMAP's server response
* @param int $num Number of tokens to return
*
* @return mixed Tokens array or string if $num=1
* @since 0.5-beta
*/
public static function tokenizeResponse(&$str, $num=0)
{
$result = array();
while (!$num || count($result) < $num) {
// remove spaces from the beginning of the string
$str = ltrim($str);
// empty string
if ($str === '' || $str === null) {
break;
}
switch ($str[0]) {
// String literal
case '{':
if (($epos = strpos($str, "}\r\n", 1)) == false) {
// error
}
if (!is_numeric(($bytes = substr($str, 1, $epos - 1)))) {
// error
}
$result[] = $bytes ? substr($str, $epos + 3, $bytes) : '';
$str = substr($str, $epos + 3 + $bytes);
break;
// Quoted string
case '"':
$len = strlen($str);
for ($pos=1; $pos<$len; $pos++) {
if ($str[$pos] == '"') {
break;
}
if ($str[$pos] == "\\") {
if ($str[$pos + 1] == '"' || $str[$pos + 1] == "\\") {
$pos++;
}
}
}
// we need to strip slashes for a quoted string
$result[] = stripslashes(substr($str, 1, $pos - 1));
$str = substr($str, $pos + 1);
break;
// Parenthesized list
case '(':
$str = substr($str, 1);
$result[] = self::tokenizeResponse($str);
break;
case ')':
$str = substr($str, 1);
return $result;
// String atom, number, astring, NIL, *, %
default:
// excluded chars: SP, CTL, ), DEL
// we do not exclude [ and ] (#1489223)
if (preg_match('/^([^\x00-\x20\x29\x7F]+)/', $str, $m)) {
$result[] = $m[1] == 'NIL' ? null : $m[1];
$str = substr($str, strlen($m[1]));
}
break;
}
}
return $num == 1 ? $result[0] : $result;
}
/**
* Joins IMAP command line elements (recursively)
*/
protected static function r_implode($element)
{
$string = '';
if (is_array($element)) {
reset($element);
foreach ($element as $value) {
$string .= ' ' . self::r_implode($value);
}
}
else {
return $element;
}
return '(' . trim($string) . ')';
}
/**
* Converts message identifiers array into sequence-set syntax
*
* @param array $messages Message identifiers
* @param bool $force Forces compression of any size
*
* @return string Compressed sequence-set
*/
public static function compressMessageSet($messages, $force=false)
{
// given a comma delimited list of independent mid's,
// compresses by grouping sequences together
if (!is_array($messages)) {
// if less than 255 bytes long, let's not bother
if (!$force && strlen($messages) < 255) {
return preg_match('/[^0-9:,*]/', $messages) ? 'INVALID' : $messages;
}
// see if it's already been compressed
if (strpos($messages, ':') !== false) {
return preg_match('/[^0-9:,*]/', $messages) ? 'INVALID' : $messages;
}
// separate, then sort
$messages = explode(',', $messages);
}
sort($messages);
$result = array();
$start = $prev = $messages[0];
foreach ($messages as $id) {
$incr = $id - $prev;
if ($incr > 1) { // found a gap
if ($start == $prev) {
$result[] = $prev; // push single id
}
else {
$result[] = $start . ':' . $prev; // push sequence as start_id:end_id
}
$start = $id; // start of new sequence
}
$prev = $id;
}
// handle the last sequence/id
if ($start == $prev) {
$result[] = $prev;
}
else {
$result[] = $start.':'.$prev;
}
// return as comma separated string
$result = implode(',', $result);
return preg_match('/[^0-9:,*]/', $result) ? 'INVALID' : $result;
}
/**
* Converts message sequence-set into array
*
* @param string $messages Message identifiers
*
* @return array List of message identifiers
*/
public static function uncompressMessageSet($messages)
{
if (empty($messages)) {
return array();
}
$result = array();
$messages = explode(',', $messages);
foreach ($messages as $idx => $part) {
$items = explode(':', $part);
$max = max($items[0], $items[1]);
for ($x=$items[0]; $x<=$max; $x++) {
$result[] = (int)$x;
}
unset($messages[$idx]);
}
return $result;
}
/**
* Clear internal status cache
*/
protected function clear_status_cache($mailbox)
{
unset($this->data['STATUS:' . $mailbox]);
$keys = array('EXISTS', 'RECENT', 'UNSEEN', 'UID-MAP');
foreach ($keys as $key) {
unset($this->data[$key]);
}
}
/**
* Clear internal cache of the current mailbox
*/
protected function clear_mailbox_cache()
{
$this->clear_status_cache($this->selected);
$keys = array('UIDNEXT', 'UIDVALIDITY', 'HIGHESTMODSEQ', 'NOMODSEQ',
'PERMANENTFLAGS', 'QRESYNC', 'VANISHED', 'READ-WRITE');
foreach ($keys as $key) {
unset($this->data[$key]);
}
}
/**
* Converts flags array into string for inclusion in IMAP command
*
* @param array $flags Flags (see self::flags)
*
* @return string Space-separated list of flags
*/
protected function flagsToStr($flags)
{
foreach ((array)$flags as $idx => $flag) {
if ($flag = $this->flags[strtoupper($flag)]) {
$flags[$idx] = $flag;
}
}
return implode(' ', (array)$flags);
}
/**
* CAPABILITY response parser
*/
protected function parseCapability($str, $trusted=false)
{
$str = preg_replace('/^\* CAPABILITY /i', '', $str);
$this->capability = explode(' ', strtoupper($str));
if (!empty($this->prefs['disabled_caps'])) {
$this->capability = array_diff($this->capability, $this->prefs['disabled_caps']);
}
if (!isset($this->prefs['literal+']) && in_array('LITERAL+', $this->capability)) {
$this->prefs['literal+'] = true;
}
if ($trusted) {
$this->capability_readed = true;
}
}
/**
* Escapes a string when it contains special characters (RFC3501)
*
* @param string $string IMAP string
* @param boolean $force_quotes Forces string quoting (for atoms)
*
* @return string String atom, quoted-string or string literal
* @todo lists
*/
public static function escape($string, $force_quotes=false)
{
if ($string === null) {
return 'NIL';
}
if ($string === '') {
return '""';
}
// atom-string (only safe characters)
if (!$force_quotes && !preg_match('/[\x00-\x20\x22\x25\x28-\x2A\x5B-\x5D\x7B\x7D\x80-\xFF]/', $string)) {
return $string;
}
// quoted-string
if (!preg_match('/[\r\n\x00\x80-\xFF]/', $string)) {
return '"' . addcslashes($string, '\\"') . '"';
}
// literal-string
return sprintf("{%d}\r\n%s", strlen($string), $string);
}
/**
* Set the value of the debugging flag.
*
* @param boolean $debug New value for the debugging flag.
* @param callback $handler Logging handler function
*
* @since 0.5-stable
*/
public function setDebug($debug, $handler = null)
{
$this->debug = $debug;
$this->debug_handler = $handler;
}
/**
* Write the given debug text to the current debug output handler.
*
* @param string $message Debug message text.
*
* @since 0.5-stable
*/
protected function debug($message)
{
if (($len = strlen($message)) > self::DEBUG_LINE_LENGTH) {
$diff = $len - self::DEBUG_LINE_LENGTH;
$message = substr($message, 0, self::DEBUG_LINE_LENGTH)
. "... [truncated $diff bytes]";
}
if ($this->resourceid) {
$message = sprintf('[%s] %s', $this->resourceid, $message);
}
if ($this->debug_handler) {
call_user_func_array($this->debug_handler, array($this, $message));
}
else {
echo "DEBUG: $message\n";
}
}
}
diff --git a/src/tests/Browser/StatusTest.php b/src/tests/Browser/StatusTest.php
index d49e1bbd..5897dc92 100644
--- a/src/tests/Browser/StatusTest.php
+++ b/src/tests/Browser/StatusTest.php
@@ -1,289 +1,287 @@
<?php
namespace Tests\Browser;
use App\Domain;
use App\User;
use Carbon\Carbon;
use Tests\Browser;
use Tests\Browser\Components\Status;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\DomainInfo;
use Tests\Browser\Pages\DomainList;
use Tests\Browser\Pages\Home;
use Tests\Browser\Pages\UserInfo;
use Tests\Browser\Pages\UserList;
use Tests\TestCaseDusk;
use Illuminate\Support\Facades\DB;
class StatusTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$domain_status = Domain::STATUS_CONFIRMED | Domain::STATUS_VERIFIED;
DB::statement("UPDATE domains SET status = (status | {$domain_status})"
. " WHERE namespace = 'kolab.org'");
DB::statement("UPDATE users SET status = (status | " . User::STATUS_IMAP_READY . ")"
. " WHERE email = 'john@kolab.org'");
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$domain_status = Domain::STATUS_CONFIRMED | Domain::STATUS_VERIFIED;
DB::statement("UPDATE domains SET status = (status | {$domain_status})"
. " WHERE namespace = 'kolab.org'");
DB::statement("UPDATE users SET status = (status | " . User::STATUS_IMAP_READY . ")"
. " WHERE email = 'john@kolab.org'");
parent::tearDown();
}
/**
* Test account status in the Dashboard
*/
public function testDashboard(): void
{
// Unconfirmed domain and user
$domain = Domain::where('namespace', 'kolab.org')->first();
if ($domain->isConfirmed()) {
$domain->status ^= Domain::STATUS_CONFIRMED;
$domain->save();
}
$john = $this->getTestUser('john@kolab.org');
$john->created_at = Carbon::now();
if ($john->isImapReady()) {
$john->status ^= User::STATUS_IMAP_READY;
}
$john->save();
$this->browse(function ($browser) use ($john, $domain) {
$browser->visit(new Home())
->submitLogon('john@kolab.org', 'simple123', true)
->on(new Dashboard())
->with(new Status(), function ($browser) use ($john) {
$browser->assertSeeIn('@body', 'We are preparing your account')
->assertProgress(71, 'Creating a mailbox...', 'pending')
->assertMissing('#status-verify')
->assertMissing('#status-link')
->assertMissing('@refresh-button')
->assertMissing('@refresh-text');
$john->status |= User::STATUS_IMAP_READY;
$john->save();
// Wait for auto-refresh, expect domain-confirmed step
$browser->pause(6000)
->assertSeeIn('@body', 'Your account is almost ready')
->assertProgress(85, 'Verifying an ownership of a custom domain...', 'failed')
->assertMissing('@refresh-button')
->assertMissing('@refresh-text')
->assertMissing('#status-verify')
->assertVisible('#status-link');
})
// check if the link to domain info page works
->click('#status-link')
->on(new DomainInfo())
->back()
->on(new Dashboard())
->with(new Status(), function ($browser) {
$browser->assertMissing('@refresh-button')
->assertProgress(85, 'Verifying an ownership of a custom domain...', 'failed');
});
// Confirm the domain and wait until the whole status box disappears
$domain->status |= Domain::STATUS_CONFIRMED;
$domain->save();
// This should take less than 10 seconds
$browser->waitUntilMissing('@status', 10);
});
- // Test the Refresh button
+ // Test the Refresh button
if ($domain->isConfirmed()) {
$domain->status ^= Domain::STATUS_CONFIRMED;
$domain->save();
}
$john->created_at = Carbon::now()->subSeconds(3600);
if ($john->isImapReady()) {
$john->status ^= User::STATUS_IMAP_READY;
}
$john->save();
$this->browse(function ($browser) use ($john, $domain) {
$browser->visit(new Dashboard())
->with(new Status(), function ($browser) use ($john, $domain) {
$browser->assertSeeIn('@body', 'We are preparing your account')
->assertProgress(71, 'Creating a mailbox...', 'failed')
->assertVisible('@refresh-button')
->assertVisible('@refresh-text');
- if ($john->refresh()->isImapReady()) {
- $john->status ^= User::STATUS_IMAP_READY;
- $john->save();
- }
+ $browser->click('@refresh-button')
+ ->assertToast(Toast::TYPE_SUCCESS, 'Setup process has been pushed. Please wait.');
+
+ $john->status |= User::STATUS_IMAP_READY;
+ $john->save();
$domain->status |= Domain::STATUS_CONFIRMED;
$domain->save();
-
- $browser->click('@refresh-button')
- ->assertToast(Toast::TYPE_SUCCESS, 'Setup process finished successfully.');
})
- ->assertMissing('@status');
+ ->waitUntilMissing('@status', 10);
});
}
/**
* Test domain status on domains list and domain info page
*
* @depends testDashboard
*/
public function testDomainStatus(): void
{
$domain = Domain::where('namespace', 'kolab.org')->first();
$domain->created_at = Carbon::now();
$domain->status = Domain::STATUS_NEW | Domain::STATUS_ACTIVE | Domain::STATUS_LDAP_READY;
$domain->save();
// side-step
$this->assertFalse($domain->isNew());
$this->assertTrue($domain->isActive());
$this->assertTrue($domain->isLdapReady());
$this->assertTrue($domain->isExternal());
$this->assertFalse($domain->isHosted());
$this->assertFalse($domain->isConfirmed());
$this->assertFalse($domain->isVerified());
$this->assertFalse($domain->isSuspended());
$this->assertFalse($domain->isDeleted());
$this->browse(function ($browser) use ($domain) {
// Test auto-refresh
$browser->on(new Dashboard())
->click('@links a.link-domains')
->on(new DomainList())
->waitFor('@table tbody tr')
// Assert domain status icon
->assertVisible('@table tbody tr:first-child td:first-child svg.fa-globe.text-danger')
->assertText('@table tbody tr:first-child td:first-child svg title', 'Not Ready')
->click('@table tbody tr:first-child td:first-child a')
->on(new DomainInfo())
->with(new Status(), function ($browser) {
$browser->assertSeeIn('@body', 'We are preparing the domain')
->assertProgress(50, 'Verifying a custom domain...', 'pending')
->assertMissing('@refresh-button')
->assertMissing('@refresh-text')
->assertMissing('#status-link')
->assertMissing('#status-verify');
});
$domain->status |= Domain::STATUS_VERIFIED;
$domain->save();
// This should take less than 10 seconds
$browser->waitFor('@status.process-failed')
->with(new Status(), function ($browser) {
$browser->assertSeeIn('@body', 'The domain is almost ready')
->assertProgress(75, 'Verifying an ownership of a custom domain...', 'failed')
->assertMissing('@refresh-button')
->assertMissing('@refresh-text')
->assertMissing('#status-link')
->assertVisible('#status-verify');
});
$domain->status |= Domain::STATUS_CONFIRMED;
$domain->save();
// Test Verify button
$browser->click('@status #status-verify')
->assertToast(Toast::TYPE_SUCCESS, 'Domain verified successfully.')
->waitUntilMissing('@status')
->waitUntilMissing('@verify')
->assertVisible('@config');
});
}
/**
* Test user status on users list and user info page
*
* @depends testDashboard
*/
public function testUserStatus(): void
{
$john = $this->getTestUser('john@kolab.org');
$john->created_at = Carbon::now();
if ($john->isImapReady()) {
$john->status ^= User::STATUS_IMAP_READY;
}
$john->save();
$domain = Domain::where('namespace', 'kolab.org')->first();
if ($domain->isConfirmed()) {
$domain->status ^= Domain::STATUS_CONFIRMED;
$domain->save();
}
$this->browse(function ($browser) use ($john, $domain) {
$browser->visit(new Dashboard())
->click('@links a.link-users')
->on(new UserList())
->waitFor('@table tbody tr')
// Assert user status icons
->assertVisible('@table tbody tr:first-child td:first-child svg.fa-user.text-success')
->assertText('@table tbody tr:first-child td:first-child svg title', 'Active')
->assertVisible('@table tbody tr:nth-child(3) td:first-child svg.fa-user.text-danger')
->assertText('@table tbody tr:nth-child(3) td:first-child svg title', 'Not Ready')
->click('@table tbody tr:nth-child(3) td:first-child a')
->on(new UserInfo())
->with('@form', function (Browser $browser) {
// Assert state in the user edit form
$browser->assertSeeIn('div.row:nth-child(1) label', 'Status')
->assertSeeIn('div.row:nth-child(1) #status', 'Not Ready');
})
->with(new Status(), function ($browser) use ($john) {
$browser->assertSeeIn('@body', 'We are preparing the user account')
->assertProgress(71, 'Creating a mailbox...', 'pending')
->assertMissing('#status-verify')
->assertMissing('#status-link')
->assertMissing('@refresh-button')
->assertMissing('@refresh-text');
$john->status |= User::STATUS_IMAP_READY;
$john->save();
// Wait for auto-refresh, expect domain-confirmed step
$browser->pause(6000)
->assertSeeIn('@body', 'The user account is almost ready')
->assertProgress(85, 'Verifying an ownership of a custom domain...', 'failed')
->assertMissing('@refresh-button')
->assertMissing('@refresh-text')
->assertMissing('#status-verify')
->assertVisible('#status-link');
})
->assertSeeIn('#status', 'Active');
// Confirm the domain and wait until the whole status box disappears
$domain->status |= Domain::STATUS_CONFIRMED;
$domain->save();
// This should take less than 10 seconds
$browser->waitUntilMissing('@status', 10);
});
}
}
diff --git a/src/tests/Feature/Backends/IMAPTest.php b/src/tests/Feature/Backends/IMAPTest.php
index c28241de..c82caf4b 100644
--- a/src/tests/Feature/Backends/IMAPTest.php
+++ b/src/tests/Feature/Backends/IMAPTest.php
@@ -1,41 +1,277 @@
<?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));
+
+ $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
- $this->expectException(\Exception::class);
- IMAP::verifyAccount('non-existing@domain.tld');
+ $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/Backends/RoundcubeTest.php b/src/tests/Feature/Backends/RoundcubeTest.php
index 2741176c..c53fe241 100644
--- a/src/tests/Feature/Backends/RoundcubeTest.php
+++ b/src/tests/Feature/Backends/RoundcubeTest.php
@@ -1,61 +1,67 @@
<?php
namespace Tests\Feature\Backends;
use App\Backends\Roundcube;
use Tests\TestCase;
class RoundcubeTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('roundcube@' . \config('app.domain'));
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('roundcube@' . \config('app.domain'));
parent::tearDown();
}
/**
* Test creating a Roundcube user record (and related data)
*
* @group roundcube
*/
- public function testUserCreation(): void
+ public function testUserCreationAndDeletion(): void
{
$user = $this->getTestUser('roundcube@' . \config('app.domain'));
$user->setSetting('first_name', 'First');
$user->setSetting('last_name', 'Last');
$db = Roundcube::dbh();
// delete the user record if exists
if ($userid = Roundcube::userId($user->email, false)) {
$db->table('users')->delete();
}
// Create the user
$userid = Roundcube::userId($user->email);
$rcuser = $db->table('users')->where('username', $user->email)->first();
$this->assertTrue(!empty($rcuser));
$rcidentity = $db->table('identities')->where('user_id', $rcuser->user_id)->first();
$this->assertSame($user->email, $rcidentity->email);
$this->assertSame('First Last', $rcidentity->name);
$this->assertSame(1, $rcidentity->standard);
+
+ // Delete the user
+ Roundcube::deleteUser($user->email);
+
+ $this->assertNull($db->table('users')->where('username', $user->email)->first());
+ $this->assertNull($db->table('identities')->where('user_id', $rcuser->user_id)->first());
}
}
diff --git a/src/tests/Feature/Console/User/StatusTest.php b/src/tests/Feature/Console/User/StatusTest.php
index 20735cfa..092323ac 100644
--- a/src/tests/Feature/Console/User/StatusTest.php
+++ b/src/tests/Feature/Console/User/StatusTest.php
@@ -1,61 +1,68 @@
<?php
namespace Tests\Feature\Console\User;
+use App\User;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class StatusTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('user@force-delete.com');
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('user@force-delete.com');
parent::tearDown();
}
/**
* Test command runs
*/
public function testHandle(): void
{
Queue::fake();
// Non-existing user
$code = \Artisan::call("user:status unknown");
$output = trim(\Artisan::output());
$this->assertSame(1, $code);
$this->assertSame("User not found.", $output);
+ $user = $this->getTestUser(
+ 'user@force-delete.com',
+ ['status' => User::STATUS_NEW | User::STATUS_ACTIVE | User::STATUS_IMAP_READY | User::STATUS_LDAP_READY]
+ );
+
// Existing user
- $code = \Artisan::call("user:status john@kolab.org");
+ $code = \Artisan::call("user:status {$user->email}");
$output = trim(\Artisan::output());
$this->assertSame(0, $code);
$this->assertSame("Status (51): active (2), ldapReady (16), imapReady (32)", $output);
- $user = $this->getTestUser('user@force-delete.com');
+ $user->status = User::STATUS_ACTIVE;
+ $user->save();
$user->delete();
// Deleted user
$code = \Artisan::call("user:status {$user->email}");
$output = trim(\Artisan::output());
$this->assertSame(0, $code);
- $this->assertSame("Status (3): active (2), deleted (8)", $output);
+ $this->assertSame("Status (2): active (2), deleted (8)", $output);
}
}
diff --git a/src/tests/Feature/Controller/AuthTest.php b/src/tests/Feature/Controller/AuthTest.php
index 4740d9e2..1d8d1ea9 100644
--- a/src/tests/Feature/Controller/AuthTest.php
+++ b/src/tests/Feature/Controller/AuthTest.php
@@ -1,330 +1,330 @@
<?php
namespace Tests\Feature\Controller;
use App\Domain;
use App\User;
use Tests\TestCase;
class AuthTest extends TestCase
{
private $expectedExpiry;
/**
* Reset all authentication guards to clear any cache users
*/
protected function resetAuth()
{
$guards = array_keys(config('auth.guards'));
foreach ($guards as $guard) {
$guard = $this->app['auth']->guard($guard);
if ($guard instanceof \Illuminate\Auth\SessionGuard) {
$guard->logout();
}
}
$protectedProperty = new \ReflectionProperty($this->app['auth'], 'guards');
$protectedProperty->setAccessible(true);
$protectedProperty->setValue($this->app['auth'], []);
}
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('UsersControllerTest1@userscontroller.com');
$this->deleteTestDomain('userscontroller.com');
$this->expectedExpiry = \config('auth.token_expiry_minutes') * 60;
\App\IP4Net::where('net_number', inet_pton('127.0.0.0'))->delete();
$user = $this->getTestUser('john@kolab.org');
$user->setSetting('limit_geo', null);
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('UsersControllerTest1@userscontroller.com');
$this->deleteTestDomain('userscontroller.com');
\App\IP4Net::where('net_number', inet_pton('127.0.0.0'))->delete();
$user = $this->getTestUser('john@kolab.org');
$user->setSetting('limit_geo', null);
parent::tearDown();
}
/**
* Test fetching current user info (/api/auth/info)
*/
public function testInfo(): void
{
- $user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
+ $user = $this->getTestUser('UsersControllerTest1@userscontroller.com', ['status' => User::STATUS_NEW]);
$domain = $this->getTestDomain('userscontroller.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_PUBLIC,
]);
$response = $this->get("api/auth/info");
$response->assertStatus(401);
$response = $this->actingAs($user)->get("api/auth/info");
$response->assertStatus(200);
$json = $response->json();
$this->assertEquals($user->id, $json['id']);
$this->assertEquals($user->email, $json['email']);
- $this->assertEquals(User::STATUS_NEW | User::STATUS_ACTIVE, $json['status']);
+ $this->assertEquals(User::STATUS_NEW, $json['status']);
$this->assertTrue(is_array($json['statusInfo']));
$this->assertTrue(is_array($json['settings']));
$this->assertTrue(!isset($json['access_token']));
// Note: Details of the content are tested in testUserResponse()
// Test token refresh via the info request
// First we log in to get the refresh token
$post = ['email' => 'john@kolab.org', 'password' => 'simple123'];
$user = $this->getTestUser('john@kolab.org');
$response = $this->post("api/auth/login", $post);
$json = $response->json();
$response = $this->actingAs($user)
->post("api/auth/info?refresh=1", ['refresh_token' => $json['refresh_token']]);
$response->assertStatus(200);
$json = $response->json();
$this->assertEquals('john@kolab.org', $json['email']);
$this->assertTrue(is_array($json['statusInfo']));
$this->assertTrue(is_array($json['settings']));
$this->assertTrue(!empty($json['access_token']));
$this->assertTrue(!empty($json['expires_in']));
}
/**
* Test fetching current user location (/api/auth/location)
*/
public function testLocation(): void
{
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
// Authentication required
$response = $this->get("api/auth/location");
$response->assertStatus(401);
$headers = ['X-Client-IP' => '127.0.0.2'];
$response = $this->actingAs($user)->withHeaders($headers)->get("api/auth/location");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('127.0.0.2', $json['ipAddress']);
$this->assertSame('', $json['countryCode']);
\App\IP4Net::create([
'net_number' => '127.0.0.0',
'net_broadcast' => '127.255.255.255',
'net_mask' => 8,
'country' => 'US',
'rir_name' => 'test',
'serial' => 1,
]);
$response = $this->actingAs($user)->withHeaders($headers)->get("api/auth/location");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('127.0.0.2', $json['ipAddress']);
$this->assertSame('US', $json['countryCode']);
}
/**
* Test /api/auth/login
*/
public function testLogin(): string
{
$user = $this->getTestUser('john@kolab.org');
// Request with no data
$response = $this->post("api/auth/login", []);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json['errors']);
$this->assertArrayHasKey('email', $json['errors']);
$this->assertArrayHasKey('password', $json['errors']);
// Request with invalid password
$post = ['email' => 'john@kolab.org', 'password' => 'wrong'];
$response = $this->post("api/auth/login", $post);
$response->assertStatus(401);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertSame('Invalid username or password.', $json['message']);
// Valid user+password
$post = ['email' => 'john@kolab.org', 'password' => 'simple123'];
$response = $this->post("api/auth/login", $post);
$json = $response->json();
$response->assertStatus(200);
$this->assertTrue(!empty($json['access_token']));
$this->assertTrue(
($this->expectedExpiry - 5) < $json['expires_in'] &&
$json['expires_in'] < ($this->expectedExpiry + 5)
);
$this->assertEquals('bearer', $json['token_type']);
$this->assertEquals($user->id, $json['id']);
$this->assertEquals($user->email, $json['email']);
$this->assertTrue(is_array($json['statusInfo']));
$this->assertTrue(is_array($json['settings']));
// Valid user+password (upper-case)
$post = ['email' => 'John@Kolab.org', 'password' => 'simple123'];
$response = $this->post("api/auth/login", $post);
$json = $response->json();
$response->assertStatus(200);
$this->assertTrue(!empty($json['access_token']));
$this->assertTrue(
($this->expectedExpiry - 5) < $json['expires_in'] &&
$json['expires_in'] < ($this->expectedExpiry + 5)
);
$this->assertEquals('bearer', $json['token_type']);
// TODO: We have browser tests for 2FA but we should probably also test it here
return $json['access_token'];
}
/**
* Test /api/auth/login with geo-lockin
*/
public function testLoginGeoLock(): void
{
$user = $this->getTestUser('john@kolab.org');
$user->setConfig(['limit_geo' => ['US']]);
$headers['X-Client-IP'] = '127.0.0.2';
$post = ['email' => 'john@kolab.org', 'password' => 'simple123'];
$response = $this->withHeaders($headers)->post("api/auth/login", $post);
$response->assertStatus(401);
$json = $response->json();
$this->assertSame("Invalid username or password.", $json['message']);
$this->assertSame('error', $json['status']);
\App\IP4Net::create([
'net_number' => '127.0.0.0',
'net_broadcast' => '127.255.255.255',
'net_mask' => 8,
'country' => 'US',
'rir_name' => 'test',
'serial' => 1,
]);
$response = $this->withHeaders($headers)->post("api/auth/login", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertTrue(!empty($json['access_token']));
$this->assertEquals($user->id, $json['id']);
}
/**
* Test /api/auth/logout
*
* @depends testLogin
*/
public function testLogout($token): void
{
// Request with no token, testing that it requires auth
$response = $this->post("api/auth/logout");
$response->assertStatus(401);
// Test the same using JSON mode
$response = $this->json('POST', "api/auth/logout", []);
$response->assertStatus(401);
// Request with invalid token
$response = $this->withHeaders(['Authorization' => 'Bearer ' . "foobar"])->post("api/auth/logout");
$response->assertStatus(401);
// Request with valid token
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->post("api/auth/logout");
$response->assertStatus(200);
$json = $response->json();
$this->assertEquals('success', $json['status']);
$this->assertEquals('Successfully logged out.', $json['message']);
$this->resetAuth();
// Check if it really destroyed the token?
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->get("api/auth/info");
$response->assertStatus(401);
}
/**
* Test /api/auth/refresh
*/
public function testRefresh(): void
{
// Request with no token, testing that it requires auth
$response = $this->post("api/auth/refresh");
$response->assertStatus(401);
// Test the same using JSON mode
$response = $this->json('POST', "api/auth/refresh", []);
$response->assertStatus(401);
// Login the user to get a valid token
$post = ['email' => 'john@kolab.org', 'password' => 'simple123'];
$response = $this->post("api/auth/login", $post);
$response->assertStatus(200);
$json = $response->json();
$token = $json['access_token'];
$user = $this->getTestUser('john@kolab.org');
// Request with a valid token
$response = $this->actingAs($user)->post("api/auth/refresh", ['refresh_token' => $json['refresh_token']]);
$response->assertStatus(200);
$json = $response->json();
$this->assertTrue(!empty($json['access_token']));
$this->assertTrue($json['access_token'] != $token);
$this->assertTrue(
($this->expectedExpiry - 5) < $json['expires_in'] &&
$json['expires_in'] < ($this->expectedExpiry + 5)
);
$this->assertEquals('bearer', $json['token_type']);
$new_token = $json['access_token'];
// TODO: Shall we invalidate the old token?
// And if the new token is working
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $new_token])->get("api/auth/info");
$response->assertStatus(200);
}
}
diff --git a/src/tests/Feature/Controller/ResourcesTest.php b/src/tests/Feature/Controller/ResourcesTest.php
index 7dce425f..d6290980 100644
--- a/src/tests/Feature/Controller/ResourcesTest.php
+++ b/src/tests/Feature/Controller/ResourcesTest.php
@@ -1,531 +1,560 @@
<?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]);
// 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);
$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']);
- $this->assertCount(7, $json['process']);
+ if (\config('app.with_imap')) {
+ $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 and get the resource status
+ // 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->assertTrue($json['isLdapReady']);
+ $this->assertFalse($json['isLdapReady']);
$this->assertTrue($json['isImapReady']);
- $this->assertTrue($json['isReady']);
- $this->assertCount(7, $json['process']);
+ $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(true, $json['process'][1]['state']);
- $this->assertSame('resource-imap-ready', $json['process'][2]['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 finished successfully.', $json['message']);
- $this->assertSame('done', $json['processState']);
+ $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->assertTrue($json['isLdapReady']);
- $this->assertTrue($json['isReady']);
- $this->assertCount(7, $json['process']);
+ $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(true, $json['process'][1]['state']);
+ $this->assertSame(false, $json['process'][1]['state']);
$this->assertSame('success', $json['status']);
- $this->assertSame('Setup process finished successfully.', $json['message']);
+ $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']);
- $this->assertCount(7, $result['process']);
+ 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']);
- $this->assertCount(7, $result['process']);
+ 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 ddd4f9b0..4fa4e99f 100644
--- a/src/tests/Feature/Controller/SharedFoldersTest.php
+++ b/src/tests/Feature/Controller/SharedFoldersTest.php
@@ -1,617 +1,646 @@
<?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]);
// 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);
$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']);
- $this->assertCount(7, $json['process']);
+ if (\config('app.with_imap')) {
+ $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 and get the folder status
+ // 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->assertTrue($json['isLdapReady']);
+ $this->assertFalse($json['isLdapReady']);
$this->assertTrue($json['isImapReady']);
- $this->assertTrue($json['isReady']);
- $this->assertCount(7, $json['process']);
+ $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(true, $json['process'][1]['state']);
- $this->assertSame('shared-folder-imap-ready', $json['process'][2]['label']);
- $this->assertSame(true, $json['process'][2]['state']);
+ $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 finished successfully.', $json['message']);
- $this->assertSame('done', $json['processState']);
+ $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->assertTrue($json['isLdapReady']);
- $this->assertTrue($json['isReady']);
- $this->assertCount(7, $json['process']);
+ $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(true, $json['process'][1]['state']);
+ $this->assertSame(false, $json['process'][1]['state']);
$this->assertSame('success', $json['status']);
- $this->assertSame('Setup process finished successfully.', $json['message']);
+ $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']);
- $this->assertCount(7, $result['process']);
+ 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']);
- $this->assertCount(7, $result['process']);
+ 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 4dfe9ea6..5c02132f 100644
--- a/src/tests/Feature/Controller/UsersTest.php
+++ b/src/tests/Feature/Controller/UsersTest.php
@@ -1,1638 +1,1656 @@
<?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('isLdapReady', $json['list'][0]);
$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('isLdapReady', $json);
$this->assertArrayHasKey('isImapReady', $json);
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
// Test unauthorized access to a profile of other user
$response = $this->actingAs($jack)->get("/api/v4/users/{$userA->id}");
$response->assertStatus(403);
// Test authorized access to a profile of other user
// Ned: Additional account controller
$response = $this->actingAs($ned)->get("/api/v4/users/{$john->id}");
$response->assertStatus(200);
$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);
if ($john->isImapReady()) {
$john->status ^= User::STATUS_IMAP_READY;
$john->save();
}
// Get user status
$response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status");
$response->assertStatus(200);
$json = $response->json();
$this->assertFalse($json['isImapReady']);
$this->assertFalse($json['isReady']);
- $this->assertCount(7, $json['process']);
- $this->assertSame('user-imap-ready', $json['process'][2]['label']);
- $this->assertSame(false, $json['process'][2]['state']);
+ if (\config('app.with_imap')) {
+ $this->assertCount(6, $json['process']);
+ $this->assertSame('user-imap-ready', $json['process'][2]['label']);
+ $this->assertSame(false, $json['process'][2]['state']);
+ } else {
+ $this->assertCount(7, $json['process']);
+ }
$this->assertTrue(empty($json['status']));
$this->assertTrue(empty($json['message']));
// Make sure the domain is confirmed (other test might unset that status)
$domain = $this->getTestDomain('kolab.org');
$domain->status |= Domain::STATUS_CONFIRMED;
$domain->save();
- // Now "reboot" the process and verify the user in imap synchronously
- $response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status?refresh=1");
- $response->assertStatus(200);
-
- $json = $response->json();
-
- $this->assertTrue($json['isImapReady']);
- $this->assertTrue($json['isReady']);
- $this->assertCount(7, $json['process']);
- $this->assertSame('user-imap-ready', $json['process'][2]['label']);
- $this->assertSame(true, $json['process'][2]['state']);
- $this->assertSame('success', $json['status']);
- $this->assertSame('Setup process finished successfully.', $json['message']);
-
- Queue::size(1);
-
- // Test case for when the verify job is dispatched to the worker
- $john->refresh();
- $john->status ^= User::STATUS_IMAP_READY;
- $john->save();
-
- \config(['imap.admin_password' => null]);
-
+ // 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['isReady']);
+ if (\config('app.with_imap')) {
+ $this->assertCount(7, $json['process']);
+ $this->assertSame('user-imap-ready', $json['process'][2]['label']);
+ $this->assertSame(false, $json['process'][2]['state']);
+ } else {
+ $this->assertCount(6, $json['process']);
+ }
$this->assertSame('success', $json['status']);
- $this->assertSame('waiting', $json['processState']);
$this->assertSame('Setup process has been pushed. Please wait.', $json['message']);
- Queue::assertPushed(\App\Jobs\User\VerifyJob::class, 1);
+ 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']);
- $this->assertCount(3, $result['process']);
+ 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']);
- $this->assertSame('user-imap-ready', $result['process'][2]['label']);
- $this->assertSame(false, $result['process'][2]['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']);
- $this->assertCount(3, $result['process']);
+ 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']);
- $this->assertSame('user-imap-ready', $result['process'][2]['label']);
- $this->assertSame(true, $result['process'][2]['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']);
- $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']);
+
+ 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/EntitlementTest.php b/src/tests/Feature/EntitlementTest.php
index 3a918829..1fa07e1f 100644
--- a/src/tests/Feature/EntitlementTest.php
+++ b/src/tests/Feature/EntitlementTest.php
@@ -1,203 +1,245 @@
<?php
namespace Tests\Feature;
use App\Domain;
use App\Entitlement;
use App\Package;
use App\Sku;
use App\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class EntitlementTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('entitlement-test@kolabnow.com');
$this->deleteTestUser('entitled-user@custom-domain.com');
$this->deleteTestGroup('test-group@custom-domain.com');
$this->deleteTestDomain('custom-domain.com');
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('entitlement-test@kolabnow.com');
$this->deleteTestUser('entitled-user@custom-domain.com');
$this->deleteTestGroup('test-group@custom-domain.com');
$this->deleteTestDomain('custom-domain.com');
parent::tearDown();
}
+ /**
+ * Tests for EntitlementObserver
+ */
+ public function testEntitlementObserver(): void
+ {
+ $skuStorage = Sku::withEnvTenantContext()->where('title', 'storage')->first();
+ $skuMailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first();
+ $user = $this->getTestUser('entitlement-test@kolabnow.com');
+ $wallet = $user->wallets->first();
+
+ // Test dispatching update jobs for the user, on quota update
+ Queue::fake();
+ $user->assignSku($skuMailbox, 1, $wallet);
+ Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 0);
+
+ Queue::fake();
+ $user->assignSku($skuStorage, 1, $wallet);
+ Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1);
+ Queue::assertPushed(
+ \App\Jobs\User\UpdateJob::class,
+ function ($job) use ($user) {
+ return $user->id === TestCase::getObjectProperty($job, 'userId');
+ }
+ );
+
+ Queue::fake();
+ $user->entitlements()->where('sku_id', $skuMailbox->id)->first()->delete();
+ Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 0);
+
+ Queue::fake();
+ $user->entitlements()->where('sku_id', $skuStorage->id)->first()->delete();
+ Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1);
+ Queue::assertPushed(
+ \App\Jobs\User\UpdateJob::class,
+ function ($job) use ($user) {
+ return $user->id === TestCase::getObjectProperty($job, 'userId');
+ }
+ );
+
+ // TODO: Test all events in the observer in more detail
+ }
+
/**
* Tests for entitlements
* @todo This really should be in User or Wallet tests file
*/
public function testEntitlements(): void
{
$packageDomain = Package::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$packageKolab = Package::withEnvTenantContext()->where('title', 'kolab')->first();
$skuDomain = Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$skuMailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first();
$owner = $this->getTestUser('entitlement-test@kolabnow.com');
$user = $this->getTestUser('entitled-user@custom-domain.com');
$domain = $this->getTestDomain(
'custom-domain.com',
[
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_EXTERNAL,
]
);
$domain->assignPackage($packageDomain, $owner);
$owner->assignPackage($packageKolab);
$owner->assignPackage($packageKolab, $user);
$wallet = $owner->wallets->first();
$this->assertCount(7, $owner->entitlements()->get());
$this->assertCount(1, $skuDomain->entitlements()->where('wallet_id', $wallet->id)->get());
$this->assertCount(2, $skuMailbox->entitlements()->where('wallet_id', $wallet->id)->get());
$this->assertCount(15, $wallet->entitlements);
$this->backdateEntitlements(
$owner->entitlements,
Carbon::now()->subMonthsWithoutOverflow(1)
);
$wallet->chargeEntitlements();
$this->assertTrue($wallet->fresh()->balance < 0);
}
/**
* @todo This really should be in User or Wallet tests file
*/
public function testBillDeletedEntitlement(): void
{
$user = $this->getTestUser('entitlement-test@kolabnow.com');
$package = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first();
$storage = \App\Sku::withEnvTenantContext()->where('title', 'storage')->first();
$user->assignPackage($package);
// some additional SKUs so we have something to delete.
$user->assignSku($storage, 4);
// the mailbox, the groupware, the 5 original storage and the additional 4
$this->assertCount(11, $user->fresh()->entitlements);
$wallet = $user->wallets()->first();
$backdate = Carbon::now()->subWeeks(7);
$this->backdateEntitlements($user->entitlements, $backdate);
$charge = $wallet->chargeEntitlements();
$this->assertSame(-1090, $wallet->balance);
$balance = $wallet->balance;
$discount = \App\Discount::withEnvTenantContext()->where('discount', 30)->first();
$wallet->discount()->associate($discount);
$wallet->save();
$user->removeSku($storage, 4);
// we expect the wallet to have been charged for ~3 weeks of use of
// 4 deleted storage entitlements, it should also take discount into account
$backdate->addMonthsWithoutOverflow(1);
$diffInDays = $backdate->diffInDays(Carbon::now());
// entitlements-num * cost * discount * days-in-month
$max = intval(4 * 25 * 0.7 * $diffInDays / 28);
$min = intval(4 * 25 * 0.7 * $diffInDays / 31);
$wallet->refresh();
$this->assertTrue($wallet->balance >= $balance - $max);
$this->assertTrue($wallet->balance <= $balance - $min);
$transactions = \App\Transaction::where('object_id', $wallet->id)
->where('object_type', \App\Wallet::class)->get();
// one round of the monthly invoicing, four sku deletions getting invoiced
$this->assertCount(5, $transactions);
// Test that deleting an entitlement on a degraded account costs nothing
$balance = $wallet->balance;
User::where('id', $user->id)->update(['status' => $user->status | User::STATUS_DEGRADED]);
$backdate = Carbon::now()->subWeeks(7);
$this->backdateEntitlements($user->entitlements()->get(), $backdate);
$groupware = \App\Sku::withEnvTenantContext()->where('title', 'groupware')->first();
$entitlement = $wallet->entitlements()->where('sku_id', $groupware->id)->first();
$entitlement->delete();
$this->assertSame($wallet->refresh()->balance, $balance);
}
/**
* Test EntitleableTrait::toString()
*/
public function testEntitleableTitle(): void
{
Queue::fake();
$packageDomain = Package::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$packageKolab = Package::withEnvTenantContext()->where('title', 'kolab')->first();
$user = $this->getTestUser('entitled-user@custom-domain.com');
$group = $this->getTestGroup('test-group@custom-domain.com');
$domain = $this->getTestDomain(
'custom-domain.com',
[
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_EXTERNAL,
]
);
$wallet = $user->wallets->first();
$domain->assignPackage($packageDomain, $user);
$user->assignPackage($packageKolab);
$group->assignToWallet($wallet);
$sku_mailbox = \App\Sku::withEnvTenantContext()->where('title', 'mailbox')->first();
$sku_group = \App\Sku::withEnvTenantContext()->where('title', 'group')->first();
$sku_domain = \App\Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$entitlement = Entitlement::where('wallet_id', $wallet->id)
->where('sku_id', $sku_mailbox->id)->first();
$this->assertSame($user->email, $entitlement->entitleable->toString());
$entitlement = Entitlement::where('wallet_id', $wallet->id)
->where('sku_id', $sku_group->id)->first();
$this->assertSame($group->email, $entitlement->entitleable->toString());
$entitlement = Entitlement::where('wallet_id', $wallet->id)
->where('sku_id', $sku_domain->id)->first();
$this->assertSame($domain->namespace, $entitlement->entitleable->toString());
// Make sure it still works if the entitleable is deleted
$domain->delete();
$entitlement->refresh();
$this->assertSame($domain->namespace, $entitlement->entitleable->toString());
$this->assertNotNull($entitlement->entitleable);
}
}
diff --git a/src/tests/Feature/GroupTest.php b/src/tests/Feature/GroupTest.php
index 044acc80..aba12a6d 100644
--- a/src/tests/Feature/GroupTest.php
+++ b/src/tests/Feature/GroupTest.php
@@ -1,398 +1,398 @@
<?php
namespace Tests\Feature;
use App\Group;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class GroupTest extends TestCase
{
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('user-test@kolabnow.com');
$this->deleteTestGroup('group-test@kolabnow.com');
}
public function tearDown(): void
{
$this->deleteTestUser('user-test@kolabnow.com');
$this->deleteTestGroup('group-test@kolabnow.com');
parent::tearDown();
}
/**
* Tests for Group::assignToWallet()
*/
public function testAssignToWallet(): void
{
$user = $this->getTestUser('user-test@kolabnow.com');
$group = $this->getTestGroup('group-test@kolabnow.com');
$result = $group->assignToWallet($user->wallets->first());
$this->assertSame($group, $result);
$this->assertSame(1, $group->entitlements()->count());
// Can't be done twice on the same group
$this->expectException(\Exception::class);
$result->assignToWallet($user->wallets->first());
}
/**
* Test Group::getConfig() and setConfig() methods
*/
public function testConfigTrait(): void
{
$group = $this->getTestGroup('group-test@kolabnow.com');
$group->setSetting('sender_policy', '["test","-"]');
$this->assertSame(['sender_policy' => ['test']], $group->getConfig());
$result = $group->setConfig(['sender_policy' => [], 'unknown' => false]);
$this->assertSame(['sender_policy' => []], $group->getConfig());
$this->assertSame('[]', $group->getSetting('sender_policy'));
$this->assertSame(['unknown' => "The requested configuration parameter is not supported."], $result);
$result = $group->setConfig(['sender_policy' => ['test']]);
$this->assertSame(['sender_policy' => ['test']], $group->getConfig());
$this->assertSame('["test","-"]', $group->getSetting('sender_policy'));
$this->assertSame([], $result);
}
/**
* Test creating a group
*/
public function testCreate(): void
{
Queue::fake();
$group = Group::create(['email' => 'GROUP-test@kolabnow.com']);
$this->assertSame('group-test@kolabnow.com', $group->email);
$this->assertSame('group-test', $group->name);
$this->assertMatchesRegularExpression('/^[0-9]{1,20}$/', $group->id);
$this->assertSame([], $group->members);
$this->assertTrue($group->isNew());
- $this->assertTrue($group->isActive());
+ $this->assertFalse($group->isActive());
Queue::assertPushed(
\App\Jobs\Group\CreateJob::class,
function ($job) use ($group) {
$groupEmail = TestCase::getObjectProperty($job, 'groupEmail');
$groupId = TestCase::getObjectProperty($job, 'groupId');
return $groupEmail === $group->email
&& $groupId === $group->id;
}
);
}
/**
* Test group deletion and force-deletion
*/
public function testDelete(): void
{
Queue::fake();
$user = $this->getTestUser('user-test@kolabnow.com');
$group = $this->getTestGroup('group-test@kolabnow.com');
$group->assignToWallet($user->wallets->first());
$entitlements = \App\Entitlement::where('entitleable_id', $group->id);
$this->assertSame(1, $entitlements->count());
$group->delete();
$this->assertTrue($group->fresh()->trashed());
$this->assertSame(0, $entitlements->count());
$this->assertSame(1, $entitlements->withTrashed()->count());
$group->forceDelete();
$this->assertSame(0, $entitlements->withTrashed()->count());
$this->assertCount(0, Group::withTrashed()->where('id', $group->id)->get());
Queue::assertPushed(\App\Jobs\Group\DeleteJob::class, 1);
Queue::assertPushed(
\App\Jobs\Group\DeleteJob::class,
function ($job) use ($group) {
$groupEmail = TestCase::getObjectProperty($job, 'groupEmail');
$groupId = TestCase::getObjectProperty($job, 'groupId');
return $groupEmail === $group->email
&& $groupId === $group->id;
}
);
}
/**
* Tests for Group::emailExists()
*/
public function testEmailExists(): void
{
Queue::fake();
$group = $this->getTestGroup('group-test@kolabnow.com');
$this->assertFalse(Group::emailExists('unknown@domain.tld'));
$this->assertTrue(Group::emailExists($group->email));
$result = Group::emailExists($group->email, true);
$this->assertSame($result->id, $group->id);
$group->delete();
$this->assertTrue(Group::emailExists($group->email));
$result = Group::emailExists($group->email, true);
$this->assertSame($result->id, $group->id);
}
/*
* Test group restoring
*/
public function testRestore(): void
{
Queue::fake();
$user = $this->getTestUser('user-test@kolabnow.com');
$group = $this->getTestGroup('group-test@kolabnow.com');
$group->assignToWallet($user->wallets->first());
$entitlements = \App\Entitlement::where('entitleable_id', $group->id);
$this->assertSame(1, $entitlements->count());
$group->delete();
$this->assertTrue($group->fresh()->trashed());
$this->assertSame(0, $entitlements->count());
$this->assertSame(1, $entitlements->withTrashed()->count());
Queue::fake();
$group->restore();
$group->refresh();
$this->assertFalse($group->trashed());
$this->assertFalse($group->isDeleted());
$this->assertFalse($group->isSuspended());
$this->assertFalse($group->isLdapReady());
$this->assertTrue($group->isActive());
$this->assertSame(1, $entitlements->count());
$entitlements->get()->each(function ($ent) {
$this->assertTrue($ent->updated_at->greaterThan(\Carbon\Carbon::now()->subSeconds(5)));
});
Queue::assertPushed(\App\Jobs\Group\CreateJob::class, 1);
Queue::assertPushed(
\App\Jobs\Group\CreateJob::class,
function ($job) use ($group) {
$groupEmail = TestCase::getObjectProperty($job, 'groupEmail');
$groupId = TestCase::getObjectProperty($job, 'groupId');
return $groupEmail === $group->email
&& $groupId === $group->id;
}
);
}
/**
* Tests for GroupSettingsTrait functionality and GroupSettingObserver
*/
public function testSettings(): void
{
Queue::fake();
Queue::assertNothingPushed();
$group = $this->getTestGroup('group-test@kolabnow.com');
Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 0);
// Add a setting
$group->setSetting('unknown', 'test');
Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 0);
// Add a setting that is synced to LDAP
$group->setSetting('sender_policy', '[]');
Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1);
// Note: We test both current group as well as fresh group object
// to make sure cache works as expected
$this->assertSame('test', $group->getSetting('unknown'));
$this->assertSame('[]', $group->fresh()->getSetting('sender_policy'));
Queue::fake();
// Update a setting
$group->setSetting('unknown', 'test1');
Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 0);
// Update a setting that is synced to LDAP
$group->setSetting('sender_policy', '["-"]');
Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1);
$this->assertSame('test1', $group->getSetting('unknown'));
$this->assertSame('["-"]', $group->fresh()->getSetting('sender_policy'));
Queue::fake();
// Delete a setting (null)
$group->setSetting('unknown', null);
Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 0);
// Delete a setting that is synced to LDAP
$group->setSetting('sender_policy', null);
Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1);
$this->assertSame(null, $group->getSetting('unknown'));
$this->assertSame(null, $group->fresh()->getSetting('sender_policy'));
}
/**
* Test group status assignment and is*() methods
*/
public function testStatus(): void
{
$group = new Group();
$this->assertSame(false, $group->isNew());
$this->assertSame(false, $group->isActive());
$this->assertSame(false, $group->isDeleted());
$this->assertSame(false, $group->isLdapReady());
$this->assertSame(false, $group->isSuspended());
$group->status = Group::STATUS_NEW;
$this->assertSame(true, $group->isNew());
$this->assertSame(false, $group->isActive());
$this->assertSame(false, $group->isDeleted());
$this->assertSame(false, $group->isLdapReady());
$this->assertSame(false, $group->isSuspended());
$group->status |= Group::STATUS_ACTIVE;
$this->assertSame(true, $group->isNew());
$this->assertSame(true, $group->isActive());
$this->assertSame(false, $group->isDeleted());
$this->assertSame(false, $group->isLdapReady());
$this->assertSame(false, $group->isSuspended());
$group->status |= Group::STATUS_LDAP_READY;
$this->assertSame(true, $group->isNew());
$this->assertSame(true, $group->isActive());
$this->assertSame(false, $group->isDeleted());
$this->assertSame(true, $group->isLdapReady());
$this->assertSame(false, $group->isSuspended());
$group->status |= Group::STATUS_DELETED;
$this->assertSame(true, $group->isNew());
$this->assertSame(true, $group->isActive());
$this->assertSame(true, $group->isDeleted());
$this->assertSame(true, $group->isLdapReady());
$this->assertSame(false, $group->isSuspended());
$group->status |= Group::STATUS_SUSPENDED;
$this->assertSame(true, $group->isNew());
$this->assertSame(true, $group->isActive());
$this->assertSame(true, $group->isDeleted());
$this->assertSame(true, $group->isLdapReady());
$this->assertSame(true, $group->isSuspended());
// Unknown status value
$this->expectException(\Exception::class);
$group->status = 111;
}
/**
* Tests for Group::suspend()
*/
public function testSuspend(): void
{
Queue::fake();
$group = $this->getTestGroup('group-test@kolabnow.com');
$group->suspend();
$this->assertTrue($group->isSuspended());
Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1);
Queue::assertPushed(
\App\Jobs\Group\UpdateJob::class,
function ($job) use ($group) {
$groupEmail = TestCase::getObjectProperty($job, 'groupEmail');
$groupId = TestCase::getObjectProperty($job, 'groupId');
return $groupEmail === $group->email
&& $groupId === $group->id;
}
);
}
/**
* Test updating a group
*/
public function testUpdate(): void
{
Queue::fake();
$group = $this->getTestGroup('group-test@kolabnow.com');
$group->status |= Group::STATUS_DELETED;
$group->save();
Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1);
Queue::assertPushed(
\App\Jobs\Group\UpdateJob::class,
function ($job) use ($group) {
$groupEmail = TestCase::getObjectProperty($job, 'groupEmail');
$groupId = TestCase::getObjectProperty($job, 'groupId');
return $groupEmail === $group->email
&& $groupId === $group->id;
}
);
}
/**
* Tests for Group::unsuspend()
*/
public function testUnsuspend(): void
{
Queue::fake();
$group = $this->getTestGroup('group-test@kolabnow.com');
$group->status = Group::STATUS_SUSPENDED;
$group->unsuspend();
$this->assertFalse($group->isSuspended());
Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1);
Queue::assertPushed(
\App\Jobs\Group\UpdateJob::class,
function ($job) use ($group) {
$groupEmail = TestCase::getObjectProperty($job, 'groupEmail');
$groupId = TestCase::getObjectProperty($job, 'groupId');
return $groupEmail === $group->email
&& $groupId === $group->id;
}
);
}
}
diff --git a/src/tests/Feature/Jobs/Group/DeleteTest.php b/src/tests/Feature/Jobs/Group/DeleteTest.php
index 1a716173..1e8af992 100644
--- a/src/tests/Feature/Jobs/Group/DeleteTest.php
+++ b/src/tests/Feature/Jobs/Group/DeleteTest.php
@@ -1,60 +1,73 @@
<?php
namespace Tests\Feature\Jobs\Group;
use App\Group;
+use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class DeleteTest 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
{
$group = $this->getTestGroup('group@kolab.org', [
'members' => [],
'status' => Group::STATUS_NEW
]);
// create to domain first
$job = new \App\Jobs\Group\CreateJob($group->id);
$job->handle();
$this->assertTrue($group->fresh()->isLdapReady());
+ Queue::fake();
+
$job = new \App\Jobs\Group\DeleteJob($group->id);
$job->handle();
$group->refresh();
$this->assertFalse($group->isLdapReady());
$this->assertTrue($group->isDeleted());
-
+/*
+ Queue::assertPushed(\App\Jobs\IMAP\AclCleanupJob::class, 1);
+ Queue::assertPushed(
+ \App\Jobs\IMAP\AclCleanupJob::class,
+ function ($job) {
+ $ident = TestCase::getObjectProperty($job, 'ident');
+ $domain = TestCase::getObjectProperty($job, 'domain');
+ return $ident == 'group' && $domain === 'kolab.org';
+ }
+ );
+*/
// Test non-existing group ID
$job = new \App\Jobs\Group\DeleteJob(123);
$job->handle();
$this->assertTrue($job->hasFailed());
$this->assertSame("Group 123 could not be found in the database.", $job->failureMessage);
}
}
diff --git a/src/tests/Feature/Jobs/Resource/CreateTest.php b/src/tests/Feature/Jobs/Resource/CreateTest.php
index b7ebfe9e..3da1cd8b 100644
--- a/src/tests/Feature/Jobs/Resource/CreateTest.php
+++ b/src/tests/Feature/Jobs/Resource/CreateTest.php
@@ -1,84 +1,89 @@
<?php
namespace Tests\Feature\Jobs\Resource;
use App\Resource;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class CreateTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestResource('resource-test@' . \config('app.domain'));
}
public function tearDown(): void
{
$this->deleteTestResource('resource-test@' . \config('app.domain'));
parent::tearDown();
}
/**
* Test job handle
*
* @group ldap
+ * @group imap
*/
public function testHandle(): void
{
Queue::fake();
// Test unknown resource
$this->expectException(\Exception::class);
$job = new \App\Jobs\Resource\CreateJob(123);
$job->handle();
$this->assertTrue($job->isReleased());
$this->assertFalse($job->hasFailed());
- $resource = $this->getTestResource('resource-test@' . \config('app.domain'));
+ $resource = $this->getTestResource(
+ 'resource-test@' . \config('app.domain'),
+ ['status' => Resource::STATUS_NEW]
+ );
$this->assertFalse($resource->isLdapReady());
+ $this->assertFalse($resource->isImapReady());
+ $this->assertFalse($resource->isActive());
// Test resource creation
$job = new \App\Jobs\Resource\CreateJob($resource->id);
$job->handle();
- $this->assertTrue($resource->fresh()->isLdapReady());
+ $resource->refresh();
+
$this->assertFalse($job->hasFailed());
+ $this->assertTrue($resource->isLdapReady());
+ $this->assertTrue($resource->isImapReady());
+ $this->assertTrue($resource->isActive());
// Test job failures
- $job = new \App\Jobs\Resource\CreateJob($resource->id);
- $job->handle();
-
- $this->assertTrue($job->hasFailed());
- $this->assertSame("Resource {$resource->id} is already marked as ldap-ready.", $job->failureMessage);
-
$resource->status |= Resource::STATUS_DELETED;
$resource->save();
$job = new \App\Jobs\Resource\CreateJob($resource->id);
$job->handle();
$this->assertTrue($job->hasFailed());
$this->assertSame("Resource {$resource->id} is marked as deleted.", $job->failureMessage);
$resource->status ^= Resource::STATUS_DELETED;
$resource->save();
$resource->delete();
$job = new \App\Jobs\Resource\CreateJob($resource->id);
$job->handle();
$this->assertTrue($job->hasFailed());
$this->assertSame("Resource {$resource->id} is actually deleted.", $job->failureMessage);
// TODO: Test failures on domain sanity checks
+ // TODO: Test partial execution, i.e. only IMAP or only LDAP
}
}
diff --git a/src/tests/Feature/Jobs/Resource/DeleteTest.php b/src/tests/Feature/Jobs/Resource/DeleteTest.php
index 6051af32..4f88a862 100644
--- a/src/tests/Feature/Jobs/Resource/DeleteTest.php
+++ b/src/tests/Feature/Jobs/Resource/DeleteTest.php
@@ -1,76 +1,78 @@
<?php
namespace Tests\Feature\Jobs\Resource;
use App\Resource;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class DeleteTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestResource('resource-test@' . \config('app.domain'));
}
public function tearDown(): void
{
$this->deleteTestResource('resource-test@' . \config('app.domain'));
parent::tearDown();
}
/**
* Test job handle
*
* @group ldap
+ * @group imap
*/
public function testHandle(): void
{
Queue::fake();
// Test non-existing resource ID
$job = new \App\Jobs\Resource\DeleteJob(123);
$job->handle();
$this->assertTrue($job->hasFailed());
$this->assertSame("Resource 123 could not be found in the database.", $job->failureMessage);
$resource = $this->getTestResource('resource-test@' . \config('app.domain'), [
'status' => Resource::STATUS_NEW
]);
// create the resource first
$job = new \App\Jobs\Resource\CreateJob($resource->id);
$job->handle();
$resource->refresh();
$this->assertTrue($resource->isLdapReady());
+ if (\config('app.with_imap')) {
+ $this->assertTrue($resource->isImapReady());
+ }
+ $this->assertFalse($resource->isDeleted());
// Test successful deletion
- $resource->status |= Resource::STATUS_IMAP_READY;
- $resource->save();
-
$job = new \App\Jobs\Resource\DeleteJob($resource->id);
$job->handle();
$resource->refresh();
$this->assertFalse($resource->isLdapReady());
$this->assertFalse($resource->isImapReady());
$this->assertTrue($resource->isDeleted());
// Test deleting already deleted resource
$job = new \App\Jobs\Resource\DeleteJob($resource->id);
$job->handle();
$this->assertTrue($job->hasFailed());
$this->assertSame("Resource {$resource->id} is already marked as deleted.", $job->failureMessage);
}
}
diff --git a/src/tests/Feature/Jobs/Resource/UpdateTest.php b/src/tests/Feature/Jobs/Resource/UpdateTest.php
index 8b509a86..a2b8a4fd 100644
--- a/src/tests/Feature/Jobs/Resource/UpdateTest.php
+++ b/src/tests/Feature/Jobs/Resource/UpdateTest.php
@@ -1,82 +1,80 @@
<?php
namespace Tests\Feature\Jobs\Resource;
use App\Backends\LDAP;
use App\Resource;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class UpdateTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestResource('resource-test@' . \config('app.domain'));
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestResource('resource-test@' . \config('app.domain'));
parent::tearDown();
}
/**
* Test job handle
*
* @group ldap
+ * @group imap
*/
public function testHandle(): void
{
Queue::fake();
// Test non-existing resource ID
$job = new \App\Jobs\Resource\UpdateJob(123);
$job->handle();
$this->assertTrue($job->hasFailed());
$this->assertSame("Resource 123 could not be found in the database.", $job->failureMessage);
- $resource = $this->getTestResource('resource-test@' . \config('app.domain'));
+ $resource = $this->getTestResource(
+ 'resource-test@' . \config('app.domain'),
+ ['status' => Resource::STATUS_NEW]
+ );
// Create the resource in LDAP
$job = new \App\Jobs\Resource\CreateJob($resource->id);
$job->handle();
+ // Run the update with some new config
$resource->setConfig(['invitation_policy' => 'accept']);
$job = new \App\Jobs\Resource\UpdateJob($resource->id);
$job->handle();
$ldap_resource = LDAP::getResource($resource->email);
$this->assertSame('ACT_ACCEPT', $ldap_resource['kolabinvitationpolicy']);
+ // TODO: Assert IMAP change worked
+
// Test that the job is being deleted if the resource is not ldap ready or is deleted
$resource->refresh();
- $resource->status = Resource::STATUS_NEW | Resource::STATUS_ACTIVE;
- $resource->save();
-
- $job = new \App\Jobs\Resource\UpdateJob($resource->id);
- $job->handle();
-
- $this->assertTrue($job->isDeleted());
-
- $resource->status = Resource::STATUS_NEW | Resource::STATUS_ACTIVE
- | Resource::STATUS_LDAP_READY | Resource::STATUS_DELETED;
+ $resource->status |= Resource::STATUS_DELETED;
$resource->save();
$job = new \App\Jobs\Resource\UpdateJob($resource->id);
$job->handle();
$this->assertTrue($job->isDeleted());
}
}
diff --git a/src/tests/Feature/Jobs/SharedFolder/CreateTest.php b/src/tests/Feature/Jobs/SharedFolder/CreateTest.php
index 389f5ab4..38bc1ed2 100644
--- a/src/tests/Feature/Jobs/SharedFolder/CreateTest.php
+++ b/src/tests/Feature/Jobs/SharedFolder/CreateTest.php
@@ -1,84 +1,89 @@
<?php
namespace Tests\Feature\Jobs\SharedFolder;
use App\SharedFolder;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class CreateTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestSharedFolder('folder-test@' . \config('app.domain'));
}
public function tearDown(): void
{
$this->deleteTestSharedFolder('folder-test@' . \config('app.domain'));
parent::tearDown();
}
/**
* Test job handle
*
* @group ldap
+ * @group imap
*/
public function testHandle(): void
{
Queue::fake();
// Test unknown folder
$this->expectException(\Exception::class);
$job = new \App\Jobs\SharedFolder\CreateJob(123);
$job->handle();
$this->assertTrue($job->isReleased());
$this->assertFalse($job->hasFailed());
- $folder = $this->getTestSharedFolder('folder-test@' . \config('app.domain'));
+ $folder = $this->getTestSharedFolder(
+ 'folder-test@' . \config('app.domain'),
+ ['status' => SharedFolder::STATUS_NEW]
+ );
$this->assertFalse($folder->isLdapReady());
+ $this->assertFalse($folder->isImapReady());
+ $this->assertFalse($folder->isActive());
// Test shared folder creation
$job = new \App\Jobs\SharedFolder\CreateJob($folder->id);
$job->handle();
- $this->assertTrue($folder->fresh()->isLdapReady());
+ $folder->refresh();
+
$this->assertFalse($job->hasFailed());
+ $this->assertTrue($folder->isLdapReady());
+ $this->assertTrue($folder->isImapReady());
+ $this->assertTrue($folder->isActive());
// Test job failures
- $job = new \App\Jobs\SharedFolder\CreateJob($folder->id);
- $job->handle();
-
- $this->assertTrue($job->hasFailed());
- $this->assertSame("Shared folder {$folder->id} is already marked as ldap-ready.", $job->failureMessage);
-
$folder->status |= SharedFolder::STATUS_DELETED;
$folder->save();
$job = new \App\Jobs\SharedFolder\CreateJob($folder->id);
$job->handle();
$this->assertTrue($job->hasFailed());
$this->assertSame("Shared folder {$folder->id} is marked as deleted.", $job->failureMessage);
$folder->status ^= SharedFolder::STATUS_DELETED;
$folder->save();
$folder->delete();
$job = new \App\Jobs\SharedFolder\CreateJob($folder->id);
$job->handle();
$this->assertTrue($job->hasFailed());
$this->assertSame("Shared folder {$folder->id} is actually deleted.", $job->failureMessage);
// TODO: Test failures on domain sanity checks
+ // TODO: Test partial execution, i.e. only IMAP or only LDAP
}
}
diff --git a/src/tests/Feature/Jobs/SharedFolder/DeleteTest.php b/src/tests/Feature/Jobs/SharedFolder/DeleteTest.php
index 66f5d0c4..0a87b39e 100644
--- a/src/tests/Feature/Jobs/SharedFolder/DeleteTest.php
+++ b/src/tests/Feature/Jobs/SharedFolder/DeleteTest.php
@@ -1,76 +1,80 @@
<?php
namespace Tests\Feature\Jobs\SharedFolder;
use App\SharedFolder;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class DeleteTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestSharedFolder('folder-test@' . \config('app.domain'));
}
public function tearDown(): void
{
$this->deleteTestSharedFolder('folder-test@' . \config('app.domain'));
parent::tearDown();
}
/**
* Test job handle
*
* @group ldap
+ * @group imap
*/
public function testHandle(): void
{
Queue::fake();
// Test non-existing folder ID
$job = new \App\Jobs\SharedFolder\DeleteJob(123);
$job->handle();
$this->assertTrue($job->hasFailed());
$this->assertSame("Shared folder 123 could not be found in the database.", $job->failureMessage);
$folder = $this->getTestSharedFolder('folder-test@' . \config('app.domain'), [
'status' => SharedFolder::STATUS_NEW
]);
// create the shared folder first
$job = new \App\Jobs\SharedFolder\CreateJob($folder->id);
$job->handle();
$folder->refresh();
$this->assertTrue($folder->isLdapReady());
+ if (\config('app.with_imap')) {
+ $this->assertTrue($folder->isImapReady());
+ }
+ $this->assertFalse($folder->isDeleted());
// Test successful deletion
- $folder->status |= SharedFolder::STATUS_IMAP_READY;
- $folder->save();
-
$job = new \App\Jobs\SharedFolder\DeleteJob($folder->id);
$job->handle();
$folder->refresh();
$this->assertFalse($folder->isLdapReady());
- $this->assertFalse($folder->isImapReady());
+ if (\config('app.with_imap')) {
+ $this->assertFalse($folder->isImapReady());
+ }
$this->assertTrue($folder->isDeleted());
// Test deleting already deleted folder
$job = new \App\Jobs\SharedFolder\DeleteJob($folder->id);
$job->handle();
$this->assertTrue($job->hasFailed());
$this->assertSame("Shared folder {$folder->id} is already marked as deleted.", $job->failureMessage);
}
}
diff --git a/src/tests/Feature/Jobs/SharedFolder/UpdateTest.php b/src/tests/Feature/Jobs/SharedFolder/UpdateTest.php
index 292726b5..1d4c8b97 100644
--- a/src/tests/Feature/Jobs/SharedFolder/UpdateTest.php
+++ b/src/tests/Feature/Jobs/SharedFolder/UpdateTest.php
@@ -1,78 +1,80 @@
<?php
namespace Tests\Feature\Jobs\SharedFolder;
use App\Backends\LDAP;
use App\SharedFolder;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class UpdateTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestSharedFolder('folder-test@' . \config('app.domain'));
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestSharedFolder('folder-test@' . \config('app.domain'));
parent::tearDown();
}
/**
* Test job handle
*
* @group ldap
+ * @group imap
*/
public function testHandle(): void
{
Queue::fake();
// Test non-existing folder ID
$job = new \App\Jobs\SharedFolder\UpdateJob(123);
$job->handle();
$this->assertTrue($job->hasFailed());
$this->assertSame("Shared folder 123 could not be found in the database.", $job->failureMessage);
- $folder = $this->getTestSharedFolder('folder-test@' . \config('app.domain'));
+ $folder = $this->getTestSharedFolder(
+ 'folder-test@' . \config('app.domain'),
+ ['status' => SharedFolder::STATUS_NEW]
+ );
// Create the folder in LDAP
$job = new \App\Jobs\SharedFolder\CreateJob($folder->id);
$job->handle();
- $job = new \App\Jobs\SharedFolder\UpdateJob($folder->id);
- $job->handle();
-
- $this->assertTrue(is_array(LDAP::getSharedFolder($folder->email)));
-
- // Test that the job is being deleted if the folder is not ldap ready or is deleted
$folder->refresh();
- $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE;
- $folder->save();
+ $this->assertTrue($folder->isLdapReady());
+ if (\config('app.with_imap')) {
+ $this->assertTrue($folder->isImapReady());
+ }
+
+ // Run the update job
$job = new \App\Jobs\SharedFolder\UpdateJob($folder->id);
$job->handle();
- $this->assertTrue($job->isDeleted());
+ // TODO: Assert that it worked on both LDAP and IMAP side
- $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE
- | SharedFolder::STATUS_LDAP_READY | SharedFolder::STATUS_DELETED;
+ // Test handling deleted folder
+ $folder->status |= SharedFolder::STATUS_DELETED;
$folder->save();
$job = new \App\Jobs\SharedFolder\UpdateJob($folder->id);
$job->handle();
$this->assertTrue($job->isDeleted());
}
}
diff --git a/src/tests/Feature/Jobs/User/CreateTest.php b/src/tests/Feature/Jobs/User/CreateTest.php
index 5dc8412c..1da8999c 100644
--- a/src/tests/Feature/Jobs/User/CreateTest.php
+++ b/src/tests/Feature/Jobs/User/CreateTest.php
@@ -1,79 +1,84 @@
<?php
namespace Tests\Feature\Jobs\User;
use App\User;
+use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class CreateTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('new-job-user@' . \config('app.domain'));
}
public function tearDown(): void
{
$this->deleteTestUser('new-job-user@' . \config('app.domain'));
parent::tearDown();
}
/**
* Test job handle
*
* @group ldap
+ * @group imap
*/
public function testHandle(): void
{
- $user = $this->getTestUser('new-job-user@' . \config('app.domain'));
+ Queue::fake();
+ $user = $this->getTestUser('new-job-user@' . \config('app.domain'), ['status' => User::STATUS_NEW]);
$this->assertFalse($user->isLdapReady());
+ $this->assertFalse($user->isImapReady());
+ $this->assertFalse($user->isActive());
$job = new \App\Jobs\User\CreateJob($user->id);
$job->handle();
- $this->assertTrue($user->fresh()->isLdapReady());
- $this->assertFalse($job->hasFailed());
+ $user->refresh();
- // Test job failures
- $job = new \App\Jobs\User\CreateJob($user->id);
- $job->handle();
-
- $this->assertTrue($job->hasFailed());
- $this->assertSame("User {$user->id} is already marked as ldap-ready.", $job->failureMessage);
+ $this->assertTrue($user->isLdapReady());
+ $this->assertTrue($user->isImapReady());
+ $this->assertTrue($user->isActive());
+ $this->assertFalse($job->hasFailed());
+ // Test job failure (user deleted)
$user->status |= User::STATUS_DELETED;
$user->save();
$job = new \App\Jobs\User\CreateJob($user->id);
$job->handle();
$this->assertTrue($job->hasFailed());
$this->assertSame("User {$user->id} is marked as deleted.", $job->failureMessage);
+ // Test job failure (user removed)
$user->status ^= User::STATUS_DELETED;
$user->save();
$user->delete();
$job = new \App\Jobs\User\CreateJob($user->id);
$job->handle();
$this->assertTrue($job->hasFailed());
$this->assertSame("User {$user->id} is actually deleted.", $job->failureMessage);
- // TODO: Test failures on domain sanity checks
-
- $this->expectException(\Exception::class);
+ // Test job failure (user unknown)
$job = new \App\Jobs\User\CreateJob(123);
$job->handle();
$this->assertTrue($job->isReleased());
$this->assertFalse($job->hasFailed());
+
+ // TODO: Test failures on domain sanity checks
+ // TODO: Test partial execution, i.e. only IMAP or only LDAP
}
}
diff --git a/src/tests/Feature/Jobs/User/DeleteTest.php b/src/tests/Feature/Jobs/User/DeleteTest.php
new file mode 100644
index 00000000..b4d2bcb8
--- /dev/null
+++ b/src/tests/Feature/Jobs/User/DeleteTest.php
@@ -0,0 +1,100 @@
+<?php
+
+namespace Tests\Feature\Jobs\User;
+
+use App\Backends\Roundcube;
+use App\User;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class DeleteTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestUser('new-job-user@' . \config('app.domain'));
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $this->deleteTestUser('new-job-user@' . \config('app.domain'));
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test job handle
+ *
+ * @group ldap
+ * @group imap
+ * @group roundcube
+ */
+ public function testHandle(): void
+ {
+ Queue::fake();
+
+ $rcdb = Roundcube::dbh();
+
+ $user = $this->getTestUser('new-job-user@' . \config('app.domain'));
+ $rcuser = Roundcube::userId($user->email);
+
+ // Create the user in LDAP+IMAP
+ $job = new \App\Jobs\User\CreateJob($user->id);
+ $job->handle();
+
+ $user->refresh();
+
+ $this->assertTrue($user->isLdapReady());
+ $this->assertTrue($user->isImapReady());
+ $this->assertFalse($user->isDeleted());
+ $this->assertNotNull($rcdb->table('users')->where('username', $user->email)->first());
+
+ // Test job failure (user already deleted)
+ $user->status |= User::STATUS_DELETED;
+ $user->save();
+
+ $job = new \App\Jobs\User\DeleteJob($user->id);
+ $job->handle();
+
+ $this->assertTrue($job->hasFailed());
+ $this->assertSame("User {$user->id} is already marked as deleted.", $job->failureMessage);
+
+ // Test success delete from LDAP, IMAP and Roundcube
+ $user->status ^= User::STATUS_DELETED;
+ $user->save();
+
+ $this->assertFalse($user->isDeleted());
+
+ $job = new \App\Jobs\User\DeleteJob($user->id);
+ $job->handle();
+
+ $user->refresh();
+
+ $this->assertFalse($job->hasFailed());
+ $this->assertFalse($user->isLdapReady());
+ $this->assertFalse($user->isImapReady());
+ $this->assertTrue($user->isDeleted());
+ $this->assertNull($rcdb->table('users')->where('username', $user->email)->first());
+
+ if (\config('app.with_imap')) {
+ Queue::assertPushed(\App\Jobs\IMAP\AclCleanupJob::class, 1);
+ Queue::assertPushed(
+ \App\Jobs\IMAP\AclCleanupJob::class,
+ function ($job) use ($user) {
+ $ident = TestCase::getObjectProperty($job, 'ident');
+ $domain = TestCase::getObjectProperty($job, 'domain');
+ return $ident == $user->email && $domain === '';
+ }
+ );
+ }
+
+ // TODO: Test partial execution, i.e. only IMAP or only LDAP
+ }
+}
diff --git a/src/tests/Feature/Jobs/User/UpdateTest.php b/src/tests/Feature/Jobs/User/UpdateTest.php
index 72776b67..0a5a7c45 100644
--- a/src/tests/Feature/Jobs/User/UpdateTest.php
+++ b/src/tests/Feature/Jobs/User/UpdateTest.php
@@ -1,94 +1,97 @@
<?php
namespace Tests\Feature\Jobs\User;
use App\Backends\LDAP;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class UpdateTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('new-job-user@' . \config('app.domain'));
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('new-job-user@' . \config('app.domain'));
parent::tearDown();
}
/**
* Test job handle
*
* @group ldap
+ * @group imap
*/
public function testHandle(): void
{
// Ignore any jobs created here (e.g. on setAliases() use)
Queue::fake();
$user = $this->getTestUser('new-job-user@' . \config('app.domain'));
// Create the user in LDAP
$job = new \App\Jobs\User\CreateJob($user->id);
$job->handle();
// Test setting two aliases
$aliases = [
'new-job-user1@' . \config('app.domain'),
'new-job-user2@' . \config('app.domain'),
];
$user->setAliases($aliases);
$job = new \App\Jobs\User\UpdateJob($user->id);
$job->handle();
$ldap_user = LDAP::getUser('new-job-user@' . \config('app.domain'));
$this->assertSame($aliases, $ldap_user['alias']);
// Test updating aliases list
$aliases = [
'new-job-user1@' . \config('app.domain'),
];
$user->setAliases($aliases);
$job = new \App\Jobs\User\UpdateJob($user->id);
$job->handle();
$ldap_user = LDAP::getUser('new-job-user@' . \config('app.domain'));
$this->assertSame($aliases, (array) $ldap_user['alias']);
// Test unsetting aliases list
$aliases = [];
$user->setAliases($aliases);
$job = new \App\Jobs\User\UpdateJob($user->id);
$job->handle();
$ldap_user = LDAP::getUser('new-job-user@' . \config('app.domain'));
$this->assertTrue(empty($ldap_user['alias']));
// Test non-existing user ID
$job = new \App\Jobs\User\UpdateJob(123);
$job->handle();
$this->assertTrue($job->hasFailed());
$this->assertSame("User 123 could not be found in the database.", $job->failureMessage);
+
+ // TODO: Test IMAP, e.g. quota change
}
}
diff --git a/src/tests/Feature/ResourceTest.php b/src/tests/Feature/ResourceTest.php
index b5e71ec3..343018d2 100644
--- a/src/tests/Feature/ResourceTest.php
+++ b/src/tests/Feature/ResourceTest.php
@@ -1,352 +1,366 @@
<?php
namespace Tests\Feature;
use App\Resource;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class ResourceTest extends TestCase
{
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('user-test@kolabnow.com');
Resource::withTrashed()->where('email', 'like', '%@kolabnow.com')->each(function ($resource) {
$this->deleteTestResource($resource->email);
});
}
public function tearDown(): void
{
$this->deleteTestUser('user-test@kolabnow.com');
Resource::withTrashed()->where('email', 'like', '%@kolabnow.com')->each(function ($resource) {
$this->deleteTestResource($resource->email);
});
parent::tearDown();
}
/**
* Tests for Resource::assignToWallet()
*/
public function testAssignToWallet(): void
{
$user = $this->getTestUser('user-test@kolabnow.com');
$resource = $this->getTestResource('resource-test@kolabnow.com');
$result = $resource->assignToWallet($user->wallets->first());
$this->assertSame($resource, $result);
$this->assertSame(1, $resource->entitlements()->count());
// Can't be done twice on the same resource
$this->expectException(\Exception::class);
$result->assignToWallet($user->wallets->first());
}
/**
* Test Resource::getConfig() and setConfig() methods
*/
public function testConfigTrait(): void
{
Queue::fake();
$resource = new Resource();
$resource->email = 'resource-test@kolabnow.com';
$resource->name = 'Test';
$resource->save();
$john = $this->getTestUser('john@kolab.org');
$resource->assignToWallet($john->wallets->first());
$this->assertSame(['invitation_policy' => 'accept'], $resource->getConfig());
$result = $resource->setConfig(['invitation_policy' => 'reject', 'unknown' => false]);
$this->assertSame(['invitation_policy' => 'reject'], $resource->getConfig());
$this->assertSame('reject', $resource->getSetting('invitation_policy'));
$this->assertSame(['unknown' => "The requested configuration parameter is not supported."], $result);
$result = $resource->setConfig(['invitation_policy' => 'unknown']);
$this->assertSame(['invitation_policy' => 'reject'], $resource->getConfig());
$this->assertSame('reject', $resource->getSetting('invitation_policy'));
$this->assertSame(['invitation_policy' => "The specified invitation policy is invalid."], $result);
// Test valid user for manual invitation policy
$result = $resource->setConfig(['invitation_policy' => 'manual:john@kolab.org']);
$this->assertSame(['invitation_policy' => 'manual:john@kolab.org'], $resource->getConfig());
$this->assertSame('manual:john@kolab.org', $resource->getSetting('invitation_policy'));
$this->assertSame([], $result);
// Test invalid user email for manual invitation policy
$result = $resource->setConfig(['invitation_policy' => 'manual:john']);
$this->assertSame(['invitation_policy' => 'manual:john@kolab.org'], $resource->getConfig());
$this->assertSame('manual:john@kolab.org', $resource->getSetting('invitation_policy'));
$this->assertSame(['invitation_policy' => "The specified email address is invalid."], $result);
// Test non-existing user for manual invitation policy
$result = $resource->setConfig(['invitation_policy' => 'manual:unknown@kolab.org']);
$this->assertSame(['invitation_policy' => "The specified email address does not exist."], $result);
// Test existing user from a different wallet, for manual invitation policy
$result = $resource->setConfig(['invitation_policy' => 'manual:user@sample-tenant.dev-local']);
$this->assertSame(['invitation_policy' => "The specified email address does not exist."], $result);
}
/**
* Test creating a resource
*/
public function testCreate(): void
{
Queue::fake();
$resource = new Resource();
$resource->name = 'Reśo';
$resource->domainName = 'kolabnow.com';
$resource->save();
$this->assertMatchesRegularExpression('/^[0-9]{1,20}$/', $resource->id);
$this->assertMatchesRegularExpression('/^resource-[0-9]{1,20}@kolabnow\.com$/', $resource->email);
$this->assertSame('Reśo', $resource->name);
$this->assertTrue($resource->isNew());
- $this->assertTrue($resource->isActive());
+ $this->assertFalse($resource->isActive());
$this->assertFalse($resource->isDeleted());
$this->assertFalse($resource->isLdapReady());
$this->assertFalse($resource->isImapReady());
$settings = $resource->settings()->get();
$this->assertCount(1, $settings);
$this->assertSame('folder', $settings[0]->key);
$this->assertSame('shared/Resources/Reśo@kolabnow.com', $settings[0]->value);
Queue::assertPushed(
\App\Jobs\Resource\CreateJob::class,
function ($job) use ($resource) {
$resourceEmail = TestCase::getObjectProperty($job, 'resourceEmail');
$resourceId = TestCase::getObjectProperty($job, 'resourceId');
return $resourceEmail === $resource->email
&& $resourceId === $resource->id;
}
);
-
- Queue::assertPushedWithChain(
- \App\Jobs\Resource\CreateJob::class,
- [
- \App\Jobs\Resource\VerifyJob::class,
- ]
- );
}
/**
* Test resource deletion and force-deletion
*/
public function testDelete(): void
{
Queue::fake();
$user = $this->getTestUser('user-test@kolabnow.com');
$resource = $this->getTestResource('resource-test@kolabnow.com');
$resource->assignToWallet($user->wallets->first());
$entitlements = \App\Entitlement::where('entitleable_id', $resource->id);
$this->assertSame(1, $entitlements->count());
$resource->delete();
$this->assertTrue($resource->fresh()->trashed());
$this->assertSame(0, $entitlements->count());
$this->assertSame(1, $entitlements->withTrashed()->count());
$resource->forceDelete();
$this->assertSame(0, $entitlements->withTrashed()->count());
$this->assertCount(0, Resource::withTrashed()->where('id', $resource->id)->get());
Queue::assertPushed(\App\Jobs\Resource\DeleteJob::class, 1);
Queue::assertPushed(
\App\Jobs\Resource\DeleteJob::class,
function ($job) use ($resource) {
$resourceEmail = TestCase::getObjectProperty($job, 'resourceEmail');
$resourceId = TestCase::getObjectProperty($job, 'resourceId');
return $resourceEmail === $resource->email
&& $resourceId === $resource->id;
}
);
}
/**
* Tests for Resource::emailExists()
*/
public function testEmailExists(): void
{
Queue::fake();
$resource = $this->getTestResource('resource-test@kolabnow.com');
$this->assertFalse(Resource::emailExists('unknown@domain.tld'));
$this->assertTrue(Resource::emailExists($resource->email));
$result = Resource::emailExists($resource->email, true);
$this->assertSame($result->id, $resource->id);
$resource->delete();
$this->assertTrue(Resource::emailExists($resource->email));
$result = Resource::emailExists($resource->email, true);
$this->assertSame($result->id, $resource->id);
}
/**
* Tests for SettingsTrait functionality and ResourceSettingObserver
*/
public function testSettings(): void
{
Queue::fake();
Queue::assertNothingPushed();
$resource = $this->getTestResource('resource-test@kolabnow.com');
Queue::assertPushed(\App\Jobs\Resource\UpdateJob::class, 0);
// Add a setting
$resource->setSetting('unknown', 'test');
Queue::assertPushed(\App\Jobs\Resource\UpdateJob::class, 0);
// Add a setting that is synced to LDAP
$resource->setSetting('invitation_policy', 'accept');
Queue::assertPushed(\App\Jobs\Resource\UpdateJob::class, 1);
+ Queue::assertPushed(
+ \App\Jobs\Resource\UpdateJob::class,
+ function ($job) use ($resource) {
+ return $resource->id === TestCase::getObjectProperty($job, 'resourceId')
+ && ['invitation_policy' => null] === TestCase::getObjectProperty($job, 'properties');
+ }
+ );
// Note: We test both current resource as well as fresh resource object
// to make sure cache works as expected
$this->assertSame('test', $resource->getSetting('unknown'));
$this->assertSame('accept', $resource->fresh()->getSetting('invitation_policy'));
Queue::fake();
// Update a setting
$resource->setSetting('unknown', 'test1');
Queue::assertPushed(\App\Jobs\Resource\UpdateJob::class, 0);
// Update a setting that is synced to LDAP
$resource->setSetting('invitation_policy', 'reject');
Queue::assertPushed(\App\Jobs\Resource\UpdateJob::class, 1);
+ Queue::assertPushed(
+ \App\Jobs\Resource\UpdateJob::class,
+ function ($job) use ($resource) {
+ return $resource->id === TestCase::getObjectProperty($job, 'resourceId')
+ && ['invitation_policy' => 'accept'] === TestCase::getObjectProperty($job, 'properties');
+ }
+ );
$this->assertSame('test1', $resource->getSetting('unknown'));
$this->assertSame('reject', $resource->fresh()->getSetting('invitation_policy'));
Queue::fake();
// Delete a setting (null)
$resource->setSetting('unknown', null);
Queue::assertPushed(\App\Jobs\Resource\UpdateJob::class, 0);
// Delete a setting that is synced to LDAP
$resource->setSetting('invitation_policy', null);
Queue::assertPushed(\App\Jobs\Resource\UpdateJob::class, 1);
+ Queue::assertPushed(
+ \App\Jobs\Resource\UpdateJob::class,
+ function ($job) use ($resource) {
+ return $resource->id === TestCase::getObjectProperty($job, 'resourceId')
+ && ['invitation_policy' => 'reject'] === TestCase::getObjectProperty($job, 'properties');
+ }
+ );
$this->assertSame(null, $resource->getSetting('unknown'));
$this->assertSame(null, $resource->fresh()->getSetting('invitation_policy'));
}
/**
* Test resource status assignment and is*() methods
*/
public function testStatus(): void
{
$resource = new Resource();
$this->assertSame(false, $resource->isNew());
$this->assertSame(false, $resource->isActive());
$this->assertSame(false, $resource->isDeleted());
$this->assertSame(false, $resource->isLdapReady());
$this->assertSame(false, $resource->isImapReady());
$resource->status = Resource::STATUS_NEW;
$this->assertSame(true, $resource->isNew());
$this->assertSame(false, $resource->isActive());
$this->assertSame(false, $resource->isDeleted());
$this->assertSame(false, $resource->isLdapReady());
$this->assertSame(false, $resource->isImapReady());
$resource->status |= Resource::STATUS_ACTIVE;
$this->assertSame(true, $resource->isNew());
$this->assertSame(true, $resource->isActive());
$this->assertSame(false, $resource->isDeleted());
$this->assertSame(false, $resource->isLdapReady());
$this->assertSame(false, $resource->isImapReady());
$resource->status |= Resource::STATUS_LDAP_READY;
$this->assertSame(true, $resource->isNew());
$this->assertSame(true, $resource->isActive());
$this->assertSame(false, $resource->isDeleted());
$this->assertSame(true, $resource->isLdapReady());
$this->assertSame(false, $resource->isImapReady());
$resource->status |= Resource::STATUS_DELETED;
$this->assertSame(true, $resource->isNew());
$this->assertSame(true, $resource->isActive());
$this->assertSame(true, $resource->isDeleted());
$this->assertSame(true, $resource->isLdapReady());
$this->assertSame(false, $resource->isImapReady());
$resource->status |= Resource::STATUS_IMAP_READY;
$this->assertSame(true, $resource->isNew());
$this->assertSame(true, $resource->isActive());
$this->assertSame(true, $resource->isDeleted());
$this->assertSame(true, $resource->isLdapReady());
$this->assertSame(true, $resource->isImapReady());
// Unknown status value
$this->expectException(\Exception::class);
$resource->status = 111;
}
/**
* Test updating a resource
*/
public function testUpdate(): void
{
Queue::fake();
$resource = $this->getTestResource('resource-test@kolabnow.com');
$resource->name = 'New';
$resource->save();
// Assert the folder changes on a resource name change
$settings = $resource->settings()->where('key', 'folder')->get();
$this->assertCount(1, $settings);
$this->assertSame('shared/Resources/New@kolabnow.com', $settings[0]->value);
Queue::assertPushed(\App\Jobs\Resource\UpdateJob::class, 1);
Queue::assertPushed(
\App\Jobs\Resource\UpdateJob::class,
function ($job) use ($resource) {
$resourceEmail = TestCase::getObjectProperty($job, 'resourceEmail');
$resourceId = TestCase::getObjectProperty($job, 'resourceId');
return $resourceEmail === $resource->email
&& $resourceId === $resource->id;
}
);
}
}
diff --git a/src/tests/Feature/SharedFolderTest.php b/src/tests/Feature/SharedFolderTest.php
index e520aff0..e5dde92a 100644
--- a/src/tests/Feature/SharedFolderTest.php
+++ b/src/tests/Feature/SharedFolderTest.php
@@ -1,358 +1,372 @@
<?php
namespace Tests\Feature;
use App\SharedFolder;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class SharedFolderTest extends TestCase
{
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('user-test@kolabnow.com');
SharedFolder::withTrashed()->where('email', 'like', '%@kolabnow.com')->each(function ($folder) {
$this->deleteTestSharedFolder($folder->email);
});
}
public function tearDown(): void
{
$this->deleteTestUser('user-test@kolabnow.com');
SharedFolder::withTrashed()->where('email', 'like', '%@kolabnow.com')->each(function ($folder) {
$this->deleteTestSharedFolder($folder->email);
});
parent::tearDown();
}
/**
* Tests for AliasesTrait methods
*/
public function testAliases(): void
{
Queue::fake();
Queue::assertNothingPushed();
$folder = $this->getTestSharedFolder('folder-test@kolabnow.com');
$this->assertCount(0, $folder->aliases->all());
// Add an alias
$folder->setAliases(['FolderAlias1@kolabnow.com']);
Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 1);
$aliases = $folder->aliases()->get();
$this->assertCount(1, $aliases);
$this->assertSame('folderalias1@kolabnow.com', $aliases[0]->alias);
$this->assertTrue(SharedFolder::aliasExists('folderalias1@kolabnow.com'));
// Add another alias
$folder->setAliases(['FolderAlias1@kolabnow.com', 'FolderAlias2@kolabnow.com']);
Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 2);
$aliases = $folder->aliases()->orderBy('alias')->get();
$this->assertCount(2, $aliases);
$this->assertSame('folderalias1@kolabnow.com', $aliases[0]->alias);
$this->assertSame('folderalias2@kolabnow.com', $aliases[1]->alias);
// Remove an alias
$folder->setAliases(['FolderAlias1@kolabnow.com']);
Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 3);
$aliases = $folder->aliases()->get();
$this->assertCount(1, $aliases);
$this->assertSame('folderalias1@kolabnow.com', $aliases[0]->alias);
$this->assertFalse(SharedFolder::aliasExists('folderalias2@kolabnow.com'));
// Remove all aliases
$folder->setAliases([]);
Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 4);
$this->assertCount(0, $folder->aliases()->get());
$this->assertFalse(SharedFolder::aliasExists('folderalias1@kolabnow.com'));
$this->assertFalse(SharedFolder::aliasExists('folderalias2@kolabnow.com'));
}
/**
* Tests for SharedFolder::assignToWallet()
*/
public function testAssignToWallet(): void
{
$user = $this->getTestUser('user-test@kolabnow.com');
$folder = $this->getTestSharedFolder('folder-test@kolabnow.com');
$result = $folder->assignToWallet($user->wallets->first());
$this->assertSame($folder, $result);
$this->assertSame(1, $folder->entitlements()->count());
$this->assertSame('shared-folder', $folder->entitlements()->first()->sku->title);
// Can't be done twice on the same folder
$this->expectException(\Exception::class);
$result->assignToWallet($user->wallets->first());
}
/**
* Test SharedFolder::getConfig() and setConfig() methods
*/
public function testConfigTrait(): void
{
Queue::fake();
$folder = new SharedFolder();
$folder->email = 'folder-test@kolabnow.com';
$folder->name = 'Test';
$folder->save();
$john = $this->getTestUser('john@kolab.org');
$folder->assignToWallet($john->wallets->first());
$this->assertSame(['acl' => []], $folder->getConfig());
$result = $folder->setConfig(['acl' => ['anyone, read-only'], 'unknown' => false]);
$this->assertSame(['acl' => ['anyone, read-only']], $folder->getConfig());
$this->assertSame('["anyone, read-only"]', $folder->getSetting('acl'));
$this->assertSame(['unknown' => "The requested configuration parameter is not supported."], $result);
$result = $folder->setConfig(['acl' => ['anyone, unknown']]);
$this->assertSame(['acl' => ['anyone, read-only']], $folder->getConfig());
$this->assertSame('["anyone, read-only"]', $folder->getSetting('acl'));
$this->assertSame(['acl' => ["The entry format is invalid. Expected an email address."]], $result);
// Test valid user for ACL
$result = $folder->setConfig(['acl' => ['john@kolab.org, full']]);
$this->assertSame(['acl' => ['john@kolab.org, full']], $folder->getConfig());
$this->assertSame('["john@kolab.org, full"]', $folder->getSetting('acl'));
$this->assertSame([], $result);
// Test invalid user for ACL
$result = $folder->setConfig(['acl' => ['john, full']]);
$this->assertSame(['acl' => ['john@kolab.org, full']], $folder->getConfig());
$this->assertSame('["john@kolab.org, full"]', $folder->getSetting('acl'));
$this->assertSame(['acl' => ["The specified email address is invalid."]], $result);
// Other invalid entries
$acl = [
// Test non-existing user for ACL
'unknown@kolab.org, full',
// Test existing user from a different wallet
'user@sample-tenant.dev-local, read-only',
// Valid entry
'john@kolab.org, read-write',
];
$result = $folder->setConfig(['acl' => $acl]);
$this->assertCount(2, $result['acl']);
$this->assertSame("The specified email address does not exist.", $result['acl'][0]);
$this->assertSame("The specified email address does not exist.", $result['acl'][1]);
$this->assertSame(['acl' => ['john@kolab.org, full']], $folder->getConfig());
$this->assertSame('["john@kolab.org, full"]', $folder->getSetting('acl'));
}
/**
* Test creating a shared folder
*/
public function testCreate(): void
{
Queue::fake();
$folder = new SharedFolder();
$folder->name = 'Reśo';
$folder->domainName = 'kolabnow.com';
$folder->save();
$this->assertMatchesRegularExpression('/^[0-9]{1,20}$/', $folder->id);
$this->assertMatchesRegularExpression('/^mail-[0-9]{1,20}@kolabnow\.com$/', $folder->email);
$this->assertSame('Reśo', $folder->name);
$this->assertTrue($folder->isNew());
- $this->assertTrue($folder->isActive());
+ $this->assertFalse($folder->isActive());
$this->assertFalse($folder->isDeleted());
$this->assertFalse($folder->isLdapReady());
$this->assertFalse($folder->isImapReady());
$settings = $folder->settings()->get();
$this->assertCount(1, $settings);
$this->assertSame('folder', $settings[0]->key);
$this->assertSame('shared/Reśo@kolabnow.com', $settings[0]->value);
Queue::assertPushed(
\App\Jobs\SharedFolder\CreateJob::class,
function ($job) use ($folder) {
$folderEmail = TestCase::getObjectProperty($job, 'folderEmail');
$folderId = TestCase::getObjectProperty($job, 'folderId');
return $folderEmail === $folder->email
&& $folderId === $folder->id;
}
);
-
- Queue::assertPushedWithChain(
- \App\Jobs\SharedFolder\CreateJob::class,
- [
- \App\Jobs\SharedFolder\VerifyJob::class,
- ]
- );
}
/**
* Test a shared folder deletion and force-deletion
*/
public function testDelete(): void
{
Queue::fake();
$user = $this->getTestUser('user-test@kolabnow.com');
$folder = $this->getTestSharedFolder('folder-test@kolabnow.com');
$folder->assignToWallet($user->wallets->first());
$entitlements = \App\Entitlement::where('entitleable_id', $folder->id);
$this->assertSame(1, $entitlements->count());
$folder->delete();
$this->assertTrue($folder->fresh()->trashed());
$this->assertSame(0, $entitlements->count());
$this->assertSame(1, $entitlements->withTrashed()->count());
$folder->forceDelete();
$this->assertSame(0, $entitlements->withTrashed()->count());
$this->assertCount(0, SharedFolder::withTrashed()->where('id', $folder->id)->get());
Queue::assertPushed(\App\Jobs\SharedFolder\DeleteJob::class, 1);
Queue::assertPushed(
\App\Jobs\SharedFolder\DeleteJob::class,
function ($job) use ($folder) {
$folderEmail = TestCase::getObjectProperty($job, 'folderEmail');
$folderId = TestCase::getObjectProperty($job, 'folderId');
return $folderEmail === $folder->email
&& $folderId === $folder->id;
}
);
}
/**
* Tests for SharedFolder::emailExists()
*/
public function testEmailExists(): void
{
Queue::fake();
$folder = $this->getTestSharedFolder('folder-test@kolabnow.com');
$this->assertFalse(SharedFolder::emailExists('unknown@domain.tld'));
$this->assertTrue(SharedFolder::emailExists($folder->email));
$result = SharedFolder::emailExists($folder->email, true);
$this->assertSame($result->id, $folder->id);
$folder->delete();
$this->assertTrue(SharedFolder::emailExists($folder->email));
$result = SharedFolder::emailExists($folder->email, true);
$this->assertSame($result->id, $folder->id);
}
/**
* Tests for SettingsTrait functionality and SharedFolderSettingObserver
*/
public function testSettings(): void
{
Queue::fake();
Queue::assertNothingPushed();
$folder = $this->getTestSharedFolder('folder-test@kolabnow.com');
Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 0);
// Add a setting
$folder->setSetting('unknown', 'test');
Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 0);
// Add a setting that is synced to LDAP
$folder->setSetting('acl', 'test');
Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 1);
+ Queue::assertPushed(
+ \App\Jobs\SharedFolder\UpdateJob::class,
+ function ($job) use ($folder) {
+ return $folder->id === TestCase::getObjectProperty($job, 'folderId')
+ && ['acl' => null] === TestCase::getObjectProperty($job, 'properties');
+ }
+ );
// Note: We test both current folder as well as fresh folder object
// to make sure cache works as expected
$this->assertSame('test', $folder->getSetting('unknown'));
$this->assertSame('test', $folder->fresh()->getSetting('acl'));
Queue::fake();
// Update a setting
$folder->setSetting('unknown', 'test1');
Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 0);
// Update a setting that is synced to LDAP
$folder->setSetting('acl', 'test1');
Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 1);
+ Queue::assertPushed(
+ \App\Jobs\SharedFolder\UpdateJob::class,
+ function ($job) use ($folder) {
+ return $folder->id === TestCase::getObjectProperty($job, 'folderId')
+ && ['acl' => 'test'] === TestCase::getObjectProperty($job, 'properties');
+ }
+ );
$this->assertSame('test1', $folder->getSetting('unknown'));
$this->assertSame('test1', $folder->fresh()->getSetting('acl'));
Queue::fake();
// Delete a setting (null)
$folder->setSetting('unknown', null);
Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 0);
// Delete a setting that is synced to LDAP
$folder->setSetting('acl', null);
Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 1);
+ Queue::assertPushed(
+ \App\Jobs\SharedFolder\UpdateJob::class,
+ function ($job) use ($folder) {
+ return $folder->id === TestCase::getObjectProperty($job, 'folderId')
+ && ['acl' => 'test1'] === TestCase::getObjectProperty($job, 'properties');
+ }
+ );
$this->assertSame(null, $folder->getSetting('unknown'));
$this->assertSame(null, $folder->fresh()->getSetting('acl'));
}
/**
* Test updating a shared folder
*/
public function testUpdate(): void
{
Queue::fake();
$folder = $this->getTestSharedFolder('folder-test@kolabnow.com');
$folder->name = 'New';
$folder->save();
// Assert the imap folder changes on a folder name change
$settings = $folder->settings()->where('key', 'folder')->get();
$this->assertCount(1, $settings);
$this->assertSame('shared/New@kolabnow.com', $settings[0]->value);
Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 1);
Queue::assertPushed(
\App\Jobs\SharedFolder\UpdateJob::class,
function ($job) use ($folder) {
$folderEmail = TestCase::getObjectProperty($job, 'folderEmail');
$folderId = TestCase::getObjectProperty($job, 'folderId');
return $folderEmail === $folder->email
&& $folderId === $folder->id;
}
);
}
}
diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php
index e60ff03b..327ab9be 100644
--- a/src/tests/Feature/UserTest.php
+++ b/src/tests/Feature/UserTest.php
@@ -1,1405 +1,1382 @@
<?php
namespace Tests\Feature;
use App\Domain;
use App\Group;
use App\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class UserTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('user-test@' . \config('app.domain'));
$this->deleteTestUser('UserAccountA@UserAccount.com');
$this->deleteTestUser('UserAccountB@UserAccount.com');
$this->deleteTestUser('UserAccountC@UserAccount.com');
$this->deleteTestGroup('test-group@UserAccount.com');
$this->deleteTestResource('test-resource@UserAccount.com');
$this->deleteTestSharedFolder('test-folder@UserAccount.com');
$this->deleteTestDomain('UserAccount.com');
$this->deleteTestDomain('UserAccountAdd.com');
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
\App\TenantSetting::truncate();
$this->deleteTestUser('user-test@' . \config('app.domain'));
$this->deleteTestUser('UserAccountA@UserAccount.com');
$this->deleteTestUser('UserAccountB@UserAccount.com');
$this->deleteTestUser('UserAccountC@UserAccount.com');
$this->deleteTestGroup('test-group@UserAccount.com');
$this->deleteTestResource('test-resource@UserAccount.com');
$this->deleteTestSharedFolder('test-folder@UserAccount.com');
$this->deleteTestDomain('UserAccount.com');
$this->deleteTestDomain('UserAccountAdd.com');
parent::tearDown();
}
/**
* Tests for User::assignPackage()
*/
public function testAssignPackage(): void
{
$user = $this->getTestUser('user-test@' . \config('app.domain'));
$wallet = $user->wallets()->first();
$package = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first();
$user->assignPackage($package);
$sku = \App\Sku::withEnvTenantContext()->where('title', 'mailbox')->first();
$entitlement = \App\Entitlement::where('wallet_id', $wallet->id)
->where('sku_id', $sku->id)->first();
$this->assertNotNull($entitlement);
$this->assertSame($sku->id, $entitlement->sku->id);
$this->assertSame($wallet->id, $entitlement->wallet->id);
$this->assertEquals($user->id, $entitlement->entitleable->id);
$this->assertTrue($entitlement->entitleable instanceof \App\User);
$this->assertCount(7, $user->entitlements()->get());
}
/**
* Tests for User::assignPlan()
*/
public function testAssignPlan(): void
{
$this->markTestIncomplete();
}
/**
* Tests for User::assignSku()
*/
public function testAssignSku(): void
{
$this->markTestIncomplete();
}
/**
* Verify a wallet assigned a controller is among the accounts of the assignee.
*/
public function testAccounts(): void
{
$userA = $this->getTestUser('UserAccountA@UserAccount.com');
$userB = $this->getTestUser('UserAccountB@UserAccount.com');
$this->assertTrue($userA->wallets()->count() == 1);
$userA->wallets()->each(
function ($wallet) use ($userB) {
$wallet->addController($userB);
}
);
$this->assertTrue($userB->accounts()->get()[0]->id === $userA->wallets()->get()[0]->id);
}
/**
* Test User::canDelete() method
*/
public function testCanDelete(): void
{
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$reseller1 = $this->getTestUser('reseller@' . \config('app.domain'));
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$domain = $this->getTestDomain('kolab.org');
// Admin
$this->assertTrue($admin->canDelete($admin));
$this->assertFalse($admin->canDelete($john));
$this->assertFalse($admin->canDelete($jack));
$this->assertFalse($admin->canDelete($reseller1));
$this->assertFalse($admin->canDelete($domain));
$this->assertFalse($admin->canDelete($domain->wallet()));
// Reseller - kolabnow
$this->assertFalse($reseller1->canDelete($john));
$this->assertFalse($reseller1->canDelete($jack));
$this->assertTrue($reseller1->canDelete($reseller1));
$this->assertFalse($reseller1->canDelete($domain));
$this->assertFalse($reseller1->canDelete($domain->wallet()));
$this->assertFalse($reseller1->canDelete($admin));
// Normal user - account owner
$this->assertTrue($john->canDelete($john));
$this->assertTrue($john->canDelete($ned));
$this->assertTrue($john->canDelete($jack));
$this->assertTrue($john->canDelete($domain));
$this->assertFalse($john->canDelete($domain->wallet()));
$this->assertFalse($john->canDelete($reseller1));
$this->assertFalse($john->canDelete($admin));
// Normal user - a non-owner and non-controller
$this->assertFalse($jack->canDelete($jack));
$this->assertFalse($jack->canDelete($john));
$this->assertFalse($jack->canDelete($domain));
$this->assertFalse($jack->canDelete($domain->wallet()));
$this->assertFalse($jack->canDelete($reseller1));
$this->assertFalse($jack->canDelete($admin));
// Normal user - John's wallet controller
$this->assertTrue($ned->canDelete($ned));
$this->assertTrue($ned->canDelete($john));
$this->assertTrue($ned->canDelete($jack));
$this->assertTrue($ned->canDelete($domain));
$this->assertFalse($ned->canDelete($domain->wallet()));
$this->assertFalse($ned->canDelete($reseller1));
$this->assertFalse($ned->canDelete($admin));
}
/**
* Test User::canRead() method
*/
public function testCanRead(): void
{
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$reseller1 = $this->getTestUser('reseller@' . \config('app.domain'));
$reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$domain = $this->getTestDomain('kolab.org');
// Admin
$this->assertTrue($admin->canRead($admin));
$this->assertTrue($admin->canRead($john));
$this->assertTrue($admin->canRead($jack));
$this->assertTrue($admin->canRead($reseller1));
$this->assertTrue($admin->canRead($reseller2));
$this->assertTrue($admin->canRead($domain));
$this->assertTrue($admin->canRead($domain->wallet()));
// Reseller - kolabnow
$this->assertTrue($reseller1->canRead($john));
$this->assertTrue($reseller1->canRead($jack));
$this->assertTrue($reseller1->canRead($reseller1));
$this->assertTrue($reseller1->canRead($domain));
$this->assertTrue($reseller1->canRead($domain->wallet()));
$this->assertFalse($reseller1->canRead($reseller2));
$this->assertFalse($reseller1->canRead($admin));
// Reseller - different tenant
$this->assertTrue($reseller2->canRead($reseller2));
$this->assertFalse($reseller2->canRead($john));
$this->assertFalse($reseller2->canRead($jack));
$this->assertFalse($reseller2->canRead($reseller1));
$this->assertFalse($reseller2->canRead($domain));
$this->assertFalse($reseller2->canRead($domain->wallet()));
$this->assertFalse($reseller2->canRead($admin));
// Normal user - account owner
$this->assertTrue($john->canRead($john));
$this->assertTrue($john->canRead($ned));
$this->assertTrue($john->canRead($jack));
$this->assertTrue($john->canRead($domain));
$this->assertTrue($john->canRead($domain->wallet()));
$this->assertFalse($john->canRead($reseller1));
$this->assertFalse($john->canRead($reseller2));
$this->assertFalse($john->canRead($admin));
// Normal user - a non-owner and non-controller
$this->assertTrue($jack->canRead($jack));
$this->assertFalse($jack->canRead($john));
$this->assertFalse($jack->canRead($domain));
$this->assertFalse($jack->canRead($domain->wallet()));
$this->assertFalse($jack->canRead($reseller1));
$this->assertFalse($jack->canRead($reseller2));
$this->assertFalse($jack->canRead($admin));
// Normal user - John's wallet controller
$this->assertTrue($ned->canRead($ned));
$this->assertTrue($ned->canRead($john));
$this->assertTrue($ned->canRead($jack));
$this->assertTrue($ned->canRead($domain));
$this->assertTrue($ned->canRead($domain->wallet()));
$this->assertFalse($ned->canRead($reseller1));
$this->assertFalse($ned->canRead($reseller2));
$this->assertFalse($ned->canRead($admin));
}
/**
* Test User::canUpdate() method
*/
public function testCanUpdate(): void
{
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$reseller1 = $this->getTestUser('reseller@' . \config('app.domain'));
$reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$domain = $this->getTestDomain('kolab.org');
// Admin
$this->assertTrue($admin->canUpdate($admin));
$this->assertTrue($admin->canUpdate($john));
$this->assertTrue($admin->canUpdate($jack));
$this->assertTrue($admin->canUpdate($reseller1));
$this->assertTrue($admin->canUpdate($reseller2));
$this->assertTrue($admin->canUpdate($domain));
$this->assertTrue($admin->canUpdate($domain->wallet()));
// Reseller - kolabnow
$this->assertTrue($reseller1->canUpdate($john));
$this->assertTrue($reseller1->canUpdate($jack));
$this->assertTrue($reseller1->canUpdate($reseller1));
$this->assertTrue($reseller1->canUpdate($domain));
$this->assertTrue($reseller1->canUpdate($domain->wallet()));
$this->assertFalse($reseller1->canUpdate($reseller2));
$this->assertFalse($reseller1->canUpdate($admin));
// Reseller - different tenant
$this->assertTrue($reseller2->canUpdate($reseller2));
$this->assertFalse($reseller2->canUpdate($john));
$this->assertFalse($reseller2->canUpdate($jack));
$this->assertFalse($reseller2->canUpdate($reseller1));
$this->assertFalse($reseller2->canUpdate($domain));
$this->assertFalse($reseller2->canUpdate($domain->wallet()));
$this->assertFalse($reseller2->canUpdate($admin));
// Normal user - account owner
$this->assertTrue($john->canUpdate($john));
$this->assertTrue($john->canUpdate($ned));
$this->assertTrue($john->canUpdate($jack));
$this->assertTrue($john->canUpdate($domain));
$this->assertFalse($john->canUpdate($domain->wallet()));
$this->assertFalse($john->canUpdate($reseller1));
$this->assertFalse($john->canUpdate($reseller2));
$this->assertFalse($john->canUpdate($admin));
// Normal user - a non-owner and non-controller
$this->assertTrue($jack->canUpdate($jack));
$this->assertFalse($jack->canUpdate($john));
$this->assertFalse($jack->canUpdate($domain));
$this->assertFalse($jack->canUpdate($domain->wallet()));
$this->assertFalse($jack->canUpdate($reseller1));
$this->assertFalse($jack->canUpdate($reseller2));
$this->assertFalse($jack->canUpdate($admin));
// Normal user - John's wallet controller
$this->assertTrue($ned->canUpdate($ned));
$this->assertTrue($ned->canUpdate($john));
$this->assertTrue($ned->canUpdate($jack));
$this->assertTrue($ned->canUpdate($domain));
$this->assertFalse($ned->canUpdate($domain->wallet()));
$this->assertFalse($ned->canUpdate($reseller1));
$this->assertFalse($ned->canUpdate($reseller2));
$this->assertFalse($ned->canUpdate($admin));
}
/**
* Test user created/creating/updated observers
*/
public function testCreateAndUpdate(): void
{
Queue::fake();
$domain = \config('app.domain');
\App\Tenant::find(\config('app.tenant_id'))->setSetting('pgp.enable', 0);
$user = User::create([
'email' => 'USER-test@' . \strtoupper($domain),
'password' => 'test',
]);
$result = User::where('email', "user-test@$domain")->first();
$this->assertSame("user-test@$domain", $result->email);
$this->assertSame($user->id, $result->id);
- $this->assertSame(User::STATUS_NEW | User::STATUS_ACTIVE, $result->status);
+ $this->assertSame(User::STATUS_NEW, $result->status);
$this->assertSame(0, $user->passwords()->count());
Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1);
Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 0);
Queue::assertPushed(
\App\Jobs\User\CreateJob::class,
function ($job) use ($user) {
$userEmail = TestCase::getObjectProperty($job, 'userEmail');
$userId = TestCase::getObjectProperty($job, 'userId');
return $userEmail === $user->email
&& $userId === $user->id;
}
);
- Queue::assertPushedWithChain(
- \App\Jobs\User\CreateJob::class,
- [
- \App\Jobs\User\VerifyJob::class,
- ]
- );
-/*
- FIXME: Looks like we can't really do detailed assertions on chained jobs
- Another thing to consider is if we maybe should run these jobs
- independently (not chained) and make sure there's no race-condition
- in status update
-
- Queue::assertPushed(\App\Jobs\User\VerifyJob::class, 1);
- Queue::assertPushed(\App\Jobs\User\VerifyJob::class, function ($job) use ($user) {
- $userEmail = TestCase::getObjectProperty($job, 'userEmail');
- $userId = TestCase::getObjectProperty($job, 'userId');
-
- return $userEmail === $user->email
- && $userId === $user->id;
- });
-*/
-
// Test invoking KeyCreateJob
$this->deleteTestUser("user-test@$domain");
\App\Tenant::find(\config('app.tenant_id'))->setSetting('pgp.enable', 1);
$user = User::create(['email' => "user-test@$domain", 'password' => 'test']);
Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1);
Queue::assertPushed(
\App\Jobs\PGP\KeyCreateJob::class,
function ($job) use ($user) {
$userEmail = TestCase::getObjectProperty($job, 'userEmail');
$userId = TestCase::getObjectProperty($job, 'userId');
return $userEmail === $user->email
&& $userId === $user->id;
}
);
// Update the user, test the password change
$user->setSetting('password_expiration_warning', '2020-10-10 10:10:10');
$oldPassword = $user->password;
$user->password = 'test123';
$user->save();
$this->assertNotEquals($oldPassword, $user->password);
$this->assertSame(0, $user->passwords()->count());
$this->assertNull($user->getSetting('password_expiration_warning'));
$this->assertMatchesRegularExpression(
'/^' . now()->format('Y-m-d') . ' [0-9]{2}:[0-9]{2}:[0-9]{2}$/',
$user->getSetting('password_update')
);
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1);
Queue::assertPushed(
\App\Jobs\User\UpdateJob::class,
function ($job) use ($user) {
$userEmail = TestCase::getObjectProperty($job, 'userEmail');
$userId = TestCase::getObjectProperty($job, 'userId');
return $userEmail === $user->email
&& $userId === $user->id;
}
);
// Update the user, test the password history
$user->setSetting('password_policy', 'last:3');
$oldPassword = $user->password;
$user->password = 'test1234';
$user->save();
$this->assertSame(1, $user->passwords()->count());
$this->assertSame($oldPassword, $user->passwords()->first()->password);
$user->password = 'test12345';
$user->save();
$oldPassword = $user->password;
$user->password = 'test123456';
$user->save();
$this->assertSame(2, $user->passwords()->count());
$this->assertSame($oldPassword, $user->passwords()->latest()->first()->password);
}
/**
* Tests for User::domains()
*/
public function testDomains(): void
{
$user = $this->getTestUser('john@kolab.org');
$domain = $this->getTestDomain('useraccount.com', [
'status' => Domain::STATUS_NEW | Domain::STATUS_ACTIVE,
'type' => Domain::TYPE_PUBLIC,
]);
$domains = $user->domains()->pluck('namespace')->all();
$this->assertContains($domain->namespace, $domains);
$this->assertContains('kolab.org', $domains);
// Jack is not the wallet controller, so for him the list should not
// include John's domains, kolab.org specifically
$user = $this->getTestUser('jack@kolab.org');
$domains = $user->domains()->pluck('namespace')->all();
$this->assertContains($domain->namespace, $domains);
$this->assertNotContains('kolab.org', $domains);
// Public domains of other tenants should not be returned
$tenant = \App\Tenant::where('id', '!=', \config('app.tenant_id'))->first();
$domain->tenant_id = $tenant->id;
$domain->save();
$domains = $user->domains()->pluck('namespace')->all();
$this->assertNotContains($domain->namespace, $domains);
}
/**
* Test User::getConfig() and setConfig() methods
*/
public function testConfigTrait(): void
{
$user = $this->getTestUser('UserAccountA@UserAccount.com');
$user->setSetting('greylist_enabled', null);
$user->setSetting('guam_enabled', null);
$user->setSetting('password_policy', null);
$user->setSetting('max_password_age', null);
$user->setSetting('limit_geo', null);
// greylist_enabled
$this->assertSame(true, $user->getConfig()['greylist_enabled']);
$result = $user->setConfig(['greylist_enabled' => false, 'unknown' => false]);
$this->assertSame(['unknown' => "The requested configuration parameter is not supported."], $result);
$this->assertSame(false, $user->getConfig()['greylist_enabled']);
$this->assertSame('false', $user->getSetting('greylist_enabled'));
$result = $user->setConfig(['greylist_enabled' => true]);
$this->assertSame([], $result);
$this->assertSame(true, $user->getConfig()['greylist_enabled']);
$this->assertSame('true', $user->getSetting('greylist_enabled'));
// guam_enabled
$this->assertSame(false, $user->getConfig()['guam_enabled']);
$result = $user->setConfig(['guam_enabled' => false]);
$this->assertSame([], $result);
$this->assertSame(false, $user->getConfig()['guam_enabled']);
$this->assertSame(null, $user->getSetting('guam_enabled'));
$result = $user->setConfig(['guam_enabled' => true]);
$this->assertSame([], $result);
$this->assertSame(true, $user->getConfig()['guam_enabled']);
$this->assertSame('true', $user->getSetting('guam_enabled'));
// max_apssword_age
$this->assertSame(null, $user->getConfig()['max_password_age']);
$result = $user->setConfig(['max_password_age' => -1]);
$this->assertSame([], $result);
$this->assertSame(null, $user->getConfig()['max_password_age']);
$this->assertSame(null, $user->getSetting('max_password_age'));
$result = $user->setConfig(['max_password_age' => 12]);
$this->assertSame([], $result);
$this->assertSame('12', $user->getConfig()['max_password_age']);
$this->assertSame('12', $user->getSetting('max_password_age'));
// password_policy
$result = $user->setConfig(['password_policy' => true]);
$this->assertSame(['password_policy' => "Specified password policy is invalid."], $result);
$this->assertSame(null, $user->getConfig()['password_policy']);
$this->assertSame(null, $user->getSetting('password_policy'));
$result = $user->setConfig(['password_policy' => 'min:-1']);
$this->assertSame(['password_policy' => "Specified password policy is invalid."], $result);
$result = $user->setConfig(['password_policy' => 'min:-1']);
$this->assertSame(['password_policy' => "Specified password policy is invalid."], $result);
$result = $user->setConfig(['password_policy' => 'min:10,unknown']);
$this->assertSame(['password_policy' => "Specified password policy is invalid."], $result);
\config(['app.password_policy' => 'min:5,max:100']);
$result = $user->setConfig(['password_policy' => 'min:4,max:255']);
$this->assertSame(['password_policy' => "Minimum password length cannot be less than 5."], $result);
\config(['app.password_policy' => 'min:5,max:100']);
$result = $user->setConfig(['password_policy' => 'min:10,max:255']);
$this->assertSame(['password_policy' => "Maximum password length cannot be more than 100."], $result);
\config(['app.password_policy' => 'min:5,max:255']);
$result = $user->setConfig(['password_policy' => 'min:10,max:255']);
$this->assertSame([], $result);
$this->assertSame('min:10,max:255', $user->getConfig()['password_policy']);
$this->assertSame('min:10,max:255', $user->getSetting('password_policy'));
// limit_geo
$this->assertSame([], $user->getConfig()['limit_geo']);
$result = $user->setConfig(['limit_geo' => '']);
$err = "Specified configuration is invalid. Expected a list of two-letter country codes.";
$this->assertSame(['limit_geo' => $err], $result);
$this->assertSame(null, $user->getSetting('limit_geo'));
$result = $user->setConfig(['limit_geo' => ['usa']]);
$this->assertSame(['limit_geo' => $err], $result);
$this->assertSame(null, $user->getSetting('limit_geo'));
$result = $user->setConfig(['limit_geo' => []]);
$this->assertSame([], $result);
$this->assertSame(null, $user->getSetting('limit_geo'));
$result = $user->setConfig(['limit_geo' => ['US', 'ru']]);
$this->assertSame([], $result);
$this->assertSame(['US', 'RU'], $user->getConfig()['limit_geo']);
$this->assertSame('["US","RU"]', $user->getSetting('limit_geo'));
}
/**
* Test user account degradation and un-degradation
*/
public function testDegradeAndUndegrade(): void
{
Queue::fake();
// Test an account with users, domain
$userA = $this->getTestUser('UserAccountA@UserAccount.com');
$userB = $this->getTestUser('UserAccountB@UserAccount.com');
$package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first();
$package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$domain = $this->getTestDomain('UserAccount.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_HOSTED,
]);
$userA->assignPackage($package_kolab);
$domain->assignPackage($package_domain, $userA);
$userA->assignPackage($package_kolab, $userB);
$entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id);
$entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id);
$entitlementsDomain = \App\Entitlement::where('entitleable_id', $domain->id);
$yesterday = Carbon::now()->subDays(1);
$this->backdateEntitlements($entitlementsA->get(), $yesterday, Carbon::now()->subMonthsWithoutOverflow(1));
$this->backdateEntitlements($entitlementsB->get(), $yesterday, Carbon::now()->subMonthsWithoutOverflow(1));
$wallet = $userA->wallets->first();
$this->assertSame(7, $entitlementsA->count());
$this->assertSame(7, $entitlementsB->count());
$this->assertSame(7, $entitlementsA->whereDate('updated_at', $yesterday->toDateString())->count());
$this->assertSame(7, $entitlementsB->whereDate('updated_at', $yesterday->toDateString())->count());
$this->assertSame(0, $wallet->balance);
Queue::fake(); // reset queue state
// Degrade the account/wallet owner
$userA->degrade();
$entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id);
$entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id);
$this->assertTrue($userA->fresh()->isDegraded());
$this->assertTrue($userA->fresh()->isDegraded(true));
$this->assertFalse($userB->fresh()->isDegraded());
$this->assertTrue($userB->fresh()->isDegraded(true));
$balance = $wallet->fresh()->balance;
$this->assertTrue($balance <= -64);
$this->assertSame(7, $entitlementsA->whereDate('updated_at', Carbon::now()->toDateString())->count());
$this->assertSame(7, $entitlementsB->whereDate('updated_at', Carbon::now()->toDateString())->count());
// Expect one update job for every user
// @phpstan-ignore-next-line
$userIds = Queue::pushed(\App\Jobs\User\UpdateJob::class)->map(function ($job) {
return TestCase::getObjectProperty($job, 'userId');
})->all();
$this->assertSame([$userA->id, $userB->id], $userIds);
// Un-Degrade the account/wallet owner
$entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id);
$entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id);
$yesterday = Carbon::now()->subDays(1);
$this->backdateEntitlements($entitlementsA->get(), $yesterday, Carbon::now()->subMonthsWithoutOverflow(1));
$this->backdateEntitlements($entitlementsB->get(), $yesterday, Carbon::now()->subMonthsWithoutOverflow(1));
Queue::fake(); // reset queue state
$userA->undegrade();
$this->assertFalse($userA->fresh()->isDegraded());
$this->assertFalse($userA->fresh()->isDegraded(true));
$this->assertFalse($userB->fresh()->isDegraded());
$this->assertFalse($userB->fresh()->isDegraded(true));
// Expect no balance change, degraded account entitlements are free
$this->assertSame($balance, $wallet->fresh()->balance);
$this->assertSame(7, $entitlementsA->whereDate('updated_at', Carbon::now()->toDateString())->count());
$this->assertSame(7, $entitlementsB->whereDate('updated_at', Carbon::now()->toDateString())->count());
// Expect one update job for every user
// @phpstan-ignore-next-line
$userIds = Queue::pushed(\App\Jobs\User\UpdateJob::class)->map(function ($job) {
return TestCase::getObjectProperty($job, 'userId');
})->all();
$this->assertSame([$userA->id, $userB->id], $userIds);
}
/**
* Test user deletion
*/
public function testDelete(): void
{
Queue::fake();
$user = $this->getTestUser('user-test@' . \config('app.domain'));
$package = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first();
$user->assignPackage($package);
$id = $user->id;
$this->assertCount(7, $user->entitlements()->get());
$user->delete();
$this->assertCount(0, $user->entitlements()->get());
$this->assertTrue($user->fresh()->trashed());
$this->assertFalse($user->fresh()->isDeleted());
// Delete the user for real
$job = new \App\Jobs\User\DeleteJob($id);
$job->handle();
$this->assertTrue(User::withTrashed()->where('id', $id)->first()->isDeleted());
$user->forceDelete();
$this->assertCount(0, User::withTrashed()->where('id', $id)->get());
// Test an account with users, domain, and group, and resource
$userA = $this->getTestUser('UserAccountA@UserAccount.com');
$userB = $this->getTestUser('UserAccountB@UserAccount.com');
$userC = $this->getTestUser('UserAccountC@UserAccount.com');
$package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first();
$package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$domain = $this->getTestDomain('UserAccount.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_HOSTED,
]);
$userA->assignPackage($package_kolab);
$domain->assignPackage($package_domain, $userA);
$userA->assignPackage($package_kolab, $userB);
$userA->assignPackage($package_kolab, $userC);
$group = $this->getTestGroup('test-group@UserAccount.com');
$group->assignToWallet($userA->wallets->first());
$resource = $this->getTestResource('test-resource@UserAccount.com', ['name' => 'test']);
$resource->assignToWallet($userA->wallets->first());
$folder = $this->getTestSharedFolder('test-folder@UserAccount.com', ['name' => 'test']);
$folder->assignToWallet($userA->wallets->first());
$entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id);
$entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id);
$entitlementsC = \App\Entitlement::where('entitleable_id', $userC->id);
$entitlementsDomain = \App\Entitlement::where('entitleable_id', $domain->id);
$entitlementsGroup = \App\Entitlement::where('entitleable_id', $group->id);
$entitlementsResource = \App\Entitlement::where('entitleable_id', $resource->id);
$entitlementsFolder = \App\Entitlement::where('entitleable_id', $folder->id);
$this->assertSame(7, $entitlementsA->count());
$this->assertSame(7, $entitlementsB->count());
$this->assertSame(7, $entitlementsC->count());
$this->assertSame(1, $entitlementsDomain->count());
$this->assertSame(1, $entitlementsGroup->count());
$this->assertSame(1, $entitlementsResource->count());
$this->assertSame(1, $entitlementsFolder->count());
// Delete non-controller user
$userC->delete();
$this->assertTrue($userC->fresh()->trashed());
$this->assertFalse($userC->fresh()->isDeleted());
$this->assertSame(0, $entitlementsC->count());
// Delete the controller (and expect "sub"-users to be deleted too)
$userA->delete();
$this->assertSame(0, $entitlementsA->count());
$this->assertSame(0, $entitlementsB->count());
$this->assertSame(0, $entitlementsDomain->count());
$this->assertSame(0, $entitlementsGroup->count());
$this->assertSame(0, $entitlementsResource->count());
$this->assertSame(0, $entitlementsFolder->count());
$this->assertSame(7, $entitlementsA->withTrashed()->count());
$this->assertSame(7, $entitlementsB->withTrashed()->count());
$this->assertSame(7, $entitlementsC->withTrashed()->count());
$this->assertSame(1, $entitlementsDomain->withTrashed()->count());
$this->assertSame(1, $entitlementsGroup->withTrashed()->count());
$this->assertSame(1, $entitlementsResource->withTrashed()->count());
$this->assertSame(1, $entitlementsFolder->withTrashed()->count());
$this->assertTrue($userA->fresh()->trashed());
$this->assertTrue($userB->fresh()->trashed());
$this->assertTrue($domain->fresh()->trashed());
$this->assertTrue($group->fresh()->trashed());
$this->assertTrue($resource->fresh()->trashed());
$this->assertTrue($folder->fresh()->trashed());
$this->assertFalse($userA->isDeleted());
$this->assertFalse($userB->isDeleted());
$this->assertFalse($domain->isDeleted());
$this->assertFalse($group->isDeleted());
$this->assertFalse($resource->isDeleted());
$this->assertFalse($folder->isDeleted());
$userA->forceDelete();
$all_entitlements = \App\Entitlement::where('wallet_id', $userA->wallets->first()->id);
$transactions = \App\Transaction::where('object_id', $userA->wallets->first()->id);
$this->assertSame(0, $all_entitlements->withTrashed()->count());
$this->assertSame(0, $transactions->count());
$this->assertCount(0, User::withTrashed()->where('id', $userA->id)->get());
$this->assertCount(0, User::withTrashed()->where('id', $userB->id)->get());
$this->assertCount(0, User::withTrashed()->where('id', $userC->id)->get());
$this->assertCount(0, Domain::withTrashed()->where('id', $domain->id)->get());
$this->assertCount(0, Group::withTrashed()->where('id', $group->id)->get());
$this->assertCount(0, \App\Resource::withTrashed()->where('id', $resource->id)->get());
$this->assertCount(0, \App\SharedFolder::withTrashed()->where('id', $folder->id)->get());
}
/**
* Test user deletion vs. group membership
*/
public function testDeleteAndGroups(): void
{
Queue::fake();
$package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first();
$userA = $this->getTestUser('UserAccountA@UserAccount.com');
$userB = $this->getTestUser('UserAccountB@UserAccount.com');
$userA->assignPackage($package_kolab, $userB);
$group = $this->getTestGroup('test-group@UserAccount.com');
$group->members = ['test@gmail.com', $userB->email];
$group->assignToWallet($userA->wallets->first());
$group->save();
Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1);
$userGroups = $userA->groups()->get();
$this->assertSame(1, $userGroups->count());
$this->assertSame($group->id, $userGroups->first()->id);
$userB->delete();
$this->assertSame(['test@gmail.com'], $group->fresh()->members);
// Twice, one for save() and one for delete() above
Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 2);
}
/**
* Test handling negative balance on user deletion
*/
public function testDeleteWithNegativeBalance(): void
{
$user = $this->getTestUser('user-test@' . \config('app.domain'));
$wallet = $user->wallets()->first();
$wallet->balance = -1000;
$wallet->save();
$reseller_wallet = $user->tenant->wallet();
$reseller_wallet->balance = 0;
$reseller_wallet->save();
\App\Transaction::where('object_id', $reseller_wallet->id)->where('object_type', \App\Wallet::class)->delete();
$user->delete();
$reseller_transactions = \App\Transaction::where('object_id', $reseller_wallet->id)
->where('object_type', \App\Wallet::class)->get();
$this->assertSame(-1000, $reseller_wallet->fresh()->balance);
$this->assertCount(1, $reseller_transactions);
$trans = $reseller_transactions[0];
$this->assertSame("Deleted user {$user->email}", $trans->description);
$this->assertSame(-1000, $trans->amount);
$this->assertSame(\App\Transaction::WALLET_DEBIT, $trans->type);
}
/**
* Test handling positive balance on user deletion
*/
public function testDeleteWithPositiveBalance(): void
{
$user = $this->getTestUser('user-test@' . \config('app.domain'));
$wallet = $user->wallets()->first();
$wallet->balance = 1000;
$wallet->save();
$reseller_wallet = $user->tenant->wallet();
$reseller_wallet->balance = 0;
$reseller_wallet->save();
$user->delete();
$this->assertSame(0, $reseller_wallet->fresh()->balance);
}
/**
* Test user deletion with PGP/WOAT enabled
*/
public function testDeleteWithPGP(): void
{
Queue::fake();
// Test with PGP disabled
$user = $this->getTestUser('user-test@' . \config('app.domain'));
$user->tenant->setSetting('pgp.enable', 0);
$user->delete();
Queue::assertPushed(\App\Jobs\PGP\KeyDeleteJob::class, 0);
// Test with PGP enabled
$this->deleteTestUser('user-test@' . \config('app.domain'));
$user = $this->getTestUser('user-test@' . \config('app.domain'));
$user->tenant->setSetting('pgp.enable', 1);
$user->delete();
$user->tenant->setSetting('pgp.enable', 0);
Queue::assertPushed(\App\Jobs\PGP\KeyDeleteJob::class, 1);
Queue::assertPushed(
\App\Jobs\PGP\KeyDeleteJob::class,
function ($job) use ($user) {
$userId = TestCase::getObjectProperty($job, 'userId');
$userEmail = TestCase::getObjectProperty($job, 'userEmail');
return $userId == $user->id && $userEmail === $user->email;
}
);
}
/**
* Test user deletion vs. rooms
*/
public function testDeleteWithRooms(): void
{
$this->markTestIncomplete();
}
/**
* Tests for User::aliasExists()
*/
public function testAliasExists(): void
{
$this->assertTrue(User::aliasExists('jack.daniels@kolab.org'));
$this->assertFalse(User::aliasExists('j.daniels@kolab.org'));
$this->assertFalse(User::aliasExists('john@kolab.org'));
}
/**
* Tests for User::emailExists()
*/
public function testEmailExists(): void
{
$this->assertFalse(User::emailExists('jack.daniels@kolab.org'));
$this->assertFalse(User::emailExists('j.daniels@kolab.org'));
$this->assertTrue(User::emailExists('john@kolab.org'));
$user = User::emailExists('john@kolab.org', true);
$this->assertSame('john@kolab.org', $user->email);
}
/**
* Tests for User::findByEmail()
*/
public function testFindByEmail(): void
{
$user = $this->getTestUser('john@kolab.org');
$result = User::findByEmail('john');
$this->assertNull($result);
$result = User::findByEmail('non-existing@email.com');
$this->assertNull($result);
$result = User::findByEmail('john@kolab.org');
$this->assertInstanceOf(User::class, $result);
$this->assertSame($user->id, $result->id);
// Use an alias
$result = User::findByEmail('john.doe@kolab.org');
$this->assertInstanceOf(User::class, $result);
$this->assertSame($user->id, $result->id);
Queue::fake();
// A case where two users have the same alias
$ned = $this->getTestUser('ned@kolab.org');
$ned->setAliases(['joe.monster@kolab.org']);
$result = User::findByEmail('joe.monster@kolab.org');
$this->assertNull($result);
$ned->setAliases([]);
// TODO: searching by external email (setting)
$this->markTestIncomplete();
}
/**
- * Test User::hasSku() method
+ * Test User::hasSku() and countEntitlementsBySku() methods
*/
public function testHasSku(): void
{
$john = $this->getTestUser('john@kolab.org');
$this->assertTrue($john->hasSku('mailbox'));
$this->assertTrue($john->hasSku('storage'));
$this->assertFalse($john->hasSku('beta'));
$this->assertFalse($john->hasSku('unknown'));
+
+ $this->assertSame(0, $john->countEntitlementsBySku('unknown'));
+ $this->assertSame(0, $john->countEntitlementsBySku('2fa'));
+ $this->assertSame(1, $john->countEntitlementsBySku('mailbox'));
+ $this->assertSame(5, $john->countEntitlementsBySku('storage'));
}
/**
* Test User::name()
*/
public function testName(): void
{
Queue::fake();
$user = $this->getTestUser('user-test@' . \config('app.domain'));
$this->assertSame('', $user->name());
$this->assertSame($user->tenant->title . ' User', $user->name(true));
$user->setSetting('first_name', 'First');
$this->assertSame('First', $user->name());
$this->assertSame('First', $user->name(true));
$user->setSetting('last_name', 'Last');
$this->assertSame('First Last', $user->name());
$this->assertSame('First Last', $user->name(true));
}
/**
* Test resources() method
*/
public function testResources(): void
{
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$resources = $john->resources()->orderBy('email')->get();
$this->assertSame(2, $resources->count());
$this->assertSame('resource-test1@kolab.org', $resources[0]->email);
$this->assertSame('resource-test2@kolab.org', $resources[1]->email);
$resources = $ned->resources()->orderBy('email')->get();
$this->assertSame(2, $resources->count());
$this->assertSame('resource-test1@kolab.org', $resources[0]->email);
$this->assertSame('resource-test2@kolab.org', $resources[1]->email);
$resources = $jack->resources()->get();
$this->assertSame(0, $resources->count());
}
/**
* Test sharedFolders() method
*/
public function testSharedFolders(): void
{
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$folders = $john->sharedFolders()->orderBy('email')->get();
$this->assertSame(2, $folders->count());
$this->assertSame('folder-contact@kolab.org', $folders[0]->email);
$this->assertSame('folder-event@kolab.org', $folders[1]->email);
$folders = $ned->sharedFolders()->orderBy('email')->get();
$this->assertSame(2, $folders->count());
$this->assertSame('folder-contact@kolab.org', $folders[0]->email);
$this->assertSame('folder-event@kolab.org', $folders[1]->email);
$folders = $jack->sharedFolders()->get();
$this->assertSame(0, $folders->count());
}
/**
* Test user restoring
*/
public function testRestore(): void
{
Queue::fake();
// Test an account with users and domain
$userA = $this->getTestUser('UserAccountA@UserAccount.com', [
'status' => User::STATUS_LDAP_READY | User::STATUS_IMAP_READY | User::STATUS_SUSPENDED,
]);
$userB = $this->getTestUser('UserAccountB@UserAccount.com');
$package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first();
$package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$domainA = $this->getTestDomain('UserAccount.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_HOSTED,
]);
$domainB = $this->getTestDomain('UserAccountAdd.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_HOSTED,
]);
$userA->assignPackage($package_kolab);
$domainA->assignPackage($package_domain, $userA);
$domainB->assignPackage($package_domain, $userA);
$userA->assignPackage($package_kolab, $userB);
$storage_sku = \App\Sku::withEnvTenantContext()->where('title', 'storage')->first();
$now = \Carbon\Carbon::now();
$wallet_id = $userA->wallets->first()->id;
// add an extra storage entitlement
$ent1 = \App\Entitlement::create([
'wallet_id' => $wallet_id,
'sku_id' => $storage_sku->id,
'cost' => 0,
'entitleable_id' => $userA->id,
'entitleable_type' => User::class,
]);
$entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id);
$entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id);
$entitlementsDomain = \App\Entitlement::where('entitleable_id', $domainA->id);
// First delete the user
$userA->delete();
$this->assertSame(0, $entitlementsA->count());
$this->assertSame(0, $entitlementsB->count());
$this->assertSame(0, $entitlementsDomain->count());
$this->assertTrue($userA->fresh()->trashed());
$this->assertTrue($userB->fresh()->trashed());
$this->assertTrue($domainA->fresh()->trashed());
$this->assertTrue($domainB->fresh()->trashed());
$this->assertFalse($userA->isDeleted());
$this->assertFalse($userB->isDeleted());
$this->assertFalse($domainA->isDeleted());
// Backdate one storage entitlement (it's not expected to be restored)
\App\Entitlement::withTrashed()->where('id', $ent1->id)
->update(['deleted_at' => $now->copy()->subMinutes(2)]);
// Backdate entitlements to assert that they were restored with proper updated_at timestamp
\App\Entitlement::withTrashed()->where('wallet_id', $wallet_id)
->update(['updated_at' => $now->subMinutes(10)]);
Queue::fake();
// Then restore it
$userA->restore();
$userA->refresh();
$this->assertFalse($userA->trashed());
$this->assertFalse($userA->isDeleted());
$this->assertFalse($userA->isSuspended());
$this->assertFalse($userA->isLdapReady());
$this->assertFalse($userA->isImapReady());
$this->assertTrue($userA->isActive());
$this->assertTrue($userB->fresh()->trashed());
$this->assertTrue($domainB->fresh()->trashed());
$this->assertFalse($domainA->fresh()->trashed());
// Assert entitlements
$this->assertSame(7, $entitlementsA->count()); // mailbox + groupware + 5 x storage
$this->assertTrue($ent1->fresh()->trashed());
$entitlementsA->get()->each(function ($ent) {
$this->assertTrue($ent->updated_at->greaterThan(\Carbon\Carbon::now()->subSeconds(5)));
});
// We expect only CreateJob + UpdateJob pair for both user and domain.
// Because how Illuminate/Database/Eloquent/SoftDeletes::restore() method
// is implemented we cannot skip the UpdateJob in any way.
// I don't want to overwrite this method, the extra job shouldn't do any harm.
$this->assertCount(4, Queue::pushedJobs()); // @phpstan-ignore-line
Queue::assertPushed(\App\Jobs\Domain\UpdateJob::class, 1);
Queue::assertPushed(\App\Jobs\Domain\CreateJob::class, 1);
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1);
Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1);
Queue::assertPushed(
\App\Jobs\User\CreateJob::class,
function ($job) use ($userA) {
return $userA->id === TestCase::getObjectProperty($job, 'userId');
}
);
- Queue::assertPushedWithChain(
- \App\Jobs\User\CreateJob::class,
- [
- \App\Jobs\User\VerifyJob::class,
- ]
- );
}
/**
* Tests for AliasesTrait::setAliases()
*/
public function testSetAliases(): void
{
Queue::fake();
Queue::assertNothingPushed();
$user = $this->getTestUser('UserAccountA@UserAccount.com');
$domain = $this->getTestDomain('UserAccount.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_HOSTED,
]);
$this->assertCount(0, $user->aliases->all());
$user->tenant->setSetting('pgp.enable', 1);
// Add an alias
$user->setAliases(['UserAlias1@UserAccount.com']);
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1);
Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1);
$user->tenant->setSetting('pgp.enable', 0);
$aliases = $user->aliases()->get();
$this->assertCount(1, $aliases);
$this->assertSame('useralias1@useraccount.com', $aliases[0]['alias']);
// Add another alias
$user->setAliases(['UserAlias1@UserAccount.com', 'UserAlias2@UserAccount.com']);
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 2);
Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1);
$aliases = $user->aliases()->orderBy('alias')->get();
$this->assertCount(2, $aliases);
$this->assertSame('useralias1@useraccount.com', $aliases[0]->alias);
$this->assertSame('useralias2@useraccount.com', $aliases[1]->alias);
$user->tenant->setSetting('pgp.enable', 1);
// Remove an alias
$user->setAliases(['UserAlias1@UserAccount.com']);
$user->tenant->setSetting('pgp.enable', 0);
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 3);
Queue::assertPushed(\App\Jobs\PGP\KeyDeleteJob::class, 1);
Queue::assertPushed(
\App\Jobs\PGP\KeyDeleteJob::class,
function ($job) use ($user) {
$userId = TestCase::getObjectProperty($job, 'userId');
$userEmail = TestCase::getObjectProperty($job, 'userEmail');
return $userId == $user->id && $userEmail === 'useralias2@useraccount.com';
}
);
$aliases = $user->aliases()->get();
$this->assertCount(1, $aliases);
$this->assertSame('useralias1@useraccount.com', $aliases[0]['alias']);
// Remove all aliases
$user->setAliases([]);
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 4);
$this->assertCount(0, $user->aliases()->get());
}
/**
* Tests for UserSettingsTrait::setSettings() and getSetting() and getSettings()
*/
public function testUserSettings(): void
{
Queue::fake();
Queue::assertNothingPushed();
$user = $this->getTestUser('UserAccountA@UserAccount.com');
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 0);
// Test default settings
// Note: Technicly this tests UserObserver::created() behavior
$all_settings = $user->settings()->orderBy('key')->get();
$this->assertCount(2, $all_settings);
$this->assertSame('country', $all_settings[0]->key);
$this->assertSame('CH', $all_settings[0]->value);
$this->assertSame('currency', $all_settings[1]->key);
$this->assertSame('CHF', $all_settings[1]->value);
// Add a setting
$user->setSetting('first_name', 'Firstname');
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1);
// Note: We test both current user as well as fresh user object
// to make sure cache works as expected
$this->assertSame('Firstname', $user->getSetting('first_name'));
$this->assertSame('Firstname', $user->fresh()->getSetting('first_name'));
// Update a setting
$user->setSetting('first_name', 'Firstname1');
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 2);
// Note: We test both current user as well as fresh user object
// to make sure cache works as expected
$this->assertSame('Firstname1', $user->getSetting('first_name'));
$this->assertSame('Firstname1', $user->fresh()->getSetting('first_name'));
// Delete a setting (null)
$user->setSetting('first_name', null);
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 3);
// Note: We test both current user as well as fresh user object
// to make sure cache works as expected
$this->assertSame(null, $user->getSetting('first_name'));
$this->assertSame(null, $user->fresh()->getSetting('first_name'));
// Delete a setting (empty string)
$user->setSetting('first_name', 'Firstname1');
$user->setSetting('first_name', '');
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 5);
// Note: We test both current user as well as fresh user object
// to make sure cache works as expected
$this->assertSame(null, $user->getSetting('first_name'));
$this->assertSame(null, $user->fresh()->getSetting('first_name'));
// Set multiple settings at once
$user->setSettings([
'first_name' => 'Firstname2',
'last_name' => 'Lastname2',
'country' => null,
]);
// TODO: This really should create a single UserUpdate job, not 3
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 7);
// Note: We test both current user as well as fresh user object
// to make sure cache works as expected
$this->assertSame('Firstname2', $user->getSetting('first_name'));
$this->assertSame('Firstname2', $user->fresh()->getSetting('first_name'));
$this->assertSame('Lastname2', $user->getSetting('last_name'));
$this->assertSame('Lastname2', $user->fresh()->getSetting('last_name'));
$this->assertSame(null, $user->getSetting('country'));
$this->assertSame(null, $user->fresh()->getSetting('country'));
$all_settings = $user->settings()->orderBy('key')->get();
$this->assertCount(3, $all_settings);
// Test getSettings() method
$this->assertSame(
[
'first_name' => 'Firstname2',
'last_name' => 'Lastname2',
'unknown' => null,
],
$user->getSettings(['first_name', 'last_name', 'unknown'])
);
}
/**
* Tests for User::users()
*/
public function testUsers(): void
{
$jack = $this->getTestUser('jack@kolab.org');
$joe = $this->getTestUser('joe@kolab.org');
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$wallet = $john->wallets()->first();
$users = $john->users()->orderBy('email')->get();
$this->assertCount(4, $users);
$this->assertEquals($jack->id, $users[0]->id);
$this->assertEquals($joe->id, $users[1]->id);
$this->assertEquals($john->id, $users[2]->id);
$this->assertEquals($ned->id, $users[3]->id);
$users = $jack->users()->orderBy('email')->get();
$this->assertCount(0, $users);
$users = $ned->users()->orderBy('email')->get();
$this->assertCount(4, $users);
}
/**
* Tests for User::walletOwner() (from EntitleableTrait)
*/
public function testWalletOwner(): void
{
$jack = $this->getTestUser('jack@kolab.org');
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$this->assertSame($john->id, $john->walletOwner()->id);
$this->assertSame($john->id, $jack->walletOwner()->id);
$this->assertSame($john->id, $ned->walletOwner()->id);
// User with no entitlements
$user = $this->getTestUser('UserAccountA@UserAccount.com');
$this->assertSame($user->id, $user->walletOwner()->id);
}
/**
* Tests for User::wallets()
*/
public function testWallets(): void
{
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$this->assertSame(1, $john->wallets()->count());
$this->assertCount(1, $john->wallets);
$this->assertInstanceOf(\App\Wallet::class, $john->wallets->first());
$this->assertSame(1, $ned->wallets()->count());
$this->assertCount(1, $ned->wallets);
$this->assertInstanceOf(\App\Wallet::class, $ned->wallets->first());
}
}

File Metadata

Mime Type
text/x-diff
Expires
Sat, Apr 4, 8:54 AM (2 w, 5 d ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18823352
Default Alt Text
(632 KB)

Event Timeline