diff --git a/src/app/Backends/IMAP.php b/src/app/Backends/IMAP.php --- a/src/app/Backends/IMAP.php +++ b/src/app/Backends/IMAP.php @@ -21,13 +21,50 @@ $folders = $imap->listMailboxes('', '*'); + $imap->closeConnection(); + if (!is_array($folders)) { throw new \Exception("Failed to get IMAP folders"); } + return count($folders) > 0; + } + + /** + * Check if a shared folder is set up. + * + * @param string $folder Folder name, eg. 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/Resources/)(.*)(@[^@]+)$|', $folder, $matches)) { + $folderName = \mb_convert_encoding($matches[2], 'UTF7-IMAP', 'UTF8'); + $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(); - return count($folders) > 0; + // 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; } /** diff --git a/src/app/Backends/LDAP.php b/src/app/Backends/LDAP.php --- a/src/app/Backends/LDAP.php +++ b/src/app/Backends/LDAP.php @@ -4,6 +4,7 @@ use App\Domain; use App\Group; +use App\Resource; use App\User; class LDAP @@ -13,6 +14,12 @@ 'sender_policy', ]; + /** @const array Resource settings used by the backend */ + public const RESOURCE_SETTINGS = [ + 'folder', + 'invitation_policy', + ]; + /** @const array User settings used by the backend */ public const USER_SETTINGS = [ 'first_name', @@ -248,6 +255,47 @@ } } + /** + * Create a resource in LDAP. + * + * @param \App\Resource $resource The resource to create. + * + * @throws \Exception + */ + public static function createResource(Resource $resource): void + { + $config = self::getConfig('admin'); + $ldap = self::initLDAP($config); + + $domainName = explode('@', $resource->email, 2)[1]; + $cn = $ldap->quote_string($resource->name); + $dn = "cn={$cn}," . self::baseDN($domainName, 'Resources'); + + $entry = [ + 'mail' => $resource->email, + 'objectclass' => [ + 'top', + 'kolabresource', + 'kolabsharedfolder', + 'mailrecipient', + ], + 'kolabfoldertype' => 'event', + ]; + + self::setResourceAttributes($ldap, $resource, $entry); + + self::addEntry( + $ldap, + $dn, + $entry, + "Failed to create resource {$resource->email} in LDAP (" . __LINE__ . ")" + ); + + if (empty(self::$ldap)) { + $ldap->close(); + } + } + /** * Create a user in LDAP. * @@ -380,6 +428,34 @@ } } + /** + * Delete a resource from LDAP. + * + * @param \App\Resource $resource The resource to delete. + * + * @throws \Exception + */ + public static function deleteResource(Resource $resource): void + { + $config = self::getConfig('admin'); + $ldap = self::initLDAP($config); + + if (self::getResourceEntry($ldap, $resource->email, $dn)) { + $result = $ldap->delete_entry($dn); + + if (!$result) { + self::throwException( + $ldap, + "Failed to delete resource {$resource->email} from LDAP (" . __LINE__ . ")" + ); + } + } + + if (empty(self::$ldap)) { + $ldap->close(); + } + } + /** * Delete a user from LDAP. * @@ -456,6 +532,28 @@ return $group; } + /** + * Get a resource data from LDAP. + * + * @param string $email The resource email. + * + * @return array|false|null + * @throws \Exception + */ + public static function getResource(string $email) + { + $config = self::getConfig('admin'); + $ldap = self::initLDAP($config); + + $resource = self::getResourceEntry($ldap, $email, $dn); + + if (empty(self::$ldap)) { + $ldap->close(); + } + + return $resource; + } + /** * Get a user data from LDAP. * @@ -559,6 +657,43 @@ } } + /** + * Update a resource in LDAP. + * + * @param \App\Resource $resource Theresource to update + * + * @throws \Exception + */ + public static function updateResource(Resource $resource): void + { + $config = self::getConfig('admin'); + $ldap = self::initLDAP($config); + + $newEntry = $oldEntry = self::getResourceEntry($ldap, $resource->email, $dn); + + if (empty($oldEntry)) { + self::throwException( + $ldap, + "Failed to update group {$resource->email} in LDAP (resource not found)" + ); + } + + self::setResourceAttributes($ldap, $resource, $newEntry); + + $result = $ldap->modify_entry($dn, $oldEntry, $newEntry); + + if (!is_array($result)) { + self::throwException( + $ldap, + "Failed to update resource {$resource->email} in LDAP (" . __LINE__ . ")" + ); + } + + if (empty(self::$ldap)) { + $ldap->close(); + } + } + /** * Update a user in LDAP. * @@ -704,6 +839,53 @@ } } + /** + * Set common resource attributes + */ + private static function setResourceAttributes($ldap, Resource $resource, &$entry) + { + $entry['cn'] = $resource->name; + $entry['owner'] = null; + $entry['kolabinvitationpolicy'] = null; + + $settings = $resource->getSettings(['invitation_policy', 'folder']); + + $entry['kolabtargetfolder'] = $settings['folder'] ?? ''; + + // Here's how Wallace's resources module works: + // - if policy is ACT_MANUAL and owner mail specified: a tentative response is sent, event saved, + // and mail sent to the owner to accept/decline the request. + // - if policy is ACT_ACCEPT_AND_NOTIFY and owner mail specified: an accept response is sent, + // event saved, and notification (not confirmation) mail sent to the owner. + // - if there's no owner (policy irrelevant): an accept response is sent, event saved. + // - if policy is ACT_REJECT: a decline response is sent + // - note that the notification email is being send if COND_NOTIFY policy is set or saving failed. + // - all above assume there's no conflict, if there's a conflict the decline response is sent automatically + // (notification is sent if policy = ACT_ACCEPT_AND_NOTIFY). + // - the only supported policies are: 'ACT_MANUAL', 'ACT_ACCEPT' (defined but not used anywhere), + // 'ACT_REJECT', 'ACT_ACCEPT_AND_NOTIFY'. + + // For now we ignore the notifications feature + + if (!empty($settings['invitation_policy'])) { + if ($settings['invitation_policy'] === 'accept') { + $entry['kolabinvitationpolicy'] = 'ACT_ACCEPT'; + } elseif ($settings['invitation_policy'] === 'reject') { + $entry['kolabinvitationpolicy'] = 'ACT_REJECT'; + } elseif (preg_match('/^manual:(\S+@\S+)$/', $settings['invitation_policy'], $m)) { + if (self::getUserEntry($ldap, $m[1], $userDN)) { + $entry['owner'] = $userDN; + $entry['kolabinvitationpolicy'] = 'ACT_MANUAL'; + } else { + $entry['kolabinvitationpolicy'] = 'ACT_ACCEPT'; + } + + // TODO: Set folder ACL so the owner can write to it + // TODO: Do we need to add lrs for anyone? + } + } + } + /** * Set common user attributes */ @@ -807,7 +989,7 @@ * @param string $email Group email (mail) * @param string $dn Reference to group DN * - * @return false|null|array Group entry, False on error, NULL if not found + * @return null|array Group entry, False on error, NULL if not found */ private static function getGroupEntry($ldap, $email, &$dn = null) { @@ -819,18 +1001,30 @@ // For groups we're using search() instead of get_entry() because // a group name is not constant, so e.g. on update we might have // the new name, but not the old one. Email address is constant. - $result = $ldap->search($base_dn, "(mail=$email)", "sub", $attrs); + return self::searchEntry($ldap, $base_dn, "(mail=$email)", $attrs, $dn); + } - if ($result && $result->count() == 1) { - $entries = $result->entries(true); - $dn = key($entries); - $entry = $entries[$dn]; - $entry['dn'] = $dn; + /** + * Get a resource entry from LDAP. + * + * @param \Net_LDAP3 $ldap Ldap connection + * @param string $email Resource email (mail) + * @param string $dn Reference to the resource DN + * + * @return null|array Resource entry, NULL if not found + */ + private static function getResourceEntry($ldap, $email, &$dn = null) + { + $domainName = explode('@', $email, 2)[1]; + $base_dn = self::baseDN($domainName, 'Resources'); - return $entry; - } + $attrs = ['dn', 'cn', 'mail', 'objectclass', 'kolabtargetfolder', + 'kolabfoldertype', 'kolabinvitationpolicy', 'owner']; - return null; + // For resources we're using search() instead of get_entry() because + // a resource name is not constant, so e.g. on update we might have + // the new name, but not the old one. Email address is constant. + return self::searchEntry($ldap, $base_dn, "(mail=$email)", $attrs, $dn); } /** @@ -950,6 +1144,33 @@ } } + /** + * Find a single entry in LDAP by using search. + * + * @param \Net_LDAP3 $ldap Ldap connection + * @param string $base_dn Base DN + * @param string $filter Search filter + * @param array $attrs Result attributes + * @param string $dn Reference to a DN of the found entry + * + * @return null|array LDAP entry, NULL if not found + */ + private static function searchEntry($ldap, $base_dn, $filter, $attrs, &$dn = null) + { + $result = $ldap->search($base_dn, $filter, 'sub', $attrs); + + if ($result && $result->count() == 1) { + $entries = $result->entries(true); + $dn = key($entries); + $entry = $entries[$dn]; + $entry['dn'] = $dn; + + return $entry; + } + + return null; + } + /** * Throw exception and close the connection when needed * 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 @@ -107,6 +107,7 @@ \App\Group::class, \App\Package::class, \App\Plan::class, + \App\Resource::class, \App\Sku::class, \App\User::class, ]; @@ -132,6 +133,19 @@ return $model; } + /** + * Find a resource. + * + * @param string $resource Resource ID or email + * @param bool $withDeleted Include deleted + * + * @return \App\Resource|null + */ + public function getResource($resource, $withDeleted = false) + { + return $this->getObject(\App\Resource::class, $resource, 'email', $withDeleted); + } + /** * Find the user. * diff --git a/src/app/Console/Commands/Resource/VerifyCommand.php b/src/app/Console/Commands/Resource/VerifyCommand.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/Resource/VerifyCommand.php @@ -0,0 +1,42 @@ +getResource($this->argument('resource')); + + if (!$resource) { + $this->error("Resource not found."); + return 1; + } + + $job = new \App\Jobs\Resource\VerifyJob($resource->id); + $job->handle(); + + // TODO: We should check the job result and print an error on failure + } +} diff --git a/src/app/Console/Commands/ResourcesCommand.php b/src/app/Console/Commands/ResourcesCommand.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/ResourcesCommand.php @@ -0,0 +1,12 @@ +wallet()->entitlements() + ->where('entitleable_type', \App\Domain::class)->count() > 0; + } + + return false; + } + + /** + * The priority that specifies the order of SKUs in UI. + * Higher number means higher on the list. + * + * @return int + */ + public static function priority(): int + { + return 10; + } +} diff --git a/src/app/Handlers/Resource.php b/src/app/Handlers/Resource.php --- a/src/app/Handlers/Resource.php +++ b/src/app/Handlers/Resource.php @@ -11,7 +11,6 @@ */ public static function entitleableClass(): string { - // TODO - return ''; + return \App\Resource::class; } } diff --git a/src/app/Http/Controllers/API/V4/DomainsController.php b/src/app/Http/Controllers/API/V4/DomainsController.php --- a/src/app/Http/Controllers/API/V4/DomainsController.php +++ b/src/app/Http/Controllers/API/V4/DomainsController.php @@ -13,7 +13,7 @@ class DomainsController extends Controller { /** @var array Common object properties in the API response */ - protected static $objectProps = ['namespace', 'status', 'type']; + protected static $objectProps = ['namespace', 'type']; /** diff --git a/src/app/Http/Controllers/API/V4/GroupsController.php b/src/app/Http/Controllers/API/V4/GroupsController.php --- a/src/app/Http/Controllers/API/V4/GroupsController.php +++ b/src/app/Http/Controllers/API/V4/GroupsController.php @@ -14,7 +14,7 @@ class GroupsController extends Controller { /** @var array Common object properties in the API response */ - protected static $objectProps = ['email', 'name', 'status']; + protected static $objectProps = ['email', 'name']; /** diff --git a/src/app/Http/Controllers/API/V4/ResourcesController.php b/src/app/Http/Controllers/API/V4/ResourcesController.php new file mode 100644 --- /dev/null +++ b/src/app/Http/Controllers/API/V4/ResourcesController.php @@ -0,0 +1,353 @@ +errorResponse(404); + } + + /** + * Delete a resource. + * + * @param int $id Resource identifier + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function destroy($id) + { + $resource = Resource::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.resource-delete-success'), + ]); + } + + /** + * Show the form for editing the specified resource. + * + * @param int $id Resource identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function edit($id) + { + return $this->errorResponse(404); + } + + /** + * 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(); + + $result = $user->resources()->orderBy('name')->get() + ->map(function (Resource $resource) { + return $this->objectToClient($resource); + }); + + return response()->json($result); + } + + /** + * Set the resource configuration. + * + * @param int $id Resource identifier + * + * @return \Illuminate\Http\JsonResponse|void + */ + public function setConfig($id) + { + $resource = Resource::find($id); + + 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.resource-setconfig-success'), + ]); + } + + /** + * Display information of a resource specified by $id. + * + * @param int $id Resource identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function show($id) + { + $resource = Resource::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); + + $response['statusInfo'] = self::statusInfo($resource); + + // Group configuration, e.g. invitation_policy + $response['config'] = $resource->getConfig(); + + return response()->json($response); + } + + /** + * Fetch resource status (and reload setup process) + * + * @param int $id Resource identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function status($id) + { + $resource = Resource::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, self::objectState($resource)); + + return response()->json($response); + } + + /** + * Resource status (extended) information + * + * @param \App\Resource $resource Resource object + * + * @return array Status information + */ + public static function statusInfo(Resource $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()) { + $errors = $v->errors()->toArray(); + return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); + } + + DB::beginTransaction(); + + // Create the resource + $resource = new Resource(); + $resource->name = request()->input('name'); + $resource->domain = $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); + } + + $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(); + } + } catch (\Exception $e) { + \Log::error($e); + } + + return false; + } + + /** + * Prepare resource statuses for the UI + * + * @param \App\Resource $resource Resource object + * + * @return array Statuses array + */ + protected static function objectState(Resource $resource): array + { + return [ + 'isLdapReady' => $resource->isLdapReady(), + 'isImapReady' => $resource->isImapReady(), + 'isActive' => $resource->isActive(), + 'isDeleted' => $resource->isDeleted() || $resource->trashed(), + ]; + } +} diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php --- a/src/app/Http/Controllers/API/V4/UsersController.php +++ b/src/app/Http/Controllers/API/V4/UsersController.php @@ -37,7 +37,7 @@ protected $deleteBeforeCreate; /** @var array Common object properties in the API response */ - protected static $objectProps = ['email', 'status']; + protected static $objectProps = ['email']; /** @@ -252,6 +252,8 @@ 'enableDomains' => $isController && $hasCustomDomain, // TODO: Make 'enableDistlists' working for wallet controllers that aren't account owners 'enableDistlists' => $isController && $hasCustomDomain && in_array('distlist', $skus), + // TODO: Make 'enableResources' working for wallet controllers that aren't account owners + 'enableResources' => $isController && $hasCustomDomain && in_array('beta-resources', $skus), 'enableUsers' => $isController, 'enableWallets' => $isController, ]; diff --git a/src/app/Jobs/Resource/CreateJob.php b/src/app/Jobs/Resource/CreateJob.php new file mode 100644 --- /dev/null +++ b/src/app/Jobs/Resource/CreateJob.php @@ -0,0 +1,29 @@ +getResource(); + + if (!$resource) { + return; + } + + if (!$resource->isLdapReady()) { + \App\Backends\LDAP::createResource($resource); + + $resource->status |= \App\Resource::STATUS_LDAP_READY; + $resource->save(); + } + } +} diff --git a/src/app/Jobs/Resource/DeleteJob.php b/src/app/Jobs/Resource/DeleteJob.php new file mode 100644 --- /dev/null +++ b/src/app/Jobs/Resource/DeleteJob.php @@ -0,0 +1,42 @@ +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); + + $resource->status |= \App\Resource::STATUS_DELETED; + + if ($resource->isLdapReady()) { + $resource->status ^= \App\Resource::STATUS_LDAP_READY; + } + + if ($resource->isImapReady()) { + $resource->status ^= \App\Resource::STATUS_IMAP_READY; + } + + $resource->save(); + } +} diff --git a/src/app/Jobs/Resource/UpdateJob.php b/src/app/Jobs/Resource/UpdateJob.php new file mode 100644 --- /dev/null +++ b/src/app/Jobs/Resource/UpdateJob.php @@ -0,0 +1,30 @@ +getResource(); + + if (!$resource) { + return; + } + + // Cancel the update if the group is deleted or not yet in LDAP + if (!$resource->isLdapReady() || $resource->isDeleted()) { + $this->delete(); + return; + } + + \App\Backends\LDAP::updateResource($resource); + } +} diff --git a/src/app/Jobs/Resource/VerifyJob.php b/src/app/Jobs/Resource/VerifyJob.php new file mode 100644 --- /dev/null +++ b/src/app/Jobs/Resource/VerifyJob.php @@ -0,0 +1,36 @@ +getResource(); + + if (!$resource) { + return; + } + + // the user has a mailbox (or is marked as such) + if ($resource->isImapReady()) { + $this->fail(new \Exception("Resource {$this->resourceId} is already verified.")); + return; + } + + $folder = $resource->getSetting('folder'); + + if ($folder && \App\Backends\IMAP::verifySharedFolder($folder)) { + $resource->status |= \App\Resource::STATUS_IMAP_READY; + $resource->status |= \App\Resource::STATUS_ACTIVE; + $resource->save(); + } + } +} diff --git a/src/app/Jobs/ResourceJob.php b/src/app/Jobs/ResourceJob.php new file mode 100644 --- /dev/null +++ b/src/app/Jobs/ResourceJob.php @@ -0,0 +1,73 @@ +handle(); + * ``` + */ +abstract class ResourceJob extends CommonJob +{ + /** + * 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. + * + * @return void + */ + public function __construct(int $resourceId) + { + $this->resourceId = $resourceId; + + $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/Observers/ResourceObserver.php b/src/app/Observers/ResourceObserver.php new file mode 100644 --- /dev/null +++ b/src/app/Observers/ResourceObserver.php @@ -0,0 +1,136 @@ +email)) { + if (!isset($resource->name)) { + throw new \Exception("Missing 'domain' property for a new resource"); + } + + $domainName = \strtolower($resource->domain); + + $resource->email = "resource-{$resource->id}@{$domainName}"; + } else { + $resource->email = \strtolower($resource->email); + } + + $resource->status |= Resource::STATUS_NEW | Resource::STATUS_ACTIVE; + } + + /** + * 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); + } + + /** + * Handle the resource "deleting" event. + * + * @param \App\Resource $resource The resource + * + * @return void + */ + public function deleting(Resource $resource) + { + // Entitlements do not have referential integrity on the entitled object, so this is our + // way of doing an onDelete('cascade') without the foreign key. + \App\Entitlement::where('entitleable_id', $resource->id) + ->where('entitleable_type', Resource::class) + ->delete(); + } + + /** + * 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]); + } + } + + /** + * Handle the resource "force deleted" event. + * + * @param \App\Resource $resource The resource + * + * @return void + */ + public function forceDeleted(Resource $resource) + { + // A group can be force-deleted separately from the owner + // we have to force-delete entitlements + \App\Entitlement::where('entitleable_id', $resource->id) + ->where('entitleable_type', Resource::class) + ->forceDelete(); + } +} diff --git a/src/app/Observers/ResourceSettingObserver.php b/src/app/Observers/ResourceSettingObserver.php new file mode 100644 --- /dev/null +++ b/src/app/Observers/ResourceSettingObserver.php @@ -0,0 +1,51 @@ +key, LDAP::RESOURCE_SETTINGS)) { + \App\Jobs\Resource\UpdateJob::dispatch($resourceSetting->resource_id); + } + } + + /** + * 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); + } + } + + /** + * 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); + } + } +} diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -51,6 +51,8 @@ \App\OpenVidu\Connection::observe(\App\Observers\OpenVidu\ConnectionObserver::class); \App\PackageSku::observe(\App\Observers\PackageSkuObserver::class); \App\PlanPackage::observe(\App\Observers\PlanPackageObserver::class); + \App\Resource::observe(\App\Observers\ResourceObserver::class); + \App\ResourceSetting::observe(\App\Observers\ResourceSettingObserver::class); \App\SignupCode::observe(\App\Observers\SignupCodeObserver::class); \App\SignupInvitation::observe(\App\Observers\SignupInvitationObserver::class); \App\Transaction::observe(\App\Observers\TransactionObserver::class); diff --git a/src/app/Resource.php b/src/app/Resource.php new file mode 100644 --- /dev/null +++ b/src/app/Resource.php @@ -0,0 +1,210 @@ +id)) { + throw new \Exception("Resource not yet exists"); + } + + if ($this->entitlements()->count()) { + throw new \Exception("Resource already assigned to a wallet"); + } + + $sku = \App\Sku::withObjectTenantContext($this)->where('title', 'resource')->first(); + $exists = $wallet->entitlements()->where('sku_id', $sku->id)->count(); + + \App\Entitlement::create([ + 'wallet_id' => $wallet->id, + 'sku_id' => $sku->id, + 'cost' => $exists >= $sku->units_free ? $sku->cost : 0, + 'fee' => $exists >= $sku->units_free ? $sku->fee : 0, + 'entitleable_id' => $this->id, + 'entitleable_type' => Resource::class + ]); + + return $this; + } + + /** + * Returns the resource domain. + * + * @return ?\App\Domain The domain group belongs to, NULL if it does not exist + */ + public function domain(): ?Domain + { + if (isset($this->domain)) { + $domainName = $this->domain; + } else { + list($local, $domainName) = explode('@', $this->email); + } + + return Domain::where('namespace', $domainName)->first(); + } + + /** + * Find whether an email address exists as a resource (including deleted resources). + * + * @param string $email Email address + * @param bool $return_resource Return Resource instance instead of boolean + * + * @return \App\Resource|bool True or Resource model object if found, False otherwise + */ + public static function emailExists(string $email, bool $return_resource = false) + { + if (strpos($email, '@') === false) { + return false; + } + + $email = \strtolower($email); + + $resource = self::withTrashed()->where('email', $email)->first(); + + if ($resource) { + return $return_resource ? $resource : true; + } + + return false; + } + + /** + * Returns whether this resource is active. + * + * @return bool + */ + public function isActive(): bool + { + return ($this->status & self::STATUS_ACTIVE) > 0; + } + + /** + * Returns whether this resource is deleted. + * + * @return bool + */ + public function isDeleted(): bool + { + return ($this->status & self::STATUS_DELETED) > 0; + } + + /** + * Returns whether this resource's folder exists in IMAP. + * + * @return bool + */ + public function isImapReady(): bool + { + return ($this->status & self::STATUS_IMAP_READY) > 0; + } + + /** + * Returns whether this resource is registered in LDAP. + * + * @return bool + */ + public function isLdapReady(): bool + { + return ($this->status & self::STATUS_LDAP_READY) > 0; + } + + /** + * Returns whether this resource is new. + * + * @return bool + */ + public function isNew(): bool + { + return ($this->status & self::STATUS_NEW) > 0; + } + + /** + * Group status mutator + * + * @throws \Exception + */ + public function setStatusAttribute($status) + { + $new_status = 0; + + $allowed_values = [ + self::STATUS_NEW, + self::STATUS_ACTIVE, + self::STATUS_DELETED, + self::STATUS_IMAP_READY, + self::STATUS_LDAP_READY, + ]; + + foreach ($allowed_values as $value) { + if ($status & $value) { + $new_status |= $value; + $status ^= $value; + } + } + + if ($status > 0) { + throw new \Exception("Invalid resource status: {$status}"); + } + + $this->attributes['status'] = $new_status; + } +} diff --git a/src/app/ResourceSetting.php b/src/app/ResourceSetting.php new file mode 100644 --- /dev/null +++ b/src/app/ResourceSetting.php @@ -0,0 +1,30 @@ +belongsTo(\App\Resource::class, 'resource_id', 'id'); + } +} diff --git a/src/app/Rules/GroupName.php b/src/app/Rules/GroupName.php --- a/src/app/Rules/GroupName.php +++ b/src/app/Rules/GroupName.php @@ -41,7 +41,7 @@ // Check the max length, according to the database column length if (strlen($name) > 191) { - $this->message = \trans('validation.nametoolong'); + $this->message = \trans('validation.max.string', ['max' => 191]); return false; } diff --git a/src/app/Rules/GroupName.php b/src/app/Rules/ResourceName.php copy from src/app/Rules/GroupName.php copy to src/app/Rules/ResourceName.php --- a/src/app/Rules/GroupName.php +++ b/src/app/Rules/ResourceName.php @@ -6,7 +6,7 @@ use Illuminate\Support\Facades\Validator; use Illuminate\Support\Str; -class GroupName implements Rule +class ResourceName implements Rule { private $message; private $owner; @@ -28,7 +28,7 @@ * Determine if the validation rule passes. * * @param string $attribute Attribute name - * @param mixed $name The value to validate + * @param mixed $name Resource name input * * @return bool */ @@ -41,15 +41,22 @@ // Check the max length, according to the database column length if (strlen($name) > 191) { - $this->message = \trans('validation.nametoolong'); + $this->message = \trans('validation.max.string', ['max' => 191]); + return false; + } + + // Check if specified domain is belongs to the user + $domains = \collect($this->owner->domains(true, false))->pluck('namespace')->all(); + if (!in_array($this->domain, $domains)) { + $this->message = \trans('validation.domaininvalid'); return false; } // Check if the name is unique in the domain // FIXME: Maybe just using the whole groups table would be faster than groups()? - $exists = $this->owner->groups() - ->where('groups.name', $name) - ->where('groups.email', 'like', '%@' . $this->domain) + $exists = $this->owner->resources() + ->where('resources.name', $name) + ->where('resources.email', 'like', '%@' . $this->domain) ->exists(); if ($exists) { diff --git a/src/app/Traits/ResourceConfigTrait.php b/src/app/Traits/ResourceConfigTrait.php new file mode 100644 --- /dev/null +++ b/src/app/Traits/ResourceConfigTrait.php @@ -0,0 +1,86 @@ +getSetting('invitation_policy') ?: 'accept'; + + return $config; + } + + /** + * A helper to update a resource configuration. + * + * @param array $config An array of configuration options + * + * @return array A list of input validation errors + */ + public function setConfig(array $config): array + { + $errors = []; + + foreach ($config as $key => $value) { + // validate and save the invitation policy + if ($key === 'invitation_policy') { + $value = (string) $value; + if ($value === 'accept' || $value === 'reject') { + // do nothing + } elseif (preg_match('/^manual:/', $value, $matches)) { + $email = trim(substr($value, 7)); + if ($error = $this->validateInvitationPolicyUser($email)) { + $errors[$key] = $error; + } else { + $value = "manual:$email"; + } + } else { + $errors[$key] = \trans('validation.ipolicy-invalid'); + } + + if (empty($errors[$key])) { + $this->setSetting($key, $value); + } + } else { + $errors[$key] = \trans('validation.invalid-config-parameter'); + } + } + + return $errors; + } + + /** + * Validate an email address for use as a resource owner (with invitation policy) + * + * @param string $email Email address + * + * @return ?string Error message on validation error + */ + protected function validateInvitationPolicyUser($email): ?string + { + $v = Validator::make(['email' => $email], ['email' => 'required|email']); + + if ($v->fails()) { + return \trans('validation.emailinvalid'); + } + + $user = \App\User::where('email', \strtolower($email))->first(); + + // The user and resource must be in the same wallet + if ($user && ($wallet = $user->wallet())) { + if ($wallet->user_id == $this->wallet()->user_id) { + return null; + } + } + + return \trans('validation.notalocaluser'); + } +} diff --git a/src/app/User.php b/src/app/User.php --- a/src/app/User.php +++ b/src/app/User.php @@ -309,22 +309,29 @@ /** * List the domains to which this user is entitled. - * Note: Active public domains are also returned (for the user tenant). + * + * @param bool $with_accounts Include domains assigned to wallets + * the current user controls but not owns. + * @param bool $with_public Include active public domains (for the user tenant). * * @return Domain[] List of Domain objects */ - public function domains(): array + public function domains($with_accounts = true, $with_public = true): array { - if ($this->tenant_id) { - $domains = Domain::where('tenant_id', $this->tenant_id); - } else { - $domains = Domain::withEnvTenantContext(); - } + $domains = []; - $domains = $domains->whereRaw(sprintf('(type & %s)', Domain::TYPE_PUBLIC)) - ->whereRaw(sprintf('(status & %s)', Domain::STATUS_ACTIVE)) - ->get() - ->all(); + if ($with_public) { + if ($this->tenant_id) { + $domains = Domain::where('tenant_id', $this->tenant_id); + } else { + $domains = Domain::withEnvTenantContext(); + } + + $domains = $domains->whereRaw(sprintf('(type & %s)', Domain::TYPE_PUBLIC)) + ->whereRaw(sprintf('(status & %s)', Domain::STATUS_ACTIVE)) + ->get() + ->all(); + } foreach ($this->wallets as $wallet) { $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get(); @@ -333,10 +340,12 @@ } } - foreach ($this->accounts as $wallet) { - $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get(); - foreach ($entitlements as $entitlement) { - $domains[] = $entitlement->entitleable; + if ($with_accounts) { + foreach ($this->accounts as $wallet) { + $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get(); + foreach ($entitlements as $entitlement) { + $domains[] = $entitlement->entitleable; + } } } @@ -561,6 +570,29 @@ return $this; } + /** + * Return resources controlled by the current user. + * + * @param bool $with_accounts Include resources assigned to wallets + * the current user controls but not owns. + * + * @return \Illuminate\Database\Eloquent\Builder Query builder + */ + public function resources($with_accounts = true) + { + $wallets = $this->wallets()->pluck('id')->all(); + + if ($with_accounts) { + $wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all()); + } + + return \App\Resource::select(['resources.*', 'entitlements.wallet_id']) + ->distinct() + ->join('entitlements', 'entitlements.entitleable_id', '=', 'resources.id') + ->whereIn('entitlements.wallet_id', $wallets) + ->where('entitlements.entitleable_type', \App\Resource::class); + } + public function senderPolicyFrameworkWhitelist($clientName) { $setting = $this->getSetting('spf_whitelist'); diff --git a/src/composer.json b/src/composer.json --- a/src/composer.json +++ b/src/composer.json @@ -69,7 +69,7 @@ "Tests\\": "tests/" } }, - "minimum-stability": "dev", + "minimum-stability": "stable", "prefer-stable": true, "scripts": { "post-autoload-dump": [ diff --git a/src/database/seeds/DatabaseSeeder.php b/src/database/seeds/DatabaseSeeder.php --- a/src/database/seeds/DatabaseSeeder.php +++ b/src/database/seeds/DatabaseSeeder.php @@ -25,6 +25,7 @@ 'UserSeeder', 'OpenViduRoomSeeder', 'OauthClientSeeder', + 'ResourceSeeder', ]; $env = ucfirst(App::environment()); diff --git a/src/database/seeds/local/ResourceSeeder.php b/src/database/seeds/local/ResourceSeeder.php new file mode 100644 --- /dev/null +++ b/src/database/seeds/local/ResourceSeeder.php @@ -0,0 +1,33 @@ +first(); + $wallet = $john->wallets()->first(); + + $resource = Resource::create([ + 'name' => 'Conference Room #1', + 'email' => 'resource-test1@kolab.org', + ]); + $resource->assignToWallet($wallet); + + $resource = Resource::create([ + 'name' => 'Conference Room #2', + 'email' => 'resource-test2@kolab.org', + ]); + $resource->assignToWallet($wallet); + } +} diff --git a/src/database/seeds/local/SkuSeeder.php b/src/database/seeds/local/SkuSeeder.php --- a/src/database/seeds/local/SkuSeeder.php +++ b/src/database/seeds/local/SkuSeeder.php @@ -110,7 +110,7 @@ 'cost' => 101, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Resource', - 'active' => false, + 'active' => true, ] ); @@ -153,7 +153,7 @@ ); // Check existence because migration might have added this already - $sku = \App\Sku::where(['title' => 'beta', 'tenant_id' => \config('app.tenant_id')])->first(); + $sku = Sku::where(['title' => 'beta', 'tenant_id' => \config('app.tenant_id')])->first(); if (!$sku) { Sku::create( @@ -171,7 +171,7 @@ } // Check existence because migration might have added this already - $sku = \App\Sku::where(['title' => 'meet', 'tenant_id' => \config('app.tenant_id')])->first(); + $sku = Sku::where(['title' => 'meet', 'tenant_id' => \config('app.tenant_id')])->first(); if (!$sku) { Sku::create( @@ -189,7 +189,7 @@ } // Check existence because migration might have added this already - $sku = \App\Sku::where(['title' => 'group', 'tenant_id' => \config('app.tenant_id')])->first(); + $sku = Sku::where(['title' => 'group', 'tenant_id' => \config('app.tenant_id')])->first(); if (!$sku) { Sku::create( @@ -207,10 +207,10 @@ } // Check existence because migration might have added this already - $sku = \App\Sku::where(['title' => 'distlist', 'tenant_id' => \config('app.tenant_id')])->first(); + $sku = Sku::where(['title' => 'distlist', 'tenant_id' => \config('app.tenant_id')])->first(); if (!$sku) { - \App\Sku::create( + Sku::create( [ 'title' => 'distlist', 'name' => 'Distribution lists', @@ -224,6 +224,22 @@ ); } + // Check existence because migration might have added this already + $sku = Sku::where(['title' => 'beta-resources', 'tenant_id' => \config('app.tenant_id')])->first(); + + if (!$sku) { + Sku::create([ + 'title' => 'beta-resources', + 'name' => 'Calendaring resources', + 'description' => 'Access to calendaring resources', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Beta\Resources', + 'active' => true, + ]); + } + // for tenants that are not the configured tenant id $tenants = \App\Tenant::where('id', '!=', \config('app.tenant_id'))->get(); diff --git a/src/database/seeds/production/SkuSeeder.php b/src/database/seeds/production/SkuSeeder.php --- a/src/database/seeds/production/SkuSeeder.php +++ b/src/database/seeds/production/SkuSeeder.php @@ -107,10 +107,10 @@ 'title' => 'resource', 'name' => 'Resource', 'description' => 'Reservation taker', - 'cost' => 101, + 'cost' => 0, 'period' => 'monthly', 'handler_class' => 'App\Handlers\Resource', - 'active' => false, + 'active' => true, ] ); @@ -153,7 +153,7 @@ ); // Check existence because migration might have added this already - if (!\App\Sku::where('title', 'beta')->first()) { + if (!Sku::where('title', 'beta')->first()) { Sku::create( [ 'title' => 'beta', @@ -169,7 +169,7 @@ } // Check existence because migration might have added this already - if (!\App\Sku::where('title', 'meet')->first()) { + if (!Sku::where('title', 'meet')->first()) { Sku::create( [ 'title' => 'meet', @@ -185,7 +185,7 @@ } // Check existence because migration might have added this already - if (!\App\Sku::where('title', 'group')->first()) { + if (!Sku::where('title', 'group')->first()) { Sku::create( [ 'title' => 'group', @@ -201,8 +201,8 @@ } // Check existence because migration might have added this already - if (!\App\Sku::where('title', 'distlist')->first()) { - \App\Sku::create([ + if (!Sku::where('title', 'distlist')->first()) { + Sku::create([ 'title' => 'distlist', 'name' => 'Distribution lists', 'description' => 'Access to mail distribution lists', @@ -213,5 +213,19 @@ 'active' => true, ]); } + + // Check existence because migration might have added this already + if (!Sku::where('title', 'beta-resources')->first()) { + Sku::create([ + 'title' => 'beta-resources', + 'name' => 'Calendaring resources', + 'description' => 'Access to calendaring resources', + 'cost' => 0, + 'units_free' => 0, + 'period' => 'monthly', + 'handler_class' => 'App\Handlers\Beta\Resources', + 'active' => true, + ]); + } } } diff --git a/src/include/rcube_imap_generic.php b/src/include/rcube_imap_generic.php --- a/src/include/rcube_imap_generic.php +++ b/src/include/rcube_imap_generic.php @@ -3787,6 +3787,11 @@ // remove spaces from the beginning of the string $str = ltrim($str); + // empty string + if ($str === '' || $str === null) { + break; + } + switch ($str[0]) { // String literal @@ -3834,11 +3839,6 @@ // String atom, number, astring, NIL, *, % default: - // empty string - if ($str === '' || $str === null) { - break 2; - } - // excluded chars: SP, CTL, ), DEL // we do not exclude [ and ] (#1489223) if (preg_match('/^([^\x00-\x20\x29\x7F]+)/', $str, $m)) { diff --git a/src/resources/js/app.js b/src/resources/js/app.js --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -189,6 +189,18 @@ $(elem).append(small ? $(loader).addClass('small') : $(loader)) }, + // Create an object copy with specified properties only + pick(obj, properties) { + let result = {} + + properties.forEach(prop => { + if (prop in obj) { + result[prop] = obj[prop] + } + }) + + return result + }, // Remove loader element added in addLoader() removeLoader(elem) { $(elem).find('.app-loader').remove() @@ -355,6 +367,12 @@ return page ? page : '404' }, + resourceStatusClass(resource) { + return this.userStatusClass(resource) + }, + resourceStatusText(resource) { + return this.userStatusText(resource) + }, supportDialog(container) { let dialog = $('#support-dialog')[0] @@ -515,8 +533,12 @@ input.addClass('is-invalid').next('.invalid-feedback').remove() input.after(feedback) - } - else { + } else { + // a special case, e.g. the invitation policy widget + if (input.is('select') && input.parent().is('.input-group-select.selected')) { + input = input.next() + } + // Standard form element input.addClass('is-invalid') input.parent().find('.invalid-feedback').remove() diff --git a/src/resources/js/fontawesome.js b/src/resources/js/fontawesome.js --- a/src/resources/js/fontawesome.js +++ b/src/resources/js/fontawesome.js @@ -11,6 +11,7 @@ import { faCheck, faCheckCircle, + faCog, faComments, faDownload, faEnvelope, @@ -41,6 +42,7 @@ faCheck, faCheckCircle, faCheckSquare, + faCog, faComments, faCreditCard, faPaypal, diff --git a/src/resources/js/user/routes.js b/src/resources/js/user/routes.js --- a/src/resources/js/user/routes.js +++ b/src/resources/js/user/routes.js @@ -8,6 +8,8 @@ import MeetComponent from '../../vue/Rooms' import PageComponent from '../../vue/Page' import PasswordResetComponent from '../../vue/PasswordReset' +import ResourceInfoComponent from '../../vue/Resource/Info' +import ResourceListComponent from '../../vue/Resource/List' import SignupComponent from '../../vue/Signup' import UserInfoComponent from '../../vue/User/Info' import UserListComponent from '../../vue/User/List' @@ -78,6 +80,18 @@ component: UserProfileDeleteComponent, meta: { requiresAuth: true } }, + { + path: '/resource/:resource', + name: 'resource', + component: ResourceInfoComponent, + meta: { requiresAuth: true, perm: 'resources' } + }, + { + path: '/resources', + name: 'resources', + component: ResourceListComponent, + meta: { requiresAuth: true, perm: 'resources' } + }, { component: RoomComponent, name: 'room', diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php --- a/src/resources/lang/en/app.php +++ b/src/resources/lang/en/app.php @@ -28,21 +28,24 @@ 'process-user-new' => 'Registering a user...', 'process-user-ldap-ready' => 'Creating a user...', 'process-user-imap-ready' => 'Creating a mailbox...', - 'process-distlist-new' => 'Registering a distribution list...', - 'process-distlist-ldap-ready' => 'Creating a distribution list...', 'process-domain-new' => 'Registering a custom domain...', 'process-domain-ldap-ready' => 'Creating a custom domain...', 'process-domain-verified' => 'Verifying a custom domain...', 'process-domain-confirmed' => 'Verifying an ownership of a custom domain...', 'process-success' => 'Setup process finished successfully.', - 'process-error-user-ldap-ready' => 'Failed to create a user.', - 'process-error-user-imap-ready' => 'Failed to verify that a mailbox exists.', + 'process-error-distlist-ldap-ready' => 'Failed to create a distribution list.', 'process-error-domain-ldap-ready' => 'Failed to create a domain.', 'process-error-domain-verified' => 'Failed to verify a domain.', 'process-error-domain-confirmed' => 'Failed to verify an ownership of a domain.', + 'process-error-resource-imap-ready' => 'Failed to verify that a shared folder exists.', + 'process-error-resource-ldap-ready' => 'Failed to create a resource.', + 'process-error-user-ldap-ready' => 'Failed to create a user.', + 'process-error-user-imap-ready' => 'Failed to verify that a mailbox exists.', 'process-distlist-new' => 'Registering a distribution list...', 'process-distlist-ldap-ready' => 'Creating a distribution list...', - 'process-error-distlist-ldap-ready' => 'Failed to create a distribution list.', + 'process-resource-new' => 'Registering a resource...', + 'process-resource-imap-ready' => 'Creating a shared folder...', + 'process-resource-ldap-ready' => 'Creating a resource...', 'distlist-update-success' => 'Distribution list updated successfully.', 'distlist-create-success' => 'Distribution list created successfully.', @@ -60,6 +63,11 @@ 'domain-unsuspend-success' => 'Domain unsuspended successfully.', 'domain-setconfig-success' => 'Domain settings updated successfully.', + 'resource-update-success' => 'Resource updated successfully.', + 'resource-create-success' => 'Resource created successfully.', + 'resource-delete-success' => 'Resource deleted successfully.', + 'resource-setconfig-success' => 'Resource settings updated successfully.', + 'user-update-success' => 'User data updated successfully.', 'user-create-success' => 'User created successfully.', 'user-delete-success' => 'User deleted successfully.', diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php --- a/src/resources/lang/en/ui.php +++ b/src/resources/lang/en/ui.php @@ -44,6 +44,7 @@ 'domains' => "Domains", 'invitations' => "Invitations", 'profile' => "Your profile", + 'resources' => "Resources", 'users' => "User accounts", 'wallet' => "Wallet", 'webmail' => "Webmail", @@ -119,12 +120,14 @@ 'firstname' => "First Name", 'general' => "General", 'lastname' => "Last Name", + 'name' => "Name", 'none' => "none", 'or' => "or", 'password' => "Password", 'password-confirm' => "Confirm Password", 'phone' => "Phone", 'settings' => "Settings", + 'shared-folder' => "Shared Folder", 'status' => "Status", 'surname' => "Surname", 'user' => "User", @@ -289,6 +292,21 @@ . " Enter the code we sent you, or click the link in the message.", ], + 'resource' => [ + 'delete' => "Delete resource", + 'invitation-policy' => "Invitation policy", + 'invitation-policy-text' => "Event invitations for a resource are normally accepted automatically" + . " if there is no conflicting event on the requested time slot. Invitation policy allows" + . " for rejecting such requests or to require a manual acceptance from a specified user.", + 'ipolicy-manual' => "Manual (tentative)", + 'ipolicy-accept' => "Accept", + 'ipolicy-reject' => "Reject", + 'list-title' => "Resource | Resources", + 'create' => "Create resource", + 'list-empty' => "There are no resources in this account.", + 'new' => "New resource", + ], + 'signup' => [ 'email' => "Existing Email Address", 'login' => "Login", @@ -303,12 +321,14 @@ 'prepare-account' => "We are preparing your account.", 'prepare-domain' => "We are preparing the domain.", 'prepare-distlist' => "We are preparing the distribution list.", + 'prepare-resource' => "We are preparing the resource.", 'prepare-user' => "We are preparing the user account.", 'prepare-hint' => "Some features may be missing or readonly at the moment.", 'prepare-refresh' => "The process never ends? Press the \"Refresh\" button, please.", 'ready-account' => "Your account is almost ready.", 'ready-domain' => "The domain is almost ready.", 'ready-distlist' => "The distribution list is almost ready.", + 'ready-resource' => "The resource is almost ready.", 'ready-user' => "The user account is almost ready.", 'verify' => "Verify your domain to finish the setup process.", 'verify-domain' => "Verify domain", diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php --- a/src/resources/lang/en/validation.php +++ b/src/resources/lang/en/validation.php @@ -140,10 +140,10 @@ 'listmembersrequired' => 'At least one recipient is required.', 'spf-entry-invalid' => 'The entry format is invalid. Expected a domain name starting with a dot.', 'sp-entry-invalid' => 'The entry format is invalid. Expected an email, domain, or part of it.', + 'ipolicy-invalid' => 'The specified invitation policy is invalid.', 'invalid-config-parameter' => 'The requested configuration parameter is not supported.', 'nameexists' => 'The specified name is not available.', 'nameinvalid' => 'The specified name is invalid.', - 'nametoolong' => 'The specified name is too long.', /* |-------------------------------------------------------------------------- diff --git a/src/resources/themes/app.scss b/src/resources/themes/app.scss --- a/src/resources/themes/app.scss +++ b/src/resources/themes/app.scss @@ -292,6 +292,9 @@ } // Some icons are too big, scale them down + &.link-domains, + &.link-resources, + &.link-wallet, &.link-invitations { svg { transform: scale(0.9); diff --git a/src/resources/themes/forms.scss b/src/resources/themes/forms.scss --- a/src/resources/themes/forms.scss +++ b/src/resources/themes/forms.scss @@ -69,6 +69,26 @@ } } +// An input group with a select and input, where input is displayed +// only for some select values +.input-group-select { + &:not(.selected) { + input { + display: none; + } + + select { + border-bottom-right-radius: .25rem !important; + border-top-right-radius: .25rem !important; + } + } + + input { + border-bottom-right-radius: .25rem !important; + border-top-right-radius: .25rem !important; + } +} + .form-control-plaintext .btn-sm { margin-top: -0.25rem; } diff --git a/src/resources/vue/Dashboard.vue b/src/resources/vue/Dashboard.vue --- a/src/resources/vue/Dashboard.vue +++ b/src/resources/vue/Dashboard.vue @@ -15,6 +15,9 @@ {{ $t('dashboard.distlists') }} + + {{ $t('dashboard.resources') }} + {{ $t('dashboard.wallet') }} {{ $root.price(balance, currency) }} diff --git a/src/resources/vue/Resource/Info.vue b/src/resources/vue/Resource/Info.vue new file mode 100644 --- /dev/null +++ b/src/resources/vue/Resource/Info.vue @@ -0,0 +1,189 @@ + + + diff --git a/src/resources/vue/Resource/List.vue b/src/resources/vue/Resource/List.vue new file mode 100644 --- /dev/null +++ b/src/resources/vue/Resource/List.vue @@ -0,0 +1,60 @@ + + + diff --git a/src/resources/vue/Widgets/Status.vue b/src/resources/vue/Widgets/Status.vue --- a/src/resources/vue/Widgets/Status.vue +++ b/src/resources/vue/Widgets/Status.vue @@ -5,6 +5,7 @@ {{ $t('status.prepare-account') }} {{ $t('status.prepare-domain') }} {{ $t('status.prepare-distlist') }} + {{ $t('status.prepare-resource') }} {{ $t('status.prepare-user') }}
{{ $t('status.prepare-hint') }} @@ -20,6 +21,7 @@ {{ $t('status.ready-account') }} {{ $t('status.ready-domain') }} {{ $t('status.ready-distlist') }} + {{ $t('status.ready-resource') }} {{ $t('status.ready-user') }}
{{ $t('status.verify') }} @@ -193,6 +195,9 @@ case 'distlist': url = '/api/v4/groups/' + this.$route.params.list + '/status' break + case 'resource': + url = '/api/v4/resources/' + this.$route.params.resource + '/status' + break default: url = '/api/v4/users/' + this.$route.params.user + '/status' } diff --git a/src/routes/api.php b/src/routes/api.php --- a/src/routes/api.php +++ b/src/routes/api.php @@ -79,6 +79,11 @@ Route::post('groups/{id}/config', 'API\V4\GroupsController@setConfig'); Route::apiResource('packages', API\V4\PackagesController::class); + + Route::apiResource('resources', API\V4\ResourcesController::class); + Route::get('resources/{id}/status', 'API\V4\ResourcesController@status'); + Route::post('resources/{id}/config', 'API\V4\ResourcesController@setConfig'); + Route::apiResource('skus', API\V4\SkusController::class); Route::apiResource('users', API\V4\UsersController::class); diff --git a/src/tests/Browser/Pages/ResourceInfo.php b/src/tests/Browser/Pages/ResourceInfo.php new file mode 100644 --- /dev/null +++ b/src/tests/Browser/Pages/ResourceInfo.php @@ -0,0 +1,47 @@ +waitFor('@general') + ->waitUntilMissing('.app-loader'); + } + + /** + * Get the element shortcuts for the page. + * + * @return array + */ + public function elements(): array + { + return [ + '@app' => '#app', + '@general' => '#general', + '@nav' => 'ul.nav-tabs', + '@settings' => '#settings', + '@status' => '#status-box', + ]; + } +} diff --git a/src/tests/Browser/Pages/ResourceList.php b/src/tests/Browser/Pages/ResourceList.php new file mode 100644 --- /dev/null +++ b/src/tests/Browser/Pages/ResourceList.php @@ -0,0 +1,45 @@ +assertPathIs($this->url()) + ->waitUntilMissing('@app .app-loader') + ->assertSeeIn('#resource-list .card-title', 'Resources'); + } + + /** + * Get the element shortcuts for the page. + * + * @return array + */ + public function elements(): array + { + return [ + '@app' => '#app', + '@table' => '#resource-list table', + ]; + } +} diff --git a/src/tests/Browser/ResourceTest.php b/src/tests/Browser/ResourceTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Browser/ResourceTest.php @@ -0,0 +1,301 @@ +delete(); + $this->clearBetaEntitlements(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + Resource::whereNotIn('email', ['resource-test1@kolab.org', 'resource-test2@kolab.org'])->delete(); + $this->clearBetaEntitlements(); + + parent::tearDown(); + } + + /** + * Test resource info page (unauthenticated) + */ + public function testInfoUnauth(): void + { + // Test that the page requires authentication + $this->browse(function (Browser $browser) { + $browser->visit('/resource/abc')->on(new Home()); + }); + } + + /** + * Test resource list page (unauthenticated) + */ + public function testListUnauth(): void + { + // Test that the page requires authentication + $this->browse(function (Browser $browser) { + $browser->visit('/resources')->on(new Home()); + }); + } + + /** + * Test resources list page + */ + public function testList(): void + { + // Log on the user + $this->browse(function (Browser $browser) { + $browser->visit(new Home()) + ->submitLogon('john@kolab.org', 'simple123', true) + ->on(new Dashboard()) + ->assertMissing('@links .link-resources'); + }); + + // Test that Resources lists page is not accessible without the 'beta-resources' entitlement + $this->browse(function (Browser $browser) { + $browser->visit('/resources') + ->assertErrorPage(403); + }); + + // Add beta+beta-resources entitlements + $john = $this->getTestUser('john@kolab.org'); + $this->addBetaEntitlement($john, 'beta-resources'); + // Make sure the first resource is active + $resource = $this->getTestResource('resource-test1@kolab.org'); + $resource->status = Resource::STATUS_NEW | Resource::STATUS_ACTIVE + | Resource::STATUS_LDAP_READY | Resource::STATUS_IMAP_READY; + $resource->save(); + + // Test resources lists page + $this->browse(function (Browser $browser) { + $browser->visit(new Dashboard()) + ->assertSeeIn('@links .link-resources', 'Resources') + ->click('@links .link-resources') + ->on(new ResourceList()) + ->whenAvailable('@table', function (Browser $browser) { + $browser->waitFor('tbody tr') + ->assertSeeIn('thead tr th:nth-child(1)', 'Name') + ->assertSeeIn('thead tr th:nth-child(2)', 'Email Address') + ->assertElementsCount('tbody tr', 2) + ->assertSeeIn('tbody tr:nth-child(1) td:nth-child(1) a', 'Conference Room #1') + ->assertText('tbody tr:nth-child(1) td:nth-child(1) svg.text-success title', 'Active') + ->assertSeeIn('tbody tr:nth-child(1) td:nth-child(2) a', 'kolab.org') + ->assertMissing('tfoot'); + }); + }); + } + + /** + * Test resource creation/editing/deleting + * + * @depends testList + */ + public function testCreateUpdateDelete(): void + { + // Test that the page is not available accessible without the 'beta-resources' entitlement + $this->browse(function (Browser $browser) { + $browser->visit('/resource/new') + ->assertErrorPage(403); + }); + + // Add beta+beta-resource entitlements + $john = $this->getTestUser('john@kolab.org'); + $this->addBetaEntitlement($john, 'beta-resources'); + + $this->browse(function (Browser $browser) { + // Create a resource + $browser->visit(new ResourceList()) + ->assertSeeIn('button.create-resource', 'Create resource') + ->click('button.create-resource') + ->on(new ResourceInfo()) + ->assertSeeIn('#resource-info .card-title', 'New resource') + ->assertSeeIn('@nav #tab-general', 'General') + ->assertMissing('@nav #tab-settings') + ->with('@general', function (Browser $browser) { + // Assert form content + $browser->assertMissing('#status') + ->assertFocused('#name') + ->assertSeeIn('div.row:nth-child(1) label', 'Name') + ->assertValue('div.row:nth-child(1) input[type=text]', '') + ->assertSeeIn('div.row:nth-child(2) label', 'Domain') + ->assertSelectHasOptions('div.row:nth-child(2) select', ['kolab.org']) + ->assertValue('div.row:nth-child(2) select', 'kolab.org') + ->assertSeeIn('button[type=submit]', 'Submit'); + }) + // Test error conditions + ->type('#name', str_repeat('A', 192)) + ->click('@general button[type=submit]') + ->waitFor('#name + .invalid-feedback') + ->assertSeeIn('#name + .invalid-feedback', 'The name may not be greater than 191 characters.') + ->assertFocused('#name') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + // Test successful resource creation + ->type('#name', 'Test Resource') + ->click('@general button[type=submit]') + ->assertToast(Toast::TYPE_SUCCESS, 'Resource created successfully.') + ->on(new ResourceList()) + ->assertElementsCount('@table tbody tr', 3); + + // Test resource update + $browser->click('@table tr:nth-child(3) td:first-child a') + ->on(new ResourceInfo()) + ->assertSeeIn('#resource-info .card-title', 'Resource') + ->with('@general', function (Browser $browser) { + // Assert form content + $browser->assertFocused('#name') + ->assertSeeIn('div.row:nth-child(1) label', 'Status') + ->assertSeeIn('div.row:nth-child(1) span.text-danger', 'Not Ready') + ->assertSeeIn('div.row:nth-child(2) label', 'Name') + ->assertValue('div.row:nth-child(2) input[type=text]', 'Test Resource') + ->assertSeeIn('div.row:nth-child(3) label', 'Email') + ->assertAttributeRegExp( + 'div.row:nth-child(3) input[type=text]:disabled', + 'value', + '/^resource-[0-9]+@kolab\.org$/' + ) + ->assertSeeIn('button[type=submit]', 'Submit'); + }) + // Test error handling + ->type('#name', str_repeat('A', 192)) + ->click('@general button[type=submit]') + ->waitFor('#name + .invalid-feedback') + ->assertSeeIn('#name + .invalid-feedback', 'The name may not be greater than 191 characters.') + ->assertVisible('#name.is-invalid') + ->assertFocused('#name') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + // Test successful update + ->type('#name', 'Test Resource Update') + ->click('@general button[type=submit]') + ->assertToast(Toast::TYPE_SUCCESS, 'Resource updated successfully.') + ->on(new ResourceList()) + ->assertElementsCount('@table tbody tr', 3) + ->assertSeeIn('@table tr:nth-child(3) td:first-child a', 'Test Resource Update'); + + $this->assertSame(1, Resource::where('name', 'Test Resource Update')->count()); + + // Test resource deletion + $browser->click('@table tr:nth-child(3) td:first-child a') + ->on(new ResourceInfo()) + ->assertSeeIn('button.button-delete', 'Delete resource') + ->click('button.button-delete') + ->assertToast(Toast::TYPE_SUCCESS, 'Resource deleted successfully.') + ->on(new ResourceList()) + ->assertElementsCount('@table tbody tr', 2); + + $this->assertNull(Resource::where('name', 'Test Resource Update')->first()); + }); + } + + /** + * Test resource status + * + * @depends testList + */ + public function testStatus(): void + { + $john = $this->getTestUser('john@kolab.org'); + $this->addBetaEntitlement($john, 'beta-resources'); + $resource = $this->getTestResource('resource-test2@kolab.org'); + $resource->status = Resource::STATUS_NEW | Resource::STATUS_ACTIVE | Resource::STATUS_LDAP_READY; + $resource->created_at = \now(); + $resource->save(); + + $this->assertFalse($resource->isImapReady()); + + $this->browse(function ($browser) use ($resource) { + // Test auto-refresh + $browser->visit('/resource/' . $resource->id) + ->on(new ResourceInfo()) + ->with(new Status(), function ($browser) { + $browser->assertSeeIn('@body', 'We are preparing the resource') + ->assertProgress(85, 'Creating a shared folder...', 'pending') + ->assertMissing('@refresh-button') + ->assertMissing('@refresh-text') + ->assertMissing('#status-link') + ->assertMissing('#status-verify'); + }); + + $resource->status |= Resource::STATUS_IMAP_READY; + $resource->save(); + + // Test Verify button + $browser->waitUntilMissing('@status', 10); + }); + + // TODO: Test all resource statuses on the list + } + + /** + * Test resource settings + */ + public function testSettings(): void + { + $john = $this->getTestUser('john@kolab.org'); + $this->addBetaEntitlement($john, 'beta-resources'); + $resource = $this->getTestResource('resource-test2@kolab.org'); + $resource->setSetting('invitation_policy', null); + + $this->browse(function ($browser) use ($resource) { + // Test auto-refresh + $browser->visit('/resource/' . $resource->id) + ->on(new ResourceInfo()) + ->assertSeeIn('@nav #tab-general', 'General') + ->assertSeeIn('@nav #tab-settings', 'Settings') + ->click('@nav #tab-settings') + ->with('@settings form', function (Browser $browser) { + // Assert form content + $browser->assertSeeIn('div.row:nth-child(1) label', 'Invitation policy') + ->assertSelectHasOptions('div.row:nth-child(1) select', ['accept', 'manual', 'reject']) + ->assertValue('div.row:nth-child(1) select', 'accept') + ->assertMissing('div.row:nth-child(1) input') + ->assertSeeIn('div.row:nth-child(1) small', 'manual acceptance') + ->assertSeeIn('button[type=submit]', 'Submit'); + }) + // Test error handling + ->select('#invitation_policy', 'manual') + ->waitFor('#invitation_policy + input') + ->type('#invitation_policy + input', 'kolab.org') + ->click('@settings button[type=submit]') + ->waitFor('#invitation_policy + input + .invalid-feedback') + ->assertSeeIn( + '#invitation_policy + input + .invalid-feedback', + 'The specified email address is invalid.' + ) + ->assertVisible('#invitation_policy + input.is-invalid') + ->assertFocused('#invitation_policy + input') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + ->type('#invitation_policy + input', 'jack@kolab.org') + ->click('@settings button[type=submit]') + ->assertToast(Toast::TYPE_SUCCESS, 'Resource settings updated successfully.') + ->assertMissing('.invalid-feedback') + ->refresh() + ->on(new ResourceInfo()) + ->click('@nav #tab-settings') + ->with('@settings form', function (Browser $browser) { + $browser->assertValue('div.row:nth-child(1) select', 'manual') + ->assertVisible('div.row:nth-child(1) input') + ->assertValue('div.row:nth-child(1) input', 'jack@kolab.org'); + }); + }); + } +} diff --git a/src/tests/Feature/Backends/IMAPTest.php b/src/tests/Feature/Backends/IMAPTest.php --- a/src/tests/Feature/Backends/IMAPTest.php +++ b/src/tests/Feature/Backends/IMAPTest.php @@ -35,4 +35,18 @@ IMAP::verifyAccount('non-existing@domain.tld'); } + + /** + * Test verifying IMAP shared folder existence + * + * @group imap + */ + public function testVerifySharedFolder(): void + { + $result = IMAP::verifySharedFolder('shared/Resources/UnknownResource@kolab.org'); + $this->assertFalse($result); + + // TODO: Test with an existing shared folder + $this->markTestIncomplete(); + } } diff --git a/src/tests/Feature/Backends/LDAPTest.php b/src/tests/Feature/Backends/LDAPTest.php --- a/src/tests/Feature/Backends/LDAPTest.php +++ b/src/tests/Feature/Backends/LDAPTest.php @@ -6,6 +6,7 @@ use App\Domain; use App\Group; use App\Entitlement; +use App\Resource; use App\User; use Illuminate\Support\Facades\Queue; use Tests\TestCase; @@ -28,6 +29,7 @@ $this->deleteTestUser('user-ldap-test@' . \config('app.domain')); $this->deleteTestDomain('testldap.com'); $this->deleteTestGroup('group@kolab.org'); + $this->deleteTestResource('test-resource@kolab.org'); // TODO: Remove group members } @@ -41,6 +43,7 @@ $this->deleteTestUser('user-ldap-test@' . \config('app.domain')); $this->deleteTestDomain('testldap.com'); $this->deleteTestGroup('group@kolab.org'); + $this->deleteTestResource('test-resource@kolab.org'); // TODO: Remove group members parent::tearDown(); @@ -200,12 +203,80 @@ // this is making sure that there's no job executed by the LDAP backend Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 5); - // Delete the domain + // Delete the group LDAP::deleteGroup($group); $this->assertSame(null, LDAP::getGroup($group->email)); } + /** + * Test creating/updating/deleting a resource record + * + * @group ldap + */ + public function testResource(): void + { + Queue::fake(); + + $root_dn = \config('ldap.hosted.root_dn'); + $resource = $this->getTestResource('test-resource@kolab.org', ['name' => 'Test1']); + $resource->setSetting('invitation_policy', null); + + // Make sure the resource does not exist + // LDAP::deleteResource($resource); + + // Create the resource + LDAP::createResource($resource); + + $ldap_resource = LDAP::getResource($resource->email); + + $expected = [ + 'cn' => 'Test1', + 'dn' => 'cn=Test1,ou=Resources,ou=kolab.org,' . $root_dn, + 'mail' => $resource->email, + 'objectclass' => [ + 'top', + 'kolabresource', + 'kolabsharedfolder', + 'mailrecipient', + ], + 'kolabfoldertype' => 'event', + 'kolabtargetfolder' => 'shared/Resources/Test1@kolab.org', + 'kolabinvitationpolicy' => null, + 'owner' => null, + ]; + + foreach ($expected as $attr => $value) { + $ldap_value = isset($ldap_resource[$attr]) ? $ldap_resource[$attr] : null; + $this->assertEquals($value, $ldap_value, "Resource $attr attribute"); + } + + // Update resource name and invitation_policy + $resource->name = 'Te(=ść)1'; + $resource->save(); + $resource->setSetting('invitation_policy', 'manual:john@kolab.org'); + + LDAP::updateResource($resource); + + $expected['kolabtargetfolder'] = 'shared/Resources/Te(=ść)1@kolab.org'; + $expected['kolabinvitationpolicy'] = 'ACT_MANUAL'; + $expected['owner'] = 'uid=john@kolab.org,ou=People,ou=kolab.org,' . $root_dn; + $expected['dn'] = 'cn=Te(\\3dść)1,ou=Resources,ou=kolab.org,' . $root_dn; + $expected['cn'] = 'Te(=ść)1'; + + $ldap_resource = LDAP::getResource($resource->email); + + foreach ($expected as $attr => $value) { + $ldap_value = isset($ldap_resource[$attr]) ? $ldap_resource[$attr] : null; + $this->assertEquals($value, $ldap_value, "Resource $attr attribute"); + } + + // Delete the resource + LDAP::deleteResource($resource); + + $this->assertSame(null, LDAP::getResource($resource->email)); + } + /** * Test creating/editing/deleting a user record * @@ -318,6 +389,25 @@ $this->assertSame(null, LDAP::getUser($user->email)); } + /** + * Test handling errors on a resource creation + * + * @group ldap + */ + public function testCreateResourceException(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessageMatches('/Failed to create resource/'); + + $resource = new Resource([ + 'email' => 'test-non-existing-ldap@non-existing.org', + 'name' => 'Test', + 'status' => User::STATUS_ACTIVE, + ]); + + LDAP::createResource($resource); + } + /** * Test handling errors on a group creation * @@ -393,6 +483,23 @@ LDAP::updateGroup($group); } + /** + * Test handling update of a non-existing resource + * + * @group ldap + */ + public function testUpdateResourceException(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessageMatches('/resource not found/'); + + $resource = new Resource([ + 'email' => 'test-resource@kolab.org', + ]); + + LDAP::updateResource($resource); + } + /** * Test handling update of a non-existing user * diff --git a/src/tests/Feature/Controller/ResourcesTest.php b/src/tests/Feature/Controller/ResourcesTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Controller/ResourcesTest.php @@ -0,0 +1,482 @@ +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/) + */ + 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(0, $json); + + // 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(2, $json); + $this->assertSame($resource->id, $json[0]['id']); + $this->assertSame($resource->email, $json[0]['email']); + $this->assertSame($resource->name, $json[0]['name']); + $this->assertArrayHasKey('isDeleted', $json[0]); + $this->assertArrayHasKey('isActive', $json[0]); + $this->assertArrayHasKey('isLdapReady', $json[0]); + $this->assertArrayHasKey('isImapReady', $json[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(2, $json); + $this->assertSame($resource->email, $json[0]['email']); + } + + /** + * Test resource config update (POST /api/v4/resources//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/) + */ + 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']); + } + + /** + * Test fetching a resource status (GET /api/v4/resources//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']); + $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 + $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['isImapReady']); + $this->assertTrue($json['isReady']); + $this->assertCount(7, $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(true, $json['process'][2]['state']); + $this->assertSame('success', $json['status']); + $this->assertSame('Setup process finished successfully.', $json['message']); + $this->assertSame('done', $json['processState']); + + // Test a case when a domain is not ready + $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->assertSame('resource-ldap-ready', $json['process'][1]['label']); + $this->assertSame(true, $json['process'][1]['state']); + $this->assertSame('success', $json['status']); + $this->assertSame('Setup process finished successfully.', $json['message']); + } + + /** + * Test 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']); + $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']); + $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/) + */ + 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/ResourceTest.php b/src/tests/Feature/ResourceTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/ResourceTest.php @@ -0,0 +1,348 @@ +deleteTestUser('user-test@kolabnow.com'); + $this->deleteTestResource('resource-test@kolabnow.com'); + } + + public function tearDown(): void + { + $this->deleteTestUser('user-test@kolabnow.com'); + $this->deleteTestResource('resource-test@kolabnow.com'); + + 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->domain = '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->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); + + // 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); + + $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); + + $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/UserTest.php b/src/tests/Feature/UserTest.php --- a/src/tests/Feature/UserTest.php +++ b/src/tests/Feature/UserTest.php @@ -694,6 +694,27 @@ $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()->get(); + $this->assertSame(2, $resources->count()); + + $resources = $ned->resources()->get(); + $this->assertSame(2, $resources->count()); + + // TODO: More detailed assertions + + $resources = $jack->resources()->get(); + $this->assertSame(0, $resources->count()); + } + /** * Test user restoring */ diff --git a/src/tests/TestCaseTrait.php b/src/tests/TestCaseTrait.php --- a/src/tests/TestCaseTrait.php +++ b/src/tests/TestCaseTrait.php @@ -4,6 +4,8 @@ use App\Domain; use App\Group; +use App\Resource; +use App\Sku; use App\Transaction; use App\User; use Carbon\Carbon; @@ -89,11 +91,23 @@ */ protected $userPassword; + /** + * Register the beta entitlement for a user + */ + protected function addBetaEntitlement($user, $title): void + { + // Add beta + $title entitlements + $beta_sku = Sku::withEnvTenantContext()->where('title', 'beta')->first(); + $sku = Sku::withEnvTenantContext()->where('title', $title)->first(); + $user->assignSku($beta_sku); + $user->assignSku($sku); + } + /** * Assert that the entitlements for the user match the expected list of entitlements. * * @param \App\User|\App\Domain $object The object for which the entitlements need to be pulled. - * @param array $expected An array of expected \App\SKU titles. + * @param array $expected An array of expected \App\Sku titles. */ protected function assertEntitlements($object, $expected) { @@ -139,10 +153,11 @@ { $beta_handlers = [ 'App\Handlers\Beta', + 'App\Handlers\Beta\Resources', 'App\Handlers\Distlist', ]; - $betas = \App\Sku::whereIn('handler_class', $beta_handlers)->pluck('id')->all(); + $betas = Sku::whereIn('handler_class', $beta_handlers)->pluck('id')->all(); \App\Entitlement::whereIn('sku_id', $betas)->delete(); } @@ -281,6 +296,27 @@ $group->forceDelete(); } + /** + * Delete a test resource whatever it takes. + * + * @coversNothing + */ + protected function deleteTestResource($email) + { + Queue::fake(); + + $resource = Resource::withTrashed()->where('email', $email)->first(); + + if (!$resource) { + return; + } + + $job = new \App\Jobs\Resource\DeleteJob($resource->id); + $job->handle(); + + $resource->forceDelete(); + } + /** * Delete a test user whatever it takes. * @@ -338,6 +374,38 @@ return Group::firstOrCreate(['email' => $email], $attrib); } + /** + * Get Resource object by name+domain, create it if needed. + * Skip LDAP jobs. + */ + protected function getTestResource($email, $attrib = []) + { + // Disable jobs (i.e. skip LDAP oprations) + Queue::fake(); + + $resource = Resource::where('email', $email)->first(); + + if (!$resource) { + list($local, $domain) = explode('@', $email, 2); + + $resource = new Resource(); + $resource->email = $email; + $resource->domain = $domain; + + if (!isset($attrib['name'])) { + $resource->name = $local; + } + } + + foreach ($attrib as $key => $val) { + $resource->{$key} = $val; + } + + $resource->save(); + + return $resource; + } + /** * Get User object by email, create it if needed. * Skip LDAP jobs. @@ -424,7 +492,7 @@ $this->domainUsers[] = $this->domainOwner; // assign second factor to joe - $this->joe->assignSku(\App\Sku::where('title', '2fa')->first()); + $this->joe->assignSku(Sku::where('title', '2fa')->first()); \App\Auth\SecondFactor::seed($this->joe->email); usort(