Page MenuHomePhorge

D3071.1775343348.diff
No OneTemporary

Authored By
Unknown
Size
256 KB
Referenced Files
None
Subscribers
None

D3071.1775343348.diff

This file is larger than 256 KB, so syntax highlighting was skipped.
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
@@ -33,7 +33,7 @@
/**
* Check if a shared folder is set up.
*
- * @param string $folder Folder name, eg. shared/Resources/Name@domain.tld
+ * @param string $folder Folder name, e.g. shared/Resources/Name@domain.tld
*
* @return bool True if a folder exists and is set up, False otherwise
*/
@@ -43,7 +43,7 @@
$imap = self::initIMAP($config);
// Convert the folder from UTF8 to UTF7-IMAP
- if (\preg_match('|^(shared/Resources/)(.*)(@[^@]+)$|', $folder, $matches)) {
+ if (\preg_match('#^(shared/|shared/Resources/)(.+)(@[^@]+)$#', $folder, $matches)) {
$folderName = \mb_convert_encoding($matches[2], 'UTF7-IMAP', 'UTF8');
$folder = $matches[1] . $folderName . $matches[3];
}
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
@@ -5,6 +5,7 @@
use App\Domain;
use App\Group;
use App\Resource;
+use App\SharedFolder;
use App\User;
class LDAP
@@ -20,6 +21,12 @@
'invitation_policy',
];
+ /** @const array Shared folder settings used by the backend */
+ public const SHARED_FOLDER_SETTINGS = [
+ 'folder',
+ 'acl',
+ ];
+
/** @const array User settings used by the backend */
public const USER_SETTINGS = [
'first_name',
@@ -297,6 +304,45 @@
}
/**
+ * Create a shared folder in LDAP.
+ *
+ * @param \App\SharedFolder $folder The shared folder to create.
+ *
+ * @throws \Exception
+ */
+ public static function createSharedFolder(SharedFolder $folder): void
+ {
+ $config = self::getConfig('admin');
+ $ldap = self::initLDAP($config);
+
+ $domainName = explode('@', $folder->email, 2)[1];
+ $cn = $ldap->quote_string($folder->name);
+ $dn = "cn={$cn}," . self::baseDN($domainName, 'Shared Folders');
+
+ $entry = [
+ 'mail' => $folder->email,
+ 'objectclass' => [
+ 'top',
+ 'kolabsharedfolder',
+ 'mailrecipient',
+ ],
+ ];
+
+ self::setSharedFolderAttributes($ldap, $folder, $entry);
+
+ self::addEntry(
+ $ldap,
+ $dn,
+ $entry,
+ "Failed to create shared folder {$folder->id} 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
@@ -457,6 +503,34 @@
}
/**
+ * Delete a shared folder from LDAP.
+ *
+ * @param \App\SharedFolder $folder The shared folder to delete.
+ *
+ * @throws \Exception
+ */
+ public static function deleteSharedFolder(SharedFolder $folder): void
+ {
+ $config = self::getConfig('admin');
+ $ldap = self::initLDAP($config);
+
+ if (self::getSharedFolderEntry($ldap, $folder->email, $dn)) {
+ $result = $ldap->delete_entry($dn);
+
+ if (!$result) {
+ self::throwException(
+ $ldap,
+ "Failed to delete shared folder {$folder->id} from LDAP (" . __LINE__ . ")"
+ );
+ }
+ }
+
+ if (empty(self::$ldap)) {
+ $ldap->close();
+ }
+ }
+
+ /**
* Delete a user from LDAP.
*
* @param \App\User $user The user account to delete.
@@ -555,6 +629,28 @@
}
/**
+ * Get a shared folder data from LDAP.
+ *
+ * @param string $email The resource email.
+ *
+ * @return array|false|null
+ * @throws \Exception
+ */
+ public static function getSharedFolder(string $email)
+ {
+ $config = self::getConfig('admin');
+ $ldap = self::initLDAP($config);
+
+ $folder = self::getSharedFolderEntry($ldap, $email, $dn);
+
+ if (empty(self::$ldap)) {
+ $ldap->close();
+ }
+
+ return $folder;
+ }
+
+ /**
* Get a user data from LDAP.
*
* @param string $email The user email.
@@ -695,6 +791,43 @@
}
/**
+ * Update a shared folder in LDAP.
+ *
+ * @param \App\SharedFolder $folder The shared folder to update
+ *
+ * @throws \Exception
+ */
+ public static function updateSharedFolder(SharedFolder $folder): void
+ {
+ $config = self::getConfig('admin');
+ $ldap = self::initLDAP($config);
+
+ $newEntry = $oldEntry = self::getSharedFolderEntry($ldap, $folder->email, $dn);
+
+ if (empty($oldEntry)) {
+ self::throwException(
+ $ldap,
+ "Failed to update shared folder {$folder->id} in LDAP (folder not found)"
+ );
+ }
+
+ self::setSharedFolderAttributes($ldap, $folder, $newEntry);
+
+ $result = $ldap->modify_entry($dn, $oldEntry, $newEntry);
+
+ if (!is_array($result)) {
+ self::throwException(
+ $ldap,
+ "Failed to update shared folder {$folder->id} in LDAP (" . __LINE__ . ")"
+ );
+ }
+
+ if (empty(self::$ldap)) {
+ $ldap->close();
+ }
+ }
+
+ /**
* Update a user in LDAP.
*
* @param \App\User $user The user account to update.
@@ -887,6 +1020,19 @@
}
/**
+ * Set common shared folder attributes
+ */
+ private static function setSharedFolderAttributes($ldap, SharedFolder $folder, &$entry)
+ {
+ $settings = $folder->getSettings(['acl', 'folder']);
+
+ $entry['cn'] = $folder->name;
+ $entry['kolabfoldertype'] = $folder->type;
+ $entry['kolabtargetfolder'] = $settings['folder'] ?? '';
+ $entry['acl'] = !empty($settings['acl']) ? json_decode($settings['acl'], true) : '';
+ }
+
+ /**
* Set common user attributes
*/
private static function setUserAttributes(User $user, array &$entry)
@@ -1028,6 +1174,28 @@
}
/**
+ * Get a shared folder entry from LDAP.
+ *
+ * @param \Net_LDAP3 $ldap Ldap connection
+ * @param string $email Resource email (mail)
+ * @param string $dn Reference to the shared folder DN
+ *
+ * @return null|array Shared folder entry, NULL if not found
+ */
+ private static function getSharedFolderEntry($ldap, $email, &$dn = null)
+ {
+ $domainName = explode('@', $email, 2)[1];
+ $base_dn = self::baseDN($domainName, 'Shared Folders');
+
+ $attrs = ['dn', 'cn', 'mail', 'objectclass', 'kolabtargetfolder', 'kolabfoldertype', 'acl'];
+
+ // For shared folders we're using search() instead of get_entry() because
+ // a folder 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);
+ }
+
+ /**
* Get user entry from LDAP.
*
* @param \Net_LDAP3 $ldap Ldap connection
diff --git a/src/app/Console/Commands/SharedFoldersCommand.php b/src/app/Console/Commands/SharedFoldersCommand.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/SharedFoldersCommand.php
@@ -0,0 +1,12 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Console\ObjectListCommand;
+
+class SharedFoldersCommand extends ObjectListCommand
+{
+ protected $objectClass = \App\SharedFolder::class;
+ protected $objectName = 'shared-folder';
+ 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
@@ -384,6 +384,7 @@
|| \App\UserAlias::whereRaw('substr(alias, ?) = ?', [-$suffixLen, $suffix])->exists()
|| \App\Group::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists()
|| \App\Resource::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists()
+ || \App\SharedFolder::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists()
);
}
diff --git a/src/app/Handlers/Beta/SharedFolders.php b/src/app/Handlers/Beta/SharedFolders.php
new file mode 100644
--- /dev/null
+++ b/src/app/Handlers/Beta/SharedFolders.php
@@ -0,0 +1,49 @@
+<?php
+
+namespace App\Handlers\Beta;
+
+class SharedFolders 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/SharedFolder.php b/src/app/Handlers/SharedFolder.php
--- a/src/app/Handlers/SharedFolder.php
+++ b/src/app/Handlers/SharedFolder.php
@@ -11,7 +11,6 @@
*/
public static function entitleableClass(): string
{
- // TODO
- return '';
+ return \App\SharedFolder::class;
}
}
diff --git a/src/app/Http/Controllers/API/V4/Admin/SharedFoldersController.php b/src/app/Http/Controllers/API/V4/Admin/SharedFoldersController.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/Admin/SharedFoldersController.php
@@ -0,0 +1,59 @@
+<?php
+
+namespace App\Http\Controllers\API\V4\Admin;
+
+use App\SharedFolder;
+use App\User;
+use Illuminate\Http\Request;
+
+class SharedFoldersController extends \App\Http\Controllers\API\V4\SharedFoldersController
+{
+ /**
+ * Search for shared folders
+ *
+ * @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->sharedFolders(false)->orderBy('name')->get();
+ }
+ } elseif (!empty($search)) {
+ if ($folder = SharedFolder::where('email', $search)->first()) {
+ $result->push($folder);
+ }
+ }
+
+ // Process the result
+ $result = $result->map(
+ function ($folder) {
+ return $this->objectToClient($folder);
+ }
+ );
+
+ $result = [
+ 'list' => $result,
+ 'count' => count($result),
+ 'message' => \trans('app.search-foundxsharedfolders', ['x' => count($result)]),
+ ];
+
+ return response()->json($result);
+ }
+
+ /**
+ * Create a new shared folder.
+ *
+ * @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
@@ -63,6 +63,8 @@
$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();
+ } elseif ($folder = \App\SharedFolder::withTrashed()->where('email', $search)->first()) {
+ $user_ids = $user_ids->merge([$folder->wallet()->user_id])->unique();
}
if (!$user_ids->isEmpty()) {
diff --git a/src/app/Http/Controllers/API/V4/PaymentsController.php b/src/app/Http/Controllers/API/V4/PaymentsController.php
--- a/src/app/Http/Controllers/API/V4/PaymentsController.php
+++ b/src/app/Http/Controllers/API/V4/PaymentsController.php
@@ -167,7 +167,7 @@
// It has to be at least minimum payment amount and must cover current debt
if (
$wallet->balance < 0
- && $wallet->balance * -1 > PaymentProvider::MIN_AMOUNT
+ && $wallet->balance <= PaymentProvider::MIN_AMOUNT * -1
&& $wallet->balance + $amount < 0
) {
return ['amount' => \trans('validation.minamountdebt')];
diff --git a/src/app/Http/Controllers/API/V4/Reseller/SharedFoldersController.php b/src/app/Http/Controllers/API/V4/Reseller/SharedFoldersController.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/Reseller/SharedFoldersController.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace App\Http\Controllers\API\V4\Reseller;
+
+use App\SharedFolder;
+use App\User;
+
+class SharedFoldersController extends \App\Http\Controllers\API\V4\Admin\SharedFoldersController
+{
+ /**
+ * Search for shared folders
+ *
+ * @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->sharedFolders(false)->orderBy('name')->get();
+ }
+ } elseif (!empty($search)) {
+ if ($folder = SharedFolder::withSubjectTenantContext()->where('email', $search)->first()) {
+ $result->push($folder);
+ }
+ }
+
+ // Process the result
+ $result = $result->map(
+ function ($folder) {
+ return $this->objectToClient($folder);
+ }
+ );
+
+ $result = [
+ 'list' => $result,
+ 'count' => count($result),
+ 'message' => \trans('app.search-foundxsharedfolders', ['x' => count($result)]),
+ ];
+
+ return response()->json($result);
+ }
+}
diff --git a/src/app/Http/Controllers/API/V4/Reseller/UsersController.php b/src/app/Http/Controllers/API/V4/Reseller/UsersController.php
--- a/src/app/Http/Controllers/API/V4/Reseller/UsersController.php
+++ b/src/app/Http/Controllers/API/V4/Reseller/UsersController.php
@@ -53,6 +53,10 @@
// Search by a distribution list email
if ($group = 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();
+ } elseif ($folder = \App\SharedFolder::withTrashed()->where('email', $search)->first()) {
+ $user_ids = $user_ids->merge([$folder->wallet()->user_id])->unique();
}
if (!$user_ids->isEmpty()) {
diff --git a/src/app/Http/Controllers/API/V4/ResourcesController.php b/src/app/Http/Controllers/API/V4/ResourcesController.php
--- a/src/app/Http/Controllers/API/V4/ResourcesController.php
+++ b/src/app/Http/Controllers/API/V4/ResourcesController.php
@@ -209,7 +209,6 @@
$v = Validator::make($request->all(), $rules);
if ($v->fails()) {
- $errors = $v->errors()->toArray();
return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
}
diff --git a/src/app/Http/Controllers/API/V4/SharedFoldersController.php b/src/app/Http/Controllers/API/V4/SharedFoldersController.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/SharedFoldersController.php
@@ -0,0 +1,357 @@
+<?php
+
+namespace App\Http\Controllers\API\V4;
+
+use App\Http\Controllers\Controller;
+use App\SharedFolder;
+use App\Rules\SharedFolderName;
+use App\Rules\SharedFolderType;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Validator;
+
+class SharedFoldersController extends Controller
+{
+ /** @var array Common object properties in the API response */
+ protected static $objectProps = ['email', 'name', 'type'];
+
+ /**
+ * Show the form for creating a new shared folder.
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function create()
+ {
+ return $this->errorResponse(404);
+ }
+
+ /**
+ * Delete a shared folder.
+ *
+ * @param int $id Shared folder identifier
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function destroy($id)
+ {
+ $folder = SharedFolder::find($id);
+
+ if (!$this->checkTenant($folder)) {
+ return $this->errorResponse(404);
+ }
+
+ if (!$this->guard()->user()->canDelete($folder)) {
+ return $this->errorResponse(403);
+ }
+
+ $folder->delete();
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => \trans('app.shared-folder-delete-success'),
+ ]);
+ }
+
+ /**
+ * Show the form for editing the specified shared folder.
+ *
+ * @param int $id Shared folder identifier
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function edit($id)
+ {
+ return $this->errorResponse(404);
+ }
+
+ /**
+ * Listing of a shared folders belonging to the authenticated user.
+ *
+ * The shared-folder entitlements billed to the current user wallet(s)
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function index()
+ {
+ $user = $this->guard()->user();
+
+ $result = $user->sharedFolders()->orderBy('name')->get()
+ ->map(function (SharedFolder $folder) {
+ return $this->objectToClient($folder);
+ });
+
+ return response()->json($result);
+ }
+
+ /**
+ * Set the shared folder configuration.
+ *
+ * @param int $id Shared folder identifier
+ *
+ * @return \Illuminate\Http\JsonResponse|void
+ */
+ public function setConfig($id)
+ {
+ $folder = SharedFolder::find($id);
+
+ if (!$this->checkTenant($folder)) {
+ return $this->errorResponse(404);
+ }
+
+ if (!$this->guard()->user()->canUpdate($folder)) {
+ return $this->errorResponse(403);
+ }
+
+ $errors = $folder->setConfig(request()->input());
+
+ if (!empty($errors)) {
+ return response()->json(['status' => 'error', 'errors' => $errors], 422);
+ }
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => \trans('app.shared-folder-setconfig-success'),
+ ]);
+ }
+
+ /**
+ * Display information of a shared folder specified by $id.
+ *
+ * @param int $id Shared folder identifier
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function show($id)
+ {
+ $folder = SharedFolder::find($id);
+
+ if (!$this->checkTenant($folder)) {
+ return $this->errorResponse(404);
+ }
+
+ if (!$this->guard()->user()->canRead($folder)) {
+ return $this->errorResponse(403);
+ }
+
+ $response = $this->objectToClient($folder, true);
+
+ $response['statusInfo'] = self::statusInfo($folder);
+
+ // Shared folder configuration, e.g. acl
+ $response['config'] = $folder->getConfig();
+
+ return response()->json($response);
+ }
+
+ /**
+ * Fetch a shared folder status (and reload setup process)
+ *
+ * @param int $id Shared folder identifier
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function status($id)
+ {
+ $folder = SharedFolder::find($id);
+
+ if (!$this->checkTenant($folder)) {
+ return $this->errorResponse(404);
+ }
+
+ if (!$this->guard()->user()->canRead($folder)) {
+ return $this->errorResponse(403);
+ }
+
+ $response = $this->processStateUpdate($folder);
+ $response = array_merge($response, self::objectState($folder));
+
+ return response()->json($response);
+ }
+
+ /**
+ * SharedFolder status (extended) information
+ *
+ * @param \App\SharedFolder $folder SharedFolder object
+ *
+ * @return array Status information
+ */
+ public static function statusInfo(SharedFolder $folder): array
+ {
+ return self::processStateInfo(
+ $folder,
+ [
+ 'shared-folder-new' => true,
+ 'shared-folder-ldap-ready' => $folder->isLdapReady(),
+ 'shared-folder-imap-ready' => $folder->isImapReady(),
+ ]
+ );
+ }
+
+ /**
+ * Create a new shared folder record.
+ *
+ * @param \Illuminate\Http\Request $request The API request.
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function store(Request $request)
+ {
+ $current_user = $this->guard()->user();
+ $owner = $current_user->wallet()->owner;
+
+ if ($owner->id != $current_user->id) {
+ return $this->errorResponse(403);
+ }
+
+ $domain = request()->input('domain');
+
+ $rules = [
+ 'name' => ['required', 'string', new SharedFolderName($owner, $domain)],
+ 'type' => ['required', 'string', new SharedFolderType()]
+ ];
+
+ $v = Validator::make($request->all(), $rules);
+
+ if ($v->fails()) {
+ return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
+ }
+
+ DB::beginTransaction();
+
+ // Create the shared folder
+ $folder = new SharedFolder();
+ $folder->name = request()->input('name');
+ $folder->type = request()->input('type');
+ $folder->domain = $domain;
+ $folder->save();
+
+ $folder->assignToWallet($owner->wallets->first());
+
+ DB::commit();
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => \trans('app.shared-folder-create-success'),
+ ]);
+ }
+
+ /**
+ * Update a shared folder.
+ *
+ * @param \Illuminate\Http\Request $request The API request.
+ * @param string $id Shared folder identifier
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function update(Request $request, $id)
+ {
+ $folder = SharedFolder::find($id);
+
+ if (!$this->checkTenant($folder)) {
+ return $this->errorResponse(404);
+ }
+
+ $current_user = $this->guard()->user();
+
+ if (!$current_user->canUpdate($folder)) {
+ return $this->errorResponse(403);
+ }
+
+ $owner = $folder->wallet()->owner;
+
+ $name = $request->input('name');
+ $errors = [];
+
+ // Validate the folder name
+ if ($name !== null && $name != $folder->name) {
+ $domainName = explode('@', $folder->email, 2)[1];
+ $rules = ['name' => ['required', 'string', new SharedFolderName($owner, $domainName)]];
+
+ $v = Validator::make($request->all(), $rules);
+
+ if ($v->fails()) {
+ $errors = $v->errors()->toArray();
+ } else {
+ $folder->name = $name;
+ }
+ }
+
+ if (!empty($errors)) {
+ return response()->json(['status' => 'error', 'errors' => $errors], 422);
+ }
+
+ $folder->save();
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => \trans('app.shared-folder-update-success'),
+ ]);
+ }
+
+ /**
+ * Execute (synchronously) specified step in a shared folder setup process.
+ *
+ * @param \App\SharedFolder $folder Shared folder object
+ * @param string $step Step identifier (as in self::statusInfo())
+ *
+ * @return bool|null True if the execution succeeded, False if not, Null when
+ * the job has been sent to the worker (result unknown)
+ */
+ public static function execProcessStep(SharedFolder $folder, string $step): ?bool
+ {
+ try {
+ if (strpos($step, 'domain-') === 0) {
+ return DomainsController::execProcessStep($folder->domain(), $step);
+ }
+
+ switch ($step) {
+ case 'shared-folder-ldap-ready':
+ // Shared folder not in LDAP, create it
+ $job = new \App\Jobs\SharedFolder\CreateJob($folder->id);
+ $job->handle();
+
+ $folder->refresh();
+
+ return $folder->isLdapReady();
+
+ case 'shared-folder-imap-ready':
+ // Shared folder not in IMAP? Verify again
+ // Do it synchronously if the imap admin credentials are available
+ // otherwise let the worker do the job
+ if (!\config('imap.admin_password')) {
+ \App\Jobs\SharedFolder\VerifyJob::dispatch($folder->id);
+
+ return null;
+ }
+
+ $job = new \App\Jobs\SharedFolder\VerifyJob($folder->id);
+ $job->handle();
+
+ $folder->refresh();
+
+ return $folder->isImapReady();
+ }
+ } catch (\Exception $e) {
+ \Log::error($e);
+ }
+
+ return false;
+ }
+
+ /**
+ * Prepare shared folder statuses for the UI
+ *
+ * @param \App\SharedFolder $folder Shared folder object
+ *
+ * @return array Statuses array
+ */
+ protected static function objectState(SharedFolder $folder): array
+ {
+ return [
+ 'isLdapReady' => $folder->isLdapReady(),
+ 'isImapReady' => $folder->isImapReady(),
+ 'isActive' => $folder->isActive(),
+ 'isDeleted' => $folder->isDeleted() || $folder->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
@@ -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 'enableFolders' working for wallet controllers that aren't account owners
+ 'enableFolders' => $isController && $hasCustomDomain && in_array('beta-shared-folders', $skus),
// TODO: Make 'enableResources' working for wallet controllers that aren't account owners
'enableResources' => $isController && $hasCustomDomain && in_array('beta-resources', $skus),
'enableUsers' => $isController,
diff --git a/src/app/Jobs/CommonJob.php b/src/app/Jobs/CommonJob.php
--- a/src/app/Jobs/CommonJob.php
+++ b/src/app/Jobs/CommonJob.php
@@ -30,6 +30,13 @@
public $failureMessage;
/**
+ * The job deleted state.
+ *
+ * @var bool
+ */
+ protected $isDeleted = false;
+
+ /**
* The job released state.
*
* @var bool
@@ -51,6 +58,22 @@
abstract public function handle();
/**
+ * Delete the job from the queue.
+ *
+ * @return void
+ */
+ public function delete()
+ {
+ // We need this for testing purposes
+ $this->isDeleted = true;
+
+ // @phpstan-ignore-next-line
+ if ($this->job) {
+ $this->job->delete();
+ }
+ }
+
+ /**
* Delete the job, call the "failed" method, and raise the failed job event.
*
* @param \Throwable|null $e An Exception
@@ -96,6 +119,16 @@
}
/**
+ * Determine if the job has been deleted.
+ *
+ * @return bool
+ */
+ public function isDeleted(): bool
+ {
+ return $this->isDeleted;
+ }
+
+ /**
* Check if the job was released
*
* @return bool
diff --git a/src/app/Jobs/Resource/VerifyJob.php b/src/app/Jobs/Resource/VerifyJob.php
--- a/src/app/Jobs/Resource/VerifyJob.php
+++ b/src/app/Jobs/Resource/VerifyJob.php
@@ -19,7 +19,7 @@
return;
}
- // the user has a mailbox (or is marked as such)
+ // the resource was already verified
if ($resource->isImapReady()) {
$this->fail(new \Exception("Resource {$this->resourceId} is already verified."));
return;
diff --git a/src/app/Jobs/SharedFolder/CreateJob.php b/src/app/Jobs/SharedFolder/CreateJob.php
new file mode 100644
--- /dev/null
+++ b/src/app/Jobs/SharedFolder/CreateJob.php
@@ -0,0 +1,61 @@
+<?php
+
+namespace App\Jobs\SharedFolder;
+
+use App\Jobs\SharedFolderJob;
+
+class CreateJob extends SharedFolderJob
+{
+ /**
+ * Execute the job.
+ *
+ * @return void
+ */
+ public function handle()
+ {
+ $folder = $this->getSharedFolder();
+
+ if (!$folder) {
+ return;
+ }
+
+ // sanity checks
+ if ($folder->isDeleted()) {
+ $this->fail(new \Exception("Shared folder {$this->folderId} is marked as deleted."));
+ return;
+ }
+
+ if ($folder->trashed()) {
+ $this->fail(new \Exception("Shared folder {$this->folderId} is actually deleted."));
+ return;
+ }
+
+ if ($folder->isLdapReady()) {
+ $this->fail(new \Exception("Shared folder {$this->folderId} is already marked as ldap-ready."));
+ return;
+ }
+
+ // see if the domain is ready
+ $domain = $folder->domain();
+
+ if (!$domain) {
+ $this->fail(new \Exception("The domain for shared folder {$this->folderId} does not exist."));
+ return;
+ }
+
+ if ($domain->isDeleted()) {
+ $this->fail(new \Exception("The domain for shared folder {$this->folderId} is marked as deleted."));
+ return;
+ }
+
+ if (!$domain->isLdapReady()) {
+ $this->release(60);
+ return;
+ }
+
+ \App\Backends\LDAP::createSharedFolder($folder);
+
+ $folder->status |= \App\SharedFolder::STATUS_LDAP_READY;
+ $folder->save();
+ }
+}
diff --git a/src/app/Jobs/SharedFolder/DeleteJob.php b/src/app/Jobs/SharedFolder/DeleteJob.php
new file mode 100644
--- /dev/null
+++ b/src/app/Jobs/SharedFolder/DeleteJob.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace App\Jobs\SharedFolder;
+
+use App\Jobs\SharedFolderJob;
+
+class DeleteJob extends SharedFolderJob
+{
+ /**
+ * Execute the job.
+ *
+ * @return void
+ */
+ public function handle()
+ {
+ $folder = $this->getSharedFolder();
+
+ if (!$folder) {
+ return;
+ }
+
+ // sanity checks
+ if ($folder->isDeleted()) {
+ $this->fail(new \Exception("Shared folder {$this->folderId} is already marked as deleted."));
+ return;
+ }
+
+ \App\Backends\LDAP::deleteSharedFolder($folder);
+
+ $folder->status |= \App\SharedFolder::STATUS_DELETED;
+
+ if ($folder->isLdapReady()) {
+ $folder->status ^= \App\SharedFolder::STATUS_LDAP_READY;
+ }
+
+ if ($folder->isImapReady()) {
+ $folder->status ^= \App\SharedFolder::STATUS_IMAP_READY;
+ }
+
+ $folder->save();
+ }
+}
diff --git a/src/app/Jobs/SharedFolder/UpdateJob.php b/src/app/Jobs/SharedFolder/UpdateJob.php
new file mode 100644
--- /dev/null
+++ b/src/app/Jobs/SharedFolder/UpdateJob.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace App\Jobs\SharedFolder;
+
+use App\Jobs\SharedFolderJob;
+
+class UpdateJob extends SharedFolderJob
+{
+ /**
+ * Execute the job.
+ *
+ * @return void
+ */
+ public function handle()
+ {
+ $folder = $this->getSharedFolder();
+
+ if (!$folder) {
+ return;
+ }
+
+ // Cancel the update if the folder is deleted or not yet in LDAP
+ if (!$folder->isLdapReady() || $folder->isDeleted()) {
+ $this->delete();
+ return;
+ }
+
+ \App\Backends\LDAP::updateSharedFolder($folder);
+ }
+}
diff --git a/src/app/Jobs/SharedFolder/VerifyJob.php b/src/app/Jobs/SharedFolder/VerifyJob.php
new file mode 100644
--- /dev/null
+++ b/src/app/Jobs/SharedFolder/VerifyJob.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace App\Jobs\SharedFolder;
+
+use App\Jobs\SharedFolderJob;
+
+class VerifyJob extends SharedFolderJob
+{
+ /**
+ * Execute the job.
+ *
+ * @return void
+ */
+ public function handle()
+ {
+ $folder = $this->getSharedFolder();
+
+ if (!$folder) {
+ return;
+ }
+
+ // the user has a mailbox (or is marked as such)
+ if ($folder->isImapReady()) {
+ $this->fail(new \Exception("Shared folder {$this->folderId} is already verified."));
+ return;
+ }
+
+ $folderName = $folder->getSetting('folder');
+
+ if (\App\Backends\IMAP::verifySharedFolder($folderName)) {
+ $folder->status |= \App\SharedFolder::STATUS_IMAP_READY;
+ $folder->status |= \App\SharedFolder::STATUS_ACTIVE;
+ $folder->save();
+ }
+ }
+}
diff --git a/src/app/Jobs/SharedFolderJob.php b/src/app/Jobs/SharedFolderJob.php
new file mode 100644
--- /dev/null
+++ b/src/app/Jobs/SharedFolderJob.php
@@ -0,0 +1,72 @@
+<?php
+
+namespace App\Jobs;
+
+/**
+ * The abstract \App\Jobs\SharedFolderJob implements the logic needed for all dispatchable Jobs related to
+ * \App\SharedFolder objects.
+ *
+ * ```php
+ * $job = new \App\Jobs\SharedFolder\CreateJob($folderId);
+ * $job->handle();
+ * ```
+ */
+abstract class SharedFolderJob extends CommonJob
+{
+ /**
+ * The ID for the \App\SharedFolder. This is the shortest globally unique identifier and saves Redis space
+ * compared to a serialized version of the complete \App\SharedFolder object.
+ *
+ * @var int
+ */
+ protected $folderId;
+ /**
+ * The \App\SharedFolder email property, for legibility in the queue management.
+ *
+ * @var string
+ */
+ protected $folderEmail;
+
+ /**
+ * Create a new job instance.
+ *
+ * @param int $folderId The ID for the shared folder to process.
+ *
+ * @return void
+ */
+ public function __construct(int $folderId)
+ {
+ $this->folderId = $folderId;
+
+ $folder = $this->getSharedFolder();
+
+ if ($folder) {
+ $this->folderEmail = $folder->email;
+ }
+ }
+
+ /**
+ * Get the \App\SharedFolder entry associated with this job.
+ *
+ * @return \App\SharedFolder|null
+ *
+ * @throws \Exception
+ */
+ protected function getSharedFolder()
+ {
+ $folder = \App\SharedFolder::withTrashed()->find($this->folderId);
+
+ if (!$folder) {
+ // The record might not exist yet in case of a db replication environment
+ // This will release the job and delay another attempt for 5 seconds
+ if ($this instanceof SharedFolder\CreateJob) {
+ $this->release(5);
+ return null;
+ }
+
+ $this->fail(new \Exception("Shared folder {$this->folderId} could not be found in the database."));
+ }
+
+ return $folder;
+ }
+}
diff --git a/src/app/Observers/SharedFolderObserver.php b/src/app/Observers/SharedFolderObserver.php
new file mode 100644
--- /dev/null
+++ b/src/app/Observers/SharedFolderObserver.php
@@ -0,0 +1,140 @@
+<?php
+
+namespace App\Observers;
+
+use App\SharedFolder;
+
+class SharedFolderObserver
+{
+ /**
+ * Handle the shared folder "creating" event.
+ *
+ * @param \App\SharedFolder $folder The folder
+ *
+ * @return void
+ */
+ public function creating(SharedFolder $folder): void
+ {
+ if (empty($folder->type)) {
+ $folder->type = 'mail';
+ }
+
+ if (empty($folder->email)) {
+ if (!isset($folder->name)) {
+ throw new \Exception("Missing 'domain' property for a new shared folder");
+ }
+
+ $domainName = \strtolower($folder->domain);
+
+ $folder->email = "{$folder->type}-{$folder->id}@{$domainName}";
+ } else {
+ $folder->email = \strtolower($folder->email);
+ }
+
+ $folder->status |= SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE;
+ }
+
+ /**
+ * Handle the shared folder "created" event.
+ *
+ * @param \App\SharedFolder $folder The folder
+ *
+ * @return void
+ */
+ public function created(SharedFolder $folder)
+ {
+ $domainName = explode('@', $folder->email, 2)[1];
+
+ $settings = [
+ 'folder' => "shared/{$folder->name}@{$domainName}",
+ ];
+
+ foreach ($settings as $key => $value) {
+ $settings[$key] = [
+ 'key' => $key,
+ 'value' => $value,
+ 'shared_folder_id' => $folder->id,
+ ];
+ }
+
+ // Note: Don't use setSettings() here to bypass SharedFolderSetting observers
+ // Note: This is a single multi-insert query
+ $folder->settings()->insert(array_values($settings));
+
+ // Create folder record in LDAP, then check if it is created in IMAP
+ $chain = [
+ new \App\Jobs\SharedFolder\VerifyJob($folder->id),
+ ];
+
+ \App\Jobs\SharedFolder\CreateJob::withChain($chain)->dispatch($folder->id);
+ }
+
+ /**
+ * Handle the shared folder "deleting" event.
+ *
+ * @param \App\SharedFolder $folder The folder
+ *
+ * @return void
+ */
+ public function deleting(SharedFolder $folder)
+ {
+ // 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', $folder->id)
+ ->where('entitleable_type', SharedFolder::class)
+ ->delete();
+ }
+
+ /**
+ * Handle the shared folder "deleted" event.
+ *
+ * @param \App\SharedFolder $folder The folder
+ *
+ * @return void
+ */
+ public function deleted(SharedFolder $folder)
+ {
+ if ($folder->isForceDeleting()) {
+ return;
+ }
+
+ \App\Jobs\SharedFolder\DeleteJob::dispatch($folder->id);
+ }
+
+ /**
+ * Handle the shared folder "updated" event.
+ *
+ * @param \App\SharedFolder $folder The folder
+ *
+ * @return void
+ */
+ public function updated(SharedFolder $folder)
+ {
+ \App\Jobs\SharedFolder\UpdateJob::dispatch($folder->id);
+
+ // Update the folder property if name changed
+ if ($folder->name != $folder->getOriginal('name')) {
+ $domainName = explode('@', $folder->email, 2)[1];
+ $folderName = "shared/{$folder->name}@{$domainName}";
+
+ // Note: This does not invoke SharedFolderSetting observer events, good.
+ $folder->settings()->where('key', 'folder')->update(['value' => $folderName]);
+ }
+ }
+
+ /**
+ * Handle the shared folder "force deleted" event.
+ *
+ * @param \App\SharedFolder $folder The folder
+ *
+ * @return void
+ */
+ public function forceDeleted(SharedFolder $folder)
+ {
+ // A folder can be force-deleted separately from the owner
+ // we have to force-delete entitlements
+ \App\Entitlement::where('entitleable_id', $folder->id)
+ ->where('entitleable_type', SharedFolder::class)
+ ->forceDelete();
+ }
+}
diff --git a/src/app/Observers/SharedFolderSettingObserver.php b/src/app/Observers/SharedFolderSettingObserver.php
new file mode 100644
--- /dev/null
+++ b/src/app/Observers/SharedFolderSettingObserver.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace App\Observers;
+
+use App\Backends\LDAP;
+use App\SharedFolderSetting;
+
+class SharedFolderSettingObserver
+{
+ /**
+ * Handle the shared folder setting "created" event.
+ *
+ * @param \App\SharedFolderSetting $folderSetting Settings object
+ *
+ * @return void
+ */
+ public function created(SharedFolderSetting $folderSetting)
+ {
+ if (in_array($folderSetting->key, LDAP::SHARED_FOLDER_SETTINGS)) {
+ \App\Jobs\SharedFolder\UpdateJob::dispatch($folderSetting->shared_folder_id);
+ }
+ }
+
+ /**
+ * Handle the shared folder setting "updated" event.
+ *
+ * @param \App\SharedFolderSetting $folderSetting Settings object
+ *
+ * @return void
+ */
+ public function updated(SharedFolderSetting $folderSetting)
+ {
+ if (in_array($folderSetting->key, LDAP::SHARED_FOLDER_SETTINGS)) {
+ \App\Jobs\SharedFolder\UpdateJob::dispatch($folderSetting->shared_folder_id);
+ }
+ }
+
+ /**
+ * Handle the shared folder setting "deleted" event.
+ *
+ * @param \App\SharedFolderSetting $folderSetting Settings object
+ *
+ * @return void
+ */
+ public function deleted(SharedFolderSetting $folderSetting)
+ {
+ if (in_array($folderSetting->key, LDAP::SHARED_FOLDER_SETTINGS)) {
+ \App\Jobs\SharedFolder\UpdateJob::dispatch($folderSetting->shared_folder_id);
+ }
+ }
+}
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
@@ -6,6 +6,7 @@
use App\Domain;
use App\Group;
use App\Resource;
+use App\SharedFolder;
use App\Transaction;
use App\User;
use App\Wallet;
@@ -149,6 +150,7 @@
$domains = [];
$groups = [];
$resources = [];
+ $folders = [];
$entitlements = [];
foreach ($assignments as $entitlement) {
@@ -160,6 +162,8 @@
$groups[] = $entitlement->entitleable_id;
} elseif ($entitlement->entitleable_type == Resource::class) {
$resources[] = $entitlement->entitleable_id;
+ } elseif ($entitlement->entitleable_type == SharedFolder::class) {
+ $folders[] = $entitlement->entitleable_id;
} else {
$entitlements[] = $entitlement;
}
@@ -191,6 +195,12 @@
}
}
+ if (!empty($folders)) {
+ foreach (SharedFolder::whereIn('id', array_unique($folders))->get() as $_folder) {
+ $_folder->delete();
+ }
+ }
+
foreach ($entitlements as $entitlement) {
$entitlement->delete();
}
@@ -222,6 +232,7 @@
$domains = [];
$groups = [];
$resources = [];
+ $folders = [];
$users = [];
foreach ($assignments as $entitlement) {
@@ -238,6 +249,8 @@
$groups[] = $entitlement->entitleable_id;
} elseif ($entitlement->entitleable_type == Resource::class) {
$resources[] = $entitlement->entitleable_id;
+ } elseif ($entitlement->entitleable_type == SharedFolder::class) {
+ $folders[] = $entitlement->entitleable_id;
}
}
@@ -270,6 +283,11 @@
Resource::withTrashed()->whereIn('id', array_unique($resources))->forceDelete();
}
+ // Shared folders can be just removed
+ if (!empty($folders)) {
+ SharedFolder::withTrashed()->whereIn('id', array_unique($folders))->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
@@ -53,6 +53,8 @@
\App\PlanPackage::observe(\App\Observers\PlanPackageObserver::class);
\App\Resource::observe(\App\Observers\ResourceObserver::class);
\App\ResourceSetting::observe(\App\Observers\ResourceSettingObserver::class);
+ \App\SharedFolder::observe(\App\Observers\SharedFolderObserver::class);
+ \App\SharedFolderSetting::observe(\App\Observers\SharedFolderSettingObserver::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
--- a/src/app/Resource.php
+++ b/src/app/Resource.php
@@ -48,8 +48,7 @@
'status',
];
- /**
- * @var ?string Domain name for a resource to be created */
+ /** @var ?string Domain name for a resource to be created */
public $domain;
diff --git a/src/app/Rules/ResourceName.php b/src/app/Rules/ResourceName.php
--- a/src/app/Rules/ResourceName.php
+++ b/src/app/Rules/ResourceName.php
@@ -12,6 +12,8 @@
private $owner;
private $domain;
+ private const FORBIDDEN_CHARS = '+/^%*!`@(){}|\\?<;"';
+
/**
* Class constructor.
*
@@ -39,13 +41,18 @@
return false;
}
+ if (strcspn($name, self::FORBIDDEN_CHARS) < strlen($name)) {
+ $this->message = \trans('validation.nameinvalid');
+ return false;
+ }
+
// Check the max length, according to the database column length
if (strlen($name) > 191) {
$this->message = \trans('validation.max.string', ['max' => 191]);
return false;
}
- // Check if specified domain is belongs to the user
+ // Check if specified domain 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');
diff --git a/src/app/Rules/ResourceName.php b/src/app/Rules/SharedFolderName.php
copy from src/app/Rules/ResourceName.php
copy to src/app/Rules/SharedFolderName.php
--- a/src/app/Rules/ResourceName.php
+++ b/src/app/Rules/SharedFolderName.php
@@ -6,12 +6,14 @@
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
-class ResourceName implements Rule
+class SharedFolderName implements Rule
{
private $message;
private $owner;
private $domain;
+ private const FORBIDDEN_CHARS = '+/^%*!`@(){}|\\?<;"';
+
/**
* Class constructor.
*
@@ -28,13 +30,18 @@
* Determine if the validation rule passes.
*
* @param string $attribute Attribute name
- * @param mixed $name Resource name input
+ * @param mixed $name Shared folder name input
*
* @return bool
*/
public function passes($attribute, $name): bool
{
- if (empty($name) || !is_string($name)) {
+ if (empty($name) || !is_string($name) || $name == 'Resources') {
+ $this->message = \trans('validation.nameinvalid');
+ return false;
+ }
+
+ if (strcspn($name, self::FORBIDDEN_CHARS) < strlen($name)) {
$this->message = \trans('validation.nameinvalid');
return false;
}
@@ -45,7 +52,7 @@
return false;
}
- // Check if specified domain is belongs to the user
+ // Check if specified domain 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');
@@ -53,10 +60,10 @@
}
// Check if the name is unique in the 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)
+ // FIXME: Maybe just using the whole shared_folders table would be faster than sharedFolders()?
+ $exists = $this->owner->sharedFolders()
+ ->where('shared_folders.name', $name)
+ ->where('shared_folders.email', 'like', '%@' . $this->domain)
->exists();
if ($exists) {
diff --git a/src/app/Rules/SharedFolderType.php b/src/app/Rules/SharedFolderType.php
new file mode 100644
--- /dev/null
+++ b/src/app/Rules/SharedFolderType.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace App\Rules;
+
+use Illuminate\Contracts\Validation\Rule;
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Support\Str;
+
+class SharedFolderType implements Rule
+{
+ private $message;
+
+ /**
+ * Determine if the validation rule passes.
+ *
+ * @param string $attribute Attribute name
+ * @param mixed $type Shared folder type input
+ *
+ * @return bool
+ */
+ public function passes($attribute, $type): bool
+ {
+ if (empty($type) || !is_string($type) || !in_array($type, \App\SharedFolder::SUPPORTED_TYPES)) {
+ $this->message = \trans('validation.entryinvalid', ['attribute' => $attribute]);
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Get the validation error message.
+ *
+ * @return string
+ */
+ public function message(): ?string
+ {
+ return $this->message;
+ }
+}
diff --git a/src/app/Resource.php b/src/app/SharedFolder.php
copy from src/app/Resource.php
copy to src/app/SharedFolder.php
--- a/src/app/Resource.php
+++ b/src/app/SharedFolder.php
@@ -4,7 +4,7 @@
use App\Traits\BelongsToTenantTrait;
use App\Traits\EntitleableTrait;
-use App\Traits\ResourceConfigTrait;
+use App\Traits\SharedFolderConfigTrait;
use App\Traits\SettingsTrait;
use App\Traits\UuidIntKeyTrait;
use App\Wallet;
@@ -12,66 +12,71 @@
use Illuminate\Database\Eloquent\SoftDeletes;
/**
- * The eloquent definition of a Resource.
+ * The eloquent definition of a SharedFolder.
*
- * @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 $id The folder identifier
+ * @property string $name The folder name
+ * @property int $status The folder status
* @property int $tenant_id Tenant identifier
+ * @property string $type The folder type
*/
-class Resource extends Model
+class SharedFolder extends Model
{
use BelongsToTenantTrait;
use EntitleableTrait;
- use ResourceConfigTrait;
+ use SharedFolderConfigTrait;
use SettingsTrait;
use SoftDeletes;
use UuidIntKeyTrait;
- // we've simply never heard of this resource
+ // we've simply never heard of this folder
public const STATUS_NEW = 1 << 0;
- // resource has been activated
+ // folder has been activated
public const STATUS_ACTIVE = 1 << 1;
- // resource has been suspended.
+ // folder has been suspended.
// public const STATUS_SUSPENDED = 1 << 2;
- // resource has been deleted
+ // folder has been deleted
public const STATUS_DELETED = 1 << 3;
- // resource has been created in LDAP
+ // folder has been created in LDAP
public const STATUS_LDAP_READY = 1 << 4;
- // resource has been created in IMAP
+ // folder has been created in IMAP
public const STATUS_IMAP_READY = 1 << 8;
+ /** @const array Supported folder type labels */
+ public const SUPPORTED_TYPES = ['mail', 'event', 'contact', 'task', 'note'];
+
+ /** @var array Mass-assignable properties */
protected $fillable = [
'email',
'name',
'status',
+ 'type',
];
- /**
- * @var ?string Domain name for a resource to be created */
+ /** @var ?string Domain name for a shared folder to be created */
public $domain;
/**
- * Assign the resource to a wallet.
+ * Assign the folder to a wallet.
*
* @param \App\Wallet $wallet The wallet
*
- * @return \App\Resource Self
+ * @return \App\SharedFolder Self
* @throws \Exception
*/
- public function assignToWallet(Wallet $wallet): Resource
+ public function assignToWallet(Wallet $wallet): SharedFolder
{
if (empty($this->id)) {
- throw new \Exception("Resource not yet exists");
+ throw new \Exception("Shared folder not yet exists");
}
if ($this->entitlements()->count()) {
- throw new \Exception("Resource already assigned to a wallet");
+ throw new \Exception("Shared folder already assigned to a wallet");
}
- $sku = \App\Sku::withObjectTenantContext($this)->where('title', 'resource')->first();
+ $sku = \App\Sku::withObjectTenantContext($this)->where('title', 'shared-folder')->first();
$exists = $wallet->entitlements()->where('sku_id', $sku->id)->count();
\App\Entitlement::create([
@@ -80,16 +85,16 @@
'cost' => $exists >= $sku->units_free ? $sku->cost : 0,
'fee' => $exists >= $sku->units_free ? $sku->fee : 0,
'entitleable_id' => $this->id,
- 'entitleable_type' => Resource::class
+ 'entitleable_type' => SharedFolder::class
]);
return $this;
}
/**
- * Returns the resource domain.
+ * Returns the shared folder domain.
*
- * @return ?\App\Domain The domain to which the resource belongs to, NULL if it does not exist
+ * @return ?\App\Domain The domain to which the folder belongs to, NULL if it does not exist
*/
public function domain(): ?Domain
{
@@ -103,14 +108,14 @@
}
/**
- * Find whether an email address exists as a resource (including deleted resources).
+ * Find whether an email address exists as a shared folder (including deleted folders).
*
- * @param string $email Email address
- * @param bool $return_resource Return Resource instance instead of boolean
+ * @param string $email Email address
+ * @param bool $return_folder Return SharedFolder instance instead of boolean
*
- * @return \App\Resource|bool True or Resource model object if found, False otherwise
+ * @return \App\SharedFolder|bool True or Resource model object if found, False otherwise
*/
- public static function emailExists(string $email, bool $return_resource = false)
+ public static function emailExists(string $email, bool $return_folder = false)
{
if (strpos($email, '@') === false) {
return false;
@@ -118,17 +123,17 @@
$email = \strtolower($email);
- $resource = self::withTrashed()->where('email', $email)->first();
+ $folder = self::withTrashed()->where('email', $email)->first();
- if ($resource) {
- return $return_resource ? $resource : true;
+ if ($folder) {
+ return $return_folder ? $folder : true;
}
return false;
}
/**
- * Returns whether this resource is active.
+ * Returns whether this folder is active.
*
* @return bool
*/
@@ -138,7 +143,7 @@
}
/**
- * Returns whether this resource is deleted.
+ * Returns whether this folder is deleted.
*
* @return bool
*/
@@ -148,7 +153,7 @@
}
/**
- * Returns whether this resource's folder exists in IMAP.
+ * Returns whether this folder exists in IMAP.
*
* @return bool
*/
@@ -158,7 +163,7 @@
}
/**
- * Returns whether this resource is registered in LDAP.
+ * Returns whether this folder is registered in LDAP.
*
* @return bool
*/
@@ -168,7 +173,7 @@
}
/**
- * Returns whether this resource is new.
+ * Returns whether this folder is new.
*
* @return bool
*/
@@ -178,7 +183,7 @@
}
/**
- * Resource status mutator
+ * Folder status mutator
*
* @throws \Exception
*/
@@ -202,9 +207,23 @@
}
if ($status > 0) {
- throw new \Exception("Invalid resource status: {$status}");
+ throw new \Exception("Invalid shared folder status: {$status}");
}
$this->attributes['status'] = $new_status;
}
+
+ /**
+ * Folder type mutator
+ *
+ * @throws \Exception
+ */
+ public function setTypeAttribute($type)
+ {
+ if (!in_array($type, self::SUPPORTED_TYPES)) {
+ throw new \Exception("Invalid shared folder type: {$type}");
+ }
+
+ $this->attributes['type'] = $type;
+ }
}
diff --git a/src/app/SharedFolderSetting.php b/src/app/SharedFolderSetting.php
new file mode 100644
--- /dev/null
+++ b/src/app/SharedFolderSetting.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace App;
+
+use Illuminate\Database\Eloquent\Model;
+
+/**
+ * A collection of settings for a SharedFolder.
+ *
+ * @property int $id
+ * @property int $shared_folder_id
+ * @property string $key
+ * @property string $value
+ */
+class SharedFolderSetting extends Model
+{
+ protected $fillable = [
+ 'shared_folder_id', 'key', 'value'
+ ];
+
+ /**
+ * The folder to which this setting belongs.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function folder()
+ {
+ return $this->belongsTo(\App\SharedFolder::class, 'shared_folder_id', 'id');
+ }
+}
diff --git a/src/app/Traits/SharedFolderConfigTrait.php b/src/app/Traits/SharedFolderConfigTrait.php
new file mode 100644
--- /dev/null
+++ b/src/app/Traits/SharedFolderConfigTrait.php
@@ -0,0 +1,118 @@
+<?php
+
+namespace App\Traits;
+
+use Illuminate\Support\Facades\Validator;
+
+trait SharedFolderConfigTrait
+{
+ /**
+ * A helper to get a shared folder configuration.
+ */
+ public function getConfig(): array
+ {
+ $config = [];
+
+ $settings = $this->getSettings(['acl']);
+
+ $config['acl'] = !empty($settings['acl']) ? json_decode($settings['acl'], true) : [];
+
+ return $config;
+ }
+
+ /**
+ * A helper to update a shared folder 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 acl
+ if ($key === 'acl') {
+ // Here's the list of acl labels supported by kolabd
+ // 'all': 'lrsedntxakcpiw',
+ // 'append': 'wip',
+ // 'full': 'lrswipkxtecdn',
+ // 'read': 'lrs',
+ // 'read-only': 'lrs',
+ // 'read-write': 'lrswitedn',
+ // 'post': 'p',
+ // 'semi-full': 'lrswit',
+ // 'write': 'lrswite',
+ // For now we support read-only, read-write, and full
+
+ if (!is_array($value)) {
+ $value = (array) $value;
+ }
+
+ $users = [];
+
+ foreach ($value as $i => $v) {
+ if (!is_string($v) || empty($v) || !substr_count($v, ',')) {
+ $errors[$key][$i] = \trans('validation.acl-entry-invalid');
+ } else {
+ list($user, $acl) = explode(',', $v, 2);
+ $user = trim($user);
+ $acl = trim($acl);
+ $error = null;
+
+ if (
+ !in_array($acl, ['read-only', 'read-write', 'full'])
+ || ($error = $this->validateAclIdentifier($user))
+ || in_array($user, $users)
+ ) {
+ $errors[$key][$i] = $error ?: \trans('validation.acl-entry-invalid');
+ }
+
+ $value[$i] = "$user, $acl";
+ $users[] = $user;
+ }
+ }
+
+ if (empty($errors[$key])) {
+ $this->setSetting($key, json_encode($value));
+ }
+ } else {
+ $errors[$key] = \trans('validation.invalid-config-parameter');
+ }
+ }
+
+ return $errors;
+ }
+
+ /**
+ * Validate an ACL identifier.
+ *
+ * @param string $identifier Email address or a special identifier
+ *
+ * @return ?string Error message on validation error
+ */
+ protected function validateAclIdentifier(string $identifier): ?string
+ {
+ if ($identifier === 'anyone') {
+ return null;
+ }
+
+ $v = Validator::make(['email' => $identifier], ['email' => 'required|email']);
+
+ if ($v->fails()) {
+ return \trans('validation.emailinvalid');
+ }
+
+ $user = \App\User::where('email', \strtolower($identifier))->first();
+
+ // The user and shared folder 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
@@ -591,6 +591,29 @@
->where('entitlements.entitleable_type', \App\Resource::class);
}
+ /**
+ * Return shared folders controlled by the current user.
+ *
+ * @param bool $with_accounts Include folders assigned to wallets
+ * the current user controls but not owns.
+ *
+ * @return \Illuminate\Database\Eloquent\Builder Query builder
+ */
+ public function sharedFolders($with_accounts = true)
+ {
+ $wallets = $this->wallets()->pluck('id')->all();
+
+ if ($with_accounts) {
+ $wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all());
+ }
+
+ return \App\SharedFolder::select(['shared_folders.*', 'entitlements.wallet_id'])
+ ->distinct()
+ ->join('entitlements', 'entitlements.entitleable_id', '=', 'shared_folders.id')
+ ->whereIn('entitlements.wallet_id', $wallets)
+ ->where('entitlements.entitleable_type', \App\SharedFolder::class);
+ }
+
public function senderPolicyFrameworkWhitelist($clientName)
{
$setting = $this->getSetting('spf_whitelist');
diff --git a/src/database/migrations/2021_11_25_100000_create_shared_folders_table.php b/src/database/migrations/2021_11_25_100000_create_shared_folders_table.php
new file mode 100644
--- /dev/null
+++ b/src/database/migrations/2021_11_25_100000_create_shared_folders_table.php
@@ -0,0 +1,83 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+// phpcs:ignore
+class CreateSharedFoldersTable extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::create(
+ 'shared_folders',
+ function (Blueprint $table) {
+ $table->unsignedBigInteger('id');
+ $table->string('email')->unique();
+ $table->string('name');
+ $table->string('type', 8);
+ $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(
+ 'shared_folder_settings',
+ function (Blueprint $table) {
+ $table->bigIncrements('id');
+ $table->unsignedBigInteger('shared_folder_id');
+ $table->string('key');
+ $table->text('value');
+ $table->timestamps();
+
+ $table->foreign('shared_folder_id')->references('id')->on('shared_folders')
+ ->onDelete('cascade')->onUpdate('cascade');
+
+ $table->unique(['shared_folder_id', 'key']);
+ }
+ );
+
+ \App\Sku::where('title', 'shared_folder')->update([
+ 'active' => true,
+ 'cost' => 0,
+ 'title' => 'shared-folder',
+ ]);
+
+ if (!\App\Sku::where('title', 'beta-shared-folders')->first()) {
+ \App\Sku::create([
+ 'title' => 'beta-shared-folders',
+ 'name' => 'Shared folders',
+ 'description' => 'Access to shared folders',
+ 'cost' => 0,
+ 'units_free' => 0,
+ 'period' => 'monthly',
+ 'handler_class' => 'App\Handlers\Beta\SharedFolders',
+ 'active' => true,
+ ]);
+ }
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::dropIfExists('shared_folder_settings');
+ Schema::dropIfExists('shared_folders');
+
+ // 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
@@ -26,6 +26,7 @@
'OpenViduRoomSeeder',
'OauthClientSeeder',
'ResourceSeeder',
+ 'SharedFolderSeeder',
];
$env = ucfirst(App::environment());
diff --git a/src/database/seeds/local/SharedFolderSeeder.php b/src/database/seeds/local/SharedFolderSeeder.php
new file mode 100644
--- /dev/null
+++ b/src/database/seeds/local/SharedFolderSeeder.php
@@ -0,0 +1,35 @@
+<?php
+
+namespace Database\Seeds\Local;
+
+use App\SharedFolder;
+use App\User;
+use Illuminate\Database\Seeder;
+
+class SharedFolderSeeder extends Seeder
+{
+ /**
+ * Run the database seeds.
+ *
+ * @return void
+ */
+ public function run()
+ {
+ $john = User::where('email', 'john@kolab.org')->first();
+ $wallet = $john->wallets()->first();
+
+ $folder = SharedFolder::create([
+ 'name' => 'Calendar',
+ 'email' => 'folder-event@kolab.org',
+ 'type' => 'event',
+ ]);
+ $folder->assignToWallet($wallet);
+
+ $folder = SharedFolder::create([
+ 'name' => 'Contacts',
+ 'email' => 'folder-contact@kolab.org',
+ 'type' => 'contact',
+ ]);
+ $folder->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
@@ -116,13 +116,13 @@
Sku::create(
[
- 'title' => 'shared_folder',
+ 'title' => 'shared-folder',
'name' => 'Shared Folder',
'description' => 'A shared folder',
'cost' => 89,
'period' => 'monthly',
'handler_class' => 'App\Handlers\SharedFolder',
- 'active' => false,
+ 'active' => true,
]
);
@@ -240,6 +240,22 @@
]);
}
+ // Check existence because migration might have added this already
+ $sku = Sku::where(['title' => 'beta-shared-folders', 'tenant_id' => \config('app.tenant_id')])->first();
+
+ if (!$sku) {
+ Sku::create([
+ 'title' => 'beta-shared-folders',
+ 'name' => 'Shared folders',
+ 'description' => 'Access to shared folders',
+ 'cost' => 0,
+ 'units_free' => 0,
+ 'period' => 'monthly',
+ 'handler_class' => 'App\Handlers\Beta\SharedFolders',
+ '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
@@ -116,7 +116,7 @@
Sku::create(
[
- 'title' => 'shared_folder',
+ 'title' => 'shared-folder',
'name' => 'Shared Folder',
'description' => 'A shared folder',
'cost' => 89,
@@ -227,5 +227,19 @@
'active' => true,
]);
}
+
+ // Check existence because migration might have added this already
+ if (!Sku::where('title', 'beta-shared-folders')->first()) {
+ Sku::create([
+ 'title' => 'beta-shared-folders',
+ 'name' => 'Shared folders',
+ 'description' => 'Access to shared folders',
+ 'cost' => 0,
+ 'units_free' => 0,
+ 'period' => 'monthly',
+ 'handler_class' => 'App\Handlers\Beta\SharedFolders',
+ 'active' => true,
+ ]);
+ }
}
}
diff --git a/src/phpstan.neon b/src/phpstan.neon
--- a/src/phpstan.neon
+++ b/src/phpstan.neon
@@ -12,4 +12,5 @@
processTimeout: 300.0
paths:
- app/
+ - config/
- tests/
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
@@ -5,6 +5,7 @@
import LogoutComponent from '../../vue/Logout'
import PageComponent from '../../vue/Page'
import ResourceComponent from '../../vue/Admin/Resource'
+import SharedFolderComponent from '../../vue/Admin/SharedFolder'
import StatsComponent from '../../vue/Admin/Stats'
import UserComponent from '../../vue/Admin/User'
@@ -48,6 +49,12 @@
meta: { requiresAuth: true }
},
{
+ path: '/shared-folder/:folder',
+ name: 'shared-folder',
+ component: SharedFolderComponent,
+ 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
@@ -351,6 +351,12 @@
return this.$t('status.active')
},
+ folderStatusClass(folder) {
+ return this.userStatusClass(folder)
+ },
+ folderStatusText(folder) {
+ return this.userStatusText(folder)
+ },
pageName(path) {
let page = this.$route.path
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
@@ -15,6 +15,7 @@
faComments,
faDownload,
faEnvelope,
+ faFolderOpen,
faGlobe,
faUniversity,
faExclamationCircle,
@@ -50,6 +51,7 @@
faDownload,
faEnvelope,
faExclamationCircle,
+ faFolderOpen,
faGlobe,
faInfoCircle,
faLock,
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
@@ -6,6 +6,7 @@
import LogoutComponent from '../../vue/Logout'
import PageComponent from '../../vue/Page'
import ResourceComponent from '../../vue/Admin/Resource'
+import SharedFolderComponent from '../../vue/Admin/SharedFolder'
import StatsComponent from '../../vue/Reseller/Stats'
import UserComponent from '../../vue/Admin/User'
import WalletComponent from '../../vue/Wallet'
@@ -56,6 +57,12 @@
meta: { requiresAuth: true }
},
{
+ path: '/shared-folder/:folder',
+ name: 'shared-folder',
+ component: SharedFolderComponent,
+ 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
@@ -10,6 +10,8 @@
import PasswordResetComponent from '../../vue/PasswordReset'
import ResourceInfoComponent from '../../vue/Resource/Info'
import ResourceListComponent from '../../vue/Resource/List'
+import SharedFolderInfoComponent from '../../vue/SharedFolder/Info'
+import SharedFolderListComponent from '../../vue/SharedFolder/List'
import SignupComponent from '../../vue/Signup'
import UserInfoComponent from '../../vue/User/Info'
import UserListComponent from '../../vue/User/List'
@@ -105,6 +107,18 @@
meta: { requiresAuth: true }
},
{
+ path: '/shared-folder/:folder',
+ name: 'shared-folder',
+ component: SharedFolderInfoComponent,
+ meta: { requiresAuth: true, perm: 'folders' }
+ },
+ {
+ path: '/shared-folders',
+ name: 'shared-folders',
+ component: SharedFolderListComponent,
+ meta: { requiresAuth: true, perm: 'folders' }
+ },
+ {
path: '/signup/invite/:param',
name: 'signup-invite',
component: SignupComponent
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
@@ -39,6 +39,8 @@
'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-shared-folder-imap-ready' => 'Failed to verify that a shared folder exists.',
+ 'process-error-shared-folder-ldap-ready' => 'Failed to create a shared folder.',
'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...',
@@ -46,6 +48,9 @@
'process-resource-new' => 'Registering a resource...',
'process-resource-imap-ready' => 'Creating a shared folder...',
'process-resource-ldap-ready' => 'Creating a resource...',
+ 'process-shared-folder-new' => 'Registering a shared folder...',
+ 'process-shared-folder-imap-ready' => 'Creating a shared folder...',
+ 'process-shared-folder-ldap-ready' => 'Creating a shared folder...',
'distlist-update-success' => 'Distribution list updated successfully.',
'distlist-create-success' => 'Distribution list created successfully.',
@@ -68,6 +73,11 @@
'resource-delete-success' => 'Resource deleted successfully.',
'resource-setconfig-success' => 'Resource settings updated successfully.',
+ 'shared-folder-update-success' => 'Shared folder updated successfully.',
+ 'shared-folder-create-success' => 'Shared folder created successfully.',
+ 'shared-folder-delete-success' => 'Shared folder deleted successfully.',
+ 'shared-folder-setconfig-success' => 'Shared folder settings updated successfully.',
+
'user-update-success' => 'User data updated successfully.',
'user-create-success' => 'User created successfully.',
'user-delete-success' => 'User deleted successfully.',
@@ -81,6 +91,7 @@
'search-foundxdomains' => ':x domains have been found.',
'search-foundxdistlists' => ':x distribution lists have been found.',
'search-foundxresources' => ':x resources have been found.',
+ 'search-foundxsharedfolders' => ':x shared folders 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
@@ -45,6 +45,7 @@
'invitations' => "Invitations",
'profile' => "Your profile",
'resources' => "Resources",
+ 'shared-folders' => "Shared folders",
'users' => "User accounts",
'wallet' => "Wallet",
'webmail' => "Webmail",
@@ -107,7 +108,12 @@
],
'form' => [
+ 'acl' => "Access rights",
+ 'acl-full' => "All",
+ 'acl-read-only' => "Read-only",
+ 'acl-read-write' => "Read-write",
'amount' => "Amount",
+ 'anyone' => "Anyone",
'code' => "Confirmation Code",
'config' => "Configuration",
'date' => "Date",
@@ -130,6 +136,7 @@
'shared-folder' => "Shared Folder",
'status' => "Status",
'surname' => "Surname",
+ 'type' => "Type",
'user' => "User",
'primary-email' => "Primary Email",
'id' => "ID",
@@ -307,6 +314,21 @@
'new' => "New resource",
],
+ 'shf' => [
+ 'create' => "Create folder",
+ 'delete' => "Delete folder",
+ 'acl-text' => "Defines user permissions to access the shared folder.",
+ 'list-title' => "Shared folder | Shared folders",
+ 'list-empty' => "There are no shared folders in this account.",
+ 'new' => "New shared folder",
+ 'type-mail' => "Mail",
+ 'type-event' => "Calendar",
+ 'type-contact' => "Address Book",
+ 'type-task' => "Tasks",
+ 'type-note' => "Notes",
+ 'type-file' => "Files",
+ ],
+
'signup' => [
'email' => "Existing Email Address",
'login' => "Login",
@@ -322,6 +344,7 @@
'prepare-domain' => "We are preparing the domain.",
'prepare-distlist' => "We are preparing the distribution list.",
'prepare-resource' => "We are preparing the resource.",
+ 'prepare-shared-folder' => "We are preparing the shared folder.",
'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.",
@@ -329,6 +352,7 @@
'ready-domain' => "The domain is almost ready.",
'ready-distlist' => "The distribution list is almost ready.",
'ready-resource' => "The resource is almost ready.",
+ 'ready-shared-folder' => "The shared-folder is almost ready.",
'ready-user' => "The user account is almost ready.",
'verify' => "Verify your domain to finish the setup process.",
'verify-domain' => "Verify domain",
diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php
--- a/src/resources/lang/en/validation.php
+++ b/src/resources/lang/en/validation.php
@@ -140,6 +140,7 @@
'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.',
+ 'acl-entry-invalid' => 'The entry format is invalid. Expected an email address.',
'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.',
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
@@ -28,6 +28,13 @@
}
}
+.acl-input {
+ select.acl,
+ select.mod-user {
+ max-width: fit-content;
+ }
+}
+
.range-input {
display: flex;
diff --git a/src/resources/vue/Admin/SharedFolder.vue b/src/resources/vue/Admin/SharedFolder.vue
new file mode 100644
--- /dev/null
+++ b/src/resources/vue/Admin/SharedFolder.vue
@@ -0,0 +1,91 @@
+<template>
+ <div v-if="folder.id" class="container">
+ <div class="card" id="folder-info">
+ <div class="card-body">
+ <div class="card-title">{{ folder.email }}</div>
+ <div class="card-text">
+ <form class="read-only short">
+ <div class="row plaintext">
+ <label for="folderid" 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="folderid">
+ {{ folder.id }} <span class="text-muted">({{ folder.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.folderStatusClass(folder) + ' form-control-plaintext'" id="status">{{ $root.folderStatusText(folder) }}</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">{{ folder.name }}</span>
+ </div>
+ </div>
+ <div class="row plaintext">
+ <label for="type" class="col-sm-4 col-form-label">{{ $t('form.type') }}</label>
+ <div class="col-sm-8">
+ <span class="form-control-plaintext" id="type">{{ $t('shf.type-' + folder.type) }}</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="#folder-settings" role="tab" aria-controls="folder-settings" aria-selected="false" @click="$root.tab">
+ {{ $t('form.settings') }}
+ </a>
+ </li>
+ </ul>
+ <div class="tab-content">
+ <div class="tab-pane show active" id="folder-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="acl" class="col-sm-4 col-form-label">{{ $t('form.acl') }}</label>
+ <div class="col-sm-8">
+ <span class="form-control-plaintext" id="acl">
+ <span v-if="folder.config.acl.length">
+ <span v-for="(entry, index) in folder.config.acl" :key="index">
+ {{ entry.replace(',', ':') }}<br>
+ </span>
+ </span>
+ <span v-else>{{ $t('form.none') }}</span>
+ </span>
+ </div>
+ </div>
+ </form>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+ export default {
+ data() {
+ return {
+ folder: { config: {} }
+ }
+ },
+ created() {
+ this.$root.startLoading()
+
+ axios.get('/api/v4/shared-folders/' + this.$route.params.folder)
+ .then(response => {
+ this.$root.stopLoading()
+ this.folder = 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
@@ -123,8 +123,13 @@
</a>
</li>
<li class="nav-item">
+ <a class="nav-link" id="tab-shared-folders" href="#user-shared-folders" role="tab" aria-controls="user-shared-folders" aria-selected="false">
+ {{ $t('dashboard.shared-folders') }} ({{ folders.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
+ {{ $t('form.settings') }}
</a>
</li>
</ul>
@@ -349,6 +354,36 @@
</div>
</div>
</div>
+ <div class="tab-pane" id="user-shared-folders" role="tabpanel" aria-labelledby="tab-shared-folders">
+ <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.type') }}</th>
+ <th scope="col">{{ $t('form.email') }}</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="folder in folders" :key="folder.id" @click="$root.clickRecord">
+ <td>
+ <svg-icon icon="folder-open" :class="$root.folderStatusClass(folder)" :title="$root.folderStatusText(folder)"></svg-icon>
+ <router-link :to="{ path: '/shared-folder/' + folder.id }">{{ folder.name }}</router-link>
+ </td>
+ <td>{{ $t('shf.type-' + folder.type) }}</td>
+ <td><router-link :to="{ path: '/shared-folder/' + folder.id }">{{ folder.email }}</router-link></td>
+ </tr>
+ </tbody>
+ <tfoot class="table-fake-body">
+ <tr>
+ <td colspan="3">{{ $t('shf.list-empty') }}</td>
+ </tr>
+ </tfoot>
+ </table>
+ </div>
+ </div>
+ </div>
<div class="tab-pane" id="user-settings" role="tabpanel" aria-labelledby="tab-settings">
<div class="card-body">
<div class="card-text">
@@ -492,6 +527,7 @@
discount_description: '',
discounts: [],
external_email: '',
+ folders: [],
has2FA: false,
hasBeta: false,
wallet: {},
@@ -604,6 +640,12 @@
.then(response => {
this.resources = response.data.list
})
+
+ // Fetch shared folders lists
+ axios.get('/api/v4/shared-folders?owner=' + user_id)
+ .then(response => {
+ this.folders = 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
@@ -18,6 +18,9 @@
<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.enableFolders" class="card link-shared-folders" :to="{ name: 'shared-folders' }">
+ <svg-icon icon="folder-open"></svg-icon><span class="name">{{ $t('dashboard.shared-folders') }}</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
--- a/src/resources/vue/Resource/Info.vue
+++ b/src/resources/vue/Resource/Info.vue
@@ -43,7 +43,7 @@
<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>
+ <option v-for="_domain in domains" :key="_domain.id" :value="_domain.namespace">{{ _domain.namespace }}</option>
</select>
</div>
</div>
diff --git a/src/resources/vue/Resource/Info.vue b/src/resources/vue/SharedFolder/Info.vue
copy from src/resources/vue/Resource/Info.vue
copy to src/resources/vue/SharedFolder/Info.vue
--- a/src/resources/vue/Resource/Info.vue
+++ b/src/resources/vue/SharedFolder/Info.vue
@@ -1,16 +1,16 @@
<template>
<div class="container">
- <status-component v-if="resource_id !== 'new'" :status="status" @status-update="statusUpdate"></status-component>
+ <status-component v-if="folder_id !== 'new'" :status="status" @status-update="statusUpdate"></status-component>
- <div class="card" id="resource-info">
+ <div class="card" id="folder-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') }}
+ <div class="card-title" v-if="folder_id !== 'new'">
+ {{ $tc('shf.list-title', 1) }}
+ <button class="btn btn-outline-danger button-delete float-end" @click="deleteFolder()" tag="button">
+ <svg-icon icon="trash-alt"></svg-icon> {{ $t('shf.delete') }}
</button>
</div>
- <div class="card-title" v-if="resource_id === 'new'">{{ $t('resource.new') }}</div>
+ <div class="card-title" v-if="folder_id === 'new'">{{ $t('shf.new') }}</div>
<div class="card-text">
<ul class="nav nav-tabs mt-3" role="tablist">
<li class="nav-item">
@@ -18,7 +18,7 @@
{{ $t('form.general') }}
</a>
</li>
- <li v-if="resource_id !== 'new'" class="nav-item">
+ <li v-if="folder_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>
@@ -27,30 +27,38 @@
<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">
+ <div v-if="folder_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>
+ <span :class="$root.folderStatusClass(folder) + ' form-control-plaintext'" id="status">{{ $root.folderStatusText(folder) }}</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">
+ <input type="text" class="form-control" id="name" v-model="folder.name">
+ </div>
+ </div>
+ <div class="row mb-3">
+ <label for="type" class="col-sm-4 col-form-label">{{ $t('form.type') }}</label>
+ <div class="col-sm-8">
+ <select id="type" class="form-select" v-model="folder.type" :disabled="folder_id !== 'new'">
+ <option v-for="type in types" :key="type" :value="type">{{ $t('shf.type-' + type) }}</option>
+ </select>
</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>
+ <div v-if="domains.length" class="col-sm-8">
+ <select class="form-select" v-model="folder.domain">
+ <option v-for="_domain in domains" :key="_domain.id" :value="_domain.namespace">{{ _domain.namespace }}</option>
</select>
</div>
</div>
- <div v-if="resource.email" class="row mb-3">
+ <div v-if="folder.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">
+ <input type="text" class="form-control" id="email" disabled v-model="folder.email">
</div>
</div>
<button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}</button>
@@ -59,18 +67,11 @@
<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>
+ <label for="acl-input" class="col-sm-4 col-form-label">{{ $t('form.acl') }}</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') }}
+ <acl-input id="acl" v-model="folder.config.acl" :list="folder.config.acl" class="mb-1"></acl-input>
+ <small id="acl-hint" class="text-muted">
+ {{ $t('shf.acl-text') }}
</small>
</div>
</div>
@@ -85,37 +86,34 @@
</template>
<script>
+ import AclInput from '../Widgets/AclInput'
import StatusComponent from '../Widgets/Status'
export default {
components: {
+ AclInput,
StatusComponent
},
data() {
return {
domains: [],
- resource_id: null,
- resource: { config: {} },
- status: {}
+ folder_id: null,
+ folder: { type: 'mail', config: {} },
+ status: {},
+ types: [ 'mail', 'event', 'task', 'contact', 'note', 'file' ]
}
},
created() {
- this.resource_id = this.$route.params.resource
+ this.folder_id = this.$route.params.folder
- if (this.resource_id != 'new') {
+ if (this.folder_id != 'new') {
this.$root.startLoading()
- axios.get('/api/v4/resources/' + this.resource_id)
+ axios.get('/api/v4/shared-folders/' + this.folder_id)
.then(response => {
this.$root.stopLoading()
- this.resource = response.data
+ this.folder = 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 {
@@ -125,7 +123,7 @@
.then(response => {
this.$root.stopLoading()
this.domains = response.data
- this.resource.domain = this.domains[0].namespace
+ this.folder.domain = this.domains[0].namespace
})
.catch(this.$root.errorHandler)
}
@@ -134,52 +132,42 @@
$('#name').focus()
},
methods: {
- deleteResource() {
- axios.delete('/api/v4/resources/' + this.resource_id)
+ deleteFolder() {
+ axios.delete('/api/v4/shared-folders/' + this.folder_id)
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
- this.$router.push({ name: 'resources' })
+ this.$router.push({ name: 'shared-folders' })
}
})
},
- policyChange() {
- let select = $('#invitation_policy')
- select.parent()[select.val() == 'manual' ? 'addClass' : 'removeClass']('selected')
- },
- statusUpdate(resource) {
- this.resource = Object.assign({}, this.resource, resource)
+ statusUpdate(folder) {
+ this.folder = Object.assign({}, this.folder, folder)
},
submit() {
- this.$root.clearFormValidation($('#resource-info form'))
+ this.$root.clearFormValidation($('#folder-info form'))
let method = 'post'
- let location = '/api/v4/resources'
+ let location = '/api/v4/shared-folders'
- if (this.resource_id !== 'new') {
+ if (this.folder_id !== 'new') {
method = 'put'
- location += '/' + this.resource_id
+ location += '/' + this.folder_id
}
- const post = this.$root.pick(this.resource, ['id', 'name', 'domain'])
+ const post = this.$root.pick(this.folder, ['id', 'name', 'domain', 'type'])
axios[method](location, post)
.then(response => {
this.$toast.success(response.data.message)
- this.$router.push({ name: 'resources' })
+ this.$router.push({ name: 'shared-folders' })
})
},
submitSettings() {
this.$root.clearFormValidation($('#settings form'))
- let post = {...this.resource.config}
-
- if (post.invitation_policy == 'manual') {
- post.invitation_policy += ':' + post.owner
- }
-
- delete post.owner
+ let post = {...this.folder.config}
- axios.post('/api/v4/resources/' + this.resource_id + '/config', post)
+ axios.post('/api/v4/shared-folders/' + this.folder_id + '/config', post)
.then(response => {
this.$toast.success(response.data.message)
})
diff --git a/src/resources/vue/SharedFolder/List.vue b/src/resources/vue/SharedFolder/List.vue
new file mode 100644
--- /dev/null
+++ b/src/resources/vue/SharedFolder/List.vue
@@ -0,0 +1,60 @@
+<template>
+ <div class="container">
+ <div class="card" id="folder-list">
+ <div class="card-body">
+ <div class="card-title">
+ {{ $tc('shf.list-title', 2) }}
+ <router-link class="btn btn-success float-end create-folder" :to="{ path: 'shared-folder/new' }" tag="button">
+ <svg-icon icon="cog"></svg-icon> {{ $t('shf.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.type') }}</th>
+ <th scope="col">{{ $t('form.email') }}</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="folder in folders" :key="folder.id" @click="$root.clickRecord">
+ <td>
+ <svg-icon icon="folder-open" :class="$root.folderStatusClass(folder)" :title="$root.folderStatusText(folder)"></svg-icon>
+ <router-link :to="{ path: 'shared-folder/' + folder.id }">{{ folder.name }}</router-link>
+ </td>
+ <td>{{ $t('shf.type-' + folder.type) }}</td>
+ <td><router-link :to="{ path: 'shared-folder/' + folder.id }">{{ folder.email }}</router-link></td>
+ </tr>
+ </tbody>
+ <tfoot class="table-fake-body">
+ <tr>
+ <td colspan="3">{{ $t('shf.list-empty') }}</td>
+ </tr>
+ </tfoot>
+ </table>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+ export default {
+ data() {
+ return {
+ folders: []
+ }
+ },
+ created() {
+ this.$root.startLoading()
+
+ axios.get('/api/v4/shared-folders')
+ .then(response => {
+ this.$root.stopLoading()
+ this.folders = response.data
+ })
+ .catch(this.$root.errorHandler)
+ }
+ }
+</script>
diff --git a/src/resources/vue/Widgets/AclInput.vue b/src/resources/vue/Widgets/AclInput.vue
new file mode 100644
--- /dev/null
+++ b/src/resources/vue/Widgets/AclInput.vue
@@ -0,0 +1,118 @@
+<template>
+ <div class="list-input acl-input" :id="id">
+ <div class="input-group">
+ <select class="form-select mod mod-user" @change="changeMod" v-model="mod">
+ <option value="user">{{ $t('form.user') }}</option>
+ <option value="anyone">{{ $t('form.anyone') }}</option>
+ </select>
+ <input :id="id + '-input'" type="text" class="form-control main-input" :placeholder="$t('form.email')" @keydown="keyDown">
+ <select class="form-select acl" v-model="perm">
+ <option v-for="t in types" :key="t" :value="t">{{ $t('form.acl-' + t) }}</option>
+ </select>
+ <a href="#" class="btn btn-outline-secondary" @click.prevent="addItem">
+ <svg-icon icon="plus"></svg-icon><span class="visually-hidden">{{ $t('btn.add') }}</span>
+ </a>
+ </div>
+ <div class="input-group" v-for="(item, index) in list" :key="index">
+ <input type="text" class="form-control" :value="aclIdent(item)" :readonly="aclIdent(item) == 'anyone'" :placeholder="$t('form.email')">
+ <select class="form-select acl">
+ <option v-for="t in types" :key="t" :value="t" :selected="aclPerm(item) == t">{{ $t('form.acl-' + t) }}</option>
+ </select>
+ <a href="#" class="btn btn-outline-secondary" @click.prevent="deleteItem(index)">
+ <svg-icon icon="trash-alt"></svg-icon><span class="visually-hidden">{{ $t('btn.delete') }}</span>
+ </a>
+ </div>
+ </div>
+</template>
+
+<script>
+ export default {
+ props: {
+ list: { type: Array, default: () => [] },
+ id: { type: String, default: '' }
+ },
+ data() {
+ return {
+ mod: 'user',
+ perm: 'read-only',
+ types: [ 'read-only', 'read-write', 'full' ]
+ }
+ },
+ mounted() {
+ this.input = $(this.$el).find('.main-input')[0]
+ this.select = $(this.$el).find('select')[0]
+
+ // On form submit add the text from main input to the list
+ // Users tend to forget about pressing the "plus" button
+ // Note: We can't use form.onsubmit (too late)
+ // Note: Use of input.onblur has been proven to be problematic
+ // TODO: What with forms that have no submit button?
+ $(this.$el).closest('form').find('button[type=submit]').on('click', () => {
+ this.updateList()
+ this.addItem(false)
+ })
+ },
+ methods: {
+ aclIdent(item) {
+ return item.split(/\s*,\s*/)[0]
+ },
+ aclPerm(item) {
+ return item.split(/\s*,\s*/)[1]
+ },
+ addItem(focus) {
+ let value = this.input.value
+
+ if (value || this.mod == 'anyone') {
+ if (this.mod == 'anyone') {
+ value = 'anyone'
+ }
+
+ this.$set(this.list, this.list.length, value + ', ' + this.perm)
+
+ this.input.classList.remove('is-invalid')
+ this.input.value = ''
+ this.mod = 'user'
+ this.perm = 'read-only'
+ this.changeMod()
+
+ if (focus !== false) {
+ this.input.focus()
+ }
+
+ if (this.list.length == 1) {
+ this.$el.classList.remove('is-invalid')
+ }
+
+ this.$emit('change', this.$el)
+ }
+ },
+ changeMod() {
+ $(this.input)[this.mod == 'user' ? 'removeClass' : 'addClass']('d-none')
+ $(this.input).prev()[this.mod == 'user' ? 'addClass' : 'removeClass']('mod-user')
+ },
+ deleteItem(index) {
+ this.updateList()
+ this.$delete(this.list, index)
+ this.$emit('change', this.$el)
+
+ if (!this.list.length) {
+ this.$el.classList.remove('is-invalid')
+ }
+ },
+ keyDown(e) {
+ if (e.which == 13 && e.target.value) {
+ this.addItem()
+ e.preventDefault()
+ }
+ },
+ updateList() {
+ // Update this.list to the current state of the html elements
+ $(this.$el).children('.input-group:not(:first-child)').each((index, elem) => {
+ const perm = $(elem).find('select.acl').val()
+ const value = $(elem).find('input').val()
+ this.$set(this.list, index, value + ', ' + perm)
+ })
+ }
+ }
+ }
+</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
@@ -2,11 +2,7 @@
<div v-if="!state.isReady" id="status-box" :class="'p-4 mb-3 rounded process-' + className">
<div v-if="state.step != 'domain-confirmed'" class="d-flex align-items-start">
<p id="status-body" class="flex-grow-1">
- <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>
+ <span>{{ $t('status.prepare-' + scopeLabel()) }}</span>
<br>
{{ $t('status.prepare-hint') }}
<br>
@@ -18,11 +14,7 @@
</div>
<div v-else class="d-flex align-items-start">
<p id="status-body" class="flex-grow-1">
- <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>
+ <span>{{ $t('status.ready-' + scopeLabel()) }}</span>
<br>
{{ $t('status.verify') }}
</p>
@@ -183,20 +175,23 @@
this.$emit('status-update', data)
},
getUrl() {
- let url
-
- switch (this.scope) {
- case 'dashboard':
- url = '/api/v4/users/' + this.$store.state.authInfo.id + '/status'
- break
- case 'distlist':
- url = '/api/v4/groups/' + this.$route.params.list + '/status'
- break
- default:
- url = '/api/v4/' + this.scope + 's/' + this.$route.params[this.scope] + '/status'
+ let scope = this.scope
+ let id = this.$route.params[scope]
+
+ if (scope == 'dashboard') {
+ id = this.$store.state.authInfo.id
+ scope = 'user'
+ } else if (scope =='distlist') {
+ id = this.$route.params.list
+ scope = 'group'
+ } else if (scope == 'shared-folder') {
+ id = this.$route.params.folder
}
- return url
+ return '/api/v4/' + scope + 's/' + id + '/status'
+ },
+ scopeLabel() {
+ return this.scope == 'dashboard' ? 'account' : this.scope
}
}
}
diff --git a/src/routes/api.php b/src/routes/api.php
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -84,6 +84,10 @@
Route::get('resources/{id}/status', 'API\V4\ResourcesController@status');
Route::post('resources/{id}/config', 'API\V4\ResourcesController@setConfig');
+ Route::apiResource('shared-folders', API\V4\SharedFoldersController::class);
+ Route::get('shared-folders/{id}/status', 'API\V4\SharedFoldersController@status');
+ Route::post('shared-folders/{id}/config', 'API\V4\SharedFoldersController@setConfig');
+
Route::apiResource('skus', API\V4\SkusController::class);
Route::apiResource('users', API\V4\UsersController::class);
@@ -190,6 +194,7 @@
Route::post('groups/{id}/unsuspend', 'API\V4\Admin\GroupsController@unsuspend');
Route::apiResource('resources', API\V4\Admin\ResourcesController::class);
+ Route::apiResource('shared-folders', API\V4\Admin\SharedFoldersController::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');
@@ -237,6 +242,7 @@
Route::get('payments/has-pending', 'API\V4\Reseller\PaymentsController@hasPayments');
Route::apiResource('resources', API\V4\Reseller\ResourcesController::class);
+ Route::apiResource('shared-folders', API\V4\Reseller\SharedFoldersController::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
--- a/src/tests/Browser/Admin/ResourceTest.php
+++ b/src/tests/Browser/Admin/ResourceTest.php
@@ -56,6 +56,9 @@
$user = $this->getTestUser('john@kolab.org');
$resource = $this->getTestResource('resource-test1@kolab.org');
$resource->setSetting('invitation_policy', 'accept');
+ $resource->status = Resource::STATUS_NEW | Resource::STATUS_ACTIVE
+ | Resource::STATUS_LDAP_READY | Resource::STATUS_IMAP_READY;
+ $resource->save();
$resource_page = new ResourcePage($resource->id);
$user_page = new UserPage($user->id);
diff --git a/src/tests/Browser/Admin/ResourceTest.php b/src/tests/Browser/Admin/SharedFolderTest.php
copy from src/tests/Browser/Admin/ResourceTest.php
copy to src/tests/Browser/Admin/SharedFolderTest.php
--- a/src/tests/Browser/Admin/ResourceTest.php
+++ b/src/tests/Browser/Admin/SharedFolderTest.php
@@ -2,17 +2,17 @@
namespace Tests\Browser\Admin;
-use App\Resource;
+use App\SharedFolder;
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\SharedFolder as SharedFolderPage;
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
+class SharedFolderTest extends TestCaseDusk
{
/**
* {@inheritDoc}
@@ -32,21 +32,21 @@
}
/**
- * Test resource info page (unauthenticated)
+ * Test shared folder info page (unauthenticated)
*/
- public function testResourceUnauth(): void
+ public function testSharedFolderUnauth(): 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');
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
- $browser->visit('/resource/' . $resource->id)->on(new Home());
+ $browser->visit('/shared-folder/' . $folder->id)->on(new Home());
});
}
/**
- * Test resource info page
+ * Test shared folder info page
*/
public function testInfo(): void
{
@@ -54,42 +54,48 @@
$this->browse(function (Browser $browser) {
$user = $this->getTestUser('john@kolab.org');
- $resource = $this->getTestResource('resource-test1@kolab.org');
- $resource->setSetting('invitation_policy', 'accept');
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
+ $folder->setConfig(['acl' => ['anyone, read-only', 'jack@kolab.org, read-write']]);
+ $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE
+ | SharedFolder::STATUS_LDAP_READY | SharedFolder::STATUS_IMAP_READY;
+ $folder->save();
- $resource_page = new ResourcePage($resource->id);
+ $folder_page = new SharedFolderPage($folder->id);
$user_page = new UserPage($user->id);
- // Goto the resource page
+ // Goto the folder 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')
+ ->click('@nav #tab-shared-folders')
->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)
+ ->click('@user-shared-folders table tbody tr:first-child td:first-child a')
+ ->on($folder_page)
+ ->assertSeeIn('@folder-info .card-title', $folder->email)
+ ->with('@folder-info form', function (Browser $browser) use ($folder) {
+ $browser->assertElementsCount('.row', 4)
->assertSeeIn('.row:nth-child(1) label', 'ID (Created)')
- ->assertSeeIn('.row:nth-child(1) #resourceid', "{$resource->id} ({$resource->created_at})")
+ ->assertSeeIn('.row:nth-child(1) #folderid', "{$folder->id} ({$folder->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);
+ ->assertSeeIn('.row:nth-child(3) #name', $folder->name)
+ ->assertSeeIn('.row:nth-child(4) label', 'Type')
+ ->assertSeeIn('.row:nth-child(4) #type', 'Calendar');
})
->assertElementsCount('ul.nav-tabs', 1)
->assertSeeIn('ul.nav-tabs .nav-link', 'Settings')
- ->with('@resource-settings form', function (Browser $browser) {
+ ->with('@folder-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');
+ ->assertSeeIn('.row:nth-child(1) label', 'Access rights')
+ ->assertSeeIn('.row:nth-child(1) #acl', 'anyone: read-only')
+ ->assertSeeIn('.row:nth-child(1) #acl', 'jack@kolab.org: read-write');
});
- // Test invalid resource identifier
- $browser->visit('/resource/abc')->assertErrorPage(404);
+ // Test invalid shared folder identifier
+ $browser->visit('/shared-folder/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', 8);
+ ->assertElementsCount('@nav a', 9);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
@@ -177,6 +177,14 @@
->assertSeeIn('table tfoot tr td', 'There are no resources in this account.');
});
+ // Assert Shared folders tab
+ $browser->assertSeeIn('@nav #tab-shared-folders', 'Shared folders (0)')
+ ->click('@nav #tab-shared-folders')
+ ->with('@user-shared-folders', function (Browser $browser) {
+ $browser->assertElementsCount('table tbody tr', 0)
+ ->assertSeeIn('table tfoot tr td', 'There are no shared folders in this account.');
+ });
+
// Assert Settings tab
$browser->assertSeeIn('@nav #tab-settings', 'Settings')
->click('@nav #tab-settings')
@@ -240,7 +248,7 @@
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 8);
+ ->assertElementsCount('@nav a', 9);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
@@ -279,6 +287,22 @@
->assertMissing('tfoot');
});
+ // Assert Users tab
+ $browser->assertSeeIn('@nav #tab-users', 'Users (4)')
+ ->click('@nav #tab-users')
+ ->with('@user-users table', function (Browser $browser) {
+ $browser->assertElementsCount('tbody tr', 4)
+ ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'jack@kolab.org')
+ ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success')
+ ->assertSeeIn('tbody tr:nth-child(2) td:first-child a', 'joe@kolab.org')
+ ->assertVisible('tbody tr:nth-child(2) td:first-child svg.text-success')
+ ->assertSeeIn('tbody tr:nth-child(3) td:first-child span', 'john@kolab.org')
+ ->assertVisible('tbody tr:nth-child(3) td:first-child svg.text-success')
+ ->assertSeeIn('tbody tr:nth-child(4) td:first-child a', 'ned@kolab.org')
+ ->assertVisible('tbody tr:nth-child(4) td:first-child svg.text-success')
+ ->assertMissing('tfoot');
+ });
+
// Assert Distribution lists tab
$browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (1)')
->click('@nav #tab-distlists')
@@ -302,20 +326,18 @@
->assertMissing('table tfoot');
});
- // Assert Users tab
- $browser->assertSeeIn('@nav #tab-users', 'Users (4)')
- ->click('@nav #tab-users')
- ->with('@user-users table', function (Browser $browser) {
- $browser->assertElementsCount('tbody tr', 4)
- ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'jack@kolab.org')
- ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success')
- ->assertSeeIn('tbody tr:nth-child(2) td:first-child a', 'joe@kolab.org')
- ->assertVisible('tbody tr:nth-child(2) td:first-child svg.text-success')
- ->assertSeeIn('tbody tr:nth-child(3) td:first-child span', 'john@kolab.org')
- ->assertVisible('tbody tr:nth-child(3) td:first-child svg.text-success')
- ->assertSeeIn('tbody tr:nth-child(4) td:first-child a', 'ned@kolab.org')
- ->assertVisible('tbody tr:nth-child(4) td:first-child svg.text-success')
- ->assertMissing('tfoot');
+ // Assert Shared folders tab
+ $browser->assertSeeIn('@nav #tab-shared-folders', 'Shared folders (2)')
+ ->click('@nav #tab-shared-folders')
+ ->with('@user-shared-folders', function (Browser $browser) {
+ $browser->assertElementsCount('table tbody tr', 2)
+ ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'Calendar')
+ ->assertSeeIn('table tbody tr:nth-child(1) td:nth-child(2)', 'Calendar')
+ ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', 'folder-event@kolab.org')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Contacts')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:nth-child(2)', 'Address Book')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', 'folder-contact@kolab.org')
+ ->assertMissing('table tfoot');
});
});
@@ -345,7 +367,8 @@
$page = new UserPage($ned->id);
$ned->setSetting('greylist_enabled', 'false');
- $browser->click('@user-users tbody tr:nth-child(4) td:first-child a')
+ $browser->click('@nav #tab-users')
+ ->click('@user-users tbody tr:nth-child(4) td:first-child a')
->on($page);
// Assert main info box content
@@ -357,7 +380,7 @@
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 8);
+ ->assertElementsCount('@nav a', 9);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
@@ -425,6 +448,14 @@
->assertSeeIn('table tfoot tr td', 'There are no resources in this account.');
});
+ // We don't expect John's folders here
+ $browser->assertSeeIn('@nav #tab-shared-folders', 'Shared folders (0)')
+ ->click('@nav #tab-shared-folders')
+ ->with('@user-shared-folders', function (Browser $browser) {
+ $browser->assertElementsCount('table tbody tr', 0)
+ ->assertSeeIn('table tfoot tr td', 'There are no shared folders in this account.');
+ });
+
// Assert Settings tab
$browser->assertSeeIn('@nav #tab-settings', 'Settings')
->click('@nav #tab-settings')
diff --git a/src/tests/Browser/Components/AclInput.php b/src/tests/Browser/Components/AclInput.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/Components/AclInput.php
@@ -0,0 +1,141 @@
+<?php
+
+namespace Tests\Browser\Components;
+
+use Laravel\Dusk\Component as BaseComponent;
+use PHPUnit\Framework\Assert as PHPUnit;
+
+class AclInput extends BaseComponent
+{
+ protected $selector;
+
+
+ public function __construct($selector)
+ {
+ $this->selector = $selector;
+ }
+
+ /**
+ * Get the root selector for the component.
+ *
+ * @return string
+ */
+ public function selector()
+ {
+ return $this->selector;
+ }
+
+ /**
+ * Assert that the browser page contains the component.
+ *
+ * @param \Laravel\Dusk\Browser $browser
+ *
+ * @return void
+ */
+ public function assert($browser)
+ {
+ $browser->assertVisible($this->selector)
+ ->assertVisible("{$this->selector} @input")
+ ->assertVisible("{$this->selector} @add-btn")
+ ->assertSelectHasOptions("{$this->selector} @mod-select", ['user', 'anyone'])
+ ->assertSelectHasOptions("{$this->selector} @acl-select", ['read-only', 'read-write', 'full']);
+ }
+
+ /**
+ * Get the element shortcuts for the component.
+ *
+ * @return array
+ */
+ public function elements()
+ {
+ return [
+ '@add-btn' => '.input-group:first-child a.btn',
+ '@input' => '.input-group:first-child input',
+ '@acl-select' => '.input-group:first-child select.acl',
+ '@mod-select' => '.input-group:first-child select.mod',
+ ];
+ }
+
+ /**
+ * Assert acl input content
+ */
+ public function assertAclValue($browser, array $list)
+ {
+ if (empty($list)) {
+ $browser->assertMissing('.input-group:not(:first-child)');
+ return;
+ }
+
+ foreach ($list as $idx => $value) {
+ $selector = '.input-group:nth-child(' . ($idx + 2) . ')';
+ list($ident, $acl) = preg_split('/\s*,\s*/', $value);
+
+ $input = $ident == 'anyone' ? 'input:read-only' : 'input:not(:read-only)';
+
+ $browser->assertVisible("$selector $input")
+ ->assertVisible("$selector select")
+ ->assertVisible("$selector a.btn")
+ ->assertValue("$selector $input", $ident)
+ ->assertSelected("$selector select", $acl);
+ }
+ }
+
+ /**
+ * Add acl entry
+ */
+ public function addAclEntry($browser, string $value)
+ {
+ list($ident, $acl) = preg_split('/\s*,\s*/', $value);
+
+ $browser->select('@mod-select', $ident == 'anyone' ? 'anyone' : 'user')
+ ->select('@acl-select', $acl);
+
+ if ($ident == 'anyone') {
+ $browser->assertValue('@input', '')->assertMissing('@input');
+ } else {
+ $browser->type('@input', $ident);
+ }
+
+ $browser->click('@add-btn')
+ ->assertSelected('@mod-select', 'user')
+ ->assertSelected('@acl-select', 'read-only')
+ ->assertValue('@input', '');
+ }
+
+ /**
+ * Remove acl entry
+ */
+ public function removeAclEntry($browser, int $num)
+ {
+ $selector = '.input-group:nth-child(' . ($num + 1) . ') a.btn';
+ $browser->click($selector);
+ }
+
+ /**
+ * Update acl entry
+ */
+ public function updateAclEntry($browser, int $num, $value)
+ {
+ list($ident, $acl) = preg_split('/\s*,\s*/', $value);
+
+ $selector = '.input-group:nth-child(' . ($num + 1) . ')';
+
+ $browser->select("$selector select.acl", $acl)
+ ->type("$selector input", $ident);
+ }
+
+ /**
+ * Assert an error message on the widget
+ */
+ public function assertFormError($browser, int $num, string $msg, bool $focused = false)
+ {
+ $selector = '.input-group:nth-child(' . ($num + 1) . ') input.is-invalid';
+
+ $browser->waitFor($selector)
+ ->assertSeeIn(' + .invalid-feedback', $msg);
+
+ if ($focused) {
+ $browser->assertFocused($selector);
+ }
+ }
+}
diff --git a/src/tests/Browser/Pages/Admin/SharedFolder.php b/src/tests/Browser/Pages/Admin/SharedFolder.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/Pages/Admin/SharedFolder.php
@@ -0,0 +1,57 @@
+<?php
+
+namespace Tests\Browser\Pages\Admin;
+
+use Laravel\Dusk\Page;
+
+class SharedFolder extends Page
+{
+ protected $folderId;
+
+ /**
+ * Object constructor.
+ *
+ * @param int $id Shared folder Id
+ */
+ public function __construct($id)
+ {
+ $this->folderId = $id;
+ }
+
+ /**
+ * Get the URL for the page.
+ *
+ * @return string
+ */
+ public function url(): string
+ {
+ return '/shared-folder/' . $this->folderId;
+ }
+
+ /**
+ * Assert that the browser is on the page.
+ *
+ * @param \Laravel\Dusk\Browser $browser The browser object
+ *
+ * @return void
+ */
+ public function assert($browser): void
+ {
+ $browser->waitForLocation($this->url())
+ ->waitFor('@folder-info');
+ }
+
+ /**
+ * Get the element shortcuts for the page.
+ *
+ * @return array
+ */
+ public function elements(): array
+ {
+ return [
+ '@app' => '#app',
+ '@folder-info' => '#folder-info',
+ '@folder-settings' => '#folder-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
@@ -59,6 +59,7 @@
'@user-distlists' => '#user-distlists',
'@user-domains' => '#user-domains',
'@user-resources' => '#user-resources',
+ '@user-shared-folders' => '#user-shared-folders',
'@user-users' => '#user-users',
'@user-settings' => '#user-settings',
];
diff --git a/src/tests/Browser/Pages/SharedFolderInfo.php b/src/tests/Browser/Pages/SharedFolderInfo.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/Pages/SharedFolderInfo.php
@@ -0,0 +1,47 @@
+<?php
+
+namespace Tests\Browser\Pages;
+
+use Laravel\Dusk\Page;
+
+class SharedFolderInfo 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/SharedFolderList.php b/src/tests/Browser/Pages/SharedFolderList.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/Pages/SharedFolderList.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace Tests\Browser\Pages;
+
+use Laravel\Dusk\Page;
+
+class SharedFolderList extends Page
+{
+ /**
+ * Get the URL for the page.
+ *
+ * @return string
+ */
+ public function url(): string
+ {
+ return '/shared-folders';
+ }
+
+ /**
+ * 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('#folder-list .card-title', 'Shared folders');
+ }
+
+ /**
+ * Get the element shortcuts for the page.
+ *
+ * @return array
+ */
+ public function elements(): array
+ {
+ return [
+ '@app' => '#app',
+ '@table' => '#folder-list table',
+ ];
+ }
+}
diff --git a/src/tests/Browser/Reseller/ResourceTest.php b/src/tests/Browser/Reseller/ResourceTest.php
--- a/src/tests/Browser/Reseller/ResourceTest.php
+++ b/src/tests/Browser/Reseller/ResourceTest.php
@@ -56,6 +56,9 @@
$user = $this->getTestUser('john@kolab.org');
$resource = $this->getTestResource('resource-test1@kolab.org');
$resource->setSetting('invitation_policy', 'accept');
+ $resource->status = Resource::STATUS_NEW | Resource::STATUS_ACTIVE
+ | Resource::STATUS_LDAP_READY | Resource::STATUS_IMAP_READY;
+ $resource->save();
$resource_page = new ResourcePage($resource->id);
$user_page = new UserPage($user->id);
diff --git a/src/tests/Browser/Reseller/ResourceTest.php b/src/tests/Browser/Reseller/SharedFolderTest.php
copy from src/tests/Browser/Reseller/ResourceTest.php
copy to src/tests/Browser/Reseller/SharedFolderTest.php
--- a/src/tests/Browser/Reseller/ResourceTest.php
+++ b/src/tests/Browser/Reseller/SharedFolderTest.php
@@ -2,17 +2,17 @@
namespace Tests\Browser\Reseller;
-use App\Resource;
+use App\SharedFolder;
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\SharedFolder as SharedFolderPage;
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
+class SharedFolderTest extends TestCaseDusk
{
/**
* {@inheritDoc}
@@ -32,21 +32,21 @@
}
/**
- * Test resource info page (unauthenticated)
+ * Test shared folder info page (unauthenticated)
*/
- public function testResourceUnauth(): void
+ public function testSharedFolderUnauth(): 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');
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
- $browser->visit('/resource/' . $resource->id)->on(new Home());
+ $browser->visit('/shared-folder/' . $folder->id)->on(new Home());
});
}
/**
- * Test distribution list info page
+ * Test shared folder info page
*/
public function testInfo(): void
{
@@ -54,42 +54,48 @@
$this->browse(function (Browser $browser) {
$user = $this->getTestUser('john@kolab.org');
- $resource = $this->getTestResource('resource-test1@kolab.org');
- $resource->setSetting('invitation_policy', 'accept');
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
+ $folder->setConfig(['acl' => ['anyone, read-only', 'jack@kolab.org, read-write']]);
+ $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE
+ | SharedFolder::STATUS_LDAP_READY | SharedFolder::STATUS_IMAP_READY;
+ $folder->save();
- $resource_page = new ResourcePage($resource->id);
+ $folder_page = new SharedFolderPage($folder->id);
$user_page = new UserPage($user->id);
- // Goto the distlist page
+ // Goto the folder 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')
+ ->click('@nav #tab-shared-folders')
->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)
+ ->click('@user-shared-folders table tbody tr:first-child td:first-child a')
+ ->on($folder_page)
+ ->assertSeeIn('@folder-info .card-title', $folder->email)
+ ->with('@folder-info form', function (Browser $browser) use ($folder) {
+ $browser->assertElementsCount('.row', 4)
->assertSeeIn('.row:nth-child(1) label', 'ID (Created)')
- ->assertSeeIn('.row:nth-child(1) #resourceid', "{$resource->id} ({$resource->created_at})")
+ ->assertSeeIn('.row:nth-child(1) #folderid', "{$folder->id} ({$folder->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);
+ ->assertSeeIn('.row:nth-child(3) #name', $folder->name)
+ ->assertSeeIn('.row:nth-child(4) label', 'Type')
+ ->assertSeeIn('.row:nth-child(4) #type', 'Calendar');
})
->assertElementsCount('ul.nav-tabs', 1)
->assertSeeIn('ul.nav-tabs .nav-link', 'Settings')
- ->with('@resource-settings form', function (Browser $browser) {
+ ->with('@folder-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');
+ ->assertSeeIn('.row:nth-child(1) label', 'Access rights')
+ ->assertSeeIn('.row:nth-child(1) #acl', 'anyone: read-only')
+ ->assertSeeIn('.row:nth-child(1) #acl', 'jack@kolab.org: read-write');
});
- // Test invalid resource identifier
- $browser->visit('/resource/abc')->assertErrorPage(404);
+ // Test invalid shared folder identifier
+ $browser->visit('/shared-folder/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', 8);
+ ->assertElementsCount('@nav a', 9);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
@@ -174,6 +174,14 @@
->assertSeeIn('table tfoot tr td', 'There are no resources in this account.');
});
+ // Assert Shared folders tab
+ $browser->assertSeeIn('@nav #tab-shared-folders', 'Shared folders (0)')
+ ->click('@nav #tab-shared-folders')
+ ->with('@user-shared-folders', function (Browser $browser) {
+ $browser->assertElementsCount('table tbody tr', 0)
+ ->assertSeeIn('table tfoot tr td', 'There are no shared folders in this account.');
+ });
+
// Assert Settings tab
$browser->assertSeeIn('@nav #tab-settings', 'Settings')
->click('@nav #tab-settings')
@@ -236,7 +244,7 @@
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 8);
+ ->assertElementsCount('@nav a', 9);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
@@ -265,6 +273,22 @@
->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher');
});
+ // Assert Users tab
+ $browser->assertSeeIn('@nav #tab-users', 'Users (4)')
+ ->click('@nav #tab-users')
+ ->with('@user-users table', function (Browser $browser) {
+ $browser->assertElementsCount('tbody tr', 4)
+ ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'jack@kolab.org')
+ ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success')
+ ->assertSeeIn('tbody tr:nth-child(2) td:first-child a', 'joe@kolab.org')
+ ->assertVisible('tbody tr:nth-child(2) td:first-child svg.text-success')
+ ->assertSeeIn('tbody tr:nth-child(3) td:first-child span', 'john@kolab.org')
+ ->assertVisible('tbody tr:nth-child(3) td:first-child svg.text-success')
+ ->assertSeeIn('tbody tr:nth-child(4) td:first-child a', 'ned@kolab.org')
+ ->assertVisible('tbody tr:nth-child(4) td:first-child svg.text-success')
+ ->assertMissing('tfoot');
+ });
+
// Assert Domains tab
$browser->assertSeeIn('@nav #tab-domains', 'Domains (1)')
->click('@nav #tab-domains')
@@ -298,20 +322,18 @@
->assertMissing('table tfoot');
});
- // Assert Users tab
- $browser->assertSeeIn('@nav #tab-users', 'Users (4)')
- ->click('@nav #tab-users')
- ->with('@user-users table', function (Browser $browser) {
- $browser->assertElementsCount('tbody tr', 4)
- ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'jack@kolab.org')
- ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success')
- ->assertSeeIn('tbody tr:nth-child(2) td:first-child a', 'joe@kolab.org')
- ->assertVisible('tbody tr:nth-child(2) td:first-child svg.text-success')
- ->assertSeeIn('tbody tr:nth-child(3) td:first-child span', 'john@kolab.org')
- ->assertVisible('tbody tr:nth-child(3) td:first-child svg.text-success')
- ->assertSeeIn('tbody tr:nth-child(4) td:first-child a', 'ned@kolab.org')
- ->assertVisible('tbody tr:nth-child(4) td:first-child svg.text-success')
- ->assertMissing('tfoot');
+ // Assert Shared folders tab
+ $browser->assertSeeIn('@nav #tab-shared-folders', 'Shared folders (2)')
+ ->click('@nav #tab-shared-folders')
+ ->with('@user-shared-folders', function (Browser $browser) {
+ $browser->assertElementsCount('table tbody tr', 2)
+ ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'Calendar')
+ ->assertSeeIn('table tbody tr:nth-child(1) td:nth-child(2)', 'Calendar')
+ ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', 'folder-event@kolab.org')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Contacts')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:nth-child(2)', 'Address Book')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', 'folder-contact@kolab.org')
+ ->assertMissing('table tfoot');
});
});
@@ -321,7 +343,8 @@
$ned->setSetting('greylist_enabled', 'false');
$page = new UserPage($ned->id);
- $browser->click('@user-users tbody tr:nth-child(4) td:first-child a')
+ $browser->click('@nav #tab-users')
+ ->click('@user-users tbody tr:nth-child(4) td:first-child a')
->on($page);
// Assert main info box content
@@ -333,7 +356,7 @@
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 8);
+ ->assertElementsCount('@nav a', 9);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
@@ -398,6 +421,14 @@
->assertSeeIn('table tfoot tr td', 'There are no resources in this account.');
});
+ // Assert Shared folders tab
+ $browser->assertSeeIn('@nav #tab-shared-folders', 'Shared folders (0)')
+ ->click('@nav #tab-shared-folders')
+ ->with('@user-shared-folders', function (Browser $browser) {
+ $browser->assertElementsCount('table tbody tr', 0)
+ ->assertSeeIn('table tfoot tr td', 'There are no shared folders in this account.');
+ });
+
// Assert Settings tab
$browser->assertSeeIn('@nav #tab-settings', 'Settings')
->click('@nav #tab-settings')
diff --git a/src/tests/Browser/SharedFolderTest.php b/src/tests/Browser/SharedFolderTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/SharedFolderTest.php
@@ -0,0 +1,333 @@
+<?php
+
+namespace Tests\Browser;
+
+use App\SharedFolder;
+use Tests\Browser;
+use Tests\Browser\Components\AclInput;
+use Tests\Browser\Components\Status;
+use Tests\Browser\Components\Toast;
+use Tests\Browser\Pages\Dashboard;
+use Tests\Browser\Pages\Home;
+use Tests\Browser\Pages\SharedFolderInfo;
+use Tests\Browser\Pages\SharedFolderList;
+use Tests\TestCaseDusk;
+
+class SharedFolderTest extends TestCaseDusk
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ SharedFolder::whereNotIn('email', ['folder-event@kolab.org', 'folder-contact@kolab.org'])->delete();
+ $this->clearBetaEntitlements();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ SharedFolder::whereNotIn('email', ['folder-event@kolab.org', 'folder-contact@kolab.org'])->delete();
+ $this->clearBetaEntitlements();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test shared folder info page (unauthenticated)
+ */
+ public function testInfoUnauth(): void
+ {
+ // Test that the page requires authentication
+ $this->browse(function (Browser $browser) {
+ $browser->visit('/shared-folder/abc')->on(new Home());
+ });
+ }
+
+ /**
+ * Test shared folder list page (unauthenticated)
+ */
+ public function testListUnauth(): void
+ {
+ // Test that the page requires authentication
+ $this->browse(function (Browser $browser) {
+ $browser->visit('/shared-folders')->on(new Home());
+ });
+ }
+
+ /**
+ * Test shared folders 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-shared-folders');
+ });
+
+ // Test that shared folders lists page is not accessible without the 'beta-shared-folders' entitlement
+ $this->browse(function (Browser $browser) {
+ $browser->visit('/shared-folders')
+ ->assertErrorPage(403);
+ });
+
+ // Add beta+beta-shared-folders entitlements
+ $john = $this->getTestUser('john@kolab.org');
+ $this->addBetaEntitlement($john, 'beta-shared-folders');
+ // Make sure the first folder is active
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
+ $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE
+ | SharedFolder::STATUS_LDAP_READY | SharedFolder::STATUS_IMAP_READY;
+ $folder->save();
+
+ // Test shared folders lists page
+ $this->browse(function (Browser $browser) {
+ $browser->visit(new Dashboard())
+ ->assertSeeIn('@links .link-shared-folders', 'Shared folders')
+ ->click('@links .link-shared-folders')
+ ->on(new SharedFolderList())
+ ->whenAvailable('@table', function (Browser $browser) {
+ $browser->waitFor('tbody tr')
+ ->assertSeeIn('thead tr th:nth-child(1)', 'Name')
+ ->assertSeeIn('thead tr th:nth-child(2)', 'Type')
+ ->assertSeeIn('thead tr th:nth-child(3)', 'Email Address')
+ ->assertElementsCount('tbody tr', 2)
+ ->assertSeeIn('tbody tr:nth-child(1) td:nth-child(1) a', 'Calendar')
+ ->assertSeeIn('tbody tr:nth-child(1) td:nth-child(2)', 'Calendar')
+ ->assertSeeIn('tbody tr:nth-child(1) td:nth-child(3) a', 'folder-event@kolab.org')
+ ->assertText('tbody tr:nth-child(1) td:nth-child(1) svg.text-success title', 'Active')
+ ->assertSeeIn('tbody tr:nth-child(2) td:nth-child(1) a', 'Contacts')
+ ->assertSeeIn('tbody tr:nth-child(2) td:nth-child(2)', 'Address Book')
+ ->assertSeeIn('tbody tr:nth-child(2) td:nth-child(3) a', 'folder-contact@kolab.org')
+ ->assertMissing('tfoot');
+ });
+ });
+ }
+
+ /**
+ * Test shared folder creation/editing/deleting
+ *
+ * @depends testList
+ */
+ public function testCreateUpdateDelete(): void
+ {
+ // Test that the page is not available accessible without the 'beta-shared-folders' entitlement
+ $this->browse(function (Browser $browser) {
+ $browser->visit('/shared-folder/new')
+ ->assertErrorPage(403);
+ });
+
+ // Add beta+beta-shared-folders entitlements
+ $john = $this->getTestUser('john@kolab.org');
+ $this->addBetaEntitlement($john, 'beta-shared-folders');
+
+ $this->browse(function (Browser $browser) {
+ // Create a folder
+ $browser->visit(new SharedFolderList())
+ ->assertSeeIn('button.create-folder', 'Create folder')
+ ->click('button.create-folder')
+ ->on(new SharedFolderInfo())
+ ->assertSeeIn('#folder-info .card-title', 'New shared folder')
+ ->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', 'Type')
+ ->assertSelectHasOptions(
+ 'div.row:nth-child(2) select',
+ ['mail', 'event', 'task', 'contact', 'note', 'file']
+ )
+ ->assertValue('div.row:nth-child(2) select', 'mail')
+ ->assertSeeIn('div.row:nth-child(3) label', 'Domain')
+ ->assertSelectHasOptions('div.row:nth-child(3) select', ['kolab.org'])
+ ->assertValue('div.row:nth-child(3) 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 folder creation
+ ->type('#name', 'Test Folder')
+ ->select('#type', 'event')
+ ->click('@general button[type=submit]')
+ ->assertToast(Toast::TYPE_SUCCESS, 'Shared folder created successfully.')
+ ->on(new SharedFolderList())
+ ->assertElementsCount('@table tbody tr', 3);
+
+ // Test folder update
+ $browser->click('@table tr:nth-child(3) td:first-child a')
+ ->on(new SharedFolderInfo())
+ ->assertSeeIn('#folder-info .card-title', 'Shared folder')
+ ->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 Folder')
+ ->assertSeeIn('div.row:nth-child(3) label', 'Type')
+ ->assertSelected('div.row:nth-child(3) select:disabled', 'event')
+ ->assertSeeIn('div.row:nth-child(4) label', 'Email Address')
+ ->assertAttributeRegExp(
+ 'div.row:nth-child(4) input[type=text]:disabled',
+ 'value',
+ '/^event-[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 Folder Update')
+ ->click('@general button[type=submit]')
+ ->assertToast(Toast::TYPE_SUCCESS, 'Shared folder updated successfully.')
+ ->on(new SharedFolderList())
+ ->assertElementsCount('@table tbody tr', 3)
+ ->assertSeeIn('@table tr:nth-child(3) td:first-child a', 'Test Folder Update');
+
+ $this->assertSame(1, SharedFolder::where('name', 'Test Folder Update')->count());
+
+ // Test folder deletion
+ $browser->click('@table tr:nth-child(3) td:first-child a')
+ ->on(new SharedFolderInfo())
+ ->assertSeeIn('button.button-delete', 'Delete folder')
+ ->click('button.button-delete')
+ ->assertToast(Toast::TYPE_SUCCESS, 'Shared folder deleted successfully.')
+ ->on(new SharedFolderList())
+ ->assertElementsCount('@table tbody tr', 2);
+
+ $this->assertNull(SharedFolder::where('name', 'Test Folder Update')->first());
+ });
+ }
+
+ /**
+ * Test shared folder status
+ *
+ * @depends testList
+ */
+ public function testStatus(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $this->addBetaEntitlement($john, 'beta-shared-folders');
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
+ $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE | SharedFolder::STATUS_LDAP_READY;
+ $folder->created_at = \now();
+ $folder->save();
+
+ $this->assertFalse($folder->isImapReady());
+
+ $this->browse(function ($browser) use ($folder) {
+ // Test auto-refresh
+ $browser->visit('/shared-folder/' . $folder->id)
+ ->on(new SharedFolderInfo())
+ ->with(new Status(), function ($browser) {
+ $browser->assertSeeIn('@body', 'We are preparing the shared folder')
+ ->assertProgress(85, 'Creating a shared folder...', 'pending')
+ ->assertMissing('@refresh-button')
+ ->assertMissing('@refresh-text')
+ ->assertMissing('#status-link')
+ ->assertMissing('#status-verify');
+ });
+
+ $folder->status |= SharedFolder::STATUS_IMAP_READY;
+ $folder->save();
+
+ // Test Verify button
+ $browser->waitUntilMissing('@status', 10);
+ });
+
+ // TODO: Test all shared folder statuses on the list
+ }
+
+ /**
+ * Test shared folder settings
+ */
+ public function testSettings(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $this->addBetaEntitlement($john, 'beta-shared-folders');
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
+ $folder->setSetting('acl', null);
+
+ $this->browse(function ($browser) use ($folder) {
+ $aclInput = new AclInput('@settings #acl');
+ // Test auto-refresh
+ $browser->visit('/shared-folder/' . $folder->id)
+ ->on(new SharedFolderInfo())
+ ->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', 'Access rights')
+ ->assertSeeIn('div.row:nth-child(1) #acl-hint', 'permissions')
+ ->assertSeeIn('button[type=submit]', 'Submit');
+ })
+ // Test the AclInput widget
+ ->with($aclInput, function (Browser $browser) {
+ $browser->assertAclValue([])
+ ->addAclEntry('anyone, read-only')
+ ->addAclEntry('test, read-write')
+ ->addAclEntry('john@kolab.org, full')
+ ->assertAclValue([
+ 'anyone, read-only',
+ 'test, read-write',
+ 'john@kolab.org, full',
+ ]);
+ })
+ // Test error handling
+ ->click('@settings button[type=submit]')
+ ->with($aclInput, function (Browser $browser) {
+ $browser->assertFormError(2, 'The specified email address is invalid.');
+ })
+ ->assertToast(Toast::TYPE_ERROR, 'Form validation error')
+ // Test successful update
+ ->with($aclInput, function (Browser $browser) {
+ $browser->removeAclEntry(2)
+ ->assertAclValue([
+ 'anyone, read-only',
+ 'john@kolab.org, full',
+ ])
+ ->updateAclEntry(2, 'jack@kolab.org, read-write')
+ ->assertAclValue([
+ 'anyone, read-only',
+ 'jack@kolab.org, read-write',
+ ]);
+ })
+ ->click('@settings button[type=submit]')
+ ->assertToast(Toast::TYPE_SUCCESS, 'Shared folder settings updated successfully.')
+ ->assertMissing('.invalid-feedback')
+ // Refresh the page and check if everything was saved
+ ->refresh()
+ ->on(new SharedFolderInfo())
+ ->click('@nav #tab-settings')
+ ->with($aclInput, function (Browser $browser) {
+ $browser->assertAclValue([
+ 'anyone, read-only',
+ 'jack@kolab.org, read-write',
+ ]);
+ });
+ });
+ }
+}
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', 9)
+ $browser->assertElementsCount('tbody tr', 10)
// 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')
@@ -739,13 +739,22 @@
'tbody tr:nth-child(8) td.buttons button',
'Access to calendaring resources'
)
- // Distlist SKU
- ->assertSeeIn('tbody tr:nth-child(9) td.name', 'Distribution lists')
+ // Shared folders SKU
+ ->assertSeeIn('tbody tr:nth-child(9) td.name', 'Shared folders')
->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 shared folders'
+ )
+ // Distlist SKU
+ ->assertSeeIn('tbody tr:nth-child(10) td.name', 'Distribution lists')
+ ->assertSeeIn('tr:nth-child(10) td.price', '0,00 CHF/month')
+ ->assertNotChecked('tbody tr:nth-child(10) td.selection input')
+ ->assertEnabled('tbody tr:nth-child(10) td.selection input')
+ ->assertTip(
+ 'tbody tr:nth-child(10) 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
@@ -14,25 +14,12 @@
*/
public function testVerifyAccountExisting(): void
{
+ // existing user
$result = IMAP::verifyAccount('john@kolab.org');
+ $this->assertTrue($result);
- // TODO: Mocking rcube_imap_generic is not that nice,
- // Find a way to be sure some testing account has folders
- // initialized, and some other not, so we can make assertions
- // on the verifyAccount() result
-
- $this->markTestIncomplete();
- }
-
- /**
- * Test verifying IMAP account existence (non-existing account)
- *
- * @group imap
- */
- public function testVerifyAccountNonExisting(): void
- {
+ // non-existing user
$this->expectException(\Exception::class);
-
IMAP::verifyAccount('non-existing@domain.tld');
}
@@ -43,10 +30,12 @@
*/
public function testVerifySharedFolder(): void
{
+ // non-existing
$result = IMAP::verifySharedFolder('shared/Resources/UnknownResource@kolab.org');
$this->assertFalse($result);
- // TODO: Test with an existing shared folder
- $this->markTestIncomplete();
+ // existing
+ $result = IMAP::verifySharedFolder('shared/Calendar@kolab.org');
+ $this->assertTrue($result);
}
}
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
@@ -7,6 +7,7 @@
use App\Group;
use App\Entitlement;
use App\Resource;
+use App\SharedFolder;
use App\User;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
@@ -30,6 +31,7 @@
$this->deleteTestDomain('testldap.com');
$this->deleteTestGroup('group@kolab.org');
$this->deleteTestResource('test-resource@kolab.org');
+ $this->deleteTestSharedFolder('test-folder@kolab.org');
// TODO: Remove group members
}
@@ -44,6 +46,7 @@
$this->deleteTestDomain('testldap.com');
$this->deleteTestGroup('group@kolab.org');
$this->deleteTestResource('test-resource@kolab.org');
+ $this->deleteTestSharedFolder('test-folder@kolab.org');
// TODO: Remove group members
parent::tearDown();
@@ -278,6 +281,71 @@
}
/**
+ * Test creating/updating/deleting a shared folder record
+ *
+ * @group ldap
+ */
+ public function testSharedFolder(): void
+ {
+ Queue::fake();
+
+ $root_dn = \config('ldap.hosted.root_dn');
+ $folder = $this->getTestSharedFolder('test-folder@kolab.org', ['type' => 'event']);
+ $folder->setSetting('acl', null);
+
+ // Make sure the shared folder does not exist
+ // LDAP::deleteSharedFolder($folder);
+
+ // Create the shared folder
+ LDAP::createSharedFolder($folder);
+
+ $ldap_folder = LDAP::getSharedFolder($folder->email);
+
+ $expected = [
+ 'cn' => 'test-folder',
+ 'dn' => 'cn=test-folder,ou=Shared Folders,ou=kolab.org,' . $root_dn,
+ 'mail' => $folder->email,
+ 'objectclass' => [
+ 'top',
+ 'kolabsharedfolder',
+ 'mailrecipient',
+ ],
+ 'kolabfoldertype' => 'event',
+ 'kolabtargetfolder' => 'shared/test-folder@kolab.org',
+ 'acl' => null,
+ ];
+
+ foreach ($expected as $attr => $value) {
+ $ldap_value = isset($ldap_folder[$attr]) ? $ldap_folder[$attr] : null;
+ $this->assertEquals($value, $ldap_value, "Shared folder $attr attribute");
+ }
+
+ // Update folder name and acl
+ $folder->name = 'Te(=ść)1';
+ $folder->save();
+ $folder->setSetting('acl', '["john@kolab.org, read-write","anyone, read-only"]');
+
+ LDAP::updateSharedFolder($folder);
+
+ $expected['kolabtargetfolder'] = 'shared/Te(=ść)1@kolab.org';
+ $expected['acl'] = ['john@kolab.org, read-write', 'anyone, read-only'];
+ $expected['dn'] = 'cn=Te(\\3dść)1,ou=Shared Folders,ou=kolab.org,' . $root_dn;
+ $expected['cn'] = 'Te(=ść)1';
+
+ $ldap_folder = LDAP::getSharedFolder($folder->email);
+
+ foreach ($expected as $attr => $value) {
+ $ldap_value = isset($ldap_folder[$attr]) ? $ldap_folder[$attr] : null;
+ $this->assertEquals($value, $ldap_value, "Shared folder $attr attribute");
+ }
+
+ // Delete the resource
+ LDAP::deleteSharedFolder($folder);
+
+ $this->assertSame(null, LDAP::getSharedFolder($folder->email));
+ }
+
+ /**
* Test creating/editing/deleting a user record
*
* @group ldap
@@ -402,7 +470,7 @@
$resource = new Resource([
'email' => 'test-non-existing-ldap@non-existing.org',
'name' => 'Test',
- 'status' => User::STATUS_ACTIVE,
+ 'status' => Resource::STATUS_ACTIVE,
]);
LDAP::createResource($resource);
@@ -428,6 +496,25 @@
}
/**
+ * Test handling errors on a shared folder creation
+ *
+ * @group ldap
+ */
+ public function testCreateSharedFolderException(): void
+ {
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessageMatches('/Failed to create shared folder/');
+
+ $folder = new SharedFolder([
+ 'email' => 'test-non-existing-ldap@non-existing.org',
+ 'name' => 'Test',
+ 'status' => SharedFolder::STATUS_ACTIVE,
+ ]);
+
+ LDAP::createSharedFolder($folder);
+ }
+
+ /**
* Test handling errors on user creation
*
* @group ldap
@@ -501,6 +588,23 @@
}
/**
+ * Test handling update of a non-existing shared folder
+ *
+ * @group ldap
+ */
+ public function testUpdateSharedFolderException(): void
+ {
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessageMatches('/folder not found/');
+
+ $folder = new SharedFolder([
+ 'email' => 'test-folder-unknown@kolab.org',
+ ]);
+
+ LDAP::updateSharedFolder($folder);
+ }
+
+ /**
* Test handling update of a non-existing user
*
* @group ldap
diff --git a/src/tests/Feature/Controller/Admin/SharedFoldersTest.php b/src/tests/Feature/Controller/Admin/SharedFoldersTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Controller/Admin/SharedFoldersTest.php
@@ -0,0 +1,149 @@
+<?php
+
+namespace Tests\Feature\Controller\Admin;
+
+use App\SharedFolder;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class SharedFoldersTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ self::useAdminUrl();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ parent::tearDown();
+ }
+
+ /**
+ * Test shared folders searching (/api/v4/shared-folders)
+ */
+ public function testIndex(): void
+ {
+ $user = $this->getTestUser('john@kolab.org');
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
+
+ // Non-admin user
+ $response = $this->actingAs($user)->get("api/v4/shared-folders");
+ $response->assertStatus(403);
+
+ // Search with no search criteria
+ $response = $this->actingAs($admin)->get("api/v4/shared-folders");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertSame([], $json['list']);
+ $this->assertSame("0 shared folders have been found.", $json['message']);
+
+ // Search with no matches expected
+ $response = $this->actingAs($admin)->get("api/v4/shared-folders?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/shared-folders?search={$folder->email}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(1, $json['count']);
+ $this->assertCount(1, $json['list']);
+ $this->assertSame($folder->email, $json['list'][0]['email']);
+
+ // Search by owner
+ $response = $this->actingAs($admin)->get("api/v4/shared-folders?owner={$user->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(2, $json['count']);
+ $this->assertCount(2, $json['list']);
+ $this->assertSame("2 shared folders have been found.", $json['message']);
+ $this->assertSame($folder->email, $json['list'][0]['email']);
+ $this->assertSame($folder->name, $json['list'][0]['name']);
+
+ // Search by owner (Ned is a controller on John's wallets,
+ // here we expect only folders assigned to Ned's wallet(s))
+ $ned = $this->getTestUser('ned@kolab.org');
+ $response = $this->actingAs($admin)->get("api/v4/shared-folders?owner={$ned->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertCount(0, $json['list']);
+ }
+
+ /**
+ * Test fetching shared folder info (GET /api/v4/shared-folders/<folder-id>)
+ */
+ public function testShow(): void
+ {
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $user = $this->getTestUser('john@kolab.org');
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
+
+ // Only admins can access it
+ $response = $this->actingAs($user)->get("api/v4/shared-folders/{$folder->id}");
+ $response->assertStatus(403);
+
+ $response = $this->actingAs($admin)->get("api/v4/shared-folders/{$folder->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertEquals($folder->id, $json['id']);
+ $this->assertEquals($folder->email, $json['email']);
+ $this->assertEquals($folder->name, $json['name']);
+ $this->assertEquals($folder->type, $json['type']);
+ }
+
+ /**
+ * Test fetching shared folder status (GET /api/v4/shared-folders/<folder-id>/status)
+ */
+ public function testStatus(): void
+ {
+ Queue::fake(); // disable jobs
+
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
+
+ // This end-point does not exist for admins
+ $response = $this->actingAs($admin)->get("/api/v4/shared-folders/{$folder->id}/status");
+ $response->assertStatus(404);
+ }
+
+ /**
+ * Test shared folder creating (POST /api/v4/shared-folders)
+ */
+ 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/shared-folders", []);
+ $response->assertStatus(403);
+
+ // Admin can't create shared folders
+ $response = $this->actingAs($admin)->post("/api/v4/shared-folders", []);
+ $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(11, $json);
+ $this->assertCount(13, $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
@@ -194,6 +194,17 @@
$this->assertSame($user->id, $json['list'][0]['id']);
$this->assertSame($user->email, $json['list'][0]['email']);
+ // Search by shared folder email
+ $response = $this->actingAs($admin)->get("api/v4/users?search=folder-event@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/SharedFoldersTest.php b/src/tests/Feature/Controller/Reseller/SharedFoldersTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Controller/Reseller/SharedFoldersTest.php
@@ -0,0 +1,180 @@
+<?php
+
+namespace Tests\Feature\Controller\Reseller;
+
+use App\SharedFolder;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class SharedFoldersTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ self::useResellerUrl();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ parent::tearDown();
+ }
+
+ /**
+ * Test shared folders searching (/api/v4/shared-folders)
+ */
+ 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');
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
+
+ // Non-admin user
+ $response = $this->actingAs($user)->get("api/v4/shared-folders");
+ $response->assertStatus(403);
+
+ // Admin user
+ $response = $this->actingAs($admin)->get("api/v4/shared-folders");
+ $response->assertStatus(403);
+
+ // Search with no search criteria
+ $response = $this->actingAs($reseller1)->get("api/v4/shared-folders");
+ $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/shared-folders?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/shared-folders?search={$folder->email}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(1, $json['count']);
+ $this->assertCount(1, $json['list']);
+ $this->assertSame($folder->email, $json['list'][0]['email']);
+
+ // Search by owner
+ $response = $this->actingAs($reseller1)->get("api/v4/shared-folders?owner={$user->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(2, $json['count']);
+ $this->assertCount(2, $json['list']);
+ $this->assertSame($folder->email, $json['list'][0]['email']);
+ $this->assertSame($folder->name, $json['list'][0]['name']);
+
+ // Search by owner (Ned is a controller on John's wallets,
+ // here we expect only folders assigned to Ned's wallet(s))
+ $ned = $this->getTestUser('ned@kolab.org');
+ $response = $this->actingAs($reseller1)->get("api/v4/shared-folders?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/shared-folders?search={$folder->email}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertSame([], $json['list']);
+
+ $response = $this->actingAs($reseller2)->get("api/v4/shared-folders?owner={$user->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertSame([], $json['list']);
+ }
+
+ /**
+ * Test fetching shared folder info (GET /api/v4/shared-folders/<folder-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');
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
+
+ // Only resellers can access it
+ $response = $this->actingAs($user)->get("api/v4/shared-folders/{$folder->id}");
+ $response->assertStatus(403);
+
+ $response = $this->actingAs($admin)->get("api/v4/shared-folders/{$folder->id}");
+ $response->assertStatus(403);
+
+ $response = $this->actingAs($reseller2)->get("api/v4/shared-folders/{$folder->id}");
+ $response->assertStatus(404);
+
+ $response = $this->actingAs($reseller1)->get("api/v4/shared-folders/{$folder->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertEquals($folder->id, $json['id']);
+ $this->assertEquals($folder->email, $json['email']);
+ $this->assertEquals($folder->name, $json['name']);
+ }
+
+ /**
+ * Test fetching shared folder status (GET /api/v4/shared-folders/<folder-id>/status)
+ */
+ public function testStatus(): void
+ {
+ Queue::fake(); // disable jobs
+
+ $reseller1 = $this->getTestUser('reseller@' . \config('app.domain'));
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
+
+ // This end-point does not exist for folders
+ $response = $this->actingAs($reseller1)->get("/api/v4/shared-folders/{$folder->id}/status");
+ $response->assertStatus(404);
+ }
+
+ /**
+ * Test shared folder creating (POST /api/v4/shared-folders)
+ */
+ 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/shared-folders", []);
+ $response->assertStatus(403);
+
+ // Reseller or admin can't create folders
+ $response = $this->actingAs($admin)->post("/api/v4/shared-folders", []);
+ $response->assertStatus(403);
+
+ $response = $this->actingAs($reseller1)->post("/api/v4/shared-folders", []);
+ $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(11, $json);
+ $this->assertCount(13, $json);
$this->assertSame(100, $json[0]['prio']);
$this->assertSame($sku->id, $json[0]['id']);
diff --git a/src/tests/Feature/Controller/SharedFoldersTest.php b/src/tests/Feature/Controller/SharedFoldersTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Controller/SharedFoldersTest.php
@@ -0,0 +1,488 @@
+<?php
+
+namespace Tests\Feature\Controller;
+
+use App\SharedFolder;
+use App\Http\Controllers\API\V4\SharedFoldersController;
+use Carbon\Carbon;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class SharedFoldersTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestSharedFolder('folder-test@kolab.org');
+ SharedFolder::where('name', 'Test Folder')->delete();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $this->deleteTestSharedFolder('folder-test@kolab.org');
+ SharedFolder::where('name', 'Test Folder')->delete();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test resource deleting (DELETE /api/v4/resources/<id>)
+ */
+ public function testDestroy(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $folder = $this->getTestSharedFolder('folder-test@kolab.org');
+ $folder->assignToWallet($john->wallets->first());
+
+ // Test unauth access
+ $response = $this->delete("api/v4/shared-folders/{$folder->id}");
+ $response->assertStatus(401);
+
+ // Test non-existing folder
+ $response = $this->actingAs($john)->delete("api/v4/shared-folders/abc");
+ $response->assertStatus(404);
+
+ // Test access to other user's folder
+ $response = $this->actingAs($jack)->delete("api/v4/shared-folders/{$folder->id}");
+ $response->assertStatus(403);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame("Access denied", $json['message']);
+ $this->assertCount(2, $json);
+
+ // Test removing a folder
+ $response = $this->actingAs($john)->delete("api/v4/shared-folders/{$folder->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertEquals('success', $json['status']);
+ $this->assertEquals("Shared folder deleted successfully.", $json['message']);
+ }
+
+ /**
+ * Test shared folders listing (GET /api/v4/shared-folders)
+ */
+ public function testIndex(): void
+ {
+ $jack = $this->getTestUser('jack@kolab.org');
+ $john = $this->getTestUser('john@kolab.org');
+ $ned = $this->getTestUser('ned@kolab.org');
+
+ // Test unauth access
+ $response = $this->get("api/v4/shared-folders");
+ $response->assertStatus(401);
+
+ // Test a user with no shared folders
+ $response = $this->actingAs($jack)->get("/api/v4/shared-folders");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(0, $json);
+
+ // Test a user with two shared folders
+ $response = $this->actingAs($john)->get("/api/v4/shared-folders");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $folder = SharedFolder::where('name', 'Calendar')->first();
+
+ $this->assertCount(2, $json);
+ $this->assertSame($folder->id, $json[0]['id']);
+ $this->assertSame($folder->email, $json[0]['email']);
+ $this->assertSame($folder->name, $json[0]['name']);
+ $this->assertSame($folder->type, $json[0]['type']);
+ $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 shared folders
+ $response = $this->actingAs($ned)->get("/api/v4/shared-folders");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame($folder->email, $json[0]['email']);
+ }
+
+ /**
+ * Test shared folder config update (POST /api/v4/shared-folders/<folder>/config)
+ */
+ public function testSetConfig(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $folder = $this->getTestSharedFolder('folder-test@kolab.org');
+ $folder->assignToWallet($john->wallets->first());
+
+ // Test unknown resource id
+ $post = ['acl' => ['john@kolab.org, full']];
+ $response = $this->actingAs($john)->post("/api/v4/shared-folders/123/config", $post);
+ $json = $response->json();
+
+ $response->assertStatus(404);
+
+ // Test access by user not being a wallet controller
+ $response = $this->actingAs($jack)->post("/api/v4/shared-folders/{$folder->id}/config", $post);
+ $json = $response->json();
+
+ $response->assertStatus(403);
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame("Access denied", $json['message']);
+ $this->assertCount(2, $json);
+
+ // Test some invalid data
+ $post = ['test' => 1];
+ $response = $this->actingAs($john)->post("/api/v4/shared-folders/{$folder->id}/config", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(2, $json);
+ $this->assertCount(1, $json['errors']);
+ $this->assertSame('The requested configuration parameter is not supported.', $json['errors']['test']);
+
+ $folder->refresh();
+
+ $this->assertNull($folder->getSetting('test'));
+ $this->assertNull($folder->getSetting('acl'));
+
+ // Test some valid data
+ $post = ['acl' => ['john@kolab.org, full']];
+ $response = $this->actingAs($john)->post("/api/v4/shared-folders/{$folder->id}/config", $post);
+
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("Shared folder settings updated successfully.", $json['message']);
+
+ $this->assertSame(['acl' => $post['acl']], $folder->fresh()->getConfig());
+
+ // Test input validation
+ $post = ['acl' => ['john@kolab.org, full', 'test, full']];
+ $response = $this->actingAs($john)->post("/api/v4/shared-folders/{$folder->id}/config", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(1, $json['errors']);
+ $this->assertCount(1, $json['errors']['acl']);
+ $this->assertSame(
+ "The specified email address is invalid.",
+ $json['errors']['acl'][1]
+ );
+
+ $this->assertSame(['acl' => ['john@kolab.org, full']], $folder->fresh()->getConfig());
+ }
+
+ /**
+ * Test fetching shared folder data/profile (GET /api/v4/shared-folders/<folder>)
+ */
+ public function testShow(): void
+ {
+ $jack = $this->getTestUser('jack@kolab.org');
+ $john = $this->getTestUser('john@kolab.org');
+ $ned = $this->getTestUser('ned@kolab.org');
+
+ $folder = $this->getTestSharedFolder('folder-test@kolab.org');
+ $folder->assignToWallet($john->wallets->first());
+ $folder->setSetting('acl', '["anyone, full"]');
+
+ // Test unauthenticated access
+ $response = $this->get("/api/v4/shared-folders/{$folder->id}");
+ $response->assertStatus(401);
+
+ // Test unauthorized access to a shared folder of another user
+ $response = $this->actingAs($jack)->get("/api/v4/shared-folders/{$folder->id}");
+ $response->assertStatus(403);
+
+ // John: Account owner - non-existing folder
+ $response = $this->actingAs($john)->get("/api/v4/shared-folders/abc");
+ $response->assertStatus(404);
+
+ // John: Account owner
+ $response = $this->actingAs($john)->get("/api/v4/shared-folders/{$folder->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame($folder->id, $json['id']);
+ $this->assertSame($folder->email, $json['email']);
+ $this->assertSame($folder->name, $json['name']);
+ $this->assertSame($folder->type, $json['type']);
+ $this->assertTrue(!empty($json['statusInfo']));
+ $this->assertArrayHasKey('isDeleted', $json);
+ $this->assertArrayHasKey('isActive', $json);
+ $this->assertArrayHasKey('isLdapReady', $json);
+ $this->assertArrayHasKey('isImapReady', $json);
+ $this->assertSame(['acl' => ['anyone, full']], $json['config']);
+ }
+
+ /**
+ * Test fetching a shared folder status (GET /api/v4/shared-folders/<folder>/status)
+ * and forcing setup process update (?refresh=1)
+ */
+ public function testStatus(): void
+ {
+ Queue::fake();
+
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+
+ $folder = $this->getTestSharedFolder('folder-test@kolab.org');
+ $folder->assignToWallet($john->wallets->first());
+
+ // Test unauthorized access
+ $response = $this->get("/api/v4/shared-folders/abc/status");
+ $response->assertStatus(401);
+
+ // Test unauthorized access
+ $response = $this->actingAs($jack)->get("/api/v4/shared-folders/{$folder->id}/status");
+ $response->assertStatus(403);
+
+ $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE;
+ $folder->save();
+
+ // Get resource status
+ $response = $this->actingAs($john)->get("/api/v4/shared-folders/{$folder->id}/status");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertFalse($json['isLdapReady']);
+ $this->assertFalse($json['isImapReady']);
+ $this->assertFalse($json['isReady']);
+ $this->assertFalse($json['isDeleted']);
+ $this->assertTrue($json['isActive']);
+ $this->assertCount(7, $json['process']);
+ $this->assertSame('shared-folder-new', $json['process'][0]['label']);
+ $this->assertSame(true, $json['process'][0]['state']);
+ $this->assertSame('shared-folder-ldap-ready', $json['process'][1]['label']);
+ $this->assertSame(false, $json['process'][1]['state']);
+ $this->assertTrue(empty($json['status']));
+ $this->assertTrue(empty($json['message']));
+ $this->assertSame('running', $json['processState']);
+
+ // Make sure the domain is confirmed (other test might unset that status)
+ $domain = $this->getTestDomain('kolab.org');
+ $domain->status |= \App\Domain::STATUS_CONFIRMED;
+ $domain->save();
+ $folder->status |= SharedFolder::STATUS_IMAP_READY;
+ $folder->save();
+
+ // Now "reboot" the process and get the folder status
+ $response = $this->actingAs($john)->get("/api/v4/shared-folders/{$folder->id}/status?refresh=1");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertTrue($json['isLdapReady']);
+ $this->assertTrue($json['isImapReady']);
+ $this->assertTrue($json['isReady']);
+ $this->assertCount(7, $json['process']);
+ $this->assertSame('shared-folder-ldap-ready', $json['process'][1]['label']);
+ $this->assertSame(true, $json['process'][1]['state']);
+ $this->assertSame('shared-folder-imap-ready', $json['process'][2]['label']);
+ $this->assertSame(true, $json['process'][2]['state']);
+ $this->assertSame('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/shared-folders/{$folder->id}/status?refresh=1");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertTrue($json['isLdapReady']);
+ $this->assertTrue($json['isReady']);
+ $this->assertCount(7, $json['process']);
+ $this->assertSame('shared-folder-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 SharedFoldersController::statusInfo()
+ */
+ public function testStatusInfo(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $folder = $this->getTestSharedFolder('folder-test@kolab.org');
+ $folder->assignToWallet($john->wallets->first());
+ $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE;
+ $folder->save();
+ $domain = $this->getTestDomain('kolab.org');
+ $domain->status |= \App\Domain::STATUS_CONFIRMED;
+ $domain->save();
+
+ $result = SharedFoldersController::statusInfo($folder);
+
+ $this->assertFalse($result['isReady']);
+ $this->assertCount(7, $result['process']);
+ $this->assertSame('shared-folder-new', $result['process'][0]['label']);
+ $this->assertSame(true, $result['process'][0]['state']);
+ $this->assertSame('shared-folder-ldap-ready', $result['process'][1]['label']);
+ $this->assertSame(false, $result['process'][1]['state']);
+ $this->assertSame('running', $result['processState']);
+
+ $folder->created_at = Carbon::now()->subSeconds(181);
+ $folder->save();
+
+ $result = SharedFoldersController::statusInfo($folder);
+
+ $this->assertSame('failed', $result['processState']);
+
+ $folder->status |= SharedFolder::STATUS_LDAP_READY | SharedFolder::STATUS_IMAP_READY;
+ $folder->save();
+
+ $result = SharedFoldersController::statusInfo($folder);
+
+ $this->assertTrue($result['isReady']);
+ $this->assertCount(7, $result['process']);
+ $this->assertSame('shared-folder-new', $result['process'][0]['label']);
+ $this->assertSame(true, $result['process'][0]['state']);
+ $this->assertSame('shared-folder-ldap-ready', $result['process'][1]['label']);
+ $this->assertSame(true, $result['process'][1]['state']);
+ $this->assertSame('shared-folder-ldap-ready', $result['process'][1]['label']);
+ $this->assertSame(true, $result['process'][1]['state']);
+ $this->assertSame('done', $result['processState']);
+ }
+
+ /**
+ * Test shared folder creation (POST /api/v4/shared-folders)
+ */
+ public function testStore(): void
+ {
+ Queue::fake();
+
+ $jack = $this->getTestUser('jack@kolab.org');
+ $john = $this->getTestUser('john@kolab.org');
+
+ // Test unauth request
+ $response = $this->post("/api/v4/shared-folders", []);
+ $response->assertStatus(401);
+
+ // Test non-controller user
+ $response = $this->actingAs($jack)->post("/api/v4/shared-folders", []);
+ $response->assertStatus(403);
+
+ // Test empty request
+ $response = $this->actingAs($john)->post("/api/v4/shared-folders", []);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame("The name field is required.", $json['errors']['name'][0]);
+ $this->assertSame("The type field is required.", $json['errors']['type'][0]);
+ $this->assertCount(2, $json);
+ $this->assertCount(2, $json['errors']);
+
+ // Test too long name
+ $post = ['domain' => 'kolab.org', 'name' => str_repeat('A', 192), 'type' => 'unknown'];
+ $response = $this->actingAs($john)->post("/api/v4/shared-folders", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(2, $json);
+ $this->assertSame(["The name may not be greater than 191 characters."], $json['errors']['name']);
+ $this->assertSame(["The specified type is invalid."], $json['errors']['type']);
+ $this->assertCount(2, $json['errors']);
+
+ // Test successful folder creation
+ $post['name'] = 'Test Folder';
+ $post['type'] = 'event';
+ $response = $this->actingAs($john)->post("/api/v4/shared-folders", $post);
+ $json = $response->json();
+
+ $response->assertStatus(200);
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("Shared folder created successfully.", $json['message']);
+ $this->assertCount(2, $json);
+
+ $folder = SharedFolder::where('name', $post['name'])->first();
+ $this->assertInstanceOf(SharedFolder::class, $folder);
+ $this->assertSame($post['type'], $folder->type);
+ $this->assertTrue($john->sharedFolders()->get()->contains($folder));
+
+ // Shared folder name must be unique within a domain
+ $post['type'] = 'mail';
+ $response = $this->actingAs($john)->post("/api/v4/shared-folders", $post);
+ $response->assertStatus(422);
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(2, $json);
+ $this->assertCount(1, $json['errors']);
+ $this->assertSame("The specified name is not available.", $json['errors']['name'][0]);
+ }
+
+ /**
+ * Test shared folder update (PUT /api/v4/shared-folders/<folder)
+ */
+ public function testUpdate(): void
+ {
+ Queue::fake();
+
+ $jack = $this->getTestUser('jack@kolab.org');
+ $john = $this->getTestUser('john@kolab.org');
+ $ned = $this->getTestUser('ned@kolab.org');
+
+ $folder = $this->getTestSharedFolder('folder-test@kolab.org');
+ $folder->assignToWallet($john->wallets->first());
+
+ // Test unauthorized update
+ $response = $this->get("/api/v4/shared-folders/{$folder->id}", []);
+ $response->assertStatus(401);
+
+ // Test unauthorized update
+ $response = $this->actingAs($jack)->get("/api/v4/shared-folders/{$folder->id}", []);
+ $response->assertStatus(403);
+
+ // Name change
+ $post = [
+ 'name' => 'Test Res',
+ ];
+
+ $response = $this->actingAs($john)->put("/api/v4/shared-folders/{$folder->id}", $post);
+ $json = $response->json();
+
+ $response->assertStatus(200);
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("Shared folder updated successfully.", $json['message']);
+ $this->assertCount(2, $json);
+
+ $folder->refresh();
+ $this->assertSame($post['name'], $folder->name);
+ }
+}
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(11, $json);
+ $this->assertCount(13, $json);
$this->assertSame(100, $json[0]['prio']);
$this->assertSame($sku->id, $json[0]['id']);
@@ -215,7 +215,7 @@
$json = $response->json();
- $this->assertCount(9, $json);
+ $this->assertCount(10, $json);
$this->assertSkuElement('beta', $json[6], [
'prio' => 10,
@@ -234,7 +234,16 @@
'required' => ['beta'],
]);
- $this->assertSkuElement('distlist', $json[8], [
+ $this->assertSkuElement('beta-shared-folders', $json[8], [
+ 'prio' => 10,
+ 'type' => 'user',
+ 'handler' => 'sharedfolders',
+ 'enabled' => false,
+ 'readonly' => false,
+ 'required' => ['beta'],
+ ]);
+
+ $this->assertSkuElement('distlist', $json[9], [
'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
@@ -239,6 +239,7 @@
$this->deleteTestUser('user@gmail.com');
$this->deleteTestGroup('group@gmail.com');
$this->deleteTestResource('resource@gmail.com');
+ $this->deleteTestSharedFolder('folder@gmail.com');
// Empty domain
$domain = $this->getTestDomain('gmail.com', [
@@ -259,6 +260,9 @@
$this->getTestResource('resource@gmail.com');
$this->assertFalse($domain->isEmpty());
$this->deleteTestResource('resource@gmail.com');
+ $this->getTestSharedFolder('folder@gmail.com');
+ $this->assertFalse($domain->isEmpty());
+ $this->deleteTestSharedFolder('folder@gmail.com');
// TODO: Test with an existing alias, but not other objects in a domain
diff --git a/src/tests/Feature/Jobs/DomainCreateTest.php b/src/tests/Feature/Jobs/Domain/CreateTest.php
rename from src/tests/Feature/Jobs/DomainCreateTest.php
rename to src/tests/Feature/Jobs/Domain/CreateTest.php
--- a/src/tests/Feature/Jobs/DomainCreateTest.php
+++ b/src/tests/Feature/Jobs/Domain/CreateTest.php
@@ -1,12 +1,12 @@
<?php
-namespace Tests\Feature\Jobs;
+namespace Tests\Feature\Jobs\Domain;
use App\Domain;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
-class DomainCreateTest extends TestCase
+class CreateTest extends TestCase
{
/**
* {@inheritDoc}
diff --git a/src/tests/Feature/Jobs/DomainVerifyTest.php b/src/tests/Feature/Jobs/Domain/VerifyTest.php
rename from src/tests/Feature/Jobs/DomainVerifyTest.php
rename to src/tests/Feature/Jobs/Domain/VerifyTest.php
--- a/src/tests/Feature/Jobs/DomainVerifyTest.php
+++ b/src/tests/Feature/Jobs/Domain/VerifyTest.php
@@ -1,11 +1,11 @@
<?php
-namespace Tests\Feature\Jobs;
+namespace Tests\Feature\Jobs\Domain;
use App\Domain;
use Tests\TestCase;
-class DomainVerifyTest extends TestCase
+class VerifyTest extends TestCase
{
/**
* {@inheritDoc}
diff --git a/src/tests/Feature/Jobs/Resource/CreateTest.php b/src/tests/Feature/Jobs/Resource/CreateTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Jobs/Resource/CreateTest.php
@@ -0,0 +1,83 @@
+<?php
+
+namespace Tests\Feature\Jobs\Resource;
+
+use App\Resource;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class CreateTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestResource('resource-test@' . \config('app.domain'));
+ }
+
+ public function tearDown(): void
+ {
+ $this->deleteTestResource('resource-test@' . \config('app.domain'));
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test job handle
+ *
+ * @group ldap
+ */
+ public function testHandle(): void
+ {
+ Queue::fake();
+
+ // Test unknown resource
+ $job = new \App\Jobs\Resource\CreateJob(123);
+ $job->handle();
+
+ $this->assertTrue($job->isReleased());
+ $this->assertFalse($job->hasFailed());
+
+ $resource = $this->getTestResource('resource-test@' . \config('app.domain'));
+
+ $this->assertFalse($resource->isLdapReady());
+
+ // Test resource creation
+ $job = new \App\Jobs\Resource\CreateJob($resource->id);
+ $job->handle();
+
+ $this->assertTrue($resource->fresh()->isLdapReady());
+ $this->assertFalse($job->hasFailed());
+
+ // Test job failures
+ $job = new \App\Jobs\Resource\CreateJob($resource->id);
+ $job->handle();
+
+ $this->assertTrue($job->hasFailed());
+ $this->assertSame("Resource {$resource->id} is already marked as ldap-ready.", $job->failureMessage);
+
+ $resource->status |= Resource::STATUS_DELETED;
+ $resource->save();
+
+ $job = new \App\Jobs\Resource\CreateJob($resource->id);
+ $job->handle();
+
+ $this->assertTrue($job->hasFailed());
+ $this->assertSame("Resource {$resource->id} is marked as deleted.", $job->failureMessage);
+
+ $resource->status ^= Resource::STATUS_DELETED;
+ $resource->save();
+ $resource->delete();
+
+ $job = new \App\Jobs\Resource\CreateJob($resource->id);
+ $job->handle();
+
+ $this->assertTrue($job->hasFailed());
+ $this->assertSame("Resource {$resource->id} is actually deleted.", $job->failureMessage);
+
+ // TODO: Test failures on domain sanity checks
+ }
+}
diff --git a/src/tests/Feature/Jobs/Resource/DeleteTest.php b/src/tests/Feature/Jobs/Resource/DeleteTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Jobs/Resource/DeleteTest.php
@@ -0,0 +1,76 @@
+<?php
+
+namespace Tests\Feature\Jobs\Resource;
+
+use App\Resource;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class DeleteTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestResource('resource-test@' . \config('app.domain'));
+ }
+
+ public function tearDown(): void
+ {
+ $this->deleteTestResource('resource-test@' . \config('app.domain'));
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test job handle
+ *
+ * @group ldap
+ */
+ public function testHandle(): void
+ {
+ Queue::fake();
+
+ // Test non-existing resource ID
+ $job = new \App\Jobs\Resource\DeleteJob(123);
+ $job->handle();
+
+ $this->assertTrue($job->hasFailed());
+ $this->assertSame("Resource 123 could not be found in the database.", $job->failureMessage);
+
+ $resource = $this->getTestResource('resource-test@' . \config('app.domain'), [
+ 'status' => Resource::STATUS_NEW
+ ]);
+
+ // create the resource first
+ $job = new \App\Jobs\Resource\CreateJob($resource->id);
+ $job->handle();
+
+ $resource->refresh();
+
+ $this->assertTrue($resource->isLdapReady());
+
+ // Test successful deletion
+ $resource->status |= Resource::STATUS_IMAP_READY;
+ $resource->save();
+
+ $job = new \App\Jobs\Resource\DeleteJob($resource->id);
+ $job->handle();
+
+ $resource->refresh();
+
+ $this->assertFalse($resource->isLdapReady());
+ $this->assertFalse($resource->isImapReady());
+ $this->assertTrue($resource->isDeleted());
+
+ // Test deleting already deleted resource
+ $job = new \App\Jobs\Resource\DeleteJob($resource->id);
+ $job->handle();
+
+ $this->assertTrue($job->hasFailed());
+ $this->assertSame("Resource {$resource->id} is already marked as deleted.", $job->failureMessage);
+ }
+}
diff --git a/src/tests/Feature/Jobs/Resource/UpdateTest.php b/src/tests/Feature/Jobs/Resource/UpdateTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Jobs/Resource/UpdateTest.php
@@ -0,0 +1,82 @@
+<?php
+
+namespace Tests\Feature\Jobs\Resource;
+
+use App\Backends\LDAP;
+use App\Resource;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class UpdateTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestResource('resource-test@' . \config('app.domain'));
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $this->deleteTestResource('resource-test@' . \config('app.domain'));
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test job handle
+ *
+ * @group ldap
+ */
+ public function testHandle(): void
+ {
+ Queue::fake();
+
+ // Test non-existing resource ID
+ $job = new \App\Jobs\Resource\UpdateJob(123);
+ $job->handle();
+
+ $this->assertTrue($job->hasFailed());
+ $this->assertSame("Resource 123 could not be found in the database.", $job->failureMessage);
+
+ $resource = $this->getTestResource('resource-test@' . \config('app.domain'));
+
+ // Create the resource in LDAP
+ $job = new \App\Jobs\Resource\CreateJob($resource->id);
+ $job->handle();
+
+ $resource->setConfig(['invitation_policy' => 'accept']);
+
+ $job = new \App\Jobs\Resource\UpdateJob($resource->id);
+ $job->handle();
+
+ $ldap_resource = LDAP::getResource($resource->email);
+
+ $this->assertSame('ACT_ACCEPT', $ldap_resource['kolabinvitationpolicy']);
+
+ // Test that the job is being deleted if the resource is not ldap ready or is deleted
+ $resource->refresh();
+ $resource->status = Resource::STATUS_NEW | Resource::STATUS_ACTIVE;
+ $resource->save();
+
+ $job = new \App\Jobs\Resource\UpdateJob($resource->id);
+ $job->handle();
+
+ $this->assertTrue($job->isDeleted());
+
+ $resource->status = Resource::STATUS_NEW | Resource::STATUS_ACTIVE
+ | Resource::STATUS_LDAP_READY | Resource::STATUS_DELETED;
+ $resource->save();
+
+ $job = new \App\Jobs\Resource\UpdateJob($resource->id);
+ $job->handle();
+
+ $this->assertTrue($job->isDeleted());
+ }
+}
diff --git a/src/tests/Feature/Jobs/Resource/VerifyTest.php b/src/tests/Feature/Jobs/Resource/VerifyTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Jobs/Resource/VerifyTest.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace Tests\Feature\Jobs\Resource;
+
+use App\Resource;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class VerifyTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $resource = $this->getTestResource('resource-test1@kolab.org');
+ $resource->status |= Resource::STATUS_IMAP_READY;
+ $resource->save();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $resource = $this->getTestResource('resource-test1@kolab.org');
+ $resource->status |= Resource::STATUS_IMAP_READY;
+ $resource->save();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test job handle
+ *
+ * @group imap
+ */
+ public function testHandle(): void
+ {
+ Queue::fake();
+
+ // Test non-existing resource ID
+ $job = new \App\Jobs\Resource\VerifyJob(123);
+ $job->handle();
+
+ $this->assertTrue($job->hasFailed());
+ $this->assertSame("Resource 123 could not be found in the database.", $job->failureMessage);
+
+ // Test existing resource
+ $resource = $this->getTestResource('resource-test1@kolab.org');
+
+ if ($resource->isImapReady()) {
+ $resource->status ^= Resource::STATUS_IMAP_READY;
+ $resource->save();
+ }
+
+ $this->assertFalse($resource->isImapReady());
+
+ for ($i = 0; $i < 10; $i++) {
+ $job = new \App\Jobs\Resource\VerifyJob($resource->id);
+ $job->handle();
+
+ if ($resource->fresh()->isImapReady()) {
+ $this->assertTrue(true);
+ return;
+ }
+
+ sleep(1);
+ }
+
+ $this->assertTrue(false, "Unable to verify the shared folder is set up in time");
+ }
+}
diff --git a/src/tests/Feature/Jobs/SharedFolder/CreateTest.php b/src/tests/Feature/Jobs/SharedFolder/CreateTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Jobs/SharedFolder/CreateTest.php
@@ -0,0 +1,83 @@
+<?php
+
+namespace Tests\Feature\Jobs\SharedFolder;
+
+use App\SharedFolder;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class CreateTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestSharedFolder('folder-test@' . \config('app.domain'));
+ }
+
+ public function tearDown(): void
+ {
+ $this->deleteTestSharedFolder('folder-test@' . \config('app.domain'));
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test job handle
+ *
+ * @group ldap
+ */
+ public function testHandle(): void
+ {
+ Queue::fake();
+
+ // Test unknown folder
+ $job = new \App\Jobs\SharedFolder\CreateJob(123);
+ $job->handle();
+
+ $this->assertTrue($job->isReleased());
+ $this->assertFalse($job->hasFailed());
+
+ $folder = $this->getTestSharedFolder('folder-test@' . \config('app.domain'));
+
+ $this->assertFalse($folder->isLdapReady());
+
+ // Test shared folder creation
+ $job = new \App\Jobs\SharedFolder\CreateJob($folder->id);
+ $job->handle();
+
+ $this->assertTrue($folder->fresh()->isLdapReady());
+ $this->assertFalse($job->hasFailed());
+
+ // Test job failures
+ $job = new \App\Jobs\SharedFolder\CreateJob($folder->id);
+ $job->handle();
+
+ $this->assertTrue($job->hasFailed());
+ $this->assertSame("Shared folder {$folder->id} is already marked as ldap-ready.", $job->failureMessage);
+
+ $folder->status |= SharedFolder::STATUS_DELETED;
+ $folder->save();
+
+ $job = new \App\Jobs\SharedFolder\CreateJob($folder->id);
+ $job->handle();
+
+ $this->assertTrue($job->hasFailed());
+ $this->assertSame("Shared folder {$folder->id} is marked as deleted.", $job->failureMessage);
+
+ $folder->status ^= SharedFolder::STATUS_DELETED;
+ $folder->save();
+ $folder->delete();
+
+ $job = new \App\Jobs\SharedFolder\CreateJob($folder->id);
+ $job->handle();
+
+ $this->assertTrue($job->hasFailed());
+ $this->assertSame("Shared folder {$folder->id} is actually deleted.", $job->failureMessage);
+
+ // TODO: Test failures on domain sanity checks
+ }
+}
diff --git a/src/tests/Feature/Jobs/SharedFolder/DeleteTest.php b/src/tests/Feature/Jobs/SharedFolder/DeleteTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Jobs/SharedFolder/DeleteTest.php
@@ -0,0 +1,76 @@
+<?php
+
+namespace Tests\Feature\Jobs\SharedFolder;
+
+use App\SharedFolder;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class DeleteTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestSharedFolder('folder-test@' . \config('app.domain'));
+ }
+
+ public function tearDown(): void
+ {
+ $this->deleteTestSharedFolder('folder-test@' . \config('app.domain'));
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test job handle
+ *
+ * @group ldap
+ */
+ public function testHandle(): void
+ {
+ Queue::fake();
+
+ // Test non-existing folder ID
+ $job = new \App\Jobs\SharedFolder\DeleteJob(123);
+ $job->handle();
+
+ $this->assertTrue($job->hasFailed());
+ $this->assertSame("Shared folder 123 could not be found in the database.", $job->failureMessage);
+
+ $folder = $this->getTestSharedFolder('folder-test@' . \config('app.domain'), [
+ 'status' => SharedFolder::STATUS_NEW
+ ]);
+
+ // create the shared folder first
+ $job = new \App\Jobs\SharedFolder\CreateJob($folder->id);
+ $job->handle();
+
+ $folder->refresh();
+
+ $this->assertTrue($folder->isLdapReady());
+
+ // Test successful deletion
+ $folder->status |= SharedFolder::STATUS_IMAP_READY;
+ $folder->save();
+
+ $job = new \App\Jobs\SharedFolder\DeleteJob($folder->id);
+ $job->handle();
+
+ $folder->refresh();
+
+ $this->assertFalse($folder->isLdapReady());
+ $this->assertFalse($folder->isImapReady());
+ $this->assertTrue($folder->isDeleted());
+
+ // Test deleting already deleted folder
+ $job = new \App\Jobs\SharedFolder\DeleteJob($folder->id);
+ $job->handle();
+
+ $this->assertTrue($job->hasFailed());
+ $this->assertSame("Shared folder {$folder->id} is already marked as deleted.", $job->failureMessage);
+ }
+}
diff --git a/src/tests/Feature/Jobs/SharedFolder/UpdateTest.php b/src/tests/Feature/Jobs/SharedFolder/UpdateTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Jobs/SharedFolder/UpdateTest.php
@@ -0,0 +1,78 @@
+<?php
+
+namespace Tests\Feature\Jobs\SharedFolder;
+
+use App\Backends\LDAP;
+use App\SharedFolder;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class UpdateTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestSharedFolder('folder-test@' . \config('app.domain'));
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $this->deleteTestSharedFolder('folder-test@' . \config('app.domain'));
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test job handle
+ *
+ * @group ldap
+ */
+ public function testHandle(): void
+ {
+ Queue::fake();
+
+ // Test non-existing folder ID
+ $job = new \App\Jobs\SharedFolder\UpdateJob(123);
+ $job->handle();
+
+ $this->assertTrue($job->hasFailed());
+ $this->assertSame("Shared folder 123 could not be found in the database.", $job->failureMessage);
+
+ $folder = $this->getTestSharedFolder('folder-test@' . \config('app.domain'));
+
+ // Create the folder in LDAP
+ $job = new \App\Jobs\SharedFolder\CreateJob($folder->id);
+ $job->handle();
+
+ $job = new \App\Jobs\SharedFolder\UpdateJob($folder->id);
+ $job->handle();
+
+ $this->assertTrue(is_array(LDAP::getSharedFolder($folder->email)));
+
+ // Test that the job is being deleted if the folder is not ldap ready or is deleted
+ $folder->refresh();
+ $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE;
+ $folder->save();
+
+ $job = new \App\Jobs\SharedFolder\UpdateJob($folder->id);
+ $job->handle();
+
+ $this->assertTrue($job->isDeleted());
+
+ $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE
+ | SharedFolder::STATUS_LDAP_READY | SharedFolder::STATUS_DELETED;
+ $folder->save();
+
+ $job = new \App\Jobs\SharedFolder\UpdateJob($folder->id);
+ $job->handle();
+
+ $this->assertTrue($job->isDeleted());
+ }
+}
diff --git a/src/tests/Feature/Jobs/SharedFolder/VerifyTest.php b/src/tests/Feature/Jobs/SharedFolder/VerifyTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Jobs/SharedFolder/VerifyTest.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace Tests\Feature\Jobs\SharedFolder;
+
+use App\SharedFolder;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class VerifyTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
+ $folder->status |= SharedFolder::STATUS_IMAP_READY;
+ $folder->save();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
+ $folder->status |= SharedFolder::STATUS_IMAP_READY;
+ $folder->save();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test job handle
+ *
+ * @group imap
+ */
+ public function testHandle(): void
+ {
+ Queue::fake();
+
+ // Test non-existing folder ID
+ $job = new \App\Jobs\SharedFolder\VerifyJob(123);
+ $job->handle();
+
+ $this->assertTrue($job->hasFailed());
+ $this->assertSame("Shared folder 123 could not be found in the database.", $job->failureMessage);
+
+ // Test existing folder
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
+
+ if ($folder->isImapReady()) {
+ $folder->status ^= SharedFolder::STATUS_IMAP_READY;
+ $folder->save();
+ }
+
+ $this->assertFalse($folder->isImapReady());
+
+ for ($i = 0; $i < 10; $i++) {
+ $job = new \App\Jobs\SharedFolder\VerifyJob($folder->id);
+ $job->handle();
+
+ if ($folder->fresh()->isImapReady()) {
+ $this->assertTrue(true);
+ return;
+ }
+
+ sleep(1);
+ }
+
+ $this->assertTrue(false, "Unable to verify the shared folder is set up in time");
+ }
+}
diff --git a/src/tests/Feature/Jobs/UserCreateTest.php b/src/tests/Feature/Jobs/User/CreateTest.php
rename from src/tests/Feature/Jobs/UserCreateTest.php
rename to src/tests/Feature/Jobs/User/CreateTest.php
--- a/src/tests/Feature/Jobs/UserCreateTest.php
+++ b/src/tests/Feature/Jobs/User/CreateTest.php
@@ -1,11 +1,11 @@
<?php
-namespace Tests\Feature\Jobs;
+namespace Tests\Feature\Jobs\User;
use App\User;
use Tests\TestCase;
-class UserCreateTest extends TestCase
+class CreateTest extends TestCase
{
/**
* {@inheritDoc}
diff --git a/src/tests/Feature/Jobs/UserUpdateTest.php b/src/tests/Feature/Jobs/User/UpdateTest.php
rename from src/tests/Feature/Jobs/UserUpdateTest.php
rename to src/tests/Feature/Jobs/User/UpdateTest.php
--- a/src/tests/Feature/Jobs/UserUpdateTest.php
+++ b/src/tests/Feature/Jobs/User/UpdateTest.php
@@ -1,12 +1,12 @@
<?php
-namespace Tests\Feature\Jobs;
+namespace Tests\Feature\Jobs\User;
use App\Backends\LDAP;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
-class UserUpdateTest extends TestCase
+class UpdateTest extends TestCase
{
/**
* {@inheritDoc}
diff --git a/src/tests/Feature/Jobs/UserVerifyTest.php b/src/tests/Feature/Jobs/User/VerifyTest.php
rename from src/tests/Feature/Jobs/UserVerifyTest.php
rename to src/tests/Feature/Jobs/User/VerifyTest.php
--- a/src/tests/Feature/Jobs/UserVerifyTest.php
+++ b/src/tests/Feature/Jobs/User/VerifyTest.php
@@ -1,12 +1,12 @@
<?php
-namespace Tests\Feature\Jobs;
+namespace Tests\Feature\Jobs\User;
use App\User;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
-class UserVerifyTest extends TestCase
+class VerifyTest extends TestCase
{
/**
* {@inheritDoc}
diff --git a/src/tests/Feature/SharedFolderTest.php b/src/tests/Feature/SharedFolderTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/SharedFolderTest.php
@@ -0,0 +1,304 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\SharedFolder;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class SharedFolderTest extends TestCase
+{
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestUser('user-test@kolabnow.com');
+ SharedFolder::withTrashed()->where('email', 'like', '%@kolabnow.com')->each(function ($folder) {
+ $this->deleteTestSharedFolder($folder->email);
+ });
+ }
+
+ public function tearDown(): void
+ {
+ $this->deleteTestUser('user-test@kolabnow.com');
+ SharedFolder::withTrashed()->where('email', 'like', '%@kolabnow.com')->each(function ($folder) {
+ $this->deleteTestSharedFolder($folder->email);
+ });
+
+ parent::tearDown();
+ }
+
+ /**
+ * Tests for SharedFolder::assignToWallet()
+ */
+ public function testAssignToWallet(): void
+ {
+ $user = $this->getTestUser('user-test@kolabnow.com');
+ $folder = $this->getTestSharedFolder('folder-test@kolabnow.com');
+
+ $result = $folder->assignToWallet($user->wallets->first());
+
+ $this->assertSame($folder, $result);
+ $this->assertSame(1, $folder->entitlements()->count());
+ $this->assertSame('shared-folder', $folder->entitlements()->first()->sku->title);
+
+ // Can't be done twice on the same folder
+ $this->expectException(\Exception::class);
+ $result->assignToWallet($user->wallets->first());
+ }
+
+ /**
+ * Test SharedFolder::getConfig() and setConfig() methods
+ */
+ public function testConfigTrait(): void
+ {
+ Queue::fake();
+
+ $folder = new SharedFolder();
+ $folder->email = 'folder-test@kolabnow.com';
+ $folder->name = 'Test';
+ $folder->save();
+ $john = $this->getTestUser('john@kolab.org');
+ $folder->assignToWallet($john->wallets->first());
+
+ $this->assertSame(['acl' => []], $folder->getConfig());
+
+ $result = $folder->setConfig(['acl' => ['anyone, read-only'], 'unknown' => false]);
+
+ $this->assertSame(['acl' => ['anyone, read-only']], $folder->getConfig());
+ $this->assertSame('["anyone, read-only"]', $folder->getSetting('acl'));
+ $this->assertSame(['unknown' => "The requested configuration parameter is not supported."], $result);
+
+ $result = $folder->setConfig(['acl' => ['anyone, unknown']]);
+
+ $this->assertSame(['acl' => ['anyone, read-only']], $folder->getConfig());
+ $this->assertSame('["anyone, read-only"]', $folder->getSetting('acl'));
+ $this->assertSame(['acl' => ["The entry format is invalid. Expected an email address."]], $result);
+
+ // Test valid user for ACL
+ $result = $folder->setConfig(['acl' => ['john@kolab.org, full']]);
+
+ $this->assertSame(['acl' => ['john@kolab.org, full']], $folder->getConfig());
+ $this->assertSame('["john@kolab.org, full"]', $folder->getSetting('acl'));
+ $this->assertSame([], $result);
+
+ // Test invalid user for ACL
+ $result = $folder->setConfig(['acl' => ['john, full']]);
+
+ $this->assertSame(['acl' => ['john@kolab.org, full']], $folder->getConfig());
+ $this->assertSame('["john@kolab.org, full"]', $folder->getSetting('acl'));
+ $this->assertSame(['acl' => ["The specified email address is invalid."]], $result);
+
+ // Other invalid entries
+ $acl = [
+ // Test non-existing user for ACL
+ 'unknown@kolab.org, full',
+ // Test existing user from a different wallet
+ 'user@sample-tenant.dev-local, read-only',
+ // Valid entry
+ 'john@kolab.org, read-write',
+ ];
+
+ $result = $folder->setConfig(['acl' => $acl]);
+ $this->assertCount(2, $result['acl']);
+ $this->assertSame("The specified email address does not exist.", $result['acl'][0]);
+ $this->assertSame("The specified email address does not exist.", $result['acl'][1]);
+ $this->assertSame(['acl' => ['john@kolab.org, full']], $folder->getConfig());
+ $this->assertSame('["john@kolab.org, full"]', $folder->getSetting('acl'));
+ }
+
+ /**
+ * Test creating a shared folder
+ */
+ public function testCreate(): void
+ {
+ Queue::fake();
+
+ $folder = new SharedFolder();
+ $folder->name = 'Reśo';
+ $folder->domain = 'kolabnow.com';
+ $folder->save();
+
+ $this->assertMatchesRegularExpression('/^[0-9]{1,20}$/', $folder->id);
+ $this->assertMatchesRegularExpression('/^mail-[0-9]{1,20}@kolabnow\.com$/', $folder->email);
+ $this->assertSame('Reśo', $folder->name);
+ $this->assertTrue($folder->isNew());
+ $this->assertTrue($folder->isActive());
+ $this->assertFalse($folder->isDeleted());
+ $this->assertFalse($folder->isLdapReady());
+ $this->assertFalse($folder->isImapReady());
+
+ $settings = $folder->settings()->get();
+ $this->assertCount(1, $settings);
+ $this->assertSame('folder', $settings[0]->key);
+ $this->assertSame('shared/Reśo@kolabnow.com', $settings[0]->value);
+
+ Queue::assertPushed(
+ \App\Jobs\SharedFolder\CreateJob::class,
+ function ($job) use ($folder) {
+ $folderEmail = TestCase::getObjectProperty($job, 'folderEmail');
+ $folderId = TestCase::getObjectProperty($job, 'folderId');
+
+ return $folderEmail === $folder->email
+ && $folderId === $folder->id;
+ }
+ );
+
+ Queue::assertPushedWithChain(
+ \App\Jobs\SharedFolder\CreateJob::class,
+ [
+ \App\Jobs\SharedFolder\VerifyJob::class,
+ ]
+ );
+ }
+
+ /**
+ * Test a shared folder deletion and force-deletion
+ */
+ public function testDelete(): void
+ {
+ Queue::fake();
+
+ $user = $this->getTestUser('user-test@kolabnow.com');
+ $folder = $this->getTestSharedFolder('folder-test@kolabnow.com');
+ $folder->assignToWallet($user->wallets->first());
+
+ $entitlements = \App\Entitlement::where('entitleable_id', $folder->id);
+
+ $this->assertSame(1, $entitlements->count());
+
+ $folder->delete();
+
+ $this->assertTrue($folder->fresh()->trashed());
+ $this->assertSame(0, $entitlements->count());
+ $this->assertSame(1, $entitlements->withTrashed()->count());
+
+ $folder->forceDelete();
+
+ $this->assertSame(0, $entitlements->withTrashed()->count());
+ $this->assertCount(0, SharedFolder::withTrashed()->where('id', $folder->id)->get());
+
+ Queue::assertPushed(\App\Jobs\SharedFolder\DeleteJob::class, 1);
+ Queue::assertPushed(
+ \App\Jobs\SharedFolder\DeleteJob::class,
+ function ($job) use ($folder) {
+ $folderEmail = TestCase::getObjectProperty($job, 'folderEmail');
+ $folderId = TestCase::getObjectProperty($job, 'folderId');
+
+ return $folderEmail === $folder->email
+ && $folderId === $folder->id;
+ }
+ );
+ }
+
+ /**
+ * Tests for SharedFolder::emailExists()
+ */
+ public function testEmailExists(): void
+ {
+ Queue::fake();
+
+ $folder = $this->getTestSharedFolder('folder-test@kolabnow.com');
+
+ $this->assertFalse(SharedFolder::emailExists('unknown@domain.tld'));
+ $this->assertTrue(SharedFolder::emailExists($folder->email));
+
+ $result = SharedFolder::emailExists($folder->email, true);
+ $this->assertSame($result->id, $folder->id);
+
+ $folder->delete();
+
+ $this->assertTrue(SharedFolder::emailExists($folder->email));
+
+ $result = SharedFolder::emailExists($folder->email, true);
+ $this->assertSame($result->id, $folder->id);
+ }
+
+ /**
+ * Tests for SettingsTrait functionality and SharedFolderSettingObserver
+ */
+ public function testSettings(): void
+ {
+ Queue::fake();
+ Queue::assertNothingPushed();
+
+ $folder = $this->getTestSharedFolder('folder-test@kolabnow.com');
+
+ Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 0);
+
+ // Add a setting
+ $folder->setSetting('unknown', 'test');
+
+ Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 0);
+
+ // Add a setting that is synced to LDAP
+ $folder->setSetting('acl', 'test');
+
+ Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 1);
+
+ // Note: We test both current folder as well as fresh folder object
+ // to make sure cache works as expected
+ $this->assertSame('test', $folder->getSetting('unknown'));
+ $this->assertSame('test', $folder->fresh()->getSetting('acl'));
+
+ Queue::fake();
+
+ // Update a setting
+ $folder->setSetting('unknown', 'test1');
+
+ Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 0);
+
+ // Update a setting that is synced to LDAP
+ $folder->setSetting('acl', 'test1');
+
+ Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 1);
+
+ $this->assertSame('test1', $folder->getSetting('unknown'));
+ $this->assertSame('test1', $folder->fresh()->getSetting('acl'));
+
+ Queue::fake();
+
+ // Delete a setting (null)
+ $folder->setSetting('unknown', null);
+
+ Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 0);
+
+ // Delete a setting that is synced to LDAP
+ $folder->setSetting('acl', null);
+
+ Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 1);
+
+ $this->assertSame(null, $folder->getSetting('unknown'));
+ $this->assertSame(null, $folder->fresh()->getSetting('acl'));
+ }
+
+ /**
+ * Test updating a shared folder
+ */
+ public function testUpdate(): void
+ {
+ Queue::fake();
+
+ $folder = $this->getTestSharedFolder('folder-test@kolabnow.com');
+
+ $folder->name = 'New';
+ $folder->save();
+
+ // Assert the imap folder changes on a folder name change
+ $settings = $folder->settings()->where('key', 'folder')->get();
+ $this->assertCount(1, $settings);
+ $this->assertSame('shared/New@kolabnow.com', $settings[0]->value);
+
+ Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 1);
+ Queue::assertPushed(
+ \App\Jobs\SharedFolder\UpdateJob::class,
+ function ($job) use ($folder) {
+ $folderEmail = TestCase::getObjectProperty($job, 'folderEmail');
+ $folderId = TestCase::getObjectProperty($job, 'folderId');
+
+ return $folderEmail === $folder->email
+ && $folderId === $folder->id;
+ }
+ );
+ }
+}
diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php
--- a/src/tests/Feature/UserTest.php
+++ b/src/tests/Feature/UserTest.php
@@ -20,6 +20,7 @@
$this->deleteTestUser('UserAccountC@UserAccount.com');
$this->deleteTestGroup('test-group@UserAccount.com');
$this->deleteTestResource('test-resource@UserAccount.com');
+ $this->deleteTestSharedFolder('test-folder@UserAccount.com');
$this->deleteTestDomain('UserAccount.com');
$this->deleteTestDomain('UserAccountAdd.com');
}
@@ -33,6 +34,7 @@
$this->deleteTestUser('UserAccountC@UserAccount.com');
$this->deleteTestGroup('test-group@UserAccount.com');
$this->deleteTestResource('test-resource@UserAccount.com');
+ $this->deleteTestSharedFolder('test-folder@UserAccount.com');
$this->deleteTestDomain('UserAccount.com');
$this->deleteTestDomain('UserAccountAdd.com');
@@ -459,6 +461,8 @@
$group->assignToWallet($userA->wallets->first());
$resource = $this->getTestResource('test-resource@UserAccount.com', ['name' => 'test']);
$resource->assignToWallet($userA->wallets->first());
+ $folder = $this->getTestSharedFolder('test-folder@UserAccount.com', ['name' => 'test']);
+ $folder->assignToWallet($userA->wallets->first());
$entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id);
$entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id);
@@ -466,6 +470,7 @@
$entitlementsDomain = \App\Entitlement::where('entitleable_id', $domain->id);
$entitlementsGroup = \App\Entitlement::where('entitleable_id', $group->id);
$entitlementsResource = \App\Entitlement::where('entitleable_id', $resource->id);
+ $entitlementsFolder = \App\Entitlement::where('entitleable_id', $folder->id);
$this->assertSame(7, $entitlementsA->count());
$this->assertSame(7, $entitlementsB->count());
@@ -473,6 +478,7 @@
$this->assertSame(1, $entitlementsDomain->count());
$this->assertSame(1, $entitlementsGroup->count());
$this->assertSame(1, $entitlementsResource->count());
+ $this->assertSame(1, $entitlementsFolder->count());
// Delete non-controller user
$userC->delete();
@@ -489,16 +495,19 @@
$this->assertSame(0, $entitlementsDomain->count());
$this->assertSame(0, $entitlementsGroup->count());
$this->assertSame(0, $entitlementsResource->count());
+ $this->assertSame(0, $entitlementsFolder->count());
$this->assertTrue($userA->fresh()->trashed());
$this->assertTrue($userB->fresh()->trashed());
$this->assertTrue($domain->fresh()->trashed());
$this->assertTrue($group->fresh()->trashed());
$this->assertTrue($resource->fresh()->trashed());
+ $this->assertTrue($folder->fresh()->trashed());
$this->assertFalse($userA->isDeleted());
$this->assertFalse($userB->isDeleted());
$this->assertFalse($domain->isDeleted());
$this->assertFalse($group->isDeleted());
$this->assertFalse($resource->isDeleted());
+ $this->assertFalse($folder->isDeleted());
$userA->forceDelete();
@@ -511,6 +520,7 @@
$this->assertCount(0, Domain::withTrashed()->where('id', $domain->id)->get());
$this->assertCount(0, Group::withTrashed()->where('id', $group->id)->get());
$this->assertCount(0, \App\Resource::withTrashed()->where('id', $resource->id)->get());
+ $this->assertCount(0, \App\SharedFolder::withTrashed()->where('id', $folder->id)->get());
}
/**
@@ -731,6 +741,32 @@
}
/**
+ * Test sharedFolders() method
+ */
+ public function testSharedFolders(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $ned = $this->getTestUser('ned@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+
+ $folders = $john->sharedFolders()->orderBy('email')->get();
+
+ $this->assertSame(2, $folders->count());
+ $this->assertSame('folder-contact@kolab.org', $folders[0]->email);
+ $this->assertSame('folder-event@kolab.org', $folders[1]->email);
+
+ $folders = $ned->sharedFolders()->orderBy('email')->get();
+
+ $this->assertSame(2, $folders->count());
+ $this->assertSame('folder-contact@kolab.org', $folders[0]->email);
+ $this->assertSame('folder-event@kolab.org', $folders[1]->email);
+
+ $folders = $jack->sharedFolders()->get();
+
+ $this->assertSame(0, $folders->count());
+ }
+
+ /**
* Test user restoring
*/
public function testRestore(): void
diff --git a/src/tests/TestCaseTrait.php b/src/tests/TestCaseTrait.php
--- a/src/tests/TestCaseTrait.php
+++ b/src/tests/TestCaseTrait.php
@@ -2,9 +2,11 @@
namespace Tests;
+use App\Backends\LDAP;
use App\Domain;
use App\Group;
use App\Resource;
+use App\SharedFolder;
use App\Sku;
use App\Transaction;
use App\User;
@@ -154,6 +156,7 @@
$beta_handlers = [
'App\Handlers\Beta',
'App\Handlers\Beta\Resources',
+ 'App\Handlers\Beta\SharedFolders',
'App\Handlers\Distlist',
];
@@ -290,8 +293,7 @@
return;
}
- $job = new \App\Jobs\Group\DeleteJob($group->id);
- $job->handle();
+ LDAP::deleteGroup($group);
$group->forceDelete();
}
@@ -311,13 +313,32 @@
return;
}
- $job = new \App\Jobs\Resource\DeleteJob($resource->id);
- $job->handle();
+ LDAP::deleteResource($resource);
$resource->forceDelete();
}
/**
+ * Delete a test shared folder whatever it takes.
+ *
+ * @coversNothing
+ */
+ protected function deleteTestSharedFolder($email)
+ {
+ Queue::fake();
+
+ $folder = SharedFolder::withTrashed()->where('email', $email)->first();
+
+ if (!$folder) {
+ return;
+ }
+
+ LDAP::deleteSharedFolder($folder);
+
+ $folder->forceDelete();
+ }
+
+ /**
* Delete a test user whatever it takes.
*
* @coversNothing
@@ -332,8 +353,7 @@
return;
}
- $job = new \App\Jobs\User\DeleteJob($user->id);
- $job->handle();
+ LDAP::deleteUser($user);
$user->forceDelete();
}
@@ -375,7 +395,7 @@
}
/**
- * Get Resource object by name+domain, create it if needed.
+ * Get Resource object by email, create it if needed.
* Skip LDAP jobs.
*/
protected function getTestResource($email, $attrib = [])
@@ -407,6 +427,38 @@
}
/**
+ * Get SharedFolder object by email, create it if needed.
+ * Skip LDAP jobs.
+ */
+ protected function getTestSharedFolder($email, $attrib = [])
+ {
+ // Disable jobs (i.e. skip LDAP oprations)
+ Queue::fake();
+
+ $folder = SharedFolder::where('email', $email)->first();
+
+ if (!$folder) {
+ list($local, $domain) = explode('@', $email, 2);
+
+ $folder = new SharedFolder();
+ $folder->email = $email;
+ $folder->domain = $domain;
+
+ if (!isset($attrib['name'])) {
+ $folder->name = $local;
+ }
+ }
+
+ foreach ($attrib as $key => $val) {
+ $folder->{$key} = $val;
+ }
+
+ $folder->save();
+
+ return $folder;
+ }
+
+ /**
* Get User object by email, create it if needed.
* Skip LDAP jobs.
*
diff --git a/src/tests/Unit/Rules/ResourceNameTest.php b/src/tests/Unit/Rules/ResourceNameTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Unit/Rules/ResourceNameTest.php
@@ -0,0 +1,47 @@
+<?php
+
+namespace Tests\Unit\Rules;
+
+use App\Rules\ResourceName;
+use Illuminate\Support\Facades\Validator;
+use Tests\TestCase;
+
+class ResourceNameTest extends TestCase
+{
+ /**
+ * Tests the resource name validator
+ */
+ public function testValidation(): void
+ {
+ $user = $this->getTestUser('john@kolab.org');
+ $rules = ['name' => ['present', new ResourceName($user, 'kolab.org')]];
+
+ // Empty/invalid input
+ $v = Validator::make(['name' => null], $rules);
+ $this->assertSame(['name' => ["The specified name is invalid."]], $v->errors()->toArray());
+
+ $v = Validator::make(['name' => []], $rules);
+ $this->assertSame(['name' => ["The specified name is invalid."]], $v->errors()->toArray());
+
+ // Forbidden chars
+ $v = Validator::make(['name' => 'Test@'], $rules);
+ $this->assertSame(['name' => ["The specified name is invalid."]], $v->errors()->toArray());
+
+ // Length limit
+ $v = Validator::make(['name' => str_repeat('a', 192)], $rules);
+ $this->assertSame(['name' => ["The name may not be greater than 191 characters."]], $v->errors()->toArray());
+
+ // Existing resource
+ $v = Validator::make(['name' => 'Conference Room #1'], $rules);
+ $this->assertSame(['name' => ["The specified name is not available."]], $v->errors()->toArray());
+
+ // Valid name
+ $v = Validator::make(['name' => 'TestRule'], $rules);
+ $this->assertSame([], $v->errors()->toArray());
+
+ // Invalid domain
+ $rules = ['name' => ['present', new ResourceName($user, 'kolabnow.com')]];
+ $v = Validator::make(['name' => 'TestRule'], $rules);
+ $this->assertSame(['name' => ["The specified domain is invalid."]], $v->errors()->toArray());
+ }
+}
diff --git a/src/tests/Unit/Rules/SharedFolderNameTest.php b/src/tests/Unit/Rules/SharedFolderNameTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Unit/Rules/SharedFolderNameTest.php
@@ -0,0 +1,47 @@
+<?php
+
+namespace Tests\Unit\Rules;
+
+use App\Rules\SharedFolderName;
+use Illuminate\Support\Facades\Validator;
+use Tests\TestCase;
+
+class SharedFolderNameTest extends TestCase
+{
+ /**
+ * Tests the shared folder name validator
+ */
+ public function testValidation(): void
+ {
+ $user = $this->getTestUser('john@kolab.org');
+ $rules = ['name' => ['present', new SharedFolderName($user, 'kolab.org')]];
+
+ // Empty/invalid input
+ $v = Validator::make(['name' => null], $rules);
+ $this->assertSame(['name' => ["The specified name is invalid."]], $v->errors()->toArray());
+
+ $v = Validator::make(['name' => []], $rules);
+ $this->assertSame(['name' => ["The specified name is invalid."]], $v->errors()->toArray());
+
+ // Forbidden chars
+ $v = Validator::make(['name' => 'Test@'], $rules);
+ $this->assertSame(['name' => ["The specified name is invalid."]], $v->errors()->toArray());
+
+ // Length limit
+ $v = Validator::make(['name' => str_repeat('a', 192)], $rules);
+ $this->assertSame(['name' => ["The name may not be greater than 191 characters."]], $v->errors()->toArray());
+
+ // Existing resource
+ $v = Validator::make(['name' => 'Calendar'], $rules);
+ $this->assertSame(['name' => ["The specified name is not available."]], $v->errors()->toArray());
+
+ // Valid name
+ $v = Validator::make(['name' => 'TestRule'], $rules);
+ $this->assertSame([], $v->errors()->toArray());
+
+ // Invalid domain
+ $rules = ['name' => ['present', new SharedFolderName($user, 'kolabnow.com')]];
+ $v = Validator::make(['name' => 'TestRule'], $rules);
+ $this->assertSame(['name' => ["The specified domain is invalid."]], $v->errors()->toArray());
+ }
+}
diff --git a/src/tests/Unit/Rules/SharedFolderTypeTest.php b/src/tests/Unit/Rules/SharedFolderTypeTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Unit/Rules/SharedFolderTypeTest.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Tests\Unit\Rules;
+
+use App\Rules\SharedFolderType;
+use Illuminate\Support\Facades\Validator;
+use Tests\TestCase;
+
+class SharedFolderTypeTest extends TestCase
+{
+ /**
+ * Tests the shared folder type validator
+ */
+ public function testValidation(): void
+ {
+ $rules = ['type' => ['present', new SharedFolderType()]];
+
+ // Empty/invalid input
+ $v = Validator::make(['type' => null], $rules);
+ $this->assertSame(['type' => ["The specified type is invalid."]], $v->errors()->toArray());
+
+ $v = Validator::make(['type' => []], $rules);
+ $this->assertSame(['type' => ["The specified type is invalid."]], $v->errors()->toArray());
+
+ $v = Validator::make(['type' => 'Test'], $rules);
+ $this->assertSame(['type' => ["The specified type is invalid."]], $v->errors()->toArray());
+
+ // Valid type
+ foreach (\App\SharedFolder::SUPPORTED_TYPES as $type) {
+ $v = Validator::make(['type' => $type], $rules);
+ $this->assertSame([], $v->errors()->toArray());
+ }
+ }
+}
diff --git a/src/tests/Unit/SharedFolderTest.php b/src/tests/Unit/SharedFolderTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Unit/SharedFolderTest.php
@@ -0,0 +1,85 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\SharedFolder;
+use Tests\TestCase;
+
+class SharedFolderTest extends TestCase
+{
+ /**
+ * Test SharedFolder status property and is*() methods
+ */
+ public function testSharedFolderStatus(): void
+ {
+ $statuses = [
+ SharedFolder::STATUS_NEW,
+ SharedFolder::STATUS_ACTIVE,
+ SharedFolder::STATUS_DELETED,
+ SharedFolder::STATUS_LDAP_READY,
+ SharedFolder::STATUS_IMAP_READY,
+ ];
+
+ $folders = \App\Utils::powerSet($statuses);
+
+ $folder = new SharedFolder(['name' => 'test']);
+
+ foreach ($folders as $folderStatuses) {
+ $folder->status = \array_sum($folderStatuses);
+
+ $folderStatuses = [];
+
+ foreach ($statuses as $status) {
+ if ($folder->status & $status) {
+ $folderStatuses[] = $status;
+ }
+ }
+
+ $this->assertSame($folder->status, \array_sum($folderStatuses));
+
+ // either one is true, but not both
+ $this->assertSame(
+ $folder->isNew() === in_array(SharedFolder::STATUS_NEW, $folderStatuses),
+ $folder->isActive() === in_array(SharedFolder::STATUS_ACTIVE, $folderStatuses)
+ );
+
+ $this->assertTrue(
+ $folder->isNew() === in_array(SharedFolder::STATUS_NEW, $folderStatuses)
+ );
+
+ $this->assertTrue(
+ $folder->isActive() === in_array(SharedFolder::STATUS_ACTIVE, $folderStatuses)
+ );
+
+ $this->assertTrue(
+ $folder->isDeleted() === in_array(SharedFolder::STATUS_DELETED, $folderStatuses)
+ );
+
+ $this->assertTrue(
+ $folder->isLdapReady() === in_array(SharedFolder::STATUS_LDAP_READY, $folderStatuses)
+ );
+
+ $this->assertTrue(
+ $folder->isImapReady() === in_array(SharedFolder::STATUS_IMAP_READY, $folderStatuses)
+ );
+ }
+
+ $this->expectException(\Exception::class);
+ $folder->status = 111;
+ }
+
+ /**
+ * Test basic SharedFolder funtionality
+ */
+ public function testSharedFolderType(): void
+ {
+ $folder = new SharedFolder(['name' => 'test']);
+
+ foreach (SharedFolder::SUPPORTED_TYPES as $type) {
+ $folder->type = $type;
+ }
+
+ $this->expectException(\Exception::class);
+ $folder->type = 'unknown';
+ }
+}

File Metadata

Mime Type
text/plain
Expires
Sat, Apr 4, 10:55 PM (12 h, 54 m ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18826070
Default Alt Text
D3071.1775343348.diff (256 KB)

Event Timeline