diff --git a/src/app/Console/Command.php b/src/app/Console/Command.php --- a/src/app/Console/Command.php +++ b/src/app/Console/Command.php @@ -29,6 +29,31 @@ */ protected $dangerous = false; + + /** + * Shortcut to creating a progress bar of a particular format with a particular message. + * + * @param int $count Number of progress steps + * @param string $message The description + * + * @return \Symfony\Component\Console\Helper\ProgressBar + */ + protected function createProgressBar($count, $message = null) + { + $bar = $this->output->createProgressBar($count); + $bar->setFormat( + '%current:7s%/%max:7s% [%bar%] %percent:3s%% %elapsed:7s%/%estimated:-7s% %message% ' + ); + + if ($message) { + $bar->setMessage("{$message}..."); + } + + $bar->start(); + + return $bar; + } + /** * Find the domain. * diff --git a/src/app/Console/Commands/Data/Import/LdifCommand.php b/src/app/Console/Commands/Data/Import/LdifCommand.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/Data/Import/LdifCommand.php @@ -0,0 +1,904 @@ +bigIncrements('id'); + $table->text('dn')->index(); + $table->string('type')->nullable()->index(); + $table->text('data')->nullable(); + $table->text('error')->nullable(); + } + ); + + $this->loadFromFile(); + + // TODO: Check for errors, print them and abort? + + // Prepare packages/skus information + $this->preparePackagesAndSkus(); + + // Import the account owner first + $this->importOwner(); + + // Import domains first + $this->importDomains(); + + // Import other objects + $this->importUsers(); + $this->importSharedFolders(); + //$this->importResources(); + //$this->importGroups(); + } + + /** + * Load data from the LDIF file into the temp table + */ + protected function loadFromFile(): void + { + $file = $this->argument('file'); + + $numLines = \App\Utils::countLines($file); + + $bar = $this->createProgressBar($numLines, "Parsing input file"); + + $fh = fopen($file, 'r'); + + $inserts = []; + $entry = []; + $lastAttr = null; + + $insertFunc = function ($limit = 0) use (&$entry, &$inserts) { + if (!empty($entry)) { + if ($entry = $this->parseLDAPEntry($entry)) { + $inserts[] = $entry; + } + $entry = []; + } + + if (count($inserts) > $limit) { + DB::table(self::$table)->insert($inserts); + $inserts = []; + } + }; + + while (!feof($fh)) { + $line = rtrim(fgets($fh)); + + $bar->advance(); + + if (trim($line) === '' || $line[0] === '#') { + continue; + } + + if (substr($line, 0, 3) == 'dn:') { + $insertFunc(20); + $entry['dn'] = strtolower(substr($line, 4)); + $lastAttr = 'dn'; + } elseif (substr($line, 0, 1) == ' ') { + if (is_array($entry[$lastAttr])) { + $elemNum = count($entry[$lastAttr]) - 1; + $entry[$lastAttr][$elemNum] .= ltrim($line); + } else { + $entry[$lastAttr] .= ltrim($line); + } + } else { + list ($attr, $remainder) = explode(':', $line, 2); + $attr = strtolower($attr); + + if ($remainder[0] === ':') { + $remainder = base64_decode(substr($remainder, 2)); + } else { + $remainder = ltrim($remainder); + } + + if (array_key_exists($attr, $entry)) { + if (!is_array($entry[$attr])) { + $entry[$attr] = [$entry[$attr]]; + } + + $entry[$attr][] = $remainder; + } else { + $entry[$attr] = $remainder; + } + + $lastAttr = $attr; + } + } + + $insertFunc(); + + $bar->finish(); + + $this->info("DONE"); + } + + /** + * Import domains from the temp table + */ + protected function importDomains(): void + { + $domains = DB::table(self::$table)->where('type', 'domain')->whereNull('error')->get(); + + $bar = \App\Utils::createProgressBar($this->output, count($domains), "Importing domains"); + + foreach ($domains as $_domain) { + $bar->advance(); + + $data = json_decode($_domain->data); + + $domain = \App\Domain::withTrashed()->where('namespace', $data->namespace)->first(); + + if (!$domain) { + $domain = \App\Domain::create([ + 'namespace' => $data->namespace, + 'type' => \App\Domain::TYPE_EXTERNAL, + // status ??? + ]); + + // Entitlements + $domain->assignPackageAndWallet($this->packages['domain'], $this->wallet); + } + + // FIXME: We're importing domains as external, but I can imagine that + // using existing public domains could be useful, is it? + // FIXME: The same as with users and all other objects, should we support "update mode", + // i.e. not throw an error if the domain/user/whatever already exists? + // Should we 1. update existing objects, 2. skip them, 3. update them? + + if (!empty($data->aliases)) { + foreach ($data->aliases as $alias) { + $alias = strtolower($alias); + $domain = \App\Domain::withTrashed()->where('namespace', $alias)->first(); + + if (!$domain) { + $domain = \App\Domain::create([ + 'namespace' => $alias, + 'type' => \App\Domain::TYPE_EXTERNAL, + // status ??? + ]); + + // Entitlements + $domain->assignPackageAndWallet($this->packages['domain'], $this->wallet); + } + } + } + } + + $bar->finish(); + + $this->info("DONE"); + } + + /** + * Import groups from the temp table + */ + protected function importGroups(): void + { + $groups = DB::table(self::$table)->where('type', 'group')->whereNull('error')->get(); + + $bar = \App\Utils::createProgressBar($this->output, count($groups), "Importing groups"); + + foreach ($groups as $_group) { + $bar->advance(); + + $data = json_decode($_group->data); + + // Collect group member email addresses + // TODO: Make sure all members belong to the same account? + $members = $this->resolveUserDNs($data->members); + + if (empty($members)) { + $this->setImportError($_group->id, "Members resolve to an empty array"); + continue; + } + + $group = \App\Group::withTrashed()->where('email', $data->email)->first(); + + if (!$group) { + // TODO: Make sure the domain exists + + $group = \App\Group::create([ + 'name' => $data->name, + 'email' => $data->email, + 'members' => $members, + // status ??? + ]); + + $group->assignToWallet($this->wallet); + } else { + $group->setSetting('sender_policy', null); + } + + // Sender policy + if (!empty($data->sender_policy)) { + $group->setSetting('sender_policy', json_encode($data->sender_policy)); + } + } + + $bar->finish(); + + $this->info("DONE"); + } + + /** + * Import resources from the temp table + */ + protected function importResources(): void + { + $resources = DB::table(self::$table)->where('type', 'resource')->whereNull('error')->get(); + + $bar = \App\Utils::createProgressBar($this->output, count($resources), "Importing resources"); + + foreach ($resources as $_resource) { + $bar->advance(); + + $data = json_decode($_resource->data); + + // Resource invitation policy + if (!empty($data->invitation_policy) && $data->invitation_policy == 'manual') { + $members = $this->resolveUserDNs([$data->owner ?? '']); + + if (empty($members)) { + $this->setImportError($_resource->id, "Failed to resolve the resource owner"); + continue; + } + + // TODO: Make sure the owner belongs to the same account? + $data->invitation_policy = 'manual:' . $members[0]; + } + + $resource = \App\Resource::withTrashed()->where('email', $data->email)->first(); + + if (!$resource) { + // TODO: Make sure the domain exists + + $resource = \App\Resource::create([ + 'name' => $data->name, + 'email' => $data->email, + // status ??? + ]); + + $resource->assignToWallet($this->wallet); + } else { + $resource->setSetting('invitation_policy', null); + } + + // Invitation policy + if (!empty($data->invitation_policy)) { + $resource->setSetting('invitation_policy', $data->invitation_policy); + } + + // Target folder + // FIXME: Should we keep the old folder or use Kolab4's generated one + if (!empty($data->folder)) { + $resource->setSetting('folder', $data->folder); + } + } + + $bar->finish(); + + $this->info("DONE"); + } + + /** + * Import shared folders from the temp table + */ + protected function importSharedFolders(): void + { + $folders = DB::table(self::$table)->where('type', 'sharedFolder')->whereNull('error')->get(); + + $bar = \App\Utils::createProgressBar($this->output, count($folders), "Importing shared folders"); + + foreach ($folders as $_folder) { + $bar->advance(); + + $data = json_decode($_folder->data); + + $folder = \App\SharedFolder::withTrashed()->where('email', $data->email)->first(); + + if (!$folder) { + // TODO: Make sure the domain exists + + $folder = \App\SharedFolder::create([ + 'name' => $data->name, + // FIXME: Should we keep the old email or use Kolab4's generated one? + 'email' => $data->email ?? null, + 'type' => $data->type ?? 'mail', + // status ??? + ]); + + $folder->assignToWallet($this->wallet); + } else { + $folder->setSetting('acl', null); + } + + // Invitation policy + if (!empty($data->acl)) { + $folder->setSetting('acl', json_encode($data->acl)); + } + + // Target folder + // FIXME: Should we keep the old folder or use Kolab4's generated one? + if (!empty($data->folder)) { + $folder->setSetting('folder', $data->folder); + } + } + + $bar->finish(); + + $this->info("DONE"); + } + + /** + * Import users from the temp table + */ + protected function importUsers(): void + { + $users = DB::table(self::$table)->where('type', 'user')->whereNull('error'); + + // Skip the (already imported) account owner + if ($this->ownerDN) { + $users->whereNotIn('dn', [$this->ownerDN]); + } + + // Import aliases of the owner, we got from importOwner() call + if (!empty($this->aliases) && $this->wallet) { + $this->wallet->owner->setAliases($this->aliases); + } + + $users = $users->get(); + + $bar = \App\Utils::createProgressBar($this->output, count($users), "Importing users"); + + foreach ($users as $_user) { + $bar->advance(); + + $this->importSingleUser($_user); + } + + $bar->finish(); + + $this->info("DONE"); + } + + /** + * Import the account owner (or find it among the existing accounts) + */ + protected function importOwner(): void + { + if ($this->ownerDN) { + // The owner email found in the import data + $bar = \App\Utils::createProgressBar($this->output, 1, "Importing account owner"); + + $user = DB::table(self::$table)->where('dn', $this->ownerDN)->first(); + $user = $this->importSingleUser($user); + + $bar->advance(); + $bar->finish(); + + $this->info("DONE"); + } else { + // The owner email not found in the import data, try existsing users + $user = $this->getUser($this->argument('owner')); + + if (!$user) { + $this->error("Unable to find the specified account owner"); + exit(1); + } + } + + $this->wallet = $user->wallets->first(); + } + + /** + * A helper that imports a single user record + */ + protected function importSingleUser($ldap_user) + { + $data = json_decode($ldap_user->data); + + $user = \App\User::withTrashed()->where('email', $data->email)->first(); + + if (!$user) { + // TODO: Make sure the domain exists + + $user = \App\User::create([ + 'email' => $data->email, + // status ??? + ]); + + // Entitlements + $user->assignPackageAndWallet($this->packages['user'], $this->wallet ?: $user->wallets()->first()); + + if (!empty($data->quota)) { + $quota = ceil($data->quota / 1024 / 1024) - $this->packages['quota']; + if ($quota > 0) { + $user->assignSku($this->packages['storage'], $quota); + } + } + } else { + // FIXME: Should that be an error? I can imagine that executing an import + // multiple times might be useful, but on the other hand it is + // more complicated, so maybe we should just not support that? + + $user->settings()->delete(); + $user->aliases()->delete(); + + // TODO: update quota (storage entitlements)? + } + + // User settings + if (!empty($data->settings)) { + $settings = []; + foreach ($data->settings as $key => $value) { + $settings[] = [ + 'user_id' => $user->id, + 'key' => $key, + 'value' => $value, + ]; + } + + DB::table('user_settings')->insert($settings); + } + + // Update password + if ($data->password != $user->password_ldap) { + \App\User::where('id', $user->id)->update(['password_ldap' => $data->password]); + } + + // Import aliases + if (!empty($data->aliases)) { + // Some users might have alias entry with their main address, remove it + $aliases = array_map('strtolower', $data->aliases); + $aliases = array_diff(array_unique($aliases), [$user->email]); + + if (!empty($aliases)) { + // TODO: Make sure that domains exist + + if (!$this->wallet) { + // This is the account owner creation, at this point we likely do not have + // domain records yet, save the aliases to be inserted later (in importUsers()) + $this->aliases = $aliases; + } else { + $user->setAliases($aliases); + } + } + } + + return $user; + } + + /** + * Convert LDAP entry into an object supported by the migration tool + * + * @param array $entry LDAP entry attributes + * + * @param array Record data for inserting to the temp table + */ + protected function parseLDAPEntry(array $entry): array + { + $type = null; + $data = null; + $error = null; + + $ouTypeMap = [ + 'Shared Folders' => 'sharedfolder', + 'Resources' => 'resource', + 'Groups' => 'group', + 'People' => 'user', + 'Domains' => 'domain', + ]; + + foreach ($ouTypeMap as $ou => $_type) { + if (stripos($entry['dn'], ",ou={$ou}")) { + $type = $_type; + break; + } + } + + if (!$type) { + $error = "Unknown record type"; + } + + if (empty($error)) { + $method = 'parseLDAP' . ucfirst($type); + list($data, $error) = $this->{$method}($entry); + } + + return [ + 'dn' => $entry['dn'], + 'type' => $type, + 'data' => json_encode($data), + 'error' => $error, + ]; + } + + /** + * Convert LDAP domain data into Kolab4 "format" + */ + protected function parseLDAPDomain($entry) + { + $error = null; + $result = []; + + if (empty($entry['associateddomain'])) { + $error = "Missing 'associatedDomain' attribute"; + } elseif (!empty($entry['inetdomainstatus']) && $entry['inetdomainstatus'] == 'deleted') { + $error = "Domain deleted"; + } else { + $result['namespace'] = strtolower($this->attrStringValue($entry, 'associateddomain')); + + if (is_array($entry['associateddomain']) && count($entry['associateddomain']) > 1) { + $result['aliases'] = array_slice($entry['associateddomain'], 1); + } + + // TODO: inetdomainstatus = suspended ??? + } + + return [$result, $error]; + } + + /** + * Convert LDAP group data into Kolab4 "format" + */ + protected function parseLDAPGroup($entry) + { + $error = null; + $result = []; + + if (empty($entry['cn'])) { + $error = "Missing 'cn' attribute"; + } elseif (empty($entry['mail'])) { + $error = "Missing 'mail' attribute"; + } elseif (empty($entry['uniquemember'])) { + $error = "Missing 'uniqueMember' attribute"; + } else { + $result['name'] = $this->attrStringValue($entry, 'cn'); + $result['email'] = strtolower($this->attrStringValue($entry, 'mail')); + $result['members'] = $this->attrArrayValue($entry, 'uniquemember'); + + if (!empty($entry['kolaballowsmtpsender'])) { + $policy = $this->attrArrayValue($entry, 'kolaballowsmtpsender'); + $result['sender_policy'] = $this->parseSenderPolicy($policy); + } + } + + return [$result, $error]; + } + + /** + * Convert LDAP resource data into Kolab4 "format" + */ + protected function parseLDAPResource($entry) + { + $error = null; + $result = []; + + if (empty($entry['cn'])) { + $error = "Missing 'cn' attribute"; + } elseif (empty($entry['mail'])) { + $error = "Missing 'mail' attribute"; + } else { + $result['name'] = $this->attrStringValue($entry, 'cn'); + $result['email'] = strtolower($this->attrStringValue($entry, 'mail')); + + if (!empty($entry['kolabfoldertype'])) { + $result['type'] = $this->attrStringValue($entry, 'kolabfoldertype'); + } + + if (!empty($entry['kolabtargetfolder'])) { + $result['folder'] = $this->attrStringValue($entry, 'kolabtargetfolder'); + } + + if (!empty($entry['owner'])) { + $result['owner'] = $this->attrStringValue($entry, 'owner'); + } + + if (!empty($entry['kolabinvitationpolicy'])) { + $policy = $this->attrArrayValue($entry, 'kolabinvitationpolicy'); + $result['invitation_policy'] = $this->parseInvitationPolicy($policy); + } + } + + return [$result, $error]; + } + + /** + * Convert LDAP shared folder data into Kolab4 "format" + */ + protected function parseLDAPSharedFolder($entry) + { + $error = null; + $result = []; + + if (empty($entry['cn'])) { + $error = "Missing 'cn' attribute"; + } elseif (empty($entry['mail'])) { + $error = "Missing 'mail' attribute"; + } else { + $result['name'] = $this->attrStringValue($entry, 'cn'); + $result['email'] = strtolower($this->attrStringValue($entry, 'mail')); + + if (!empty($entry['kolabfoldertype'])) { + $result['type'] = $this->attrStringValue($entry, 'kolabfoldertype'); + } + + if (!empty($entry['kolabtargetfolder'])) { + $result['folder'] = $this->attrStringValue($entry, 'kolabtargetfolder'); + } + + if (!empty($entry['acl'])) { + $result['acl'] = $this->parseACL($this->attrArrayValue($entry, 'acl')); + } + } + + return [$result, $error]; + } + + /** + * Convert LDAP user data into Kolab4 "format" + */ + protected function parseLDAPUser($entry) + { + $error = null; + $result = []; + + $settingAttrs = [ + 'givenname' => 'first_name', + 'sn' => 'last_name', + 'telephonenumber' => 'phone', + 'mailalternateaddress' => 'external_email', + 'mobile' => 'phone', + 'o' => 'organization', + // 'address' => 'billing_address' + ]; + + if (empty($entry['mail'])) { + $error = "Missing 'mail' attribute"; + } else { + $result['email'] = strtolower($this->attrStringValue($entry, 'mail')); + $result['settings'] = []; + $result['aliases'] = []; + + foreach ($settingAttrs as $attr => $setting) { + if (!empty($entry[$attr])) { + $result['settings'][$setting] = $this->attrStringValue($entry, $attr); + } + } + + if (!empty($entry['alias'])) { + $result['aliases'] = $this->attrArrayValue($entry, 'alias'); + } + + if (!empty($entry['userpassword'])) { + $result['password'] = $this->attrStringValue($entry, 'userpassword'); + } + + if (!empty($entry['mailquota'])) { + $result['quota'] = $this->attrStringValue($entry, 'mailquota'); + } + + if ($result['email'] == $this->argument('owner')) { + $this->ownerDN = $entry['dn']; + } + } + + return [$result, $error]; + } + + /** + * Convert ldap attribute value to an array + */ + protected static function attrArrayValue($entry, $attribute) + { + return is_array($entry[$attribute]) ? $entry[$attribute] : [$entry[$attribute]]; + } + + /** + * Convert ldap attribute to a string + */ + protected static function attrStringValue($entry, $attribute) + { + return is_array($entry[$attribute]) ? $entry[$attribute][0] : $entry[$attribute]; + } + + /** + * Resolve a list of user DNs into email addresses. Makes sure + * the returned addresses exist in Kolab4 database. + */ + protected static function resolveUserDNs($user_dns): array + { + // Get email addresses from the import data + $users = DB::table(self::$table)->whereIn('dn', $user_dns) + ->where('type', 'user') + ->whereNull('error') + ->get() + ->map(function ($user) { + $mdata = json_decode($user->data); + return $mdata->email; + }) + ->all(); + + // Get email addresses for existing Kolab4 users + if (!empty($users)) { + $users = \App\User::whereIn('email', $users)->get()->pluck('email')->all(); + } + + return $users; + } + + /** + * Validate/convert acl to Kolab4 format + */ + protected static function parseACL(array $acl): array + { + $map = [ + 'lrswipkxtecdn' => 'full', + 'lrs' => 'read-only', + 'read' => 'read-only', + 'lrswitedn' => 'read-write', + ]; + + $supportedRights = ['full', 'read-only', 'read-write']; + + foreach ($acl as $idx => $entry) { + $parts = explode(',', $entry); + $entry = null; + + if (count($parts) == 2) { + $label = trim($parts[0]); + $rights = trim($parts[1]); + $rights = $map[$rights] ?? $rights; + + if (in_array($rights, $supportedRights) && ($label === 'anyone' || strpos($label, '@'))) { + $entry = "{$label}, {$rights}"; + } + + // TODO: Throw an error or log a warning on unsupported acl entry? + } + + $acl[$idx] = $entry; + } + + return array_values(array_filter($acl)); + } + + /** + * Validate/convert invitation policy to Kolab4 format + */ + protected static function parseInvitationPolicy(array $policies): ?string + { + foreach ($policies as $policy) { + if ($policy == 'ACT_MANUAL') { + // 'owner' attribute handling in another place + return 'manual'; + } + + if ($policy == 'ACT_ACCEPT_AND_NOTIFY') { + break; // use the default 'accept' (null) policy + } + + if ($policy == 'ACT_REJECT') { + return 'reject'; + } + } + + return null; + } + + /** + * Validate/convert sender policy to Kolab4 format + */ + protected static function parseSenderPolicy(array $rules): array + { + foreach ($rules as $idx => $rule) { + $entry = trim($rule); + $rule = null; + + // 'deny' rules aren't supported + if (isset($entry[0]) && $entry[0] !== '-') { + $rule = $entry; + } + + $rules[$idx] = $rule; + } + + $rules = array_values(array_filter($rules)); + + if (!empty($rules) && $rules[count($rules) - 1] != '-') { + $rules[] = '-'; + } + + return $rules; + } + + /** + * Get/prepare packages/skus information + */ + protected function preparePackagesAndSkus(): void + { + // Find the tenant + if (empty($this->ownerDN)) { + if ($user = $this->getUser($this->argument('owner'))) { + $tenant_id = $user->tenant_id; + } + } + + // TODO: Tenant id could be a command option + + if (empty($tenant_id)) { + $tenant_id = \config('app.tenant_id'); + } + + // TODO: We should probably make package titles configurable with command options + + $this->packages = [ + 'user' => \App\Package::where('title', 'kolab')->where('tenant_id', $tenant_id)->first(), + 'domain' => \App\Package::where('title', 'domain-hosting')->where('tenant_id', $tenant_id)->first(), + ]; + + // Count storage skus + $sku = $this->packages['user']->skus()->where('title', 'storage')->first(); + + $this->packages['quota'] = $sku ? $sku->pivot->qty : 0; + $this->packages['storage'] = \App\Sku::where('title', 'storage')->where('tenant_id', $tenant_id)->first(); + } + + /** + * Set error message for specified import data record + */ + protected static function setImportError($id, $error): void + { + DB::table(self::$table)->where('id', $id)->update(['error' => $error]); + } +} diff --git a/src/app/User.php b/src/app/User.php --- a/src/app/User.php +++ b/src/app/User.php @@ -24,6 +24,7 @@ * @property string $email * @property int $id * @property string $password + * @property string $password_ldap * @property int $status * @property int $tenant_id */ diff --git a/src/tests/Feature/Console/Data/Import/LdifTest.php b/src/tests/Feature/Console/Data/Import/LdifTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Console/Data/Import/LdifTest.php @@ -0,0 +1,199 @@ +deleteTestUser('owner@kolab3.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('owner@kolab3.com'); + + parent::tearDown(); + } + + /** + * Test the command + */ + public function testHandle(): void + { + $code = \Artisan::call("data:import:ldif tests/data/kolab3.ldif owner@kolab3.com"); + + $this->assertSame(0, $code); + + $owner = \App\User::where('email', 'owner@kolab3.com')->first(); + + $this->assertNull($owner->password); + $this->assertSame( + '{SSHA512}g74+SECTLsM1x0aYkSrTG9sOFzEp8wjCflhshr2DjE7mi1G3iNb4ClH3ljorPRlTgZ105PsQGEpNtNr+XRjigg==', + $owner->password_ldap + ); + + // User settings + $this->assertSame('Aleksander', $owner->getSetting('first_name')); + $this->assertSame('Machniak', $owner->getSetting('last_name')); + $this->assertSame('123456789', $owner->getSetting('phone')); + $this->assertSame('external@gmail.com', $owner->getSetting('external_email')); + $this->assertSame('Organization AG', $owner->getSetting('organization')); + + // User aliases + $aliases = $owner->aliases()->orderBy('alias')->pluck('alias')->all(); + $this->assertSame(['alias@kolab3-alias.com', 'alias@kolab3.com'], $aliases); + + // Wallet, entitlements + $wallet = $owner->wallets->first(); + + $this->assertEntitlements($owner, [ + 'groupware', + 'mailbox', + 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', + ]); + + // Users + $this->assertSame(2, $owner->users(false)->count()); + $user = $owner->users(false)->where('email', 'user@kolab3.com')->first(); + + // User settings + $this->assertSame('Jane', $user->getSetting('first_name')); + $this->assertSame('Doe', $user->getSetting('last_name')); + $this->assertSame('1234567890', $user->getSetting('phone')); + $this->assertSame('ext@gmail.com', $user->getSetting('external_email')); + $this->assertSame('Org AG', $user->getSetting('organization')); + + // User aliases + $aliases = $user->aliases()->orderBy('alias')->pluck('alias')->all(); + $this->assertSame(['alias2@kolab3.com'], $aliases); + + $this->assertEntitlements($user, [ + 'groupware', + 'mailbox', + 'storage', 'storage', 'storage', 'storage', 'storage', + ]); + + // Domains + $domains = $owner->domains(false, false)->orderBy('namespace')->get(); + + $this->assertCount(2, $domains); + $this->assertSame('kolab3-alias.com', $domains[0]->namespace); + $this->assertSame('kolab3.com', $domains[1]->namespace); + $this->assertSame(\App\Domain::TYPE_EXTERNAL, $domains[0]->type); + $this->assertSame(\App\Domain::TYPE_EXTERNAL, $domains[1]->type); + + $this->assertEntitlements($domains[0], ['domain-hosting']); + $this->assertEntitlements($domains[1], ['domain-hosting']); + + // Shared folders + $folders = $owner->sharedFolders(false)->orderBy('email')->get(); + + $this->assertCount(2, $folders); + $this->assertSame('folder1@kolab3.com', $folders[0]->email); + $this->assertSame('folder2@kolab3.com', $folders[1]->email); + $this->assertSame('Folder1', $folders[0]->name); + $this->assertSame('Folder2', $folders[1]->name); + $this->assertSame('mail', $folders[0]->type); + $this->assertSame('event', $folders[1]->type); + $this->assertSame('["anyone, read-write","owner@kolab3.com, full"]', $folders[0]->getSetting('acl')); + $this->assertSame('shared/Folder1@kolab3.com', $folders[0]->getSetting('folder')); + $this->assertSame('["anyone, read-only"]', $folders[1]->getSetting('acl')); + $this->assertSame('shared/Folder2@kolab3.com', $folders[1]->getSetting('folder')); + + // TODO: Groups, Resources + + // TODO: Error handling + } + + /** + * Test parseACL() method + */ + public function testParseACL(): void + { + $command = new \App\Console\Commands\Data\Import\LdifCommand(); + + $result = $this->invokeMethod($command, 'parseACL', [[]]); + $this->assertSame([], $result); + + $acl = [ + 'anyone, read-write', + 'read-only@kolab3.com, read-only', + 'read-only@kolab3.com, read', + 'full@kolab3.com,full', + 'lrswipkxtecdn@kolab3.com, lrswipkxtecdn', // full + 'lrs@kolab3.com, lrs', // read-only + 'lrswitedn@kolab3.com, lrswitedn', // read-write + // unsupported: + 'anonymous, read-only', + 'group:test, lrs', + 'test@kolab3.com, lrspkxtdn', + ]; + + $expected = [ + 'anyone, read-write', + 'read-only@kolab3.com, read-only', + 'read-only@kolab3.com, read-only', + 'full@kolab3.com, full', + 'lrswipkxtecdn@kolab3.com, full', + 'lrs@kolab3.com, read-only', + 'lrswitedn@kolab3.com, read-write', + ]; + + $result = $this->invokeMethod($command, 'parseACL', [$acl]); + $this->assertSame($expected, $result); + } + + /** + * Test parseInvitationPolicy() method + */ + public function testParseInvitationPolicy(): void + { + $command = new \App\Console\Commands\Data\Import\LdifCommand(); + + $result = $this->invokeMethod($command, 'parseInvitationPolicy', [[]]); + $this->assertSame(null, $result); + + $result = $this->invokeMethod($command, 'parseInvitationPolicy', [['UNKNOWN']]); + $this->assertSame(null, $result); + + $result = $this->invokeMethod($command, 'parseInvitationPolicy', [['ACT_ACCEPT']]); + $this->assertSame(null, $result); + + $result = $this->invokeMethod($command, 'parseInvitationPolicy', [['ACT_MANUAL']]); + $this->assertSame('manual', $result); + + $result = $this->invokeMethod($command, 'parseInvitationPolicy', [['ACT_REJECT']]); + $this->assertSame('reject', $result); + + $result = $this->invokeMethod($command, 'parseInvitationPolicy', [['ACT_ACCEPT_AND_NOTIFY', 'ACT_REJECT']]); + $this->assertSame(null, $result); + } + + /** + * Test parseSenderPolicy() method + */ + public function testParseSenderPolicy(): void + { + $command = new \App\Console\Commands\Data\Import\LdifCommand(); + + $result = $this->invokeMethod($command, 'parseSenderPolicy', [[]]); + $this->assertSame([], $result); + + $result = $this->invokeMethod($command, 'parseSenderPolicy', [['test']]); + $this->assertSame(['test', '-'], $result); + + $result = $this->invokeMethod($command, 'parseSenderPolicy', [['test', '-test2', 'test3', '']]); + $this->assertSame(['test', 'test3', '-'], $result); + } +} diff --git a/src/tests/data/kolab3.ldif b/src/tests/data/kolab3.ldif new file mode 100644 --- /dev/null +++ b/src/tests/data/kolab3.ldif @@ -0,0 +1,102 @@ +dn: associateddomain=kolab3.com,ou=Domains,dc=hosted,dc=com +objectClass: top +objectClass: domainrelatedobject +objectClass: inetdomain +inetDomainBaseDN: ou=kolab3.com,dc=hosted,dc=com +associatedDomain: kolab3.com +associatedDomain: kolab3-alias.com + +dn: uid=owner@kolab3.com,ou=People,ou=kolab3.com,dc=hosted,dc=com +cn: Aleksander Machniak +displayName: Machniak, Aleksander +givenName: Aleksander +sn: Machniak +o: Organization AG +l:: U2llbWlhbm93aWNlIMWabMSFc2tpZQ== +mobile: 123456789 +c: PL +objectClass: top +objectClass: inetorgperson +objectClass: kolabinetorgperson +objectClass: organizationalperson +objectClass: mailrecipient +objectClass: country +objectClass: person +mail: owner@kolab3.com +alias: alias@kolab3.com +alias: alias@kolab3-alias.com +mailAlternateAddress: external@gmail.com +mailHost: imap.hosted.com +mailQuota: 8388608 +uid: owner@kolab3.com +userPassword:: e1NTSEE1MTJ9Zzc0K1NFQ1RMc00xeDBhWWtTclRHOXNPRnpFcDh3akNmbGhzaHIyRGpFN21pMUczaU5iNENsSDNsam9yUFJsVGdaMTA1UHNRR0VwTnROcitYUmppZ2c9PQ== +nsUniqueID: 229dc10c-1b6a11f7-b7c1edc1-0e0f46c4 +createtimestamp: 20170407081419Z +modifytimestamp: 20200915082359Z + +dn: uid=user@kolab3.com,ou=People,ou=kolab3.com,dc=hosted,dc=com +cn: Jane Doe +displayName: Doe, Jane +givenName: Jane +sn: Doe +o: Org AG +telephoneNumber: 1234567890 +objectClass: top +objectClass: inetorgperson +objectClass: kolabinetorgperson +objectClass: organizationalperson +objectClass: mailrecipient +objectClass: country +objectClass: person +mail: user@kolab3.com +alias: alias2@kolab3.com +mailAlternateAddress: ext@gmail.com +mailHost: imap.hosted.com +mailQuota: 2097152 +uid: user@kolab3.com +userPassword:: e1NTSEE1MTJ9Zzc0K1NFQ1RMc00xeDBhWWtTclRHOXNPRnpFcDh3akNmbGhzaHIyRGpFN21pMUczaU5iNENsSDNsam9yUFJsVGdaMTA1UHNRR0VwTnROcitYUmppZ2c9PQ== +nsUniqueID: 229dc20c-1b6a11f7-b7c1edc1-0e0f46c4 + +dn: cn=Group,ou=Groups,ou=kolab3.com,dc=hosted,dc=com +cn: Group +mail: group@kolab3.com +objectClass: top +objectClass: groupofuniquenames +objectClass: kolabgroupofuniquenames +uniqueMember: uid=user@kolab3.com,ou=People,ou=kolab3.com,dc=hosted,dc=com +kolabAllowSMTPRecipient: recipient@kolab.org +kolabAllowSMTPSender: sender@gmail.com + +dn: cn=Resource,ou=Resources,ou=kolab3.com,dc=hosted,dc=com +cn: Resource +mail: resource-car-resource@kolab3.com +objectClass: top +objectClass: kolabsharedfolder +objectClass: kolabresource +objectClass: mailrecipient +owner: uid=user@kolab3.com,ou=People,ou=kolab3.com,dc=hosted,dc=com +kolabAllowSMTPRecipient: recipient@kolab.org +kolabAllowSMTPSender: sender@gmail.com +kolabInvitationPolicy: ACT_MANUAL +kolabTargetFolder: shared/Resource@kolab3.com + +dn: cn=Folder1,ou=Shared Folders,ou=kolab3.com,dc=hosted,dc=com +cn: Folder1 +objectClass: kolabsharedfolder +objectClass: mailrecipient +objectClass: top +kolabFolderType: mail +kolabTargetFolder: shared/Folder1@kolab3.com +mail: folder1@kolab3.com +acl: anyone, read-write +acl: owner@kolab3.com, full + +dn: cn=Folder2,ou=Shared Folders,ou=kolab3.com,dc=hosted,dc=com +cn: Folder2 +objectClass: kolabsharedfolder +objectClass: mailrecipient +objectClass: top +kolabFolderType: event +kolabTargetFolder: shared/Folder2@kolab3.com +mail: folder2@kolab3.com +acl: anyone, read-only