Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117361248
D3044.1774814900.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
216 KB
Referenced Files
None
Subscribers
None
D3044.1774814900.diff
View Options
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 @@
+<?php
+
+namespace App\Console\Commands\Resource;
+
+use App\Console\Command;
+
+class VerifyCommand extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'resource:verify {resource}';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = 'Verify the state of a resource';
+
+ /**
+ * Execute the console command.
+ *
+ * @return mixed
+ */
+ public function handle()
+ {
+ $resource = $this->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 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Console\ObjectListCommand;
+
+class ResourcesCommand extends ObjectListCommand
+{
+ protected $objectClass = \App\Resource::class;
+ protected $objectName = 'resource';
+ protected $objectTitle = 'name';
+}
diff --git a/src/app/Domain.php b/src/app/Domain.php
--- a/src/app/Domain.php
+++ b/src/app/Domain.php
@@ -383,6 +383,7 @@
\App\User::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->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 @@
+<?php
+
+namespace App\Handlers\Beta;
+
+class Resources extends Base
+{
+ /**
+ * The entitleable class for this handler.
+ *
+ * @return string
+ */
+ public static function entitleableClass(): string
+ {
+ return \App\User::class;
+ }
+
+ /**
+ * Check if the SKU is available to the user/domain.
+ *
+ * @param \App\Sku $sku The SKU object
+ * @param \App\User|\App\Domain $object The user or domain object
+ *
+ * @return bool
+ */
+ public static function isAvailable(\App\Sku $sku, $object): bool
+ {
+ // This SKU must be:
+ // - already assigned, or active and a 'beta' entitlement must exist
+ // - and this is a group account owner (custom domain)
+
+ if (parent::isAvailable($sku, $object)) {
+ return $object->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 @@
+<?php
+
+namespace App\Http\Controllers\API\V4\Admin;
+
+use App\Resource;
+use App\User;
+use Illuminate\Http\Request;
+
+class ResourcesController extends \App\Http\Controllers\API\V4\ResourcesController
+{
+ /**
+ * Search for resources
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function index()
+ {
+ $search = trim(request()->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 @@
+<?php
+
+namespace App\Http\Controllers\API\V4\Reseller;
+
+use App\Resource;
+use App\User;
+
+class ResourcesController extends \App\Http\Controllers\API\V4\Admin\ResourcesController
+{
+ /**
+ * Search for resources
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function index()
+ {
+ $search = trim(request()->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 @@
+<?php
+
+namespace App\Http\Controllers\API\V4;
+
+use App\Http\Controllers\Controller;
+use App\Resource;
+use App\Rules\ResourceName;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Validator;
+
+class ResourcesController extends Controller
+{
+ /** @var array Common object properties in the API response */
+ protected static $objectProps = ['email', 'name'];
+
+ /**
+ * Show the form for creating a new resource.
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function create()
+ {
+ return $this->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 @@
+<?php
+
+namespace App\Jobs\Resource;
+
+use App\Jobs\ResourceJob;
+
+class CreateJob extends ResourceJob
+{
+ /**
+ * Execute the job.
+ *
+ * @return void
+ */
+ public function handle()
+ {
+ $resource = $this->getResource();
+
+ if (!$resource) {
+ return;
+ }
+
+ // sanity checks
+ if ($resource->isDeleted()) {
+ $this->fail(new \Exception("Resource {$this->resourceId} is marked as deleted."));
+ return;
+ }
+
+ if ($resource->trashed()) {
+ $this->fail(new \Exception("Resource {$this->resourceId} is actually deleted."));
+ return;
+ }
+
+ if ($resource->isLdapReady()) {
+ $this->fail(new \Exception("Resource {$this->resourceId} is already marked as ldap-ready."));
+ return;
+ }
+
+ // 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 @@
+<?php
+
+namespace App\Jobs\Resource;
+
+use App\Jobs\ResourceJob;
+
+class DeleteJob extends ResourceJob
+{
+ /**
+ * Execute the job.
+ *
+ * @return void
+ */
+ public function handle()
+ {
+ $resource = $this->getResource();
+
+ if (!$resource) {
+ return;
+ }
+
+ // sanity checks
+ if ($resource->isDeleted()) {
+ $this->fail(new \Exception("Resource {$this->resourceId} is already marked as deleted."));
+ return;
+ }
+
+ \App\Backends\LDAP::deleteResource($resource);
+
+ $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 @@
+<?php
+
+namespace App\Jobs\Resource;
+
+use App\Jobs\ResourceJob;
+
+class UpdateJob extends ResourceJob
+{
+ /**
+ * Execute the job.
+ *
+ * @return void
+ */
+ public function handle()
+ {
+ $resource = $this->getResource();
+
+ if (!$resource) {
+ return;
+ }
+
+ // Cancel the update if the resource is deleted or not yet in LDAP
+ if (!$resource->isLdapReady() || $resource->isDeleted()) {
+ $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 @@
+<?php
+
+namespace App\Jobs\Resource;
+
+use App\Jobs\ResourceJob;
+
+class VerifyJob extends ResourceJob
+{
+ /**
+ * Execute the job.
+ *
+ * @return void
+ */
+ public function handle()
+ {
+ $resource = $this->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 @@
+<?php
+
+namespace App\Jobs;
+
+/**
+ * The abstract \App\Jobs\ResourceJob implements the logic needed for all dispatchable Jobs related to
+ * \App\Resource objects.
+ *
+ * ```php
+ * $job = new \App\Jobs\Resource\CreateJob($resourceId);
+ * $job->handle();
+ * ```
+ */
+abstract class ResourceJob extends CommonJob
+{
+ /**
+ * 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 @@
+<?php
+
+namespace App\Observers;
+
+use App\Resource;
+
+class ResourceObserver
+{
+ /**
+ * Handle the resource "creating" event.
+ *
+ * @param \App\Resource $resource The resource
+ *
+ * @return void
+ */
+ public function creating(Resource $resource): void
+ {
+ if (empty($resource->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 @@
+<?php
+
+namespace App\Observers;
+
+use App\Backends\LDAP;
+use App\ResourceSetting;
+
+class ResourceSettingObserver
+{
+ /**
+ * Handle the resource setting "created" event.
+ *
+ * @param \App\ResourceSetting $resourceSetting Settings object
+ *
+ * @return void
+ */
+ public function created(ResourceSetting $resourceSetting)
+ {
+ if (in_array($resourceSetting->key, LDAP::RESOURCE_SETTINGS)) {
+ \App\Jobs\Resource\UpdateJob::dispatch($resourceSetting->resource_id);
+ }
+ }
+
+ /**
+ * 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 @@
+<?php
+
+namespace App;
+
+use App\Traits\BelongsToTenantTrait;
+use App\Traits\EntitleableTrait;
+use App\Traits\ResourceConfigTrait;
+use App\Traits\SettingsTrait;
+use App\Traits\UuidIntKeyTrait;
+use App\Wallet;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\SoftDeletes;
+
+/**
+ * The eloquent definition of a Resource.
+ *
+ * @property int $id The resource identifier
+ * @property string $email An email address
+ * @property string $name The resource name
+ * @property int $status The resource status
+ * @property int $tenant_id Tenant identifier
+ */
+class Resource extends Model
+{
+ use BelongsToTenantTrait;
+ use EntitleableTrait;
+ use ResourceConfigTrait;
+ use SettingsTrait;
+ use SoftDeletes;
+ use UuidIntKeyTrait;
+
+ // we've simply never heard of this resource
+ public const STATUS_NEW = 1 << 0;
+ // resource has been activated
+ public const STATUS_ACTIVE = 1 << 1;
+ // resource has been suspended.
+ // public const STATUS_SUSPENDED = 1 << 2;
+ // resource has been deleted
+ public const STATUS_DELETED = 1 << 3;
+ // resource has been created in LDAP
+ public const STATUS_LDAP_READY = 1 << 4;
+ // resource has been created in IMAP
+ public const STATUS_IMAP_READY = 1 << 8;
+
+ protected $fillable = [
+ 'email',
+ 'name',
+ 'status',
+ ];
+
+ /**
+ * @var ?string Domain name for a resource to be created */
+ public $domain;
+
+
+ /**
+ * Assign the resource to a wallet.
+ *
+ * @param \App\Wallet $wallet The wallet
+ *
+ * @return \App\Resource Self
+ * @throws \Exception
+ */
+ public function assignToWallet(Wallet $wallet): Resource
+ {
+ if (empty($this->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 @@
+<?php
+
+namespace App;
+
+use Illuminate\Database\Eloquent\Model;
+
+/**
+ * A collection of settings for a Resource.
+ *
+ * @property int $id
+ * @property int $resource_id
+ * @property string $key
+ * @property string $value
+ */
+class ResourceSetting extends Model
+{
+ protected $fillable = [
+ 'resource_id', 'key', 'value'
+ ];
+
+ /**
+ * The resource to which this setting belongs.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function resource()
+ {
+ return $this->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 @@
+<?php
+
+namespace App\Traits;
+
+use Illuminate\Support\Facades\Validator;
+
+trait ResourceConfigTrait
+{
+ /**
+ * A helper to get a resource configuration.
+ */
+ public function getConfig(): array
+ {
+ $config = [];
+
+ $config['invitation_policy'] = $this->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 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+// phpcs:ignore
+class CreateResourcesTables extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::create(
+ 'resources',
+ function (Blueprint $table) {
+ $table->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 @@
+<?php
+
+namespace Database\Seeds\Local;
+
+use App\Resource;
+use App\User;
+use Illuminate\Database\Seeder;
+
+class ResourceSeeder extends Seeder
+{
+ /**
+ * Run the database seeds.
+ *
+ * @return void
+ */
+ public function run()
+ {
+ $john = User::where('email', 'john@kolab.org')->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 @@
+<template>
+ <div v-if="resource.id" class="container">
+ <div class="card" id="resource-info">
+ <div class="card-body">
+ <div class="card-title">{{ resource.email }}</div>
+ <div class="card-text">
+ <form class="read-only short">
+ <div class="row plaintext">
+ <label for="resourceid" class="col-sm-4 col-form-label">
+ {{ $t('form.id') }} <span class="text-muted">({{ $t('form.created') }})</span>
+ </label>
+ <div class="col-sm-8">
+ <span class="form-control-plaintext" id="resourceid">
+ {{ resource.id }} <span class="text-muted">({{ resource.created_at }})</span>
+ </span>
+ </div>
+ </div>
+ <div class="row plaintext">
+ <label for="status" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label>
+ <div class="col-sm-8">
+ <span :class="$root.resourceStatusClass(resource) + ' form-control-plaintext'" id="status">{{ $root.resourceStatusText(resource) }}</span>
+ </div>
+ </div>
+ <div class="row plaintext">
+ <label for="name" class="col-sm-4 col-form-label">{{ $t('form.name') }}</label>
+ <div class="col-sm-8">
+ <span class="form-control-plaintext" id="name">{{ resource.name }}</span>
+ </div>
+ </div>
+ </form>
+ </div>
+ </div>
+ </div>
+ <ul class="nav nav-tabs mt-3" role="tablist">
+ <li class="nav-item">
+ <a class="nav-link active" id="tab-settings" href="#resource-settings" role="tab" aria-controls="resource-settings" aria-selected="false" @click="$root.tab">
+ {{ $t('form.settings') }}
+ </a>
+ </li>
+ </ul>
+ <div class="tab-content">
+ <div class="tab-pane show active" id="resource-settings" role="tabpanel" aria-labelledby="tab-settings">
+ <div class="card-body">
+ <div class="card-text">
+ <form class="read-only short">
+ <div class="row plaintext">
+ <label for="invitation_policy" class="col-sm-4 col-form-label">{{ $t('resource.invitation-policy') }}</label>
+ <div class="col-sm-8">
+ <span class="form-control-plaintext" id="invitation_policy">
+ {{ resource.config.invitation_policy || $t('form.none') }}
+ </span>
+ </div>
+ </div>
+ </form>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+ export default {
+ data() {
+ return {
+ resource: { config: {} }
+ }
+ },
+ created() {
+ this.$root.startLoading()
+
+ axios.get('/api/v4/resources/' + this.$route.params.resource)
+ .then(response => {
+ this.$root.stopLoading()
+ this.resource = response.data
+ })
+ .catch(this.$root.errorHandler)
+ }
+ }
+</script>
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 @@
</a>
</li>
<li class="nav-item">
+ <a class="nav-link" id="tab-resources" href="#user-resources" role="tab" aria-controls="user-resources" aria-selected="false">
+ {{ $t('user.resources') }} ({{ resources.length }})
+ </a>
+ </li>
+ <li class="nav-item">
<a class="nav-link" id="tab-settings" href="#user-settings" role="tab" aria-controls="user-settings" aria-selected="false">
Settings
</a>
@@ -307,7 +312,37 @@
</tbody>
<tfoot class="table-fake-body">
<tr>
- <td colspan="2">{{ $t('user.distlists-none') }}</td>
+ <td colspan="2">{{ $t('distlist.list-empty') }}</td>
+ </tr>
+ </tfoot>
+ </table>
+ </div>
+ </div>
+ </div>
+ <div class="tab-pane" id="user-resources" role="tabpanel" aria-labelledby="tab-resources">
+ <div class="card-body">
+ <div class="card-text">
+ <table class="table table-sm table-hover mb-0">
+ <thead>
+ <tr>
+ <th scope="col">{{ $t('form.name') }}</th>
+ <th scope="col">{{ $t('form.email') }}</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="resource in resources" :key="resource.id" @click="$root.clickRecord">
+ <td>
+ <svg-icon icon="cog" :class="$root.resourceStatusClass(resource)" :title="$root.resourceStatusText(resource)"></svg-icon>
+ <router-link :to="{ path: '/resource/' + resource.id }">{{ resource.name }}</router-link>
+ </td>
+ <td>
+ <router-link :to="{ path: '/resource/' + resource.id }">{{ resource.email }}</router-link>
+ </td>
+ </tr>
+ </tbody>
+ <tfoot class="table-fake-body">
+ <tr>
+ <td colspan="2">{{ $t('resource.list-empty') }}</td>
</tr>
</tfoot>
</table>
@@ -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 @@
<router-link v-if="status.enableDistlists" class="card link-distlists" :to="{ name: 'distlists' }">
<svg-icon icon="users"></svg-icon><span class="name">{{ $t('dashboard.distlists') }}</span>
</router-link>
+ <router-link v-if="status.enableResources" class="card link-resources" :to="{ name: 'resources' }">
+ <svg-icon icon="cog"></svg-icon><span class="name">{{ $t('dashboard.resources') }}</span>
+ </router-link>
<router-link v-if="status.enableWallets" class="card link-wallet" :to="{ name: 'wallet' }">
<svg-icon icon="wallet"></svg-icon><span class="name">{{ $t('dashboard.wallet') }}</span>
<span v-if="balance < 0" class="badge bg-danger">{{ $root.price(balance, currency) }}</span>
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 @@
+<template>
+ <div class="container">
+ <status-component v-if="resource_id !== 'new'" :status="status" @status-update="statusUpdate"></status-component>
+
+ <div class="card" id="resource-info">
+ <div class="card-body">
+ <div class="card-title" v-if="resource_id !== 'new'">
+ {{ $tc('resource.list-title', 1) }}
+ <button class="btn btn-outline-danger button-delete float-end" @click="deleteResource()" tag="button">
+ <svg-icon icon="trash-alt"></svg-icon> {{ $t('resource.delete') }}
+ </button>
+ </div>
+ <div class="card-title" v-if="resource_id === 'new'">{{ $t('resource.new') }}</div>
+ <div class="card-text">
+ <ul class="nav nav-tabs mt-3" role="tablist">
+ <li class="nav-item">
+ <a class="nav-link active" id="tab-general" href="#general" role="tab" aria-controls="general" aria-selected="true" @click="$root.tab">
+ {{ $t('form.general') }}
+ </a>
+ </li>
+ <li v-if="resource_id !== 'new'" class="nav-item">
+ <a class="nav-link" id="tab-settings" href="#settings" role="tab" aria-controls="settings" aria-selected="false" @click="$root.tab">
+ {{ $t('form.settings') }}
+ </a>
+ </li>
+ </ul>
+ <div class="tab-content">
+ <div class="tab-pane show active" id="general" role="tabpanel" aria-labelledby="tab-general">
+ <form @submit.prevent="submit" class="card-body">
+ <div v-if="resource_id !== 'new'" class="row plaintext mb-3">
+ <label for="status" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label>
+ <div class="col-sm-8">
+ <span :class="$root.resourceStatusClass(resource) + ' form-control-plaintext'" id="status">{{ $root.resourceStatusText(resource) }}</span>
+ </div>
+ </div>
+ <div class="row mb-3">
+ <label for="name" class="col-sm-4 col-form-label">{{ $t('form.name') }}</label>
+ <div class="col-sm-8">
+ <input type="text" class="form-control" id="name" v-model="resource.name">
+ </div>
+ </div>
+ <div v-if="domains.length" class="row mb-3">
+ <label for="domain" class="col-sm-4 col-form-label">{{ $t('form.domain') }}</label>
+ <div class="col-sm-8">
+ <select class="form-select" v-model="resource.domain">
+ <option v-for="_domain in domains" :key="_domain" :value="_domain.namespace">{{ _domain.namespace }}</option>
+ </select>
+ </div>
+ </div>
+ <div v-if="resource.email" class="row mb-3">
+ <label for="email" class="col-sm-4 col-form-label">{{ $t('form.email') }}</label>
+ <div class="col-sm-8">
+ <input type="text" class="form-control" id="email" disabled v-model="resource.email">
+ </div>
+ </div>
+ <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}</button>
+ </form>
+ </div>
+ <div class="tab-pane" id="settings" role="tabpanel" aria-labelledby="tab-settings">
+ <form @submit.prevent="submitSettings" class="card-body">
+ <div class="row mb-3">
+ <label for="invitation_policy" class="col-sm-4 col-form-label">{{ $t('resource.invitation-policy') }}</label>
+ <div class="col-sm-8">
+ <div class="input-group input-group-select mb-1">
+ <select class="form-select" id="invitation_policy" v-model="resource.config.invitation_policy" @change="policyChange">
+ <option value="accept">{{ $t('resource.ipolicy-accept') }}</option>
+ <option value="manual">{{ $t('resource.ipolicy-manual') }}</option>
+ <option value="reject">{{ $t('resource.ipolicy-reject') }}</option>
+ </select>
+ <input type="text" class="form-control" id="owner" v-model="resource.config.owner" :placeholder="$t('form.email')">
+ </div>
+ <small id="invitation-policy-hint" class="text-muted">
+ {{ $t('resource.invitation-policy-text') }}
+ </small>
+ </div>
+ </div>
+ <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}</button>
+ </form>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+ import StatusComponent from '../Widgets/Status'
+
+ export default {
+ components: {
+ StatusComponent
+ },
+ data() {
+ return {
+ domains: [],
+ resource_id: null,
+ resource: { config: {} },
+ status: {}
+ }
+ },
+ created() {
+ this.resource_id = this.$route.params.resource
+
+ if (this.resource_id != 'new') {
+ this.$root.startLoading()
+
+ axios.get('/api/v4/resources/' + this.resource_id)
+ .then(response => {
+ this.$root.stopLoading()
+ this.resource = response.data
+ this.status = response.data.statusInfo
+
+ if (this.resource.config.invitation_policy.match(/^manual:(.+)$/)) {
+ this.resource.config.owner = RegExp.$1
+ this.resource.config.invitation_policy = 'manual'
+ }
+ this.$nextTick().then(() => { this.policyChange() })
+ })
+ .catch(this.$root.errorHandler)
+ } else {
+ this.$root.startLoading()
+
+ axios.get('/api/v4/domains')
+ .then(response => {
+ this.$root.stopLoading()
+ this.domains = response.data
+ this.resource.domain = this.domains[0].namespace
+ })
+ .catch(this.$root.errorHandler)
+ }
+ },
+ mounted() {
+ $('#name').focus()
+ },
+ methods: {
+ deleteResource() {
+ axios.delete('/api/v4/resources/' + this.resource_id)
+ .then(response => {
+ if (response.data.status == 'success') {
+ this.$toast.success(response.data.message)
+ this.$router.push({ name: 'resources' })
+ }
+ })
+ },
+ policyChange() {
+ let select = $('#invitation_policy')
+ select.parent()[select.val() == 'manual' ? 'addClass' : 'removeClass']('selected')
+ },
+ statusUpdate(resource) {
+ this.resource = Object.assign({}, this.resource, resource)
+ },
+ submit() {
+ this.$root.clearFormValidation($('#resource-info form'))
+
+ let method = 'post'
+ let location = '/api/v4/resources'
+
+ if (this.resource_id !== 'new') {
+ method = 'put'
+ location += '/' + this.resource_id
+ }
+
+ const post = this.$root.pick(this.resource, ['id', 'name', 'domain'])
+
+ axios[method](location, post)
+ .then(response => {
+ this.$toast.success(response.data.message)
+ this.$router.push({ name: 'resources' })
+ })
+ },
+ submitSettings() {
+ this.$root.clearFormValidation($('#settings form'))
+ let post = {...this.resource.config}
+
+ if (post.invitation_policy == 'manual') {
+ post.invitation_policy += ':' + post.owner
+ }
+
+ delete post.owner
+
+ axios.post('/api/v4/resources/' + this.resource_id + '/config', post)
+ .then(response => {
+ this.$toast.success(response.data.message)
+ })
+ }
+ }
+ }
+</script>
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 @@
+<template>
+ <div class="container">
+ <div class="card" id="resource-list">
+ <div class="card-body">
+ <div class="card-title">
+ {{ $tc('resource.list-title', 2) }}
+ <router-link class="btn btn-success float-end create-resource" :to="{ path: 'resource/new' }" tag="button">
+ <svg-icon icon="cog"></svg-icon> {{ $t('resource.create') }}
+ </router-link>
+ </div>
+ <div class="card-text">
+ <table class="table table-sm table-hover">
+ <thead>
+ <tr>
+ <th scope="col">{{ $t('form.name') }}</th>
+ <th scope="col">{{ $t('form.email') }}</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="resource in resources" :key="resource.id" @click="$root.clickRecord">
+ <td>
+ <svg-icon icon="cog" :class="$root.resourceStatusClass(resource)" :title="$root.resourceStatusText(resource)"></svg-icon>
+ <router-link :to="{ path: 'resource/' + resource.id }">{{ resource.name }}</router-link>
+ </td>
+ <td>
+ <router-link :to="{ path: 'resource/' + resource.id }">{{ resource.email }}</router-link>
+ </td>
+ </tr>
+ </tbody>
+ <tfoot class="table-fake-body">
+ <tr>
+ <td colspan="2">{{ $t('resource.list-empty') }}</td>
+ </tr>
+ </tfoot>
+ </table>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+ export default {
+ data() {
+ return {
+ resources: []
+ }
+ },
+ created() {
+ this.$root.startLoading()
+
+ axios.get('/api/v4/resources')
+ .then(response => {
+ this.$root.stopLoading()
+ this.resources = response.data
+ })
+ .catch(this.$root.errorHandler)
+ }
+ }
+</script>
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 @@
<span v-if="scope == 'dashboard'">{{ $t('status.prepare-account') }}</span>
<span v-else-if="scope == 'domain'">{{ $t('status.prepare-domain') }}</span>
<span v-else-if="scope == 'distlist'">{{ $t('status.prepare-distlist') }}</span>
+ <span v-else-if="scope == 'resource'">{{ $t('status.prepare-resource') }}</span>
<span v-else>{{ $t('status.prepare-user') }}</span>
<br>
{{ $t('status.prepare-hint') }}
@@ -20,6 +21,7 @@
<span v-if="scope == 'dashboard'">{{ $t('status.ready-account') }}</span>
<span v-else-if="scope == 'domain'">{{ $t('status.ready-domain') }}</span>
<span v-else-if="scope == 'distlist'">{{ $t('status.ready-distlist') }}</span>
+ <span v-else-if="scope == 'resource'">{{ $t('status.ready-resource') }}</span>
<span v-else>{{ $t('status.ready-user') }}</span>
<br>
{{ $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 @@
+<?php
+
+namespace Tests\Browser\Admin;
+
+use App\Resource;
+use Illuminate\Support\Facades\Queue;
+use Tests\Browser;
+use Tests\Browser\Components\Toast;
+use Tests\Browser\Pages\Admin\Resource as ResourcePage;
+use Tests\Browser\Pages\Admin\User as UserPage;
+use Tests\Browser\Pages\Dashboard;
+use Tests\Browser\Pages\Home;
+use Tests\TestCaseDusk;
+
+class ResourceTest extends TestCaseDusk
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ self::useAdminUrl();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ parent::tearDown();
+ }
+
+ /**
+ * Test resource info page (unauthenticated)
+ */
+ public function testResourceUnauth(): void
+ {
+ // Test that the page requires authentication
+ $this->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 @@
+<?php
+
+namespace Tests\Browser\Pages;
+
+use Laravel\Dusk\Page;
+
+class ResourceInfo extends Page
+{
+ /**
+ * Get the URL for the page.
+ *
+ * @return string
+ */
+ public function url(): string
+ {
+ return '';
+ }
+
+ /**
+ * Assert that the browser is on the page.
+ *
+ * @param \Laravel\Dusk\Browser $browser The browser object
+ *
+ * @return void
+ */
+ public function assert($browser)
+ {
+ $browser->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 @@
+<?php
+
+namespace Tests\Browser\Pages;
+
+use Laravel\Dusk\Page;
+
+class ResourceList extends Page
+{
+ /**
+ * Get the URL for the page.
+ *
+ * @return string
+ */
+ public function url(): string
+ {
+ return '/resources';
+ }
+
+ /**
+ * Assert that the browser is on the page.
+ *
+ * @param \Laravel\Dusk\Browser $browser The browser object
+ *
+ * @return void
+ */
+ public function assert($browser)
+ {
+ $browser->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 @@
+<?php
+
+namespace Tests\Browser\Reseller;
+
+use App\Resource;
+use Illuminate\Support\Facades\Queue;
+use Tests\Browser;
+use Tests\Browser\Components\Toast;
+use Tests\Browser\Pages\Admin\Resource as ResourcePage;
+use Tests\Browser\Pages\Admin\User as UserPage;
+use Tests\Browser\Pages\Dashboard;
+use Tests\Browser\Pages\Home;
+use Tests\TestCaseDusk;
+
+class ResourceTest extends TestCaseDusk
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ self::useResellerUrl();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ parent::tearDown();
+ }
+
+ /**
+ * Test resource info page (unauthenticated)
+ */
+ public function testResourceUnauth(): void
+ {
+ // Test that the page requires authentication
+ $this->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 @@
+<?php
+
+namespace Tests\Browser;
+
+use App\Resource;
+use Tests\Browser;
+use Tests\Browser\Components\Status;
+use Tests\Browser\Components\Toast;
+use Tests\Browser\Pages\Dashboard;
+use Tests\Browser\Pages\Home;
+use Tests\Browser\Pages\ResourceInfo;
+use Tests\Browser\Pages\ResourceList;
+use Tests\TestCaseDusk;
+
+class ResourceTest extends TestCaseDusk
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ Resource::whereNotIn('email', ['resource-test1@kolab.org', 'resource-test2@kolab.org'])->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/<domain-id>/status)
+ * Test fetching group status (GET /api/v4/groups/<group-id>/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 @@
+<?php
+
+namespace Tests\Feature\Controller\Admin;
+
+use App\Resource;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class ResourcesTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ self::useAdminUrl();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ parent::tearDown();
+ }
+
+ /**
+ * Test resources searching (/api/v4/resources)
+ */
+ public function testIndex(): void
+ {
+ $user = $this->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/<resource-id>)
+ */
+ 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/<resource-id>/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/<domain-id>/status)
+ * Test fetching group status (GET /api/v4/groups/<group-id>/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 @@
+<?php
+
+namespace Tests\Feature\Controller\Reseller;
+
+use App\Resource;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class ResourcesTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ self::useResellerUrl();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ parent::tearDown();
+ }
+
+ /**
+ * Test resources searching (/api/v4/resources)
+ */
+ public function testIndex(): void
+ {
+ $user = $this->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/<resource-id>)
+ */
+ 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/<resource-id>/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 @@
+<?php
+
+namespace Tests\Feature\Controller;
+
+use App\Resource;
+use App\Http\Controllers\API\V4\ResourcesController;
+use Carbon\Carbon;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class ResourcesTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestResource('resource-test@kolab.org');
+ Resource::where('name', 'Test Resource')->delete();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $this->deleteTestResource('resource-test@kolab.org');
+ Resource::where('name', 'Test Resource')->delete();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test resource deleting (DELETE /api/v4/resources/<id>)
+ */
+ public function testDestroy(): void
+ {
+ // First create some groups to delete
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $resource = $this->getTestResource('resource-test@kolab.org');
+ $resource->assignToWallet($john->wallets->first());
+
+ // Test unauth access
+ $response = $this->delete("api/v4/resources/{$resource->id}");
+ $response->assertStatus(401);
+
+ // Test non-existing resource
+ $response = $this->actingAs($john)->delete("api/v4/resources/abc");
+ $response->assertStatus(404);
+
+ // Test access to other user's resource
+ $response = $this->actingAs($jack)->delete("api/v4/resources/{$resource->id}");
+ $response->assertStatus(403);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame("Access denied", $json['message']);
+ $this->assertCount(2, $json);
+
+ // Test removing a resource
+ $response = $this->actingAs($john)->delete("api/v4/resources/{$resource->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertEquals('success', $json['status']);
+ $this->assertEquals("Resource deleted successfully.", $json['message']);
+ }
+
+ /**
+ * Test resources listing (GET /api/v4/resources)
+ */
+ public function testIndex(): void
+ {
+ $jack = $this->getTestUser('jack@kolab.org');
+ $john = $this->getTestUser('john@kolab.org');
+ $ned = $this->getTestUser('ned@kolab.org');
+
+ // Test unauth access
+ $response = $this->get("api/v4/resources");
+ $response->assertStatus(401);
+
+ // Test a user with no resources
+ $response = $this->actingAs($jack)->get("/api/v4/resources");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(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/<resource>/config)
+ */
+ public function testSetConfig(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $resource = $this->getTestResource('resource-test@kolab.org');
+ $resource->assignToWallet($john->wallets->first());
+
+ // Test unknown resource id
+ $post = ['invitation_policy' => 'reject'];
+ $response = $this->actingAs($john)->post("/api/v4/resources/123/config", $post);
+ $json = $response->json();
+
+ $response->assertStatus(404);
+
+ // Test access by user not being a wallet controller
+ $post = ['invitation_policy' => 'reject'];
+ $response = $this->actingAs($jack)->post("/api/v4/resources/{$resource->id}/config", $post);
+ $json = $response->json();
+
+ $response->assertStatus(403);
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame("Access denied", $json['message']);
+ $this->assertCount(2, $json);
+
+ // Test some invalid data
+ $post = ['test' => 1];
+ $response = $this->actingAs($john)->post("/api/v4/resources/{$resource->id}/config", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(2, $json);
+ $this->assertCount(1, $json['errors']);
+ $this->assertSame('The requested configuration parameter is not supported.', $json['errors']['test']);
+
+ $resource->refresh();
+
+ $this->assertNull($resource->getSetting('test'));
+ $this->assertNull($resource->getSetting('invitation_policy'));
+
+ // Test some valid data
+ $post = ['invitation_policy' => 'reject'];
+ $response = $this->actingAs($john)->post("/api/v4/resources/{$resource->id}/config", $post);
+
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("Resource settings updated successfully.", $json['message']);
+
+ $this->assertSame(['invitation_policy' => 'reject'], $resource->fresh()->getConfig());
+
+ // Test input validation
+ $post = ['invitation_policy' => 'aaa'];
+ $response = $this->actingAs($john)->post("/api/v4/resources/{$resource->id}/config", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(1, $json['errors']);
+ $this->assertSame(
+ "The specified invitation policy is invalid.",
+ $json['errors']['invitation_policy']
+ );
+
+ $this->assertSame(['invitation_policy' => 'reject'], $resource->fresh()->getConfig());
+ }
+
+ /**
+ * Test fetching resource data/profile (GET /api/v4/resources/<resource>)
+ */
+ public function testShow(): void
+ {
+ $jack = $this->getTestUser('jack@kolab.org');
+ $john = $this->getTestUser('john@kolab.org');
+ $ned = $this->getTestUser('ned@kolab.org');
+
+ $resource = $this->getTestResource('resource-test@kolab.org');
+ $resource->assignToWallet($john->wallets->first());
+ $resource->setSetting('invitation_policy', 'reject');
+
+ // Test unauthorized access to a profile of other user
+ $response = $this->get("/api/v4/resources/{$resource->id}");
+ $response->assertStatus(401);
+
+ // Test unauthorized access to a resource of another user
+ $response = $this->actingAs($jack)->get("/api/v4/resources/{$resource->id}");
+ $response->assertStatus(403);
+
+ // John: Account owner - non-existing resource
+ $response = $this->actingAs($john)->get("/api/v4/resources/abc");
+ $response->assertStatus(404);
+
+ // John: Account owner
+ $response = $this->actingAs($john)->get("/api/v4/resources/{$resource->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame($resource->id, $json['id']);
+ $this->assertSame($resource->email, $json['email']);
+ $this->assertSame($resource->name, $json['name']);
+ $this->assertTrue(!empty($json['statusInfo']));
+ $this->assertArrayHasKey('isDeleted', $json);
+ $this->assertArrayHasKey('isActive', $json);
+ $this->assertArrayHasKey('isLdapReady', $json);
+ $this->assertArrayHasKey('isImapReady', $json);
+ $this->assertSame(['invitation_policy' => 'reject'], $json['config']);
+ }
+
+ /**
+ * Test fetching a resource status (GET /api/v4/resources/<resource>/status)
+ * and forcing setup process update (?refresh=1)
+ */
+ public function testStatus(): void
+ {
+ Queue::fake();
+
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+
+ $resource = $this->getTestResource('resource-test@kolab.org');
+ $resource->assignToWallet($john->wallets->first());
+
+ // Test unauthorized access
+ $response = $this->get("/api/v4/resources/abc/status");
+ $response->assertStatus(401);
+
+ // Test unauthorized access
+ $response = $this->actingAs($jack)->get("/api/v4/resources/{$resource->id}/status");
+ $response->assertStatus(403);
+
+ $resource->status = Resource::STATUS_NEW | Resource::STATUS_ACTIVE;
+ $resource->save();
+
+ // Get resource status
+ $response = $this->actingAs($john)->get("/api/v4/resources/{$resource->id}/status");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertFalse($json['isLdapReady']);
+ $this->assertFalse($json['isImapReady']);
+ $this->assertFalse($json['isReady']);
+ $this->assertFalse($json['isDeleted']);
+ $this->assertTrue($json['isActive']);
+ $this->assertCount(7, $json['process']);
+ $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/<resource>)
+ */
+ public function testUpdate(): void
+ {
+ Queue::fake();
+
+ $jack = $this->getTestUser('jack@kolab.org');
+ $john = $this->getTestUser('john@kolab.org');
+ $ned = $this->getTestUser('ned@kolab.org');
+
+ $resource = $this->getTestResource('resource-test@kolab.org');
+ $resource->assignToWallet($john->wallets->first());
+
+ // Test unauthorized update
+ $response = $this->get("/api/v4/resources/{$resource->id}", []);
+ $response->assertStatus(401);
+
+ // Test unauthorized update
+ $response = $this->actingAs($jack)->get("/api/v4/resources/{$resource->id}", []);
+ $response->assertStatus(403);
+
+ // Name change
+ $post = [
+ 'name' => 'Test Res',
+ ];
+
+ $response = $this->actingAs($john)->put("/api/v4/resources/{$resource->id}", $post);
+ $json = $response->json();
+
+ $response->assertStatus(200);
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("Resource updated successfully.", $json['message']);
+ $this->assertCount(2, $json);
+
+ $resource->refresh();
+ $this->assertSame($post['name'], $resource->name);
+ }
+}
diff --git a/src/tests/Feature/Controller/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 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Resource;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class ResourceTest extends TestCase
+{
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestUser('user-test@kolabnow.com');
+ Resource::withTrashed()->where('email', 'like', '%@kolabnow.com')->each(function ($resource) {
+ $this->deleteTestResource($resource->email);
+ });
+ }
+
+ public function tearDown(): void
+ {
+ $this->deleteTestUser('user-test@kolabnow.com');
+ Resource::withTrashed()->where('email', 'like', '%@kolabnow.com')->each(function ($resource) {
+ $this->deleteTestResource($resource->email);
+ });
+
+ parent::tearDown();
+ }
+
+ /**
+ * Tests for Resource::assignToWallet()
+ */
+ public function testAssignToWallet(): void
+ {
+ $user = $this->getTestUser('user-test@kolabnow.com');
+ $resource = $this->getTestResource('resource-test@kolabnow.com');
+
+ $result = $resource->assignToWallet($user->wallets->first());
+
+ $this->assertSame($resource, $result);
+ $this->assertSame(1, $resource->entitlements()->count());
+
+ // Can't be done twice on the same resource
+ $this->expectException(\Exception::class);
+ $result->assignToWallet($user->wallets->first());
+ }
+
+ /**
+ * Test Resource::getConfig() and setConfig() methods
+ */
+ public function testConfigTrait(): void
+ {
+ Queue::fake();
+
+ $resource = new Resource();
+ $resource->email = 'resource-test@kolabnow.com';
+ $resource->name = 'Test';
+ $resource->save();
+ $john = $this->getTestUser('john@kolab.org');
+ $resource->assignToWallet($john->wallets->first());
+
+ $this->assertSame(['invitation_policy' => 'accept'], $resource->getConfig());
+
+ $result = $resource->setConfig(['invitation_policy' => 'reject', 'unknown' => false]);
+
+ $this->assertSame(['invitation_policy' => 'reject'], $resource->getConfig());
+ $this->assertSame('reject', $resource->getSetting('invitation_policy'));
+ $this->assertSame(['unknown' => "The requested configuration parameter is not supported."], $result);
+
+ $result = $resource->setConfig(['invitation_policy' => 'unknown']);
+
+ $this->assertSame(['invitation_policy' => 'reject'], $resource->getConfig());
+ $this->assertSame('reject', $resource->getSetting('invitation_policy'));
+ $this->assertSame(['invitation_policy' => "The specified invitation policy is invalid."], $result);
+
+ // Test valid user for manual invitation policy
+ $result = $resource->setConfig(['invitation_policy' => 'manual:john@kolab.org']);
+
+ $this->assertSame(['invitation_policy' => 'manual:john@kolab.org'], $resource->getConfig());
+ $this->assertSame('manual:john@kolab.org', $resource->getSetting('invitation_policy'));
+ $this->assertSame([], $result);
+
+ // Test invalid user email for manual invitation policy
+ $result = $resource->setConfig(['invitation_policy' => 'manual:john']);
+
+ $this->assertSame(['invitation_policy' => 'manual:john@kolab.org'], $resource->getConfig());
+ $this->assertSame('manual:john@kolab.org', $resource->getSetting('invitation_policy'));
+ $this->assertSame(['invitation_policy' => "The specified email address is invalid."], $result);
+
+ // Test non-existing user for manual invitation policy
+ $result = $resource->setConfig(['invitation_policy' => 'manual:unknown@kolab.org']);
+ $this->assertSame(['invitation_policy' => "The specified email address does not exist."], $result);
+
+ // Test existing user from a different wallet, for manual invitation policy
+ $result = $resource->setConfig(['invitation_policy' => 'manual:user@sample-tenant.dev-local']);
+ $this->assertSame(['invitation_policy' => "The specified email address does not exist."], $result);
+ }
+
+ /**
+ * Test creating a resource
+ */
+ public function testCreate(): void
+ {
+ Queue::fake();
+
+ $resource = new Resource();
+ $resource->name = 'Reśo';
+ $resource->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(
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Sun, Mar 29, 8:08 PM (5 d, 16 h ago)
Storage Engine
local-disk
Storage Format
Raw Data
Storage Handle
11/8b/c46c45a6f928d76cbd23fea5c943
Default Alt Text
D3044.1774814900.diff (216 KB)
Attached To
Mode
D3044: Resources
Attached
Detach File
Event Timeline