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',
@@ -249,6 +256,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.
*
* Only need to add user if in any of the local domains? Figure that out here for now. Should
@@ -381,6 +429,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.
*
* @param \App\User $user The user account to delete.
@@ -457,6 +533,28 @@
}
/**
+ * 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.
*
* @param string $email The user email.
@@ -560,6 +658,43 @@
}
/**
+ * Update a resource in LDAP.
+ *
+ * @param \App\Resource $resource The resource to update
+ *
+ * @throws \Exception
+ */
+ public static function updateResource(Resource $resource): void
+ {
+ $config = self::getConfig('admin');
+ $ldap = self::initLDAP($config);
+
+ $newEntry = $oldEntry = self::getResourceEntry($ldap, $resource->email, $dn);
+
+ if (empty($oldEntry)) {
+ self::throwException(
+ $ldap,
+ "Failed to update resource {$resource->email} in LDAP (resource not found)"
+ );
+ }
+
+ self::setResourceAttributes($ldap, $resource, $newEntry);
+
+ $result = $ldap->modify_entry($dn, $oldEntry, $newEntry);
+
+ if (!is_array($result)) {
+ self::throwException(
+ $ldap,
+ "Failed to update resource {$resource->email} in LDAP (" . __LINE__ . ")"
+ );
+ }
+
+ if (empty(self::$ldap)) {
+ $ldap->close();
+ }
+ }
+
+ /**
* Update a user in LDAP.
*
* @param \App\User $user The user account to update.
@@ -705,6 +840,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
*/
private static function setUserAttributes(User $user, array &$entry)
@@ -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);
}
/**
@@ -951,6 +1145,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
*
* @param \Net_LDAP3 $ldap Ldap connection
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,
];
@@ -133,6 +134,19 @@
}
/**
+ * 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.
*
* @param string $user User ID or email
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 @@
+exists()
|| \App\UserAlias::whereRaw('substr(alias, ?) = ?', [-$suffixLen, $suffix])->exists()
|| \App\Group::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists()
+ || \App\Resource::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists()
);
}
diff --git a/src/app/Handlers/Beta/Resources.php b/src/app/Handlers/Beta/Resources.php
new file mode 100644
--- /dev/null
+++ b/src/app/Handlers/Beta/Resources.php
@@ -0,0 +1,49 @@
+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/Admin/GroupsController.php b/src/app/Http/Controllers/API/V4/Admin/GroupsController.php
--- a/src/app/Http/Controllers/API/V4/Admin/GroupsController.php
+++ b/src/app/Http/Controllers/API/V4/Admin/GroupsController.php
@@ -21,14 +21,7 @@
if ($owner) {
if ($owner = User::find($owner)) {
- foreach ($owner->wallets as $wallet) {
- $wallet->entitlements()->where('entitleable_type', Group::class)->get()
- ->each(function ($entitlement) use ($result) {
- $result->push($entitlement->entitleable);
- });
- }
-
- $result = $result->sortBy('name')->values();
+ $result = $owner->groups(false)->orderBy('name')->get();
}
} elseif (!empty($search)) {
if ($group = Group::where('email', $search)->first()) {
diff --git a/src/app/Http/Controllers/API/V4/Admin/ResourcesController.php b/src/app/Http/Controllers/API/V4/Admin/ResourcesController.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/Admin/ResourcesController.php
@@ -0,0 +1,59 @@
+input('search'));
+ $owner = trim(request()->input('owner'));
+ $result = collect([]);
+
+ if ($owner) {
+ if ($owner = User::find($owner)) {
+ $result = $owner->resources(false)->orderBy('name')->get();
+ }
+ } elseif (!empty($search)) {
+ if ($resource = Resource::where('email', $search)->first()) {
+ $result->push($resource);
+ }
+ }
+
+ // Process the result
+ $result = $result->map(
+ function ($resource) {
+ return $this->objectToClient($resource);
+ }
+ );
+
+ $result = [
+ 'list' => $result,
+ 'count' => count($result),
+ 'message' => \trans('app.search-foundxresources', ['x' => count($result)]),
+ ];
+
+ return response()->json($result);
+ }
+
+ /**
+ * Create a new resource.
+ *
+ * @param \Illuminate\Http\Request $request The API request.
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function store(Request $request)
+ {
+ return $this->errorResponse(404);
+ }
+}
diff --git a/src/app/Http/Controllers/API/V4/Admin/UsersController.php b/src/app/Http/Controllers/API/V4/Admin/UsersController.php
--- a/src/app/Http/Controllers/API/V4/Admin/UsersController.php
+++ b/src/app/Http/Controllers/API/V4/Admin/UsersController.php
@@ -3,11 +3,8 @@
namespace App\Http\Controllers\API\V4\Admin;
use App\Domain;
-use App\Group;
use App\Sku;
use App\User;
-use App\UserAlias;
-use App\UserSetting;
use App\Wallet;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
@@ -51,19 +48,21 @@
if ($result->isEmpty()) {
// Search by an alias
- $user_ids = UserAlias::where('alias', $search)->get()->pluck('user_id');
+ $user_ids = \App\UserAlias::where('alias', $search)->get()->pluck('user_id');
// Search by an external email
- $ext_user_ids = UserSetting::where('key', 'external_email')
+ $ext_user_ids = \App\UserSetting::where('key', 'external_email')
->where('value', $search)
->get()
->pluck('user_id');
$user_ids = $user_ids->merge($ext_user_ids)->unique();
- // Search by a distribution list email
- if ($group = Group::withTrashed()->where('email', $search)->first()) {
+ // Search by a distribution list or resource email
+ if ($group = \App\Group::withTrashed()->where('email', $search)->first()) {
$user_ids = $user_ids->merge([$group->wallet()->user_id])->unique();
+ } elseif ($resource = \App\Resource::withTrashed()->where('email', $search)->first()) {
+ $user_ids = $user_ids->merge([$resource->wallet()->user_id])->unique();
}
if (!$user_ids->isEmpty()) {
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/Reseller/GroupsController.php b/src/app/Http/Controllers/API/V4/Reseller/GroupsController.php
--- a/src/app/Http/Controllers/API/V4/Reseller/GroupsController.php
+++ b/src/app/Http/Controllers/API/V4/Reseller/GroupsController.php
@@ -20,14 +20,7 @@
if ($owner) {
if ($owner = User::withSubjectTenantContext()->find($owner)) {
- foreach ($owner->wallets as $wallet) {
- $wallet->entitlements()->where('entitleable_type', Group::class)->get()
- ->each(function ($entitlement) use ($result) {
- $result->push($entitlement->entitleable);
- });
- }
-
- $result = $result->sortBy('name')->values();
+ $result = $owner->groups(false)->orderBy('name')->get();
}
} elseif (!empty($search)) {
if ($group = Group::withSubjectTenantContext()->where('email', $search)->first()) {
diff --git a/src/app/Http/Controllers/API/V4/Reseller/ResourcesController.php b/src/app/Http/Controllers/API/V4/Reseller/ResourcesController.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/Reseller/ResourcesController.php
@@ -0,0 +1,46 @@
+input('search'));
+ $owner = trim(request()->input('owner'));
+ $result = collect([]);
+
+ if ($owner) {
+ if ($owner = User::withSubjectTenantContext()->find($owner)) {
+ $result = $owner->resources(false)->orderBy('name')->get();
+ }
+ } elseif (!empty($search)) {
+ if ($resource = Resource::withSubjectTenantContext()->where('email', $search)->first()) {
+ $result->push($resource);
+ }
+ }
+
+ // Process the result
+ $result = $result->map(
+ function ($resource) {
+ return $this->objectToClient($resource);
+ }
+ );
+
+ $result = [
+ 'list' => $result,
+ 'count' => count($result),
+ 'message' => \trans('app.search-foundxresources', ['x' => count($result)]),
+ ];
+
+ return response()->json($result);
+ }
+}
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);
+
+ // Resource 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,
];
@@ -718,12 +720,15 @@
return \trans('validation.entryexists', ['attribute' => 'email']);
}
- // Check if a group with specified address already exists
- if ($existing_group = Group::emailExists($email, true)) {
- // If this is a deleted group in the same custom domain
+ // Check if a group or resource with specified address already exists
+ if (
+ ($existing = Group::emailExists($email, true))
+ || ($existing = \App\Resource::emailExists($email, true))
+ ) {
+ // If this is a deleted group/resource in the same custom domain
// we'll force delete it before
- if (!$domain->isPublic() && $existing_group->trashed()) {
- $deleted = $existing_group;
+ if (!$domain->isPublic() && $existing->trashed()) {
+ $deleted = $existing;
} else {
return \trans('validation.entryexists', ['attribute' => 'email']);
}
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,61 @@
+getResource();
+
+ if (!$resource) {
+ return;
+ }
+
+ // sanity checks
+ if ($resource->isDeleted()) {
+ $this->fail(new \Exception("Resource {$this->resourceId} is marked as deleted."));
+ return;
+ }
+
+ if ($resource->trashed()) {
+ $this->fail(new \Exception("Resource {$this->resourceId} is actually deleted."));
+ return;
+ }
+
+ if ($resource->isLdapReady()) {
+ $this->fail(new \Exception("Resource {$this->resourceId} is already marked as ldap-ready."));
+ return;
+ }
+
+ // see if the domain is ready
+ $domain = $resource->domain();
+
+ if (!$domain) {
+ $this->fail(new \Exception("The domain for resource {$this->resourceId} does not exist."));
+ return;
+ }
+
+ if ($domain->isDeleted()) {
+ $this->fail(new \Exception("The domain for resource {$this->resourceId} is marked as deleted."));
+ return;
+ }
+
+ if (!$domain->isLdapReady()) {
+ $this->release(60);
+ return;
+ }
+
+ \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 resource 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/Observers/UserObserver.php b/src/app/Observers/UserObserver.php
--- a/src/app/Observers/UserObserver.php
+++ b/src/app/Observers/UserObserver.php
@@ -5,6 +5,7 @@
use App\Entitlement;
use App\Domain;
use App\Group;
+use App\Resource;
use App\Transaction;
use App\User;
use App\Wallet;
@@ -147,6 +148,7 @@
$users = [];
$domains = [];
$groups = [];
+ $resources = [];
$entitlements = [];
foreach ($assignments as $entitlement) {
@@ -156,6 +158,8 @@
$users[] = $entitlement->entitleable_id;
} elseif ($entitlement->entitleable_type == Group::class) {
$groups[] = $entitlement->entitleable_id;
+ } elseif ($entitlement->entitleable_type == Resource::class) {
+ $resources[] = $entitlement->entitleable_id;
} else {
$entitlements[] = $entitlement;
}
@@ -181,6 +185,12 @@
}
}
+ if (!empty($resources)) {
+ foreach (Resource::whereIn('id', array_unique($resources))->get() as $_resource) {
+ $_resource->delete();
+ }
+ }
+
foreach ($entitlements as $entitlement) {
$entitlement->delete();
}
@@ -211,6 +221,7 @@
$entitlements = [];
$domains = [];
$groups = [];
+ $resources = [];
$users = [];
foreach ($assignments as $entitlement) {
@@ -225,6 +236,8 @@
$users[] = $entitlement->entitleable_id;
} elseif ($entitlement->entitleable_type == Group::class) {
$groups[] = $entitlement->entitleable_id;
+ } elseif ($entitlement->entitleable_type == Resource::class) {
+ $resources[] = $entitlement->entitleable_id;
}
}
@@ -252,6 +265,11 @@
Group::withTrashed()->whereIn('id', array_unique($groups))->forceDelete();
}
+ // Resources can be just removed
+ if (!empty($resources)) {
+ Resource::withTrashed()->whereIn('id', array_unique($resources))->forceDelete();
+ }
+
// Remove transactions, they also have no foreign key constraint
Transaction::where('object_type', Entitlement::class)
->whereIn('object_id', $entitlements)
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 to which the resource 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;
+ }
+
+ /**
+ * Resource 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)
+ // FIXME: Maybe just using the whole resources table would be faster than resources()?
+ $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;
+ }
}
}
@@ -404,8 +413,6 @@
return null;
}
-
-
/**
* Return groups controlled by the current user.
*
@@ -561,6 +568,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/migrations/2021_11_16_100000_create_resources_tables.php b/src/database/migrations/2021_11_16_100000_create_resources_tables.php
new file mode 100644
--- /dev/null
+++ b/src/database/migrations/2021_11_16_100000_create_resources_tables.php
@@ -0,0 +1,80 @@
+unsignedBigInteger('id');
+ $table->string('email')->unique();
+ $table->string('name');
+ $table->smallInteger('status');
+ $table->unsignedBigInteger('tenant_id')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+
+ $table->primary('id');
+ $table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('set null');
+ }
+ );
+
+ Schema::create(
+ 'resource_settings',
+ function (Blueprint $table) {
+ $table->bigIncrements('id');
+ $table->unsignedBigInteger('resource_id');
+ $table->string('key');
+ $table->text('value');
+ $table->timestamps();
+
+ $table->foreign('resource_id')->references('id')->on('resources')
+ ->onDelete('cascade')->onUpdate('cascade');
+
+ $table->unique(['resource_id', 'key']);
+ }
+ );
+
+ \App\Sku::where('title', 'resource')->update([
+ 'active' => true,
+ 'cost' => 0,
+ ]);
+
+ if (!\App\Sku::where('title', 'beta-resources')->first()) {
+ \App\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,
+ ]);
+ }
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::dropIfExists('resource_settings');
+ Schema::dropIfExists('resources');
+
+ // there's no need to remove the SKU
+ }
+}
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/admin/routes.js b/src/resources/js/admin/routes.js
--- a/src/resources/js/admin/routes.js
+++ b/src/resources/js/admin/routes.js
@@ -4,6 +4,7 @@
import LoginComponent from '../../vue/Login'
import LogoutComponent from '../../vue/Logout'
import PageComponent from '../../vue/Page'
+import ResourceComponent from '../../vue/Admin/Resource'
import StatsComponent from '../../vue/Admin/Stats'
import UserComponent from '../../vue/Admin/User'
@@ -41,6 +42,12 @@
component: LogoutComponent
},
{
+ path: '/resource/:resource',
+ name: 'resource',
+ component: ResourceComponent,
+ meta: { requiresAuth: true }
+ },
+ {
path: '/stats',
name: 'stats',
component: StatsComponent,
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/reseller/routes.js b/src/resources/js/reseller/routes.js
--- a/src/resources/js/reseller/routes.js
+++ b/src/resources/js/reseller/routes.js
@@ -5,6 +5,7 @@
import LoginComponent from '../../vue/Login'
import LogoutComponent from '../../vue/Logout'
import PageComponent from '../../vue/Page'
+import ResourceComponent from '../../vue/Admin/Resource'
import StatsComponent from '../../vue/Reseller/Stats'
import UserComponent from '../../vue/Admin/User'
import WalletComponent from '../../vue/Wallet'
@@ -49,6 +50,12 @@
meta: { requiresAuth: true }
},
{
+ path: '/resource/:resource',
+ name: 'resource',
+ component: ResourceComponent,
+ meta: { requiresAuth: true }
+ },
+ {
path: '/stats',
name: 'stats',
component: StatsComponent,
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'
@@ -79,6 +81,18 @@
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',
path: '/meet/: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.',
@@ -71,7 +79,8 @@
'user-set-sku-already-exists' => 'The subscription already exists.',
'search-foundxdomains' => ':x domains have been found.',
- 'search-foundxgroups' => ':x distribution lists have been found.',
+ 'search-foundxdistlists' => ':x distribution lists have been found.',
+ 'search-foundxresources' => ':x resources have been found.',
'search-foundxusers' => ':x user accounts have been found.',
'signup-invitations-created' => 'The invitation has been created.|:count invitations has been created.',
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' => [
+ 'create' => "Create 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",
+ '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",
@@ -359,7 +379,6 @@
'discount-hint' => "applied discount",
'discount-title' => "Account discount",
'distlists' => "Distribution lists",
- 'distlists-none' => "There are no distribution lists in this account.",
'domains' => "Domains",
'domains-none' => "There are no domains in this account.",
'ext-email' => "External Email",
@@ -386,6 +405,7 @@
'profile-delete-contact' => "Also feel free to contact {app} Support with any questions or concerns that you may have in this context.",
'reset-2fa' => "Reset 2-Factor Auth",
'reset-2fa-title' => "2-Factor Authentication Reset",
+ 'resources' => "Resources",
'title' => "User account",
'search' => "User email address or name",
'search-pl' => "User ID, email or 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/lang/fr/app.php b/src/resources/lang/fr/app.php
--- a/src/resources/lang/fr/app.php
+++ b/src/resources/lang/fr/app.php
@@ -71,7 +71,7 @@
'user-set-sku-already-exists' => "La souscription existe déjà.",
'search-foundxdomains' => "Les domaines :x ont été trouvés.",
- 'search-foundxgroups' => "Les listes de distribution :x ont été trouvées.",
+ 'search-foundxdistlists' => "Les listes de distribution :x ont été trouvées.",
'search-foundxusers' => "Les comptes d'utilisateurs :x ont été trouvés.",
'signup-invitations-created' => "L'invitation à été crée.|:count nombre d'invitations ont été crée.",
diff --git a/src/resources/lang/fr/ui.php b/src/resources/lang/fr/ui.php
--- a/src/resources/lang/fr/ui.php
+++ b/src/resources/lang/fr/ui.php
@@ -358,7 +358,6 @@
'discount-hint' => "rabais appliqué",
'discount-title' => "Rabais de compte",
'distlists' => "Listes de Distribution",
- 'distlists-none' => "Il y a aucune liste de distribution dans ce compte.",
'domains' => "Domaines",
'domains-none' => "Il y a pas de domaines dans ce compte.",
'ext-email' => "E-mail externe",
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/Admin/Resource.vue b/src/resources/vue/Admin/Resource.vue
new file mode 100644
--- /dev/null
+++ b/src/resources/vue/Admin/Resource.vue
@@ -0,0 +1,80 @@
+
+
+
+
+
{{ resource.email }}
+
+
+
+
+
+
+
+
+
diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue
--- a/src/resources/vue/Admin/User.vue
+++ b/src/resources/vue/Admin/User.vue
@@ -118,6 +118,11 @@
+
+ {{ $t('user.resources') }} ({{ resources.length }})
+
+
+
Settings
@@ -307,7 +312,37 @@
- {{ $t('user.distlists-none') }} |
+ {{ $t('distlist.list-empty') }} |
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('form.name') }} |
+ {{ $t('form.email') }} |
+
+
+
+
+
+
+ {{ resource.name }}
+ |
+
+ {{ resource.email }}
+ |
+
+
+
+
+ {{ $t('resource.list-empty') }} |
@@ -463,6 +498,7 @@
walletReload: false,
distlists: [],
domains: [],
+ resources: [],
skus: [],
sku2FA: null,
users: [],
@@ -562,6 +598,12 @@
.then(response => {
this.distlists = response.data.list
})
+
+ // Fetch resources lists
+ axios.get('/api/v4/resources?owner=' + user_id)
+ .then(response => {
+ this.resources = response.data.list
+ })
})
.catch(this.$root.errorHandler)
},
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 @@
+
+
+
+
+
+
+
+ {{ $tc('resource.list-title', 1) }}
+
+
+
{{ $t('resource.new') }}
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+ {{ $tc('resource.list-title', 2) }}
+
+ {{ $t('resource.create') }}
+
+
+
+
+
+
+ {{ $t('form.name') }} |
+ {{ $t('form.email') }} |
+
+
+
+
+
+
+ {{ resource.name }}
+ |
+
+ {{ resource.email }}
+ |
+
+
+
+
+ {{ $t('resource.list-empty') }} |
+
+
+
+
+
+
+
+
+
+
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') }}
@@ -187,14 +189,11 @@
case 'dashboard':
url = '/api/v4/users/' + this.$store.state.authInfo.id + '/status'
break
- case 'domain':
- url = '/api/v4/domains/' + this.$route.params.domain + '/status'
- break
case 'distlist':
url = '/api/v4/groups/' + this.$route.params.list + '/status'
break
default:
- url = '/api/v4/users/' + this.$route.params.user + '/status'
+ url = '/api/v4/' + this.scope + 's/' + this.$route.params[this.scope] + '/status'
}
return url
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);
@@ -184,6 +189,7 @@
Route::post('groups/{id}/suspend', 'API\V4\Admin\GroupsController@suspend');
Route::post('groups/{id}/unsuspend', 'API\V4\Admin\GroupsController@unsuspend');
+ Route::apiResource('resources', API\V4\Admin\ResourcesController::class);
Route::apiResource('skus', API\V4\Admin\SkusController::class);
Route::apiResource('users', API\V4\Admin\UsersController::class);
Route::get('users/{id}/discounts', 'API\V4\Reseller\DiscountsController@userDiscounts');
@@ -230,6 +236,7 @@
Route::get('payments/pending', 'API\V4\Reseller\PaymentsController@payments');
Route::get('payments/has-pending', 'API\V4\Reseller\PaymentsController@hasPayments');
+ Route::apiResource('resources', API\V4\Reseller\ResourcesController::class);
Route::apiResource('skus', API\V4\Reseller\SkusController::class);
Route::apiResource('users', API\V4\Reseller\UsersController::class);
Route::get('users/{id}/discounts', 'API\V4\Reseller\DiscountsController@userDiscounts');
diff --git a/src/tests/Browser/Admin/ResourceTest.php b/src/tests/Browser/Admin/ResourceTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/Admin/ResourceTest.php
@@ -0,0 +1,95 @@
+browse(function (Browser $browser) {
+ $user = $this->getTestUser('john@kolab.org');
+ $resource = $this->getTestResource('resource-test1@kolab.org');
+
+ $browser->visit('/resource/' . $resource->id)->on(new Home());
+ });
+ }
+
+ /**
+ * Test resource info page
+ */
+ public function testInfo(): void
+ {
+ Queue::fake();
+
+ $this->browse(function (Browser $browser) {
+ $user = $this->getTestUser('john@kolab.org');
+ $resource = $this->getTestResource('resource-test1@kolab.org');
+ $resource->setSetting('invitation_policy', 'accept');
+
+ $resource_page = new ResourcePage($resource->id);
+ $user_page = new UserPage($user->id);
+
+ // Goto the resource page
+ $browser->visit(new Home())
+ ->submitLogon('jeroen@jeroen.jeroen', \App\Utils::generatePassphrase(), true)
+ ->on(new Dashboard())
+ ->visit($user_page)
+ ->on($user_page)
+ ->click('@nav #tab-resources')
+ ->pause(1000)
+ ->click('@user-resources table tbody tr:first-child td:first-child a')
+ ->on($resource_page)
+ ->assertSeeIn('@resource-info .card-title', $resource->email)
+ ->with('@resource-info form', function (Browser $browser) use ($resource) {
+ $browser->assertElementsCount('.row', 3)
+ ->assertSeeIn('.row:nth-child(1) label', 'ID (Created)')
+ ->assertSeeIn('.row:nth-child(1) #resourceid', "{$resource->id} ({$resource->created_at})")
+ ->assertSeeIn('.row:nth-child(2) label', 'Status')
+ ->assertSeeIn('.row:nth-child(2) #status.text-success', 'Active')
+ ->assertSeeIn('.row:nth-child(3) label', 'Name')
+ ->assertSeeIn('.row:nth-child(3) #name', $resource->name);
+ })
+ ->assertElementsCount('ul.nav-tabs', 1)
+ ->assertSeeIn('ul.nav-tabs .nav-link', 'Settings')
+ ->with('@resource-settings form', function (Browser $browser) {
+ $browser->assertElementsCount('.row', 1)
+ ->assertSeeIn('.row:nth-child(1) label', 'Invitation policy')
+ ->assertSeeIn('.row:nth-child(1) #invitation_policy', 'accept');
+ });
+
+ // Test invalid resource identifier
+ $browser->visit('/resource/abc')->assertErrorPage(404);
+ });
+ }
+}
diff --git a/src/tests/Browser/Admin/UserTest.php b/src/tests/Browser/Admin/UserTest.php
--- a/src/tests/Browser/Admin/UserTest.php
+++ b/src/tests/Browser/Admin/UserTest.php
@@ -116,7 +116,7 @@
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 7);
+ ->assertElementsCount('@nav a', 8);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
@@ -169,6 +169,14 @@
->assertSeeIn('table tfoot tr td', 'There are no distribution lists in this account.');
});
+ // Assert Resources tab
+ $browser->assertSeeIn('@nav #tab-resources', 'Resources (0)')
+ ->click('@nav #tab-resources')
+ ->with('@user-resources', function (Browser $browser) {
+ $browser->assertElementsCount('table tbody tr', 0)
+ ->assertSeeIn('table tfoot tr td', 'There are no resources in this account.');
+ });
+
// Assert Settings tab
$browser->assertSeeIn('@nav #tab-settings', 'Settings')
->click('@nav #tab-settings')
@@ -232,7 +240,7 @@
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 7);
+ ->assertElementsCount('@nav a', 8);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
@@ -282,6 +290,18 @@
->assertMissing('tfoot');
});
+ // Assert Resources tab
+ $browser->assertSeeIn('@nav #tab-resources', 'Resources (2)')
+ ->click('@nav #tab-resources')
+ ->with('@user-resources', function (Browser $browser) {
+ $browser->assertElementsCount('table tbody tr', 2)
+ ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'Conference Room #1')
+ ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', 'resource-test1@kolab.org')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Conference Room #2')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', 'resource-test2@kolab.org')
+ ->assertMissing('table tfoot');
+ });
+
// Assert Users tab
$browser->assertSeeIn('@nav #tab-users', 'Users (4)')
->click('@nav #tab-users')
@@ -337,7 +357,7 @@
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 7);
+ ->assertElementsCount('@nav a', 8);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
@@ -397,6 +417,14 @@
->assertSeeIn('table tfoot tr td', 'There are no distribution lists in this account.');
});
+ // We don't expect John's resources here
+ $browser->assertSeeIn('@nav #tab-resources', 'Resources (0)')
+ ->click('@nav #tab-resources')
+ ->with('@user-resources', function (Browser $browser) {
+ $browser->assertElementsCount('table tbody tr', 0)
+ ->assertSeeIn('table tfoot tr td', 'There are no resources in this account.');
+ });
+
// Assert Settings tab
$browser->assertSeeIn('@nav #tab-settings', 'Settings')
->click('@nav #tab-settings')
diff --git a/src/tests/Browser/Pages/Admin/User.php b/src/tests/Browser/Pages/Admin/Resource.php
copy from src/tests/Browser/Pages/Admin/User.php
copy to src/tests/Browser/Pages/Admin/Resource.php
--- a/src/tests/Browser/Pages/Admin/User.php
+++ b/src/tests/Browser/Pages/Admin/Resource.php
@@ -4,18 +4,18 @@
use Laravel\Dusk\Page;
-class User extends Page
+class Resource extends Page
{
- protected $userid;
+ protected $resourceId;
/**
* Object constructor.
*
- * @param int $userid User Id
+ * @param int $id Resource Id
*/
- public function __construct($userid)
+ public function __construct($id)
{
- $this->userid = $userid;
+ $this->resourceId = $id;
}
/**
@@ -25,7 +25,7 @@
*/
public function url(): string
{
- return '/user/' . $this->userid;
+ return '/resource/' . $this->resourceId;
}
/**
@@ -38,8 +38,7 @@
public function assert($browser): void
{
$browser->waitForLocation($this->url())
- ->waitUntilMissing('@app .app-loader')
- ->waitFor('@user-info');
+ ->waitFor('@resource-info');
}
/**
@@ -51,15 +50,8 @@
{
return [
'@app' => '#app',
- '@user-info' => '#user-info',
- '@nav' => 'ul.nav-tabs',
- '@user-finances' => '#user-finances',
- '@user-aliases' => '#user-aliases',
- '@user-subscriptions' => '#user-subscriptions',
- '@user-distlists' => '#user-distlists',
- '@user-domains' => '#user-domains',
- '@user-users' => '#user-users',
- '@user-settings' => '#user-settings',
+ '@resource-info' => '#resource-info',
+ '@resource-settings' => '#resource-settings',
];
}
}
diff --git a/src/tests/Browser/Pages/Admin/User.php b/src/tests/Browser/Pages/Admin/User.php
--- a/src/tests/Browser/Pages/Admin/User.php
+++ b/src/tests/Browser/Pages/Admin/User.php
@@ -58,6 +58,7 @@
'@user-subscriptions' => '#user-subscriptions',
'@user-distlists' => '#user-distlists',
'@user-domains' => '#user-domains',
+ '@user-resources' => '#user-resources',
'@user-users' => '#user-users',
'@user-settings' => '#user-settings',
];
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/Reseller/ResourceTest.php b/src/tests/Browser/Reseller/ResourceTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/Reseller/ResourceTest.php
@@ -0,0 +1,95 @@
+browse(function (Browser $browser) {
+ $user = $this->getTestUser('john@kolab.org');
+ $resource = $this->getTestResource('resource-test1@kolab.org');
+
+ $browser->visit('/resource/' . $resource->id)->on(new Home());
+ });
+ }
+
+ /**
+ * Test distribution list info page
+ */
+ public function testInfo(): void
+ {
+ Queue::fake();
+
+ $this->browse(function (Browser $browser) {
+ $user = $this->getTestUser('john@kolab.org');
+ $resource = $this->getTestResource('resource-test1@kolab.org');
+ $resource->setSetting('invitation_policy', 'accept');
+
+ $resource_page = new ResourcePage($resource->id);
+ $user_page = new UserPage($user->id);
+
+ // Goto the distlist page
+ $browser->visit(new Home())
+ ->submitLogon('reseller@' . \config('app.domain'), \App\Utils::generatePassphrase(), true)
+ ->on(new Dashboard())
+ ->visit($user_page)
+ ->on($user_page)
+ ->click('@nav #tab-resources')
+ ->pause(1000)
+ ->click('@user-resources table tbody tr:first-child td:first-child a')
+ ->on($resource_page)
+ ->assertSeeIn('@resource-info .card-title', $resource->email)
+ ->with('@resource-info form', function (Browser $browser) use ($resource) {
+ $browser->assertElementsCount('.row', 3)
+ ->assertSeeIn('.row:nth-child(1) label', 'ID (Created)')
+ ->assertSeeIn('.row:nth-child(1) #resourceid', "{$resource->id} ({$resource->created_at})")
+ ->assertSeeIn('.row:nth-child(2) label', 'Status')
+ ->assertSeeIn('.row:nth-child(2) #status.text-success', 'Active')
+ ->assertSeeIn('.row:nth-child(3) label', 'Name')
+ ->assertSeeIn('.row:nth-child(3) #name', $resource->name);
+ })
+ ->assertElementsCount('ul.nav-tabs', 1)
+ ->assertSeeIn('ul.nav-tabs .nav-link', 'Settings')
+ ->with('@resource-settings form', function (Browser $browser) {
+ $browser->assertElementsCount('.row', 1)
+ ->assertSeeIn('.row:nth-child(1) label', 'Invitation policy')
+ ->assertSeeIn('.row:nth-child(1) #invitation_policy', 'accept');
+ });
+
+ // Test invalid resource identifier
+ $browser->visit('/resource/abc')->assertErrorPage(404);
+ });
+ }
+}
diff --git a/src/tests/Browser/Reseller/UserTest.php b/src/tests/Browser/Reseller/UserTest.php
--- a/src/tests/Browser/Reseller/UserTest.php
+++ b/src/tests/Browser/Reseller/UserTest.php
@@ -113,7 +113,7 @@
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 7);
+ ->assertElementsCount('@nav a', 8);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
@@ -165,6 +165,23 @@
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no distribution lists in this account.');
});
+
+ // Assert Resources tab
+ $browser->assertSeeIn('@nav #tab-resources', 'Resources (0)')
+ ->click('@nav #tab-resources')
+ ->with('@user-resources', function (Browser $browser) {
+ $browser->assertElementsCount('table tbody tr', 0)
+ ->assertSeeIn('table tfoot tr td', 'There are no resources in this account.');
+ });
+
+ // Assert Settings tab
+ $browser->assertSeeIn('@nav #tab-settings', 'Settings')
+ ->click('@nav #tab-settings')
+ ->whenAvailable('@user-settings form', function (Browser $browser) {
+ $browser->assertElementsCount('.row', 1)
+ ->assertSeeIn('.row:first-child label', 'Greylisting')
+ ->assertSeeIn('.row:first-child .text-success', 'enabled');
+ });
});
}
@@ -219,7 +236,7 @@
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 7);
+ ->assertElementsCount('@nav a', 8);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
@@ -269,6 +286,18 @@
->assertMissing('tfoot');
});
+ // Assert Resources tab
+ $browser->assertSeeIn('@nav #tab-resources', 'Resources (2)')
+ ->click('@nav #tab-resources')
+ ->with('@user-resources', function (Browser $browser) {
+ $browser->assertElementsCount('table tbody tr', 2)
+ ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'Conference Room #1')
+ ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', 'resource-test1@kolab.org')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Conference Room #2')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', 'resource-test2@kolab.org')
+ ->assertMissing('table tfoot');
+ });
+
// Assert Users tab
$browser->assertSeeIn('@nav #tab-users', 'Users (4)')
->click('@nav #tab-users')
@@ -304,7 +333,7 @@
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 7);
+ ->assertElementsCount('@nav a', 8);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
@@ -361,6 +390,14 @@
->assertSeeIn('table tfoot tr td', 'There are no distribution lists in this account.');
});
+ // Assert Resources tab
+ $browser->assertSeeIn('@nav #tab-resources', 'Resources (0)')
+ ->click('@nav #tab-resources')
+ ->with('@user-resources', function (Browser $browser) {
+ $browser->assertElementsCount('table tbody tr', 0)
+ ->assertSeeIn('table tfoot tr td', 'There are no resources in this account.');
+ });
+
// Assert Settings tab
$browser->assertSeeIn('@nav #tab-settings', 'Settings')
->click('@nav #tab-settings')
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/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php
--- a/src/tests/Browser/UsersTest.php
+++ b/src/tests/Browser/UsersTest.php
@@ -711,7 +711,7 @@
$browser->visit('/user/' . $john->id)
->on(new UserInfo())
->with('@skus', function ($browser) {
- $browser->assertElementsCount('tbody tr', 8)
+ $browser->assertElementsCount('tbody tr', 9)
// Meet SKU
->assertSeeIn('tbody tr:nth-child(6) td.name', 'Voice & Video Conferencing (public beta)')
->assertSeeIn('tr:nth-child(6) td.price', '0,00 CHF/month')
@@ -730,13 +730,22 @@
'tbody tr:nth-child(7) td.buttons button',
'Access to the private beta program subscriptions'
)
- // Distlist SKU
- ->assertSeeIn('tbody tr:nth-child(8) td.name', 'Distribution lists')
+ // Resources SKU
+ ->assertSeeIn('tbody tr:nth-child(8) td.name', 'Calendaring resources')
->assertSeeIn('tr:nth-child(8) td.price', '0,00 CHF/month')
->assertNotChecked('tbody tr:nth-child(8) td.selection input')
->assertEnabled('tbody tr:nth-child(8) td.selection input')
->assertTip(
'tbody tr:nth-child(8) td.buttons button',
+ 'Access to calendaring resources'
+ )
+ // Distlist SKU
+ ->assertSeeIn('tbody tr:nth-child(9) td.name', 'Distribution lists')
+ ->assertSeeIn('tr:nth-child(9) td.price', '0,00 CHF/month')
+ ->assertNotChecked('tbody tr:nth-child(9) td.selection input')
+ ->assertEnabled('tbody tr:nth-child(9) td.selection input')
+ ->assertTip(
+ 'tbody tr:nth-child(9) td.buttons button',
'Access to mail distribution lists'
)
// Check Distlist, Uncheck Beta, expect Distlist unchecked
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,13 +203,81 @@
// 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
*
* @group ldap
@@ -319,6 +390,25 @@
}
/**
+ * 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
*
* @group ldap
@@ -394,6 +484,23 @@
}
/**
+ * 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
*
* @group ldap
diff --git a/src/tests/Feature/Controller/Admin/GroupsTest.php b/src/tests/Feature/Controller/Admin/GroupsTest.php
--- a/src/tests/Feature/Controller/Admin/GroupsTest.php
+++ b/src/tests/Feature/Controller/Admin/GroupsTest.php
@@ -83,7 +83,7 @@
$this->assertSame($group->name, $json['list'][0]['name']);
// Search by owner (Ned is a controller on John's wallets,
- // here we expect only domains assigned to Ned's wallet(s))
+ // here we expect only groups assigned to Ned's wallet(s))
$ned = $this->getTestUser('ned@kolab.org');
$response = $this->actingAs($admin)->get("api/v4/groups?owner={$ned->id}");
$response->assertStatus(200);
@@ -120,7 +120,7 @@
}
/**
- * Test fetching domain status (GET /api/v4/domains//status)
+ * Test fetching group status (GET /api/v4/groups//status)
*/
public function testStatus(): void
{
diff --git a/src/tests/Feature/Controller/Admin/ResourcesTest.php b/src/tests/Feature/Controller/Admin/ResourcesTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Controller/Admin/ResourcesTest.php
@@ -0,0 +1,148 @@
+getTestUser('john@kolab.org');
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $resource = $this->getTestResource('resource-test1@kolab.org');
+
+ // Non-admin user
+ $response = $this->actingAs($user)->get("api/v4/resources");
+ $response->assertStatus(403);
+
+ // Search with no search criteria
+ $response = $this->actingAs($admin)->get("api/v4/resources");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertSame([], $json['list']);
+ $this->assertSame("0 resources have been found.", $json['message']);
+
+ // Search with no matches expected
+ $response = $this->actingAs($admin)->get("api/v4/resources?search=john@kolab.org");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertSame([], $json['list']);
+
+ // Search by email
+ $response = $this->actingAs($admin)->get("api/v4/resources?search={$resource->email}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(1, $json['count']);
+ $this->assertCount(1, $json['list']);
+ $this->assertSame($resource->email, $json['list'][0]['email']);
+
+ // Search by owner
+ $response = $this->actingAs($admin)->get("api/v4/resources?owner={$user->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(2, $json['count']);
+ $this->assertCount(2, $json['list']);
+ $this->assertSame("2 resources have been found.", $json['message']);
+ $this->assertSame($resource->email, $json['list'][0]['email']);
+ $this->assertSame($resource->name, $json['list'][0]['name']);
+
+ // Search by owner (Ned is a controller on John's wallets,
+ // here we expect only resources assigned to Ned's wallet(s))
+ $ned = $this->getTestUser('ned@kolab.org');
+ $response = $this->actingAs($admin)->get("api/v4/resources?owner={$ned->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertCount(0, $json['list']);
+ }
+
+ /**
+ * Test fetching resource info (GET /api/v4/resources/)
+ */
+ public function testShow(): void
+ {
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $user = $this->getTestUser('john@kolab.org');
+ $resource = $this->getTestResource('resource-test1@kolab.org');
+
+ // Only admins can access it
+ $response = $this->actingAs($user)->get("api/v4/resources/{$resource->id}");
+ $response->assertStatus(403);
+
+ $response = $this->actingAs($admin)->get("api/v4/resources/{$resource->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertEquals($resource->id, $json['id']);
+ $this->assertEquals($resource->email, $json['email']);
+ $this->assertEquals($resource->name, $json['name']);
+ }
+
+ /**
+ * Test fetching resource status (GET /api/v4/resources//status)
+ */
+ public function testStatus(): void
+ {
+ Queue::fake(); // disable jobs
+
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $resource = $this->getTestResource('resource-test1@kolab.org');
+
+ // This end-point does not exist for admins
+ $response = $this->actingAs($admin)->get("/api/v4/resources/{$resource->id}/status");
+ $response->assertStatus(404);
+ }
+
+ /**
+ * Test resource creating (POST /api/v4/resources)
+ */
+ public function testStore(): void
+ {
+ $user = $this->getTestUser('john@kolab.org');
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+
+ // Test unauthorized access to admin API
+ $response = $this->actingAs($user)->post("/api/v4/resources", []);
+ $response->assertStatus(403);
+
+ // Admin can't create resources
+ $response = $this->actingAs($admin)->post("/api/v4/resources", []);
+ $response->assertStatus(404);
+ }
+}
diff --git a/src/tests/Feature/Controller/Admin/SkusTest.php b/src/tests/Feature/Controller/Admin/SkusTest.php
--- a/src/tests/Feature/Controller/Admin/SkusTest.php
+++ b/src/tests/Feature/Controller/Admin/SkusTest.php
@@ -82,7 +82,7 @@
$json = $response->json();
- $this->assertCount(9, $json);
+ $this->assertCount(11, $json);
$this->assertSame(100, $json[0]['prio']);
$this->assertSame($sku->id, $json[0]['id']);
diff --git a/src/tests/Feature/Controller/Admin/UsersTest.php b/src/tests/Feature/Controller/Admin/UsersTest.php
--- a/src/tests/Feature/Controller/Admin/UsersTest.php
+++ b/src/tests/Feature/Controller/Admin/UsersTest.php
@@ -183,6 +183,17 @@
$this->assertSame($user->id, $json['list'][0]['id']);
$this->assertSame($user->email, $json['list'][0]['email']);
+ // Search by resource email
+ $response = $this->actingAs($admin)->get("api/v4/users?search=resource-test1@kolab.org");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(1, $json['count']);
+ $this->assertCount(1, $json['list']);
+ $this->assertSame($user->id, $json['list'][0]['id']);
+ $this->assertSame($user->email, $json['list'][0]['email']);
+
// Deleted users/domains
$domain = $this->getTestDomain('testsearch.com', ['type' => \App\Domain::TYPE_EXTERNAL]);
$user = $this->getTestUser('test@testsearch.com');
diff --git a/src/tests/Feature/Controller/Reseller/GroupsTest.php b/src/tests/Feature/Controller/Reseller/GroupsTest.php
--- a/src/tests/Feature/Controller/Reseller/GroupsTest.php
+++ b/src/tests/Feature/Controller/Reseller/GroupsTest.php
@@ -89,7 +89,7 @@
$this->assertSame($group->name, $json['list'][0]['name']);
// Search by owner (Ned is a controller on John's wallets,
- // here we expect only domains assigned to Ned's wallet(s))
+ // here we expect only groups assigned to Ned's wallet(s))
$ned = $this->getTestUser('ned@kolab.org');
$response = $this->actingAs($reseller1)->get("api/v4/groups?owner={$ned->id}");
$response->assertStatus(200);
@@ -99,7 +99,7 @@
$this->assertSame(0, $json['count']);
$this->assertCount(0, $json['list']);
- $response = $this->actingAs($reseller2)->get("api/v4/groups?search=kolab.org");
+ $response = $this->actingAs($reseller2)->get("api/v4/groups?search={$group->email}");
$response->assertStatus(200);
$json = $response->json();
@@ -150,7 +150,7 @@
}
/**
- * Test fetching group status (GET /api/v4/domains//status)
+ * Test fetching group status (GET /api/v4/groups//status)
*/
public function testStatus(): void
{
diff --git a/src/tests/Feature/Controller/Reseller/ResourcesTest.php b/src/tests/Feature/Controller/Reseller/ResourcesTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Controller/Reseller/ResourcesTest.php
@@ -0,0 +1,180 @@
+getTestUser('john@kolab.org');
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $reseller1 = $this->getTestUser('reseller@' . \config('app.domain'));
+ $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local');
+ $resource = $this->getTestResource('resource-test1@kolab.org');
+
+ // Non-admin user
+ $response = $this->actingAs($user)->get("api/v4/resources");
+ $response->assertStatus(403);
+
+ // Admin user
+ $response = $this->actingAs($admin)->get("api/v4/resources");
+ $response->assertStatus(403);
+
+ // Search with no search criteria
+ $response = $this->actingAs($reseller1)->get("api/v4/resources");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertSame([], $json['list']);
+
+ // Search with no matches expected
+ $response = $this->actingAs($reseller1)->get("api/v4/resources?search=john@kolab.org");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertSame([], $json['list']);
+
+ // Search by email
+ $response = $this->actingAs($reseller1)->get("api/v4/resources?search={$resource->email}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(1, $json['count']);
+ $this->assertCount(1, $json['list']);
+ $this->assertSame($resource->email, $json['list'][0]['email']);
+
+ // Search by owner
+ $response = $this->actingAs($reseller1)->get("api/v4/resources?owner={$user->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(2, $json['count']);
+ $this->assertCount(2, $json['list']);
+ $this->assertSame($resource->email, $json['list'][0]['email']);
+ $this->assertSame($resource->name, $json['list'][0]['name']);
+
+ // Search by owner (Ned is a controller on John's wallets,
+ // here we expect only resources assigned to Ned's wallet(s))
+ $ned = $this->getTestUser('ned@kolab.org');
+ $response = $this->actingAs($reseller1)->get("api/v4/resources?owner={$ned->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertCount(0, $json['list']);
+
+ $response = $this->actingAs($reseller2)->get("api/v4/resources?search={$resource->email}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertSame([], $json['list']);
+
+ $response = $this->actingAs($reseller2)->get("api/v4/resources?owner={$user->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertSame([], $json['list']);
+ }
+
+ /**
+ * Test fetching resource info (GET /api/v4/resources/)
+ */
+ public function testShow(): void
+ {
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $user = $this->getTestUser('john@kolab.org');
+ $reseller1 = $this->getTestUser('reseller@' . \config('app.domain'));
+ $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local');
+ $resource = $this->getTestResource('resource-test1@kolab.org');
+
+ // Only resellers can access it
+ $response = $this->actingAs($user)->get("api/v4/resources/{$resource->id}");
+ $response->assertStatus(403);
+
+ $response = $this->actingAs($admin)->get("api/v4/resources/{$resource->id}");
+ $response->assertStatus(403);
+
+ $response = $this->actingAs($reseller2)->get("api/v4/resources/{$resource->id}");
+ $response->assertStatus(404);
+
+ $response = $this->actingAs($reseller1)->get("api/v4/resources/{$resource->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertEquals($resource->id, $json['id']);
+ $this->assertEquals($resource->email, $json['email']);
+ $this->assertEquals($resource->name, $json['name']);
+ }
+
+ /**
+ * Test fetching resource status (GET /api/v4/resources//status)
+ */
+ public function testStatus(): void
+ {
+ Queue::fake(); // disable jobs
+
+ $reseller1 = $this->getTestUser('reseller@' . \config('app.domain'));
+ $resource = $this->getTestResource('resource-test1@kolab.org');
+
+ // This end-point does not exist for resources
+ $response = $this->actingAs($reseller1)->get("/api/v4/resources/{$resource->id}/status");
+ $response->assertStatus(404);
+ }
+
+ /**
+ * Test resources creating (POST /api/v4/resources)
+ */
+ public function testStore(): void
+ {
+ $user = $this->getTestUser('john@kolab.org');
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $reseller1 = $this->getTestUser('reseller@' . \config('app.domain'));
+
+ // Test unauthorized access to reseller API
+ $response = $this->actingAs($user)->post("/api/v4/resources", []);
+ $response->assertStatus(403);
+
+ // Reseller or admin can't create resources
+ $response = $this->actingAs($admin)->post("/api/v4/resources", []);
+ $response->assertStatus(403);
+
+ $response = $this->actingAs($reseller1)->post("/api/v4/resources", []);
+ $response->assertStatus(404);
+ }
+}
diff --git a/src/tests/Feature/Controller/Reseller/SkusTest.php b/src/tests/Feature/Controller/Reseller/SkusTest.php
--- a/src/tests/Feature/Controller/Reseller/SkusTest.php
+++ b/src/tests/Feature/Controller/Reseller/SkusTest.php
@@ -99,7 +99,7 @@
$json = $response->json();
- $this->assertCount(9, $json);
+ $this->assertCount(11, $json);
$this->assertSame(100, $json[0]['prio']);
$this->assertSame($sku->id, $json[0]['id']);
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/Controller/SkusTest.php b/src/tests/Feature/Controller/SkusTest.php
--- a/src/tests/Feature/Controller/SkusTest.php
+++ b/src/tests/Feature/Controller/SkusTest.php
@@ -105,7 +105,7 @@
$json = $response->json();
- $this->assertCount(9, $json);
+ $this->assertCount(11, $json);
$this->assertSame(100, $json[0]['prio']);
$this->assertSame($sku->id, $json[0]['id']);
@@ -215,7 +215,7 @@
$json = $response->json();
- $this->assertCount(8, $json);
+ $this->assertCount(9, $json);
$this->assertSkuElement('beta', $json[6], [
'prio' => 10,
@@ -225,7 +225,16 @@
'readonly' => false,
]);
- $this->assertSkuElement('distlist', $json[7], [
+ $this->assertSkuElement('beta-resources', $json[7], [
+ 'prio' => 10,
+ 'type' => 'user',
+ 'handler' => 'resources', // TODO: shouldn't it be beta-resources or beta/resources?
+ 'enabled' => false,
+ 'readonly' => false,
+ 'required' => ['beta'],
+ ]);
+
+ $this->assertSkuElement('distlist', $json[8], [
'prio' => 10,
'type' => 'user',
'handler' => 'distlist',
diff --git a/src/tests/Feature/DomainTest.php b/src/tests/Feature/DomainTest.php
--- a/src/tests/Feature/DomainTest.php
+++ b/src/tests/Feature/DomainTest.php
@@ -236,6 +236,10 @@
{
Queue::fake();
+ $this->deleteTestUser('user@gmail.com');
+ $this->deleteTestGroup('group@gmail.com');
+ $this->deleteTestResource('resource@gmail.com');
+
// Empty domain
$domain = $this->getTestDomain('gmail.com', [
'status' => Domain::STATUS_NEW,
@@ -244,17 +248,24 @@
$this->assertTrue($domain->isEmpty());
- // TODO: Test with adding a group/alias/user, each separately
+ $this->getTestUser('user@gmail.com');
+ $this->assertFalse($domain->isEmpty());
+ $this->deleteTestUser('user@gmail.com');
+ $this->assertTrue($domain->isEmpty());
+ $this->getTestGroup('group@gmail.com');
+ $this->assertFalse($domain->isEmpty());
+ $this->deleteTestGroup('group@gmail.com');
+ $this->assertTrue($domain->isEmpty());
+ $this->getTestResource('resource@gmail.com');
+ $this->assertFalse($domain->isEmpty());
+ $this->deleteTestResource('resource@gmail.com');
+
+ // TODO: Test with an existing alias, but not other objects in a domain
// Empty public domain
$domain = Domain::where('namespace', 'libertymail.net')->first();
$this->assertFalse($domain->isEmpty());
-
- // Non-empty private domain
- $domain = Domain::where('namespace', 'kolab.org')->first();
-
- $this->assertFalse($domain->isEmpty());
}
/**
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,352 @@
+deleteTestUser('user-test@kolabnow.com');
+ Resource::withTrashed()->where('email', 'like', '%@kolabnow.com')->each(function ($resource) {
+ $this->deleteTestResource($resource->email);
+ });
+ }
+
+ public function tearDown(): void
+ {
+ $this->deleteTestUser('user-test@kolabnow.com');
+ Resource::withTrashed()->where('email', 'like', '%@kolabnow.com')->each(function ($resource) {
+ $this->deleteTestResource($resource->email);
+ });
+
+ parent::tearDown();
+ }
+
+ /**
+ * Tests for Resource::assignToWallet()
+ */
+ public function testAssignToWallet(): void
+ {
+ $user = $this->getTestUser('user-test@kolabnow.com');
+ $resource = $this->getTestResource('resource-test@kolabnow.com');
+
+ $result = $resource->assignToWallet($user->wallets->first());
+
+ $this->assertSame($resource, $result);
+ $this->assertSame(1, $resource->entitlements()->count());
+
+ // Can't be done twice on the same resource
+ $this->expectException(\Exception::class);
+ $result->assignToWallet($user->wallets->first());
+ }
+
+ /**
+ * Test Resource::getConfig() and setConfig() methods
+ */
+ public function testConfigTrait(): void
+ {
+ Queue::fake();
+
+ $resource = new Resource();
+ $resource->email = 'resource-test@kolabnow.com';
+ $resource->name = 'Test';
+ $resource->save();
+ $john = $this->getTestUser('john@kolab.org');
+ $resource->assignToWallet($john->wallets->first());
+
+ $this->assertSame(['invitation_policy' => 'accept'], $resource->getConfig());
+
+ $result = $resource->setConfig(['invitation_policy' => 'reject', 'unknown' => false]);
+
+ $this->assertSame(['invitation_policy' => 'reject'], $resource->getConfig());
+ $this->assertSame('reject', $resource->getSetting('invitation_policy'));
+ $this->assertSame(['unknown' => "The requested configuration parameter is not supported."], $result);
+
+ $result = $resource->setConfig(['invitation_policy' => 'unknown']);
+
+ $this->assertSame(['invitation_policy' => 'reject'], $resource->getConfig());
+ $this->assertSame('reject', $resource->getSetting('invitation_policy'));
+ $this->assertSame(['invitation_policy' => "The specified invitation policy is invalid."], $result);
+
+ // Test valid user for manual invitation policy
+ $result = $resource->setConfig(['invitation_policy' => 'manual:john@kolab.org']);
+
+ $this->assertSame(['invitation_policy' => 'manual:john@kolab.org'], $resource->getConfig());
+ $this->assertSame('manual:john@kolab.org', $resource->getSetting('invitation_policy'));
+ $this->assertSame([], $result);
+
+ // Test invalid user email for manual invitation policy
+ $result = $resource->setConfig(['invitation_policy' => 'manual:john']);
+
+ $this->assertSame(['invitation_policy' => 'manual:john@kolab.org'], $resource->getConfig());
+ $this->assertSame('manual:john@kolab.org', $resource->getSetting('invitation_policy'));
+ $this->assertSame(['invitation_policy' => "The specified email address is invalid."], $result);
+
+ // Test non-existing user for manual invitation policy
+ $result = $resource->setConfig(['invitation_policy' => 'manual:unknown@kolab.org']);
+ $this->assertSame(['invitation_policy' => "The specified email address does not exist."], $result);
+
+ // Test existing user from a different wallet, for manual invitation policy
+ $result = $resource->setConfig(['invitation_policy' => 'manual:user@sample-tenant.dev-local']);
+ $this->assertSame(['invitation_policy' => "The specified email address does not exist."], $result);
+ }
+
+ /**
+ * Test creating a resource
+ */
+ public function testCreate(): void
+ {
+ Queue::fake();
+
+ $resource = new Resource();
+ $resource->name = 'Reśo';
+ $resource->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
@@ -19,6 +19,7 @@
$this->deleteTestUser('UserAccountB@UserAccount.com');
$this->deleteTestUser('UserAccountC@UserAccount.com');
$this->deleteTestGroup('test-group@UserAccount.com');
+ $this->deleteTestResource('test-resource@UserAccount.com');
$this->deleteTestDomain('UserAccount.com');
$this->deleteTestDomain('UserAccountAdd.com');
}
@@ -31,6 +32,7 @@
$this->deleteTestUser('UserAccountB@UserAccount.com');
$this->deleteTestUser('UserAccountC@UserAccount.com');
$this->deleteTestGroup('test-group@UserAccount.com');
+ $this->deleteTestResource('test-resource@UserAccount.com');
$this->deleteTestDomain('UserAccount.com');
$this->deleteTestDomain('UserAccountAdd.com');
@@ -439,7 +441,7 @@
$this->assertCount(0, User::withTrashed()->where('id', $id)->get());
- // Test an account with users, domain, and group
+ // Test an account with users, domain, and group, and resource
$userA = $this->getTestUser('UserAccountA@UserAccount.com');
$userB = $this->getTestUser('UserAccountB@UserAccount.com');
$userC = $this->getTestUser('UserAccountC@UserAccount.com');
@@ -455,18 +457,22 @@
$userA->assignPackage($package_kolab, $userC);
$group = $this->getTestGroup('test-group@UserAccount.com');
$group->assignToWallet($userA->wallets->first());
+ $resource = $this->getTestResource('test-resource@UserAccount.com', ['name' => 'test']);
+ $resource->assignToWallet($userA->wallets->first());
$entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id);
$entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id);
$entitlementsC = \App\Entitlement::where('entitleable_id', $userC->id);
$entitlementsDomain = \App\Entitlement::where('entitleable_id', $domain->id);
$entitlementsGroup = \App\Entitlement::where('entitleable_id', $group->id);
+ $entitlementsResource = \App\Entitlement::where('entitleable_id', $resource->id);
$this->assertSame(7, $entitlementsA->count());
$this->assertSame(7, $entitlementsB->count());
$this->assertSame(7, $entitlementsC->count());
$this->assertSame(1, $entitlementsDomain->count());
$this->assertSame(1, $entitlementsGroup->count());
+ $this->assertSame(1, $entitlementsResource->count());
// Delete non-controller user
$userC->delete();
@@ -482,14 +488,17 @@
$this->assertSame(0, $entitlementsB->count());
$this->assertSame(0, $entitlementsDomain->count());
$this->assertSame(0, $entitlementsGroup->count());
+ $this->assertSame(0, $entitlementsResource->count());
$this->assertTrue($userA->fresh()->trashed());
$this->assertTrue($userB->fresh()->trashed());
$this->assertTrue($domain->fresh()->trashed());
$this->assertTrue($group->fresh()->trashed());
+ $this->assertTrue($resource->fresh()->trashed());
$this->assertFalse($userA->isDeleted());
$this->assertFalse($userB->isDeleted());
$this->assertFalse($domain->isDeleted());
$this->assertFalse($group->isDeleted());
+ $this->assertFalse($resource->isDeleted());
$userA->forceDelete();
@@ -501,6 +510,7 @@
$this->assertCount(0, User::withTrashed()->where('id', $userC->id)->get());
$this->assertCount(0, Domain::withTrashed()->where('id', $domain->id)->get());
$this->assertCount(0, Group::withTrashed()->where('id', $group->id)->get());
+ $this->assertCount(0, \App\Resource::withTrashed()->where('id', $resource->id)->get());
}
/**
@@ -695,6 +705,32 @@
}
/**
+ * Test resources() method
+ */
+ public function testResources(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $ned = $this->getTestUser('ned@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+
+ $resources = $john->resources()->orderBy('email')->get();
+
+ $this->assertSame(2, $resources->count());
+ $this->assertSame('resource-test1@kolab.org', $resources[0]->email);
+ $this->assertSame('resource-test2@kolab.org', $resources[1]->email);
+
+ $resources = $ned->resources()->orderBy('email')->get();
+
+ $this->assertSame(2, $resources->count());
+ $this->assertSame('resource-test1@kolab.org', $resources[0]->email);
+ $this->assertSame('resource-test2@kolab.org', $resources[1]->email);
+
+ $resources = $jack->resources()->get();
+
+ $this->assertSame(0, $resources->count());
+ }
+
+ /**
* Test user restoring
*/
public function testRestore(): void
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;
@@ -90,10 +92,22 @@
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();
}
@@ -282,6 +297,27 @@
}
/**
+ * 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.
*
* @coversNothing
@@ -339,6 +375,38 @@
}
/**
+ * 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(