Page MenuHomePhorge

No OneTemporary

Size
860 KB
Referenced Files
None
Subscribers
None
This file is larger than 256 KB, so syntax highlighting was skipped.
diff --git a/src/app/Console/Commands/Meet/RoomCreate.php b/src/app/Console/Commands/Meet/RoomCreate.php
index 6bb2df68..cb3eae6c 100644
--- a/src/app/Console/Commands/Meet/RoomCreate.php
+++ b/src/app/Console/Commands/Meet/RoomCreate.php
@@ -1,55 +1,56 @@
<?php
namespace App\Console\Commands\Meet;
use App\Console\Command;
class RoomCreate extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'meet:room-create {user} {room}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Create a room for a user';
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$user = $this->getUser($this->argument('user'));
if (!$user) {
return 1;
}
$roomName = $this->argument('room');
if (!preg_match('/^[a-zA-Z0-9_-]{1,16}$/', $roomName)) {
$this->error("Invalid room name. Should be up to 16 characters ([a-zA-Z0-9_-]).");
return 1;
}
$room = \App\Meet\Room::where('name', $roomName)->first();
if ($room) {
$this->error("Room already exists.");
return 1;
}
- \App\Meet\Room::create([
+ $room = \App\Meet\Room::create([
'name' => $roomName,
- 'user_id' => $user->id
]);
+
+ $room->assignToWallet($user->wallets()->first());
}
}
diff --git a/src/app/Console/Commands/Meet/Sessions.php b/src/app/Console/Commands/Meet/Sessions.php
index b8cfb83b..ba6ab7af 100644
--- a/src/app/Console/Commands/Meet/Sessions.php
+++ b/src/app/Console/Commands/Meet/Sessions.php
@@ -1,70 +1,70 @@
<?php
namespace App\Console\Commands\Meet;
use App\Console\Command;
class Sessions extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'meet:sessions';
/**
* The console command description.
*
* @var string
*/
protected $description = 'List Meet sessions';
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$client = new \GuzzleHttp\Client([
'http_errors' => false, // No exceptions from Guzzle
'base_uri' => \config('meet.api_url'),
'verify' => \config('meet.api_verify_tls'),
'headers' => [
'X-Auth-Token' => \config('meet.api_token'),
],
'connect_timeout' => 10,
'timeout' => 10,
]);
$response = $client->request('GET', 'sessions');
if ($response->getStatusCode() !== 200) {
return 1;
}
$sessions = json_decode($response->getBody(), true);
foreach ($sessions as $session) {
$room = \App\Meet\Room::where('session_id', $session['roomId'])->first();
if ($room) {
- $owner = $room->owner->email;
+ $owner = $room->wallet()->owner->email;
$roomName = $room->name;
} else {
$owner = '(none)';
$roomName = '(none)';
}
$this->info(
sprintf(
"Session: %s for %s since %s (by %s)",
$session['roomId'],
$roomName,
\Carbon\Carbon::parse($session['createdAt'], 'UTC'),
$owner
)
);
}
}
}
diff --git a/src/app/Domain.php b/src/app/Domain.php
index a97e681b..3fca1923 100644
--- a/src/app/Domain.php
+++ b/src/app/Domain.php
@@ -1,420 +1,430 @@
<?php
namespace App;
use App\Traits\BelongsToTenantTrait;
use App\Traits\DomainConfigTrait;
use App\Traits\EntitleableTrait;
use App\Traits\SettingsTrait;
use App\Traits\StatusPropertyTrait;
use App\Traits\UuidIntKeyTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* The eloquent definition of a Domain.
*
* @property string $namespace
* @property int $status
* @property int $tenant_id
* @property int $type
*/
class Domain extends Model
{
use BelongsToTenantTrait;
use DomainConfigTrait;
use EntitleableTrait;
use SettingsTrait;
use SoftDeletes;
use StatusPropertyTrait;
use UuidIntKeyTrait;
// we've simply never heard of this domain
public const STATUS_NEW = 1 << 0;
// it's been activated
public const STATUS_ACTIVE = 1 << 1;
// domain has been suspended.
public const STATUS_SUSPENDED = 1 << 2;
// domain has been deleted
public const STATUS_DELETED = 1 << 3;
// ownership of the domain has been confirmed
public const STATUS_CONFIRMED = 1 << 4;
// domain has been verified that it exists in DNS
public const STATUS_VERIFIED = 1 << 5;
// domain has been created in LDAP
public const STATUS_LDAP_READY = 1 << 6;
// open for public registration
public const TYPE_PUBLIC = 1 << 0;
// zone hosted with us
public const TYPE_HOSTED = 1 << 1;
// zone registered externally
public const TYPE_EXTERNAL = 1 << 2;
public const HASH_CODE = 1;
public const HASH_TEXT = 2;
public const HASH_CNAME = 3;
/** @var array<string, string> The attributes that should be cast */
protected $casts = [
'created_at' => 'datetime:Y-m-d H:i:s',
'deleted_at' => 'datetime:Y-m-d H:i:s',
'updated_at' => 'datetime:Y-m-d H:i:s',
];
/** @var array<int, string> The attributes that are mass assignable */
protected $fillable = ['namespace', 'status', 'type'];
/**
* Assign a package to a domain. The domain should not belong to any existing entitlements.
*
* @param \App\Package $package The package to assign.
* @param \App\User $user The wallet owner.
*
* @return \App\Domain Self
*/
public function assignPackage($package, $user)
{
// If this domain is public it can not be assigned to a user.
if ($this->isPublic()) {
return $this;
}
// See if this domain is already owned by another user.
$wallet = $this->wallet();
if ($wallet) {
\Log::error(
"Domain {$this->namespace} is already assigned to {$wallet->owner->email}"
);
return $this;
}
return $this->assignPackageAndWallet($package, $user->wallets()->first());
}
/**
* Return list of public+active domain names (for current tenant)
*/
public static function getPublicDomains(): array
{
return self::withEnvTenantContext()
->where('type', '&', Domain::TYPE_PUBLIC)
->pluck('namespace')->all();
}
/**
* Returns whether this domain is confirmed the ownership of.
*
* @return bool
*/
public function isConfirmed(): bool
{
return ($this->status & self::STATUS_CONFIRMED) > 0;
}
/**
* Returns whether this domain is registered with us.
*
* @return bool
*/
public function isExternal(): bool
{
return ($this->type & self::TYPE_EXTERNAL) > 0;
}
/**
* Returns whether this domain is hosted with us.
*
* @return bool
*/
public function isHosted(): bool
{
return ($this->type & self::TYPE_HOSTED) > 0;
}
/**
* Returns whether this domain is public.
*
* @return bool
*/
public function isPublic(): bool
{
return ($this->type & self::TYPE_PUBLIC) > 0;
}
/**
* Returns whether this (external) domain has been verified
* to exist in DNS.
*
* @return bool
*/
public function isVerified(): bool
{
return ($this->status & self::STATUS_VERIFIED) > 0;
}
/**
* Ensure the namespace is appropriately cased.
*/
public function setNamespaceAttribute($namespace)
{
$this->attributes['namespace'] = strtolower($namespace);
}
/**
* Domain status mutator
*
* @throws \Exception
*/
public function setStatusAttribute($status)
{
$new_status = 0;
$allowed_values = [
self::STATUS_NEW,
self::STATUS_ACTIVE,
self::STATUS_SUSPENDED,
self::STATUS_DELETED,
self::STATUS_CONFIRMED,
self::STATUS_VERIFIED,
self::STATUS_LDAP_READY,
];
foreach ($allowed_values as $value) {
if ($status & $value) {
$new_status |= $value;
$status ^= $value;
}
}
if ($status > 0) {
throw new \Exception("Invalid domain status: {$status}");
}
if ($this->isPublic()) {
$this->attributes['status'] = $new_status;
return;
}
if ($new_status & self::STATUS_CONFIRMED) {
// if we have confirmed ownership of or management access to the domain, then we have
// also confirmed the domain exists in DNS.
$new_status |= self::STATUS_VERIFIED;
$new_status |= self::STATUS_ACTIVE;
}
if ($new_status & self::STATUS_DELETED && $new_status & self::STATUS_ACTIVE) {
$new_status ^= self::STATUS_ACTIVE;
}
if ($new_status & self::STATUS_SUSPENDED && $new_status & self::STATUS_ACTIVE) {
$new_status ^= self::STATUS_ACTIVE;
}
// if the domain is now active, it is not new anymore.
if ($new_status & self::STATUS_ACTIVE && $new_status & self::STATUS_NEW) {
$new_status ^= self::STATUS_NEW;
}
$this->attributes['status'] = $new_status;
}
/**
* Ownership verification by checking for a TXT (or CNAME) record
* in the domain's DNS (that matches the verification hash).
*
* @return bool True if verification was successful, false otherwise
* @throws \Exception Throws exception on DNS or DB errors
*/
public function confirm(): bool
{
if ($this->isConfirmed()) {
return true;
}
$hash = $this->hash(self::HASH_TEXT);
$confirmed = false;
// Get DNS records and find a matching TXT entry
$records = \dns_get_record($this->namespace, DNS_TXT);
if ($records === false) {
throw new \Exception("Failed to get DNS record for {$this->namespace}");
}
foreach ($records as $record) {
if ($record['txt'] === $hash) {
$confirmed = true;
break;
}
}
// Get DNS records and find a matching CNAME entry
// Note: some servers resolve every non-existing name
// so we need to define left and right side of the CNAME record
// i.e.: kolab-verify IN CNAME <hash>.domain.tld.
if (!$confirmed) {
$cname = $this->hash(self::HASH_CODE) . '.' . $this->namespace;
$records = \dns_get_record('kolab-verify.' . $this->namespace, DNS_CNAME);
if ($records === false) {
throw new \Exception("Failed to get DNS record for {$this->namespace}");
}
foreach ($records as $records) {
if ($records['target'] === $cname) {
$confirmed = true;
break;
}
}
}
if ($confirmed) {
$this->status |= Domain::STATUS_CONFIRMED;
$this->save();
}
return $confirmed;
}
/**
* Generate a verification hash for this domain
*
* @param int $mod One of: HASH_CNAME, HASH_CODE (Default), HASH_TEXT
*
* @return string Verification hash
*/
public function hash($mod = null): string
{
$cname = 'kolab-verify';
if ($mod === self::HASH_CNAME) {
return $cname;
}
$hash = \md5('hkccp-verify-' . $this->namespace);
return $mod === self::HASH_TEXT ? "$cname=$hash" : $hash;
}
/**
* Checks if there are any objects (users/aliases/groups) in a domain.
* Note: Public domains are always reported not empty.
*
* @return bool True if there are no objects assigned, False otherwise
*/
public function isEmpty(): bool
{
if ($this->isPublic()) {
return false;
}
// FIXME: These queries will not use indexes, so maybe we should consider
// wallet/entitlements to search in objects that belong to this domain account?
$suffix = '@' . $this->namespace;
$suffixLen = strlen($suffix);
return !(
User::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists()
|| UserAlias::whereRaw('substr(alias, ?) = ?', [-$suffixLen, $suffix])->exists()
|| Group::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists()
|| Resource::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists()
|| SharedFolder::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists()
);
}
+ /**
+ * Returns domain's namespace (required by the EntitleableTrait).
+ *
+ * @return string|null Domain namespace
+ */
+ public function toString(): ?string
+ {
+ return $this->namespace;
+ }
+
/**
* Unsuspend this domain.
*
* The domain is unsuspended through either of the following courses of actions;
*
* * The account balance has been topped up, or
* * a suspected spammer has resolved their issues, or
* * the command-line is triggered.
*
* Therefore, we can also confidently set the domain status to 'active' should the ownership of or management
* access to have been confirmed before.
*
* @return void
*/
public function unsuspend(): void
{
if (!$this->isSuspended()) {
return;
}
$this->status ^= Domain::STATUS_SUSPENDED;
if ($this->isConfirmed() && $this->isVerified()) {
$this->status |= Domain::STATUS_ACTIVE;
}
$this->save();
}
/**
* List the users of a domain, so long as the domain is not a public registration domain.
* Note: It returns only users with a mailbox.
*
* @return \App\User[] A list of users
*/
public function users(): array
{
if ($this->isPublic()) {
return [];
}
$wallet = $this->wallet();
if (!$wallet) {
return [];
}
$mailboxSKU = Sku::withObjectTenantContext($this)->where('title', 'mailbox')->first();
if (!$mailboxSKU) {
\Log::error("No mailbox SKU available.");
return [];
}
return $wallet->entitlements()
->where('entitleable_type', User::class)
->where('sku_id', $mailboxSKU->id)
->get()
->pluck('entitleable')
->all();
}
/**
* Verify if a domain exists in DNS
*
* @return bool True if registered, False otherwise
* @throws \Exception Throws exception on DNS or DB errors
*/
public function verify(): bool
{
if ($this->isVerified()) {
return true;
}
$records = \dns_get_record($this->namespace, DNS_ANY);
if ($records === false) {
throw new \Exception("Failed to get DNS record for {$this->namespace}");
}
// It may happen that result contains other domains depending on the host DNS setup
// that's why in_array() and not just !empty()
if (in_array($this->namespace, array_column($records, 'host'))) {
$this->status |= Domain::STATUS_VERIFIED;
$this->save();
return true;
}
return false;
}
}
diff --git a/src/app/Entitlement.php b/src/app/Entitlement.php
index 47fb1e88..dcedd6dd 100644
--- a/src/app/Entitlement.php
+++ b/src/app/Entitlement.php
@@ -1,171 +1,157 @@
<?php
namespace App;
use App\Traits\UuidStrKeyTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* The eloquent definition of an Entitlement.
*
* Owned by a {@link \App\User}, billed to a {@link \App\Wallet}.
*
* @property int $cost
* @property ?string $description
* @property ?object $entitleable The entitled object (receiver of the entitlement).
* @property int $entitleable_id
* @property string $entitleable_type
* @property int $fee
* @property string $id
* @property \App\User $owner The owner of this entitlement (subject).
* @property \App\Sku $sku The SKU to which this entitlement applies.
* @property string $sku_id
* @property \App\Wallet $wallet The wallet to which this entitlement is charged.
* @property string $wallet_id
*/
class Entitlement extends Model
{
use SoftDeletes;
use UuidStrKeyTrait;
/** @var array<int, string> The attributes that are mass assignable */
protected $fillable = [
'sku_id',
'wallet_id',
'entitleable_id',
'entitleable_type',
'cost',
'description',
'fee',
];
/** @var array<string, string> The attributes that should be cast */
protected $casts = [
'cost' => 'integer',
'fee' => 'integer'
];
/**
* Return the costs per day for this entitlement.
*
* @return float
*/
public function costsPerDay()
{
if ($this->cost == 0) {
return (float) 0;
}
$discount = $this->wallet->getDiscountRate();
$daysInLastMonth = \App\Utils::daysInLastMonth();
$costsPerDay = (float) ($this->cost * $discount) / $daysInLastMonth;
return $costsPerDay;
}
/**
* Create a transaction record for this entitlement.
*
* @param string $type The type of transaction ('created', 'billed', 'deleted'), but use the
* \App\Transaction constants.
* @param int $amount The amount involved in cents
*
* @return string The transaction ID
*/
public function createTransaction($type, $amount = null)
{
$transaction = Transaction::create(
[
'object_id' => $this->id,
'object_type' => Entitlement::class,
'type' => $type,
'amount' => $amount
]
);
return $transaction->id;
}
/**
* Principally entitleable object such as Domain, User, Group.
* Note that it may be trashed (soft-deleted).
*
* @return mixed
*/
public function entitleable()
{
return $this->morphTo()->withTrashed(); // @phpstan-ignore-line
}
- /**
- * Returns entitleable object title (e.g. email or domain name).
- *
- * @return string|null An object title/name
- */
- public function entitleableTitle(): ?string
- {
- if ($this->entitleable instanceof Domain) {
- return $this->entitleable->namespace;
- }
-
- return $this->entitleable->email;
- }
-
/**
* Simplified Entitlement/SKU information for a specified entitleable object
*
* @param object $object Entitleable object
*
* @return array Skus list with some metadata
*/
public static function objectEntitlementsSummary($object): array
{
$skus = [];
// TODO: I agree this format may need to be extended in future
foreach ($object->entitlements as $ent) {
$sku = $ent->sku;
if (!isset($skus[$sku->id])) {
$skus[$sku->id] = ['costs' => [], 'count' => 0];
}
$skus[$sku->id]['count']++;
$skus[$sku->id]['costs'][] = $ent->cost;
}
return $skus;
}
/**
* The SKU concerned.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function sku()
{
return $this->belongsTo(Sku::class);
}
/**
* The wallet this entitlement is being billed to
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function wallet()
{
return $this->belongsTo(Wallet::class);
}
/**
* Cost mutator. Make sure cost is integer.
*/
public function setCostAttribute($cost): void
{
$this->attributes['cost'] = round($cost);
}
}
diff --git a/src/app/Handlers/Base.php b/src/app/Handlers/Base.php
index 87041e50..c24d4f8e 100644
--- a/src/app/Handlers/Base.php
+++ b/src/app/Handlers/Base.php
@@ -1,101 +1,101 @@
<?php
namespace App\Handlers;
abstract class Base
{
/**
* The entitleable class for this handler.
*
* @return string
*/
public static function entitleableClass(): string
{
return '';
}
/**
* Check if the SKU is available to the user. An SKU is available
* to the user/domain when either it is active or there's already an
* active entitlement.
*
- * @param \App\Sku $sku The SKU object
- * @param \App\User|\App\Domain $object The user or domain object
+ * @param \App\Sku $sku The SKU
+ * @param object $object The entitleable object
*
* @return bool
*/
public static function isAvailable(\App\Sku $sku, $object): bool
{
if (!$sku->active) {
if (!$object->entitlements()->where('sku_id', $sku->id)->first()) {
return false;
}
}
return true;
}
/**
* Metadata of this SKU handler.
*
* @param \App\Sku $sku The SKU object
*
* @return array
*/
public static function metadata(\App\Sku $sku): array
{
return [
// entitleable type
'type' => \lcfirst(\class_basename(static::entitleableClass())),
// handler
'handler' => str_replace("App\\Handlers\\", '', static::class),
// readonly entitlement state cannot be changed
'readonly' => false,
// is entitlement enabled by default?
'enabled' => false,
// priority on the entitlements list
'prio' => static::priority(),
];
}
/**
* Prerequisites for the Entitlement to be applied to the object.
*
* @param \App\Entitlement $entitlement
* @param mixed $object
*
* @return bool
*/
public static function preReq($entitlement, $object): bool
{
$type = static::entitleableClass();
if (empty($type) || empty($entitlement->entitleable_type)) {
\Log::error("Entitleable class/type not specified");
return false;
}
if ($type !== $entitlement->entitleable_type) {
\Log::error("Entitleable class mismatch");
return false;
}
if (!$entitlement->sku->active) {
\Log::error("Sku not active");
return false;
}
return true;
}
/**
* 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 0;
}
}
diff --git a/src/app/Handlers/Meet.php b/src/app/Handlers/GroupRoom.php
similarity index 58%
copy from src/app/Handlers/Meet.php
copy to src/app/Handlers/GroupRoom.php
index a30f142a..9c68671b 100644
--- a/src/app/Handlers/Meet.php
+++ b/src/app/Handlers/GroupRoom.php
@@ -1,43 +1,33 @@
<?php
namespace App\Handlers;
-class Meet extends Base
+class GroupRoom extends Base
{
/**
* The entitleable class for this handler.
*
* @return string
*/
public static function entitleableClass(): string
{
- return \App\User::class;
+ return \App\Meet\Room::class;
}
/**
* SKU handler metadata.
*
* @param \App\Sku $sku The SKU object
*
* @return array
*/
public static function metadata(\App\Sku $sku): array
{
$data = parent::metadata($sku);
- $data['required'] = ['Groupware'];
+ $data['exclusive'] = ['Room'];
+ $data['controllerOnly'] = true;
return $data;
}
-
- /**
- * 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 50;
- }
}
diff --git a/src/app/Handlers/Meet.php b/src/app/Handlers/Room.php
similarity index 80%
rename from src/app/Handlers/Meet.php
rename to src/app/Handlers/Room.php
index a30f142a..1bd39945 100644
--- a/src/app/Handlers/Meet.php
+++ b/src/app/Handlers/Room.php
@@ -1,43 +1,44 @@
<?php
namespace App\Handlers;
-class Meet extends Base
+class Room extends Base
{
/**
* The entitleable class for this handler.
*
* @return string
*/
public static function entitleableClass(): string
{
- return \App\User::class;
+ return \App\Meet\Room::class;
}
/**
* SKU handler metadata.
*
* @param \App\Sku $sku The SKU object
*
* @return array
*/
public static function metadata(\App\Sku $sku): array
{
$data = parent::metadata($sku);
- $data['required'] = ['Groupware'];
+ $data['enabled'] = true;
+ $data['exclusive'] = ['GroupRoom'];
return $data;
}
/**
* 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 50;
+ return 10;
}
}
diff --git a/src/app/Http/Controllers/API/V4/DomainsController.php b/src/app/Http/Controllers/API/V4/DomainsController.php
index bad14941..718a03d8 100644
--- a/src/app/Http/Controllers/API/V4/DomainsController.php
+++ b/src/app/Http/Controllers/API/V4/DomainsController.php
@@ -1,333 +1,333 @@
<?php
namespace App\Http\Controllers\API\V4;
use App\Domain;
use App\Http\Controllers\RelationController;
use App\Backends\LDAP;
use App\Rules\UserEmailDomain;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
class DomainsController extends RelationController
{
/** @var string Resource localization label */
protected $label = 'domain';
/** @var string Resource model name */
protected $model = Domain::class;
/** @var array Common object properties in the API response */
protected $objectProps = ['namespace', 'type'];
/** @var array Resource listing order (column names) */
protected $order = ['namespace'];
/** @var array Resource relation method arguments */
protected $relationArgs = [true, false];
/**
* Confirm ownership of the specified domain (via DNS check).
*
* @param int $id Domain identifier
*
* @return \Illuminate\Http\JsonResponse|void
*/
public function confirm($id)
{
$domain = Domain::find($id);
if (!$this->checkTenant($domain)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canRead($domain)) {
return $this->errorResponse(403);
}
if (!$domain->confirm()) {
return response()->json([
'status' => 'error',
'message' => \trans('app.domain-verify-error'),
]);
}
return response()->json([
'status' => 'success',
'statusInfo' => self::statusInfo($domain),
'message' => \trans('app.domain-verify-success'),
]);
}
/**
* Remove the specified domain.
*
* @param string $id Domain identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function destroy($id)
{
$domain = Domain::withEnvTenantContext()->find($id);
if (empty($domain)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canDelete($domain)) {
return $this->errorResponse(403);
}
// It is possible to delete domain only if there are no users/aliases/groups using it.
if (!$domain->isEmpty()) {
$response = ['status' => 'error', 'message' => \trans('app.domain-notempty-error')];
return response()->json($response, 422);
}
$domain->delete();
return response()->json([
'status' => 'success',
'message' => \trans('app.domain-delete-success'),
]);
}
/**
* Create a domain.
*
* @param \Illuminate\Http\Request $request
*
* @return \Illuminate\Http\JsonResponse
*/
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);
}
// Validate the input
$v = Validator::make(
$request->all(),
[
'namespace' => ['required', 'string', new UserEmailDomain()]
]
);
if ($v->fails()) {
return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
}
$namespace = \strtolower(request()->input('namespace'));
// Domain already exists
if ($domain = Domain::withTrashed()->where('namespace', $namespace)->first()) {
// Check if the domain is soft-deleted and belongs to the same user
$deleteBeforeCreate = $domain->trashed() && ($wallet = $domain->wallet())
&& $wallet->owner && $wallet->owner->id == $owner->id;
if (!$deleteBeforeCreate) {
$errors = ['namespace' => \trans('validation.domainnotavailable')];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
}
if (empty($request->package) || !($package = \App\Package::withEnvTenantContext()->find($request->package))) {
$errors = ['package' => \trans('validation.packagerequired')];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
if (!$package->isDomain()) {
$errors = ['package' => \trans('validation.packageinvalid')];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
DB::beginTransaction();
// Force-delete the existing domain if it is soft-deleted and belongs to the same user
if (!empty($deleteBeforeCreate)) {
$domain->forceDelete();
}
// Create the domain
$domain = Domain::create([
'namespace' => $namespace,
'type' => \App\Domain::TYPE_EXTERNAL,
]);
$domain->assignPackage($package, $owner);
DB::commit();
return response()->json([
'status' => 'success',
- 'message' => __('app.domain-create-success'),
+ 'message' => \trans('app.domain-create-success'),
]);
}
/**
* Get the information about the specified domain.
*
* @param string $id Domain identifier
*
* @return \Illuminate\Http\JsonResponse|void
*/
public function show($id)
{
$domain = Domain::find($id);
if (!$this->checkTenant($domain)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canRead($domain)) {
return $this->errorResponse(403);
}
$response = $this->objectToClient($domain, true);
// Add hash information to the response
$response['hash_text'] = $domain->hash(Domain::HASH_TEXT);
$response['hash_cname'] = $domain->hash(Domain::HASH_CNAME);
$response['hash_code'] = $domain->hash(Domain::HASH_CODE);
// Add DNS/MX configuration for the domain
$response['dns'] = self::getDNSConfig($domain);
$response['mx'] = self::getMXConfig($domain->namespace);
// Domain configuration, e.g. spf whitelist
$response['config'] = $domain->getConfig();
// Status info
$response['statusInfo'] = self::statusInfo($domain);
// Entitlements/Wallet info
SkusController::objectEntitlements($domain, $response);
return response()->json($response);
}
/**
* Provide DNS MX information to configure specified domain for
*/
protected static function getMXConfig(string $namespace): array
{
$entries = [];
// copy MX entries from an existing domain
if ($master = \config('dns.copyfrom')) {
// TODO: cache this lookup
foreach ((array) dns_get_record($master, DNS_MX) as $entry) {
$entries[] = sprintf(
"@\t%s\t%s\tMX\t%d %s.",
\config('dns.ttl', $entry['ttl']),
$entry['class'],
$entry['pri'],
$entry['target']
);
}
} elseif ($static = \config('dns.static')) {
$entries[] = strtr($static, array('\n' => "\n", '%s' => $namespace));
}
// display SPF settings
if ($spf = \config('dns.spf')) {
$entries[] = ';';
foreach (['TXT', 'SPF'] as $type) {
$entries[] = sprintf(
"@\t%s\tIN\t%s\t\"%s\"",
\config('dns.ttl'),
$type,
$spf
);
}
}
return $entries;
}
/**
* Provide sample DNS config for domain confirmation
*/
protected static function getDNSConfig(Domain $domain): array
{
$serial = date('Ymd01');
$hash_txt = $domain->hash(Domain::HASH_TEXT);
$hash_cname = $domain->hash(Domain::HASH_CNAME);
$hash = $domain->hash(Domain::HASH_CODE);
return [
"@ IN SOA ns1.dnsservice.com. hostmaster.{$domain->namespace}. (",
" {$serial} 10800 3600 604800 86400 )",
";",
"@ IN A <some-ip>",
"www IN A <some-ip>",
";",
"{$hash_cname}.{$domain->namespace}. IN CNAME {$hash}.{$domain->namespace}.",
"@ 3600 TXT \"{$hash_txt}\"",
];
}
/**
* Domain status (extended) information.
*
* @param \App\Domain $domain Domain object
*
* @return array Status information
*/
public static function statusInfo($domain): array
{
// If that is not a public domain, add domain specific steps
return self::processStateInfo(
$domain,
[
'domain-new' => true,
'domain-ldap-ready' => $domain->isLdapReady(),
'domain-verified' => $domain->isVerified(),
'domain-confirmed' => [$domain->isConfirmed(), "/domain/{$domain->id}"],
]
);
}
/**
* Execute (synchronously) specified step in a domain setup process.
*
* @param \App\Domain $domain Domain object
* @param string $step Step identifier (as in self::statusInfo())
*
* @return bool True if the execution succeeded, False otherwise
*/
public static function execProcessStep(Domain $domain, string $step): bool
{
try {
switch ($step) {
case 'domain-ldap-ready':
// Domain not in LDAP, create it
if (!$domain->isLdapReady()) {
LDAP::createDomain($domain);
$domain->status |= Domain::STATUS_LDAP_READY;
$domain->save();
}
return $domain->isLdapReady();
case 'domain-verified':
// Domain existence not verified
$domain->verify();
return $domain->isVerified();
case 'domain-confirmed':
// Domain ownership confirmation
$domain->confirm();
return $domain->isConfirmed();
}
} catch (\Exception $e) {
\Log::error($e);
}
return false;
}
}
diff --git a/src/app/Http/Controllers/API/V4/MeetController.php b/src/app/Http/Controllers/API/V4/MeetController.php
index 0fc013b4..51da0f96 100644
--- a/src/app/Http/Controllers/API/V4/MeetController.php
+++ b/src/app/Http/Controllers/API/V4/MeetController.php
@@ -1,293 +1,190 @@
<?php
namespace App\Http\Controllers\API\V4;
use App\Http\Controllers\Controller;
use App\Meet\Room;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
-use Illuminate\Support\Facades\Validator;
class MeetController extends Controller
{
- /**
- * Listing of rooms that belong to the authenticated user.
- *
- * @return \Illuminate\Http\JsonResponse
- */
- public function index()
- {
- $user = Auth::guard()->user();
-
- $rooms = Room::where('user_id', $user->id)->orderBy('name')->get();
-
- if (count($rooms) == 0) {
- // Create a room for the user (with a random and unique name)
- while (true) {
- $name = strtolower(\App\Utils::randStr(3, 3, '-'));
- if (!Room::where('name', $name)->count()) {
- break;
- }
- }
-
- $room = Room::create([
- 'name' => $name,
- 'user_id' => $user->id
- ]);
-
- $rooms = collect([$room]);
- }
-
- $result = [
- 'list' => $rooms,
- 'count' => count($rooms),
- ];
-
- return response()->json($result);
- }
-
/**
* Join the room session. Each room has one owner, and the room isn't open until the owner
* joins (and effectively creates the session).
*
* @param string $id Room identifier (name)
*
* @return \Illuminate\Http\JsonResponse
*/
public function joinRoom($id)
{
$room = Room::where('name', $id)->first();
// Room does not exist, or the owner is deleted
- if (!$room || !$room->owner || $room->owner->isDegraded(true)) {
- return $this->errorResponse(404, \trans('meet.room-not-found'));
- }
-
- // Check if there's still a valid meet entitlement for the room owner
- if (!$room->owner->hasSku('meet')) {
+ if (!$room || !($wallet = $room->wallet()) || !$wallet->owner || $wallet->owner->isDegraded(true)) {
return $this->errorResponse(404, \trans('meet.room-not-found'));
}
$user = Auth::guard()->user();
- $isOwner = $user && $user->id == $room->user_id;
+ $isOwner = $user && (
+ $user->id == $wallet->owner->id || $room->permissions()->where('user', $user->email)->exists()
+ );
$init = !empty(request()->input('init'));
// There's no existing session
if (!$room->hasSession()) {
// Participants can't join the room until the session is created by the owner
if (!$isOwner) {
return $this->errorResponse(422, \trans('meet.session-not-found'), ['code' => 323]);
}
// The room owner can create the session on request
if (!$init) {
return $this->errorResponse(422, \trans('meet.session-not-found'), ['code' => 324]);
}
$session = $room->createSession();
if (empty($session)) {
return $this->errorResponse(500, \trans('meet.session-create-error'));
}
}
$settings = $room->getSettings(['locked', 'nomedia', 'password']);
$password = (string) $settings['password'];
$config = [
'locked' => $settings['locked'] === 'true',
'nomedia' => $settings['nomedia'] === 'true',
'password' => $isOwner ? $password : '',
'requires_password' => !$isOwner && strlen($password),
];
$response = ['config' => $config];
// Validate room password
if (!$isOwner && strlen($password)) {
$request_password = request()->input('password');
if ($request_password !== $password) {
return $this->errorResponse(422, \trans('meet.session-password-error'), $response + ['code' => 325]);
}
}
// Handle locked room
if (!$isOwner && $config['locked']) {
$nickname = request()->input('nickname');
$picture = request()->input('picture');
$requestId = request()->input('requestId');
$request = $requestId ? $room->requestGet($requestId) : null;
$error = \trans('meet.session-room-locked-error');
// Request already has been processed (not accepted yet, but it could be denied)
if (empty($request['status']) || $request['status'] != Room::REQUEST_ACCEPTED) {
if (!$request) {
if (empty($nickname) || empty($requestId) || !preg_match('/^[a-z0-9]{8,32}$/i', $requestId)) {
return $this->errorResponse(422, $error, $response + ['code' => 326]);
}
if (empty($picture)) {
$svg = file_get_contents(resource_path('images/user.svg'));
$picture = 'data:image/svg+xml;base64,' . base64_encode($svg);
} elseif (!preg_match('|^data:image/png;base64,[a-zA-Z0-9=+/]+$|', $picture)) {
return $this->errorResponse(422, $error, $response + ['code' => 326]);
}
// TODO: Resize when big/make safe the user picture?
$request = ['nickname' => $nickname, 'requestId' => $requestId, 'picture' => $picture];
if (!$room->requestSave($requestId, $request)) {
// FIXME: should we use error code 500?
return $this->errorResponse(422, $error, $response + ['code' => 326]);
}
// Send the request (signal) to all moderators
$result = $room->signal('joinRequest', $request, Room::ROLE_MODERATOR);
}
return $this->errorResponse(422, $error, $response + ['code' => 327]);
}
}
// Initialize connection tokens
if ($init) {
// Choose the connection role
$canPublish = !empty(request()->input('canPublish')) && (empty($config['nomedia']) || $isOwner);
$role = $canPublish ? Room::ROLE_PUBLISHER : Room::ROLE_SUBSCRIBER;
if ($isOwner) {
$role |= Room::ROLE_MODERATOR;
$role |= Room::ROLE_OWNER;
}
// Create session token for the current user/connection
$response = $room->getSessionToken($role);
if (empty($response)) {
return $this->errorResponse(500, \trans('meet.session-join-error'));
}
$response_code = 200;
$response['role'] = $role;
$response['config'] = $config;
} else {
$response_code = 422;
$response['code'] = 322;
}
return response()->json($response, $response_code);
}
- /**
- * Set the domain configuration.
- *
- * @param string $id Room identifier (name)
- *
- * @return \Illuminate\Http\JsonResponse|void
- */
- public function setRoomConfig($id)
- {
- $room = Room::where('name', $id)->first();
-
- // Room does not exist, or the owner is deleted
- if (!$room || !$room->owner || $room->owner->isDegraded(true)) {
- return $this->errorResponse(404);
- }
-
- $user = Auth::guard()->user();
-
- // Only room owner can configure the room
- if ($user->id != $room->user_id) {
- return $this->errorResponse(403);
- }
-
- $input = request()->input();
- $errors = [];
-
- foreach ($input as $key => $value) {
- switch ($key) {
- case 'password':
- if ($value === null || $value === '') {
- $input[$key] = null;
- } else {
- // TODO: Do we have to validate the password in any way?
- }
- break;
-
- case 'locked':
- $input[$key] = $value ? 'true' : null;
- break;
-
- case 'nomedia':
- $input[$key] = $value ? 'true' : null;
- break;
-
- default:
- $errors[$key] = \trans('meet.room-unsupported-option-error');
- }
- }
-
- if (!empty($errors)) {
- return response()->json(['status' => 'error', 'errors' => $errors], 422);
- }
-
- if (!empty($input)) {
- $room->setSettings($input);
- }
-
- return response()->json([
- 'status' => 'success',
- 'message' => \trans('meet.room-setconfig-success'),
- ]);
- }
-
/**
* Webhook as triggered from the Meet server
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\Response The response
*/
public function webhook(Request $request)
{
\Log::debug($request->getContent());
// Authenticate the request
if ($request->headers->get('X-Auth-Token') != \config('meet.webhook_token')) {
return response('Unauthorized', 403);
}
$sessionId = (string) $request->input('roomId');
$event = (string) $request->input('event');
switch ($event) {
case 'roomClosed':
// When all participants left the room the server will dispatch roomClosed
// event. We'll remove the session reference from the database.
$room = Room::where('session_id', $sessionId)->first();
if ($room) {
$room->session_id = null;
$room->save();
}
break;
case 'joinRequestAccepted':
case 'joinRequestDenied':
$room = Room::where('session_id', $sessionId)->first();
if ($room) {
$method = $event == 'joinRequestAccepted' ? 'requestAccept' : 'requestDeny';
$room->{$method}($request->input('requestId'));
}
break;
}
return response('Success', 200);
}
}
diff --git a/src/app/Http/Controllers/API/V4/RoomsController.php b/src/app/Http/Controllers/API/V4/RoomsController.php
new file mode 100644
index 00000000..3b4d6ab3
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/RoomsController.php
@@ -0,0 +1,314 @@
+<?php
+
+namespace App\Http\Controllers\API\V4;
+
+use App\Http\Controllers\RelationController;
+use App\Meet\Room;
+use App\Permission;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Validator;
+
+class RoomsController extends RelationController
+{
+ /** @var string Resource localization label */
+ protected $label = 'room';
+
+ /** @var string Resource model name */
+ protected $model = Room::class;
+
+ /** @var array Resource listing order (column names) */
+ protected $order = ['name'];
+
+ /** @var array Common object properties in the API response */
+ protected $objectProps = ['name', 'description'];
+
+
+ /**
+ * Delete a room
+ *
+ * @param string $id Room identifier
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function destroy($id)
+ {
+ $room = $this->inputRoom($id);
+ if (is_int($room)) {
+ return $this->errorResponse($room);
+ }
+
+ $room->delete();
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => \trans("app.room-delete-success"),
+ ]);
+ }
+
+ /**
+ * Listing of rooms that belong to the authenticated user.
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function index()
+ {
+ $user = $this->guard()->user();
+
+ $shared = Room::whereIn('id', function ($query) use ($user) {
+ $query->select('permissible_id')
+ ->from('permissions')
+ ->where('permissible_type', Room::class)
+ ->where('user', $user->email);
+ });
+
+ // Create a "private" room for the user
+ if (!$user->rooms()->count()) {
+ $room = Room::create();
+ $room->assignToWallet($user->wallets()->first());
+ }
+
+ $rooms = $user->rooms(true)->union($shared)->orderBy('name')->get()
+ ->map(function ($room) {
+ return $this->objectToClient($room);
+ });
+
+ $result = [
+ 'list' => $rooms,
+ 'count' => count($rooms),
+ ];
+
+ return response()->json($result);
+ }
+
+ /**
+ * Set the room configuration.
+ *
+ * @param int|string $id Room identifier (or name)
+ *
+ * @return \Illuminate\Http\JsonResponse|void
+ */
+ public function setConfig($id)
+ {
+ $room = $this->inputRoom($id, Permission::ADMIN, $permission);
+ if (is_int($room)) {
+ return $this->errorResponse($room);
+ }
+
+ $request = request()->input();
+
+ // Room sharees can't manage room ACL
+ if ($permission) {
+ unset($request['acl']);
+ }
+
+ $errors = $room->setConfig($request);
+
+ if (!empty($errors)) {
+ return response()->json(['status' => 'error', 'errors' => $errors], 422);
+ }
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => \trans("app.room-setconfig-success"),
+ ]);
+ }
+
+ /**
+ * Display information of a room specified by $id.
+ *
+ * @param string $id The room to show information for.
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function show($id)
+ {
+ $room = $this->inputRoom($id, Permission::READ, $permission);
+ if (is_int($room)) {
+ return $this->errorResponse($room);
+ }
+
+ $wallet = $room->wallet();
+ $user = $this->guard()->user();
+
+ $response = $this->objectToClient($room, true);
+
+ unset($response['session_id']);
+
+ $response['config'] = $room->getConfig();
+
+ // Room sharees can't manage/see room ACL
+ if ($permission) {
+ unset($response['config']['acl']);
+ }
+
+ $response['skus'] = \App\Entitlement::objectEntitlementsSummary($room);
+ $response['wallet'] = $wallet->toArray();
+
+ if ($wallet->discount) {
+ $response['wallet']['discount'] = $wallet->discount->discount;
+ $response['wallet']['discount_description'] = $wallet->discount->description;
+ }
+
+ $isOwner = $user->canDelete($room);
+ $response['canUpdate'] = $isOwner || $room->permissions()->where('user', $user->email)->exists();
+ $response['canDelete'] = $isOwner && $user->wallet()->isController($user);
+ $response['canShare'] = $isOwner && $room->hasSKU('group-room');
+ $response['isOwner'] = $isOwner;
+
+ return response()->json($response);
+ }
+
+ /**
+ * Get a list of SKUs available to the room.
+ *
+ * @param int $id Room identifier
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function skus($id)
+ {
+ $room = $this->inputRoom($id);
+ if (is_int($room)) {
+ return $this->errorResponse($room);
+ }
+
+ return SkusController::objectSkus($room);
+ }
+
+ /**
+ * Create a new room.
+ *
+ * @param \Illuminate\Http\Request $request The API request.
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function store(Request $request)
+ {
+ $user = $this->guard()->user();
+ $wallet = $user->wallet();
+
+ if (!$wallet->isController($user)) {
+ return $this->errorResponse(403);
+ }
+
+ // Validate the input
+ $v = Validator::make(
+ $request->all(),
+ [
+ 'description' => 'nullable|string|max:191'
+ ]
+ );
+
+ if ($v->fails()) {
+ return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
+ }
+
+ DB::beginTransaction();
+
+ $room = Room::create([
+ 'description' => $request->input('description'),
+ ]);
+
+ if (!empty($request->skus)) {
+ SkusController::updateEntitlements($room, $request->skus, $wallet);
+ } else {
+ $room->assignToWallet($wallet);
+ }
+
+ DB::commit();
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => \trans("app.room-create-success"),
+ ]);
+ }
+
+ /**
+ * Update a room.
+ *
+ * @param \Illuminate\Http\Request $request The API request.
+ * @param string $id Room identifier
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function update(Request $request, $id)
+ {
+ $room = $this->inputRoom($id, Permission::ADMIN);
+ if (is_int($room)) {
+ return $this->errorResponse($room);
+ }
+
+ // Validate the input
+ $v = Validator::make(
+ request()->all(),
+ [
+ 'description' => 'nullable|string|max:191'
+ ]
+ );
+
+ if ($v->fails()) {
+ return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
+ }
+
+ DB::beginTransaction();
+
+ $room->description = request()->input('description');
+ $room->save();
+
+ if (!empty($request->skus)) {
+ SkusController::updateEntitlements($room, $request->skus);
+ }
+
+ if (!$room->hasSKU('group-room')) {
+ $room->setSetting('acl', null);
+ }
+
+ DB::commit();
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => \trans("app.room-update-success"),
+ ]);
+ }
+
+ /**
+ * Get the input room object, check permissions.
+ *
+ * @param int|string $id Room identifier (or name)
+ * @param ?int $rights Required access rights
+ * @param ?\App\Permission $permission Room permission reference if the user has permissions
+ * to the room and is not the owner
+ *
+ * @return \App\Meet\Room|int File object or error code
+ */
+ protected function inputRoom($id, $rights = 0, &$permission = null): int|Room
+ {
+ if (!is_numeric($id)) {
+ $room = Room::where('name', $id)->first();
+ } else {
+ $room = Room::find($id);
+ }
+
+ if (!$room) {
+ return 404;
+ }
+
+ $user = $this->guard()->user();
+
+ // Room owner (or another wallet controller)?
+ if ($room->wallet()->isController($user)) {
+ return $room;
+ }
+
+ if ($rights) {
+ $permission = $room->permissions()->where('user', $user->email)->first();
+
+ if ($permission && $permission->rights & $rights) {
+ return $room;
+ }
+ }
+
+ return 403;
+ }
+}
diff --git a/src/app/Http/Controllers/API/V4/SkusController.php b/src/app/Http/Controllers/API/V4/SkusController.php
index 7ff95e74..8d2e906b 100644
--- a/src/app/Http/Controllers/API/V4/SkusController.php
+++ b/src/app/Http/Controllers/API/V4/SkusController.php
@@ -1,191 +1,204 @@
<?php
namespace App\Http\Controllers\API\V4;
use App\Http\Controllers\ResourceController;
use App\Sku;
use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Str;
class SkusController extends ResourceController
{
/**
* Get a list of active SKUs.
*
* @return \Illuminate\Http\JsonResponse
*/
public function index()
{
$type = request()->input('type');
// Note: Order by title for consistent ordering in tests
$response = Sku::withSubjectTenantContext()->where('active', true)->orderBy('title')
->get()
->transform(function ($sku) {
return $this->skuElement($sku);
})
->filter(function ($sku) use ($type) {
return !$type || $sku['type'] === $type;
})
->sortByDesc('prio')
->values();
if ($type) {
$wallet = $this->guard()->user()->wallet();
// Figure out the cost for a new object of the specified type
$response = $response->map(function ($sku) use ($wallet) {
$sku['nextCost'] = $sku['cost'];
if ($sku['cost'] && $sku['units_free']) {
$count = $wallet->entitlements()->where('sku_id', $sku['id'])->count();
if ($count < $sku['units_free']) {
$sku['nextCost'] = 0;
}
}
return $sku;
});
}
return response()->json($response->all());
}
/**
- * Return SKUs available to the specified user/domain.
+ * Return SKUs available to the specified entitleable object.
*
- * @param object $object User or Domain object
+ * @param object $object Entitleable object
*
* @return \Illuminate\Http\JsonResponse
*/
public static function objectSkus($object)
{
- $type = \lcfirst(\class_basename($object::class));
$response = [];
// Note: Order by title for consistent ordering in tests
$skus = Sku::withObjectTenantContext($object)->orderBy('title')->get();
foreach ($skus as $sku) {
if (!class_exists($sku->handler_class)) {
continue;
}
+ if ($object::class != $sku->handler_class::entitleableClass()) {
+ continue;
+ }
+
if (!$sku->handler_class::isAvailable($sku, $object)) {
continue;
}
if ($data = self::skuElement($sku)) {
- if ($type != $data['type']) {
- continue;
+ if (!empty($data['controllerOnly'])) {
+ $user = Auth::guard()->user();
+ if (!$user->wallet()->isController($user)) {
+ continue;
+ }
}
$response[] = $data;
}
}
usort($response, function ($a, $b) {
return ($b['prio'] <=> $a['prio']);
});
return response()->json($response);
}
/**
* Include SKUs/Wallet information in the object's response.
*
* @param object $object User/Domain/etc object
* @param array $response The response to put the data into
*/
public static function objectEntitlements($object, &$response = []): void
{
// Object's entitlements information
$response['skus'] = \App\Entitlement::objectEntitlementsSummary($object);
// Some basic information about the object's wallet
$wallet = $object->wallet();
$response['wallet'] = $wallet->toArray();
if ($wallet->discount) {
$response['wallet']['discount'] = $wallet->discount->discount;
$response['wallet']['discount_description'] = $wallet->discount->description;
}
}
/**
* Update object entitlements.
*
- * @param object $object The object for update
- * @param array $rSkus List of SKU IDs requested for the user in the form [id=>qty]
+ * @param object $object The object for update
+ * @param array $rSkus List of SKU IDs requested for the object in the form [id=>qty]
+ * @param ?\App\Wallet $wallet The target wallet
*/
- public static function updateEntitlements($object, $rSkus): void
+ public static function updateEntitlements($object, $rSkus, $wallet = null): void
{
if (!is_array($rSkus)) {
return;
}
// list of skus, [id=>obj]
$skus = Sku::withEnvTenantContext()->get()->mapWithKeys(
function ($sku) {
return [$sku->id => $sku];
}
);
// existing entitlement's SKUs
$eSkus = [];
$object->entitlements()->groupBy('sku_id')
->selectRaw('count(*) as total, sku_id')->each(
function ($e) use (&$eSkus) {
$eSkus[$e->sku_id] = $e->total;
}
);
foreach ($skus as $skuID => $sku) {
$e = array_key_exists($skuID, $eSkus) ? $eSkus[$skuID] : 0;
$r = array_key_exists($skuID, $rSkus) ? $rSkus[$skuID] : 0;
+ if (!is_a($object, $sku->handler_class::entitleableClass())) {
+ continue;
+ }
+
if ($sku->handler_class == \App\Handlers\Mailbox::class) {
if ($r != 1) {
throw new \Exception("Invalid quantity of mailboxes");
}
}
if ($e > $r) {
// remove those entitled more than existing
$object->removeSku($sku, ($e - $r));
} elseif ($e < $r) {
// add those requested more than entitled
- $object->assignSku($sku, ($r - $e));
+ $object->assignSku($sku, ($r - $e), $wallet);
}
}
}
/**
* Convert SKU information to metadata used by UI to
* display the form control
*
* @param \App\Sku $sku SKU object
*
* @return array|null Metadata
*/
protected static function skuElement($sku): ?array
{
if (!class_exists($sku->handler_class)) {
return null;
}
$data = array_merge($sku->toArray(), $sku->handler_class::metadata($sku));
// ignore incomplete handlers
if (empty($data['type'])) {
return null;
}
// Use localized value, toArray() does not get them right
$data['name'] = $sku->name;
$data['description'] = $sku->description;
unset($data['handler_class'], $data['created_at'], $data['updated_at'], $data['fee'], $data['tenant_id']);
return $data;
}
}
diff --git a/src/app/Http/Controllers/RelationController.php b/src/app/Http/Controllers/RelationController.php
index 982612d9..f9c2625f 100644
--- a/src/app/Http/Controllers/RelationController.php
+++ b/src/app/Http/Controllers/RelationController.php
@@ -1,390 +1,392 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Support\Str;
class RelationController extends ResourceController
{
/** @var array Common object properties in the API response */
protected $objectProps = [];
/** @var string Resource localization label */
protected $label = '';
/** @var string Resource model name */
protected $model = '';
/** @var array Resource listing order (column names) */
protected $order = [];
/** @var array Resource relation method arguments */
protected $relationArgs = [];
/**
* Delete a resource.
*
* @param string $id Resource identifier
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function destroy($id)
{
$resource = $this->model::find($id);
if (!$this->checkTenant($resource)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canDelete($resource)) {
return $this->errorResponse(403);
}
$resource->delete();
return response()->json([
'status' => 'success',
'message' => \trans("app.{$this->label}-delete-success"),
]);
}
/**
* Listing of resources belonging to the authenticated user.
*
* The resource entitlements billed to the current user wallet(s)
*
* @return \Illuminate\Http\JsonResponse
*/
public function index()
{
$user = $this->guard()->user();
$method = Str::plural(\lcfirst(\class_basename($this->model)));
$query = call_user_func_array([$user, $method], $this->relationArgs);
if (!empty($this->order)) {
foreach ($this->order as $col) {
$query->orderBy($col);
}
}
// TODO: Search and paging
$result = $query->get()
->map(function ($resource) {
return $this->objectToClient($resource);
});
$result = [
'list' => $result,
'count' => count($result),
'hasMore' => false,
'message' => \trans("app.search-foundx{$this->label}s", ['x' => count($result)]),
];
return response()->json($result);
}
/**
* Prepare resource statuses for the UI
*
* @param object $resource Resource object
*
* @return array Statuses array
*/
protected static function objectState($resource): array
{
$state = [];
$reflect = new \ReflectionClass(get_class($resource));
foreach (array_keys($reflect->getConstants()) as $const) {
if (strpos($const, 'STATUS_') === 0 && $const != 'STATUS_NEW') {
$method = Str::camel('is_' . strtolower(substr($const, 7)));
$state[$method] = $resource->{$method}();
}
}
if (empty($state['isDeleted']) && method_exists($resource, 'trashed')) {
$state['isDeleted'] = $resource->trashed();
}
return $state;
}
/**
* Prepare a resource object for the UI.
*
* @param object $object An object
* @param bool $full Include all object properties
*
* @return array Object information
*/
protected function objectToClient($object, bool $full = false): array
{
if ($full) {
$result = $object->toArray();
+
+ unset($result['tenant_id']);
} else {
$result = ['id' => $object->id];
foreach ($this->objectProps as $prop) {
$result[$prop] = $object->{$prop};
}
}
$result = array_merge($result, $this->objectState($object));
return $result;
}
/**
* Object status' process information.
*
* @param object $object The object to process
* @param array $steps The steps definition
*
* @return array Process state information
*/
protected static function processStateInfo($object, array $steps): array
{
$process = [];
// Create a process check list
foreach ($steps as $step_name => $state) {
$step = [
'label' => $step_name,
'title' => \trans("app.process-{$step_name}"),
];
if (is_array($state)) {
$step['link'] = $state[1];
$state = $state[0];
}
$step['state'] = $state;
$process[] = $step;
}
// Add domain specific steps
if (method_exists($object, 'domain')) {
$domain = $object->domain();
// If that is not a public domain
if ($domain && !$domain->isPublic()) {
$domain_status = API\V4\DomainsController::statusInfo($domain);
$process = array_merge($process, $domain_status['process']);
}
}
$all = count($process);
$checked = count(array_filter($process, function ($v) {
return $v['state'];
}));
$state = $all === $checked ? 'done' : 'running';
// After 180 seconds assume the process is in failed state,
// this should unlock the Refresh button in the UI
if ($all !== $checked && $object->created_at->diffInSeconds(\Carbon\Carbon::now()) > 180) {
$state = 'failed';
}
return [
'process' => $process,
'processState' => $state,
'isReady' => $all === $checked,
];
}
/**
* Object status' process information update.
*
* @param object $object The object to process
*
* @return array Process state information
*/
protected function processStateUpdate($object): array
{
$response = $this->statusInfo($object);
if (!empty(request()->input('refresh'))) {
$updated = false;
$async = false;
$last_step = 'none';
foreach ($response['process'] as $idx => $step) {
$last_step = $step['label'];
if (!$step['state']) {
$exec = $this->execProcessStep($object, $step['label']); // @phpstan-ignore-line
if (!$exec) {
if ($exec === null) {
$async = true;
}
break;
}
$updated = true;
}
}
if ($updated) {
$response = $this->statusInfo($object);
}
$success = $response['isReady'];
$suffix = $success ? 'success' : 'error-' . $last_step;
$response['status'] = $success ? 'success' : 'error';
$response['message'] = \trans('app.process-' . $suffix);
if ($async && !$success) {
$response['processState'] = 'waiting';
$response['status'] = 'success';
$response['message'] = \trans('app.process-async');
}
}
return $response;
}
/**
* Set the resource configuration.
*
* @param int $id Resource identifier
*
* @return \Illuminate\Http\JsonResponse|void
*/
public function setConfig($id)
{
$resource = $this->model::find($id);
if (!method_exists($this->model, 'setConfig')) {
return $this->errorResponse(404);
}
if (!$this->checkTenant($resource)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canUpdate($resource)) {
return $this->errorResponse(403);
}
$errors = $resource->setConfig(request()->input());
if (!empty($errors)) {
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
return response()->json([
'status' => 'success',
'message' => \trans("app.{$this->label}-setconfig-success"),
]);
}
/**
* Display information of a resource specified by $id.
*
* @param string $id The resource to show information for.
*
* @return \Illuminate\Http\JsonResponse
*/
public function show($id)
{
$resource = $this->model::find($id);
if (!$this->checkTenant($resource)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canRead($resource)) {
return $this->errorResponse(403);
}
$response = $this->objectToClient($resource, true);
if (!empty($statusInfo = $this->statusInfo($resource))) {
$response['statusInfo'] = $statusInfo;
}
// Resource configuration, e.g. sender_policy, invitation_policy, acl
if (method_exists($resource, 'getConfig')) {
$response['config'] = $resource->getConfig();
}
if (method_exists($resource, 'aliases')) {
$response['aliases'] = $resource->aliases()->pluck('alias')->all();
}
// Entitlements/Wallet info
if (method_exists($resource, 'wallet')) {
API\V4\SkusController::objectEntitlements($resource, $response);
}
return response()->json($response);
}
/**
* Get a list of SKUs available to the resource.
*
* @param int $id Resource identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function skus($id)
{
$resource = $this->model::find($id);
if (!$this->checkTenant($resource)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canRead($resource)) {
return $this->errorResponse(403);
}
return API\V4\SkusController::objectSkus($resource);
}
/**
* Fetch resource status (and reload setup process)
*
* @param int $id Resource identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function status($id)
{
$resource = $this->model::find($id);
if (!$this->checkTenant($resource)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canRead($resource)) {
return $this->errorResponse(403);
}
$response = $this->processStateUpdate($resource);
$response = array_merge($response, $this->objectState($resource));
return response()->json($response);
}
/**
* Resource status (extended) information
*
* @param object $resource Resource object
*
* @return array Status information
*/
public static function statusInfo($resource): array
{
return [];
}
}
diff --git a/src/app/Meet/Room.php b/src/app/Meet/Room.php
index 9209ab53..722408c3 100644
--- a/src/app/Meet/Room.php
+++ b/src/app/Meet/Room.php
@@ -1,315 +1,340 @@
<?php
namespace App\Meet;
+use App\Traits\BelongsToTenantTrait;
+use App\Traits\EntitleableTrait;
+use App\Traits\Meet\RoomConfigTrait;
+use App\Traits\PermissibleTrait;
use App\Traits\SettingsTrait;
+use Dyrynda\Database\Support\NullableFields;
use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Cache;
/**
* The eloquent definition of a Room.
*
- * @property int $id Room identifier
- * @property string $name Room name
- * @property int $user_id Room owner
- * @property ?string $session_id Meet session identifier
+ * @property int $id Room identifier
+ * @property ?string $description Description
+ * @property string $name Room name
+ * @property int $tenant_id Tenant identifier
+ * @property ?string $session_id Meet session identifier
*/
class Room extends Model
{
+ use BelongsToTenantTrait;
+ use EntitleableTrait;
+ use RoomConfigTrait;
+ use NullableFields;
use SettingsTrait;
+ use PermissibleTrait;
+ use SoftDeletes;
public const ROLE_SUBSCRIBER = 1 << 0;
public const ROLE_PUBLISHER = 1 << 1;
public const ROLE_MODERATOR = 1 << 2;
public const ROLE_SCREEN = 1 << 3;
public const ROLE_OWNER = 1 << 4;
public const REQUEST_ACCEPTED = 'accepted';
public const REQUEST_DENIED = 'denied';
+ /** @var array<string, string> The attributes that should be cast */
+ protected $casts = [
+ 'created_at' => 'datetime:Y-m-d H:i:s',
+ 'deleted_at' => 'datetime:Y-m-d H:i:s',
+ 'updated_at' => 'datetime:Y-m-d H:i:s',
+ ];
+
/** @var array<int, string> The attributes that are mass assignable */
- protected $fillable = ['user_id', 'name'];
+ protected $fillable = ['name', 'description'];
+
+ /** @var array<int, string> The attributes that can be not set */
+ protected $nullable = ['description'];
/** @var string Database table name */
protected $table = 'openvidu_rooms';
/** @var \GuzzleHttp\Client|null HTTP client instance */
private $client = null;
/**
* Select a Meet server for this room
*
* This needs to always result in the same server for the same room,
* so all participants end up on the same server.
*
* @return string The server url
*/
private function selectMeetServer()
{
$urls = \config('meet.api_urls');
$count = count($urls);
if ($count == 0) {
\Log::error("No meet server configured.");
return "";
}
//Select a random backend.
//If the names are evenly distributed this should theoretically result in an even distribution.
$index = abs(intval(hash("crc32b", $this->name), 16) % $count);
return $urls[$index];
}
/**
* Creates HTTP client for connections to Meet server
*
* @return \GuzzleHttp\Client HTTP client instance
*/
private function client()
{
if (!$this->client) {
$url = $this->selectMeetServer();
$this->client = new \GuzzleHttp\Client(
[
'http_errors' => false, // No exceptions from Guzzle
'base_uri' => $url,
'verify' => \config('meet.api_verify_tls'),
'headers' => [
'X-Auth-Token' => \config('meet.api_token'),
],
'connect_timeout' => 10,
'timeout' => 10,
'on_stats' => function (\GuzzleHttp\TransferStats $stats) {
$threshold = \config('logging.slow_log');
if ($threshold && ($sec = $stats->getTransferTime()) > $threshold) {
$url = $stats->getEffectiveUri();
$method = $stats->getRequest()->getMethod();
\Log::warning(sprintf("[STATS] %s %s: %.4f sec.", $method, $url, $sec));
}
},
]
);
}
return $this->client;
}
/**
* Create a Meet session
*
* @return array|null Session data on success, NULL otherwise
*/
public function createSession(): ?array
{
$params = [
'json' => [ /* request params here */ ]
];
$response = $this->client()->request('POST', "sessions", $params);
if ($response->getStatusCode() !== 200) {
$this->logError("Failed to create the meet session", $response);
$this->session_id = null;
$this->save();
return null;
}
$session = json_decode($response->getBody(), true);
$this->session_id = $session['id'];
$this->save();
return $session;
}
/**
* Create a Meet session (connection) token
*
* @param int $role User role (see self::ROLE_* constants)
*
* @return array|null Token data on success, NULL otherwise
* @throws \Exception if session does not exist
*/
public function getSessionToken($role = self::ROLE_SUBSCRIBER): ?array
{
if (!$this->session_id) {
throw new \Exception("The room session does not exist");
}
$url = 'sessions/' . $this->session_id . '/connection';
$post = [
'json' => [
'role' => $role,
]
];
$response = $this->client()->request('POST', $url, $post);
if ($response->getStatusCode() == 200) {
$json = json_decode($response->getBody(), true);
return [
'token' => $json['token'],
'role' => $role,
];
}
$this->logError("Failed to create the meet peer connection", $response);
return null;
}
/**
* Check if the room has an active session
*
* @return bool True when the session exists, False otherwise
*/
public function hasSession(): bool
{
if (!$this->session_id) {
return false;
}
$response = $this->client()->request('GET', "sessions/{$this->session_id}");
$this->logError("Failed to check that a meet session exists", $response);
return $response->getStatusCode() == 200;
}
- /**
- * The room owner.
- *
- * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
- */
- public function owner()
- {
- return $this->belongsTo('\App\User', 'user_id', 'id');
- }
-
/**
* Accept the join request.
*
* @param string $id Request identifier
*
* @return bool True on success, False on failure
*/
public function requestAccept(string $id): bool
{
$request = Cache::get($this->session_id . '-' . $id);
if ($request) {
$request['status'] = self::REQUEST_ACCEPTED;
return Cache::put($this->session_id . '-' . $id, $request, now()->addHours(1));
}
return false;
}
/**
* Deny the join request.
*
* @param string $id Request identifier
*
* @return bool True on success, False on failure
*/
public function requestDeny(string $id): bool
{
$request = Cache::get($this->session_id . '-' . $id);
if ($request) {
$request['status'] = self::REQUEST_DENIED;
return Cache::put($this->session_id . '-' . $id, $request, now()->addHours(1));
}
return false;
}
/**
* Get the join request data.
*
* @param string $id Request identifier
*
* @return array|null Request data (e.g. nickname, status, picture?)
*/
public function requestGet(string $id): ?array
{
return Cache::get($this->session_id . '-' . $id);
}
/**
* Save the join request.
*
* @param string $id Request identifier
* @param array $request Request data
*
* @return bool True on success, False on failure
*/
public function requestSave(string $id, array $request): bool
{
// We don't really need the picture in the cache
// As we use this cache for the request status only
unset($request['picture']);
return Cache::put($this->session_id . '-' . $id, $request, now()->addHours(1));
}
- /**
- * Any (additional) properties of this room.
- *
- * @return \Illuminate\Database\Eloquent\Relations\HasMany
- */
- public function settings()
- {
- return $this->hasMany('App\Meet\RoomSetting', 'room_id');
- }
-
/**
* Send a signal to the Meet session participants (peers)
*
* @param string $name Signal name (type)
* @param array $data Signal data array
* @param int $target Limit targets by their participant role
*
* @return bool True on success, False on failure
* @throws \Exception if session does not exist
*/
public function signal(string $name, array $data = [], $target = null): bool
{
if (!$this->session_id) {
throw new \Exception("The room session does not exist");
}
$post = [
'roomId' => $this->session_id,
'type' => $name,
'role' => $target,
'data' => $data,
];
$response = $this->client()->request('POST', 'signal', ['json' => $post]);
$this->logError("Failed to send a signal to the meet session", $response);
return $response->getStatusCode() == 200;
}
+ /**
+ * Returns a map of supported ACL labels.
+ *
+ * @return array Map of supported permission rights/ACL labels
+ */
+ protected function supportedACL(): array
+ {
+ return [
+ 'full' => \App\Permission::READ | \App\Permission::WRITE | \App\Permission::ADMIN,
+ ];
+ }
+
+ /**
+ * Returns room name (required by the EntitleableTrait).
+ *
+ * @return string|null Room name
+ */
+ public function toString(): ?string
+ {
+ return $this->name;
+ }
+
/**
* Log an error for a failed request to the meet server
*
* @param string $str The error string
* @param object $response Guzzle client response
*/
private function logError(string $str, $response)
{
$code = $response->getStatusCode();
if ($code != 200) {
\Log::error("$str [$code]");
}
}
}
diff --git a/src/app/Meet/RoomSetting.php b/src/app/Meet/RoomSetting.php
index 9581eaf0..1cef89f1 100644
--- a/src/app/Meet/RoomSetting.php
+++ b/src/app/Meet/RoomSetting.php
@@ -1,32 +1,32 @@
<?php
namespace App\Meet;
use Illuminate\Database\Eloquent\Model;
/**
* A collection of settings for a Room.
*
* @property int $id
* @property int $room_id
* @property string $key
* @property string $value
*/
class RoomSetting extends Model
{
- protected $fillable = [
- 'room_id', 'key', 'value'
- ];
+ /** @var array<int, string> The attributes that are mass assignable */
+ protected $fillable = ['room_id', 'key', 'value'];
+ /** @var string Database table name */
protected $table = 'openvidu_room_settings';
/**
* The room to which this setting belongs.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function room()
{
- return $this->belongsTo('\App\Meet\Room', 'room_id', 'id');
+ return $this->belongsTo(Room::class);
}
}
diff --git a/src/app/Observers/Meet/RoomObserver.php b/src/app/Observers/Meet/RoomObserver.php
new file mode 100644
index 00000000..834d42a2
--- /dev/null
+++ b/src/app/Observers/Meet/RoomObserver.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace App\Observers\Meet;
+
+use App\Meet\Room;
+
+class RoomObserver
+{
+ /**
+ * Handle the room "created" event.
+ *
+ * @param \App\Meet\Room $room The room
+ *
+ * @return void
+ */
+ public function creating(Room $room): void
+ {
+ if (empty($room->name)) {
+ // Generate a random and unique room name
+ while (true) {
+ $room->name = strtolower(\App\Utils::randStr(3, 3, '-'));
+ if (!Room::where('name', $room->name)->exists()) {
+ break;
+ }
+ }
+ }
+ }
+}
diff --git a/src/app/Observers/UserObserver.php b/src/app/Observers/UserObserver.php
index 696a8c95..a91ca44c 100644
--- a/src/app/Observers/UserObserver.php
+++ b/src/app/Observers/UserObserver.php
@@ -1,332 +1,339 @@
<?php
namespace App\Observers;
use App\User;
use App\Wallet;
class UserObserver
{
/**
* Handle the "creating" event.
*
* Ensure that the user is created with a random, large integer.
*
* @param \App\User $user The user being created.
*
* @return void
*/
public function creating(User $user)
{
$user->email = \strtolower($user->email);
// only users that are not imported get the benefit of the doubt.
$user->status |= User::STATUS_NEW | User::STATUS_ACTIVE;
}
/**
* Handle the "created" event.
*
* Ensures the user has at least one wallet.
*
* Should ensure some basic settings are available as well.
*
* @param \App\User $user The user created.
*
* @return void
*/
public function created(User $user)
{
$settings = [
'country' => \App\Utils::countryForRequest(),
'currency' => \config('app.currency'),
/*
'first_name' => '',
'last_name' => '',
'billing_address' => '',
'organization' => '',
'phone' => '',
'external_email' => '',
*/
];
foreach ($settings as $key => $value) {
$settings[$key] = [
'key' => $key,
'value' => $value,
'user_id' => $user->id,
];
}
// Note: Don't use setSettings() here to bypass UserSetting observers
// Note: This is a single multi-insert query
$user->settings()->insert(array_values($settings));
$user->wallets()->create();
// Create user record in LDAP, then check if the account is created in IMAP
$chain = [
new \App\Jobs\User\VerifyJob($user->id),
];
\App\Jobs\User\CreateJob::withChain($chain)->dispatch($user->id);
if (\App\Tenant::getConfig($user->tenant_id, 'pgp.enable')) {
\App\Jobs\PGP\KeyCreateJob::dispatch($user->id, $user->email);
}
}
/**
* Handle the "deleted" event.
*
* @param \App\User $user The user deleted.
*
* @return void
*/
public function deleted(User $user)
{
// Remove the user from existing groups
$wallet = $user->wallet();
if ($wallet && $wallet->owner) {
$wallet->owner->groups()->each(function ($group) use ($user) {
if (in_array($user->email, $group->members)) {
$group->members = array_diff($group->members, [$user->email]);
$group->save();
}
});
}
+
+ // TODO: Remove Permission records for the user
+ // TODO: Remove file permissions for the user
}
/**
* Handle the "deleting" event.
*
* @param User $user The user that is being deleted.
*
* @return void
*/
public function deleting(User $user)
{
// Remove owned users/domains/groups/resources/etc
self::removeRelatedObjects($user, $user->isForceDeleting());
// TODO: Especially in tests we're doing delete() on a already deleted user.
// Should we escape here - for performance reasons?
if (!$user->isForceDeleting()) {
\App\Jobs\User\DeleteJob::dispatch($user->id);
if (\App\Tenant::getConfig($user->tenant_id, 'pgp.enable')) {
\App\Jobs\PGP\KeyDeleteJob::dispatch($user->id, $user->email);
}
// Debit the reseller's wallet with the user negative balance
$balance = 0;
foreach ($user->wallets as $wallet) {
// Note: here we assume all user wallets are using the same currency.
// It might get changed in the future
$balance += $wallet->balance;
}
if ($balance < 0 && $user->tenant && ($wallet = $user->tenant->wallet())) {
$wallet->debit($balance * -1, "Deleted user {$user->email}");
}
}
}
/**
* Handle the user "restoring" event.
*
* @param \App\User $user The user
*
* @return void
*/
public function restoring(User $user)
{
// Make sure it's not DELETED/LDAP_READY/IMAP_READY/SUSPENDED anymore
if ($user->isDeleted()) {
$user->status ^= User::STATUS_DELETED;
}
if ($user->isLdapReady()) {
$user->status ^= User::STATUS_LDAP_READY;
}
if ($user->isImapReady()) {
$user->status ^= User::STATUS_IMAP_READY;
}
if ($user->isSuspended()) {
$user->status ^= User::STATUS_SUSPENDED;
}
$user->status |= User::STATUS_ACTIVE;
// Note: $user->save() is invoked between 'restoring' and 'restored' events
}
/**
* Handle the user "restored" event.
*
* @param \App\User $user The user
*
* @return void
*/
public function restored(User $user)
{
// We need at least the user domain so it can be created in ldap.
// FIXME: What if the domain is owned by someone else?
$domain = $user->domain();
if ($domain->trashed() && !$domain->isPublic()) {
// Note: Domain entitlements will be restored by the DomainObserver
$domain->restore();
}
// FIXME: Should we reset user aliases? or re-validate them in any way?
// Create user record in LDAP, then run the verification process
$chain = [
new \App\Jobs\User\VerifyJob($user->id),
];
\App\Jobs\User\CreateJob::withChain($chain)->dispatch($user->id);
}
/**
* Handle the "updated" event.
*
* @param \App\User $user The user that is being updated.
*
* @return void
*/
public function updated(User $user)
{
\App\Jobs\User\UpdateJob::dispatch($user->id);
$oldStatus = $user->getOriginal('status');
$newStatus = $user->status;
if (($oldStatus & User::STATUS_DEGRADED) !== ($newStatus & User::STATUS_DEGRADED)) {
$wallets = [];
$isDegraded = $user->isDegraded();
// Charge all entitlements as if they were being deleted,
// but don't delete them. Just debit the wallet and update
// entitlements' updated_at timestamp. On un-degrade we still
// update updated_at, but with no debit (the cost is 0 on a degraded account).
foreach ($user->wallets as $wallet) {
$wallet->updateEntitlements($isDegraded);
// Remember time of the degradation for sending periodic reminders
// and reset it on un-degradation
$val = $isDegraded ? \Carbon\Carbon::now()->toDateTimeString() : null;
$wallet->setSetting('degraded_last_reminder', $val);
$wallets[] = $wallet->id;
}
// (Un-)degrade users by invoking an update job.
// LDAP backend will read the wallet owner's degraded status and
// set LDAP attributes accordingly.
// We do not change their status as their wallets have its own state
\App\Entitlement::whereIn('wallet_id', $wallets)
->where('entitleable_id', '!=', $user->id)
->where('entitleable_type', User::class)
->pluck('entitleable_id')
->unique()
->each(function ($user_id) {
\App\Jobs\User\UpdateJob::dispatch($user_id);
});
}
// Save the old password in the password history
$oldPassword = $user->getOriginal('password');
if ($oldPassword && $user->password != $oldPassword) {
self::saveOldPassword($user, $oldPassword);
}
}
/**
* Remove entitleables/transactions related to the user (in user's wallets)
*
* @param \App\User $user The user
* @param bool $force Force-delete mode
*/
private static function removeRelatedObjects(User $user, $force = false): void
{
$wallets = $user->wallets->pluck('id')->all();
\App\Entitlement::withTrashed()
->select('entitleable_id', 'entitleable_type')
->distinct()
->whereIn('wallet_id', $wallets)
->get()
->each(function ($entitlement) use ($user, $force) {
// Skip the current user (infinite recursion loop)
if ($entitlement->entitleable_type == User::class && $entitlement->entitleable_id == $user->id) {
return;
}
+ if (!$entitlement->entitleable) {
+ return;
+ }
+
// Objects need to be deleted one by one to make sure observers can do the proper cleanup
if ($force) {
$entitlement->entitleable->forceDelete();
} elseif (!$entitlement->entitleable->trashed()) {
$entitlement->entitleable->delete();
}
});
if ($force) {
// Remove "wallet" transactions, they have no foreign key constraint
\App\Transaction::where('object_type', Wallet::class)
->whereIn('object_id', $wallets)
->delete();
}
// regardless of force delete, we're always purging whitelists... just in case
\App\Policy\RateLimitWhitelist::where(
[
'whitelistable_id' => $user->id,
'whitelistable_type' => User::class
]
)->delete();
}
/**
* Store the old password in user password history. Make sure
* we do not store more passwords than we need in the history.
*
* @param \App\User $user The user
* @param string $password The old password
*/
private static function saveOldPassword(User $user, string $password): void
{
// Remember the timestamp of the last password change and unset the last warning date
$user->setSettings([
'password_expiration_warning' => null,
// Note: We could get this from user_passwords table, but only if the policy
// enables storing of old passwords there.
'password_update' => now()->format('Y-m-d H:i:s'),
]);
// Note: All this is kinda heavy and complicated because we don't want to store
// more old passwords than we need. However, except the complication/performance,
// there's one issue with it. E.g. the policy changes from 2 to 4, and we already
// removed the old passwords that were excessive before, but not now.
// Get the account password policy
$policy = new \App\Rules\Password($user->walletOwner());
$rules = $policy->rules();
// Password history disabled?
if (empty($rules['last']) || $rules['last']['param'] < 2) {
return;
}
// Store the old password
$user->passwords()->create(['password' => $password]);
// Remove passwords that we don't need anymore
$limit = $rules['last']['param'] - 1;
$ids = $user->passwords()->latest()->limit($limit)->pluck('id')->all();
if (count($ids) >= $limit) {
$user->passwords()->where('id', '<', $ids[count($ids) - 1])->delete();
}
}
}
diff --git a/src/app/Permission.php b/src/app/Permission.php
new file mode 100644
index 00000000..68743916
--- /dev/null
+++ b/src/app/Permission.php
@@ -0,0 +1,57 @@
+<?php
+
+namespace App;
+
+use App\Traits\UuidStrKeyTrait;
+use Illuminate\Database\Eloquent\Model;
+
+/**
+ * The eloquent definition of a Permission.
+ *
+ * @property string $id Permission identifier
+ * @property int $rights Access rights
+ * @property int $permissible_id The shared object identifier
+ * @property string $permissible_type The shared object type (class name)
+ * @property string $user Permitted user (email)
+ */
+class Permission extends Model
+{
+ use UuidStrKeyTrait;
+
+ public const READ = 1;
+ public const WRITE = 2;
+ public const ADMIN = 4;
+
+
+ /** @var array<int, string> The attributes that are mass assignable */
+ protected $fillable = [
+ 'permissible_id',
+ 'permissible_type',
+ 'rights',
+ 'user',
+ ];
+
+ /** @var array<string, string> The attributes that should be cast */
+ protected $casts = [
+ 'rights' => 'integer',
+ ];
+
+ /**
+ * Principally permissible object such as Room.
+ * Note that it may be trashed (soft-deleted).
+ *
+ * @return mixed
+ */
+ public function permissible()
+ {
+ return $this->morphTo()->withTrashed(); // @phpstan-ignore-line
+ }
+
+ /**
+ * Rights mutator. Make sure rights is integer.
+ */
+ public function setRightsAttribute($rights): void
+ {
+ $this->attributes['rights'] = (int) $rights;
+ }
+}
diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php
index 71d22f49..d84828f0 100644
--- a/src/app/Providers/AppServiceProvider.php
+++ b/src/app/Providers/AppServiceProvider.php
@@ -1,166 +1,167 @@
<?php
namespace App\Providers;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\ServiceProvider;
use Laravel\Passport\Passport;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*
* @return void
*/
public function register()
{
Passport::ignoreMigrations();
}
/**
* Serialize a bindings array to a string.
*
* @return string
*/
private static function serializeSQLBindings(array $array): string
{
$serialized = array_map(function ($entry) {
if ($entry instanceof \DateTime) {
return $entry->format('Y-m-d h:i:s');
}
return $entry;
}, $array);
return implode(', ', $serialized);
}
/**
* Bootstrap any application services.
*
* @return void
*/
public function boot()
{
\App\Domain::observe(\App\Observers\DomainObserver::class);
\App\Entitlement::observe(\App\Observers\EntitlementObserver::class);
\App\Group::observe(\App\Observers\GroupObserver::class);
\App\GroupSetting::observe(\App\Observers\GroupSettingObserver::class);
+ \App\Meet\Room::observe(\App\Observers\Meet\RoomObserver::class);
\App\PackageSku::observe(\App\Observers\PackageSkuObserver::class);
\App\PlanPackage::observe(\App\Observers\PlanPackageObserver::class);
\App\Resource::observe(\App\Observers\ResourceObserver::class);
\App\ResourceSetting::observe(\App\Observers\ResourceSettingObserver::class);
\App\SharedFolder::observe(\App\Observers\SharedFolderObserver::class);
\App\SharedFolderAlias::observe(\App\Observers\SharedFolderAliasObserver::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);
\App\User::observe(\App\Observers\UserObserver::class);
\App\UserAlias::observe(\App\Observers\UserAliasObserver::class);
\App\UserSetting::observe(\App\Observers\UserSettingObserver::class);
\App\VerificationCode::observe(\App\Observers\VerificationCodeObserver::class);
\App\Wallet::observe(\App\Observers\WalletObserver::class);
\App\PowerDNS\Domain::observe(\App\Observers\PowerDNS\DomainObserver::class);
\App\PowerDNS\Record::observe(\App\Observers\PowerDNS\RecordObserver::class);
Schema::defaultStringLength(191);
// Log SQL queries in debug mode
if (\config('app.debug')) {
DB::listen(function ($query) {
\Log::debug(
sprintf(
'[SQL] %s [%s]: %.4f sec.',
$query->sql,
self::serializeSQLBindings($query->bindings),
$query->time / 1000
)
);
});
}
// Register some template helpers
Blade::directive(
'theme_asset',
function ($path) {
$path = trim($path, '/\'"');
return "<?php echo secure_asset('themes/' . \$env['app.theme'] . '/' . '$path'); ?>";
}
);
Builder::macro(
'withEnvTenantContext',
function (string $table = null) {
$tenantId = \config('app.tenant_id');
if ($tenantId) {
/** @var Builder $this */
return $this->where(($table ? "$table." : "") . "tenant_id", $tenantId);
}
/** @var Builder $this */
return $this->whereNull(($table ? "$table." : "") . "tenant_id");
}
);
Builder::macro(
'withObjectTenantContext',
function (object $object, string $table = null) {
$tenantId = $object->tenant_id;
if ($tenantId) {
/** @var Builder $this */
return $this->where(($table ? "$table." : "") . "tenant_id", $tenantId);
}
/** @var Builder $this */
return $this->whereNull(($table ? "$table." : "") . "tenant_id");
}
);
Builder::macro(
'withSubjectTenantContext',
function (string $table = null) {
if ($user = auth()->user()) {
$tenantId = $user->tenant_id;
} else {
$tenantId = \config('app.tenant_id');
}
if ($tenantId) {
/** @var Builder $this */
return $this->where(($table ? "$table." : "") . "tenant_id", $tenantId);
}
/** @var Builder $this */
return $this->whereNull(($table ? "$table." : "") . "tenant_id");
}
);
// Query builder 'whereLike' mocro
Builder::macro(
'whereLike',
function (string $column, string $search, int $mode = 0) {
$search = addcslashes($search, '%_');
switch ($mode) {
case 2:
$search .= '%';
break;
case 1:
$search = '%' . $search;
break;
default:
$search = '%' . $search . '%';
}
/** @var Builder $this */
return $this->where($column, 'like', $search);
}
);
}
}
diff --git a/src/app/Traits/EntitleableTrait.php b/src/app/Traits/EntitleableTrait.php
index b0bd3997..ee7a48e7 100644
--- a/src/app/Traits/EntitleableTrait.php
+++ b/src/app/Traits/EntitleableTrait.php
@@ -1,278 +1,310 @@
<?php
namespace App\Traits;
use App\Entitlement;
use App\Sku;
use App\Wallet;
use Illuminate\Support\Str;
trait EntitleableTrait
{
/**
* Assign a package to an entitleable object. It should not have any existing entitlements.
*
* @param \App\Package $package The package
* @param \App\Wallet $wallet The wallet
*
* @return $this
*/
public function assignPackageAndWallet(\App\Package $package, Wallet $wallet)
{
// TODO: There should be some sanity checks here. E.g. not package can be
// assigned to any entitleable, but we don't really have package types.
foreach ($package->skus as $sku) {
for ($i = $sku->pivot->qty; $i > 0; $i--) {
Entitlement::create([
'wallet_id' => $wallet->id,
'sku_id' => $sku->id,
'cost' => $sku->pivot->cost(),
'fee' => $sku->pivot->fee(),
'entitleable_id' => $this->id,
'entitleable_type' => self::class
]);
}
}
return $this;
}
/**
- * Assign a Sku to an entitleable object.
+ * Assign a SKU to an entitleable object.
*
- * @param \App\Sku $sku The sku to assign.
- * @param int $count Count of entitlements to add
+ * @param \App\Sku $sku The sku to assign.
+ * @param int $count Count of entitlements to add
+ * @param ?\App\Wallet $wallet The wallet to use when objects's wallet is unknown
*
* @return $this
* @throws \Exception
*/
- public function assignSku(Sku $sku, int $count = 1)
+ public function assignSku(Sku $sku, int $count = 1, $wallet = null)
{
- // TODO: I guess wallet could be parametrized in future
- $wallet = $this->wallet();
- $exists = $this->entitlements()->where('sku_id', $sku->id)->count();
+ if (!$wallet) {
+ $wallet = $this->wallet();
+ }
+
+ if (!$wallet) {
+ throw new \Exception("No wallet specified for the new entitlement");
+ }
- // TODO: Make sure the SKU can be assigned to the object
+ $exists = $this->entitlements()->where('sku_id', $sku->id)->count();
while ($count > 0) {
Entitlement::create([
'wallet_id' => $wallet->id,
'sku_id' => $sku->id,
'cost' => $exists >= $sku->units_free ? $sku->cost : 0,
'fee' => $exists >= $sku->units_free ? $sku->fee : 0,
'entitleable_id' => $this->id,
'entitleable_type' => self::class
]);
$exists++;
$count--;
}
return $this;
}
/**
* Assign the object to a wallet.
*
* @param \App\Wallet $wallet The wallet
+ * @param ?string $title Optional SKU title
*
* @return $this
* @throws \Exception
*/
- public function assignToWallet(Wallet $wallet)
+ public function assignToWallet(Wallet $wallet, $title = null)
{
if (empty($this->id)) {
throw new \Exception("Object not yet exists");
}
if ($this->entitlements()->count()) {
throw new \Exception("Object already assigned to a wallet");
}
// Find the SKU title, e.g. \App\SharedFolder -> shared-folder
// Note: it does not work with User/Domain model (yet)
- $title = Str::kebab(\class_basename(self::class));
+ if (!$title) {
+ $title = Str::kebab(\class_basename(self::class));
+ }
- $sku = Sku::withObjectTenantContext($this)->where('title', $title)->first();
+ $sku = $this->skuByTitle($title);
$exists = $wallet->entitlements()->where('sku_id', $sku->id)->count();
Entitlement::create([
'wallet_id' => $wallet->id,
'sku_id' => $sku->id,
'cost' => $exists >= $sku->units_free ? $sku->cost : 0,
'fee' => $exists >= $sku->units_free ? $sku->fee : 0,
'entitleable_id' => $this->id,
'entitleable_type' => self::class
]);
return $this;
}
/**
* Boot function from Laravel.
*/
protected static function bootEntitleableTrait()
{
// Soft-delete and force-delete object's entitlements on object's delete
static::deleting(function ($model) {
$force = $model->isForceDeleting();
$entitlements = $model->entitlements();
if ($force) {
$entitlements = $entitlements->withTrashed();
}
$list = $entitlements->get()
->map(function ($entitlement) use ($force) {
if ($force) {
$entitlement->forceDelete();
} else {
$entitlement->delete();
}
return $entitlement->id;
})
->all();
// Remove transactions, they have no foreign key constraint
if ($force && !empty($list)) {
\App\Transaction::where('object_type', \App\Entitlement::class)
->whereIn('object_id', $list)
->delete();
}
});
// Restore object's entitlements on restore
static::restored(function ($model) {
$model->restoreEntitlements();
});
}
/**
* Entitlements for this object.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function entitlements()
{
return $this->hasMany(Entitlement::class, 'entitleable_id', 'id')
->where('entitleable_type', self::class);
}
/**
* Check if an entitlement for the specified SKU exists.
*
* @param string $title The SKU title
*
* @return bool True if specified SKU entitlement exists
*/
public function hasSku(string $title): bool
{
- $sku = Sku::withObjectTenantContext($this)->where('title', $title)->first();
+ $sku = $this->skuByTitle($title);
if (!$sku) {
return false;
}
return $this->entitlements()->where('sku_id', $sku->id)->count() > 0;
}
/**
* Remove a number of entitlements for the SKU.
*
* @param \App\Sku $sku The SKU
* @param int $count The number of entitlements to remove
*
* @return $this
*/
public function removeSku(Sku $sku, int $count = 1)
{
$entitlements = $this->entitlements()
->where('sku_id', $sku->id)
->orderBy('cost', 'desc')
->orderBy('created_at')
->get();
$entitlements_count = count($entitlements);
foreach ($entitlements as $entitlement) {
if ($entitlements_count <= $sku->units_free) {
continue;
}
if ($count > 0) {
$entitlement->delete();
$entitlements_count--;
$count--;
}
}
return $this;
}
/**
* Restore object entitlements.
*/
public function restoreEntitlements(): void
{
// We'll restore only these that were deleted last. So, first we get
// the maximum deleted_at timestamp and then use it to select
// entitlements for restore
$deleted_at = $this->entitlements()->withTrashed()->max('deleted_at');
if ($deleted_at) {
$threshold = (new \Carbon\Carbon($deleted_at))->subMinute();
// Restore object entitlements
$this->entitlements()->withTrashed()
->where('deleted_at', '>=', $threshold)
->update(['updated_at' => now(), 'deleted_at' => null]);
// Note: We're assuming that cost of entitlements was correct
// on deletion, so we don't have to re-calculate it again.
// TODO: We should probably re-calculate the cost
}
}
+ /**
+ * Find the SKU object by title. Use current object's tenant context.
+ *
+ * @param string $title SKU title.
+ *
+ * @return ?\App\Sku A SKU object
+ */
+ protected function skuByTitle(string $title): ?Sku
+ {
+ return Sku::withObjectTenantContext($this)->where('title', $title)->first();
+ }
+
+ /**
+ * Returns entitleable object title (e.g. email or domain name).
+ *
+ * @return string|null An object title/name
+ */
+ public function toString(): ?string
+ {
+ // This method should be overloaded by the model class
+ // if the object has not email attribute
+ return $this->email;
+ }
+
/**
* Returns the wallet by which the object is controlled
*
* @return ?\App\Wallet A wallet object
*/
public function wallet(): ?Wallet
{
$entitlement = $this->entitlements()->withTrashed()->orderBy('created_at', 'desc')->first();
if ($entitlement) {
return $entitlement->wallet;
}
// TODO: No entitlement should not happen, but in tests we have
// such cases, so we fallback to the user's wallet in this case
if ($this instanceof \App\User) {
return $this->wallets()->first();
}
return null;
}
/**
* Return the owner of the wallet (account) this entitleable is assigned to
*
* @return ?\App\User Account owner
*/
public function walletOwner(): ?\App\User
{
$wallet = $this->wallet();
if ($wallet) {
if ($this instanceof \App\User && $wallet->user_id == $this->id) {
return $this;
}
return $wallet->owner;
}
return null;
}
}
diff --git a/src/app/Traits/Meet/RoomConfigTrait.php b/src/app/Traits/Meet/RoomConfigTrait.php
new file mode 100644
index 00000000..ac9422ad
--- /dev/null
+++ b/src/app/Traits/Meet/RoomConfigTrait.php
@@ -0,0 +1,65 @@
+<?php
+
+namespace App\Traits\Meet;
+
+trait RoomConfigTrait
+{
+ /**
+ * A helper to get the room configuration.
+ */
+ public function getConfig(): array
+ {
+ $settings = $this->getSettings(['password', 'locked', 'nomedia']);
+
+ $config = [
+ 'acl' => $this->getACL(),
+ 'locked' => $settings['locked'] === 'true',
+ 'nomedia' => $settings['nomedia'] === 'true',
+ 'password' => $settings['password'],
+ ];
+
+ return $config;
+ }
+
+ /**
+ * A helper to update room configuration.
+ *
+ * @param array $config An array of configuration options
+ *
+ * @return array A list of input validation error messages
+ */
+ public function setConfig(array $config): array
+ {
+ $errors = [];
+
+ foreach ($config as $key => $value) {
+ if ($key == 'password') {
+ if ($value === null || $value === '') {
+ $value = null;
+ } else {
+ // TODO: Do we have to validate the password in any way?
+ }
+ $this->setSetting($key, $value);
+ } elseif ($key == 'locked' || $key == 'nomedia') {
+ $this->setSetting($key, $value ? 'true' : null);
+ } elseif ($key == 'acl') {
+ if (!empty($value) && !$this->hasSKU('group-room')) {
+ $errors[$key] = \trans('validation.invalid-config-parameter');
+ continue;
+ }
+
+ $acl_errors = $this->validateACL($value);
+
+ if (empty($acl_errors)) {
+ $this->setACL($value);
+ } else {
+ $errors[$key] = $acl_errors;
+ }
+ } else {
+ $errors[$key] = \trans('validation.invalid-config-parameter');
+ }
+ }
+
+ return $errors;
+ }
+}
diff --git a/src/app/Traits/PermissibleTrait.php b/src/app/Traits/PermissibleTrait.php
new file mode 100644
index 00000000..27db89e2
--- /dev/null
+++ b/src/app/Traits/PermissibleTrait.php
@@ -0,0 +1,163 @@
+<?php
+
+namespace App\Traits;
+
+use App\Permission;
+use Illuminate\Support\Facades\Validator;
+
+trait PermissibleTrait
+{
+ /**
+ * Boot function from Laravel.
+ */
+ protected static function bootPermissibleTrait()
+ {
+ // Selete object's shares on object's delete
+ static::deleting(function ($model) {
+ $model->permissions()->delete();
+ });
+ }
+
+ /**
+ * Permissions for this object.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\HasMany
+ */
+ public function permissions()
+ {
+ return $this->hasMany(Permission::class, 'permissible_id', 'id')
+ ->where('permissible_type', self::class);
+ }
+
+ /**
+ * Validate ACL input
+ *
+ * @param mixed $input Common ACL input
+ *
+ * @return array List of validation errors
+ */
+ protected function validateACL(&$input): array
+ {
+ if (!is_array($input)) {
+ $input = (array) $input;
+ }
+
+ $users = [];
+ $errors = [];
+ $supported = $this->supportedACL();
+
+ foreach ($input as $i => $v) {
+ if (!is_string($v) || empty($v) || !substr_count($v, ',')) {
+ $errors[$i] = \trans('validation.acl-entry-invalid');
+ } else {
+ list($user, $acl) = explode(',', $v, 2);
+ $user = trim($user);
+ $acl = trim($acl);
+ $error = null;
+
+ if (!isset($supported[$acl])) {
+ $errors[$i] = \trans('validation.acl-permission-invalid');
+ } elseif (in_array($user, $users) || ($error = $this->validateACLIdentifier($user))) {
+ $errors[$i] = $error ?: \trans('validation.acl-entry-invalid');
+ }
+
+ $input[$i] = "$user, $acl";
+ $users[] = $user;
+ }
+ }
+
+ return $errors;
+ }
+
+ /**
+ * Validate an ACL identifier.
+ *
+ * @param string $identifier Email address
+ *
+ * @return ?string Error message on validation error
+ */
+ protected function validateACLIdentifier(string $identifier): ?string
+ {
+ $v = Validator::make(['email' => $identifier], ['email' => 'required|email']);
+
+ if ($v->fails()) {
+ return \trans('validation.emailinvalid');
+ }
+
+ $user = \App\User::where('email', \strtolower($identifier))->first();
+
+ if ($user) {
+ return null;
+ }
+
+ return \trans('validation.notalocaluser');
+ }
+
+ /**
+ * Build an ACL list from the object's permissions
+ *
+ * @return array ACL list in a "common" format
+ */
+ protected function getACL(): array
+ {
+ $supported = $this->supportedACL();
+
+ return $this->permissions()->get()
+ ->map(function ($permission) use ($supported) {
+ $acl = array_search($permission->rights, $supported) ?: 'none';
+ return "{$permission->user}, {$acl}";
+ })
+ ->all();
+ }
+
+ /**
+ * Update the permissions based on the ACL input.
+ *
+ * @param array $acl ACL list in a "common" format
+ */
+ protected function setACL(array $acl): void
+ {
+ $users = [];
+ $supported = $this->supportedACL();
+
+ foreach ($acl as $item) {
+ list($user, $right) = explode(',', $item, 2);
+ $users[\strtolower($user)] = $supported[trim($right)] ?? 0;
+ }
+
+ // Compare the input with existing shares
+ $this->permissions()->get()->each(function ($permission) use (&$users) {
+ if (isset($users[$permission->user])) {
+ if ($permission->rights != $users[$permission->user]) {
+ $permission->rights = $users[$permission->user];
+ $permission->save();
+ }
+ unset($users[$permission->user]);
+ } else {
+ $permission->delete();
+ }
+ });
+
+ foreach ($users as $user => $rights) {
+ $this->permissions()->create([
+ 'user' => $user,
+ 'rights' => $rights,
+ 'permissible_type' => self::class,
+ ]);
+ }
+ }
+
+ /**
+ * Returns a map of supported ACL labels.
+ *
+ * @return array Map of supported share rights/ACL labels
+ */
+ protected function supportedACL(): array
+ {
+ return [
+ 'read-only' => Permission::RIGHT_READ,
+ 'read-write' => Permission::RIGHT_READ | Permission::RIGHT_WRITE,
+ 'full' => Permission::RIGHT_ADMIN,
+ ];
+ }
+}
diff --git a/src/app/Transaction.php b/src/app/Transaction.php
index 054603ed..42e63119 100644
--- a/src/app/Transaction.php
+++ b/src/app/Transaction.php
@@ -1,190 +1,190 @@
<?php
namespace App;
use App\Traits\UuidStrKeyTrait;
use Illuminate\Database\Eloquent\Model;
/**
* The eloquent definition of a Transaction.
*
* @property int $amount
* @property string $description
* @property string $id
* @property string $object_id
* @property string $object_type
* @property string $type
* @property string $transaction_id
* @property string $user_email
*/
class Transaction extends Model
{
use UuidStrKeyTrait;
public const ENTITLEMENT_BILLED = 'billed';
public const ENTITLEMENT_CREATED = 'created';
public const ENTITLEMENT_DELETED = 'deleted';
public const WALLET_AWARD = 'award';
public const WALLET_CREDIT = 'credit';
public const WALLET_DEBIT = 'debit';
public const WALLET_PENALTY = 'penalty';
public const WALLET_REFUND = 'refund';
public const WALLET_CHARGEBACK = 'chback';
/** @var array<int, string> The attributes that are mass assignable */
protected $fillable = [
// actor, if any
'user_email',
// entitlement, wallet
'object_id',
'object_type',
// entitlement: created, deleted, billed
// wallet: debit, credit, award, penalty
'type',
'amount',
'description',
// parent, for example wallet debit is parent for entitlements charged.
'transaction_id'
];
/** @var array<string, string> Casts properties as type */
protected $casts = [
'amount' => 'integer',
];
/**
* Returns the entitlement to which the transaction is assigned (if any)
*
* @return \App\Entitlement|null The entitlement
*/
public function entitlement(): ?Entitlement
{
if ($this->object_type !== Entitlement::class) {
return null;
}
return Entitlement::withTrashed()->find($this->object_id);
}
/**
* Transaction type mutator
*
* @throws \Exception
*/
public function setTypeAttribute($value): void
{
switch ($value) {
case self::ENTITLEMENT_BILLED:
case self::ENTITLEMENT_CREATED:
case self::ENTITLEMENT_DELETED:
// TODO: Must be an entitlement.
$this->attributes['type'] = $value;
break;
case self::WALLET_AWARD:
case self::WALLET_CREDIT:
case self::WALLET_DEBIT:
case self::WALLET_PENALTY:
case self::WALLET_REFUND:
case self::WALLET_CHARGEBACK:
// TODO: This must be a wallet.
$this->attributes['type'] = $value;
break;
default:
throw new \Exception("Invalid type value");
}
}
/**
* Returns a short text describing the transaction.
*
* @return string The description
*/
public function shortDescription(): string
{
$label = $this->objectTypeToLabelString() . '-' . $this->{'type'} . '-short';
$result = \trans("transactions.{$label}", $this->descriptionParams());
return trim($result, ': ');
}
/**
* Returns a text describing the transaction.
*
* @return string The description
*/
public function toString(): string
{
$label = $this->objectTypeToLabelString() . '-' . $this->{'type'};
return \trans("transactions.{$label}", $this->descriptionParams());
}
/**
* Returns a wallet to which the transaction is assigned (if any)
*
* @return \App\Wallet|null The wallet
*/
public function wallet(): ?Wallet
{
if ($this->object_type !== Wallet::class) {
return null;
}
return Wallet::find($this->object_id);
}
/**
* Collect transaction parameters used in (localized) descriptions
*
* @return array Parameters
*/
private function descriptionParams(): array
{
$result = [
'user_email' => $this->user_email,
'description' => $this->description,
];
$amount = $this->amount * ($this->amount < 0 ? -1 : 1);
if ($entitlement = $this->entitlement()) {
$wallet = $entitlement->wallet;
$cost = $entitlement->cost;
$discount = $entitlement->wallet->getDiscountRate();
$result['entitlement_cost'] = $cost * $discount;
- $result['object'] = $entitlement->entitleableTitle();
+ $result['object'] = $entitlement->entitleable->toString();
$result['sku_title'] = $entitlement->sku->title;
} else {
$wallet = $this->wallet();
}
$result['wallet'] = $wallet->description ?: 'Default wallet';
$result['amount'] = $wallet->money($amount);
return $result;
}
/**
* Get a string for use in translation tables derived from the object type.
*
* @return string|null
*/
private function objectTypeToLabelString(): ?string
{
if ($this->object_type == Entitlement::class) {
return 'entitlement';
}
if ($this->object_type == Wallet::class) {
return 'wallet';
}
return null;
}
}
diff --git a/src/app/User.php b/src/app/User.php
index ef369817..92495f26 100644
--- a/src/app/User.php
+++ b/src/app/User.php
@@ -1,720 +1,733 @@
<?php
namespace App;
use App\Traits\AliasesTrait;
use App\Traits\BelongsToTenantTrait;
use App\Traits\EntitleableTrait;
use App\Traits\EmailPropertyTrait;
use App\Traits\UserConfigTrait;
use App\Traits\UuidIntKeyTrait;
use App\Traits\SettingsTrait;
use App\Traits\StatusPropertyTrait;
use Dyrynda\Database\Support\NullableFields;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Laravel\Passport\HasApiTokens;
use League\OAuth2\Server\Exception\OAuthServerException;
/**
* The eloquent definition of a User.
*
* @property string $email
* @property int $id
* @property string $password
* @property string $password_ldap
* @property string $role
* @property int $status
* @property int $tenant_id
*/
class User extends Authenticatable
{
use AliasesTrait;
use BelongsToTenantTrait;
use EntitleableTrait;
use EmailPropertyTrait;
use HasApiTokens;
use NullableFields;
use UserConfigTrait;
use UuidIntKeyTrait;
use SettingsTrait;
use SoftDeletes;
use StatusPropertyTrait;
// a new user, default on creation
public const STATUS_NEW = 1 << 0;
// it's been activated
public const STATUS_ACTIVE = 1 << 1;
// user has been suspended
public const STATUS_SUSPENDED = 1 << 2;
// user has been deleted
public const STATUS_DELETED = 1 << 3;
// user has been created in LDAP
public const STATUS_LDAP_READY = 1 << 4;
// user mailbox has been created in IMAP
public const STATUS_IMAP_READY = 1 << 5;
// user in "limited feature-set" state
public const STATUS_DEGRADED = 1 << 6;
/** @var array<int, string> The attributes that are mass assignable */
protected $fillable = [
'id',
'email',
'password',
'password_ldap',
'status',
];
/** @var array<int, string> The attributes that should be hidden for arrays */
protected $hidden = [
'password',
'password_ldap',
'role'
];
/** @var array<int, string> The attributes that can be null */
protected $nullable = [
'password',
'password_ldap'
];
/** @var array<string, string> The attributes that should be cast */
protected $casts = [
'created_at' => 'datetime:Y-m-d H:i:s',
'deleted_at' => 'datetime:Y-m-d H:i:s',
'updated_at' => 'datetime:Y-m-d H:i:s',
];
/**
* Any wallets on which this user is a controller.
*
* This does not include wallets owned by the user.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function accounts()
{
return $this->belongsToMany(
Wallet::class, // The foreign object definition
'user_accounts', // The table name
'user_id', // The local foreign key
'wallet_id' // The remote foreign key
);
}
/**
* Assign a package to a user. The user should not have any existing entitlements.
*
* @param \App\Package $package The package to assign.
* @param \App\User|null $user Assign the package to another user.
*
* @return \App\User
*/
public function assignPackage($package, $user = null)
{
if (!$user) {
$user = $this;
}
return $user->assignPackageAndWallet($package, $this->wallets()->first());
}
/**
* Assign a package plan to a user.
*
* @param \App\Plan $plan The plan to assign
* @param \App\Domain $domain Optional domain object
*
* @return \App\User Self
*/
public function assignPlan($plan, $domain = null): User
{
$this->setSetting('plan_id', $plan->id);
foreach ($plan->packages as $package) {
if ($package->isDomain()) {
$domain->assignPackage($package, $this);
} else {
$this->assignPackage($package);
}
}
return $this;
}
/**
* Check if current user can delete another object.
*
* @param mixed $object A user|domain|wallet|group object
*
* @return bool True if he can, False otherwise
*/
public function canDelete($object): bool
{
if (!method_exists($object, 'wallet')) {
return false;
}
$wallet = $object->wallet();
// TODO: For now controller can delete/update the account owner,
// this may change in future, controllers are not 0-regression feature
return $wallet && ($wallet->user_id == $this->id || $this->accounts->contains($wallet));
}
/**
* Check if current user can read data of another object.
*
* @param mixed $object A user|domain|wallet|group object
*
* @return bool True if he can, False otherwise
*/
public function canRead($object): bool
{
if ($this->role == 'admin') {
return true;
}
if ($object instanceof User && $this->id == $object->id) {
return true;
}
if ($this->role == 'reseller') {
if ($object instanceof User && $object->role == 'admin') {
return false;
}
if ($object instanceof Wallet && !empty($object->owner)) {
$object = $object->owner;
}
return isset($object->tenant_id) && $object->tenant_id == $this->tenant_id;
}
if ($object instanceof Wallet) {
return $object->user_id == $this->id || $object->controllers->contains($this);
}
if (!method_exists($object, 'wallet')) {
return false;
}
$wallet = $object->wallet();
return $wallet && ($wallet->user_id == $this->id || $this->accounts->contains($wallet));
}
/**
* Check if current user can update data of another object.
*
* @param mixed $object A user|domain|wallet|group object
*
* @return bool True if he can, False otherwise
*/
public function canUpdate($object): bool
{
if ($object instanceof User && $this->id == $object->id) {
return true;
}
if ($this->role == 'admin') {
return true;
}
if ($this->role == 'reseller') {
if ($object instanceof User && $object->role == 'admin') {
return false;
}
if ($object instanceof Wallet && !empty($object->owner)) {
$object = $object->owner;
}
return isset($object->tenant_id) && $object->tenant_id == $this->tenant_id;
}
return $this->canDelete($object);
}
/**
* Degrade the user
*
* @return void
*/
public function degrade(): void
{
if ($this->isDegraded()) {
return;
}
$this->status |= User::STATUS_DEGRADED;
$this->save();
}
/**
* List the domains to which this user is entitled.
*
* @param bool $with_accounts Include domains assigned to wallets
* the current user controls but not owns.
* @param bool $with_public Include active public domains (for the user tenant).
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
public function domains($with_accounts = true, $with_public = true)
{
$domains = $this->entitleables(Domain::class, $with_accounts);
if ($with_public) {
$domains->orWhere(function ($query) {
if (!$this->tenant_id) {
$query->where('tenant_id', $this->tenant_id);
} else {
$query->withEnvTenantContext();
}
$query->where('domains.type', '&', Domain::TYPE_PUBLIC)
->where('domains.status', '&', Domain::STATUS_ACTIVE);
});
}
return $domains;
}
/**
* Return entitleable objects of a specified type controlled by the current user.
*
* @param string $class Object class
* @param bool $with_accounts Include objects assigned to wallets
* the current user controls, but not owns.
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
private function entitleables(string $class, bool $with_accounts = true)
{
$wallets = $this->wallets()->pluck('id')->all();
if ($with_accounts) {
$wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all());
}
$object = new $class();
$table = $object->getTable();
return $object->select("{$table}.*")
->whereExists(function ($query) use ($table, $wallets, $class) {
$query->select(DB::raw(1))
->from('entitlements')
->whereColumn('entitleable_id', "{$table}.id")
->whereIn('entitlements.wallet_id', $wallets)
->where('entitlements.entitleable_type', $class);
});
}
/**
* Helper to find user by email address, whether it is
* main email address, alias or an external email.
*
* If there's more than one alias NULL will be returned.
*
* @param string $email Email address
* @param bool $external Search also for an external email
*
* @return \App\User|null User model object if found
*/
public static function findByEmail(string $email, bool $external = false): ?User
{
if (strpos($email, '@') === false) {
return null;
}
$email = \strtolower($email);
$user = self::where('email', $email)->first();
if ($user) {
return $user;
}
$aliases = UserAlias::where('alias', $email)->get();
if (count($aliases) == 1) {
return $aliases->first()->user;
}
// TODO: External email
return null;
}
/**
* Storage items for this user.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function fsItems()
{
return $this->hasMany(Fs\Item::class);
}
/**
* Return groups controlled by the current user.
*
* @param bool $with_accounts Include groups assigned to wallets
* the current user controls but not owns.
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
public function groups($with_accounts = true)
{
return $this->entitleables(Group::class, $with_accounts);
}
/**
* Returns whether this user (or its wallet owner) is degraded.
*
* @param bool $owner Check also the wallet owner instead just the user himself
*
* @return bool
*/
public function isDegraded(bool $owner = false): bool
{
if ($this->status & self::STATUS_DEGRADED) {
return true;
}
if ($owner && ($wallet = $this->wallet())) {
return $wallet->owner && $wallet->owner->isDegraded();
}
return false;
}
/**
* A shortcut to get the user name.
*
* @param bool $fallback Return "<aa.name> User" if there's no name
*
* @return string Full user name
*/
public function name(bool $fallback = false): string
{
$settings = $this->getSettings(['first_name', 'last_name']);
$name = trim($settings['first_name'] . ' ' . $settings['last_name']);
if (empty($name) && $fallback) {
return trim(\trans('app.siteuser', ['site' => Tenant::getConfig($this->tenant_id, 'app.name')]));
}
return $name;
}
/**
* Old passwords for this user.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function passwords()
{
return $this->hasMany(UserPassword::class);
}
/**
* Return resources controlled by the current user.
*
* @param bool $with_accounts Include resources assigned to wallets
* the current user controls but not owns.
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
public function resources($with_accounts = true)
{
return $this->entitleables(Resource::class, $with_accounts);
}
+ /**
+ * Return rooms controlled by the current user.
+ *
+ * @param bool $with_accounts Include rooms assigned to wallets
+ * the current user controls but not owns.
+ *
+ * @return \Illuminate\Database\Eloquent\Builder Query builder
+ */
+ public function rooms($with_accounts = true)
+ {
+ return $this->entitleables(Meet\Room::class, $with_accounts);
+ }
+
/**
* 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)
{
return $this->entitleables(SharedFolder::class, $with_accounts);
}
public function senderPolicyFrameworkWhitelist($clientName)
{
$setting = $this->getSetting('spf_whitelist');
if (!$setting) {
return false;
}
$whitelist = json_decode($setting);
$matchFound = false;
foreach ($whitelist as $entry) {
if (substr($entry, 0, 1) == '/') {
$match = preg_match($entry, $clientName);
if ($match) {
$matchFound = true;
}
continue;
}
if (substr($entry, 0, 1) == '.') {
if (substr($clientName, (-1 * strlen($entry))) == $entry) {
$matchFound = true;
}
continue;
}
if ($entry == $clientName) {
$matchFound = true;
continue;
}
}
return $matchFound;
}
/**
* Un-degrade this user.
*
* @return void
*/
public function undegrade(): void
{
if (!$this->isDegraded()) {
return;
}
$this->status ^= User::STATUS_DEGRADED;
$this->save();
}
/**
* Return users controlled by the current user.
*
* @param bool $with_accounts Include users assigned to wallets
* the current user controls but not owns.
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
public function users($with_accounts = true)
{
return $this->entitleables(User::class, $with_accounts);
}
/**
* Verification codes for this user.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function verificationcodes()
{
return $this->hasMany(VerificationCode::class, 'user_id', 'id');
}
/**
* Wallets this user owns.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function wallets()
{
return $this->hasMany(Wallet::class);
}
/**
* User password mutator
*
* @param string $password The password in plain text.
*
* @return void
*/
public function setPasswordAttribute($password)
{
if (!empty($password)) {
$this->attributes['password'] = Hash::make($password);
$this->attributes['password_ldap'] = '{SSHA512}' . base64_encode(
pack('H*', hash('sha512', $password))
);
}
}
/**
* User LDAP password mutator
*
* @param string $password The password in plain text.
*
* @return void
*/
public function setPasswordLdapAttribute($password)
{
$this->setPasswordAttribute($password);
}
/**
* User status mutator
*
* @throws \Exception
*/
public function setStatusAttribute($status)
{
$new_status = 0;
$allowed_values = [
self::STATUS_NEW,
self::STATUS_ACTIVE,
self::STATUS_SUSPENDED,
self::STATUS_DELETED,
self::STATUS_LDAP_READY,
self::STATUS_IMAP_READY,
self::STATUS_DEGRADED,
];
foreach ($allowed_values as $value) {
if ($status & $value) {
$new_status |= $value;
$status ^= $value;
}
}
if ($status > 0) {
throw new \Exception("Invalid user status: {$status}");
}
$this->attributes['status'] = $new_status;
}
/**
* Validate the user credentials
*
* @param string $username The username.
* @param string $password The password in plain text.
* @param bool $updatePassword Store the password if currently empty
*
* @return bool true on success
*/
public function validateCredentials(string $username, string $password, bool $updatePassword = true): bool
{
$authenticated = false;
if ($this->email === \strtolower($username)) {
if (!empty($this->password)) {
if (Hash::check($password, $this->password)) {
$authenticated = true;
}
} elseif (!empty($this->password_ldap)) {
if (substr($this->password_ldap, 0, 6) == "{SSHA}") {
$salt = substr(base64_decode(substr($this->password_ldap, 6)), 20);
$hash = '{SSHA}' . base64_encode(
sha1($password . $salt, true) . $salt
);
if ($hash == $this->password_ldap) {
$authenticated = true;
}
} elseif (substr($this->password_ldap, 0, 9) == "{SSHA512}") {
$salt = substr(base64_decode(substr($this->password_ldap, 9)), 64);
$hash = '{SSHA512}' . base64_encode(
pack('H*', hash('sha512', $password . $salt)) . $salt
);
if ($hash == $this->password_ldap) {
$authenticated = true;
}
}
} else {
\Log::error("Incomplete credentials for {$this->email}");
}
}
if ($authenticated) {
\Log::info("Successful authentication for {$this->email}");
// TODO: update last login time
if ($updatePassword && (empty($this->password) || empty($this->password_ldap))) {
$this->password = $password;
$this->save();
}
} else {
// TODO: Try actual LDAP?
\Log::info("Authentication failed for {$this->email}");
}
return $authenticated;
}
/**
* Retrieve and authenticate a user
*
* @param string $username The username.
* @param string $password The password in plain text.
* @param string $secondFactor The second factor (secondfactor from current request is used as fallback).
*
* @return array ['user', 'reason', 'errorMessage']
*/
public static function findAndAuthenticate($username, $password, $secondFactor = null): ?array
{
$user = User::where('email', $username)->first();
if (!$user) {
return ['reason' => 'notfound', 'errorMessage' => "User not found."];
}
if (!$user->validateCredentials($username, $password)) {
return ['reason' => 'credentials', 'errorMessage' => "Invalid password."];
}
if (!$secondFactor) {
// Check the request if there is a second factor provided
// as fallback.
$secondFactor = request()->secondfactor;
}
try {
(new \App\Auth\SecondFactor($user))->validate($secondFactor);
} catch (\Exception $e) {
return ['reason' => 'secondfactor', 'errorMessage' => $e->getMessage()];
}
return ['user' => $user];
}
/**
* Hook for passport
*
* @throws \Throwable
*
* @return \App\User User model object if found
*/
public function findAndValidateForPassport($username, $password): User
{
$result = self::findAndAuthenticate($username, $password);
if (isset($result['reason'])) {
if ($result['reason'] == 'secondfactor') {
// This results in a json response of {'error': 'secondfactor', 'error_description': '$errorMessage'}
throw new OAuthServerException($result['errorMessage'], 6, 'secondfactor', 401);
}
throw OAuthServerException::invalidCredentials();
}
return $result['user'];
}
}
diff --git a/src/app/Wallet.php b/src/app/Wallet.php
index 274fdb3a..c7433e0b 100644
--- a/src/app/Wallet.php
+++ b/src/app/Wallet.php
@@ -1,492 +1,504 @@
<?php
namespace App;
use App\Traits\SettingsTrait;
use App\Traits\UuidStrKeyTrait;
use Carbon\Carbon;
use Dyrynda\Database\Support\NullableFields;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
/**
* The eloquent definition of a wallet -- a container with a chunk of change.
*
* A wallet is owned by an {@link \App\User}.
*
* @property int $balance Current balance in cents
* @property string $currency Currency code
* @property ?string $description Description
* @property string $id Unique identifier
* @property ?\App\User $owner Owner (can be null when owner is deleted)
* @property int $user_id Owner's identifier
*/
class Wallet extends Model
{
use NullableFields;
use SettingsTrait;
use UuidStrKeyTrait;
/** @var bool Indicates that the model should be timestamped or not */
public $timestamps = false;
/** @var array The attributes' default values */
protected $attributes = [
'balance' => 0,
];
/** @var array<int, string> The attributes that are mass assignable */
protected $fillable = [
'currency',
'description'
];
/** @var array<int, string> The attributes that can be not set */
protected $nullable = [
'description',
];
/** @var array<string, string> The types of attributes to which its values will be cast */
protected $casts = [
'balance' => 'integer',
];
/**
* Add a controller to this wallet.
*
* @param \App\User $user The user to add as a controller to this wallet.
*
* @return void
*/
public function addController(User $user)
{
if (!$this->controllers->contains($user)) {
$this->controllers()->save($user);
}
}
/**
* Charge entitlements in the wallet
*
* @param bool $apply Set to false for a dry-run mode
*
* @return int Charged amount in cents
*/
public function chargeEntitlements($apply = true): int
{
// This wallet has been created less than a month ago, this is the trial period
if ($this->owner->created_at >= Carbon::now()->subMonthsWithoutOverflow(1)) {
// Move all the current entitlement's updated_at timestamps forward to one month after
// this wallet was created.
$freeMonthEnds = $this->owner->created_at->copy()->addMonthsWithoutOverflow(1);
foreach ($this->entitlements()->get()->fresh() as $entitlement) {
if ($entitlement->updated_at < $freeMonthEnds) {
$entitlement->updated_at = $freeMonthEnds;
$entitlement->save();
}
}
return 0;
}
$profit = 0;
$charges = 0;
$discount = $this->getDiscountRate();
$isDegraded = $this->owner->isDegraded();
if ($apply) {
DB::beginTransaction();
}
// used to parent individual entitlement billings to the wallet debit.
$entitlementTransactions = [];
foreach ($this->entitlements()->get() as $entitlement) {
// This entitlement has been created less than or equal to 14 days ago (this is at
// maximum the fourteenth 24-hour period).
if ($entitlement->created_at > Carbon::now()->subDays(14)) {
continue;
}
// This entitlement was created, or billed last, less than a month ago.
if ($entitlement->updated_at > Carbon::now()->subMonthsWithoutOverflow(1)) {
continue;
}
// updated last more than a month ago -- was it billed?
if ($entitlement->updated_at <= Carbon::now()->subMonthsWithoutOverflow(1)) {
$diff = $entitlement->updated_at->diffInMonths(Carbon::now());
$cost = (int) ($entitlement->cost * $discount * $diff);
$fee = (int) ($entitlement->fee * $diff);
if ($isDegraded) {
$cost = 0;
}
$charges += $cost;
$profit += $cost - $fee;
// if we're in dry-run, you know...
if (!$apply) {
continue;
}
$entitlement->updated_at = $entitlement->updated_at->copy()
->addMonthsWithoutOverflow($diff);
$entitlement->save();
if ($cost == 0) {
continue;
}
$entitlementTransactions[] = $entitlement->createTransaction(
Transaction::ENTITLEMENT_BILLED,
$cost
);
}
}
if ($apply) {
$this->debit($charges, '', $entitlementTransactions);
// Credit/debit the reseller
if ($profit != 0 && $this->owner->tenant) {
// FIXME: Should we have a simpler way to skip this for non-reseller tenant(s)
if ($wallet = $this->owner->tenant->wallet()) {
$desc = "Charged user {$this->owner->email}";
$method = $profit > 0 ? 'credit' : 'debit';
$wallet->{$method}(abs($profit), $desc);
}
}
DB::commit();
}
return $charges;
}
/**
* Calculate for how long the current balance will last.
*
* Returns NULL for balance < 0 or discount = 100% or on a fresh account
*
* @return \Carbon\Carbon|null Date
*/
public function balanceLastsUntil()
{
if ($this->balance < 0 || $this->getDiscount() == 100) {
return null;
}
// retrieve any expected charges
$expectedCharge = $this->expectedCharges();
// get the costs per day for all entitlements billed against this wallet
$costsPerDay = $this->costsPerDay();
if (!$costsPerDay) {
return null;
}
// the number of days this balance, minus the expected charges, would last
$daysDelta = floor(($this->balance - $expectedCharge) / $costsPerDay);
// calculate from the last entitlement billed
$entitlement = $this->entitlements()->orderBy('updated_at', 'desc')->first();
$until = $entitlement->updated_at->copy()->addDays($daysDelta);
// Don't return dates from the past
if ($until < Carbon::now() && !$until->isToday()) {
return null;
}
return $until;
}
/**
* Controllers of this wallet.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function controllers()
{
return $this->belongsToMany(
User::class, // The foreign object definition
'user_accounts', // The table name
'wallet_id', // The local foreign key
'user_id' // The remote foreign key
);
}
/**
* Retrieve the costs per day of everything charged to this wallet.
*
* @return float
*/
public function costsPerDay()
{
$costs = (float) 0;
foreach ($this->entitlements as $entitlement) {
$costs += $entitlement->costsPerDay();
}
return $costs;
}
/**
* Add an amount of pecunia to this wallet's balance.
*
* @param int $amount The amount of pecunia to add (in cents).
* @param string $description The transaction description
*
* @return Wallet Self
*/
public function credit(int $amount, string $description = ''): Wallet
{
$this->balance += $amount;
$this->save();
Transaction::create(
[
'object_id' => $this->id,
'object_type' => Wallet::class,
'type' => Transaction::WALLET_CREDIT,
'amount' => $amount,
'description' => $description
]
);
return $this;
}
/**
* Deduct an amount of pecunia from this wallet's balance.
*
* @param int $amount The amount of pecunia to deduct (in cents).
* @param string $description The transaction description
* @param array $eTIDs List of transaction IDs for the individual entitlements
* that make up this debit record, if any.
* @return Wallet Self
*/
public function debit(int $amount, string $description = '', array $eTIDs = []): Wallet
{
if ($amount == 0) {
return $this;
}
$this->balance -= $amount;
$this->save();
$transaction = Transaction::create(
[
'object_id' => $this->id,
'object_type' => Wallet::class,
'type' => Transaction::WALLET_DEBIT,
'amount' => $amount * -1,
'description' => $description
]
);
if (!empty($eTIDs)) {
Transaction::whereIn('id', $eTIDs)->update(['transaction_id' => $transaction->id]);
}
return $this;
}
/**
* The discount assigned to the wallet.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function discount()
{
return $this->belongsTo(Discount::class, 'discount_id', 'id');
}
/**
* Entitlements billed to this wallet.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function entitlements()
{
return $this->hasMany(Entitlement::class);
}
/**
* Calculate the expected charges to this wallet.
*
* @return int
*/
public function expectedCharges()
{
return $this->chargeEntitlements(false);
}
/**
* Return the exact, numeric version of the discount to be applied.
*
* Ranges from 0 - 100.
*
* @return int
*/
public function getDiscount()
{
return $this->discount ? $this->discount->discount : 0;
}
/**
* The actual discount rate for use in multiplication
*
* Ranges from 0.00 to 1.00.
*/
public function getDiscountRate()
{
return (100 - $this->getDiscount()) / 100;
}
+ /**
+ * Check if the specified user is a controller to this wallet.
+ *
+ * @param \App\User $user The user object.
+ *
+ * @return bool True if the user is one of the wallet controllers (including user), False otherwise
+ */
+ public function isController(User $user): bool
+ {
+ return $user->id == $this->user_id || $this->controllers->contains($user);
+ }
+
/**
* A helper to display human-readable amount of money using
* the wallet currency and specified locale.
*
* @param int $amount A amount of money (in cents)
* @param string $locale A locale for the output
*
* @return string String representation, e.g. "9.99 CHF"
*/
public function money(int $amount, $locale = 'de_DE')
{
$amount = round($amount / 100, 2);
$nf = new \NumberFormatter($locale, \NumberFormatter::CURRENCY);
$result = $nf->formatCurrency($amount, $this->currency);
// Replace non-breaking space
return str_replace("\xC2\xA0", " ", $result);
}
/**
* The owner of the wallet -- the wallet is in his/her back pocket.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function owner()
{
return $this->belongsTo(User::class, 'user_id', 'id');
}
/**
* Payments on this wallet.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function payments()
{
return $this->hasMany(Payment::class);
}
/**
* Remove a controller from this wallet.
*
* @param \App\User $user The user to remove as a controller from this wallet.
*
* @return void
*/
public function removeController(User $user)
{
if ($this->controllers->contains($user)) {
$this->controllers()->detach($user);
}
}
/**
* Retrieve the transactions against this wallet.
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
public function transactions()
{
return Transaction::where(
[
'object_id' => $this->id,
'object_type' => Wallet::class
]
);
}
/**
* Force-update entitlements' updated_at, charge if needed.
*
* @param bool $withCost When enabled the cost will be charged
*
* @return int Charged amount in cents
*/
public function updateEntitlements($withCost = true): int
{
$charges = 0;
$discount = $this->getDiscountRate();
$now = Carbon::now();
DB::beginTransaction();
// used to parent individual entitlement billings to the wallet debit.
$entitlementTransactions = [];
foreach ($this->entitlements()->get() as $entitlement) {
$cost = 0;
$diffInDays = $entitlement->updated_at->diffInDays($now);
// This entitlement has been created less than or equal to 14 days ago (this is at
// maximum the fourteenth 24-hour period).
if ($entitlement->created_at > Carbon::now()->subDays(14)) {
// $cost=0
} elseif ($withCost && $diffInDays > 0) {
// The price per day is based on the number of days in the last month
// or the current month if the period does not overlap with the previous month
// FIXME: This really should be simplified to constant $daysInMonth=30
if ($now->day >= $diffInDays && $now->month == $entitlement->updated_at->month) {
$daysInMonth = $now->daysInMonth;
} else {
$daysInMonth = \App\Utils::daysInLastMonth();
}
$pricePerDay = $entitlement->cost / $daysInMonth;
$cost = (int) (round($pricePerDay * $discount * $diffInDays, 0));
}
if ($diffInDays > 0) {
$entitlement->updated_at = $entitlement->updated_at->setDateFrom($now);
$entitlement->save();
}
if ($cost == 0) {
continue;
}
$charges += $cost;
// FIXME: Shouldn't we store also cost=0 transactions (to have the full history)?
$entitlementTransactions[] = $entitlement->createTransaction(
Transaction::ENTITLEMENT_BILLED,
$cost
);
}
if ($charges > 0) {
$this->debit($charges, '', $entitlementTransactions);
}
DB::commit();
return $charges;
}
}
diff --git a/src/database/migrations/2021_10_27_120000_extend_openvidu_rooms_session_id.php b/src/database/migrations/2021_10_27_120000_extend_openvidu_rooms_session_id.php
index 8736bb55..4ac546be 100644
--- a/src/database/migrations/2021_10_27_120000_extend_openvidu_rooms_session_id.php
+++ b/src/database/migrations/2021_10_27_120000_extend_openvidu_rooms_session_id.php
@@ -1,39 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
// phpcs:ignore
class ExtendOpenviduRoomsSessionId extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table(
'openvidu_rooms',
function (Blueprint $table) {
$table->string('session_id', 36)->change();
}
);
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table(
'openvidu_rooms',
function (Blueprint $table) {
- $table->string('session_id', 16)->change();
+ // $table->string('session_id', 16)->change();
}
);
}
}
diff --git a/src/database/migrations/2022_05_13_100000_permissions_and_room_subscriptions.php b/src/database/migrations/2022_05_13_100000_permissions_and_room_subscriptions.php
new file mode 100644
index 00000000..18ede086
--- /dev/null
+++ b/src/database/migrations/2022_05_13_100000_permissions_and_room_subscriptions.php
@@ -0,0 +1,150 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+ /**
+ * Run the migrations.
+ */
+ public function up(): void
+ {
+ Schema::create(
+ 'permissions',
+ function (Blueprint $table) {
+ $table->string('id', 36)->primary();
+ $table->bigInteger('permissible_id');
+ $table->string('permissible_type');
+ $table->integer('rights')->default(0);
+ $table->string('user');
+ $table->timestamps();
+
+ $table->index('user');
+ $table->index(['permissible_id', 'permissible_type']);
+ }
+ );
+
+ Schema::table(
+ 'openvidu_rooms',
+ function (Blueprint $table) {
+ $table->bigInteger('tenant_id')->unsigned()->nullable();
+ $table->string('description')->nullable();
+ $table->softDeletes();
+
+ $table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('set null');
+ }
+ );
+
+ // Create the new SKUs
+ if (!\App\Sku::where('title', 'room')->first()) {
+ $sku = \App\Sku::create([
+ 'title' => 'group-room',
+ 'name' => 'Group conference room',
+ 'description' => 'Shareable audio & video conference room',
+ 'cost' => 0,
+ 'units_free' => 0,
+ 'period' => 'monthly',
+ 'handler_class' => 'App\Handlers\GroupRoom',
+ 'active' => true,
+ ]);
+
+ $sku = \App\Sku::create([
+ 'title' => 'room',
+ 'name' => 'Standard conference room',
+ 'description' => 'Audio & video conference room',
+ 'cost' => 0,
+ 'units_free' => 0,
+ 'period' => 'monthly',
+ 'handler_class' => 'App\Handlers\Room',
+ 'active' => true,
+ ]);
+
+ // Create the entitlement for every existing room
+ foreach (\App\Meet\Room::get() as $room) {
+ $user = \App\User::find($room->user_id); // @phpstan-ignore-line
+ if (!$user) {
+ $room->forceDelete();
+ continue;
+ }
+
+ // Set tenant_id
+ if ($user->tenant_id) {
+ $room->tenant_id = $user->tenant_id;
+ $room->save();
+ }
+
+ $wallet = $user->wallets()->first();
+
+ \App\Entitlement::create([
+ 'wallet_id' => $wallet->id,
+ 'sku_id' => $sku->id,
+ 'cost' => 0,
+ 'fee' => 0,
+ 'entitleable_id' => $room->id,
+ 'entitleable_type' => \App\Meet\Room::class
+ ]);
+ }
+ }
+
+ // Remove 'meet' SKU/entitlements
+ \App\Sku::where('title', 'meet')->delete();
+
+ Schema::table(
+ 'openvidu_rooms',
+ function (Blueprint $table) {
+ $table->dropForeign('openvidu_rooms_user_id_foreign');
+ $table->dropColumn('user_id');
+ }
+ );
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table(
+ 'openvidu_rooms',
+ function (Blueprint $table) {
+ $table->dropForeign('openvidu_rooms_tenant_id_foreign');
+ $table->dropColumn('tenant_id');
+ $table->dropColumn('description');
+ $table->dropSoftDeletes();
+
+ $table->bigInteger('user_id')->nullable();
+ $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
+ }
+ );
+
+ // Set user_id back
+ foreach (\App\Meet\Room::get() as $room) {
+ $wallet = $room->wallet();
+ if (!$wallet) {
+ $room->forceDelete();
+ continue;
+ }
+
+ $room->user_id = $wallet->user_id; // @phpstan-ignore-line
+ $room->save();
+ }
+
+ \App\Entitlement::where('entitleable_type', \App\Meet\Room::class)->forceDelete();
+ \App\Sku::where('title', 'room')->delete();
+ \App\Sku::where('title', 'group-room')->delete();
+
+ \App\Sku::create([
+ 'title' => 'meet',
+ 'name' => 'Voice & Video Conferencing (public beta)',
+ 'description' => 'Video conferencing tool',
+ 'cost' => 0,
+ 'units_free' => 0,
+ 'period' => 'monthly',
+ 'handler_class' => 'App\Handlers\Meet',
+ 'active' => true,
+ ]);
+
+ Schema::dropIfExists('permissions');
+ }
+};
diff --git a/src/database/seeds/local/MeetRoomSeeder.php b/src/database/seeds/local/MeetRoomSeeder.php
index 3fa9010b..b38bb27a 100644
--- a/src/database/seeds/local/MeetRoomSeeder.php
+++ b/src/database/seeds/local/MeetRoomSeeder.php
@@ -1,34 +1,39 @@
<?php
namespace Database\Seeds\Local;
-use App\Meet\Room;
use Illuminate\Database\Seeder;
class MeetRoomSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
$john = \App\User::where('email', 'john@kolab.org')->first();
- $jack = \App\User::where('email', 'jack@kolab.org')->first();
+ $wallet = $john->wallets()->first();
- \App\Meet\Room::create(
+ $rooms = [
[
- 'user_id' => $john->id,
- 'name' => 'john'
- ]
- );
-
- \App\Meet\Room::create(
+ 'name' => 'john',
+ 'description' => "Standard room"
+ ],
[
- 'user_id' => $jack->id,
- 'name' => strtolower(\App\Utils::randStr(3, 3, '-'))
+ 'name' => 'shared',
+ 'description' => "Shared room"
]
- );
+ ];
+
+ foreach ($rooms as $idx => $room) {
+ $room = \App\Meet\Room::create($room);
+ $rooms[$idx] = $room;
+ }
+
+ $rooms[0]->assignToWallet($wallet, 'room');
+ $rooms[1]->assignToWallet($wallet, 'group-room');
+ $rooms[1]->setConfig(['acl' => 'jack@kolab.org, full']);
}
}
diff --git a/src/database/seeds/local/SkuSeeder.php b/src/database/seeds/local/SkuSeeder.php
index fb6e09e2..0645b15a 100644
--- a/src/database/seeds/local/SkuSeeder.php
+++ b/src/database/seeds/local/SkuSeeder.php
@@ -1,314 +1,250 @@
<?php
namespace Database\Seeds\Local;
use App\Sku;
use Illuminate\Database\Seeder;
class SkuSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
- Sku::create(
+ $skus = [
[
'title' => 'mailbox',
'name' => 'User Mailbox',
'description' => 'Just a mailbox',
'cost' => 500,
'units_free' => 0,
'period' => 'monthly',
'handler_class' => 'App\Handlers\Mailbox',
'active' => true,
- ]
- );
-
- Sku::create(
+ ],
[
'title' => 'domain',
'name' => 'Hosted Domain',
'description' => 'Somewhere to place a mailbox',
'cost' => 100,
'period' => 'monthly',
'handler_class' => 'App\Handlers\Domain',
'active' => false,
- ]
- );
-
- Sku::create(
+ ],
[
'title' => 'domain-registration',
'name' => 'Domain Registration',
'description' => 'Register a domain with us',
'cost' => 101,
'period' => 'yearly',
'handler_class' => 'App\Handlers\DomainRegistration',
'active' => false,
- ]
- );
-
- Sku::create(
+ ],
[
'title' => 'domain-hosting',
'name' => 'External Domain',
'description' => 'Host a domain that is externally registered',
'cost' => 100,
'units_free' => 1,
'period' => 'monthly',
'handler_class' => 'App\Handlers\DomainHosting',
'active' => true,
- ]
- );
-
- Sku::create(
+ ],
[
'title' => 'domain-relay',
'name' => 'Domain Relay',
'description' => 'A domain you host at home, for which we relay email',
'cost' => 103,
'period' => 'monthly',
'handler_class' => 'App\Handlers\DomainRelay',
'active' => false,
- ]
- );
-
- Sku::create(
+ ],
[
'title' => 'storage',
'name' => 'Storage Quota',
'description' => 'Some wiggle room',
'cost' => 25,
'units_free' => 5,
'period' => 'monthly',
'handler_class' => 'App\Handlers\Storage',
'active' => true,
- ]
- );
-
- Sku::create(
+ ],
[
'title' => 'groupware',
'name' => 'Groupware Features',
'description' => 'Groupware functions like Calendar, Tasks, Notes, etc.',
'cost' => 490,
'units_free' => 0,
'period' => 'monthly',
'handler_class' => 'App\Handlers\Groupware',
'active' => true,
- ]
- );
-
- Sku::create(
+ ],
[
'title' => 'resource',
'name' => 'Resource',
'description' => 'Reservation taker',
'cost' => 101,
'period' => 'monthly',
'handler_class' => 'App\Handlers\Resource',
'active' => true,
- ]
- );
-
- Sku::create(
+ ],
[
'title' => 'shared-folder',
'name' => 'Shared Folder',
'description' => 'A shared folder',
'cost' => 89,
'period' => 'monthly',
'handler_class' => 'App\Handlers\SharedFolder',
'active' => true,
- ]
- );
-
- Sku::create(
+ ],
[
'title' => '2fa',
'name' => '2-Factor Authentication',
'description' => 'Two factor authentication for webmail and administration panel',
'cost' => 0,
'units_free' => 0,
'period' => 'monthly',
'handler_class' => 'App\Handlers\Auth2F',
'active' => true,
- ]
- );
-
- Sku::create(
+ ],
[
'title' => 'activesync',
'name' => 'Activesync',
'description' => 'Mobile synchronization',
'cost' => 0,
'units_free' => 0,
'period' => 'monthly',
'handler_class' => 'App\Handlers\Activesync',
'active' => true,
- ]
- );
-
- // Check existence because migration might have added this already
- $sku = Sku::where(['title' => 'beta', 'tenant_id' => \config('app.tenant_id')])->first();
-
- if (!$sku) {
- Sku::create(
- [
- 'title' => 'beta',
- 'name' => 'Private Beta (invitation only)',
- 'description' => 'Access to the private beta program features',
- 'cost' => 0,
- 'units_free' => 0,
- 'period' => 'monthly',
- 'handler_class' => 'App\Handlers\Beta',
- 'active' => false,
- ]
- );
- }
-
- // Check existence because migration might have added this already
- $sku = Sku::where(['title' => 'meet', 'tenant_id' => \config('app.tenant_id')])->first();
-
- if (!$sku) {
- Sku::create(
- [
- 'title' => 'meet',
- 'name' => 'Voice & Video Conferencing (public beta)',
- 'description' => 'Video conferencing tool',
- 'cost' => 0,
- 'units_free' => 0,
- 'period' => 'monthly',
- 'handler_class' => 'App\Handlers\Meet',
- 'active' => true,
- ]
- );
+ ],
+ [
+ 'title' => 'beta',
+ 'name' => 'Private Beta (invitation only)',
+ 'description' => 'Access to the private beta program features',
+ 'cost' => 0,
+ 'units_free' => 0,
+ 'period' => 'monthly',
+ 'handler_class' => 'App\Handlers\Beta',
+ 'active' => false,
+ ],
+ [
+ 'title' => 'group',
+ 'name' => 'Group',
+ 'description' => 'Distribution list',
+ 'cost' => 0,
+ 'units_free' => 0,
+ 'period' => 'monthly',
+ 'handler_class' => 'App\Handlers\Group',
+ 'active' => true,
+ ],
+ [
+ 'title' => 'group-room',
+ 'name' => 'Group conference room',
+ 'description' => 'Shareable audio & video conference room',
+ 'cost' => 0,
+ 'units_free' => 0,
+ 'period' => 'monthly',
+ 'handler_class' => 'App\Handlers\GroupRoom',
+ 'active' => true,
+ ],
+ [
+ 'title' => 'room',
+ 'name' => 'Standard conference room',
+ 'description' => 'Audio & video conference room',
+ 'cost' => 0,
+ 'units_free' => 0,
+ 'period' => 'monthly',
+ 'handler_class' => 'App\Handlers\Room',
+ 'active' => true,
+ ],
+ ];
+
+ foreach ($skus as $sku) {
+ // Check existence because migration might have added this already
+ if (!Sku::where('title', $sku['title'])->where('tenant_id', \config('app.tenant_id'))->first()) {
+ Sku::create($sku);
+ }
}
- // Check existence because migration might have added this already
- $sku = Sku::where(['title' => 'group', 'tenant_id' => \config('app.tenant_id')])->first();
-
- if (!$sku) {
- Sku::create(
- [
- 'title' => 'group',
- 'name' => 'Group',
- 'description' => 'Distribution list',
- 'cost' => 0,
- 'units_free' => 0,
- 'period' => 'monthly',
- 'handler_class' => 'App\Handlers\Group',
- 'active' => true,
- ]
- );
- }
+ $skus = [
+ [
+ 'title' => 'mailbox',
+ 'name' => 'User Mailbox',
+ 'description' => 'Just a mailbox',
+ 'cost' => 500,
+ 'fee' => 333,
+ 'units_free' => 0,
+ 'period' => 'monthly',
+ 'handler_class' => 'App\Handlers\Mailbox',
+ 'active' => true,
+ ],
+ [
+ 'title' => 'storage',
+ 'name' => 'Storage Quota',
+ 'description' => 'Some wiggle room',
+ 'cost' => 25,
+ 'fee' => 16,
+ 'units_free' => 5,
+ 'period' => 'monthly',
+ 'handler_class' => 'App\Handlers\Storage',
+ 'active' => true,
+ ],
+ [
+ 'title' => 'domain-hosting',
+ 'name' => 'External Domain',
+ 'description' => 'Host a domain that is externally registered',
+ 'cost' => 100,
+ 'fee' => 66,
+ 'units_free' => 1,
+ 'period' => 'monthly',
+ 'handler_class' => 'App\Handlers\DomainHosting',
+ 'active' => true,
+ ],
+ [
+ 'title' => 'groupware',
+ 'name' => 'Groupware Features',
+ 'description' => 'Groupware functions like Calendar, Tasks, Notes, etc.',
+ 'cost' => 490,
+ 'fee' => 327,
+ 'units_free' => 0,
+ 'period' => 'monthly',
+ 'handler_class' => 'App\Handlers\Groupware',
+ 'active' => true,
+ ],
+ [
+ 'title' => '2fa',
+ 'name' => '2-Factor Authentication',
+ 'description' => 'Two factor authentication for webmail and administration panel',
+ 'cost' => 0,
+ 'units_free' => 0,
+ 'period' => 'monthly',
+ 'handler_class' => 'App\Handlers\Auth2F',
+ 'active' => true,
+ ],
+ [
+ 'title' => 'activesync',
+ 'name' => 'Activesync',
+ 'description' => 'Mobile synchronization',
+ 'cost' => 0,
+ 'units_free' => 0,
+ 'period' => 'monthly',
+ 'handler_class' => 'App\Handlers\Activesync',
+ 'active' => true,
+ ],
+ ];
// for tenants that are not the configured tenant id
$tenants = \App\Tenant::where('id', '!=', \config('app.tenant_id'))->get();
foreach ($tenants as $tenant) {
- $sku = Sku::create(
- [
- 'title' => 'mailbox',
- 'name' => 'User Mailbox',
- 'description' => 'Just a mailbox',
- 'cost' => 500,
- 'fee' => 333,
- 'units_free' => 0,
- 'period' => 'monthly',
- 'handler_class' => 'App\Handlers\Mailbox',
- 'active' => true,
- ]
- );
-
- $sku->tenant_id = $tenant->id;
- $sku->save();
-
- $sku = Sku::create(
- [
- 'title' => 'storage',
- 'name' => 'Storage Quota',
- 'description' => 'Some wiggle room',
- 'cost' => 25,
- 'fee' => 16,
- 'units_free' => 5,
- 'period' => 'monthly',
- 'handler_class' => 'App\Handlers\Storage',
- 'active' => true,
- ]
- );
-
- $sku->tenant_id = $tenant->id;
- $sku->save();
-
- $sku = Sku::create(
- [
- 'title' => 'domain-hosting',
- 'name' => 'External Domain',
- 'description' => 'Host a domain that is externally registered',
- 'cost' => 100,
- 'fee' => 66,
- 'units_free' => 1,
- 'period' => 'monthly',
- 'handler_class' => 'App\Handlers\DomainHosting',
- 'active' => true,
- ]
- );
-
- $sku->tenant_id = $tenant->id;
- $sku->save();
-
- $sku = Sku::create(
- [
- 'title' => 'groupware',
- 'name' => 'Groupware Features',
- 'description' => 'Groupware functions like Calendar, Tasks, Notes, etc.',
- 'cost' => 490,
- 'fee' => 327,
- 'units_free' => 0,
- 'period' => 'monthly',
- 'handler_class' => 'App\Handlers\Groupware',
- 'active' => true,
- ]
- );
-
- $sku->tenant_id = $tenant->id;
- $sku->save();
-
- $sku = Sku::create(
- [
- 'title' => '2fa',
- 'name' => '2-Factor Authentication',
- 'description' => 'Two factor authentication for webmail and administration panel',
- 'cost' => 0,
- 'units_free' => 0,
- 'period' => 'monthly',
- 'handler_class' => 'App\Handlers\Auth2F',
- 'active' => true,
- ]
- );
-
- $sku->tenant_id = $tenant->id;
- $sku->save();
-
- $sku = Sku::create(
- [
- 'title' => 'activesync',
- 'name' => 'Activesync',
- 'description' => 'Mobile synchronization',
- 'cost' => 0,
- 'units_free' => 0,
- 'period' => 'monthly',
- 'handler_class' => 'App\Handlers\Activesync',
- 'active' => true,
- ]
- );
-
- $sku->tenant_id = $tenant->id;
- $sku->save();
+ foreach ($skus as $sku) {
+ $sku = Sku::create($sku);
+ $sku->tenant_id = $tenant->id;
+ $sku->save();
+ }
}
}
}
diff --git a/src/database/seeds/production/SkuSeeder.php b/src/database/seeds/production/SkuSeeder.php
index f239d16e..99dbb501 100644
--- a/src/database/seeds/production/SkuSeeder.php
+++ b/src/database/seeds/production/SkuSeeder.php
@@ -1,203 +1,172 @@
<?php
namespace Database\Seeds\Production;
use App\Sku;
use Illuminate\Database\Seeder;
class SkuSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
- Sku::create(
+ $skus = [
[
'title' => 'mailbox',
'name' => 'User Mailbox',
'description' => 'Just a mailbox',
'cost' => 444,
'units_free' => 0,
'period' => 'monthly',
'handler_class' => 'App\Handlers\Mailbox',
'active' => true,
- ]
- );
-
- Sku::create(
+ ],
[
'title' => 'domain',
'name' => 'Hosted Domain',
'description' => 'Somewhere to place a mailbox',
'cost' => 100,
'period' => 'monthly',
'handler_class' => 'App\Handlers\Domain',
'active' => false,
- ]
- );
-
- Sku::create(
+ ],
[
'title' => 'domain-registration',
'name' => 'Domain Registration',
'description' => 'Register a domain with us',
'cost' => 101,
'period' => 'yearly',
'handler_class' => 'App\Handlers\DomainRegistration',
'active' => false,
- ]
- );
-
- Sku::create(
+ ],
[
'title' => 'domain-hosting',
'name' => 'External Domain',
'description' => 'Host a domain that is externally registered',
'cost' => 100,
'units_free' => 1,
'period' => 'monthly',
'handler_class' => 'App\Handlers\DomainHosting',
'active' => true,
- ]
- );
-
- Sku::create(
+ ],
[
'title' => 'domain-relay',
'name' => 'Domain Relay',
'description' => 'A domain you host at home, for which we relay email',
'cost' => 103,
'period' => 'monthly',
'handler_class' => 'App\Handlers\DomainRelay',
'active' => false,
- ]
- );
-
- Sku::create(
+ ],
[
'title' => 'storage',
'name' => 'Storage Quota',
'description' => 'Some wiggle room',
'cost' => 50,
'units_free' => 2,
'period' => 'monthly',
'handler_class' => 'App\Handlers\Storage',
'active' => true,
- ]
- );
-
- Sku::create(
+ ],
[
'title' => 'groupware',
'name' => 'Groupware Features',
'description' => 'Groupware functions like Calendar, Tasks, Notes, etc.',
'cost' => 555,
'units_free' => 0,
'period' => 'monthly',
'handler_class' => 'App\Handlers\Groupware',
'active' => true,
- ]
- );
-
- Sku::create(
+ ],
[
'title' => 'resource',
'name' => 'Resource',
'description' => 'Reservation taker',
'cost' => 0,
'period' => 'monthly',
'handler_class' => 'App\Handlers\Resource',
'active' => true,
- ]
- );
-
- Sku::create(
+ ],
[
'title' => 'shared-folder',
'name' => 'Shared Folder',
'description' => 'A shared folder',
'cost' => 89,
'period' => 'monthly',
'handler_class' => 'App\Handlers\SharedFolder',
'active' => false,
- ]
- );
-
- Sku::create(
+ ],
[
'title' => '2fa',
'name' => '2-Factor Authentication',
'description' => 'Two factor authentication for webmail and administration panel',
'cost' => 0,
'units_free' => 0,
'period' => 'monthly',
'handler_class' => 'App\Handlers\Auth2F',
'active' => true,
- ]
- );
-
- Sku::create(
+ ],
[
'title' => 'activesync',
'name' => 'Activesync',
'description' => 'Mobile synchronization',
'cost' => 100,
'units_free' => 0,
'period' => 'monthly',
'handler_class' => 'App\Handlers\Activesync',
'active' => true,
- ]
- );
-
- // Check existence because migration might have added this already
- if (!Sku::where('title', 'beta')->first()) {
- Sku::create(
- [
- 'title' => 'beta',
- 'name' => 'Private Beta (invitation only)',
- 'description' => 'Access to the private beta program subscriptions',
- 'cost' => 0,
- 'units_free' => 0,
- 'period' => 'monthly',
- 'handler_class' => 'App\Handlers\Beta',
- 'active' => false,
- ]
- );
- }
-
- // Check existence because migration might have added this already
- if (!Sku::where('title', 'meet')->first()) {
- Sku::create(
- [
- 'title' => 'meet',
- 'name' => 'Voice & Video Conferencing (public beta)',
- 'description' => 'Video conferencing tool',
- 'cost' => 0,
- 'units_free' => 0,
- 'period' => 'monthly',
- 'handler_class' => 'App\Handlers\Meet',
- 'active' => true,
- ]
- );
- }
-
- // Check existence because migration might have added this already
- if (!Sku::where('title', 'group')->first()) {
- Sku::create(
- [
- 'title' => 'group',
- 'name' => 'Group',
- 'description' => 'Distribution list',
- 'cost' => 0,
- 'units_free' => 0,
- 'period' => 'monthly',
- 'handler_class' => 'App\Handlers\Group',
- 'active' => true,
- ]
- );
+ ],
+ [
+ 'title' => 'beta',
+ 'name' => 'Private Beta (invitation only)',
+ 'description' => 'Access to the private beta program subscriptions',
+ 'cost' => 0,
+ 'units_free' => 0,
+ 'period' => 'monthly',
+ 'handler_class' => 'App\Handlers\Beta',
+ 'active' => false,
+ ],
+ [
+ 'title' => 'group',
+ 'name' => 'Group',
+ 'description' => 'Distribution list',
+ 'cost' => 0,
+ 'units_free' => 0,
+ 'period' => 'monthly',
+ 'handler_class' => 'App\Handlers\Group',
+ 'active' => true,
+ ],
+ [
+ 'title' => 'group-room',
+ 'name' => 'Group conference room',
+ 'description' => 'Shareable audio & video conference room',
+ 'cost' => 0,
+ 'units_free' => 0,
+ 'period' => 'monthly',
+ 'handler_class' => 'App\Handlers\GroupRoom',
+ 'active' => true,
+ ],
+ [
+ 'title' => 'room',
+ 'name' => 'Standard conference room',
+ 'description' => 'Audio & video conference room',
+ 'cost' => 0,
+ 'units_free' => 0,
+ 'period' => 'monthly',
+ 'handler_class' => 'App\Handlers\Room',
+ 'active' => true,
+ ],
+ ];
+
+ foreach ($skus as $sku) {
+ // Check existence because migration might have added this already
+ if (!Sku::where('title', $sku['title'])->first()) {
+ Sku::create($sku);
+ }
}
}
}
diff --git a/src/resources/js/meet/room.js b/src/resources/js/meet/room.js
index ee28823f..72fe1c64 100644
--- a/src/resources/js/meet/room.js
+++ b/src/resources/js/meet/room.js
@@ -1,1099 +1,1098 @@
'use strict'
import { Client } from './client.js'
import { Roles } from './constants.js'
import { Dropdown } from 'bootstrap'
import { library } from '@fortawesome/fontawesome-svg-core'
import linkifyStr from 'linkify-string'
function Room(container)
{
let sessionData // Room session metadata
let peers = {} // Participants in the session (including self)
let publishersContainer // Container element for publishers
let subscribersContainer // Container element for subscribers
let selfId // peer Id of the current user
let chatCount = 0
let scrollStop
let $t
let $toast
const client = new Client()
// Disconnect participant when browser's window close
window.addEventListener('beforeunload', () => {
leaveRoom()
})
window.addEventListener('resize', resize)
// Public methods
this.isScreenSharingSupported = isScreenSharingSupported
this.joinRoom = joinRoom
this.leaveRoom = leaveRoom
this.raiseHand = raiseHand
this.setupStart = setupStart
this.setupStop = setupStop
this.setupSetAudioDevice = setupSetAudioDevice
this.setupSetVideoDevice = setupSetVideoDevice
this.switchAudio = switchAudio
this.switchChannel = switchChannel
this.switchScreen = switchScreen
this.switchVideo = switchVideo
this.getStats = getStats
/**
* Join the room session
*
* @param data Session metadata and event handlers:
* token - A token for the main connection,
* nickname - Participant name,
* languages - Supported languages (code-to-label map)
* chatElement - DOM element for the chat widget,
* counterElement - DOM element for the participants counter,
* menuElement - DOM element of the room toolbar,
* queueElement - DOM element for the Q&A queue (users with a raised hand)
* onSuccess - Callback for session connection (join) success
* onError - Callback for session connection (join) error
* onDestroy - Callback for session disconnection event,
* onMediaSetup - Called when user clicks the Media setup button
* onUpdate - Callback for current user/session update,
* toast - Toast widget
* translate - Translation function
*/
function joinRoom(data) {
// Create a container for subscribers and publishers
publishersContainer = $('<div id="meet-publishers">').appendTo(container).get(0)
subscribersContainer = $('<div id="meet-subscribers">').appendTo(container).get(0)
resize()
$t = data.translate
$toast = data.toast
// Make sure all supported callbacks exist, so we don't have to check
// their existence everywhere anymore
let events = ['Success', 'Error', 'Destroy', 'Update', 'MediaSetup']
events.map(event => 'on' + event).forEach(event => {
if (!data[event]) {
data[event] = () => {}
}
})
sessionData = data
// Handle new participants (including self)
client.on('addPeer', (event) => {
if (event.isSelf) {
selfId = event.id
}
peers[event.id] = event
event.element = participantCreate(event, event.videoElement)
if (event.screenVideoElement && !event.screen) {
const screen = { id: event.id, role: event.role | Roles.SCREEN, nickname: event.nickname }
event.screen = participantCreate(screen, event.screenVideoElement)
}
if (event.raisedHand) {
peerHandUp(event)
}
})
// Handle removed participants
client.on('removePeer', (peerId) => {
let peer = peers[peerId]
if (peer) {
// Remove elements related to the participant
peerHandDown(peer)
$(peer.element).remove()
if (peer.screen) {
$(peer.screen).remove()
}
delete peers[peerId]
}
resize()
})
// Participant properties changed e.g. audio/video muted/unmuted
client.on('updatePeer', (event, changed) => {
let peer = peers[event.id]
if (!peer) {
return
}
event.element = peer.element
event.screen = peer.screen
// Video element added or removed
if (event.videoElement && event.videoElement.parentNode != event.element) {
$(event.element).prepend(event.videoElement)
} else if (!event.videoElement) {
$(event.element).find('video').remove()
}
// Video element of the shared screen added or removed
if (event.screenVideoElement && !event.screen) {
const screen = { id: event.id, role: event.role | Roles.SCREEN, nickname: event.nickname }
event.screen = participantCreate(screen, event.screenVideoElement)
} else if (!event.screenVideoElement && event.screen) {
$(event.screen).remove()
event.screen = null
resize()
}
peers[event.id] = event
if (changed && changed.length) {
if (changed && changed.includes('nickname')) {
nicknameUpdate(event.nickname, event.id)
}
if (changed.includes('raisedHand')) {
if (event.raisedHand) {
peerHandUp(event)
} else {
peerHandDown(event)
}
}
if (changed && changed.includes('screenWidth')) {
resize()
return
}
}
if (changed && changed.includes('interpreterRole')
&& !event.isSelf && $(event.element).find('video').length
) {
// Publisher-to-interpreter or vice-versa, move element to the subscribers list or vice-versa,
// but keep the existing video element
let wrapper = participantCreate(event, $(event.element).find('video'))
event.element.remove()
event.element = wrapper
} else if (changed && changed.includes('publisherRole') && !event.language) {
// Handle publisher-to-subscriber and subscriber-to-publisher change
event.element.remove()
event.element = participantCreate(event, event.videoElement)
} else {
participantUpdate(event.element, event)
}
// It's me, got publisher role
if (event.isSelf && (event.role & Roles.PUBLISHER) && changed && changed.includes('publisherRole')) {
// Open the media setup dialog
sessionData.onMediaSetup()
}
if (changed && changed.includes('moderatorRole')) {
participantUpdateAll()
}
})
// Handle successful connection to the room
client.on('joinSuccess', () => {
data.onSuccess()
client.media.setupStop()
})
// Handle join requests from other users (knocking to the room)
client.on('joinRequest', event => {
joinRequest(event)
})
// Handle session disconnection events
client.on('closeSession', event => {
// Notify the UI
data.onDestroy(event)
// Remove all participant elements
Object.keys(peers).forEach(peerId => {
$(peers[peerId].element).remove()
})
peers = {}
// refresh the matrix
resize()
})
// Handle session update events (e.g. channel, channels list changes)
client.on('updateSession', event => {
// Inform the vue component, so it can update some UI controls
sessionData.onUpdate(event)
})
const { audioSource, videoSource } = client.media.setupData()
// Start the session
client.joinSession(data.token, { videoSource, audioSource, nickname: data.nickname })
// Prepare the chat
initChat()
}
async function getStats() {
return await client.getStats()
}
/**
* Leave the room (disconnect)
*/
function leaveRoom(forced) {
client.closeSession(forced)
peers = {}
}
/**
* Handler for an event received by the moderator when a participant
* is asking for a permission to join the room
*/
function joinRequest(data) {
const id = data.requestId
// The toast for this user request already exists, ignore
// It's not really needed as we do this on server-side already
if ($('#i' + id).length) {
return
}
const body = $(
`<div>`
+ `<div class="picture"><img src="${data.picture}"></div>`
+ `<div class="content">`
+ `<p class="mb-2"></p>`
+ `<div class="text-end">`
+ `<button type="button" class="btn btn-sm btn-success accept">${$t('btn.accept')}</button>`
+ `<button type="button" class="btn btn-sm btn-danger deny ms-2">${$t('btn.deny')}</button>`
)
$toast.message({
className: 'join-request',
icon: 'user',
timeout: 0,
title: $t('meet.join-request'),
// titleClassName: '',
body: body.html(),
onShow: element => {
$(element).find('p').text($t('meet.join-requested', { user: data.nickname || '' }))
// add id attribute, so we can identify it
$(element).attr('id', 'i' + id)
// add action to the buttons
.find('button.accept,button.deny').on('click', e => {
const action = $(e.target).is('.accept') ? 'Accept' : 'Deny'
client['joinRequest' + action](id)
$('#i' + id).remove()
})
}
})
}
/**
* Raise or lower the hand
*
* @param status Hand raised or not
*/
async function raiseHand(status) {
return await client.raiseHand(status)
}
/**
* Sets the audio and video devices for the session.
* This will ask user for permission to access media devices.
*
* @param props Setup properties (videoElement, volumeElement, onSuccess, onError)
*/
async function setupStart(props) {
client.media.setupStart(props)
// When setting up devices while the session is ongoing we have to
// disable currently selected devices (temporarily) otherwise e.g.
// changing a mic or camera to another device will not be possible.
if (client.isJoined()) {
client.setMic('')
client.setCamera('')
}
}
/**
* Stop the setup "process", cleanup after it.
*/
async function setupStop() {
client.media.setupStop()
// Apply device changes to the client
const { audioSource, videoSource } = client.media.setupData()
await client.setMic(audioSource)
await client.setCamera(videoSource)
}
/**
* Change the publisher audio device
*
* @param deviceId Device identifier string
*/
async function setupSetAudioDevice(deviceId) {
return await client.media.setupSetAudio(deviceId)
}
/**
* Change the publisher video device
*
* @param deviceId Device identifier string
*/
async function setupSetVideoDevice(deviceId) {
return await client.media.setupSetVideo(deviceId)
}
/**
* Setup the chat UI
*/
function initChat() {
// Handle arriving chat messages
client.on('chatMessage', pushChatMessage)
// The UI elements are created in the vue template
// Here we add a logic for how they work
const chat = $(sessionData.chatElement).find('.chat').get(0)
const textarea = $(sessionData.chatElement).find('textarea')
const button = $(sessionData.menuElement).find('.link-chat')
textarea.on('keydown', e => {
if (e.keyCode == 13 && !e.shiftKey) {
if (textarea.val().length) {
client.chatMessage(textarea.val())
textarea.val('')
}
return false
}
})
// Add an element for the count of unread messages on the chat button
button.append('<span class="badge bg-dark blinker">')
.on('click', () => {
button.find('.badge').text('')
chatCount = 0
// When opening the chat scroll it to the bottom, or we shouldn't?
scrollStop = false
chat.scrollTop = chat.scrollHeight
})
$(chat).on('scroll', event => {
// Detect manual scrollbar moves, disable auto-scrolling until
// the scrollbar is positioned on the element bottom again
scrollStop = chat.scrollTop + chat.offsetHeight < chat.scrollHeight
})
}
/**
* Add a message to the chat
*
* @param data Object with a message, nickname, id (of the connection, empty for self)
*/
function pushChatMessage(data) {
let message = $('<span>').text(data.message).text() // make the message secure
// Format the message, convert emails and urls to links
message = linkifyStr(message, {
nl2br: true,
rel: 'noreferrer',
target: '_blank',
truncate: 25,
// Skip links that don't begin with a protocol
// e.g., "http://github.com" will be linkified, but "github.com" will not.
validate: {
url: value => /^https?:\/\//.test(value)
}
})
// Display the message
let chat = $(sessionData.chatElement).find('.chat')
let box = chat.find('.message').last()
message = $('<div>').html(message)
if (box.length && box.data('id') == data.peerId) {
// A message from the same user as the last message, no new box needed
message.appendTo(box)
} else {
box = $('<div class="message">').data('id', data.peerId)
.append($('<div class="nickname">').text(data.nickname || ''))
.append(message)
.appendTo(chat)
if (data.isSelf) {
box.addClass('self')
}
}
// Count unread messages
if (!$(sessionData.chatElement).is('.open')) {
if (!data.isSelf) {
chatCount++
}
} else {
chatCount = 0
}
$(sessionData.menuElement).find('.link-chat .badge').text(chatCount ? chatCount : '')
// Scroll the chat element to the end
if (!scrollStop) {
chat.get(0).scrollTop = chat.get(0).scrollHeight
}
}
/**
* Switch interpreted language channel
*
* @param channel Two-letter language code
*/
function switchChannel(channel) {
client.setLanguageChannel(channel)
}
/**
* Mute/Unmute audio for current session publisher
*/
async function switchAudio() {
const isActive = client.micStatus()
if (isActive) {
return await client.micMute()
} else {
return await client.micUnmute()
}
}
/**
* Mute/Unmute video for current session publisher
*/
async function switchVideo() {
const isActive = client.camStatus()
if (isActive) {
return await client.camMute()
} else {
return await client.camUnmute()
}
}
/**
* Switch on/off screen sharing
*/
async function switchScreen() {
const isActive = client.screenStatus()
if (isActive) {
return await client.screenUnshare()
} else {
return await client.screenShare()
}
}
/**
* Detect if screen sharing is supported by the browser
*/
function isScreenSharingSupported() {
return !!(navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia)
}
/**
* Handler for Hand-Up "signal"
*/
function peerHandUp(peer) {
let element = $(nicknameWidget(peer))
participantUpdate(element, peer)
element.attr('id', 'qa' + peer.id).appendTo($(sessionData.queueElement).show())
setTimeout(() => element.addClass('wiggle'), 50)
}
/**
* Handler for Hand-Down "signal"
*/
function peerHandDown(peer) {
let list = $(sessionData.queueElement)
list.find('#qa' + peer.id).remove()
if (!list.find('.meet-nickname').length) {
list.hide()
}
}
/**
* Update participant nickname in the UI
*
* @param nickname Nickname
* @param peerId Connection identifier of the user
*/
function nicknameUpdate(nickname, peerId) {
if (peerId) {
$(sessionData.chatElement).find('.chat').find('.message').each(function() {
let elem = $(this)
if (elem.data('id') == peerId) {
elem.find('.nickname').text(nickname || '')
}
})
$(sessionData.queueElement).find('#qa' + peerId + ' .content').text(nickname || '')
// Also update the nickname for the shared screen as we do not call
// participantUpdate() for this element
$('#screen-' + peerId).find('.meet-nickname .content').text(nickname || '')
}
}
/**
* Create a participant element in the matrix. Depending on the peer role
* parameter it will be a video element wrapper inside the matrix or a simple
* tag-like element on the subscribers list.
*
* @param params Peer metadata/params
* @param content Optional content to prepend to the element, e.g. video element
*
* @return The element
*/
function participantCreate(params, content) {
let element
if ((!params.language && params.role & Roles.PUBLISHER) || params.role & Roles.SCREEN) {
// publishers and shared screens
element = publisherCreate(params, content)
} else {
// subscribers and language interpreters
element = subscriberCreate(params, content)
}
setTimeout(resize, 50)
return element
}
/**
* Create a <video> element wrapper with controls
*
* @param params Connection metadata/params
* @param content Optional content to prepend to the element
*/
function publisherCreate(params, content) {
let isScreen = params.role & Roles.SCREEN
// Create the element
let wrapper = $(
'<div class="meet-video">'
+ svgIcon('user', 'fas', 'watermark')
+ '<div class="controls">'
+ '<button type="button" class="btn btn-link link-setup hidden" title="' + $t('meet.media-setup') + '">' + svgIcon('gear') + '</button>'
+ '<div class="volume hidden"><input type="range" min="0" max="1" step="0.1" /></div>'
+ '<button type="button" class="btn btn-link link-audio hidden" title="' + $t('meet.menu-audio-mute') + '">' + svgIcon('volume-xmark') + '</button>'
+ '<button type="button" class="btn btn-link link-fullscreen closed hidden" title="' + $t('meet.menu-fullscreen') + '">' + svgIcon('expand') + '</button>'
+ '<button type="button" class="btn btn-link link-fullscreen open hidden" title="' + $t('meet.menu-fullscreen') + '">' + svgIcon('compress') + '</button>'
+ '</div>'
+ '<div class="status">'
+ '<span class="bg-warning status-audio hidden">' + svgIcon('microphone-slash') + '</span>'
+ '<span class="bg-warning status-video hidden">' + svgIcon('video-slash') + '</span>'
+ '</div>'
+ '</div>'
)
// Append the nickname widget
wrapper.find('.controls').before(nicknameWidget(params))
if (content) {
wrapper.prepend(content)
}
if (isScreen) {
wrapper.addClass('screen')
}
if (params.isSelf) {
wrapper.find('.link-setup').removeClass('hidden').on('click', () => sessionData.onMediaSetup())
} else if (!isScreen) {
let volumeInput = wrapper.find('.volume input')
let audioButton = wrapper.find('.link-audio')
let inVolume = false
let hideVolumeTimeout
let hideVolume = () => {
if (inVolume) {
hideVolumeTimeout = setTimeout(hideVolume, 1000)
} else {
volumeInput.parent().addClass('hidden')
}
}
let setVolume = (video, volume) => {
video.volume = volume
video.muted = volume == 0
audioButton[video.muted ? 'addClass' : 'removeClass']('text-danger')
client[video.muted ? 'peerMicMute' : 'peerMicUnmute'](params.id)
}
// Enable and set up the audio mute button
audioButton.removeClass('hidden')
.on('click', e => {
let video = wrapper.find('video')[0]
setVolume(video, !video.muted ? 0 : 1)
volumeInput.val(video.volume)
})
// Show the volume slider when mouse is over the audio mute/unmute button
.on('mouseenter', () => {
let video = wrapper.find('video')[0]
clearTimeout(hideVolumeTimeout)
volumeInput.parent().removeClass('hidden')
volumeInput.val(video.volume)
})
.on('mouseleave', () => {
hideVolumeTimeout = setTimeout(hideVolume, 1000)
})
// Set up the audio volume control
volumeInput
.on('mouseenter', () => { inVolume = true })
.on('mouseleave', () => { inVolume = false })
.on('change input', () => {
setVolume(wrapper.find('video')[0], volumeInput.val())
})
}
participantUpdate(wrapper, params)
// Fullscreen control
if (document.fullscreenEnabled) {
wrapper.find('.link-fullscreen.closed').removeClass('hidden')
.on('click', () => {
wrapper.get(0).requestFullscreen()
})
wrapper.find('.link-fullscreen.open')
.on('click', () => {
document.exitFullscreen()
})
wrapper.on('fullscreenchange', () => {
// const enabled = document.fullscreenElement
wrapper.find('.link-fullscreen').toggleClass('hidden')
})
}
// Remove the subscriber element, if exists
$('#subscriber-' + params.id).remove()
let prio = params.isSelf || (isScreen && !$(publishersContainer).children('.screen').length)
return wrapper[prio ? 'prependTo' : 'appendTo'](publishersContainer)
.attr('id', (isScreen ? 'screen-' : 'publisher-') + params.id)
.get(0)
}
/**
* Update the publisher/subscriber element controls
*
* @param wrapper The wrapper element
* @param params Peer metadata/params
*/
function participantUpdate(wrapper, params) {
const element = $(wrapper)
const isSelf = params.isSelf
const rolePublisher = params.role & Roles.PUBLISHER
const roleModerator = params.role & Roles.MODERATOR
const roleScreen = params.role & Roles.SCREEN
const roleOwner = params.role & Roles.OWNER
const roleInterpreter = rolePublisher && !!params.language
const audioActive = roleScreen ? true : params.audioActive
const videoActive = roleScreen ? true : params.videoActive
element.find('.status-audio')[audioActive ? 'addClass' : 'removeClass']('hidden')
element.find('.status-video')[videoActive ? 'addClass' : 'removeClass']('hidden')
element.find('.meet-nickname > .content').text(params.nickname || '')
if (isSelf) {
element.addClass('self')
} else if (!roleScreen) {
element.find('.link-audio').removeClass('hidden')
}
const isModerator = peers[selfId] && peers[selfId].role & Roles.MODERATOR
const withPerm = isModerator && !roleScreen && !(roleOwner && !isSelf)
const withMenu = isSelf || (isModerator && !roleOwner)
if (isModerator) {
element.addClass('moderated')
}
// TODO: This probably could be better done with css
let elements = {
- '.dropdown-menu': withMenu,
'.permissions': withPerm,
'.interpreting': withPerm && rolePublisher,
'svg.moderator': roleModerator,
'svg.user': !roleModerator && !roleInterpreter,
'svg.interpreter': !roleModerator && roleInterpreter
}
Object.keys(elements).forEach(key => {
element.find(key)[elements[key] ? 'removeClass' : 'addClass']('hidden')
})
element.find('.action-role-publisher input').prop('checked', rolePublisher)
element.find('.action-role-moderator input').prop('checked', roleModerator)
.prop('disabled', roleOwner)
element.find('.interpreting select').val(roleInterpreter ? params.language : '')
}
/**
* Update/refresh state of all participants' elements
*/
function participantUpdateAll() {
Object.keys(peers).forEach(peerId => {
const peer = peers[peerId]
participantUpdate(peer.element, peer)
})
}
/**
* Create a tag-like element for a subscriber participant
*
* @param params Connection metadata/params
* @param content Optional content to prepend to the element
*/
function subscriberCreate(params, content) {
// Create the element
let wrapper = $('<div class="meet-subscriber">').append(nicknameWidget(params))
if (content) {
wrapper.prepend(content)
}
participantUpdate(wrapper, params)
// Two event handlers below fix the dropdown in the subscribers list.
// Normally it gets hidden because of the overflow/height of the container.
// FIXME: I think it wasn't needed in BS4, but it's a problem with BS5.
const nickname = wrapper.children('.dropdown')[0]
nickname.addEventListener('show.bs.dropdown', () => {
$(subscribersContainer).css({
'overflow-y': 'unset',
height: $(subscribersContainer).height() + 'px'
})
})
nickname.addEventListener('hide.bs.dropdown', () => {
$(subscribersContainer).css({
'overflow-y': 'auto',
height: 'unset'
})
})
return wrapper[params.isSelf ? 'prependTo' : 'appendTo'](subscribersContainer)
.attr('id', 'subscriber-' + params.id)
.get(0)
}
/**
* Create a tag-like nickname widget
*
* @param object params Peer metadata/params
*/
function nicknameWidget(params) {
let languages = []
// Append languages selection options
Object.keys(sessionData.languages).forEach(code => {
languages.push(`<option value="${code}">${$t(sessionData.languages[code])}</option>`)
})
// Create the element
let element = $(
'<div class="dropdown">'
+ '<a href="#" class="meet-nickname btn" aria-haspopup="true" aria-expanded="false" role="button">'
+ '<span class="content"></span>'
+ '<span class="icon">'
+ svgIcon('user', null, 'user')
+ svgIcon('crown', null, 'moderator hidden')
+ svgIcon('headphones', null, 'interpreter hidden')
+ '</span>'
+ '</a>'
+ '<div class="dropdown-menu">'
+ '<a class="dropdown-item action-nickname" href="#">Nickname</a>'
+ '<a class="dropdown-item action-dismiss" href="#">Dismiss</a>'
+ '<div class="dropdown-divider permissions"></div>'
+ '<div class="permissions">'
+ '<h6 class="dropdown-header">' + $t('meet.perm') + '</h6>'
+ '<label class="dropdown-item action-role-publisher form-check form-switch">'
+ '<input type="checkbox" class="form-check-input">'
+ ' <span class="form-check-label">' + $t('meet.perm-av') + '</span>'
+ '</label>'
+ '<label class="dropdown-item action-role-moderator form-check form-switch">'
+ '<input type="checkbox" class="form-check-input">'
+ ' <span class="form-check-label">' + $t('meet.perm-mod') + '</span>'
+ '</label>'
+ '</div>'
+ '<div class="dropdown-divider interpreting"></div>'
+ '<div class="interpreting">'
+ '<h6 class="dropdown-header">' + $t('meet.lang-int') + '</h6>'
+ '<div class="ps-3 pe-3"><select class="form-select">'
+ '<option value="">- ' + $t('form.none') + ' -</option>'
+ languages.join('')
+ '</select></div>'
+ '</div>'
+ '</div>'
+ '</div>'
)
let nickname = element.find('.meet-nickname')
.addClass('btn btn-outline-' + (params.isSelf ? 'primary' : 'secondary'))
.attr({title: $t('meet.menu-options'), 'data-bs-toggle': 'dropdown'})
const dropdown = new Dropdown(nickname[0], { boundary: container.parentNode })
if (params.isSelf) {
// Add events for nickname change
let editable = element.find('.content')[0]
let editableEnable = () => {
editable.contentEditable = true
editable.focus()
}
let editableUpdate = () => {
// Skip redundant update on blur, if it was already updated
if (editable.contentEditable !== 'false') {
editable.contentEditable = false
client.setNickname(editable.innerText)
}
}
element.find('.action-nickname').on('click', editableEnable)
element.find('.action-dismiss').remove()
$(editable).on('blur', editableUpdate)
.on('keydown', e => {
// Enter or Esc
if (e.keyCode == 13 || e.keyCode == 27) {
editableUpdate()
return false
}
// Do not propagate the event, so it does not interfere with our
// keyboard shortcuts
e.stopPropagation()
})
} else {
element.find('.action-nickname').remove()
element.find('.action-dismiss').on('click', () => {
client.kickPeer(params.id)
})
}
// Don't close the menu on permission change
element.find('.dropdown-menu > label').on('click', e => { e.stopPropagation() })
element.find('.action-role-publisher input').on('change', e => {
client[e.target.checked ? 'addRole' : 'removeRole'](params.id, Roles.PUBLISHER)
})
element.find('.action-role-moderator input').on('change', e => {
client[e.target.checked ? 'addRole' : 'removeRole'](params.id, Roles.MODERATOR)
})
element.find('.interpreting select')
.on('change', e => {
const language = $(e.target).val()
client.setLanguage(params.id, language)
dropdown.hide()
})
.on('click', e => {
// Prevents from closing the dropdown menu on click
e.stopPropagation()
})
return element.get(0)
}
/**
* Window onresize event handler (updates room layout)
*/
function resize() {
if (publishersContainer) {
updateLayout()
}
$(container).parent()[window.screen.width <= 768 ? 'addClass' : 'removeClass']('mobile')
}
/**
* Update the room "matrix" layout
*/
function updateLayout() {
let publishers = $(publishersContainer).find('.meet-video')
let numOfVideos = publishers.length
if (sessionData && sessionData.counterElement) {
sessionData.counterElement.innerHTML = Object.keys(peers).length
}
if (!numOfVideos) {
subscribersContainer.style.minHeight = 'auto'
return
}
// Note: offsetHeight/offsetWidth return rounded values, but for proper matrix
// calculations we need more precision, therefore we use getBoundingClientRect()
let allHeight = container.offsetHeight
let scrollHeight = subscribersContainer.scrollHeight
let bcr = publishersContainer.getBoundingClientRect()
let containerWidth = bcr.width
let containerHeight = bcr.height
let limit = Math.ceil(allHeight * 0.25) // max subscribers list height
// Fix subscribers list height
if (subscribersContainer.offsetHeight <= scrollHeight) {
limit = Math.min(scrollHeight, limit)
subscribersContainer.style.minHeight = limit + 'px'
containerHeight = allHeight - limit
} else {
subscribersContainer.style.minHeight = 'auto'
}
let css, rows, cols, height, padding = 0
// Make the first screen sharing tile big
let screenVideo = publishers.filter('.screen').find('video').get(0)
if (screenVideo) {
const element = screenVideo.parentNode
const peerId = element.id.replace(/^screen-/, '')
const peer = peers[peerId]
// We know the shared screen video dimensions, we can calculate
// width/height of the tile in the matrix
if (peer && peer.screenWidth) {
let screenWidth = peer.screenWidth
let screenHeight = containerHeight
// TODO: When the shared window is minimized the width/height is set to 1 (or 2)
// - at least on my system. We might need to handle this case nicer. Right now
// it create a 1-2px line on the left of the matrix - not a big issue.
// TODO: Make the 0.666 factor bigger for wide screen and small number of participants?
let maxWidth = Math.ceil(containerWidth * 0.666)
if (screenWidth > maxWidth) {
screenWidth = maxWidth
}
// Set the tile position and size
$(element).css({
width: screenWidth + 'px',
height: screenHeight + 'px',
position: 'absolute',
top: 0,
left: 0
})
padding = screenWidth + 'px'
// Now the estate for the rest of participants is what's left on the right side
containerWidth -= screenWidth
publishers = publishers.not(element)
numOfVideos -= 1
}
}
// Compensate the shared screen estate with a padding
$(publishersContainer).css('padding-left', padding)
const factor = containerWidth / containerHeight
if (factor >= 16/9) {
if (numOfVideos <= 3) {
rows = 1
} else if (numOfVideos <= 8) {
rows = 2
} else if (numOfVideos <= 15) {
rows = 3
} else if (numOfVideos <= 20) {
rows = 4
} else {
rows = 5
}
cols = Math.ceil(numOfVideos / rows)
} else {
if (numOfVideos == 1) {
cols = 1
} else if (numOfVideos <= 4) {
cols = 2
} else if (numOfVideos <= 9) {
cols = 3
} else if (numOfVideos <= 16) {
cols = 4
} else if (numOfVideos <= 25) {
cols = 5
} else {
cols = 6
}
rows = Math.ceil(numOfVideos / cols)
if (rows < cols && containerWidth < containerHeight) {
cols = rows
rows = Math.ceil(numOfVideos / cols)
}
}
// console.log('factor=' + factor, 'num=' + numOfVideos, 'cols = '+cols, 'rows=' + rows)
// Update all tiles (except the main shared screen) in the matrix
publishers.css({
width: (containerWidth / cols) + 'px',
// Height must be in pixels to make object-fit:cover working
height: (containerHeight / rows) + 'px'
})
}
/**
* Create an svg element (string) for a FontAwesome icon
*
* @todo Find if there's a "official" way to do this
*/
function svgIcon(name, type, className) {
// Note: the library will contain definitions for all icons registered elswhere
const icon = library.definitions[type || 'fas'][name]
let attrs = {
'class': 'svg-inline--fa',
'aria-hidden': true,
focusable: false,
role: 'img',
xmlns: 'http://www.w3.org/2000/svg',
viewBox: `0 0 ${icon[0]} ${icon[1]}`
}
if (className) {
attrs['class'] += ' ' + className
}
return $(`<svg><path fill="currentColor" d="${icon[4]}"></path></svg>`)
.attr(attrs)
.get(0).outerHTML
}
}
export { Room }
diff --git a/src/resources/js/user/routes.js b/src/resources/js/user/routes.js
index c3294544..f5ce89b2 100644
--- a/src/resources/js/user/routes.js
+++ b/src/resources/js/user/routes.js
@@ -1,186 +1,193 @@
import LoginComponent from '../../vue/Login'
import LogoutComponent from '../../vue/Logout'
import PageComponent from '../../vue/Page'
import PasswordResetComponent from '../../vue/PasswordReset'
import SignupComponent from '../../vue/Signup'
// Here's a list of lazy-loaded components
// Note: you can pack multiple components into the same chunk, webpackChunkName
// is also used to get a sensible file name instead of numbers
const CompanionAppComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/CompanionApp')
const DashboardComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Dashboard')
const DistlistInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Distlist/Info')
const DistlistListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Distlist/List')
const DomainInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Domain/Info')
const DomainListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Domain/List')
const FileInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/File/Info')
const FileListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/File/List')
-const MeetComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Rooms')
const ResourceInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Resource/Info')
const ResourceListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Resource/List')
+const RoomInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Room/Info')
+const RoomListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Room/List')
const SettingsComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Settings')
const SharedFolderInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/SharedFolder/Info')
const SharedFolderListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/SharedFolder/List')
const UserInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/User/Info')
const UserListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/User/List')
const UserProfileComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/User/Profile')
const UserProfileDeleteComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/User/ProfileDelete')
const WalletComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Wallet')
-const RoomComponent = () => import(/* webpackChunkName: "../user/meet" */ '../../vue/Meet/Room.vue')
+const MeetComponent = () => import(/* webpackChunkName: "../user/meet" */ '../../vue/Meet/Room.vue')
const routes = [
{
path: '/dashboard',
name: 'dashboard',
component: DashboardComponent,
meta: { requiresAuth: true }
},
{
path: '/distlist/:list',
name: 'distlist',
component: DistlistInfoComponent,
meta: { requiresAuth: true, perm: 'distlists' }
},
{
path: '/distlists',
name: 'distlists',
component: DistlistListComponent,
meta: { requiresAuth: true, perm: 'distlists' }
},
{
path: '/companion',
name: 'companion',
component: CompanionAppComponent,
meta: { requiresAuth: true, perm: 'companionapps' }
},
{
path: '/domain/:domain',
name: 'domain',
component: DomainInfoComponent,
meta: { requiresAuth: true, perm: 'domains' }
},
{
path: '/domains',
name: 'domains',
component: DomainListComponent,
meta: { requiresAuth: true, perm: 'domains' }
},
{
path: '/file/:file',
name: 'file',
component: FileInfoComponent,
meta: { requiresAuth: true /*, perm: 'files' */ }
},
{
path: '/files',
name: 'files',
component: FileListComponent,
meta: { requiresAuth: true, perm: 'files' }
},
{
path: '/login',
name: 'login',
component: LoginComponent
},
{
path: '/logout',
name: 'logout',
component: LogoutComponent
},
+ {
+ name: 'meet',
+ path: '/meet/:room',
+ component: MeetComponent,
+ meta: { loading: true }
+ },
{
path: '/password-reset/:code?',
name: 'password-reset',
component: PasswordResetComponent
},
{
path: '/profile',
name: 'profile',
component: UserProfileComponent,
meta: { requiresAuth: true }
},
{
path: '/profile/delete',
name: 'profile-delete',
component: UserProfileDeleteComponent,
meta: { requiresAuth: true }
},
{
path: '/resource/:resource',
name: 'resource',
component: ResourceInfoComponent,
meta: { requiresAuth: true, perm: 'resources' }
},
{
path: '/resources',
name: 'resources',
component: ResourceListComponent,
meta: { requiresAuth: true, perm: 'resources' }
},
{
- component: RoomComponent,
+ path: '/room/:room',
name: 'room',
- path: '/meet/:room',
- meta: { loading: true }
+ component: RoomInfoComponent,
+ meta: { requiresAuth: true, perm: 'rooms' }
},
{
path: '/rooms',
name: 'rooms',
- component: MeetComponent,
- meta: { requiresAuth: true }
+ component: RoomListComponent,
+ meta: { requiresAuth: true, perm: 'rooms' }
},
{
path: '/settings',
name: 'settings',
component: SettingsComponent,
meta: { requiresAuth: true, perm: 'settings' }
},
{
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
},
{
path: '/signup/:param?',
alias: '/signup/voucher/:param',
name: 'signup',
component: SignupComponent
},
{
path: '/user/:user',
name: 'user',
component: UserInfoComponent,
meta: { requiresAuth: true, perm: 'users' }
},
{
path: '/users',
name: 'users',
component: UserListComponent,
meta: { requiresAuth: true, perm: 'users' }
},
{
path: '/wallet',
name: 'wallet',
component: WalletComponent,
meta: { requiresAuth: true, perm: 'wallets' }
},
{
name: '404',
path: '*',
component: PageComponent
}
]
export default routes
diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php
index 3da22d53..41abec6e 100644
--- a/src/resources/lang/en/app.php
+++ b/src/resources/lang/en/app.php
@@ -1,135 +1,141 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used in the application.
*/
'chart-created' => 'Created',
'chart-deleted' => 'Deleted',
'chart-average' => 'average',
'chart-allusers' => 'All Users - last year',
'chart-discounts' => 'Discounts',
'chart-vouchers' => 'Vouchers',
'chart-income' => 'Income in :currency - last 8 weeks',
'chart-users' => 'Users - last 8 weeks',
'companion-deleteall-success' => 'All companion apps have been removed.',
'mandate-delete-success' => 'The auto-payment has been removed.',
'mandate-update-success' => 'The auto-payment has been updated.',
'planbutton' => 'Choose :plan',
'process-async' => 'Setup process has been pushed. Please wait.',
'process-user-new' => 'Registering a user...',
'process-user-ldap-ready' => 'Creating a user...',
'process-user-imap-ready' => 'Creating a mailbox...',
'process-domain-new' => 'Registering a custom domain...',
'process-domain-ldap-ready' => 'Creating a custom domain...',
'process-domain-verified' => 'Verifying a custom domain...',
'process-domain-confirmed' => 'Verifying an ownership of a custom domain...',
'process-success' => 'Setup process finished successfully.',
'process-error-distlist-ldap-ready' => 'Failed to create a distribution list.',
'process-error-domain-ldap-ready' => 'Failed to create a domain.',
'process-error-domain-verified' => 'Failed to verify a domain.',
'process-error-domain-confirmed' => 'Failed to verify an ownership of a domain.',
'process-error-resource-imap-ready' => 'Failed to verify that a shared folder exists.',
'process-error-resource-ldap-ready' => 'Failed to create a resource.',
'process-error-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...',
'process-distlist-ldap-ready' => 'Creating a distribution list...',
'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.',
'distlist-delete-success' => 'Distribution list deleted successfully.',
'distlist-suspend-success' => 'Distribution list suspended successfully.',
'distlist-unsuspend-success' => 'Distribution list unsuspended successfully.',
'distlist-setconfig-success' => 'Distribution list settings updated successfully.',
'domain-create-success' => 'Domain created successfully.',
'domain-delete-success' => 'Domain deleted successfully.',
'domain-notempty-error' => 'Unable to delete a domain with assigned users or other objects.',
'domain-verify-success' => 'Domain verified successfully.',
'domain-verify-error' => 'Domain ownership verification failed.',
'domain-suspend-success' => 'Domain suspended successfully.',
'domain-unsuspend-success' => 'Domain unsuspended successfully.',
'domain-setconfig-success' => 'Domain settings updated successfully.',
'file-create-success' => 'File created successfully.',
'file-delete-success' => 'File deleted successfully.',
'file-update-success' => 'File updated successfully.',
'file-permissions-create-success' => 'File permissions created successfully.',
'file-permissions-update-success' => 'File permissions updated successfully.',
'file-permissions-delete-success' => 'File permissions deleted successfully.',
'resource-update-success' => 'Resource updated successfully.',
'resource-create-success' => 'Resource created successfully.',
'resource-delete-success' => 'Resource deleted successfully.',
'resource-setconfig-success' => 'Resource settings updated successfully.',
+ 'room-update-success' => 'Room updated successfully.',
+ 'room-create-success' => 'Room created successfully.',
+ 'room-delete-success' => 'Room deleted successfully.',
+ 'room-setconfig-success' => 'Room configuration updated successfully.',
+ 'room-unsupported-option-error' => 'Invalid room configuration option.',
+
'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.',
'user-suspend-success' => 'User suspended successfully.',
'user-unsuspend-success' => 'User unsuspended successfully.',
'user-reset-2fa-success' => '2-Factor authentication reset successfully.',
'user-setconfig-success' => 'User settings updated successfully.',
'user-set-sku-success' => 'The subscription added successfully.',
'user-set-sku-already-exists' => 'The subscription already exists.',
'search-foundxdomains' => ':x domains have been found.',
'search-foundxdistlists' => ':x distribution lists have been found.',
'search-foundxresources' => ':x resources have been found.',
'search-foundxshared-folders' => ':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.',
'signup-invitations-csv-empty' => 'Failed to find any valid email addresses in the uploaded file.',
'signup-invitations-csv-invalid-email' => 'Found an invalid email address (:email) on line :line.',
'signup-invitation-delete-success' => 'Invitation deleted successfully.',
'signup-invitation-resend-success' => 'Invitation added to the sending queue successfully.',
'support-request-success' => 'Support request submitted successfully.',
'support-request-error' => 'Failed to submit the support request.',
'siteuser' => ':site User',
'wallet-award-success' => 'The bonus has been added to the wallet successfully.',
'wallet-penalty-success' => 'The penalty has been added to the wallet successfully.',
'wallet-update-success' => 'User wallet updated successfully.',
'password-reset-code-delete-success' => 'Password reset code deleted successfully.',
'password-rule-min' => 'Minimum password length: :param characters',
'password-rule-max' => 'Maximum password length: :param characters',
'password-rule-lower' => 'Password contains a lower-case character',
'password-rule-upper' => 'Password contains an upper-case character',
'password-rule-digit' => 'Password contains a digit',
'password-rule-special' => 'Password contains a special character',
'password-rule-last' => 'Password cannot be the same as the last :param passwords',
'wallet-notice-date' => 'With your current subscriptions your account balance will last until about :date (:days).',
'wallet-notice-nocredit' => 'You are out of credit, top up your balance now.',
'wallet-notice-today' => 'You will run out of credit today, top up your balance now.',
'wallet-notice-trial' => 'You are in your free trial period.',
'wallet-notice-trial-end' => 'Your free trial is about to end, top up to continue.',
];
diff --git a/src/resources/lang/en/meet.php b/src/resources/lang/en/meet.php
index 5a5fe1af..f76695d1 100644
--- a/src/resources/lang/en/meet.php
+++ b/src/resources/lang/en/meet.php
@@ -1,30 +1,28 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Pagination Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used by the paginator library to build
| the simple pagination links. You are free to change them to anything
| you want to customize your views to better match your application.
|
*/
'connection-not-found' => 'The connection does not exist.',
'connection-dismiss-error' => 'Failed to dismiss the connection.',
'room-not-found' => 'The room does not exist.',
- 'room-setconfig-success' => 'Room configuration updated successfully.',
- 'room-unsupported-option-error' => 'Invalid room configuration option.',
'session-not-found' => 'The session does not exist.',
'session-create-error' => 'Failed to create the session.',
'session-join-error' => 'Failed to join the session.',
'session-close-error' => 'Failed to close the session.',
'session-close-success' => 'The session has been closed successfully.',
'session-password-error' => 'Failed to join the session. Invalid password.',
'session-request-accept-error' => 'Failed to accept the join request.',
'session-request-deny-error' => 'Failed to deny the join request.',
'session-room-locked-error' => 'Failed to join the session. Room locked.',
];
diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php
index 67e17b82..3300c9d4 100644
--- a/src/resources/lang/en/ui.php
+++ b/src/resources/lang/en/ui.php
@@ -1,536 +1,527 @@
<?php
/**
* This file will be converted to a Vue-i18n compatible JSON format on build time
*
* Note: The Laravel localization features do not work here. Vue-i18n rules are different
*/
return [
'app' => [
'faq' => "FAQ",
],
'btn' => [
'add' => "Add",
'accept' => "Accept",
'back' => "Back",
'cancel' => "Cancel",
'close' => "Close",
'continue' => "Continue",
'copy' => "Copy",
'delete' => "Delete",
'deny' => "Deny",
'download' => "Download",
'edit' => "Edit",
'file' => "Choose file...",
'moreinfo' => "More information",
'refresh' => "Refresh",
'reset' => "Reset",
'resend' => "Resend",
'save' => "Save",
'search' => "Search",
'share' => "Share",
'signup' => "Sign Up",
'submit' => "Submit",
'suspend' => "Suspend",
'unsuspend' => "Unsuspend",
'verify' => "Verify",
],
'companion' => [
'title' => "Companion App",
'name' => "Name",
'description' => "Use the Companion App on your mobile phone for advanced two factor authentication.",
'pair-new' => "Pair new device",
'paired' => "Paired devices",
'pairing-instructions' => "Pair a new device using the following QR-Code:",
'deviceid' => "Device ID",
'list-empty' => "There are currently no devices",
'delete' => "Remove devices",
'remove-devices' => "Remove Devices",
'remove-devices-text' => "Do you really want to remove all devices permanently?"
. " Please note that this action cannot be undone, and you can only remove all devices together."
. " You may pair devices you would like to keep individually again.",
],
'dashboard' => [
'beta' => "beta",
'distlists' => "Distribution lists",
'chat' => "Video chat",
'companion' => "Companion app",
'domains' => "Domains",
'files' => "Files",
'invitations' => "Invitations",
'profile' => "Your profile",
'resources' => "Resources",
'settings' => "Settings",
'shared-folders' => "Shared folders",
'users' => "User accounts",
'wallet' => "Wallet",
'webmail' => "Webmail",
'stats' => "Stats",
],
'distlist' => [
'list-title' => "Distribution list | Distribution lists",
'create' => "Create list",
'delete' => "Delete list",
'email' => "Email",
'list-empty' => "There are no distribution lists in this account.",
'name' => "Name",
'new' => "New distribution list",
'recipients' => "Recipients",
'sender-policy' => "Sender Access List",
'sender-policy-text' => "With this list you can specify who can send mail to the distribution list."
. " You can put a complete email address (jane@kolab.org), domain (kolab.org) or suffix (.org) that the sender email address is compared to."
. " If the list is empty, mail from anyone is allowed.",
],
'domain' => [
'delete' => "Delete domain",
'delete-domain' => "Delete {domain}",
'delete-text' => "Do you really want to delete this domain permanently?"
. " This is only possible if there are no users, aliases or other objects in this domain."
. " Please note that this action cannot be undone.",
'dns-verify' => "Domain DNS verification sample:",
'dns-config' => "Domain DNS configuration sample:",
'list-empty' => "There are no domains in this account.",
'namespace' => "Namespace",
'spf-whitelist' => "SPF Whitelist",
'spf-whitelist-text' => "The Sender Policy Framework allows a sender domain to disclose, through DNS, "
. "which systems are allowed to send emails with an envelope sender address within said domain.",
'spf-whitelist-ex' => "Here you can specify a list of allowed servers, for example: <var>.ess.barracuda.com</var>.",
'verify' => "Domain verification",
'verify-intro' => "In order to confirm that you're the actual holder of the domain, we need to run a verification process before finally activating it for email delivery.",
'verify-dns' => "The domain <b>must have one of the following entries</b> in DNS:",
'verify-dns-txt' => "TXT entry with value:",
'verify-dns-cname' => "or CNAME entry:",
'verify-outro' => "When this is done press the button below to start the verification.",
'verify-sample' => "Here's a sample zone file for your domain:",
'config' => "Domain configuration",
'config-intro' => "In order to let {app} receive email traffic for your domain you need to adjust the DNS settings, more precisely the MX entries, accordingly.",
'config-sample' => "Edit your domain's zone file and replace existing MX entries with the following values:",
'config-hint' => "If you don't know how to set DNS entries for your domain, please contact the registration service where you registered the domain or your web hosting provider.",
'create' => "Create domain",
'new' => "New domain",
],
'error' => [
'400' => "Bad request",
'401' => "Unauthorized",
'403' => "Access denied",
'404' => "Not found",
'405' => "Method not allowed",
'500' => "Internal server error",
'unknown' => "Unknown Error",
'server' => "Server Error",
'form' => "Form validation error",
],
'file' => [
'create' => "Create file",
'delete' => "Delete file",
'list-empty' => "There are no files in this account.",
'mimetype' => "Mimetype",
'mtime' => "Modified",
'new' => "New file",
'search' => "File name",
'sharing' => "Sharing",
'sharing-links-text' => "You can share the file with other users by giving them read-only access "
. "to the file via a unique link.",
],
'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",
'description' => "Description",
'details' => "Details",
'disabled' => "disabled",
'domain' => "Domain",
'email' => "Email Address",
'emails' => "Email Addresses",
'enabled' => "enabled",
'firstname' => "First Name",
'general' => "General",
'lastname' => "Last Name",
'name' => "Name",
'months' => "months",
'none' => "none",
'or' => "or",
'password' => "Password",
'password-confirm' => "Confirm Password",
'phone' => "Phone",
'settings' => "Settings",
'shared-folder' => "Shared Folder",
'size' => "Size",
'status' => "Status",
'subscriptions' => "Subscriptions",
'surname' => "Surname",
'type' => "Type",
'user' => "User",
'primary-email' => "Primary Email",
'id' => "ID",
'created' => "Created",
'deleted' => "Deleted",
],
'invitation' => [
'create' => "Create invite(s)",
'create-title' => "Invite for a signup",
'create-email' => "Enter an email address of the person you want to invite.",
'create-csv' => "To send multiple invitations at once, provide a CSV (comma separated) file, or alternatively a plain-text file, containing one email address per line.",
'list-empty' => "There are no invitations in the database.",
'title' => "Signup invitations",
'search' => "Email address or domain",
'send' => "Send invite(s)",
'status-completed' => "User signed up",
'status-failed' => "Sending failed",
'status-sent' => "Sent",
'status-new' => "Not sent yet",
],
'lang' => [
'en' => "English",
'de' => "German",
'fr' => "French",
'it' => "Italian",
],
'login' => [
'2fa' => "Second factor code",
'2fa_desc' => "Second factor code is optional for users with no 2-Factor Authentication setup.",
'forgot_password' => "Forgot password?",
'header' => "Please sign in",
'sign_in' => "Sign in",
'webmail' => "Webmail"
],
'meet' => [
- 'title' => "Voice & Video Conferencing",
- 'welcome' => "Welcome to our beta program for Voice & Video Conferencing.",
- 'url' => "You have a room of your own at the URL below. This room is only open when you yourself are in attendance. Use this URL to invite people to join you.",
- 'notice' => "This is a work in progress and more features will be added over time. Current features include:",
- 'sharing' => "Screen Sharing",
- 'sharing-text' => "Share your screen for presentations or show-and-tell.",
- 'security' => "Room Security",
- 'security-text' => "Increase the room security by setting a password that attendees will need to know"
- . " before they can enter, or lock the door so attendees will have to knock, and a moderator can accept or deny those requests.",
- 'qa-title' => "Raise Hand (Q&A)",
- 'qa-text' => "Silent audience members can raise their hand to facilitate a Question & Answer session with the panel members.",
- 'moderation' => "Moderator Delegation",
- 'moderation-text' => "Delegate moderator authority for the session, so that a speaker is not needlessly"
- . " interrupted with attendees knocking and other moderator duties.",
- 'eject' => "Eject Attendees",
- 'eject-text' => "Eject attendees from the session in order to force them to reconnect, or address policy"
- . " violations. Click the user icon for effective dismissal.",
- 'silent' => "Silent Audience Members",
- 'silent-text' => "For a webinar-style session, configure the room to force all new attendees to be silent audience members.",
- 'interpreters' => "Language Specific Audio Channels",
- 'interpreters-text' => "Designate a participant to interpret the original audio to a target language, for sessions"
- . " with multi-lingual attendees. The interpreter is expected to be able to relay the original audio, and override it.",
- 'beta-notice' => "Keep in mind that this is still in beta and might come with some issues."
- . " Should you encounter any on your way, let us know by contacting support.",
-
// Room options dialog
'options' => "Room options",
'password' => "Password",
'password-none' => "none",
'password-clear' => "Clear password",
'password-set' => "Set password",
'password-text' => "You can add a password to your meeting. Participants will have to provide the password before they are allowed to join the meeting.",
'lock' => "Locked room",
'lock-text' => "When the room is locked participants have to be approved by a moderator before they could join the meeting.",
'nomedia' => "Subscribers only",
'nomedia-text' => "Forces all participants to join as subscribers (with camera and microphone turned off)."
. " Moderators will be able to promote them to publishers throughout the session.",
// Room menu
'partcnt' => "Number of participants",
'menu-audio-mute' => "Mute audio",
'menu-audio-unmute' => "Unmute audio",
'menu-video-mute' => "Mute video",
'menu-video-unmute' => "Unmute video",
'menu-screen' => "Share screen",
'menu-hand-lower' => "Lower hand",
'menu-hand-raise' => "Raise hand",
'menu-channel' => "Interpreted language channel",
'menu-chat' => "Chat",
'menu-fullscreen' => "Full screen",
'menu-fullscreen-exit' => "Exit full screen",
'menu-leave' => "Leave session",
// Room setup screen
'setup-title' => "Set up your session",
'mic' => "Microphone",
'cam' => "Camera",
'nick' => "Nickname",
'nick-placeholder' => "Your name",
'join' => "JOIN",
'joinnow' => "JOIN NOW",
'imaowner' => "I'm the owner",
// Room
'qa' => "Q & A",
'leave-title' => "Room closed",
'leave-body' => "The session has been closed by the room owner.",
'media-title' => "Media setup",
'join-request' => "Join request",
'join-requested' => "{user} requested to join.",
// Status messages
'status-init' => "Checking the room...",
'status-323' => "The room is closed. Please, wait for the owner to start the session.",
'status-324' => "The room is closed. It will be open for others after you join.",
'status-325' => "The room is ready. Please, provide a valid password.",
'status-326' => "The room is locked. Please, enter your name and try again.",
'status-327' => "Waiting for permission to join the room.",
'status-404' => "The room does not exist.",
'status-429' => "Too many requests. Please, wait.",
'status-500' => "Failed to connect to the room. Server error.",
// Other menus
'media-setup' => "Media setup",
'perm' => "Permissions",
'perm-av' => "Audio &amp; Video publishing",
'perm-mod' => "Moderation",
'lang-int' => "Language interpreter",
'menu-options' => "Options",
],
'menu' => [
'cockpit' => "Cockpit",
'login' => "Login",
'logout' => "Logout",
'signup' => "Signup",
'toggle' => "Toggle navigation",
],
'msg' => [
'initializing' => "Initializing...",
'loading' => "Loading...",
'loading-failed' => "Failed to load data.",
'notfound' => "Resource not found.",
'info' => "Information",
'error' => "Error",
'uploading' => "Uploading...",
'warning' => "Warning",
'success' => "Success",
],
'nav' => [
'more' => "Load more",
'step' => "Step {i}/{n}",
],
'password' => [
'link-invalid' => "The password reset code is expired or invalid.",
'reset' => "Password Reset",
'reset-step1' => "Enter your email address to reset your password.",
'reset-step1-hint' => "You may need to check your spam folder or unblock {email}.",
'reset-step2' => "We sent out a confirmation code to your external email address."
. " Enter the code we sent you, or click the link in the message.",
],
'resource' => [
'create' => "Create resource",
'delete' => "Delete resource",
'invitation-policy' => "Invitation policy",
'invitation-policy-text' => "Event invitations for a resource are normally accepted automatically"
. " if there is no conflicting event on the requested time slot. Invitation policy allows"
. " for rejecting such requests or to require a manual acceptance from a specified user.",
'ipolicy-manual' => "Manual (tentative)",
'ipolicy-accept' => "Accept",
'ipolicy-reject' => "Reject",
'list-title' => "Resource | Resources",
'list-empty' => "There are no resources in this account.",
'new' => "New resource",
],
+ 'room' => [
+ 'create' => "Create room",
+ 'delete' => "Delete room",
+ 'description-hint' => "This is an optional short description for the room, so you can find it more easily on the list.",
+ 'goto' => "Enter the room",
+ 'list-empty' => "There are no conference rooms in this account.",
+ 'list-empty-nocontroller' => "Do you need a room? Ask your account owner to create one and share it with you.",
+ 'list-title' => "Voice & video conferencing rooms",
+ 'moderators' => "Moderators",
+ 'moderators-text' => "You can share your room with other users. They will become the room moderators with all moderator powers and ability to open the room without your presence.",
+ 'new' => "New room",
+ 'new-hint' => "We'll generate a unique name for the room that will then allow you to access the room.",
+ 'title' => "Room: {name}",
+ 'url' => "You can access the room at the URL below. Use this URL to invite people to join you. This room is only open when you (or another room moderator) is in attendance.",
+ ],
+
'settings' => [
'password-policy' => "Password Policy",
'password-retention' => "Password Retention",
'password-max-age' => "Require a password change every",
],
'shf' => [
'aliases-none' => "This shared folder has no email aliases.",
'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",
'title' => "Sign Up",
'step1' => "Sign up to start your free month.",
'step2' => "We sent out a confirmation code to your email address. Enter the code we sent you, or click the link in the message.",
'step3' => "Create your Kolab identity (you can choose additional addresses later).",
'voucher' => "Voucher Code",
],
'status' => [
'prepare-account' => "We are preparing your account.",
'prepare-domain' => "We are preparing the domain.",
'prepare-distlist' => "We are preparing the distribution list.",
'prepare-resource' => "We are preparing the resource.",
'prepare-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.",
'ready-account' => "Your account is almost ready.",
'ready-domain' => "The domain is almost ready.",
'ready-distlist' => "The distribution list is almost ready.",
'ready-resource' => "The resource is almost ready.",
'ready-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",
'degraded' => "Degraded",
'deleted' => "Deleted",
'suspended' => "Suspended",
'notready' => "Not Ready",
'active' => "Active",
],
'support' => [
'title' => "Contact Support",
'id' => "Customer number or email address you have with us",
'id-pl' => "e.g. 12345678 or john@kolab.org",
'id-hint' => "Leave blank if you are not a customer yet",
'name' => "Name",
'name-pl' => "how we should call you in our reply",
'email' => "Working email address",
'email-pl' => "make sure we can reach you at this address",
'summary' => "Issue Summary",
'summary-pl' => "one sentence that summarizes your issue",
'expl' => "Issue Explanation",
],
'user' => [
'2fa-hint1' => "This will remove 2-Factor Authentication entitlement as well as the user-configured factors.",
'2fa-hint2' => "Please, make sure to confirm the user identity properly.",
'add-beta' => "Enable beta program",
'address' => "Address",
'aliases' => "Aliases",
'aliases-none' => "This user has no email aliases.",
'add-bonus' => "Add bonus",
'add-bonus-title' => "Add a bonus to the wallet",
'add-penalty' => "Add penalty",
'add-penalty-title' => "Add a penalty to the wallet",
'auto-payment' => "Auto-payment",
'auto-payment-text' => "Fill up by <b>{amount}</b> when under <b>{balance}</b> using {method}",
'country' => "Country",
'create' => "Create user",
'custno' => "Customer No.",
'degraded-warning' => "The account is degraded. Some features have been disabled.",
'degraded-hint' => "Please, make a payment.",
'delete' => "Delete user",
'delete-account' => "Delete this account?",
'delete-email' => "Delete {email}",
'delete-text' => "Do you really want to delete this user permanently?"
. " This will delete all account data and withdraw the permission to access the email account."
. " Please note that this action cannot be undone.",
'discount' => "Discount",
'discount-hint' => "applied discount",
'discount-title' => "Account discount",
'distlists' => "Distribution lists",
'domains' => "Domains",
'ext-email' => "External Email",
'email-aliases' => "Email Aliases",
'finances' => "Finances",
'greylisting' => "Greylisting",
'greylisting-text' => "Greylisting is a method of defending users against spam. Any incoming mail from an unrecognized sender "
. "is temporarily rejected. The originating server should try again after a delay. "
. "This time the email will be accepted. Spammers usually do not reattempt mail delivery.",
'list-title' => "User accounts",
'list-empty' => "There are no users in this account.",
'managed-by' => "Managed by",
'new' => "New user account",
'org' => "Organization",
'package' => "Package",
'pass-input' => "Enter password",
'pass-link' => "Set via link",
'pass-link-label' => "Link:",
'pass-link-hint' => "Press Submit to activate the link",
'passwordpolicy' => "Password Policy",
'price' => "Price",
'profile-title' => "Your profile",
'profile-delete' => "Delete account",
'profile-delete-title' => "Delete this account?",
'profile-delete-text1' => "This will delete the account as well as all domains, users and aliases associated with this account.",
'profile-delete-warning' => "This operation is irreversible",
'profile-delete-text2' => "As you will not be able to recover anything after this point, please make sure that you have migrated all data before proceeding.",
'profile-delete-support' => "As we always strive to improve, we would like to ask for 2 minutes of your time. "
. "The best tool for improvement is feedback from users, and we would like to ask "
. "for a few words about your reasons for leaving our service. Please send your feedback to <a href=\"{href}\">{email}</a>.",
'profile-delete-contact' => "Also feel free to contact {app} Support with any questions or concerns that you may have in this context.",
'reset-2fa' => "Reset 2-Factor Auth",
'reset-2fa-title' => "2-Factor Authentication Reset",
'resources' => "Resources",
'title' => "User account",
'search' => "User email address or name",
'search-pl' => "User ID, email or domain",
'skureq' => "{sku} requires {list}.",
'subscription' => "Subscription",
'subscriptions-none' => "This user has no subscriptions.",
'users' => "Users",
],
'wallet' => [
'add-credit' => "Add credit",
'auto-payment-cancel' => "Cancel auto-payment",
'auto-payment-change' => "Change auto-payment",
'auto-payment-failed' => "The setup of automatic payments failed. Restart the process to enable automatic top-ups.",
'auto-payment-hint' => "Here is how it works: Every time your account runs low, we will charge your preferred payment method for an amount you choose."
. " You can cancel or change the auto-payment option at any time.",
'auto-payment-setup' => "Set up auto-payment",
'auto-payment-disabled' => "The configured auto-payment has been disabled. Top up your wallet or raise the auto-payment amount.",
'auto-payment-info' => "Auto-payment is <b>set</b> to fill up your account by <b>{amount}</b> every time your account balance gets under <b>{balance}</b>.",
'auto-payment-inprogress' => "The setup of the automatic payment is still in progress.",
'auto-payment-next' => "Next, you will be redirected to the checkout page, where you can provide your credit card details.",
'auto-payment-disabled-next' => "The auto-payment is disabled. Immediately after you submit new settings we'll enable it and attempt to top up your wallet.",
'auto-payment-update' => "Update auto-payment",
'banktransfer-hint' => "Please note that a bank transfer can take several days to complete.",
'coinbase-hint' => "Here is how it works: You specify the amount by which you want to top up your wallet in {wc}."
. " We will then create a charge on Coinbase for the specified amount that you can pay using Bitcoin.",
'currency-conv' => "Here is how it works: You specify the amount by which you want to top up your wallet in {wc}."
. " We will then convert this to {pc}, and on the next page you will be provided with the bank-details to transfer the amount in {pc}.",
'fill-up' => "Fill up by",
'history' => "History",
'month' => "month",
'noperm' => "Only account owners can access a wallet.",
'payment-amount-hint' => "Choose the amount by which you want to top up your wallet.",
'payment-method' => "Method of payment: {method}",
'payment-warning' => "You will be charged for {price}.",
'pending-payments' => "Pending Payments",
'pending-payments-warning' => "You have payments that are still in progress. See the \"Pending Payments\" tab below.",
'pending-payments-none' => "There are no pending payments for this account.",
'receipts' => "Receipts",
'receipts-hint' => "Here you can download receipts (in PDF format) for payments in specified period. Select the period and press the Download button.",
'receipts-none' => "There are no receipts for payments in this account. Please, note that you can download receipts after the month ends.",
'title' => "Account balance",
'top-up' => "Top up your wallet",
'transactions' => "Transactions",
'transactions-none' => "There are no transactions for this account.",
'when-below' => "when account balance is below",
],
];
diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php
index 84b39d31..2201e9b3 100644
--- a/src/resources/lang/en/validation.php
+++ b/src/resources/lang/en/validation.php
@@ -1,200 +1,201 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Validation Language Lines
|--------------------------------------------------------------------------
|
| The following language lines contain the default error messages used by
| the validator class. Some of these rules have multiple versions such
| as the size rules. Feel free to tweak each of these messages here.
|
*/
'accepted' => 'The :attribute must be accepted.',
'accepted_if' => 'The :attribute must be accepted when :other is :value.',
'active_url' => 'The :attribute is not a valid URL.',
'after' => 'The :attribute must be a date after :date.',
'after_or_equal' => 'The :attribute must be a date after or equal to :date.',
'alpha' => 'The :attribute must only contain letters.',
'alpha_dash' => 'The :attribute must only contain letters, numbers, dashes and underscores.',
'alpha_num' => 'The :attribute must only contain letters and numbers.',
'array' => 'The :attribute must be an array.',
'before' => 'The :attribute must be a date before :date.',
'before_or_equal' => 'The :attribute must be a date before or equal to :date.',
'between' => [
'numeric' => 'The :attribute must be between :min and :max.',
'file' => 'The :attribute must be between :min and :max kilobytes.',
'string' => 'The :attribute must be between :min and :max characters.',
'array' => 'The :attribute must have between :min and :max items.',
],
'boolean' => 'The :attribute field must be true or false.',
'confirmed' => 'The :attribute confirmation does not match.',
'current_password' => 'The password is incorrect.',
'date' => 'The :attribute is not a valid date.',
'date_equals' => 'The :attribute must be a date equal to :date.',
'date_format' => 'The :attribute does not match the format :format.',
'declined' => 'The :attribute must be declined.',
'declined_if' => 'The :attribute must be declined when :other is :value.',
'different' => 'The :attribute and :other must be different.',
'digits' => 'The :attribute must be :digits digits.',
'digits_between' => 'The :attribute must be between :min and :max digits.',
'dimensions' => 'The :attribute has invalid image dimensions.',
'distinct' => 'The :attribute field has a duplicate value.',
'email' => 'The :attribute must be a valid email address.',
'ends_with' => 'The :attribute must end with one of the following: :values',
'enum' => 'The selected :attribute is invalid.',
'exists' => 'The selected :attribute is invalid.',
'file' => 'The :attribute must be a file.',
'filled' => 'The :attribute field must have a value.',
'gt' => [
'numeric' => 'The :attribute must be greater than :value.',
'file' => 'The :attribute must be greater than :value kilobytes.',
'string' => 'The :attribute must be greater than :value characters.',
'array' => 'The :attribute must have more than :value items.',
],
'gte' => [
'numeric' => 'The :attribute must be greater than or equal :value.',
'file' => 'The :attribute must be greater than or equal :value kilobytes.',
'string' => 'The :attribute must be greater than or equal :value characters.',
'array' => 'The :attribute must have :value items or more.',
],
'image' => 'The :attribute must be an image.',
'in' => 'The selected :attribute is invalid.',
'in_array' => 'The :attribute field does not exist in :other.',
'integer' => 'The :attribute must be an integer.',
'ip' => 'The :attribute must be a valid IP address.',
'ipv4' => 'The :attribute must be a valid IPv4 address.',
'ipv6' => 'The :attribute must be a valid IPv6 address.',
'json' => 'The :attribute must be a valid JSON string.',
'lt' => [
'numeric' => 'The :attribute must be less than :value.',
'file' => 'The :attribute must be less than :value kilobytes.',
'string' => 'The :attribute must be less than :value characters.',
'array' => 'The :attribute must have less than :value items.',
],
'lte' => [
'numeric' => 'The :attribute must be less than or equal :value.',
'file' => 'The :attribute must be less than or equal :value kilobytes.',
'string' => 'The :attribute must be less than or equal :value characters.',
'array' => 'The :attribute must not have more than :value items.',
],
'max' => [
'numeric' => 'The :attribute may not be greater than :max.',
'file' => 'The :attribute may not be greater than :max kilobytes.',
'string' => 'The :attribute may not be greater than :max characters.',
'array' => 'The :attribute may not have more than :max items.',
],
'mac_address' => 'The :attribute must be a valid MAC address.',
'mimes' => 'The :attribute must be a file of type: :values.',
'mimetypes' => 'The :attribute must be a file of type: :values.',
'min' => [
'numeric' => 'The :attribute must be at least :min.',
'file' => 'The :attribute must be at least :min kilobytes.',
'string' => 'The :attribute must be at least :min characters.',
'array' => 'The :attribute must have at least :min items.',
],
'multiple_of' => 'The :attribute must be a multiple of :value.',
'not_in' => 'The selected :attribute is invalid.',
'not_regex' => 'The :attribute format is invalid.',
'numeric' => 'The :attribute must be a number.',
'present' => 'The :attribute field must be present.',
'prohibited' => 'The :attribute field is prohibited.',
'prohibited_if' => 'The :attribute field is prohibited when :other is :value.',
'prohibited_unless' => 'The :attribute field is prohibited unless :other is in :values.',
'prohibits' => 'The :attribute field prohibits :other from being present.',
'regex' => 'The :attribute format is invalid.',
'required' => 'The :attribute field is required.',
'required_array_keys' => 'The :attribute field must contain entries for: :values.',
'required_if' => 'The :attribute field is required when :other is :value.',
'required_unless' => 'The :attribute field is required unless :other is in :values.',
'required_with' => 'The :attribute field is required when :values is present.',
'required_with_all' => 'The :attribute field is required when :values are present.',
'required_without' => 'The :attribute field is required when :values is not present.',
'required_without_all' => 'The :attribute field is required when none of :values are present.',
'same' => 'The :attribute and :other must match.',
'size' => [
'numeric' => 'The :attribute must be :size.',
'file' => 'The :attribute must be :size kilobytes.',
'string' => 'The :attribute must be :size characters.',
'array' => 'The :attribute must contain :size items.',
],
'starts_with' => 'The :attribute must start with one of the following: :values',
'string' => 'The :attribute must be a string.',
'timezone' => 'The :attribute must be a valid timezone.',
'unique' => 'The :attribute has already been taken.',
'uploaded' => 'The :attribute failed to upload.',
'url' => 'The :attribute must be a valid URL.',
'uuid' => 'The :attribute must be a valid UUID.',
'2fareq' => 'Second factor code is required.',
'2fainvalid' => 'Second factor code is invalid.',
'emailinvalid' => 'The specified email address is invalid.',
'domaininvalid' => 'The specified domain is invalid.',
'domainnotavailable' => 'The specified domain is not available.',
'logininvalid' => 'The specified login is invalid.',
'loginexists' => 'The specified login is not available.',
'domainexists' => 'The specified domain is not available.',
'noemailorphone' => 'The specified text is neither a valid email address nor a phone number.',
'packageinvalid' => 'Invalid package selected.',
'packagerequired' => 'Package is required.',
'usernotexists' => 'Unable to find user.',
'voucherinvalid' => 'The voucher code is invalid or expired.',
'noextemail' => 'This user has no external email address.',
'entryinvalid' => 'The specified :attribute is invalid.',
'entryexists' => 'The specified :attribute is not available.',
'minamount' => 'Minimum amount for a single payment is :amount.',
'minamountdebt' => 'The specified amount does not cover the balance on the account.',
'notalocaluser' => 'The specified email address does not exist.',
'memberislist' => 'A recipient cannot be the same as the list address.',
'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.',
+ 'acl-permission-invalid' => 'The specified permission is invalid.',
'file-perm-exists' => 'File permission already exists.',
'file-perm-invalid' => 'The file permission is invalid.',
'file-name-exists' => 'The file name already exists.',
'file-name-invalid' => 'The file name is invalid.',
'file-name-toolong' => 'The file name is too long.',
'ipolicy-invalid' => 'The specified invitation policy is invalid.',
'invalid-config-parameter' => 'The requested configuration parameter is not supported.',
'nameexists' => 'The specified name is not available.',
'nameinvalid' => 'The specified name is invalid.',
'password-policy-error' => 'Specified password does not comply with the policy.',
'invalid-password-policy' => 'Specified password policy is invalid.',
'password-policy-min-len-error' => 'Minimum password length cannot be less than :min.',
'password-policy-max-len-error' => 'Maximum password length cannot be more than :max.',
'password-policy-last-error' => 'The minimum value for last N passwords is :last.',
/*
|--------------------------------------------------------------------------
| Custom Validation Language Lines
|--------------------------------------------------------------------------
|
| Here you may specify custom validation messages for attributes using the
| convention "attribute.rule" to name the lines. This makes it quick to
| specify a specific custom language line for a given attribute rule.
|
*/
'custom' => [
'attribute-name' => [
'rule-name' => 'custom-message',
],
],
/*
|--------------------------------------------------------------------------
| Custom Validation Attributes
|--------------------------------------------------------------------------
|
| The following language lines are used to swap our attribute placeholder
| with something more reader friendly such as "E-Mail Address" instead
| of "email". This simply helps us make our message more expressive.
|
*/
'attributes' => [],
];
diff --git a/src/resources/lang/fr/app.php b/src/resources/lang/fr/app.php
index 6c0f65cb..24335726 100644
--- a/src/resources/lang/fr/app.php
+++ b/src/resources/lang/fr/app.php
@@ -1,115 +1,118 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used in the application.
*/
'chart-created' => "Crée",
'chart-deleted' => "Supprimé",
'chart-average' => "moyenne",
'chart-allusers' => "Tous Utilisateurs - l'année derniere",
'chart-discounts' => "Rabais",
'chart-vouchers' => "Coupons",
'chart-income' => "Revenus en :currency - 8 dernières semaines",
'chart-users' => "Utilisateurs - 8 dernières semaines",
'mandate-delete-success' => "L'auto-paiement a été supprimé.",
'mandate-update-success' => "L'auto-paiement a été mis-à-jour.",
'planbutton' => "Choisir :plan",
'siteuser' => "Utilisateur du :site",
'domain-setconfig-success' => "Les paramètres du domaine sont mis à jour avec succès.",
'user-setconfig-success' => "Les paramètres d'utilisateur sont mis à jour avec succès.",
'process-async' => "Le processus d'installation a été poussé. Veuillez patienter.",
'process-user-new' => "Enregistrement d'un utilisateur...",
'process-user-ldap-ready' => "Création d'un utilisateur...",
'process-user-imap-ready' => "Création d'une boîte aux lettres...",
'process-distlist-new' => "Enregistrement d'une liste de distribution...",
'process-distlist-ldap-ready' => "Création d'une liste de distribution...",
'process-domain-new' => "Enregistrement d'un domaine personnalisé...",
'process-domain-ldap-ready' => "Création d'un domaine personnalisé...",
'process-domain-verified' => "Vérification d'un domaine personnalisé...",
'process-domain-confirmed' => "vérification de la propriété d'un domaine personnalisé...",
'process-success' => "Le processus d'installation s'est terminé avec succès.",
'process-error-domain-ldap-ready' => "Échec de créer un domaine.",
'process-error-domain-verified' => "Échec de vérifier un domaine.",
'process-error-domain-confirmed' => "Échec de la vérification de la propriété d'un domaine.",
'process-error-distlist-ldap-ready' => "Échec de créer une liste de distrubion.",
'process-error-resource-imap-ready' => "Échec de la vérification de l'existence d'un dossier partagé.",
'process-error-resource-ldap-ready' => "Échec de la création d'une ressource.",
'process-error-shared-folder-imap-ready' => "Impossible de vérifier qu'un dossier partagé existe.",
'process-error-shared-folder-ldap-ready' => "Échec de la création d'un dossier partagé.",
'process-error-user-ldap-ready' => "Échec de la création d'un utilisateur.",
'process-error-user-imap-ready' => "Échec de la vérification de l'existence d'une boîte aux lettres.",
'process-resource-new' => "Enregistrement d'une ressource...",
'process-resource-imap-ready' => "Création d'un dossier partagé...",
'process-resource-ldap-ready' => "Création d'un ressource...",
'process-shared-folder-new' => "Enregistrement d'un dossier partagé...",
'process-shared-folder-imap-ready' => "Création d'un dossier partagé...",
'process-shared-folder-ldap-ready' => "Création d'un dossier partagé...",
'distlist-update-success' => "Liste de distribution mis-à-jour avec succès.",
'distlist-create-success' => "Liste de distribution créer avec succès.",
'distlist-delete-success' => "Liste de distribution suppriméee avec succès.",
'distlist-suspend-success' => "Liste de distribution à été suspendue avec succès.",
'distlist-unsuspend-success' => "Liste de distribution à été débloquée avec succès.",
'distlist-setconfig-success' => "Mise à jour des paramètres de la liste de distribution avec succès.",
'domain-create-success' => "Domaine a été crée avec succès.",
'domain-delete-success' => "Domaine supprimé avec succès.",
'domain-verify-success' => "Domaine vérifié avec succès.",
'domain-verify-error' => "Vérification de propriété de domaine à échoué.",
'domain-suspend-success' => "Domaine suspendue avec succès.",
'domain-unsuspend-success' => "Domaine debloqué avec succès.",
'resource-update-success' => "Ressource mise à jour avec succès.",
'resource-create-success' => "Resource crée avec succès.",
'resource-delete-success' => "Ressource suprimmée avec succès.",
'resource-setconfig-success' => "Les paramètres des ressources ont été mis à jour avec succès.",
+ 'room-setconfig-success' => 'La configuration de la salle a été actualisée avec succès.',
+ 'room-unsupported-option-error' => 'Option de configuration de la salle invalide.',
+
'shared-folder-update-success' => "Dossier partagé mis à jour avec succès.",
'shared-folder-create-success' => "Dossier partagé créé avec succès.",
'shared-folder-delete-success' => "Dossier partagé supprimé avec succès.",
'shared-folder-setconfig-success' => "Mise à jour des paramètres du dossier partagé avec succès.",
'user-update-success' => "Mis-à-jour des données de l'utilsateur effectué avec succès.",
'user-create-success' => "Utilisateur a été crée avec succès.",
'user-delete-success' => "Utilisateur a été supprimé avec succès.",
'user-suspend-success' => "Utilisateur a été suspendu avec succès.",
'user-unsuspend-success' => "Utilisateur a été debloqué avec succès.",
'user-reset-2fa-success' => "Réinstallation de l'authentification à 2-Facteur avec succès.",
'user-set-sku-success' => "Souscription ajoutée avec succès.",
'user-set-sku-already-exists' => "La souscription existe déjà.",
'search-foundxdomains' => "Les domaines :x ont été trouvés.",
'search-foundxdistlists' => "Les listes de distribution :x ont été trouvés.",
'search-foundxresources' => "Les ressources :x ont été trouvés.",
'search-foundxusers' => "Les comptes d'utilisateurs :x ont été trouvés.",
'search-foundxshared-folders' => ":x dossiers partagés ont été trouvés.",
'signup-invitations-created' => "L'invitation à été crée.|:count nombre d'invitations ont été crée.",
'signup-invitations-csv-empty' => "Aucune adresses email valides ont été trouvées dans le fichier téléchargé.",
'signup-invitations-csv-invalid-email' => "Une adresse email invalide a été trouvée (:email) on line :line.",
'signup-invitation-delete-success' => "Invitation supprimée avec succès.",
'signup-invitation-resend-success' => "Invitation ajoutée à la file d'attente d'envoi avec succès.",
'support-request-success' => "Demande de soutien soumise avec succès.",
'support-request-error' => "La soumission de demande de soutien a échoué.",
'wallet-award-success' => "Le bonus a été ajouté au portefeuille avec succès.",
'wallet-penalty-success' => "La pénalité a été ajoutée au portefeuille avec succès.",
'wallet-update-success' => "Portefeuille d'utilisateur a été mis-à-jour avec succès.",
'wallet-notice-date' => "Avec vos abonnements actuels, le solde de votre compte durera jusqu'à environ :date (:days).",
'wallet-notice-nocredit' => "Votre crédit a été epuisé, veuillez recharger immédiatement votre solde.",
'wallet-notice-today' => "Votre reste crédit sera épuisé aujourd'hui, veuillez recharger immédiatement.",
'wallet-notice-trial' => "Vous êtes dans votre période d'essai gratuite.",
'wallet-notice-trial-end' => "Vous approchez de la fin de votre période d'essai gratuite, veuillez recharger pour continuer.",
];
diff --git a/src/resources/lang/fr/meet.php b/src/resources/lang/fr/meet.php
index a544a396..aac33ffd 100644
--- a/src/resources/lang/fr/meet.php
+++ b/src/resources/lang/fr/meet.php
@@ -1,30 +1,28 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Pagination Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used by the paginator library to build
| the simple pagination links. You are free to change them to anything
| you want to customize your views to better match your application.
|
*/
'connection-not-found' => 'La connexion n´existe pas.',
'connection-dismiss-error' => 'Échec du rejet de la connexion.',
'room-not-found' => 'La salle n´existe pas.',
- 'room-setconfig-success' => 'La configuration de la salle a été actualisée avec succès.',
- 'room-unsupported-option-error' => 'Option de configuration de la salle invalide.',
'session-not-found' => 'La session n\'existe pas.',
'session-create-error' => 'Échec de la création de la session.',
'session-join-error' => 'Échec de se joindre à la session.',
'session-close-error' => 'Échec de fermer la session.',
'session-close-success' => 'La session a été terminée avec succès.',
'session-password-error' => 'Échec de se joindre à la session. Mot de pas invalide.',
'session-request-accept-error' => 'Echec d\'accepter la demande d\'adhésion',
'session-request-deny-error' => 'Echec de refuser la demande d\'adhésion.',
'session-room-locked-error' => 'Échec de se joindre à la session. Salle verrouillée.',
];
diff --git a/src/resources/lang/fr/ui.php b/src/resources/lang/fr/ui.php
index 2a6db372..e1cbe634 100644
--- a/src/resources/lang/fr/ui.php
+++ b/src/resources/lang/fr/ui.php
@@ -1,482 +1,480 @@
<?php
/**
* This file will be converted to a Vue-i18n compatible JSON format on build time
*
* Note: The Laravel localization features do not work here. Vue-i18n rules are different
*/
return [
'app' => [
'faq' => "FAQ",
],
'btn' => [
'add' => "Ajouter",
'accept' => "Accepter",
'back' => "Back",
'cancel' => "Annuler",
'close' => "Fermer",
'continue' => "Continuer",
'delete' => "Supprimer",
'deny' => "Refuser",
'download' => "Télécharger",
'edit' => "Modifier",
'file' => "Choisir le ficher...",
'moreinfo' => "Plus d'information",
'refresh' => "Actualiser",
'reset' => "Réinitialiser",
'resend' => "Envoyer à nouveau",
'save' => "Sauvegarder",
'search' => "Chercher",
'signup' => "S'inscrire",
'submit' => "Soumettre",
'suspend' => "Suspendre",
'unsuspend' => "Débloquer",
'verify' => "Vérifier",
],
'dashboard' => [
'beta' => "bêta",
'distlists' => "Listes de distribution",
'chat' => "Chat Vidéo",
'domains' => "Domaines",
'invitations' => "Invitations",
'profile' => "Votre profil",
'resources' => "Ressources",
'users' => "D'utilisateurs",
'wallet' => "Portefeuille",
'webmail' => "Webmail",
'stats' => "Statistiques",
],
'distlist' => [
'list-title' => "Liste de distribution | Listes de Distribution",
'create' => "Créer une liste",
'delete' => "Suprimmer une list",
'email' => "Courriel",
'list-empty' => "il n'y a pas de listes de distribution dans ce compte.",
'name' => "Nom",
'new' => "Nouvelle liste de distribution",
'recipients' => "Destinataires",
'sender-policy' => "Liste d'Accès d'Expéditeur",
'sender-policy-text' => "Cette liste vous permet de spécifier qui peut envoyer du courrier à la liste de distribution."
. " Vous pouvez mettre une adresse e-mail complète (jane@kolab.org), un domaine (kolab.org) ou un suffixe (.org)"
. " auquel l'adresse électronique de l'expéditeur est assimilée."
. " Si la liste est vide, le courriels de quiconque est autorisé."
],
'domain' => [
'dns-verify' => "Exemple de vérification du DNS d'un domaine:",
'dns-config' => "Exemple de configuration du DNS d'un domaine:",
'list-empty' => "Il y a pas de domaines dans ce compte.",
'namespace' => "Espace de noms",
'verify' => "Vérification du domaine",
'verify-intro' => "Afin de confirmer que vous êtes bien le titulaire du domaine, nous devons exécuter un processus de vérification avant de l'activer définitivement pour la livraison d'e-mails.",
'verify-dns' => "Le domaine <b>doit avoir l'une des entrées suivantes</b> dans le DNS:",
'verify-dns-txt' => "Entrée TXT avec valeur:",
'verify-dns-cname' => "ou entrée CNAME:",
'verify-outro' => "Lorsque cela est fait, appuyez sur le bouton ci-dessous pour lancer la vérification.",
'verify-sample' => "Voici un fichier de zone simple pour votre domaine:",
'config' => "Configuration du domaine",
'config-intro' => "Afin de permettre à {app} de recevoir le trafic de messagerie pour votre domaine, vous devez ajuster les paramètres DNS, plus précisément les entrées MX, en conséquence.",
'config-sample' => "Modifiez le fichier de zone de votre domaine et remplacez les entrées MX existantes par les valeurs suivantes:",
'config-hint' => "Si vous ne savez pas comment définir les entrées DNS pour votre domaine, veuillez contacter le service d'enregistrement auprès duquel vous avez enregistré le domaine ou votre fournisseur d'hébergement Web.",
'spf-whitelist' => "SPF Whitelist",
'spf-whitelist-text' => "Le Sender Policy Framework permet à un domaine expéditeur de dévoiler, par le biais de DNS,"
. " quels systèmes sont autorisés à envoyer des e-mails avec une adresse d'expéditeur d'enveloppe dans le domaine en question.",
'spf-whitelist-ex' => "Vous pouvez ici spécifier une liste de serveurs autorisés, par exemple: <var>.ess.barracuda.com</var>.",
'create' => "Créer domaine",
'new' => "Nouveau domaine",
'delete' => "Supprimer domaine",
'delete-domain' => "Supprimer {domain}",
'delete-text' => "Voulez-vous vraiment supprimer ce domaine de façon permanente?"
. " Ceci n'est possible que s'il n'y a pas d'utilisateurs, d'alias ou d'autres objets dans ce domaine."
. " Veuillez noter que cette action ne peut pas être inversée.",
],
'error' => [
'400' => "Mauvaide demande",
'401' => "Non autorisé",
'403' => "Accès refusé",
'404' => "Pas trouvé",
'405' => "Méthode non autorisée",
'500' => "Erreur de serveur interne",
'unknown' => "Erreur inconnu",
'server' => "Erreur de serveur",
'form' => "Erreur de validation du formulaire",
],
'form' => [
'acl' => "Droits d'accès",
'acl-full' => "Tout",
'acl-read-only' => "Lecture seulement",
'acl-read-write' => "Lecture-écriture",
'amount' => "Montant",
'anyone' => "Chacun",
'code' => "Le code de confirmation",
'config' => "Configuration",
'date' => "Date",
'description' => "Description",
'details' => "Détails",
'domain' => "Domaine",
'email' => "Adresse e-mail",
'firstname' => "Prénom",
'lastname' => "Nom de famille",
'none' => "aucun",
'or' => "ou",
'password' => "Mot de passe",
'password-confirm' => "Confirmer le mot de passe",
'phone' => "Téléphone",
'shared-folder' => "Dossier partagé",
'status' => "État",
+ 'subscriptions' => "Subscriptions",
'surname' => "Nom de famille",
'type' => "Type",
'user' => "Utilisateur",
'primary-email' => "Email principal",
'id' => "ID",
'created' => "Créé",
'deleted' => "Supprimé",
'disabled' => "Désactivé",
'enabled' => "Activé",
'general' => "Général",
'settings' => "Paramètres",
],
'invitation' => [
'create' => "Créez des invitation(s)",
'create-title' => "Invitation à une inscription",
'create-email' => "Saisissez l'adresse électronique de la personne que vous souhaitez inviter.",
'create-csv' => "Pour envoyer plusieurs invitations à la fois, fournissez un fichier CSV (séparé par des virgules) ou un fichier en texte brut, contenant une adresse e-mail par ligne.",
'list-empty' => "Il y a aucune invitation dans la mémoire de données.",
'title' => "Invitation d'inscription",
'search' => "Adresse E-mail ou domaine",
'send' => "Envoyer invitation(s)",
'status-completed' => "Utilisateur s'est inscrit",
'status-failed' => "L'envoi a échoué",
'status-sent' => "Envoyé",
'status-new' => "Pas encore envoyé",
],
'lang' => [
'en' => "Anglais",
'de' => "Allemand",
'fr' => "Français",
'it' => "Italien",
],
'login' => [
'2fa' => "Code du 2ème facteur",
'2fa_desc' => "Le code du 2ème facteur est facultatif pour les utilisateurs qui n'ont pas configuré l'authentification à deux facteurs.",
'forgot_password' => "Mot de passe oublié?",
'header' => "Veuillez vous connecter",
'sign_in' => "Se connecter",
'webmail' => "Webmail"
],
'meet' => [
- 'title' => "Voix et vidéo-conférence",
'welcome' => "Bienvenue dans notre programme bêta pour les conférences vocales et vidéo.",
- 'url' => "Vous disposez d'une salle avec l'URL ci-dessous. Cette salle ouvre uniquement quand vous y êtes vous-même. Utilisez cette URL pour inviter des personnes à vous rejoindre.",
'notice' => "Il s'agit d'un travail en évolution et d'autres fonctions seront ajoutées au fil du temps. Les fonctions actuelles sont les suivantes:",
'sharing' => "Partage d'écran",
'sharing-text' => "Partagez votre écran pour des présentations ou des exposés.",
'security' => "sécurité de chambre",
'security-text' => "Renforcez la sécurité de la salle en définissant un mot de passe que les participants devront connaître."
. " avant de pouvoir entrer, ou verrouiller la porte afin que les participants doivent frapper, et un modérateur peut accepter ou refuser ces demandes.",
'qa-title' => "Lever la main (Q&A)",
'qa-text' => "Les membres du public silencieux peuvent lever la main pour animer une séance de questions-réponses avec les membres du panel.",
'moderation' => "Délégation des Modérateurs",
'moderation-text' => "Déléguer l'autorité du modérateur pour la séance, afin qu'un orateur ne soit pas inutilement"
. " interrompu par l'arrivée des participants et d'autres tâches du modérateur.",
'eject' => "Éjecter les participants",
'eject-text' => "Éjectez les participants de la session afin de les obliger à se reconnecter ou de remédier aux violations des règles."
. " Cliquez sur l'icône de l'utilisateur pour un renvoi effectif.",
'silent' => "Membres du Public en Silence",
'silent-text' => "Pour une séance de type webinaire, configurez la salle pour obliger tous les nouveaux participants à être des spectateurs silencieux.",
'interpreters' => "Canaux d'Audio Spécifiques de Langues",
'interpreters-text' => "Désignez un participant pour interpréter l'audio original dans une langue cible, pour les sessions avec des participants multilingues."
. " L'interprète doit être capable de relayer l'audio original et de le remplacer.",
'beta-notice' => "Rappelez-vous qu'il s'agit d'une version bêta et pourrait entraîner des problèmes."
. " Au cas où vous rencontreriez des problèmes, n'hésitez pas à nous en faire part en contactant le support.",
// Room options dialog
'options' => "Options de salle",
'password' => "Mot de passe",
'password-none' => "aucun",
'password-clear' => "Effacer mot de passe",
'password-set' => "Définir le mot de passe",
'password-text' => "Vous pouvez ajouter un mot de passe à votre session. Les participants devront fournir le mot de passe avant d'être autorisés à rejoindre la session.",
'lock' => "Salle verrouillée",
'lock-text' => "Lorsque la salle est verrouillée, les participants doivent être approuvés par un modérateur avant de pouvoir rejoindre la réunion.",
'nomedia' => "Réservé aux abonnés",
'nomedia-text' => "Force tous les participants à se joindre en tant qu'abonnés (avec caméra et microphone désactivés)"
. "Les modérateurs pourront les promouvoir en tant qu'éditeurs tout au long de la session.",
// Room menu
'partcnt' => "Nombres de participants",
'menu-audio-mute' => "Désactiver le son",
'menu-audio-unmute' => "Activer le son",
'menu-video-mute' => "Désactiver la vidéo",
'menu-video-unmute' => "Activer la vidéo",
'menu-screen' => "Partager l'écran",
'menu-hand-lower' => "Baisser la main",
'menu-hand-raise' => "Lever la main",
'menu-channel' => "Canal de langue interprétée",
'menu-chat' => "Le Chat",
'menu-fullscreen' => "Plein écran",
'menu-fullscreen-exit' => "Sortir en plein écran",
'menu-leave' => "Quitter la session",
// Room setup screen
'setup-title' => "Préparez votre session",
'mic' => "Microphone",
'cam' => "Caméra",
'nick' => "Surnom",
'nick-placeholder' => "Votre nom",
'join' => "JOINDRE",
'joinnow' => "JOINDRE MAINTENANT",
'imaowner' => "Je suis le propriétaire",
// Room
'qa' => "Q & A",
'leave-title' => "Salle fermée",
'leave-body' => "La session a été fermée par le propriétaire de la salle.",
'media-title' => "Configuration des médias",
'join-request' => "Demande de rejoindre",
'join-requested' => "{user} demandé à rejoindre.",
// Status messages
'status-init' => "Vérification de la salle...",
'status-323' => "La salle est fermée. Veuillez attendre le démarrage de la session par le propriétaire.",
'status-324' => "La salle est fermée. Elle sera ouverte aux autres participants après votre adhésion.",
'status-325' => "La salle est prête. Veuillez entrer un mot de passe valide.",
'status-326' => "La salle est fermée. Veuillez entrer votre nom et réessayer.",
'status-327' => "En attendant la permission de joindre la salle.",
'status-404' => "La salle n'existe pas.",
'status-429' => "Trop de demande. Veuillez, patienter.",
'status-500' => "La connexion à la salle a échoué. Erreur de serveur.",
// Other menus
'media-setup' => "configuration des médias",
'perm' => "Permissions",
'perm-av' => "Publication d'audio et vidéo",
'perm-mod' => "Modération",
'lang-int' => "Interprète de langue",
'menu-options' => "Options",
],
'menu' => [
'cockpit' => "Cockpit",
'login' => "Connecter",
'logout' => "Deconnecter",
'signup' => "S'inscrire",
'toggle' => "Basculer la navigation",
],
'msg' => [
'initializing' => "Initialisation...",
'loading' => "Chargement...",
'loading-failed' => "Échec du chargement des données.",
'notfound' => "Resource introuvable.",
'info' => "Information",
'error' => "Erreur",
'warning' => "Avertissement",
'success' => "Succès",
],
'nav' => [
'more' => "Charger plus",
'step' => "Étape {i}/{n}",
],
'password' => [
'reset' => "Réinitialiser le mot de passe",
'reset-step1' => "Entrez votre adresse e-mail pour réinitialiser votre mot de passe.",
'reset-step1-hint' => "Veuillez vérifier votre dossier de spam ou débloquer {email}.",
'reset-step2' => "Nous avons envoyé un code de confirmation à votre adresse e-mail externe."
. " Entrez le code que nous vous avons envoyé, ou cliquez sur le lien dans le message.",
],
'resource' => [
'create' => "Créer une ressource",
'delete' => "Supprimer une ressource",
'invitation-policy' => "Procédure d'invitation",
'invitation-policy-text' => "Les invitations à des événements pour une ressource sont généralement acceptées automatiquement"
. " si aucun événement n'est en conflit avec le temps demandé. La procédure d'invitation le permet"
. " de rejeter ces demandes ou d'exiger une acceptation manuelle d'un utilisateur spécifique.",
'ipolicy-manual' => "Manuel (provisoire)",
'ipolicy-accept' => "Accepter",
'ipolicy-reject' => "Rejecter",
'list-title' => "Ressource | Ressources",
'list-empty' => "Il y a aucune ressource sur ce compte.",
'new' => "Nouvelle ressource",
],
'shf' => [
'create' => "Créer un dossier",
'delete' => "Supprimer un dossier",
'acl-text' => "Permet de définir les droits d'accès des utilisateurs au dossier partagé..",
'list-title' => "Dossier partagé | Dossiers partagés",
'list-empty' => "Il y a aucun dossier partagé dans ce compte.",
'new' => "Nouvelle dossier",
'type-mail' => "Courriel",
'type-event' => "Calendrier",
'type-contact' => "Carnet d'Adresses",
'type-task' => "Tâches",
'type-note' => "Notes",
'type-file' => "Fichiers",
],
'signup' => [
'email' => "Adresse e-mail actuelle",
'login' => "connecter",
'title' => "S'inscrire",
'step1' => "Inscrivez-vous pour commencer votre mois gratuit.",
'step2' => "Nous avons envoyé un code de confirmation à votre adresse e-mail. Entrez le code que nous vous avons envoyé, ou cliquez sur le lien dans le message.",
'step3' => "Créez votre identité Kolab (vous pourrez choisir des adresses supplémentaires plus tard).",
'voucher' => "Coupon Code",
],
'status' => [
'prepare-account' => "Votre compte est en cours de préparation.",
'prepare-domain' => "Le domain est en cours de préparation.",
'prepare-distlist' => "La liste de distribution est en cours de préparation.",
'prepare-shared-folder' => "Le dossier portagé est en cours de préparation.",
'prepare-user' => "Le compte d'utilisateur est en cours de préparation.",
'prepare-hint' => "Certaines fonctionnalités peuvent être manquantes ou en lecture seule pour le moment.",
'prepare-refresh' => "Le processus ne se termine jamais? Appuyez sur le bouton \"Refresh\", s'il vous plaît.",
'prepare-resource' => "Nous préparons la ressource.",
'ready-account' => "Votre compte est presque prêt.",
'ready-domain' => "Le domaine est presque prêt.",
'ready-distlist' => "La liste de distribution est presque prête.",
'ready-resource' => "La ressource est presque prête.",
'ready-shared-folder' => "Le dossier partagé est presque prêt.",
'ready-user' => "Le compte d'utilisateur est presque prêt.",
'verify' => "Veuillez vérifier votre domaine pour terminer le processus de configuration.",
'verify-domain' => "Vérifier domaine",
'degraded' => "Dégradé",
'deleted' => "Supprimé",
'suspended' => "Suspendu",
'notready' => "Pas Prêt",
'active' => "Actif",
],
'support' => [
'title' => "Contacter Support",
'id' => "Numéro de client ou adresse é-mail que vous avez chez nous.",
'id-pl' => "e.g. 12345678 ou john@kolab.org",
'id-hint' => "Laissez vide si vous n'êtes pas encore client",
'name' => "Nom",
'name-pl' => "comment nous devons vous adresser dans notre réponse",
'email' => "adresse e-mail qui fonctionne",
'email-pl' => "assurez-vous que nous pouvons vous atteindre à cette adresse",
'summary' => "Résumé du problème",
'summary-pl' => "une phrase qui résume votre situation",
'expl' => "Analyse du problème",
],
'user' => [
'2fa-hint1' => "Cela éliminera le droit à l'authentification à 2-Facteurs ainsi que les éléments configurés par l'utilisateur.",
'2fa-hint2' => "Veuillez vous assurer que l'identité de l'utilisateur est correctement confirmée.",
'add-beta' => "Activer le programme bêta",
'address' => "Adresse",
'aliases' => "Alias",
'aliases-none' => "Cet utilisateur n'aucune alias e-mail.",
'add-bonus' => "Ajouter un bonus",
'add-bonus-title' => "Ajouter un bonus au portefeuille",
'add-penalty' => "Ajouter une pénalité",
'add-penalty-title' => "Ajouter une pénalité au portefeuille",
'auto-payment' => "Auto-paiement",
'auto-payment-text' => "Recharger par <b>{amount}</b> quand le montant est inférieur à <b>{balance}</b> utilisant {method}",
'country' => "Pays",
'create' => "Créer un utilisateur",
'custno' => "No. de Client.",
'degraded-warning' => "Le compte est dégradé. Certaines fonctionnalités ont été désactivées.",
'degraded-hint' => "Veuillez effectuer un paiement.",
'delete' => "Supprimer Utilisateur",
'delete-email' => "Supprimer {email}",
'delete-text' => "Voulez-vous vraiment supprimer cet utilisateur de façon permanente?"
. " Cela supprimera toutes les données du compte et retirera la permission d'accéder au compte d'e-email."
. " Veuillez noter que cette action ne peut pas être révoquée.",
'discount' => "Rabais",
'discount-hint' => "rabais appliqué",
'discount-title' => "Rabais de compte",
'distlists' => "Listes de Distribution",
'domains' => "Domaines",
'email-aliases' => "Alias E-mail",
'ext-email' => "E-mail externe",
'finances' => "Finances",
'greylisting' => "Greylisting",
'greylisting-text' => "La greylisting est une méthode de défense des utilisateurs contre le spam."
. " Tout e-mail entrant provenant d'un expéditeur non reconnu est temporairement rejeté."
. " Le serveur d'origine doit réessayer après un délai cette fois-ci, le mail sera accepté."
. " Les spammeurs ne réessayent généralement pas de remettre le mail.",
'list-title' => "Comptes d'utilisateur",
'list-empty' => "Il n'y a aucun utilisateur dans ce compte.",
'managed-by' => "Géré par",
'new' => "Nouveau compte d'utilisateur",
'org' => "Organisation",
'package' => "Paquet",
'price' => "Prix",
'profile-title' => "Votre profile",
'profile-delete' => "Supprimer compte",
'profile-delete-title' => "Supprimer ce compte?",
'profile-delete-text1' => "Cela supprimera le compte ainsi que tous les domaines, utilisateurs et alias associés à ce compte.",
'profile-delete-warning' => "Cette opération est irrévocable",
'profile-delete-text2' => "Comme vous ne pourrez plus rien récupérer après ce point, assurez-vous d'avoir migré toutes les données avant de poursuivre.",
'profile-delete-support' => "Étant donné que nous nous attachons à toujours nous améliorer, nous aimerions vous demander 2 minutes de votre temps. "
. "Le meilleur moyen de nous améliorer est le feedback des utilisateurs, et nous voudrions vous demander"
. "quelques mots sur les raisons pour lesquelles vous avez quitté notre service. Veuillez envoyer vos commentaires au <a href=\"{href}\">{email}</a>.",
'profile-delete-contact' => "Par ailleurs, n'hésitez pas à contacter le support de {app} pour toute question ou souci que vous pourriez avoir dans ce contexte.",
'reset-2fa' => "Réinitialiser l'authentification à 2-Facteurs.",
'reset-2fa-title' => "Réinitialisation de l'Authentification à 2-Facteurs",
'resources' => "Ressources",
'title' => "Compte d'utilisateur",
'search' => "Adresse e-mail ou nom de l'utilisateur",
'search-pl' => "ID utilisateur, e-mail ou domamine",
'skureq' => "{sku} demande {list}.",
'subscription' => "Subscription",
- 'subscriptions' => "Subscriptions",
'subscriptions-none' => "Cet utilisateur n'a pas de subscriptions.",
'users' => "Utilisateurs",
],
'wallet' => [
'add-credit' => "Ajouter un crédit",
'auto-payment-cancel' => "Annuler l'auto-paiement",
'auto-payment-change' => "Changer l'auto-paiement",
'auto-payment-failed' => "La configuration des paiements automatiques a échoué. Redémarrer le processus pour activer les top-ups automatiques.",
'auto-payment-hint' => "Cela fonctionne de la manière suivante: Chaque fois que votre compte est épuisé, nous débiterons votre méthode de paiement préférée d'un montant que vous aurez défini."
. " Vous pouvez annuler ou modifier l'option de paiement automatique à tout moment.",
'auto-payment-setup' => "configurer l'auto-paiement",
'auto-payment-disabled' => "L'auto-paiement configuré a été désactivé. Rechargez votre porte-monnaie ou augmentez le montant d'auto-paiement.",
'auto-payment-info' => "L'auto-paiement est <b>set</b> pour recharger votre compte par <b>{amount}</b> lorsque le solde de votre compte devient inférieur à <b>{balance}</b>.",
'auto-payment-inprogress' => "La configuration d'auto-paiement est toujours en cours.",
'auto-payment-next' => "Ensuite, vous serez redirigé vers la page de paiement, où vous pourrez fournir les coordonnées de votre carte de crédit.",
'auto-payment-disabled-next' => "L'auto-paiement est désactivé. Dès que vous aurez soumis de nouveaux paramètres, nous l'activerons et essaierons de recharger votre portefeuille.",
'auto-payment-update' => "Mise à jour de l'auto-paiement.",
'banktransfer-hint' => "Veuillez noter qu'un virement bancaire peut nécessiter plusieurs jours avant d'être effectué.",
'currency-conv' => "Le principe est le suivant: Vous spécifiez le montant dont vous voulez recharger votre portefeuille en {wc}."
. " Nous convertirons ensuite ce montant en {pc}, et sur la page suivante, vous obtiendrez les coordonnées bancaires pour transférer le montant en {pc}.",
'fill-up' => "Recharger par",
'history' => "Histoire",
'month' => "mois",
'noperm' => "Seuls les propriétaires de compte peuvent accéder à un portefeuille.",
'payment-amount-hint' => "Choisissez le montant dont vous voulez recharger votre portefeuille.",
'payment-method' => "Mode de paiement: {method}",
'payment-warning' => "Vous serez facturé pour {price}.",
'pending-payments' => "Paiements en attente",
'pending-payments-warning' => "Vous avez des paiements qui sont encore en cours. Voir l'onglet \"Paiements en attente\" ci-dessous.",
'pending-payments-none' => "Il y a aucun paiement en attente pour ce compte.",
'receipts' => "Reçus",
'receipts-hint' => "Vous pouvez télécharger ici les reçus (au format PDF) pour les paiements de la période spécifiée. Sélectionnez la période et appuyez sur le bouton Télécharger.",
'receipts-none' => "Il y a aucun reçu pour les paiements de ce compte. Veuillez noter que vous pouvez télécharger les reçus après la fin du mois.",
'title' => "Solde du compte",
'top-up' => "Rechargez votre portefeuille",
'transactions' => "Transactions",
'transactions-none' => "Il y a aucun transaction pour ce compte.",
'when-below' => "lorsque le solde du compte est inférieur à",
],
];
diff --git a/src/resources/vue/Dashboard.vue b/src/resources/vue/Dashboard.vue
index 358ee538..a7a11a5a 100644
--- a/src/resources/vue/Dashboard.vue
+++ b/src/resources/vue/Dashboard.vue
@@ -1,105 +1,105 @@
<template>
<div class="container" dusk="dashboard-component">
<status-component :status="status" @status-update="statusUpdate"></status-component>
<div id="dashboard-nav">
<router-link class="card link-profile" :to="{ name: 'profile' }">
<svg-icon icon="user-gear"></svg-icon><span>{{ $t('dashboard.profile') }}</span>
</router-link>
<router-link v-if="status.enableDomains" class="card link-domains" :to="{ name: 'domains' }">
<svg-icon icon="globe"></svg-icon><span>{{ $t('dashboard.domains') }}</span>
</router-link>
<router-link v-if="status.enableUsers" class="card link-users" :to="{ name: 'users' }">
<svg-icon icon="user-group"></svg-icon><span>{{ $t('dashboard.users') }}</span>
</router-link>
<router-link v-if="status.enableDistlists" class="card link-distlists" :to="{ name: 'distlists' }">
<svg-icon icon="users"></svg-icon><span>{{ $t('dashboard.distlists') }}</span>
<span class="badge bg-primary">{{ $t('dashboard.beta') }}</span>
</router-link>
<router-link v-if="status.enableResources" class="card link-resources" :to="{ name: 'resources' }">
<svg-icon icon="gear"></svg-icon><span>{{ $t('dashboard.resources') }}</span>
<span class="badge bg-primary">{{ $t('dashboard.beta') }}</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>{{ $t('dashboard.shared-folders') }}</span>
<span class="badge bg-primary">{{ $t('dashboard.beta') }}</span>
</router-link>
<router-link v-if="status.enableWallets" class="card link-wallet" :to="{ name: 'wallet' }">
<svg-icon icon="wallet"></svg-icon><span>{{ $t('dashboard.wallet') }}</span>
<span v-if="balance < 0" class="badge bg-danger">{{ $root.price(balance, currency) }}</span>
</router-link>
- <router-link v-if="$root.hasSKU('meet') && !$root.isDegraded()" class="card link-chat" :to="{ name: 'rooms' }">
+ <router-link v-if="status.enableRooms" class="card link-chat" :to="{ name: 'rooms' }">
<svg-icon icon="comments"></svg-icon><span>{{ $t('dashboard.chat') }}</span>
<span class="badge bg-primary">{{ $t('dashboard.beta') }}</span>
</router-link>
- <router-link v-if="status.enableFiles && !$root.isDegraded()" class="card link-files" :to="{ name: 'files' }">
+ <router-link v-if="status.enableFiles" class="card link-files" :to="{ name: 'files' }">
<svg-icon icon="folder-closed"></svg-icon><span>{{ $t('dashboard.files') }}</span>
<span class="badge bg-primary">{{ $t('dashboard.beta') }}</span>
</router-link>
<router-link v-if="status.enableSettings" class="card link-settings" :to="{ name: 'settings' }">
<svg-icon icon="sliders"></svg-icon><span>{{ $t('dashboard.settings') }}</span>
</router-link>
<a v-if="webmailURL" class="card link-webmail" :href="webmailURL">
<svg-icon icon="envelope"></svg-icon><span>{{ $t('dashboard.webmail') }}</span>
</a>
<router-link v-if="status.enableCompanionapps" class="card link-companionapp" :to="{ name: 'companion' }">
<svg-icon icon="mobile-screen"></svg-icon><span>{{ $t('dashboard.companion') }}</span>
<span class="badge bg-primary">{{ $t('dashboard.beta') }}</span>
</router-link>
</div>
</div>
</template>
<script>
import StatusComponent from './Widgets/Status'
import { library } from '@fortawesome/fontawesome-svg-core'
library.add(
require('@fortawesome/free-solid-svg-icons/faComments').definition,
require('@fortawesome/free-solid-svg-icons/faDownload').definition,
require('@fortawesome/free-solid-svg-icons/faEnvelope').definition,
require('@fortawesome/free-solid-svg-icons/faFolderOpen').definition,
require('@fortawesome/free-solid-svg-icons/faFolderClosed').definition,
require('@fortawesome/free-solid-svg-icons/faGear').definition,
require('@fortawesome/free-solid-svg-icons/faGlobe').definition,
require('@fortawesome/free-solid-svg-icons/faMobileScreen').definition,
require('@fortawesome/free-solid-svg-icons/faSliders').definition,
require('@fortawesome/free-solid-svg-icons/faUserGear').definition,
require('@fortawesome/free-solid-svg-icons/faUsers').definition,
require('@fortawesome/free-solid-svg-icons/faUserGroup').definition,
require('@fortawesome/free-solid-svg-icons/faWallet').definition,
)
export default {
components: {
StatusComponent
},
data() {
return {
status: {},
balance: 0,
currency: '',
webmailURL: window.config['app.webmail_url']
}
},
mounted() {
this.status = this.$root.authInfo.statusInfo
this.getBalance(this.$root.authInfo)
},
methods: {
getBalance(authInfo) {
this.balance = 0;
// TODO: currencies, multi-wallets, accounts
authInfo.wallets.forEach(wallet => {
this.balance += wallet.balance
this.currency = wallet.currency
})
},
statusUpdate(user) {
this.status = Object.assign({}, this.status, user)
this.$root.authInfo.statusInfo = this.status
}
}
}
</script>
diff --git a/src/resources/vue/Meet/RoomOptions.vue b/src/resources/vue/Meet/RoomOptions.vue
index 0dd81d2c..e45b017a 100644
--- a/src/resources/vue/Meet/RoomOptions.vue
+++ b/src/resources/vue/Meet/RoomOptions.vue
@@ -1,105 +1,105 @@
<template>
<modal-dialog v-if="config" id="room-options-dialog" ref="dialog" :title="$t('meet.options')">
<form id="room-options-password">
<div id="password-input" class="input-group input-group-activable mb-2">
<span class="input-group-text label">{{ $t('meet.password') }}:</span>
<span v-if="config.password" id="password-input-text" class="input-group-text">{{ config.password }}</span>
<span v-else id="password-input-text" class="input-group-text text-muted">{{ $t('meet.password-none') }}</span>
<input type="text" :value="config.password" name="password" class="form-control rounded-start activable">
<btn @click="passwordSave" id="password-save-btn" class="btn-outline-primary activable rounded-end">{{ $t('btn.save') }}</btn>
<btn v-if="config.password" id="password-clear-btn" @click="passwordClear" class="btn-outline-danger rounded">{{ $t('meet.password-clear') }}</btn>
<btn v-else @click="passwordSet" id="password-set-btn" class="btn-outline-primary rounded">{{ $t('meet.password-set') }}</btn>
</div>
<small class="text-muted">
{{ $t('meet.password-text') }}
</small>
</form>
<hr>
<form id="room-options-lock">
<div id="room-lock" class="mb-2">
<label for="room-lock-input">{{ $t('meet.lock') }}:</label>
<input type="checkbox" id="room-lock-input" name="lock" value="1" :checked="config.locked" @click="lockSave">
</div>
<small class="text-muted">
{{ $t('meet.lock-text') }}
</small>
</form>
<hr>
<form id="room-options-nomedia">
<div id="room-nomedia" class="mb-2">
<label for="room-nomedia-input">{{ $t('meet.nomedia') }}:</label>
<input type="checkbox" id="room-nomedia-input" name="lock" value="1" :checked="config.nomedia" @click="nomediaSave">
</div>
<small class="text-muted">
{{ $t('meet.nomedia-text') }}
</small>
</form>
</modal-dialog>
</template>
<script>
import ModalDialog from '../Widgets/ModalDialog'
export default {
components: {
ModalDialog
},
props: {
config: { type: Object, default: () => null },
room: { type: String, default: () => null }
},
mounted() {
$('#room-options-dialog')[0].addEventListener('show.bs.modal', e => {
$(e.target).find('.input-group-activable.active').removeClass('active')
})
},
methods: {
configSave(name, value, callback) {
const post = { [name]: value }
- axios.post('/api/v4/meet/rooms/' + this.room + '/config', post)
+ axios.post('/api/v4/rooms/' + this.room + '/config', post)
.then(response => {
this.$set(this.config, name, value)
if (callback) {
callback(response.data)
}
this.$emit('config-update', this.config)
this.$toast.success(response.data.message)
})
},
lockSave(e) {
this.configSave('locked', $(e.target).prop('checked') ? 1 : 0)
},
nomediaSave(e) {
this.configSave('nomedia', $(e.target).prop('checked') ? 1 : 0)
},
passwordClear() {
this.configSave('password', '')
},
passwordSave() {
this.configSave('password', $('#password-input input').val(), () => {
$('#password-input').removeClass('active')
})
},
passwordSet() {
$('#password-input').addClass('active').find('input')
.off('keydown.pass')
.on('keydown.pass', e => {
if (e.which == 13) {
// On ENTER save the password
this.passwordSave()
e.preventDefault()
} else if (e.which == 27) {
// On ESC escape from the input, but not the dialog
$('#password-input').removeClass('active')
e.stopPropagation()
}
})
.focus()
},
show() {
this.$refs.dialog.show()
}
}
}
</script>
diff --git a/src/resources/vue/Room/Info.vue b/src/resources/vue/Room/Info.vue
new file mode 100644
index 00000000..7dafc1bc
--- /dev/null
+++ b/src/resources/vue/Room/Info.vue
@@ -0,0 +1,166 @@
+<template>
+ <div class="container">
+ <div id="room-info" class="card">
+ <div class="card-body">
+ <div class="card-title" v-if="room.id">
+ {{ $t('room.title', { name: room.name }) }}
+ <btn v-if="room.canDelete" class="btn-outline-danger button-delete float-end" @click="roomDelete" icon="trash-can">{{ $t('room.delete') }}</btn>
+ </div>
+ <div class="card-title" v-else>{{ $t('room.new') }}</div>
+ <div class="card-text">
+ <div id="room-intro" class="pt-2">
+ <p v-if="room.id">{{ $t('room.url') }}</p>
+ <p v-if="room.id" class="text-center"><router-link :to="roomRoute">{{ href }}</router-link></p>
+ <p v-if="!room.id">{{ $t('room.new-hint') }}</p>
+ </div>
+ <tabs class="mt-3" :tabs="tabs"></tabs>
+ <div class="tab-content">
+ <div v-if="!room.id || room.isOwner" class="tab-pane show active" id="general" role="tabpanel" aria-labelledby="tab-general">
+ <form @submit.prevent="submit" class="card-body">
+ <div class="row mb-3">
+ <label for="description" class="col-sm-4 col-form-label">{{ $t('form.description') }}</label>
+ <div class="col-sm-8">
+ <input type="text" class="form-control" id="description" v-model="room.description">
+ <small class="form-text">{{ $t('room.description-hint') }}</small>
+ </div>
+ </div>
+ <div v-if="room_id === 'new' || room.isOwner" id="room-skus" class="row mb-3">
+ <label class="col-sm-4 col-form-label">{{ $t('form.subscriptions') }}</label>
+ <subscription-select class="col-sm-8 pt-sm-1" ref="skus" :object="room" type="room"></subscription-select>
+ </div>
+ <btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
+ </form>
+ </div>
+ <div v-if="room.canUpdate" :class="'tab-pane' + (!tabs.includes('form.general') ? ' show active' : '')" id="settings" role="tabpanel" aria-labelledby="tab-settings">
+ <form @submit.prevent="submitSettings" class="card-body">
+ <div class="row mb-3">
+ <label for="password" class="col-sm-4 col-form-label">{{ $t('form.password') }}</label>
+ <div class="col-sm-8">
+ <input type="text" class="form-control" v-model="room.config.password">
+ <span class="form-text">{{ $t('meet.password-text') }}</span>
+ </div>
+ </div>
+ <div class="row mb-3">
+ <label for="room-lock-input" class="col-sm-4 col-form-label">{{ $t('meet.lock') }}</label>
+ <div class="col-sm-8">
+ <input type="checkbox" id="room-lock-input" class="form-check-input d-block" v-model="room.config.locked">
+ <small class="form-text">{{ $t('meet.lock-text') }}</small>
+ </div>
+ </div>
+ <div class="row mb-3">
+ <label for="room-nomedia-input" class="col-sm-4 col-form-label">{{ $t('meet.nomedia') }}</label>
+ <div class="col-sm-8">
+ <input type="checkbox" id="room-nomedia-input" class="form-check-input d-block" v-model="room.config.nomedia">
+ <small class="form-text">{{ $t('meet.nomedia-text') }}</small>
+ </div>
+ </div>
+ <div v-if="room.canShare" class="row mb-3">
+ <label for="acl-input" class="col-sm-4 col-form-label">{{ $t('room.moderators') }}</label>
+ <div class="col-sm-8">
+ <acl-input id="acl" v-model="room.config.acl" :list="room.config.acl" :useronly="true" :types="['full']"></acl-input>
+ <small class="form-text">{{ $t('room.moderators-text') }}</small>
+ </div>
+ </div>
+ <btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
+ </form>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+ import AclInput from '../Widgets/AclInput'
+ import SubscriptionSelect from '../Widgets/SubscriptionSelect'
+
+ export default {
+ components: {
+ AclInput,
+ SubscriptionSelect
+ },
+ data() {
+ return {
+ href: '',
+ room_id: '',
+ room: { config: { acl: [] } },
+ roomRoute: ''
+ }
+ },
+ computed: {
+ tabs() {
+ let tabs = []
+
+ if (!this.room.id || this.room.isOwner) {
+ tabs.push('form.general')
+ }
+
+ if (this.room.canUpdate) {
+ tabs.push('form.settings')
+ }
+
+ return tabs
+ },
+ },
+ created() {
+ this.room_id = this.$route.params.room
+
+ if (this.room_id != 'new') {
+ axios.get('/api/v4/rooms/' + this.room_id, { loader: true })
+ .then(response => {
+ this.room = response.data
+ this.roomRoute = '/meet/' + encodeURI(this.room.name)
+ this.href = window.config['app.url'] + this.roomRoute
+ })
+ .catch(this.$root.errorHandler)
+ }
+ },
+ mounted() {
+ $('#description').focus()
+ },
+ methods: {
+ roomDelete() {
+ axios.delete('/api/v4/rooms/' + this.room.id)
+ .then(response => {
+ if (response.data.status == 'success') {
+ this.$toast.success(response.data.message)
+ this.$router.push('/rooms')
+ }
+ })
+ },
+ submit() {
+ this.$root.clearFormValidation($('#general form'))
+
+ let method = 'post'
+ let location = '/api/v4/rooms'
+ let post = this.$root.pick(this.room, ['description'])
+
+ if (this.room.id) {
+ method = 'put'
+ location += '/' + this.room.id
+ }
+
+ if (this.$refs.skus) {
+ post.skus = this.$refs.skus.getSkus()
+ }
+
+ axios[method](location, post)
+ .then(response => {
+ this.$toast.success(response.data.message)
+ this.$router.push('/rooms')
+ })
+ },
+ submitSettings() {
+ this.$root.clearFormValidation($('#settings form'))
+
+ const post = this.$root.pick(this.room.config, [ 'password', 'acl', 'locked', 'nomedia' ])
+
+ axios.post('/api/v4/rooms/' + this.room.id + '/config', post)
+ .then(response => {
+ this.$toast.success(response.data.message)
+ })
+ }
+ }
+ }
+</script>
diff --git a/src/resources/vue/Room/List.vue b/src/resources/vue/Room/List.vue
new file mode 100644
index 00000000..5503bafc
--- /dev/null
+++ b/src/resources/vue/Room/List.vue
@@ -0,0 +1,81 @@
+<template>
+ <div class="container">
+ <div id="rooms-list" class="card">
+ <div class="card-body">
+ <div class="card-title">
+ {{ $t('room.list-title') }}
+ <small><sup class="badge bg-primary">{{ $t('dashboard.beta') }}</sup></small>
+ <btn-router v-if="!$root.isDegraded() && $root.hasPermission('settings')" to="room/new" class="btn-success float-end" icon="comments">
+ {{ $t('room.create') }}
+ </btn-router>
+ </div>
+ <div class="card-text">
+ <list-table :list="rooms" :setup="setup">
+ <template #buttons="{ item }">
+ <btn class="btn-link p-0 ms-1 lh-1" @click="goto(item)" icon="arrow-up-right-from-square" :title="$t('room.goto')"></btn>
+ </template>
+ </list-table>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+ import { ListTable } from '../Widgets/ListTools'
+ import { library } from '@fortawesome/fontawesome-svg-core'
+
+ library.add(
+ require('@fortawesome/free-solid-svg-icons/faArrowUpRightFromSquare').definition,
+ require('@fortawesome/free-solid-svg-icons/faComments').definition
+ )
+
+ export default {
+ components: {
+ ListTable
+ },
+ data() {
+ return {
+ rooms: []
+ }
+ },
+ computed: {
+ setup() {
+ let setup = {
+ buttons: true,
+ model: 'room',
+ columns: [
+ {
+ prop: 'name',
+ icon: 'comments',
+ link: true
+ },
+ {
+ prop: 'description',
+ link: true
+ }
+ ]
+ }
+
+ if (!this.$root.hasPermission('settings')) {
+ setup.footLabel = 'room.list-empty-nocontroller'
+ }
+
+ return setup
+ }
+ },
+ mounted() {
+ axios.get('/api/v4/rooms', { loader: true })
+ .then(response => {
+ this.rooms = response.data.list
+ })
+ .catch(this.$root.errorHandler)
+ },
+ methods: {
+ goto(room) {
+ const location = window.config['app.url'] + '/meet/' + encodeURI(room.name)
+ window.open(location, '_blank')
+ }
+ }
+ }
+</script>
diff --git a/src/resources/vue/Rooms.vue b/src/resources/vue/Rooms.vue
deleted file mode 100644
index 013677ef..00000000
--- a/src/resources/vue/Rooms.vue
+++ /dev/null
@@ -1,60 +0,0 @@
-<template>
- <div class="container" dusk="rooms-component">
- <div id="meet-rooms" class="card">
- <div class="card-body">
- <div class="card-title">{{ $t('meet.title') }} <small><sup class="badge bg-primary">{{ $t('dashboard.beta') }}</sup></small></div>
- <div class="card-text">
- <p>{{ $t('meet.welcome') }}</p>
- <p>{{ $t('meet.url') }}</p>
- <p><router-link v-if="href" :to="roomRoute">{{ href }}</router-link></p>
- <p>{{ $t('meet.notice') }}</p>
- <dl>
- <dt>{{ $t('meet.sharing') }}</dt>
- <dd>{{ $t('meet.sharing-text') }}</dd>
- <dt>{{ $t('meet.security') }}</dt>
- <dd>{{ $t('meet.security-text') }}</dd>
- <dt>{{ $t('meet.qa-title') }}</dt>
- <dd>{{ $t('meet.qa-text') }}</dd>
- <dt>{{ $t('meet.moderation') }}</dt>
- <dd>{{ $t('meet.moderation-text') }}</dd>
- <dt>{{ $t('meet.eject') }}</dt>
- <dd>{{ $t('meet.eject-text') }}</dd>
- <dt>{{ $t('meet.silent') }}</dt>
- <dd>{{ $t('meet.silent-text') }}</dd>
- <dt>{{ $t('meet.interpreters') }}</dt>
- <dd>{{ $t('meet.interpreters-text') }}</dd>
- </dl>
- <p>{{ $t('meet.beta-notice') }}</p>
- </div>
- </div>
- </div>
- </div>
-</template>
-
-<script>
- export default {
- data() {
- return {
- rooms: [],
- href: '',
- roomRoute: ''
- }
- },
- mounted() {
- if (!this.$root.hasSKU('meet') || this.$root.isDegraded()) {
- this.$root.errorPage(403)
- return
- }
-
- axios.get('/api/v4/meet/rooms', { loader: true })
- .then(response => {
- this.rooms = response.data.list
- if (response.data.count) {
- this.roomRoute = '/meet/' + encodeURI(this.rooms[0].name)
- this.href = window.config['app.url'] + this.roomRoute
- }
- })
- .catch(this.$root.errorHandler)
- }
- }
-</script>
diff --git a/src/resources/vue/User/Info.vue b/src/resources/vue/User/Info.vue
index 281f5ba3..75474edc 100644
--- a/src/resources/vue/User/Info.vue
+++ b/src/resources/vue/User/Info.vue
@@ -1,297 +1,289 @@
<template>
<div class="container">
<status-component v-if="user_id !== 'new'" :status="status" @status-update="statusUpdate"></status-component>
<div class="card" id="user-info">
<div class="card-body">
<div class="card-title" v-if="user_id !== 'new'">{{ $t('user.title') }}
<btn icon="trash-can" class="btn-outline-danger button-delete float-end" @click="showDeleteConfirmation()">
{{ $t('user.delete') }}
</btn>
</div>
<div class="card-title" v-if="user_id === 'new'">{{ $t('user.new') }}</div>
<div class="card-text">
<tabs class="mt-3" :tabs="user_id === 'new' ? ['form.general'] : ['form.general','form.settings']"></tabs>
<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="user_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.statusClass(user) + ' form-control-plaintext'" id="status">{{ $root.statusText(user) }}</span>
</div>
</div>
<div class="row mb-3">
<label for="first_name" class="col-sm-4 col-form-label">{{ $t('form.firstname') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="first_name" v-model="user.first_name">
</div>
</div>
<div class="row mb-3">
<label for="last_name" class="col-sm-4 col-form-label">{{ $t('form.lastname') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="last_name" v-model="user.last_name">
</div>
</div>
<div class="row mb-3">
<label for="organization" class="col-sm-4 col-form-label">{{ $t('user.org') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="organization" v-model="user.organization">
</div>
</div>
<div 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="user_id !== 'new'" required v-model="user.email">
</div>
</div>
<div class="row mb-3">
<label for="aliases-input" class="col-sm-4 col-form-label">{{ $t('user.email-aliases') }}</label>
<div class="col-sm-8">
<list-input id="aliases" :list="user.aliases"></list-input>
</div>
</div>
<div class="row mb-3">
<label for="password" class="col-sm-4 col-form-label">{{ $t('form.password') }}</label>
<div class="col-sm-8">
<div v-if="!isSelf" class="btn-group w-100" role="group">
<input type="checkbox" id="pass-mode-input" value="input" class="btn-check" @change="setPasswordMode" :checked="passwordMode == 'input'">
<label class="btn btn-outline-secondary" for="pass-mode-input">{{ $t('user.pass-input') }}</label>
<input type="checkbox" id="pass-mode-link" value="link" class="btn-check" @change="setPasswordMode">
<label class="btn btn-outline-secondary" for="pass-mode-link">{{ $t('user.pass-link') }}</label>
</div>
<password-input v-if="passwordMode == 'input'" :class="isSelf ? '' : 'mt-2'" v-model="user"></password-input>
<div id="password-link" v-if="passwordMode == 'link' || user.passwordLinkCode" class="mt-2">
<span>{{ $t('user.pass-link-label') }}</span>&nbsp;<code>{{ passwordLink }}</code>
<span class="d-inline-block">
<btn class="btn-link p-1" :icon="['far', 'clipboard']" :title="$t('btn.copy')" @click="passwordLinkCopy"></btn>
<btn v-if="user.passwordLinkCode" class="btn-link text-danger p-1" icon="trash-can" :title="$t('btn.delete')" @click="passwordLinkDelete"></btn>
</span>
<div v-if="!user.passwordLinkCode" class="form-text m-0">{{ $t('user.pass-link-hint') }}</div>
</div>
</div>
</div>
<div v-if="user_id === 'new'" id="user-packages" class="row mb-3">
<label class="col-sm-4 col-form-label">{{ $t('user.package') }}</label>
<package-select class="col-sm-8 pt-sm-1"></package-select>
</div>
<div v-if="user_id !== 'new'" id="user-skus" class="row mb-3">
<label class="col-sm-4 col-form-label">{{ $t('form.subscriptions') }}</label>
- <subscription-select v-if="user.id" class="col-sm-8 pt-sm-1" :object="user"></subscription-select>
+ <subscription-select v-if="user.id" class="col-sm-8 pt-sm-1" :object="user" ref="skus"></subscription-select>
</div>
<btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
</form>
</div>
<div class="tab-pane" id="settings" role="tabpanel" aria-labelledby="tab-settings">
<form @submit.prevent="submitSettings" class="card-body">
<div class="row checkbox mb-3">
<label for="greylist_enabled" class="col-sm-4 col-form-label">{{ $t('user.greylisting') }}</label>
<div class="col-sm-8 pt-2">
<input type="checkbox" id="greylist_enabled" name="greylist_enabled" value="1" class="form-check-input d-block mb-2" :checked="user.config.greylist_enabled">
<small id="greylisting-hint" class="text-muted">
{{ $t('user.greylisting-text') }}
</small>
</div>
</div>
<btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
</form>
</div>
</div>
</div>
</div>
</div>
<modal-dialog id="delete-warning" ref="deleteWarning" :buttons="['delete']" :cancel-focus="true" @click="deleteUser()"
:title="$t('user.delete-email', { email: user.email })"
>
<p>{{ $t('user.delete-text') }}</p>
</modal-dialog>
</div>
</template>
<script>
import ListInput from '../Widgets/ListInput'
import ModalDialog from '../Widgets/ModalDialog'
import PackageSelect from '../Widgets/PackageSelect'
import PasswordInput from '../Widgets/PasswordInput'
import StatusComponent from '../Widgets/Status'
import SubscriptionSelect from '../Widgets/SubscriptionSelect'
import { library } from '@fortawesome/fontawesome-svg-core'
library.add(
require('@fortawesome/free-regular-svg-icons/faClipboard').definition,
)
export default {
components: {
ListInput,
ModalDialog,
PackageSelect,
PasswordInput,
StatusComponent,
SubscriptionSelect
},
data() {
return {
passwordLinkCode: '',
passwordMode: '',
user_id: null,
user: { aliases: [], config: [] },
status: {}
}
},
computed: {
isSelf: function () {
return this.user_id == this.$root.authInfo.id
},
passwordLink: function () {
return this.$root.appUrl + '/password-reset/' + this.passwordLinkCode
}
},
created() {
this.user_id = this.$route.params.user
if (this.user_id !== 'new') {
axios.get('/api/v4/users/' + this.user_id, { loader: true })
.then(response => {
this.user = response.data
this.user.first_name = response.data.settings.first_name
this.user.last_name = response.data.settings.last_name
this.user.organization = response.data.settings.organization
this.status = response.data.statusInfo
this.passwordLinkCode = this.user.passwordLinkCode
})
.catch(this.$root.errorHandler)
if (this.isSelf) {
this.passwordMode = 'input'
}
} else {
this.passwordMode = 'input'
}
},
mounted() {
$('#first_name').focus()
},
methods: {
passwordLinkCopy() {
navigator.clipboard.writeText($('#password-link code').text());
},
passwordLinkDelete() {
this.passwordMode = ''
$('#pass-mode-link')[0].checked = false
// Delete the code for real
axios.delete('/api/v4/password-reset/code/' + this.passwordLinkCode)
.then(response => {
this.passwordLinkCode = ''
this.user.passwordLinkCode = ''
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
}
})
},
setPasswordMode(event) {
const mode = event.target.checked ? event.target.value : ''
// In the "new user" mode the password mode cannot be unchecked
if (!mode && this.user_id === 'new') {
event.target.checked = true
return
}
this.passwordMode = mode
if (!event.target.checked) {
return
}
$('#pass-mode-' + (mode == 'link' ? 'input' : 'link'))[0].checked = false
// Note: we use $nextTick() because we have to wait for the HTML elements to exist
this.$nextTick().then(() => {
if (mode == 'link' && !this.passwordLinkCode) {
axios.post('/api/v4/password-reset/code', {}, { loader: '#password-link' })
.then(response => {
this.passwordLinkCode = response.data.short_code + '-' + response.data.code
})
} else if (mode == 'input') {
$('#password').focus();
}
})
},
submit() {
this.$root.clearFormValidation($('#general form'))
let method = 'post'
let location = '/api/v4/users'
let post = this.$root.pick(this.user, ['aliases', 'email', 'first_name', 'last_name', 'organization'])
if (this.user_id !== 'new') {
method = 'put'
location += '/' + this.user_id
-
- let skus = {}
- $('#user-skus input[type=checkbox]:checked').each((idx, input) => {
- let id = $(input).val()
- let range = $(input).parents('tr').first().find('input[type=range]').val()
-
- skus[id] = range || 1
- })
- post.skus = skus
+ post.skus = this.$refs.skus.getSkus()
} else {
post.package = $('#user-packages input:checked').val()
}
if (this.passwordMode == 'link' && this.passwordLinkCode) {
post.passwordLinkCode = this.passwordLinkCode
} else if (this.passwordMode == 'input') {
post.password = this.user.password
post.password_confirmation = this.user.password_confirmation
}
axios[method](location, post)
.then(response => {
if (response.data.statusInfo) {
this.$root.authInfo.statusInfo = response.data.statusInfo
}
this.$toast.success(response.data.message)
this.$router.push({ name: 'users' })
})
},
submitSettings() {
this.$root.clearFormValidation($('#settings form'))
let post = { greylist_enabled: $('#greylist_enabled').prop('checked') ? 1 : 0 }
axios.post('/api/v4/users/' + this.user_id + '/config', post)
.then(response => {
this.$toast.success(response.data.message)
})
},
statusUpdate(user) {
this.user = Object.assign({}, this.user, user)
},
deleteUser() {
// Delete the user from the confirm dialog
axios.delete('/api/v4/users/' + this.user_id)
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.$router.push({ name: 'users' })
}
})
},
showDeleteConfirmation() {
if (this.user_id == this.$root.authInfo.id) {
// Deleting self, redirect to /profile/delete page
this.$router.push({ name: 'profile-delete' })
} else {
// Display the warning
this.$refs.deleteWarning.show()
}
}
}
}
</script>
diff --git a/src/resources/vue/Widgets/AclInput.vue b/src/resources/vue/Widgets/AclInput.vue
index 82ae55fd..47befca4 100644
--- a/src/resources/vue/Widgets/AclInput.vue
+++ b/src/resources/vue/Widgets/AclInput.vue
@@ -1,119 +1,123 @@
<template>
<div class="list-input acl-input" :id="id">
<div class="input-group">
<select v-if="!useronly" 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">
+ <select v-if="types.length > 1" 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">
+ <select v-if="types.length > 1" 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-can"></svg-icon><span class="visually-hidden">{{ $t('btn.delete') }}</span>
</a>
</div>
</div>
</template>
<script>
+ const DEFAULT_TYPES = [ 'read-only', 'read-write', 'full' ]
+
export default {
props: {
list: { type: Array, default: () => [] },
id: { type: String, default: '' },
+ types: { type: Array, default: () => DEFAULT_TYPES },
useronly: { type: Boolean, default: false }
},
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)
+ const perm = this.types.length > 1 ? this.perm : this.types[0]
+
+ this.$set(this.list, this.list.length, value + ', ' + 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 perm = this.types.length > 1 ? $(elem).find('select.acl').val() : this.types[0]
const value = $(elem).find('input').val()
this.$set(this.list, index, value + ', ' + perm)
})
}
}
}
</script>
diff --git a/src/resources/vue/Widgets/SubscriptionSelect.vue b/src/resources/vue/Widgets/SubscriptionSelect.vue
index 165499f2..b08af731 100644
--- a/src/resources/vue/Widgets/SubscriptionSelect.vue
+++ b/src/resources/vue/Widgets/SubscriptionSelect.vue
@@ -1,214 +1,247 @@
<template>
<div>
<table class="table table-sm form-list">
<thead class="visually-hidden">
<tr>
<th scope="col"></th>
<th scope="col">{{ $t('user.subscription') }}</th>
<th scope="col">{{ $t('user.price') }}</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
<tr v-for="sku in skus" :id="'s' + sku.id" :key="sku.id">
<td class="selection">
<input type="checkbox" @input="onInputSku"
:value="sku.id"
:disabled="sku.readonly || readonly"
:checked="sku.enabled"
:id="'sku-input-' + sku.title"
>
</td>
<td class="name">
<label :for="'sku-input-' + sku.title">{{ sku.name }}</label>
<div v-if="sku.range" class="range-input">
<label class="text-nowrap">{{ sku.range.min }} {{ sku.range.unit }}</label>
<input type="range" class="form-range" @input="rangeUpdate"
:value="sku.value || sku.range.min"
:min="sku.range.min"
:max="sku.range.max"
>
</div>
</td>
<td class="price text-nowrap">
{{ $root.priceLabel(sku.cost, discount, currency) }}
</td>
<td class="buttons">
<btn v-if="sku.description" class="btn-link btn-lg p-0" v-tooltip="sku.description" icon="circle-info">
<span class="visually-hidden">{{ $t('btn.moreinfo') }}</span>
</btn>
</td>
</tr>
</tbody>
</table>
<small v-if="discount > 0" class="hint">
<hr class="m-0 mt-1">
&sup1; {{ $t('user.discount-hint') }}: {{ discount }}% - {{ discount_description }}
</small>
</div>
</template>
<script>
export default {
props: {
object: { type: Object, default: () => {} },
readonly: { type: Boolean, default: false },
type: { type: String, default: 'user' }
},
data() {
return {
currency: '',
discount: 0,
discount_description: '',
skus: []
}
},
created() {
// assign currency, discount, discount_description of the current user
this.$root.userWalletProps(this)
if (this.object.wallet) {
this.discount = this.object.wallet.discount
this.discount_description = this.object.wallet.discount_description
}
let url = '/api/v4/' + this.type + 's/' + this.object.id + '/skus'
if (!this.object.id) {
url = '/api/v4/skus?type=' + this.type.replace(/-([a-z])/g, (match, p1) => p1.toUpperCase())
}
axios.get(url, { loader: true })
.then(response => {
if (this.readonly && this.object.skus) {
response.data = response.data.filter(sku => { return sku.id in this.object.skus })
}
// "merge" SKUs with user entitlement-SKUs
this.skus = response.data
.map(sku => {
const objSku = this.object.skus ? this.object.skus[sku.id] : null
if (objSku) {
sku.enabled = true
sku.skuCost = sku.cost
sku.cost = objSku.costs.reduce((sum, current) => sum + current)
sku.value = objSku.count
sku.costs = objSku.costs
} else {
if ('nextCost' in sku) {
sku.cost = sku.nextCost
}
- if (!sku.readonly) {
+ if (!sku.readonly && this.object.skus) {
sku.enabled = false
}
}
return sku
})
- // Update all range inputs (and price)
this.$nextTick(() => {
+ // Update all range inputs (and price)
$(this.$el).find('input[type=range]').each((idx, elem) => { this.rangeUpdate(elem) })
+
+ // Mark 'exclusive' SKUs as readonly, they can't be unchecked
+ this.skus.forEach(item => {
+ if (item.exclusive && item.enabled) {
+ $('#s' + item.id).find('input[type=checkbox]')[0].readOnly = true
+ }
+ })
})
})
.catch(this.$root.errorHandler)
},
methods: {
findSku(id) {
for (let i = 0; i < this.skus.length; i++) {
if (this.skus[i].id == id) {
return this.skus[i];
}
}
},
onInputSku(e) {
let input = e.target
let sku = this.findSku(input.value)
let required = []
// We use 'readonly', not 'disabled', because we might want to handle
// input events. For example to display an error when someone clicks
// the locked input
if (input.readOnly) {
input.checked = !input.checked
// TODO: Display an alert explaining why it's locked
return
}
// TODO: Following code might not work if we change definition of forbidden/required
// or we just need more sophisticated SKU dependency rules
if (input.checked) {
// Check if a required SKU is selected, alert the user if not
- (sku.required || []).forEach(requiredHandler => {
+ (sku.required || []).forEach(handler => {
this.skus.forEach(item => {
- if (item.handler == requiredHandler) {
+ if (item.handler == handler) {
if (!$('#s' + item.id).find('input[type=checkbox]:checked').length) {
required.push(item.name)
}
}
})
})
if (required.length) {
input.checked = false
return alert(this.$t('user.skureq', { sku: sku.name, list: required.join(', ') }))
}
+
+ // Make sure there must be only one of 'exclusive' SKUs
+ if (sku.exclusive) {
+ input.readOnly = true
+
+ this.skus.forEach(item => {
+ if (sku.exclusive.includes(item.handler)) {
+ $('#s' + item.id).find('input[type=checkbox]').prop({
+ checked: false,
+ readonly: false
+ })
+ }
+ })
+ }
} else {
// Uncheck all dependent SKUs, e.g. when unchecking Groupware we also uncheck Activesync
// TODO: Should we display an alert instead?
this.skus.forEach(item => {
if (item.required && item.required.indexOf(sku.handler) > -1) {
$('#s' + item.id).find('input[type=checkbox]').prop('checked', false)
}
})
}
// Uncheck+lock/unlock conflicting SKUs
- (sku.forbidden || []).forEach(forbiddenHandler => {
+ (sku.forbidden || []).forEach(handler => {
this.skus.forEach(item => {
let checkbox
- if (item.handler == forbiddenHandler && (checkbox = $('#s' + item.id).find('input[type=checkbox]')[0])) {
+ if (item.handler == handler && (checkbox = $('#s' + item.id).find('input[type=checkbox]')[0])) {
if (input.checked) {
checkbox.checked = false
checkbox.readOnly = true
} else {
checkbox.readOnly = false
}
}
})
})
},
rangeUpdate(e) {
let input = $(e.target || e)
let value = input.val()
let record = input.parents('tr').first()
let sku_id = record.find('input[type=checkbox]').val()
let sku = this.findSku(sku_id)
let existing = sku.costs ? sku.costs.length : 0
let cost
// Calculate cost, considering both existing entitlement cost and sku cost
if (existing) {
cost = sku.costs
.sort((a, b) => a - b) // sort by cost ascending (free units first)
.slice(0, value)
.reduce((sum, current) => sum + current)
if (value > existing) {
cost += sku.skuCost * (value - existing)
}
} else {
cost = sku.cost * (value - sku.units_free)
}
// Update the label
input.prev().text(value + ' ' + sku.range.unit)
// Update the price
record.find('.price').text(this.$root.priceLabel(cost, this.discount, this.currency))
+ },
+ getSkus() {
+ let skus = {}
+
+ $(this.$el).find('input[type=checkbox]:checked').each((idx, input) => {
+ let id = $(input).val()
+ let range = $(input).parents('tr').first().find('input[type=range]').val()
+
+ skus[id] = range || 1
+ })
+
+ return skus
}
}
}
</script>
diff --git a/src/routes/api.php b/src/routes/api.php
index a761e9f6..c1a7cdd3 100644
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -1,266 +1,268 @@
<?php
use App\Http\Controllers\API;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
|
| Here is where you can register API routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| is assigned the "api" middleware group. Enjoy building your API!
|
*/
Route::group(
[
'middleware' => 'api',
'prefix' => 'auth'
],
function () {
Route::post('login', [API\AuthController::class, 'login']);
Route::group(
['middleware' => 'auth:api'],
function () {
Route::get('info', [API\AuthController::class, 'info']);
Route::post('info', [API\AuthController::class, 'info']);
Route::post('logout', [API\AuthController::class, 'logout']);
Route::post('refresh', [API\AuthController::class, 'refresh']);
}
);
}
);
Route::group(
[
'domain' => \config('app.website_domain'),
'middleware' => 'api',
'prefix' => 'auth'
],
function () {
Route::post('password-policy/check', [API\PasswordPolicyController::class, 'check']);
Route::post('password-reset/init', [API\PasswordResetController::class, 'init']);
Route::post('password-reset/verify', [API\PasswordResetController::class, 'verify']);
Route::post('password-reset', [API\PasswordResetController::class, 'reset']);
Route::post('signup/init', [API\SignupController::class, 'init']);
Route::get('signup/invitations/{id}', [API\SignupController::class, 'invitation']);
Route::get('signup/plans', [API\SignupController::class, 'plans']);
Route::post('signup/verify', [API\SignupController::class, 'verify']);
Route::post('signup', [API\SignupController::class, 'signup']);
}
);
Route::group(
[
'domain' => \config('app.website_domain'),
'middleware' => 'auth:api',
'prefix' => 'v4'
],
function () {
Route::post('companion/register', [API\V4\CompanionAppsController::class, 'register']);
Route::post('auth-attempts/{id}/confirm', [API\V4\AuthAttemptsController::class, 'confirm']);
Route::post('auth-attempts/{id}/deny', [API\V4\AuthAttemptsController::class, 'deny']);
Route::get('auth-attempts/{id}/details', [API\V4\AuthAttemptsController::class, 'details']);
Route::get('auth-attempts', [API\V4\AuthAttemptsController::class, 'index']);
Route::get('companion/pairing', [API\V4\CompanionAppsController::class, 'pairing']);
Route::apiResource('companion', API\V4\CompanionAppsController::class);
Route::post('companion/register', [API\V4\CompanionAppsController::class, 'register']);
Route::post('companion/revoke', [API\V4\CompanionAppsController::class, 'revokeAll']);
Route::apiResource('domains', API\V4\DomainsController::class);
Route::get('domains/{id}/confirm', [API\V4\DomainsController::class, 'confirm']);
Route::get('domains/{id}/skus', [API\V4\DomainsController::class, 'skus']);
Route::get('domains/{id}/status', [API\V4\DomainsController::class, 'status']);
Route::post('domains/{id}/config', [API\V4\DomainsController::class, 'setConfig']);
if (\config('app.with_files')) {
Route::apiResource('files', API\V4\FilesController::class);
Route::get('files/{fileId}/permissions', [API\V4\FilesController::class, 'getPermissions']);
Route::post('files/{fileId}/permissions', [API\V4\FilesController::class, 'createPermission']);
Route::put('files/{fileId}/permissions/{id}', [API\V4\FilesController::class, 'updatePermission']);
Route::delete('files/{fileId}/permissions/{id}', [API\V4\FilesController::class, 'deletePermission']);
Route::post('files/uploads/{id}', [API\V4\FilesController::class, 'upload'])
->withoutMiddleware(['auth:api'])
->middleware(['api']);
Route::get('files/downloads/{id}', [API\V4\FilesController::class, 'download'])
->withoutMiddleware(['auth:api']);
}
Route::apiResource('groups', API\V4\GroupsController::class);
Route::get('groups/{id}/skus', [API\V4\GroupsController::class, 'skus']);
Route::get('groups/{id}/status', [API\V4\GroupsController::class, 'status']);
Route::post('groups/{id}/config', [API\V4\GroupsController::class, 'setConfig']);
Route::apiResource('packages', API\V4\PackagesController::class);
- Route::get('meet/rooms', [API\V4\MeetController::class, 'index']);
- Route::post('meet/rooms/{id}/config', [API\V4\MeetController::class, 'setRoomConfig']);
+ Route::apiResource('rooms', API\V4\RoomsController::class);
+ Route::post('rooms/{id}/config', [API\V4\RoomsController::class, 'setConfig']);
+ Route::get('rooms/{id}/skus', [API\V4\RoomsController::class, 'skus']);
+
Route::post('meet/rooms/{id}', [API\V4\MeetController::class, 'joinRoom'])
->withoutMiddleware(['auth:api']);
Route::apiResource('resources', API\V4\ResourcesController::class);
Route::get('resources/{id}/skus', [API\V4\ResourcesController::class, 'skus']);
Route::get('resources/{id}/status', [API\V4\ResourcesController::class, 'status']);
Route::post('resources/{id}/config', [API\V4\ResourcesController::class, 'setConfig']);
Route::apiResource('shared-folders', API\V4\SharedFoldersController::class);
Route::get('shared-folders/{id}/skus', [API\V4\SharedFoldersController::class, 'skus']);
Route::get('shared-folders/{id}/status', [API\V4\SharedFoldersController::class, 'status']);
Route::post('shared-folders/{id}/config', [API\V4\SharedFoldersController::class, 'setConfig']);
Route::apiResource('skus', API\V4\SkusController::class);
Route::apiResource('users', API\V4\UsersController::class);
Route::post('users/{id}/config', [API\V4\UsersController::class, 'setConfig']);
Route::get('users/{id}/skus', [API\V4\UsersController::class, 'skus']);
Route::get('users/{id}/status', [API\V4\UsersController::class, 'status']);
Route::apiResource('wallets', API\V4\WalletsController::class);
Route::get('wallets/{id}/transactions', [API\V4\WalletsController::class, 'transactions']);
Route::get('wallets/{id}/receipts', [API\V4\WalletsController::class, 'receipts']);
Route::get('wallets/{id}/receipts/{receipt}', [API\V4\WalletsController::class, 'receiptDownload']);
Route::get('password-policy', [API\PasswordPolicyController::class, 'index']);
Route::post('password-reset/code', [API\PasswordResetController::class, 'codeCreate']);
Route::delete('password-reset/code/{id}', [API\PasswordResetController::class, 'codeDelete']);
Route::post('payments', [API\V4\PaymentsController::class, 'store']);
//Route::delete('payments', [API\V4\PaymentsController::class, 'cancel']);
Route::get('payments/mandate', [API\V4\PaymentsController::class, 'mandate']);
Route::post('payments/mandate', [API\V4\PaymentsController::class, 'mandateCreate']);
Route::put('payments/mandate', [API\V4\PaymentsController::class, 'mandateUpdate']);
Route::delete('payments/mandate', [API\V4\PaymentsController::class, 'mandateDelete']);
Route::get('payments/methods', [API\V4\PaymentsController::class, 'paymentMethods']);
Route::get('payments/pending', [API\V4\PaymentsController::class, 'payments']);
Route::get('payments/has-pending', [API\V4\PaymentsController::class, 'hasPayments']);
Route::post('support/request', [API\V4\SupportController::class, 'request'])
->withoutMiddleware(['auth:api'])
->middleware(['api']);
}
);
Route::group(
[
'domain' => \config('app.website_domain'),
'prefix' => 'webhooks'
],
function () {
Route::post('payment/{provider}', [API\V4\PaymentsController::class, 'webhook']);
Route::post('meet', [API\V4\MeetController::class, 'webhook']);
}
);
if (\config('app.with_services')) {
Route::group(
[
'domain' => 'services.' . \config('app.website_domain'),
'prefix' => 'webhooks'
],
function () {
Route::get('nginx', [API\V4\NGINXController::class, 'authenticate']);
Route::get('nginx-httpauth', [API\V4\NGINXController::class, 'httpauth']);
Route::post('policy/greylist', [API\V4\PolicyController::class, 'greylist']);
Route::post('policy/ratelimit', [API\V4\PolicyController::class, 'ratelimit']);
Route::post('policy/spf', [API\V4\PolicyController::class, 'senderPolicyFramework']);
}
);
}
if (\config('app.with_admin')) {
Route::group(
[
'domain' => 'admin.' . \config('app.website_domain'),
'middleware' => ['auth:api', 'admin'],
'prefix' => 'v4',
],
function () {
Route::apiResource('domains', API\V4\Admin\DomainsController::class);
Route::get('domains/{id}/skus', [API\V4\Admin\DomainsController::class, 'skus']);
Route::post('domains/{id}/suspend', [API\V4\Admin\DomainsController::class, 'suspend']);
Route::post('domains/{id}/unsuspend', [API\V4\Admin\DomainsController::class, 'unsuspend']);
Route::apiResource('groups', API\V4\Admin\GroupsController::class);
Route::post('groups/{id}/suspend', [API\V4\Admin\GroupsController::class, 'suspend']);
Route::post('groups/{id}/unsuspend', [API\V4\Admin\GroupsController::class, '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::class, 'userDiscounts']);
Route::post('users/{id}/reset2FA', [API\V4\Admin\UsersController::class, 'reset2FA']);
Route::get('users/{id}/skus', [API\V4\Admin\UsersController::class, 'skus']);
Route::post('users/{id}/skus/{sku}', [API\V4\Admin\UsersController::class, 'setSku']);
Route::post('users/{id}/suspend', [API\V4\Admin\UsersController::class, 'suspend']);
Route::post('users/{id}/unsuspend', [API\V4\Admin\UsersController::class, 'unsuspend']);
Route::apiResource('wallets', API\V4\Admin\WalletsController::class);
Route::post('wallets/{id}/one-off', [API\V4\Admin\WalletsController::class, 'oneOff']);
Route::get('wallets/{id}/transactions', [API\V4\Admin\WalletsController::class, 'transactions']);
Route::get('stats/chart/{chart}', [API\V4\Admin\StatsController::class, 'chart']);
}
);
}
if (\config('app.with_reseller')) {
Route::group(
[
'domain' => 'reseller.' . \config('app.website_domain'),
'middleware' => ['auth:api', 'reseller'],
'prefix' => 'v4',
],
function () {
Route::apiResource('domains', API\V4\Reseller\DomainsController::class);
Route::get('domains/{id}/skus', [API\V4\Reseller\DomainsController::class, 'skus']);
Route::post('domains/{id}/suspend', [API\V4\Reseller\DomainsController::class, 'suspend']);
Route::post('domains/{id}/unsuspend', [API\V4\Reseller\DomainsController::class, 'unsuspend']);
Route::apiResource('groups', API\V4\Reseller\GroupsController::class);
Route::post('groups/{id}/suspend', [API\V4\Reseller\GroupsController::class, 'suspend']);
Route::post('groups/{id}/unsuspend', [API\V4\Reseller\GroupsController::class, 'unsuspend']);
Route::apiResource('invitations', API\V4\Reseller\InvitationsController::class);
Route::post('invitations/{id}/resend', [API\V4\Reseller\InvitationsController::class, 'resend']);
Route::post('payments', [API\V4\Reseller\PaymentsController::class, 'store']);
Route::get('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandate']);
Route::post('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandateCreate']);
Route::put('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandateUpdate']);
Route::delete('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandateDelete']);
Route::get('payments/methods', [API\V4\Reseller\PaymentsController::class, 'paymentMethods']);
Route::get('payments/pending', [API\V4\Reseller\PaymentsController::class, 'payments']);
Route::get('payments/has-pending', [API\V4\Reseller\PaymentsController::class, '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::class, 'userDiscounts']);
Route::post('users/{id}/reset2FA', [API\V4\Reseller\UsersController::class, 'reset2FA']);
Route::get('users/{id}/skus', [API\V4\Reseller\UsersController::class, 'skus']);
Route::post('users/{id}/skus/{sku}', [API\V4\Admin\UsersController::class, 'setSku']);
Route::post('users/{id}/suspend', [API\V4\Reseller\UsersController::class, 'suspend']);
Route::post('users/{id}/unsuspend', [API\V4\Reseller\UsersController::class, 'unsuspend']);
Route::apiResource('wallets', API\V4\Reseller\WalletsController::class);
Route::post('wallets/{id}/one-off', [API\V4\Reseller\WalletsController::class, 'oneOff']);
Route::get('wallets/{id}/receipts', [API\V4\Reseller\WalletsController::class, 'receipts']);
Route::get('wallets/{id}/receipts/{receipt}', [API\V4\Reseller\WalletsController::class, 'receiptDownload']);
Route::get('wallets/{id}/transactions', [API\V4\Reseller\WalletsController::class, 'transactions']);
Route::get('stats/chart/{chart}', [API\V4\Reseller\StatsController::class, 'chart']);
}
);
}
diff --git a/src/tests/Browser/Admin/UserTest.php b/src/tests/Browser/Admin/UserTest.php
index 9bc0aa0b..a2333e7e 100644
--- a/src/tests/Browser/Admin/UserTest.php
+++ b/src/tests/Browser/Admin/UserTest.php
@@ -1,604 +1,602 @@
<?php
namespace Tests\Browser\Admin;
use App\Auth\SecondFactor;
use App\Discount;
use App\Entitlement;
use App\Sku;
use App\User;
use Tests\Browser;
use Tests\Browser\Components\Dialog;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Admin\User as UserPage;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
use Tests\TestCaseDusk;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class UserTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
self::useAdminUrl();
$john = $this->getTestUser('john@kolab.org');
$john->setSettings([
'phone' => '+48123123123',
'external_email' => 'john.doe.external@gmail.com',
]);
if ($john->isSuspended()) {
User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]);
}
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
$wallet->save();
Entitlement::where('cost', '>=', 5000)->delete();
$this->deleteTestGroup('group-test@kolab.org');
- $this->clearMeetEntitlements();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$john = $this->getTestUser('john@kolab.org');
$john->setSettings([
'phone' => null,
'external_email' => 'john.doe.external@gmail.com',
]);
if ($john->isSuspended()) {
User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]);
}
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
$wallet->save();
Entitlement::where('cost', '>=', 5000)->delete();
$this->deleteTestGroup('group-test@kolab.org');
- $this->clearMeetEntitlements();
parent::tearDown();
}
/**
* Test user info page (unauthenticated)
*/
public function testUserUnauth(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$jack = $this->getTestUser('jack@kolab.org');
$browser->visit('/user/' . $jack->id)->on(new Home());
});
}
/**
* Test user info page
*/
public function testUserInfo(): void
{
$this->browse(function (Browser $browser) {
$jack = $this->getTestUser('jack@kolab.org');
$page = new UserPage($jack->id);
$browser->visit(new Home())
->submitLogon('jeroen@jeroen.jeroen', \App\Utils::generatePassphrase(), true)
->on(new Dashboard())
->visit($page)
->on($page);
// Assert main info box content
$browser->assertSeeIn('@user-info .card-title', $jack->email)
->with('@user-info form', function (Browser $browser) use ($jack) {
$browser->assertElementsCount('.row', 7)
->assertSeeIn('.row:nth-child(1) label', 'Managed by')
->assertSeeIn('.row:nth-child(1) #manager a', 'john@kolab.org')
->assertSeeIn('.row:nth-child(2) label', 'ID (Created)')
->assertSeeIn('.row:nth-child(2) #userid', "{$jack->id} ({$jack->created_at})")
->assertSeeIn('.row:nth-child(3) label', 'Status')
->assertSeeIn('.row:nth-child(3) #status span.text-success', 'Active')
->assertSeeIn('.row:nth-child(4) label', 'First Name')
->assertSeeIn('.row:nth-child(4) #first_name', 'Jack')
->assertSeeIn('.row:nth-child(5) label', 'Last Name')
->assertSeeIn('.row:nth-child(5) #last_name', 'Daniels')
->assertSeeIn('.row:nth-child(6) label', 'External Email')
->assertMissing('.row:nth-child(6) #external_email a')
->assertSeeIn('.row:nth-child(7) label', 'Country')
->assertSeeIn('.row:nth-child(7) #country', 'United States');
});
// Some tabs are loaded in background, wait a second
$browser->pause(500)
->assertElementsCount('@nav a', 9);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
// Assert Aliases tab
$browser->assertSeeIn('@nav #tab-aliases', 'Aliases (1)')
->click('@nav #tab-aliases')
->whenAvailable('@user-aliases', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 1)
->assertSeeIn('table tbody tr:first-child td:first-child', 'jack.daniels@kolab.org')
->assertMissing('table tfoot');
});
// Assert Subscriptions tab
$browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (3)')
->click('@nav #tab-subscriptions')
->with('@user-subscriptions', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 3)
->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox')
->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '5,00 CHF')
->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 5 GB')
->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF')
->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features')
->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,90 CHF')
->assertMissing('table tfoot')
->assertMissing('#reset2fa');
});
// Assert Domains tab
$browser->assertSeeIn('@nav #tab-domains', 'Domains (0)')
->click('@nav #tab-domains')
->with('@user-domains', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no domains in this account.');
});
// Assert Users tab
$browser->assertSeeIn('@nav #tab-users', 'Users (0)')
->click('@nav #tab-users')
->with('@user-users', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no users in this account.');
});
// Assert Distribution lists tab
$browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (0)')
->click('@nav #tab-distlists')
->with('@user-distlists', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no distribution lists in this account.');
});
// Assert Resources tab
$browser->assertSeeIn('@nav #tab-resources', 'Resources (0)')
->click('@nav #tab-resources')
->with('@user-resources', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no resources in this account.');
});
// Assert Shared folders tab
$browser->assertSeeIn('@nav #tab-folders', 'Shared folders (0)')
->click('@nav #tab-folders')
->with('@user-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')
->whenAvailable('@user-settings form', function (Browser $browser) {
$browser->assertElementsCount('.row', 1)
->assertSeeIn('.row:first-child label', 'Greylisting')
->assertSeeIn('.row:first-child .text-success', 'enabled');
});
});
}
/**
* Test user info page (continue)
*
* @depends testUserInfo
*/
public function testUserInfo2(): void
{
$this->browse(function (Browser $browser) {
$john = $this->getTestUser('john@kolab.org');
$page = new UserPage($john->id);
$discount = Discount::where('code', 'TEST')->first();
$wallet = $john->wallet();
$wallet->discount()->associate($discount);
$wallet->debit(2010);
$wallet->save();
$group = $this->getTestGroup('group-test@kolab.org', ['name' => 'Test Group']);
$group->assignToWallet($john->wallets->first());
$john->setSetting('greylist_enabled', null);
// Click the managed-by link on Jack's page
$browser->click('@user-info #manager a')
->on($page);
// Assert main info box content
$browser->assertSeeIn('@user-info .card-title', $john->email)
->with('@user-info form', function (Browser $browser) use ($john) {
$ext_email = $john->getSetting('external_email');
$browser->assertElementsCount('.row', 9)
->assertSeeIn('.row:nth-child(1) label', 'ID (Created)')
->assertSeeIn('.row:nth-child(1) #userid', "{$john->id} ({$john->created_at})")
->assertSeeIn('.row:nth-child(2) label', 'Status')
->assertSeeIn('.row:nth-child(2) #status span.text-success', 'Active')
->assertSeeIn('.row:nth-child(3) label', 'First Name')
->assertSeeIn('.row:nth-child(3) #first_name', 'John')
->assertSeeIn('.row:nth-child(4) label', 'Last Name')
->assertSeeIn('.row:nth-child(4) #last_name', 'Doe')
->assertSeeIn('.row:nth-child(5) label', 'Organization')
->assertSeeIn('.row:nth-child(5) #organization', 'Kolab Developers')
->assertSeeIn('.row:nth-child(6) label', 'Phone')
->assertSeeIn('.row:nth-child(6) #phone', $john->getSetting('phone'))
->assertSeeIn('.row:nth-child(7) label', 'External Email')
->assertSeeIn('.row:nth-child(7) #external_email a', $ext_email)
->assertAttribute('.row:nth-child(7) #external_email a', 'href', "mailto:$ext_email")
->assertSeeIn('.row:nth-child(8) label', 'Address')
->assertSeeIn('.row:nth-child(8) #billing_address', $john->getSetting('billing_address'))
->assertSeeIn('.row:nth-child(9) label', 'Country')
->assertSeeIn('.row:nth-child(9) #country', 'United States');
});
// Some tabs are loaded in background, wait a second
$browser->pause(500)
->assertElementsCount('@nav a', 9);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
// Assert Aliases tab
$browser->assertSeeIn('@nav #tab-aliases', 'Aliases (1)')
->click('@nav #tab-aliases')
->whenAvailable('@user-aliases', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 1)
->assertSeeIn('table tbody tr:first-child td:first-child', 'john.doe@kolab.org')
->assertMissing('table tfoot');
});
// Assert Subscriptions tab
$browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (3)')
->click('@nav #tab-subscriptions')
->with('@user-subscriptions', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 3)
->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox')
->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '4,50 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 5 GB')
->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features')
->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,41 CHF/month¹')
->assertMissing('table tfoot')
->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher');
});
// Assert Domains tab
$browser->assertSeeIn('@nav #tab-domains', 'Domains (1)')
->click('@nav #tab-domains')
->with('@user-domains table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 1)
->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'kolab.org')
->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success')
->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')
->with('@user-distlists table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 1)
->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'Test Group')
->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-danger')
->assertSeeIn('tbody tr:nth-child(1) td:last-child a', 'group-test@kolab.org')
->assertMissing('tfoot');
});
// Assert Resources tab
$browser->assertSeeIn('@nav #tab-resources', 'Resources (2)')
->click('@nav #tab-resources')
->with('@user-resources', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 2)
->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'Conference Room #1')
->assertSeeIn('table tbody tr:nth-child(1) td:last-child', 'resource-test1@kolab.org')
->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Conference Room #2')
->assertSeeIn('table tbody tr:nth-child(2) td:last-child', 'resource-test2@kolab.org')
->assertMissing('table tfoot');
});
// Assert Shared folders tab
$browser->assertSeeIn('@nav #tab-folders', 'Shared folders (2)')
->click('@nav #tab-folders')
->with('@user-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');
});
});
// Now we go to Ned's info page, he's a controller on John's wallet
$this->browse(function (Browser $browser) {
$ned = $this->getTestUser('ned@kolab.org');
$beta_sku = Sku::withEnvTenantContext()->where('title', 'beta')->first();
$storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first();
$wallet = $ned->wallet();
// Add an extra storage and beta entitlement with different prices
Entitlement::create([
'wallet_id' => $wallet->id,
'sku_id' => $beta_sku->id,
'cost' => 5010,
'entitleable_id' => $ned->id,
'entitleable_type' => User::class
]);
Entitlement::create([
'wallet_id' => $wallet->id,
'sku_id' => $storage_sku->id,
'cost' => 5000,
'entitleable_id' => $ned->id,
'entitleable_type' => User::class
]);
$page = new UserPage($ned->id);
$ned->setSetting('greylist_enabled', 'false');
$browser->click('@nav #tab-users')
->click('@user-users tbody tr:nth-child(4) td:first-child a')
->on($page);
// Assert main info box content
$browser->assertSeeIn('@user-info .card-title', $ned->email)
->with('@user-info form', function (Browser $browser) use ($ned) {
$browser->assertSeeIn('.row:nth-child(2) label', 'ID (Created)')
->assertSeeIn('.row:nth-child(2) #userid', "{$ned->id} ({$ned->created_at})");
});
// Some tabs are loaded in background, wait a second
$browser->pause(500)
->assertElementsCount('@nav a', 9);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
// Assert Aliases tab
$browser->assertSeeIn('@nav #tab-aliases', 'Aliases (0)')
->click('@nav #tab-aliases')
->whenAvailable('@user-aliases', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'This user has no email aliases.');
});
// Assert Subscriptions tab, we expect John's discount here
$browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (6)')
->click('@nav #tab-subscriptions')
->with('@user-subscriptions', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 6)
->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox')
->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '4,50 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 6 GB')
->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '45,00 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features')
->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,41 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(4) td:first-child', 'Activesync')
->assertSeeIn('table tbody tr:nth-child(4) td:last-child', '0,00 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(5) td:first-child', '2-Factor Authentication')
->assertSeeIn('table tbody tr:nth-child(5) td:last-child', '0,00 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(6) td:first-child', 'Private Beta (invitation only)')
->assertSeeIn('table tbody tr:nth-child(6) td:last-child', '45,09 CHF/month¹')
->assertMissing('table tfoot')
->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher')
->assertSeeIn('#reset2fa', 'Reset 2-Factor Auth')
->assertMissing('#addbetasku');
});
// We don't expect John's domains here
$browser->assertSeeIn('@nav #tab-domains', 'Domains (0)')
->click('@nav #tab-domains')
->with('@user-domains', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no domains in this account.');
});
// We don't expect John's users here
$browser->assertSeeIn('@nav #tab-users', 'Users (0)')
->click('@nav #tab-users')
->with('@user-users', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no users in this account.');
});
// We don't expect John's distribution lists here
$browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (0)')
->click('@nav #tab-distlists')
->with('@user-distlists', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no distribution lists in this account.');
});
// We don't expect John's resources here
$browser->assertSeeIn('@nav #tab-resources', 'Resources (0)')
->click('@nav #tab-resources')
->with('@user-resources', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no resources in this account.');
});
// We don't expect John's folders here
$browser->assertSeeIn('@nav #tab-folders', 'Shared folders (0)')
->click('@nav #tab-folders')
->with('@user-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')
->whenAvailable('@user-settings form', function (Browser $browser) {
$browser->assertElementsCount('.row', 1)
->assertSeeIn('.row:first-child label', 'Greylisting')
->assertSeeIn('.row:first-child .text-danger', 'disabled');
});
});
}
/**
* Test editing an external email
*
* @depends testUserInfo2
*/
public function testExternalEmail(): void
{
$this->browse(function (Browser $browser) {
$john = $this->getTestUser('john@kolab.org');
$browser->visit(new UserPage($john->id))
->waitFor('@user-info #external_email button')
->click('@user-info #external_email button')
// Test dialog content, and closing it with Cancel button
->with(new Dialog('#email-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'External Email')
->assertFocused('@body input')
->assertValue('@body input', 'john.doe.external@gmail.com')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Submit')
->click('@button-cancel');
})
->assertMissing('#email-dialog')
->click('@user-info #external_email button')
// Test email validation error handling, and email update
->with(new Dialog('#email-dialog'), function (Browser $browser) {
$browser->type('@body input', 'test')
->click('@button-action')
->waitFor('@body input.is-invalid')
->assertSeeIn(
'@body input + .invalid-feedback',
'The external email must be a valid email address.'
)
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->type('@body input', 'test@test.com')
->click('@button-action');
})
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.')
->assertSeeIn('@user-info #external_email a', 'test@test.com')
->click('@user-info #external_email button')
->with(new Dialog('#email-dialog'), function (Browser $browser) {
$browser->assertValue('@body input', 'test@test.com')
->assertMissing('@body input.is-invalid')
->assertMissing('@body input + .invalid-feedback')
->click('@button-cancel');
})
->assertSeeIn('@user-info #external_email a', 'test@test.com');
// $john->getSetting() may not work here as it uses internal cache
// read the value form database
$current_ext_email = $john->settings()->where('key', 'external_email')->first()->value;
$this->assertSame('test@test.com', $current_ext_email);
});
}
/**
* Test suspending/unsuspending the user
*/
public function testSuspendAndUnsuspend(): void
{
$this->browse(function (Browser $browser) {
$john = $this->getTestUser('john@kolab.org');
$browser->visit(new UserPage($john->id))
->assertVisible('@user-info #button-suspend')
->assertMissing('@user-info #button-unsuspend')
->click('@user-info #button-suspend')
->assertToast(Toast::TYPE_SUCCESS, 'User suspended successfully.')
->assertSeeIn('@user-info #status span.text-warning', 'Suspended')
->assertMissing('@user-info #button-suspend')
->click('@user-info #button-unsuspend')
->assertToast(Toast::TYPE_SUCCESS, 'User unsuspended successfully.')
->assertSeeIn('@user-info #status span.text-success', 'Active')
->assertVisible('@user-info #button-suspend')
->assertMissing('@user-info #button-unsuspend');
});
}
/**
* Test resetting 2FA for the user
*/
public function testReset2FA(): void
{
$this->browse(function (Browser $browser) {
$this->deleteTestUser('userstest1@kolabnow.com');
$user = $this->getTestUser('userstest1@kolabnow.com');
$sku2fa = Sku::withEnvTenantContext()->where('title', '2fa')->first();
$user->assignSku($sku2fa);
SecondFactor::seed('userstest1@kolabnow.com');
$browser->visit(new UserPage($user->id))
->click('@nav #tab-subscriptions')
->with('@user-subscriptions', function (Browser $browser) use ($sku2fa) {
$browser->waitFor('#reset2fa')
->assertVisible('#sku' . $sku2fa->id);
})
->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (1)')
->click('#reset2fa')
->with(new Dialog('#reset-2fa-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', '2-Factor Authentication Reset')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Reset')
->click('@button-action');
})
->assertToast(Toast::TYPE_SUCCESS, '2-Factor authentication reset successfully.')
->assertMissing('#sku' . $sku2fa->id)
->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (0)');
});
}
/**
* Test adding the beta SKU for the user
*/
public function testAddBetaSku(): void
{
$this->browse(function (Browser $browser) {
$this->deleteTestUser('userstest1@kolabnow.com');
$user = $this->getTestUser('userstest1@kolabnow.com');
$sku = Sku::withEnvTenantContext()->where('title', 'beta')->first();
$browser->visit(new UserPage($user->id))
->click('@nav #tab-subscriptions')
->waitFor('@user-subscriptions #addbetasku')
->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (0)')
->assertSeeIn('#addbetasku', 'Enable beta program')
->click('#addbetasku')
->assertToast(Toast::TYPE_SUCCESS, 'The subscription added successfully.')
->waitFor('#sku' . $sku->id)
->assertSeeIn("#sku{$sku->id} td:first-child", 'Private Beta (invitation only)')
->assertSeeIn("#sku{$sku->id} td:last-child", '0,00 CHF/month')
->assertMissing('#addbetasku')
->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (1)');
});
}
}
diff --git a/src/tests/Browser/Components/SubscriptionSelect.php b/src/tests/Browser/Components/SubscriptionSelect.php
new file mode 100644
index 00000000..5836e7bf
--- /dev/null
+++ b/src/tests/Browser/Components/SubscriptionSelect.php
@@ -0,0 +1,78 @@
+<?php
+
+namespace Tests\Browser\Components;
+
+use Laravel\Dusk\Component as BaseComponent;
+use PHPUnit\Framework\Assert as PHPUnit;
+
+class SubscriptionSelect 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);
+ }
+
+ /**
+ * Assert subscription record
+ */
+ public function assertSubscription($browser, int $idx, $name, $title = null, $price = null)
+ {
+ $idx += 1; // index starts with 1 in css
+ $row = "tbody tr:nth-child($idx)";
+
+ $browser->assertSeeIn("$row td.name label", $name);
+
+ if ($title !== null) {
+ $browser->assertTip("$row td.buttons button", $title);
+ }
+
+ if ($price !== null) {
+ $browser->assertSeeIn("$row td.price", $price);
+ }
+ }
+
+ /**
+ * Assert subscription state
+ */
+ public function assertSubscriptionState($browser, int $idx, bool $enabled)
+ {
+ $idx += 1; // index starts with 1 in css
+ $row = "tbody tr:nth-child($idx)";
+ $browser->{$enabled ? 'assertChecked' : 'assertNotChecked'}("$row td.selection input");
+ }
+
+ /**
+ * Enable/Disable the subscription
+ */
+ public function clickSubscription($browser, int $idx)
+ {
+ $idx += 1; // index starts with 1 in css
+ $row = "tbody tr:nth-child($idx)";
+ $browser->click("$row td.selection input");
+ }
+}
diff --git a/src/tests/Browser/Meet/RoomControlsTest.php b/src/tests/Browser/Meet/RoomControlsTest.php
index 3990b2ee..17d151a3 100644
--- a/src/tests/Browser/Meet/RoomControlsTest.php
+++ b/src/tests/Browser/Meet/RoomControlsTest.php
@@ -1,411 +1,411 @@
<?php
namespace Tests\Browser\Meet;
use App\Meet\Room;
use Tests\Browser;
use Tests\Browser\Pages\Meet\Room as RoomPage;
use Tests\TestCaseDusk;
class RoomControlsTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
- $this->setupTestRoom();
+ $this->resetTestRoom();
}
public function tearDown(): void
{
$this->resetTestRoom();
parent::tearDown();
}
/**
* Test fullscreen buttons
*
* @group meet
*/
public function testFullscreen(): void
{
// TODO: This test does not work in headless mode
$this->markTestIncomplete();
/*
$this->browse(function (Browser $browser) {
// Join the room as an owner (authenticate)
$browser->visit(new RoomPage('john'))
->click('@setup-button')
->assertMissing('@toolbar')
->assertMissing('@menu')
->assertMissing('@session')
->assertMissing('@chat')
->assertMissing('@setup-form')
->assertVisible('@login-form')
->submitLogon('john@kolab.org', 'simple123')
->waitFor('@setup-form')
->assertMissing('@login-form')
->waitUntilMissing('@setup-status-message.loading')
->click('@setup-button')
->waitFor('@session')
// Test fullscreen for the whole room
->click('@menu button.link-fullscreen.closed')
->assertVisible('@toolbar')
->assertVisible('@session')
->assertMissing('nav')
->assertMissing('@menu button.link-fullscreen.closed')
->click('@menu button.link-fullscreen.open')
->assertVisible('nav')
// Test fullscreen for the participant video
->click('@session button.link-fullscreen.closed')
->assertVisible('@session')
->assertMissing('@toolbar')
->assertMissing('nav')
->assertMissing('@session button.link-fullscreen.closed')
->click('@session button.link-fullscreen.open')
->assertVisible('nav')
->assertVisible('@toolbar');
});
*/
}
/**
* Test nickname and audio/video muting/volume controls
*
* @group meet
*/
public function testNicknameAndMuting(): void
{
$this->browse(function (Browser $owner, Browser $guest) {
// Join the room as an owner (authenticate)
$owner->visit(new RoomPage('john'))
->click('@setup-button')
->submitLogon('john@kolab.org', 'simple123')
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->type('@setup-nickname-input', 'john')
->keys('@setup-nickname-input', '{enter}') // Test form submit with Enter key
->waitFor('@session');
// In another browser act as a guest
$guest->visit(new RoomPage('john'))
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->assertMissing('@setup-status-message')
->assertSeeIn('@setup-button', "JOIN")
// Join the room, disable cam/mic
->select('@setup-mic-select', '')
//->select('@setup-cam-select', '')
->clickWhenEnabled('@setup-button')
->waitFor('@session');
// Assert current UI state
$owner->assertToolbar([
'audio' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED,
'video' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED,
'screen' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED,
'chat' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED,
'fullscreen' => RoomPage::BUTTON_ENABLED,
'options' => RoomPage::BUTTON_ENABLED,
'logout' => RoomPage::BUTTON_ENABLED,
])
->whenAvailable('div.meet-video.self', function (Browser $browser) {
$browser->waitFor('video')
->assertAudioMuted('video', true)
->assertSeeIn('.meet-nickname', 'john')
->assertVisible('.controls button.link-fullscreen')
->assertMissing('.controls button.link-audio')
->assertMissing('.status .status-audio')
->assertMissing('.status .status-video');
})
->whenAvailable('div.meet-video:not(.self)', function (Browser $browser) {
$browser->waitFor('video')
->assertVisible('.meet-nickname')
->assertVisible('.controls button.link-fullscreen')
->assertVisible('.controls button.link-audio')
->assertVisible('.status .status-audio')
->assertMissing('.status .status-video');
})
->assertElementsCount('@session div.meet-video', 2);
// Assert current UI state
$guest->assertToolbar([
'audio' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED,
'video' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED,
'screen' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED,
'chat' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED,
'fullscreen' => RoomPage::BUTTON_ENABLED,
'logout' => RoomPage::BUTTON_ENABLED,
])
->whenAvailable('div.meet-video:not(.self)', function (Browser $browser) {
$browser->waitFor('video')
->assertSeeIn('.meet-nickname', 'john')
->assertVisible('.controls button.link-fullscreen')
->assertVisible('.controls button.link-audio')
->assertMissing('.status .status-audio')
->assertMissing('.status .status-video');
})
->whenAvailable('div.meet-video.self', function (Browser $browser) {
$browser->waitFor('video')
->assertVisible('.controls button.link-fullscreen')
->assertMissing('.controls button.link-audio')
->assertVisible('.status .status-audio')
->assertMissing('.status .status-video');
})
->assertElementsCount('@session div.meet-video', 2);
// Test nickname change propagation
$guest->setNickname('div.meet-video.self', 'guest');
$owner->waitFor('div.meet-video:not(.self) .meet-nickname')
->assertSeeIn('div.meet-video:not(.self) .meet-nickname', 'guest');
// Test muting audio
$owner->click('@menu button.link-audio')
->assertToolbarButtonState('audio', RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED)
->waitFor('div.meet-video.self .status .status-audio');
// FIXME: It looks that we can't just check the <video> element state
// We might consider using some API to make sure
$guest->waitFor('div.meet-video:not(.self) .status .status-audio');
// Test unmuting audio
$owner->click('@menu button.link-audio')
->assertToolbarButtonState('audio', RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED)
->assertMissing('div.meet-video.self .status .status-audio');
$guest->waitUntilMissing('div.meet-video:not(.self) .status .status-audio');
// Test muting audio with a keyboard shortcut (key 'm')
$owner->driver->getKeyboard()->sendKeys('m');
$owner->assertToolbarButtonState('audio', RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED)
->assertVisible('div.meet-video.self .status .status-audio');
$guest->waitFor('div.meet-video:not(.self) .status .status-audio')
->assertAudioMuted('div.meet-video:not(.self) video', true);
// Test unmuting audio with a keyboard shortcut (key 'm')
$owner->driver->getKeyboard()->sendKeys('m');
$owner->assertToolbarButtonState('audio', RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED)
->assertMissing('div.meet-video.self .status .status-audio');
$guest->waitUntilMissing('div.meet-video:not(.self) .status .status-audio')
->assertAudioMuted('div.meet-video:not(.self) video', false);
// Test muting video
$owner->click('@menu button.link-video')
->assertToolbarButtonState('video', RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED)
->assertVisible('div.meet-video.self .status .status-video');
// FIXME: It looks that we can't just check the <video> element state
// We might consider using some API to make sure
$guest->waitFor('div.meet-video:not(.self) .status .status-video');
// Test unmuting video
$owner->click('@menu button.link-video')
->assertToolbarButtonState('video', RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED)
->assertMissing('div.meet-video.self .status .status-video');
$guest->waitUntilMissing('div.meet-video:not(.self) .status .status-video');
// Test muting other user
$guest->with('div.meet-video:not(.self)', function (Browser $browser) {
$browser->click('.controls button.link-audio')
->assertAudioMuted('video', true)
->assertVisible('.controls button.link-audio.text-danger')
->click('.controls button.link-audio')
->assertAudioMuted('video', false)
->assertVisible('.controls button.link-audio:not(.text-danger)');
});
// Test volume control
$guest->mouseover('@menu')
->with('div.meet-video:not(.self)', function (Browser $browser) {
$browser->waitUntilMissing('.volume')
->mouseover('.controls button.link-audio')
->waitFor('.volume')
->assertValue('.volume input', '1')
->keys('.volume input', ['{arrow_down}'])
->keys('.volume input', ['{arrow_down}'])
->keys('.volume input', ['{arrow_down}'])
->keys('.volume input', ['{arrow_down}'])
->keys('.volume input', ['{arrow_down}'])
->keys('.volume input', ['{arrow_down}'])
->keys('.volume input', ['{arrow_down}'])
->keys('.volume input', ['{arrow_down}'])
->keys('.volume input', ['{arrow_down}'])
->keys('.volume input', ['{arrow_down}'])
->assertValue('.volume input', '0')
->assertAudioMuted('video', true)
->assertVisible('.controls button.link-audio.text-danger')
->click('.controls button.link-audio')
->assertAudioMuted('video', false)
->assertValue('.volume input', '1')
->click('.controls button.link-audio')
->assertAudioMuted('video', true)
->assertValue('.volume input', '0')
->keys('.volume input', ['{arrow_up}'])
->assertValue('.volume input', '0.1')
->assertAudioMuted('video', false)
->assertVisible('.controls button.link-audio:not(.text-danger)')
->mouseover('.meet-nickname')
->waitUntilMissing('.volume');
});
});
}
/**
* Test text chat
*
* @group meet
*/
public function testChat(): void
{
$this->browse(function (Browser $owner, Browser $guest) {
// Join the room as an owner
$owner->visit(new RoomPage('john'))
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->type('@setup-nickname-input', 'john')
->clickWhenEnabled('@setup-button')
->waitFor('@session');
// In another browser act as a guest
$guest->visit(new RoomPage('john'))
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->assertMissing('@setup-status-message')
->assertSeeIn('@setup-button', "JOIN")
// Join the room, disable cam/mic
->select('@setup-mic-select', '')
// ->select('@setup-cam-select', '')
->clickWhenEnabled('@setup-button')
->waitFor('@session');
// Test chat elements
$owner->click('@menu button.link-chat')
->assertToolbarButtonState('chat', RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED)
->assertVisible('@chat')
->assertVisible('@session')
->assertFocused('@chat-input')
->assertElementsCount('@chat-list .message', 0)
->keys('@chat-input', 'test1', '{enter}')
->assertValue('@chat-input', '')
->waitFor('@chat-list .message')
->assertElementsCount('@chat-list .message', 1)
->assertSeeIn('@chat-list .message .nickname', 'john')
->assertSeeIn('@chat-list .message div:last-child', 'test1');
$guest->waitFor('@menu button.link-chat .badge')
->assertTextRegExp('@menu button.link-chat .badge', '/^1$/')
->click('@menu button.link-chat')
->assertToolbarButtonState('chat', RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED)
->assertMissing('@menu button.link-chat .badge')
->assertVisible('@chat')
->assertVisible('@session')
->assertElementsCount('@chat-list .message', 1)
->assertSeeIn('@chat-list .message .nickname', 'john')
->assertSeeIn('@chat-list .message div:last-child', 'test1');
// Test the number of (hidden) incoming messages
$guest->click('@menu button.link-chat')
->assertMissing('@chat');
$owner->keys('@chat-input', 'test2', '{enter}', 'test3', '{enter}')
->assertElementsCount('@chat-list .message', 1)
->assertSeeIn('@chat-list .message .nickname', 'john')
->assertElementsCount('@chat-list .message div', 4)
->assertSeeIn('@chat-list .message div:last-child', 'test3');
$guest->waitFor('@menu button.link-chat .badge')
->assertSeeIn('@menu button.link-chat .badge', '2')
->click('@menu button.link-chat')
->assertElementsCount('@chat-list .message', 1)
->assertSeeIn('@chat-list .message .nickname', 'john')
->assertSeeIn('@chat-list .message div:last-child', 'test3')
->keys('@chat-input', 'guest1', '{enter}')
->assertElementsCount('@chat-list .message', 2)
->assertMissing('@chat-list .message:last-child .nickname')
->assertSeeIn('@chat-list .message:last-child div:last-child', 'guest1');
$owner->assertElementsCount('@chat-list .message', 2)
->assertMissing('@chat-list .message:last-child .nickname')
->assertSeeIn('@chat-list .message:last-child div:last-child', 'guest1');
// Test nickname change is propagated to chat messages
$guest->setNickname('div.meet-video.self', 'guest')
->keys('@chat-input', 'guest2', '{enter}')
->assertElementsCount('@chat-list .message', 2)
->assertSeeIn('@chat-list .message:last-child .nickname', 'guest')
->assertSeeIn('@chat-list .message:last-child div:last-child', 'guest2');
$owner->assertElementsCount('@chat-list .message', 2)
->assertSeeIn('@chat-list .message:last-child .nickname', 'guest')
->assertSeeIn('@chat-list .message:last-child div:last-child', 'guest2');
// TODO: Test text chat features, e.g. link handling
});
}
/**
* Test screen sharing
*
* @group meet
*/
public function testShareScreen(): void
{
$this->browse(function (Browser $owner, Browser $guest) {
// Join the room as an owner
$owner->visit(new RoomPage('john'))
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->type('@setup-nickname-input', 'john')
->clickWhenEnabled('@setup-button')
->waitFor('@session');
// In another browser act as a guest
$guest->visit(new RoomPage('john'))
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
// Join the room, disable cam/mic
->select('@setup-mic-select', '')
->select('@setup-cam-select', '')
->clickWhenEnabled('@setup-button')
->waitFor('@session');
// Test screen sharing
$owner->assertToolbarButtonState('screen', RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED)
->assertElementsCount('@session div.meet-video', 1)
->click('@menu button.link-screen')
->whenAvailable('div.meet-video:not(.self)', function (Browser $browser) {
$browser->waitFor('video')
->assertSeeIn('.meet-nickname', 'john')
->assertVisible('.controls button.link-fullscreen')
->assertMissing('.controls button.link-audio')
->assertMissing('.status .status-audio')
->assertMissing('.status .status-video');
})
->assertElementsCount('@session div.meet-video', 2)
->assertElementsCount('@subscribers .meet-subscriber', 1)
->assertToolbarButtonState('screen', RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED);
$guest
->whenAvailable('div.meet-video.screen', function (Browser $browser) {
$browser->waitFor('video')
->assertSeeIn('.meet-nickname', 'john')
->assertVisible('.controls button.link-fullscreen')
->assertMissing('.controls button.link-audio')
->assertMissing('.status .status-audio')
->assertMissing('.status .status-video');
})
->assertElementsCount('@session div.meet-video', 2)
->assertElementsCount('@subscribers .meet-subscriber', 1);
});
}
}
diff --git a/src/tests/Browser/Meet/RoomInterpretersTest.php b/src/tests/Browser/Meet/RoomInterpretersTest.php
index 78a5d839..0d11d8a2 100644
--- a/src/tests/Browser/Meet/RoomInterpretersTest.php
+++ b/src/tests/Browser/Meet/RoomInterpretersTest.php
@@ -1,170 +1,170 @@
<?php
namespace Tests\Browser\Meet;
use App\Meet\Room;
use Tests\Browser;
use Tests\Browser\Pages\Meet\Room as RoomPage;
use Tests\TestCaseDusk;
class RoomInterpretersTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
- $this->setupTestRoom();
+ $this->resetTestRoom();
}
public function tearDown(): void
{
$this->resetTestRoom();
parent::tearDown();
}
/**
* Test language interpreted channels functionality
*
* @group meet
*/
public function testInterpreters(): void
{
$this->browse(function (Browser $owner, Browser $interpreter, Browser $guest) {
$page = new RoomPage('john');
// Join the room as an owner (authenticate)
$owner->visit($page)
->click('@setup-button')
->submitLogon('john@kolab.org', 'simple123')
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->type('@setup-nickname-input', 'John')
->clickWhenEnabled('@setup-button')
->waitFor('@session');
// In another browser act as a guest-to-be-interpreter
$interpreter->visit($page)
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->assertMissing('@setup-status-message')
->assertSeeIn('@setup-button', "JOIN")
->type('@setup-nickname-input', 'Interpreter')
->clickWhenEnabled('@setup-button')
->waitFor('@session');
// Assert current UI state, make the guest an interpreter
$owner->waitFor('@session .meet-video.self')
->waitFor('@session .meet-video:not(.self)')
->assertSeeIn('@session .meet-video:not(.self)', 'Interpreter')
->click('@session .meet-video:not(.self) .meet-nickname')
->whenAvailable('@session .meet-video:not(.self) .dropdown-menu', function ($browser) {
$browser->assertVisible('.interpreting')
->assertSeeIn('.interpreting h6', 'Language interpreter')
->select('.interpreting select', 'en');
})
->assertMissing('@session .meet-video:not(.self) .dropdown-menu')
->waitFor('@session div.meet-subscriber')
->assertUserIcon('@session div.meet-subscriber', RoomPage::ICO_INTERPRETER)
->assertToolbarButtonState('channel', RoomPage::BUTTON_ENABLED)
->assertMissing('@menu button.link-channel .badge');
// Assert current UI state
$interpreter->waitFor('@session .meet-video.self svg.interpreter')
->assertUserIcon('@session .meet-video.self', RoomPage::ICO_INTERPRETER)
->assertMissing('@menu button.link-channel')
->assertAudioMuted('@session .meet-video.self video', true) // always muted video of self
->assertAudioMuted('@session .meet-video:not(.self) video', false); // unmuted other publisher (owner)
// In another browser act as a guest-subscriber
// Test using a channel by subscriber
$guest->visit($page)
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->assertMissing('@setup-status-message')
->assertSeeIn('@setup-button', "JOIN")
->type('@setup-nickname-input', 'Guest')
->select('@setup-mic-select', '')
->select('@setup-cam-select', '')
->clickWhenEnabled('@setup-button')
->waitFor('@session .meet-subscriber.self') // self
->waitFor('@session .meet-subscriber:not(.self)') // interpreter
->waitFor('@session .meet-video') // owner
->waitFor('@menu button.link-channel')
->assertToolbarButtonState('channel', RoomPage::BUTTON_ENABLED)
->assertMissing('@menu button.link-channel .badge')
->assertUserIcon('@session .meet-subscriber.self', RoomPage::ICO_USER) // self
->assertUserIcon('@session .meet-subscriber:not(.self)', RoomPage::ICO_INTERPRETER) // interpreter
->assertMissing('@session .meet-subscriber:not(.self) video') // hidden interpreter video
->assertAudioMuted('@session .meet-video video', false) // unmuted owner
->assertAudioMuted('@session .meet-subscriber:not(.self) video', true) // muted interpreter
// select a channel
->click('@menu button.link-channel')
->whenAvailable('#channel-select .dropdown-menu', function ($browser) {
$browser->assertSeeIn('a:first-child.active', '- none -')
->assertSeeIn('a:nth-child(2):not(.active)', 'English')
->assertMissing('a:nth-child(3)')
->click('a:nth-child(2)');
})
->waitFor('@menu button.link-channel .badge')
->assertSeeIn('@menu button.link-channel .badge', 'EN')
->assertAudioMuted('@session .meet-video video', true) // muted owner
->assertAudioMuted('@session .meet-subscriber:not(.self) video', false) // unmuted interpreter
// Unselect a channel
->click('@menu button.link-channel')
->whenAvailable('#channel-select .dropdown-menu', function ($browser) {
$browser->assertVisible('a:first-child:not(.active)')
->assertVisible('a:nth-child(2).active')
->click('a:first-child');
})
->waitUntilMissing('@menu button.link-channel .badge')
->assertAudioMuted('@session .meet-video video', false) // unmuted owner
->assertAudioMuted('@session .meet-subscriber:not(.self) video', true); // muted interpreter
// Test setting a channel by publisher
$owner->assertUserIcon('@session .meet-video.self', RoomPage::ICO_MODERATOR) // self
->assertUserIcon('@session .meet-subscriber:nth-child(1)', RoomPage::ICO_INTERPRETER) // interpreter
->assertUserIcon('@session .meet-subscriber:nth-child(2)', RoomPage::ICO_USER) // guest-subscriber
->assertMissing('@session .meet-subscriber:nth-child(1) video') // hidden interpreter video
->assertAudioMuted('@session .meet-video video', true) // always muted owner's self video
->assertAudioMuted('@session .meet-subscriber:nth-child(1) video', true) // muted interpreter
// select a channel
->click('@menu button.link-channel')
->whenAvailable('#channel-select .dropdown-menu', function ($browser) {
$browser->assertSeeIn('a:first-child.active', '- none -')
->assertSeeIn('a:nth-child(2):not(.active)', 'English')
->assertMissing('a:nth-child(3)')
->click('a:nth-child(2)');
})
->waitFor('@menu button.link-channel .badge')
->assertSeeIn('@menu button.link-channel .badge', 'EN')
->assertAudioMuted('@session .meet-video video', true) // always muted self video
->assertAudioMuted('@session .meet-subscriber:nth-child(1) video', false) // unmuted interpreter
// Unselect a channel
->click('@menu button.link-channel')
->whenAvailable('#channel-select .dropdown-menu', function ($browser) {
$browser->assertVisible('a:first-child:not(.active)')
->assertVisible('a:nth-child(2).active')
->click('a:first-child');
})
->waitUntilMissing('@menu button.link-channel .badge')
->assertAudioMuted('@session .meet-video video', true) // always muted video of self
->assertAudioMuted('@session .meet-subscriber:nth-child(1) video', true); // muted interpreter
// Remove interpreting role
$owner->click('@session .meet-subscriber:nth-child(1) .meet-nickname')
->whenAvailable('@session .meet-subscriber:nth-child(1) .dropdown-menu', function ($browser) {
$browser->assertSelected('.interpreting select', 'en')
->select('.interpreting select', '');
})
->waitFor('div.meet-video:not(.self)')
->assertUserIcon('div.meet-video:not(.self)', RoomPage::ICO_USER)
->assertMissing('@menu button.link-channel');
$guest->waitUntilMissing('@menu button.link-channel');
// TODO: Test what happens for users with a selectd channel when the interpreter is removed
// TODO: Test two interpreters
});
}
}
diff --git a/src/tests/Browser/Meet/RoomModeratorTest.php b/src/tests/Browser/Meet/RoomModeratorTest.php
index cce8787a..180e26c0 100644
--- a/src/tests/Browser/Meet/RoomModeratorTest.php
+++ b/src/tests/Browser/Meet/RoomModeratorTest.php
@@ -1,167 +1,167 @@
<?php
namespace Tests\Browser\Meet;
use App\Meet\Room;
use Tests\Browser;
use Tests\Browser\Components\Dialog;
use Tests\Browser\Components\Menu;
use Tests\Browser\Pages\Meet\Room as RoomPage;
use Tests\TestCaseDusk;
class RoomModeratorTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
- $this->setupTestRoom();
+ $this->resetTestRoom();
}
public function tearDown(): void
{
$this->resetTestRoom();
parent::tearDown();
}
/**
* Test three users in a room, one will be promoted/demoted to/from a moderator
*
* @group meet
*/
public function testModeratorPromotion(): void
{
$this->browse(function (Browser $browser, Browser $guest1, Browser $guest2) {
// In one browser window join as a room owner
$browser->visit(new RoomPage('john'))
->click('@setup-button')
->submitLogon('john@kolab.org', 'simple123')
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->type('@setup-nickname-input', 'John')
->select('@setup-mic-select', '')
->select('@setup-cam-select', '')
->clickWhenEnabled('@setup-button')
->waitFor('@session');
// In one browser window join as a guest (to be promoted)
$guest1->visit(new RoomPage('john'))
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->assertMissing('@setup-status-message')
->assertSeeIn('@setup-button', "JOIN")
->type('@setup-nickname-input', 'Guest1')
// Join the room, disable cam/mic
->select('@setup-mic-select', '')
->select('@setup-cam-select', '')
->clickWhenEnabled('@setup-button')
->waitFor('@session');
// In one browser window join as a guest
$guest2->visit(new RoomPage('john'))
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->assertMissing('@setup-status-message')
->assertSeeIn('@setup-button', "JOIN")
// Join the room, disable mic
->select('@setup-mic-select', '')
->clickWhenEnabled('@setup-button')
->waitFor('@session');
// Assert that only the owner is a moderator right now
$guest1->waitFor('@session video')
->assertMissing('@session div.meet-video .meet-nickname') // guest2
->assertVisible('@session div.meet-subscriber.self svg.user') // self
->assertMissing('@session div.meet-subscriber.self svg.moderator') // self
->assertMissing('@session div.meet-subscriber:not(.self) svg.user') // owner
->assertVisible('@session div.meet-subscriber:not(.self) svg.moderator') // owner
->click('@session div.meet-subscriber.self .meet-nickname')
->whenAvailable('@session div.meet-subscriber.self .dropdown-menu', function (Browser $browser) {
$browser->assertMissing('.permissions');
})
->click('@session div.meet-subscriber:not(.self) .meet-nickname')
->assertMissing('.dropdown-menu');
$guest2->waitFor('@session video')
->assertVisible('@session div.meet-video svg.user') // self
->assertMissing('@session div.meet-video svg.moderator') // self
// the following 4 assertions used to be flaky on openvidu
// because the order is different all the time
->assertMissing('@session div.meet-subscriber:nth-child(1) svg.user') // owner
->assertVisible('@session div.meet-subscriber:nth-child(1) svg.moderator') // owner
->assertVisible('@session div.meet-subscriber:nth-child(2) svg.user') // guest1
->assertMissing('@session div.meet-subscriber:nth-child(2) svg.moderator'); // guest1
// Promote guest1 to a moderator
$browser->waitFor('@session video')
->assertMissing('@session div.meet-subscriber.self svg.user') // self
->assertVisible('@session div.meet-subscriber.self svg.moderator') // self
->click('@session div.meet-subscriber.self .meet-nickname')
->whenAvailable('@session div.meet-subscriber.self .dropdown-menu', function (Browser $browser) {
$browser->assertChecked('.action-role-moderator input')
->assertDisabled('.action-role-moderator input');
})
->click('@session div.meet-subscriber:not(.self) .meet-nickname')
->whenAvailable('@session div.meet-subscriber:not(.self) .dropdown-menu', function (Browser $browser) {
$browser->assertNotChecked('.action-role-moderator input')
->click('.action-role-moderator input');
});
// Assert that we have two moderators now
$guest2->waitFor('@session div.meet-subscriber:nth-child(2) svg.moderator')
->assertMissing('@session div.meet-subscriber:nth-child(2) svg.user'); // guest1
$guest1->waitFor('@session div.meet-subscriber.self svg.moderator')
->assertMissing('@session div.meet-subscriber.self svg.user') // self
->assertVisible('@session div.meet-video svg.user') // guest2
->assertMissing('@session div.meet-video svg.moderator') // guest2
->assertMissing('@session div.meet-subscriber:not(.self) svg.user') // owner
->assertVisible('@session div.meet-subscriber:not(.self) svg.moderator') // owner
->click('@session div.meet-subscriber:not(.self) .meet-nickname') // owner
->assertMissing('@session div.meet-subscriber:not(.self) .dropdown-menu')
->click('@session div.meet-subscriber.self .meet-nickname')
->whenAvailable('@session div.meet-subscriber.self .dropdown-menu', function (Browser $browser) {
$browser->assertChecked('.action-role-moderator input')
->assertEnabled('.action-role-moderator input')
->assertNotChecked('.action-role-publisher input')
->assertEnabled('.action-role-publisher input');
});
$browser->waitFor('@session div.meet-subscriber:not(.self) svg.moderator')
->assertMissing('@session div.meet-subscriber:not(.self) svg.user');
// Check if a moderator can unpublish another user
$guest1->click('@session div.meet-video .meet-nickname')
->whenAvailable('@session div.meet-video .dropdown-menu', function (Browser $browser) {
$browser->assertNotChecked('.action-role-moderator input')
->assertEnabled('.action-role-moderator input')
->assertChecked('.action-role-publisher input')
->assertEnabled('.action-role-publisher input')
->click('.action-role-publisher input');
})
->waitUntilMissing('@session div.meet-video');
$guest2->waitUntilMissing('@session div.meet-video');
// Demote guest1 back to a normal user
$browser->waitFor('@session div.meet-subscriber:nth-child(3)')
->click('@session') // somehow needed to make the next line invoke the menu
->click('@session div.meet-subscriber:nth-child(2) .meet-nickname')
->whenAvailable('@session div.meet-subscriber:nth-child(2) .dropdown-menu', function ($browser) {
$browser->assertChecked('.action-role-moderator input')
->click('.action-role-moderator input');
})
->waitFor('@session div.meet-subscriber:nth-child(2) svg.user')
->assertMissing('@session div.meet-subscriber:nth-child(2) svg.moderator');
$guest1->waitFor('@session div.meet-subscriber.self svg.user')
->assertMissing('@session div.meet-subscriber.self svg.moderator')
->click('@session div.meet-subscriber.self .meet-nickname')
->whenAvailable('@session .dropdown-menu', function (Browser $browser) {
$browser->assertMissing('.permissions');
});
});
}
}
diff --git a/src/tests/Browser/Meet/RoomOptionsTest.php b/src/tests/Browser/Meet/RoomOptionsTest.php
index c16b0104..7210f08b 100644
--- a/src/tests/Browser/Meet/RoomOptionsTest.php
+++ b/src/tests/Browser/Meet/RoomOptionsTest.php
@@ -1,326 +1,326 @@
<?php
namespace Tests\Browser\Meet;
use App\Meet\Room;
use Tests\Browser;
use Tests\Browser\Components\Dialog;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Meet\Room as RoomPage;
use Tests\TestCaseDusk;
class RoomOptionsTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
- $this->setupTestRoom();
+ $this->resetTestRoom();
}
public function tearDown(): void
{
$this->resetTestRoom();
parent::tearDown();
}
/**
* Test password protected room
*
* @group meet
*/
public function testRoomPassword(): void
{
$this->browse(function (Browser $owner, Browser $guest) {
$room = Room::where('name', 'john')->first();
// Join the room as an owner (authenticate)
$owner->visit(new RoomPage('john'))
->click('@setup-button')
->submitLogon('john@kolab.org', 'simple123')
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->assertMissing('@setup-password-input')
->clickWhenEnabled('@setup-button')
->waitFor('@session')
// Enter room option dialog
->click('@menu button.link-options')
->with(new Dialog('#room-options-dialog'), function (Browser $browser) use ($room) {
$browser->assertSeeIn('@title', 'Room options')
->assertSeeIn('@button-cancel', 'Close')
->assertElementsCount('.modal-footer button', 1)
->assertSeeIn('#password-input .label', 'Password:')
->assertSeeIn('#password-input-text.text-muted', 'none')
->assertVisible('#password-input + small')
->assertSeeIn('#password-set-btn', 'Set password')
->assertElementsCount('#password-input button', 1)
->assertMissing('#password-input input')
// Test setting a password
->click('#password-set-btn')
->assertMissing('#password-input-text')
->assertVisible('#password-input input')
->assertValue('#password-input input', '')
->assertSeeIn('#password-input #password-save-btn', 'Save')
->assertElementsCount('#password-input button', 1)
->type('#password-input input', 'pass')
->click('#password-input #password-save-btn')
->assertToast(Toast::TYPE_SUCCESS, 'Room configuration updated successfully.')
->assertMissing('#password-input input')
->assertSeeIn('#password-input-text:not(.text-muted)', 'pass')
->assertSeeIn('#password-clear-btn.btn-outline-danger', 'Clear password')
->assertElementsCount('#password-input button', 1)
->click('@button-cancel');
$this->assertSame('pass', $room->fresh()->getSetting('password'));
});
// In another browser act as a guest, expect password required
$guest->visit(new RoomPage('john'))
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->assertSeeIn('@setup-status-message', "Please, provide a valid password.")
->assertVisible('@setup-form .input-group:nth-child(4) svg')
->assertAttribute('@setup-form .input-group:nth-child(4) .input-group-text', 'title', 'Password')
->assertAttribute('@setup-password-input', 'placeholder', 'Password')
->assertValue('@setup-password-input', '')
->assertSeeIn('@setup-button', "JOIN")
// Try to join w/o password
->clickWhenEnabled('@setup-button')
->waitFor('#setup-password.is-invalid')
// Try to join with a valid password
->type('#setup-password', 'pass')
->clickWhenEnabled('@setup-button')
->waitFor('@session');
// Test removing the password
$owner->click('@menu button.link-options')
->with(new Dialog('#room-options-dialog'), function (Browser $browser) use ($room) {
$browser->assertSeeIn('@title', 'Room options')
->assertSeeIn('#password-input-text:not(.text-muted)', 'pass')
->assertSeeIn('#password-clear-btn.btn-outline-danger', 'Clear password')
->assertElementsCount('#password-input button', 1)
->click('#password-clear-btn')
->assertToast(Toast::TYPE_SUCCESS, "Room configuration updated successfully.")
->assertMissing('#password-input input')
->assertSeeIn('#password-input-text.text-muted', 'none')
->assertSeeIn('#password-set-btn', 'Set password')
->assertElementsCount('#password-input button', 1)
->click('@button-cancel');
$this->assertSame(null, $room->fresh()->getSetting('password'));
});
});
}
/**
* Test locked room (denying the join request)
*
* @group meet
*/
public function testLockedRoomDeny(): void
{
$this->browse(function (Browser $owner, Browser $guest) {
$room = Room::where('name', 'john')->first();
// Join the room as an owner (authenticate)
$owner->visit(new RoomPage('john'))
// ->click('@setup-button')
// ->submitLogon('john@kolab.org', 'simple123')
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->type('@setup-nickname-input', 'John')
->clickWhenEnabled('@setup-button')
->waitFor('@session')
// Enter room option dialog
->click('@menu button.link-options')
->with(new Dialog('#room-options-dialog'), function (Browser $browser) use ($room) {
$browser->assertSeeIn('@title', 'Room options')
->assertSeeIn('#room-lock label', 'Locked room:')
->assertVisible('#room-lock input[type=checkbox]:not(:checked)')
->assertVisible('#room-lock + small')
// Test setting the lock
->click('#room-lock input')
->assertToast(Toast::TYPE_SUCCESS, "Room configuration updated successfully.")
->click('@button-cancel');
$this->assertSame('true', $room->fresh()->getSetting('locked'));
});
// In another browser act as a guest
$guest->visit(new RoomPage('john'))
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->assertButtonEnabled('@setup-button')
->assertSeeIn('@setup-button.btn-success', 'JOIN NOW')
// try without the nickname
->clickWhenEnabled('@setup-button')
->waitFor('@setup-nickname-input.is-invalid')
->assertSeeIn(
'@setup-status-message',
"The room is locked. Please, enter your name and try again."
)
->assertMissing('@setup-password-input')
->assertButtonEnabled('@setup-button')
->assertSeeIn('@setup-button.btn-success', 'JOIN NOW')
->type('@setup-nickname-input', 'Guest<p>')
->clickWhenEnabled('@setup-button')
->assertMissing('@setup-nickname-input.is-invalid')
->waitForText("Waiting for permission to join the room.")
->assertButtonDisabled('@setup-button');
// Test denying the request (this will also test custom toasts)
$owner
->whenAvailable(new Toast(Toast::TYPE_CUSTOM), function ($browser) {
$browser->assertToastTitle('Join request')
->assertVisible('.toast-header svg.fa-user')
->assertSeeIn('@message', 'Guest<p> requested to join.')
->assertAttributeRegExp('@message img', 'src', '|^data:image|')
->assertSeeIn('@message button.accept.btn-success', 'Accept')
->assertSeeIn('@message button.deny.btn-danger', 'Deny')
->click('@message button.deny');
})
->waitUntilMissing('.toast')
// wait 10 seconds to make sure the request message does not show up again
->pause(10 * 1000)
->assertMissing('.toast');
});
}
/**
* Test locked room (accepting the join request, and dismissing a user)
*
* @group meet
*/
public function testLockedRoomAcceptAndDismiss(): void
{
$this->browse(function (Browser $owner, Browser $guest) {
$room = Room::where('name', 'john')->first();
// Join the room as an owner (authenticate)
$owner->visit(new RoomPage('john'))
// ->click('@setup-button')
// ->submitLogon('john@kolab.org', 'simple123')
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->type('@setup-nickname-input', 'John')
->clickWhenEnabled('@setup-button')
->waitFor('@session')
// Enter room option dialog
->click('@menu button.link-options')
->with(new Dialog('#room-options-dialog'), function (Browser $browser) use ($room) {
$browser->assertSeeIn('@title', 'Room options')
->assertSeeIn('#room-lock label', 'Locked room:')
->assertVisible('#room-lock input[type=checkbox]:not(:checked)')
->assertVisible('#room-lock + small')
// Test setting the lock
->click('#room-lock input')
->assertToast(Toast::TYPE_SUCCESS, "Room configuration updated successfully.")
->click('@button-cancel');
$this->assertSame('true', $room->fresh()->getSetting('locked'));
});
// In another browser act as a guest
$guest->visit(new RoomPage('john'))
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->type('@setup-nickname-input', 'guest')
->clickWhenEnabled('@setup-button')
->waitForText("Waiting for permission to join the room.")
->assertButtonDisabled('@setup-button');
$owner
->whenAvailable(new Toast(Toast::TYPE_CUSTOM), function ($browser) {
$browser->assertToastTitle('Join request')
->assertSeeIn('@message', 'guest requested to join.')
->click('@message button.accept');
});
// Guest automatically anters the room
$guest->waitFor('@session', 12)
// make sure he has no access to the Options menu
->waitFor('@session .meet-video:not(.self)')
->assertSeeIn('@session .meet-video:not(.self) .meet-nickname', 'John')
// TODO: Assert title and icon
->click('@session .meet-video:not(.self) .meet-nickname')
->pause(100)
->assertMissing('.dropdown-menu');
// Test dismissing the participant
$owner->click('@session .meet-video:not(.self) .meet-nickname')
->waitFor('@session .meet-video:not(.self) .dropdown-menu')
->assertSeeIn('@session .meet-video:not(.self) .dropdown-menu > .action-dismiss', 'Dismiss')
->click('@session .meet-video:not(.self) .dropdown-menu > .action-dismiss')
->waitUntilMissing('.dropdown-menu')
->waitUntilMissing('@session .meet-video:not(.self)');
// Expect a "end of session" dialog on the participant side
$guest->with(new Dialog('#leave-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Room closed')
->assertSeeIn('@body', "The session has been closed by the room owner.")
->assertMissing('@button-action')
->assertSeeIn('@button-cancel', 'Close');
});
});
}
/**
* Test nomedia (subscribers only) feature
*
* @group meet
*/
public function testSubscribersOnly(): void
{
$this->browse(function (Browser $owner, Browser $guest) {
$room = Room::where('name', 'john')->first();
// Join the room as an owner (authenticate)
$owner->visit(new RoomPage('john'))
// ->click('@setup-button')
// ->submitLogon('john@kolab.org', 'simple123')
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->type('@setup-nickname-input', 'John')
->clickWhenEnabled('@setup-button')
->waitFor('@session')
// Enter room option dialog
->click('@menu button.link-options')
->with(new Dialog('#room-options-dialog'), function (Browser $browser) use ($room) {
$browser->assertSeeIn('@title', 'Room options')
->assertSeeIn('#room-nomedia label', 'Subscribers only:')
->assertVisible('#room-nomedia input[type=checkbox]:not(:checked)')
->assertVisible('#room-nomedia + small')
// Test enabling the option
->click('#room-nomedia input')
->assertToast(Toast::TYPE_SUCCESS, "Room configuration updated successfully.")
->click('@button-cancel');
$this->assertSame('true', $room->fresh()->getSetting('nomedia'));
});
// In another browser act as a guest
$guest->visit(new RoomPage('john'))
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->type('@setup-nickname-input', 'John')
->clickWhenEnabled('@setup-button')
// expect the owner to have a video, but the guest to have none
->waitFor('@session .meet-video')
->waitFor('@session .meet-subscriber.self');
// Unset the option back
$owner->click('@menu button.link-options')
->with(new Dialog('#room-options-dialog'), function (Browser $browser) use ($room) {
$browser->assertVisible('#room-nomedia input[type=checkbox]:checked')
// Test enabling the option
->click('#room-nomedia input')
->assertToast(Toast::TYPE_SUCCESS, "Room configuration updated successfully.")
->click('@button-cancel');
$this->assertSame(null, $room->fresh()->getSetting('nomedia'));
});
});
}
}
diff --git a/src/tests/Browser/Meet/RoomQATest.php b/src/tests/Browser/Meet/RoomQATest.php
index 05984fa1..5cce3342 100644
--- a/src/tests/Browser/Meet/RoomQATest.php
+++ b/src/tests/Browser/Meet/RoomQATest.php
@@ -1,139 +1,139 @@
<?php
namespace Tests\Browser\Meet;
use App\Meet\Room;
use Tests\Browser;
use Tests\Browser\Pages\Meet\Room as RoomPage;
use Tests\TestCaseDusk;
class RoomQATest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
- $this->setupTestRoom();
+ $this->resetTestRoom();
}
public function tearDown(): void
{
$this->resetTestRoom();
parent::tearDown();
}
/**
* Test Q&A queue
*
* @group meet
*/
public function testQA(): void
{
$this->browse(function (Browser $owner, Browser $guest1, Browser $guest2) {
// Join the room as an owner (authenticate)
$owner->visit(new RoomPage('john'))
->click('@setup-button')
->submitLogon('john@kolab.org', 'simple123')
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->select('@setup-mic-select', '')
->select('@setup-cam-select', '')
->type('@setup-nickname-input', 'John')
->clickWhenEnabled('@setup-button')
->waitFor('@session');
// In another browser act as a guest (1)
$guest1->visit(new RoomPage('john'))
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->assertMissing('@setup-status-message')
->assertSeeIn('@setup-button', "JOIN")
// Join the room, disable cam/mic
->select('@setup-mic-select', '')
->select('@setup-cam-select', '')
->type('@setup-nickname-input', 'Guest1')
->clickWhenEnabled('@setup-button')
->waitFor('@session');
// Assert current UI state
$owner->assertToolbarButtonState('hand', RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED)
->waitFor('div.meet-subscriber.self')
->assertMissing('@queue')
->click('@menu button.link-hand')
->waitFor('@queue .dropdown.self.moderated')
->assertSeeIn('@queue .dropdown.self.moderated', 'John')
->assertToolbarButtonState('hand', RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED);
// Assert current UI state
$guest1->waitFor('@queue .dropdown')
->assertSeeIn('@queue .dropdown', 'John')
->assertElementsCount('@queue .dropdown', 1)
->waitFor('div.meet-subscriber.self')
->click('@menu button.link-hand')
->waitFor('@queue .dropdown.self')
->assertSeeIn('@queue .dropdown.self', 'Guest1')
->assertElementsCount('@queue .dropdown', 2)
->click('@menu button.link-hand')
->waitUntilMissing('@queue .dropdown.self')
->assertElementsCount('@queue .dropdown', 1)
->assertToolbarButtonState('hand', RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED);
// In another browser act as a guest (2)
$guest2->visit(new RoomPage('john'))
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->assertMissing('@setup-status-message')
->assertSeeIn('@setup-button', "JOIN")
->type('@setup-nickname-input', 'Guest2')
->clickWhenEnabled('@setup-button')
->waitFor('@queue .dropdown')
->waitForTextIn('@queue .dropdown', 'John')
->assertElementsCount('@queue .dropdown', 1)
->assertMissing('@menu button.link-hand');
// Demote the guest (2) to subscriber, assert Hand button in toolbar
$owner->click('@session div.meet-video .meet-nickname')
->whenAvailable('@session div.meet-video .dropdown-menu', function ($browser) {
$browser->click('.action-role-publisher input');
});
// Guest (2) rises his hand
$guest2->waitUntilMissing('@session .meet-video')
->waitFor('@menu button.link-hand')
->assertToolbarButtonState('hand', RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED)
->click('@menu button.link-hand')
->waitFor('@queue .dropdown.self')
->assertElementsCount('@queue .dropdown', 2);
// Promote guest (2) to publisher
$owner->waitFor('@queue .dropdown:not(.self)')
->pause(8000) // wait until it's not moving, otherwise click() will fail
->click('@queue .dropdown:not(.self)')
->whenAvailable('@queue .dropdown:not(.self) .dropdown-menu', function ($browser) {
$browser->click('.action-role-publisher input');
})
->waitUntilMissing('@queue .dropdown:not(.self)')
->waitFor('@session .meet-video');
$guest1->waitFor('@session .meet-video')
->assertElementsCount('@queue .dropdown', 1);
$guest2->waitFor('@session .meet-video')
->waitUntilMissing('@queue .dropdown.self')
->assertElementsCount('@queue .dropdown', 1);
// Finally, do the same with the owner (last in the queue)
$owner->click('@queue .dropdown.self')
->whenAvailable('@queue .dropdown.self .dropdown-menu', function ($browser) {
$browser->click('.action-role-publisher input');
})
->waitUntilMissing('@queue')
->waitFor('@session .meet-video.self');
$guest1->waitUntilMissing('@queue');
$guest2->waitUntilMissing('@queue');
});
}
}
diff --git a/src/tests/Browser/Meet/RoomSetupTest.php b/src/tests/Browser/Meet/RoomSetupTest.php
index 98878c66..ec38fb19 100644
--- a/src/tests/Browser/Meet/RoomSetupTest.php
+++ b/src/tests/Browser/Meet/RoomSetupTest.php
@@ -1,570 +1,568 @@
<?php
namespace Tests\Browser\Meet;
use App\Meet\Room;
use Tests\Browser;
use Tests\Browser\Components\Dialog;
use Tests\Browser\Components\Menu;
use Tests\Browser\Pages\Meet\Room as RoomPage;
use Tests\TestCaseDusk;
class RoomSetupTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
- $this->setupTestRoom();
+ $this->resetTestRoom();
}
public function tearDown(): void
{
$this->resetTestRoom();
parent::tearDown();
}
/**
* Test non-existing room
*
* @group meet
*/
public function testRoomNonExistingRoom(): void
{
$this->browse(function (Browser $browser) {
$browser->visit(new RoomPage('unknown'))
->within(new Menu(), function ($browser) {
$browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login', 'lang']);
});
if ($browser->isDesktop()) {
$browser->within(new Menu('footer'), function ($browser) {
$browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'tos', 'login']);
});
} else {
$browser->assertMissing('#footer-menu .navbar-nav');
}
// FIXME: Maybe it would be better to just display the usual 404 Not Found error page?
$browser->assertMissing('@toolbar')
->assertMissing('@menu')
->assertMissing('@session')
->assertMissing('@chat')
->assertMissing('@login-form')
->assertVisible('@setup-form')
->assertSeeIn('@setup-status-message', "The room does not exist.")
->assertButtonDisabled('@setup-button');
});
}
/**
* Test the room setup page
*
* @group meet
*/
public function testRoomSetup(): void
{
$this->browse(function (Browser $browser) {
$browser->visit(new RoomPage('john'))
->within(new Menu(), function ($browser) {
$browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login', 'lang']);
});
if ($browser->isDesktop()) {
$browser->within(new Menu('footer'), function ($browser) {
$browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'tos', 'login']);
});
} else {
$browser->assertMissing('#footer-menu .navbar-nav');
}
// Note: I've found out that if I have another Chrome instance running
// that uses media, here the media devices will not be available
// TODO: Test enabling/disabling cam/mic in the setup widget
$browser->assertMissing('@toolbar')
->assertMissing('@menu')
->assertMissing('@session')
->assertMissing('@chat')
->assertMissing('@login-form')
->assertVisible('@setup-form')
->assertSeeIn('@setup-title', 'Set up your session')
->assertVisible('@setup-video')
->assertVisible('@setup-form .input-group:nth-child(1) svg')
->assertAttribute('@setup-form .input-group:nth-child(1) .input-group-text', 'title', 'Microphone')
->assertVisible('@setup-mic-select')
->assertVisible('@setup-form .input-group:nth-child(2) svg')
->assertAttribute('@setup-form .input-group:nth-child(2) .input-group-text', 'title', 'Camera')
->assertVisible('@setup-cam-select')
->assertVisible('@setup-form .input-group:nth-child(3) svg')
->assertAttribute('@setup-form .input-group:nth-child(3) .input-group-text', 'title', 'Nickname')
->assertValue('@setup-nickname-input', '')
->assertAttribute('@setup-nickname-input', 'placeholder', 'Your name')
->assertMissing('@setup-password-input')
->assertSeeIn(
'@setup-status-message',
"The room is closed. Please, wait for the owner to start the session."
)
->assertSeeIn('@setup-button', "I'm the owner");
});
}
/**
* Test two users in a room (joining/leaving and some basic functionality)
*
* @group meet
* @depends testRoomSetup
*/
public function testTwoUsersInARoom(): void
{
$this->browse(function (Browser $browser, Browser $guest) {
// In one browser window act as a guest
$guest->visit(new RoomPage('john'))
->assertMissing('@toolbar')
->assertMissing('@menu')
->assertMissing('@session')
->assertMissing('@chat')
->assertMissing('@login-form')
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->assertSeeIn(
'@setup-status-message',
"The room is closed. Please, wait for the owner to start the session."
)
->assertSeeIn('@setup-button', "I'm the owner");
// In another window join the room as the owner (authenticate)
$browser->on(new RoomPage('john'))
->assertSeeIn('@setup-button', "I'm the owner")
->clickWhenEnabled('@setup-button')
->assertMissing('@toolbar')
->assertMissing('@menu')
->assertMissing('@session')
->assertMissing('@chat')
->assertMissing('@setup-form')
->assertVisible('@login-form')
->submitLogon('john@kolab.org', 'simple123')
->waitFor('@setup-form')
->within(new Menu(), function ($browser) {
$browser->assertMenuItems(['explore', 'blog', 'support', 'dashboard', 'logout', 'lang']);
});
if ($browser->isDesktop()) {
$browser->within(new Menu('footer'), function ($browser) {
$browser->assertMenuItems(['explore', 'blog', 'support', 'tos', 'dashboard', 'logout']);
});
}
$browser->assertMissing('@login-form')
->waitUntilMissing('@setup-status-message.loading')
->waitFor('@setup-status-message')
->assertSeeIn('@setup-status-message', "The room is closed. It will be open for others after you join.")
->assertSeeIn('@setup-button', "JOIN")
->type('@setup-nickname-input', 'john')
->clickWhenEnabled('@setup-button')
->waitFor('@session')
->assertMissing('@setup-form')
->whenAvailable('div.meet-video.self', function (Browser $browser) {
$browser->waitFor('video')
->assertSeeIn('.meet-nickname', 'john')
->assertVisible('.controls button.link-fullscreen')
->assertMissing('.controls button.link-audio')
->assertMissing('.status .status-audio')
->assertMissing('.status .status-video');
})
->assertMissing('#header-menu')
->assertSeeIn('@counter', 1);
if (!$browser->isPhone()) {
$browser->assertMissing('#footer-menu');
} else {
$browser->assertVisible('#footer-menu');
}
// After the owner "opened the room" guest should be able to join
$guest->waitUntilMissing('@setup-status-message', 10)
->assertSeeIn('@setup-button', "JOIN")
// Join the room, disable cam/mic
->select('@setup-mic-select', '')
//->select('@setup-cam-select', '')
->clickWhenEnabled('@setup-button')
->waitFor('@session')
->assertMissing('@setup-form')
->whenAvailable('div.meet-video.self', function (Browser $browser) {
$browser->waitFor('video')
->assertVisible('.meet-nickname')
->assertVisible('.controls button.link-fullscreen')
->assertMissing('.controls button.link-audio')
->assertVisible('.status .status-audio')
->assertMissing('.status .status-video');
})
->whenAvailable('div.meet-video:not(.self)', function (Browser $browser) {
$browser->waitFor('video')
->assertSeeIn('.meet-nickname', 'john')
->assertVisible('.controls button.link-fullscreen')
->assertVisible('.controls button.link-audio')
->assertMissing('.status .status-audio')
->assertMissing('.status .status-video');
})
->assertElementsCount('@session div.meet-video', 2)
->assertSeeIn('@counter', 2);
// Check guest's elements in the owner's window
$browser
->whenAvailable('div.meet-video:not(.self)', function (Browser $browser) {
$browser->waitFor('video')
->assertVisible('.meet-nickname')
->assertVisible('.controls button.link-fullscreen')
->assertVisible('.controls button.link-audio')
->assertMissing('.controls button.link-setup')
->assertVisible('.status .status-audio')
->assertMissing('.status .status-video');
})
->assertElementsCount('@session div.meet-video', 2);
// Test leaving the room
// Guest is leaving
$guest->click('@menu button.link-logout')
->waitForLocation('/login')
->assertVisible('#header-menu');
// Expect the participant removed from other users windows
$browser->waitUntilMissing('@session div.meet-video:not(.self)')
->assertSeeIn('@counter', 1);
// Join the room as guest again
$guest->visit(new RoomPage('john'))
->assertMissing('@toolbar')
->assertMissing('@menu')
->assertMissing('@session')
->assertMissing('@chat')
->assertMissing('@login-form')
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->assertMissing('@setup-status-message')
->assertSeeIn('@setup-button', "JOIN")
// Join the room, disable cam/mic
->select('@setup-mic-select', '')
//->select('@setup-cam-select', '')
->clickWhenEnabled('@setup-button')
->waitFor('@session');
// Leave the room as the room owner
// TODO: Test leaving the room by closing the browser window,
// it should not destroy the session
$browser->click('@menu button.link-logout')
->waitForLocation('/dashboard');
// Expect other participants be informed about the end of the session
$guest->with(new Dialog('#leave-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Room closed')
->assertSeeIn('@body', "The session has been closed by the room owner.")
- ->assertMissing('@button-cancel')
- ->assertSeeIn('@button-action', 'Close')
- ->click('@button-action');
+ ->assertSeeIn('@button-cancel', 'Close')
+ ->click('@button-cancel');
})
->assertMissing('#leave-dialog')
->waitForLocation('/login');
});
}
/**
* Test two subscribers-only users in a room
*
* @group meet
* @depends testTwoUsersInARoom
*/
public function testSubscribers(): void
{
$this->browse(function (Browser $browser, Browser $guest) {
// Join the room as the owner
$browser->visit(new RoomPage('john'))
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->waitFor('@setup-status-message')
->type('@setup-nickname-input', 'john')
->select('@setup-mic-select', '')
->select('@setup-cam-select', '')
->clickWhenEnabled('@setup-button')
->waitFor('@session')
->assertMissing('@setup-form')
->whenAvailable('@subscribers .meet-subscriber.self', function (Browser $browser) {
$browser->assertSeeIn('.meet-nickname', 'john');
})
->assertElementsCount('@session div.meet-video', 0)
->assertElementsCount('@session video', 0)
->assertElementsCount('@session .meet-subscriber', 1)
->assertToolbar([
'audio' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_DISABLED,
'video' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_DISABLED,
'screen' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_DISABLED,
'hand' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED,
'chat' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED,
'fullscreen' => RoomPage::BUTTON_ENABLED,
'options' => RoomPage::BUTTON_ENABLED,
'logout' => RoomPage::BUTTON_ENABLED,
]);
// After the owner "opened the room" guest should be able to join
// In one browser window act as a guest
$guest->visit(new RoomPage('john'))
->waitUntilMissing('@setup-status-message', 10)
->assertSeeIn('@setup-button', "JOIN")
// Join the room, disable cam/mic
->select('@setup-mic-select', '')
->select('@setup-cam-select', '')
->clickWhenEnabled('@setup-button')
->waitFor('@session')
->assertMissing('@setup-form')
->whenAvailable('@subscribers .meet-subscriber.self', function (Browser $browser) {
$browser->assertVisible('.meet-nickname');
})
->whenAvailable('@subscribers .meet-subscriber:not(.self)', function (Browser $browser) {
$browser->assertSeeIn('.meet-nickname', 'john');
})
->assertElementsCount('@session div.meet-video', 0)
->assertElementsCount('@session video', 0)
->assertElementsCount('@session div.meet-subscriber', 2)
->assertSeeIn('@counter', 2)
->assertToolbar([
'audio' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_DISABLED,
'video' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_DISABLED,
'screen' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_DISABLED,
'hand' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED,
'chat' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED,
'fullscreen' => RoomPage::BUTTON_ENABLED,
'logout' => RoomPage::BUTTON_ENABLED,
]);
// Check guest's elements in the owner's window
$browser
->whenAvailable('@subscribers .meet-subscriber:not(.self)', function (Browser $browser) {
$browser->assertVisible('.meet-nickname');
})
->assertElementsCount('@session div.meet-video', 0)
->assertElementsCount('@session video', 0)
->assertElementsCount('@session .meet-subscriber', 2)
->assertSeeIn('@counter', 2);
// Test leaving the room
// Guest is leaving
$guest->click('@menu button.link-logout')
->waitForLocation('/login');
// Expect the participant removed from other users windows
$browser->waitUntilMissing('@session .meet-subscriber:not(.self)');
});
}
/**
* Test demoting publisher to a subscriber
*
* @group meet
* @depends testSubscribers
*/
public function testDemoteToSubscriber(): void
{
$this->browse(function (Browser $browser, Browser $guest1, Browser $guest2) {
// Join the room as the owner
$browser->visit(new RoomPage('john'))
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->waitFor('@setup-status-message')
->type('@setup-nickname-input', 'john')
->clickWhenEnabled('@setup-button')
->waitFor('@session')
->assertMissing('@setup-form')
->waitFor('@session video');
// In one browser window act as a guest
$guest1->visit(new RoomPage('john'))
->waitUntilMissing('@setup-status-message', 10)
->assertSeeIn('@setup-button', "JOIN")
->clickWhenEnabled('@setup-button')
->waitFor('@session')
->assertMissing('@setup-form')
->waitFor('div.meet-video.self')
->waitFor('div.meet-video:not(.self)')
->assertElementsCount('@session div.meet-video', 2)
->assertElementsCount('@session video', 2)
->assertElementsCount('@session div.meet-subscriber', 0)
// assert there's no moderator-related features for this guess available
->click('@session .meet-video.self .meet-nickname')
->whenAvailable('@session .meet-video.self .dropdown-menu', function (Browser $browser) {
$browser->assertMissing('.permissions');
})
->click('@session .meet-video:not(.self) .meet-nickname')
->pause(50)
->assertMissing('.dropdown-menu');
// Demote the guest to a subscriber
$browser
->waitFor('div.meet-video.self video')
->waitFor('div.meet-video:not(.self) video')
->assertElementsCount('@session div.meet-video', 2)
->assertElementsCount('@session video', 2)
->assertElementsCount('@session .meet-subscriber', 0)
->click('@session .meet-video:not(.self) .meet-nickname')
->whenAvailable('@session .meet-video:not(.self) .dropdown-menu', function (Browser $browser) {
$browser->assertSeeIn('.action-role-publisher', 'Audio & Video publishing')
->click('.action-role-publisher')
->waitUntilMissing('.dropdown-menu');
})
->waitUntilMissing('@session .meet-video:not(.self)')
->waitFor('@session div.meet-subscriber')
->assertElementsCount('@session div.meet-video', 1)
->assertElementsCount('@session video', 1)
->assertElementsCount('@session div.meet-subscriber', 1);
$guest1
->waitUntilMissing('@session .meet-video.self')
->waitFor('@session div.meet-subscriber')
->assertElementsCount('@session div.meet-video', 1)
->assertElementsCount('@session video', 1)
->assertElementsCount('@session div.meet-subscriber', 1);
// Join as another user to make sure the role change is propagated to new connections
$guest2->visit(new RoomPage('john'))
->waitUntilMissing('@setup-status-message', 10)
->assertSeeIn('@setup-button', "JOIN")
->select('@setup-mic-select', '')
->select('@setup-cam-select', '')
->clickWhenEnabled('@setup-button')
->waitFor('@session')
->assertMissing('@setup-form')
->waitFor('div.meet-subscriber:not(.self)')
->assertElementsCount('@session div.meet-video', 1)
->assertElementsCount('@session video', 1)
->assertElementsCount('@session div.meet-subscriber', 2)
->click('@toolbar .link-logout');
// Promote the guest back to a publisher
$browser
->click('@session .meet-subscriber .meet-nickname')
->whenAvailable('@session .meet-subscriber .dropdown-menu', function (Browser $browser) {
$browser->assertSeeIn('.action-role-publisher', 'Audio & Video publishing')
->assertNotChecked('.action-role-publisher input')
->click('.action-role-publisher')
->waitUntilMissing('.dropdown-menu');
})
->waitFor('@session .meet-video:not(.self) video')
->assertElementsCount('@session div.meet-video', 2)
->assertElementsCount('@session video', 2)
->assertElementsCount('@session div.meet-subscriber', 0);
$guest1
->with(new Dialog('#media-setup-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Media setup')
- ->click('@button-action');
+ ->click('@button-cancel');
})
->waitFor('@session .meet-video.self')
->assertElementsCount('@session div.meet-video', 2)
->assertElementsCount('@session video', 2)
->assertElementsCount('@session div.meet-subscriber', 0);
// Demote the owner to a subscriber
$browser
->click('@session .meet-video.self .meet-nickname')
->whenAvailable('@session .meet-video.self .dropdown-menu', function (Browser $browser) {
$browser->assertSeeIn('.action-role-publisher', 'Audio & Video publishing')
->assertChecked('.action-role-publisher input')
->click('.action-role-publisher')
->waitUntilMissing('.dropdown-menu');
})
->waitUntilMissing('@session .meet-video.self')
->waitFor('@session div.meet-subscriber.self')
->assertElementsCount('@session div.meet-video', 1)
->assertElementsCount('@session video', 1)
->assertElementsCount('@session div.meet-subscriber', 1);
// Promote the owner to a publisher
$browser
->click('@session .meet-subscriber.self .meet-nickname')
->whenAvailable('@session .meet-subscriber.self .dropdown-menu', function (Browser $browser) {
$browser->assertSeeIn('.action-role-publisher', 'Audio & Video publishing')
->assertNotChecked('.action-role-publisher input')
->click('.action-role-publisher')
->waitUntilMissing('.dropdown-menu');
})
->waitUntilMissing('@session .meet-subscriber.self')
->with(new Dialog('#media-setup-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Media setup')
- ->click('@button-action');
+ ->click('@button-cancel');
})
->waitFor('@session div.meet-video.self')
->assertElementsCount('@session div.meet-video', 2)
->assertElementsCount('@session video', 2)
->assertElementsCount('@session div.meet-subscriber', 0);
});
}
/**
* Test the media setup dialog
*
* @group meet
* @depends testDemoteToSubscriber
*/
public function testMediaSetupDialog(): void
{
$this->browse(function (Browser $browser, $guest) {
// Join the room as the owner
$browser->visit(new RoomPage('john'))
->waitFor('@setup-form')
->waitUntilMissing('@setup-status-message.loading')
->waitFor('@setup-status-message')
->type('@setup-nickname-input', 'john')
->clickWhenEnabled('@setup-button')
->waitFor('@session')
->assertMissing('@setup-form');
// In one browser window act as a guest
$guest->visit(new RoomPage('john'))
->waitUntilMissing('@setup-status-message', 10)
->assertSeeIn('@setup-button', "JOIN")
->select('@setup-mic-select', '')
->select('@setup-cam-select', '')
->clickWhenEnabled('@setup-button')
->waitFor('@session')
->assertMissing('@setup-form');
$browser->waitFor('@session video')
->click('.controls button.link-setup')
->with(new Dialog('#media-setup-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Media setup')
->assertVisible('form video')
->assertVisible('form > div:nth-child(1) video')
->assertVisible('form > div:nth-child(1) .volume')
->assertVisible('form > div:nth-child(2) svg')
->assertAttribute('form > div:nth-child(2) .input-group-text', 'title', 'Microphone')
->assertVisible('form > div:nth-child(2) select')
->assertVisible('form > div:nth-child(3) svg')
->assertAttribute('form > div:nth-child(3) .input-group-text', 'title', 'Camera')
->assertVisible('form > div:nth-child(3) select')
- ->assertMissing('@button-cancel')
- ->assertSeeIn('@button-action', 'Close')
- ->click('@button-action');
+ ->assertSeeIn('@button-cancel', 'Close')
+ ->click('@button-cancel');
})
->assertMissing('#media-setup-dialog')
// Test mute audio and video
->click('.controls button.link-setup')
->with(new Dialog('#media-setup-dialog'), function (Browser $browser) {
$browser->select('form > div:nth-child(2) select', '')
->select('form > div:nth-child(3) select', '')
- ->click('@button-action');
+ ->click('@button-cancel');
})
->assertMissing('#media-setup-dialog')
->assertVisible('@session .meet-video .status .status-audio')
->assertVisible('@session .meet-video .status .status-video');
$guest->waitFor('@session video')
->waitFor('@session .meet-video .status .status-audio')
->assertVisible('@session .meet-video .status .status-video');
});
}
}
diff --git a/src/tests/Browser/Meet/RoomsTest.php b/src/tests/Browser/Meet/RoomsTest.php
index 9845f5ea..9a3ca863 100644
--- a/src/tests/Browser/Meet/RoomsTest.php
+++ b/src/tests/Browser/Meet/RoomsTest.php
@@ -1,111 +1,393 @@
<?php
namespace Tests\Browser\Meet;
-use App\Sku;
+use App\Meet\Room;
use Tests\Browser;
+use Tests\Browser\Components\SubscriptionSelect;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
use Tests\Browser\Pages\Meet\Room as RoomPage;
+use Tests\Browser\Pages\Meet\RoomInfo;
+use Tests\Browser\Pages\Meet\RoomList;
use Tests\Browser\Pages\UserInfo;
use Tests\TestCaseDusk;
class RoomsTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
- $this->clearMeetEntitlements();
+
+ Room::withTrashed()->whereNotIn('name', ['shared', 'john'])->forceDelete();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
- $this->clearMeetEntitlements();
+ Room::withTrashed()->whereNotIn('name', ['shared', 'john'])->forceDelete();
+ $room = $this->resetTestRoom('shared', ['acl' => ['jack@kolab.org, full']]);
+
parent::tearDown();
}
/**
- * Test rooms page (unauthenticated and unauthorized)
- *
- * @group meet
+ * Test rooms page (unauthenticated)
*/
public function testRoomsUnauth(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$browser->visit('/rooms')
->on(new Home())
- // User has no 'meet' entitlement yet, expect redirect to error page
->submitLogon('john@kolab.org', 'simple123', false)
- ->waitFor('#app > #error-page')
- ->assertSeeIn('#error-page .code', '403')
- ->assertSeeIn('#error-page .message', 'Access denied');
+ ->on(new RoomList());
});
}
/**
- * Test rooms page
+ * Test rooms list page
*
* @group meet
*/
public function testRooms(): void
{
$this->browse(function (Browser $browser) {
- $href = \config('app.url') . '/meet/john';
$john = $this->getTestUser('john@kolab.org');
- // User has no 'meet' entitlement yet
$browser->visit('/login')
->on(new Home())
->submitLogon('john@kolab.org', 'simple123', true)
->on(new Dashboard())
- ->assertMissing('@links a.link-chat');
+ ->assertSeeIn('@links a.link-chat', 'Video chat')
+ // Test Video chat page
+ ->click('@links a.link-chat')
+ ->on(new RoomList())
+ ->whenAvailable('@table', function ($browser) {
+ $browser->assertElementsCount('tbody tr', 2)
+ ->assertElementsCount('thead th', 3)
+ ->with('tbody tr:nth-child(1)', function ($browser) {
+ $browser->assertSeeIn('td:nth-child(1) a', 'john')
+ ->assertSeeIn('td:nth-child(2) a', "Standard room")
+ ->assertVisible('td.buttons button')
+ ->assertAttribute('td.buttons button', 'title', 'Enter the room');
+ })
+ ->with('tbody tr:nth-child(2)', function ($browser) {
+ $browser->assertSeeIn('td:nth-child(1) a', 'shared')
+ ->assertSeeIn('td:nth-child(2) a', "Shared room")
+ ->assertVisible('td.buttons button')
+ ->assertAttribute('td.buttons button', 'title', 'Enter the room');
+ })
+ ->click('tbody tr:nth-child(1) button');
+ });
+
+ $newWindow = collect($browser->driver->getWindowHandles())->last();
+ $browser->driver->switchTo()->window($newWindow);
+
+ $browser->on(new RoomPage('john'))
+ // check that entering the room skips the logon form
+ ->assertMissing('@toolbar')
+ ->assertMissing('@menu')
+ ->assertMissing('@session')
+ ->assertMissing('@chat')
+ ->assertMissing('@login-form')
+ ->assertVisible('@setup-form')
+ ->assertSeeIn('@setup-status-message', "The room is closed. It will be open for others after you join.")
+ ->assertSeeIn('@setup-button', "JOIN")
+ ->click('@setup-button')
+ ->waitFor('@session')
+ ->assertMissing('@setup-form');
+ });
+ }
- // Goto user subscriptions, and enable 'meet' subscription
- $browser->visit('/user/' . $john->id)
- ->on(new UserInfo())
- ->whenAvailable('@skus', function ($browser) {
- $browser->click('#sku-input-meet');
+ /**
+ * Test rooms create and edit and delete
+ */
+ public function testRoomCreateAndEditAndDelete(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+
+ $this->browse(function (Browser $browser) {
+ // Test room creation
+ $browser->visit(new RoomList())
+ ->assertSeeIn('button.room-new', 'Create room')
+ ->click('button.room-new')
+ ->on(new RoomInfo())
+ ->assertVisible('@intro p')
+ ->assertElementsCount('@nav li', 1)
+ ->assertSeeIn('@nav li a', 'General')
+ ->with('@general form', function ($browser) {
+ $browser->assertSeeIn('.row:nth-child(1) label', 'Description')
+ ->assertFocused('.row:nth-child(1) input')
+ ->assertSeeIn('.row:nth-child(2) label', 'Subscriptions')
+ ->with(new SubscriptionSelect('@skus'), function ($browser) {
+ $browser->assertElementsCount('tbody tr', 2)
+ ->assertSubscription(
+ 0,
+ "Standard conference room",
+ "Audio & video conference room",
+ "0,00 CHF/month"
+ )
+ ->assertSubscriptionState(0, true)
+ ->assertSubscription(
+ 1,
+ "Group conference room",
+ "Shareable audio & video conference room",
+ "0,00 CHF/month"
+ )
+ ->assertSubscriptionState(1, false)
+ ->clickSubscription(1)
+ ->assertSubscriptionState(0, false)
+ ->assertSubscriptionState(1, true);
+ })
+ ->type('.row:nth-child(1) input', 'test123');
})
- ->scrollTo('#general button[type=submit]')->pause(200)
- ->click('#general button[type=submit]')
- ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.')
- ->click('.navbar-brand')
- ->on(new Dashboard())
- ->assertSeeIn('@links a.link-chat', 'Video chat')
- // Make sure the element also exists on Dashboard page load
- ->refresh()
+ ->click('@general button[type=submit]')
+ ->assertToast(Toast::TYPE_SUCCESS, "Room created successfully.")
+ ->on(new RoomList())
+ ->whenAvailable('@table', function ($browser) {
+ $browser->assertElementsCount('tbody tr', 3);
+ });
+
+ $room = Room::where('description', 'test123')->first();
+
+ $this->assertTrue($room->hasSKU('group-room'));
+
+ // Test room editing
+ $browser->click("a[href=\"/room/{$room->id}\"]")
+ ->on(new RoomInfo())
+ ->assertSeeIn('.card-title', "Room: {$room->name}")
+ ->assertVisible('@intro p')
+ ->assertVisible("@intro a[href=\"/meet/{$room->name}\"]")
+ ->assertElementsCount('@nav li', 2)
+ ->assertSeeIn('@nav li:first-child a', 'General')
+ ->with('@general form', function ($browser) {
+ $browser->assertSeeIn('.row:nth-child(1) label', 'Description')
+ ->assertFocused('.row:nth-child(1) input')
+ ->type('.row:nth-child(1) input', 'test321')
+ ->assertSeeIn('.row:nth-child(2) label', 'Subscriptions')
+ ->with(new SubscriptionSelect('@skus'), function ($browser) {
+ $browser->assertElementsCount('tbody tr', 2)
+ ->assertSubscription(
+ 0,
+ "Standard conference room",
+ "Audio & video conference room",
+ "0,00 CHF/month"
+ )
+ ->assertSubscriptionState(0, false)
+ ->assertSubscription(
+ 1,
+ "Group conference room",
+ "Shareable audio & video conference room",
+ "0,00 CHF/month"
+ )
+ ->assertSubscriptionState(1, true)
+ ->clickSubscription(0)
+ ->assertSubscriptionState(0, true)
+ ->assertSubscriptionState(1, false);
+ });
+ })
+ ->click('@general button[type=submit]')
+ ->assertToast(Toast::TYPE_SUCCESS, "Room updated successfully.")
+ ->on(new RoomList());
+
+ $room->refresh();
+
+ $this->assertSame('test321', $room->description);
+ $this->assertFalse($room->hasSKU('group-room'));
+
+ // Test room deleting
+ $browser->visit('/room/' . $room->id)
+ ->on(new Roominfo())
+ ->assertSeeIn('button.button-delete', 'Delete room')
+ ->click('button.button-delete')
+ ->assertToast(Toast::TYPE_SUCCESS, "Room deleted successfully.")
+ ->on(new RoomList())
+ ->whenAvailable('@table', function ($browser) {
+ $browser->assertElementsCount('tbody tr', 2);
+ });
+ });
+ }
+
+ /**
+ * Test room settings
+ */
+ public function testRoomSettings(): void
+ {
+ $this->browse(function (Browser $browser) {
+ $john = $this->getTestUser('john@kolab.org');
+ $room = $this->getTestRoom('test', $john->wallets()->first());
+
+ // Test that there's no Moderators for non-group rooms
+ $browser->visit('/room/' . $room->id)
+ ->on(new RoomInfo())
+ ->assertSeeIn('@nav li:last-child a', 'Settings')
+ ->click('@nav li:last-child a')
+ ->with('@settings form', function ($browser) {
+ $browser->assertSeeIn('.row:nth-child(1) label', 'Password')
+ ->assertValue('.row:nth-child(1) input', '')
+ ->assertVisible('.row:nth-child(1) .form-text')
+ ->assertSeeIn('.row:nth-child(2) label', 'Locked room')
+ ->assertNotChecked('.row:nth-child(2) input')
+ ->assertVisible('.row:nth-child(2) .form-text')
+ ->assertSeeIn('.row:nth-child(3) label', 'Subscribers only')
+ ->assertNotChecked('.row:nth-child(3) input')
+ ->assertVisible('.row:nth-child(3) .form-text')
+ ->assertMissing('.row:nth-child(4)'); // no Moderators section on a standard room
+ });
+
+ $room->forceDelete();
+ $room = $this->getTestRoom('test', $john->wallets()->first(), [], [], 'group-room');
+
+ // Now we can assert and change all settings
+ $browser->visit('/room/' . $room->id)
+ ->on(new RoomInfo())
+ ->assertSeeIn('@nav li:last-child a', 'Settings')
+ ->click('@nav li:last-child a')
+ ->with('@settings form', function ($browser) {
+ $browser->assertSeeIn('.row:nth-child(4) label', 'Moderators')
+ ->assertVisible('.row:nth-child(4) .form-text')
+ ->type('#acl .input-group:first-child input', 'jack')
+ ->click('#acl a.btn');
+ })
+ ->click('@settings button[type=submit]')
+ ->assertToast(Toast::TYPE_ERROR, "Form validation error")
+ ->assertSeeIn('#acl + .invalid-feedback', "The specified email address is invalid.")
+ ->with('@settings form', function ($browser) {
+ $browser->type('.row:nth-child(1) input', 'pass')
+ ->click('.row:nth-child(2) input')
+ ->click('.row:nth-child(3) input')
+ ->click('#acl .input-group:last-child a.btn')
+ ->type('#acl .input-group:first-child input', 'jack@kolab.org')
+ ->click('#acl a.btn');
+ })
+ ->click('@settings button[type=submit]')
+ ->assertToast(Toast::TYPE_SUCCESS, "Room configuration updated successfully.");
+
+ $config = $room->getConfig();
+
+ $this->assertSame('pass', $config['password']);
+ $this->assertSame(true, $config['locked']);
+ $this->assertSame(true, $config['nomedia']);
+ $this->assertSame(['jack@kolab.org, full'], $config['acl']);
+ });
+ }
+
+ /**
+ * Test acting as a non-controller user
+ *
+ * @group meet
+ */
+ public function testNonControllerRooms(): void
+ {
+ $jack = $this->getTestUser('jack@kolab.org');
+ $john = $this->getTestUser('john@kolab.org');
+ $room = $this->resetTestRoom('shared', [
+ 'password' => 'pass',
+ 'locked' => true,
+ 'nomedia' => true,
+ 'acl' => ['jack@kolab.org, full']
+ ]);
+
+ $this->browse(function (Browser $browser) use ($room, $jack) {
+ $browser->visit(new Home())
+ ->submitLogon('jack@kolab.org', 'simple123', true)
->on(new Dashboard())
- ->assertSeeIn('@links a.link-chat', 'Video chat');
-
- // Test Video chat page
- $browser->click('@links a.link-chat')
- ->waitFor('#meet-rooms')
- ->waitFor('.card-text a')
- ->assertSeeIn('.card-title', 'Voice & Video Conferencing')
- ->assertSeeIn('.card-text a', $href)
- ->assertAttribute('.card-text a', 'href', '/meet/john')
- ->click('.card-text a')
- ->on(new RoomPage('john'))
+ ->click('@links a.link-chat')
+ ->on(new RoomList())
+ ->assertMissing('button.room-new')
+ ->whenAvailable('@table', function ($browser) {
+ $browser->assertElementsCount('tbody tr', 2); // one shared room, one owned room
+ });
+
+ // the owned room
+ $owned = $jack->rooms()->first();
+ $browser->visit('/room/' . $owned->id)
+ ->on(new RoomInfo())
+ ->assertSeeIn('.card-title', "Room: {$owned->name}")
+ ->assertVisible('@intro p')
+ ->assertVisible("@intro a[href=\"/meet/{$owned->name}\"]")
+ ->assertMissing('button.button-delete')
+ ->assertElementsCount('@nav li', 2)
+ ->with('@general form', function ($browser) {
+ $browser->assertSeeIn('.row:nth-child(1) label', 'Description')
+ ->assertFocused('.row:nth-child(1) input')
+ ->assertSeeIn('.row:nth-child(2) label', 'Subscriptions')
+ ->with(new SubscriptionSelect('@skus'), function ($browser) {
+ $browser->assertElementsCount('tbody tr', 1)
+ ->assertSubscription(
+ 0,
+ "Standard conference room",
+ "Audio & video conference room",
+ "0,00 CHF/month"
+ )
+ ->assertSubscriptionState(0, true);
+ });
+ })
+ ->click('@nav li:last-child a')
+ ->with('@settings form', function ($browser) {
+ $browser->assertSeeIn('.row:nth-child(1) label', 'Password')
+ ->assertValue('.row:nth-child(1) input', '')
+ ->assertSeeIn('.row:nth-child(2) label', 'Locked room')
+ ->assertNotChecked('.row:nth-child(2) input')
+ ->assertSeeIn('.row:nth-child(3) label', 'Subscribers only')
+ ->assertNotChecked('.row:nth-child(3) input')
+ ->assertMissing('.row:nth-child(4)');
+ });
+
+ // Shared room
+ $browser->visit('/room/' . $room->id)
+ ->on(new RoomInfo())
+ ->assertSeeIn('.card-title', "Room: {$room->name}")
+ ->assertVisible('@intro p')
+ ->assertVisible("@intro a[href=\"/meet/{$room->name}\"]")
+ ->assertMissing('button.button-delete')
+ ->assertElementsCount('@nav li', 1)
+ // Test room settings
+ ->assertSeeIn('@nav li:last-child a', 'Settings')
+ ->with('@settings form', function ($browser) {
+ $browser->assertSeeIn('.row:nth-child(1) label', 'Password')
+ ->assertValue('.row:nth-child(1) input', 'pass')
+ ->assertSeeIn('.row:nth-child(2) label', 'Locked room')
+ ->assertChecked('.row:nth-child(2) input')
+ ->assertSeeIn('.row:nth-child(3) label', 'Subscribers only')
+ ->assertChecked('.row:nth-child(3) input')
+ ->assertMissing('.row:nth-child(4)')
+ ->type('.row:nth-child(1) input', 'pass123')
+ ->click('.row:nth-child(2) input')
+ ->click('.row:nth-child(3) input');
+ })
+ ->click('@settings button[type=submit]')
+ ->assertToast(Toast::TYPE_SUCCESS, "Room configuration updated successfully.");
+
+ $config = $room->getConfig();
+
+ $this->assertSame('pass123', $config['password']);
+ $this->assertSame(false, $config['locked']);
+ $this->assertSame(false, $config['nomedia']);
+ $this->assertSame(['jack@kolab.org, full'], $config['acl']);
+
+ $browser->click("@intro a[href=\"/meet/shared\"]")
+ ->on(new RoomPage('shared'))
// check that entering the room skips the logon form
->assertMissing('@toolbar')
->assertMissing('@menu')
->assertMissing('@session')
->assertMissing('@chat')
->assertMissing('@login-form')
->assertVisible('@setup-form')
->assertSeeIn('@setup-status-message', "The room is closed. It will be open for others after you join.")
->assertSeeIn('@setup-button', "JOIN")
->click('@setup-button')
->waitFor('@session')
- ->assertMissing('@setup-form');
+ ->assertMissing('@setup-form')
+ ->waitFor('a.meet-nickname svg.moderator');
});
}
}
diff --git a/src/tests/Browser/Pages/Meet/RoomInfo.php b/src/tests/Browser/Pages/Meet/RoomInfo.php
new file mode 100644
index 00000000..3333c2c1
--- /dev/null
+++ b/src/tests/Browser/Pages/Meet/RoomInfo.php
@@ -0,0 +1,47 @@
+<?php
+
+namespace Tests\Browser\Pages\Meet;
+
+use Laravel\Dusk\Page;
+
+class RoomInfo 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->waitUntilMissing('@app .app-loader');
+ }
+
+ /**
+ * Get the element shortcuts for the page.
+ *
+ * @return array
+ */
+ public function elements(): array
+ {
+ return [
+ '@app' => '#app',
+ '@intro' => '#room-intro',
+ '@general' => '#general',
+ '@nav' => 'ul.nav-tabs',
+ '@settings' => '#settings',
+ '@skus' => '#room-skus table',
+ ];
+ }
+}
diff --git a/src/tests/Browser/Pages/Meet/RoomList.php b/src/tests/Browser/Pages/Meet/RoomList.php
new file mode 100644
index 00000000..1d0cd9e5
--- /dev/null
+++ b/src/tests/Browser/Pages/Meet/RoomList.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace Tests\Browser\Pages\Meet;
+
+use Laravel\Dusk\Page;
+
+class RoomList extends Page
+{
+ /**
+ * Get the URL for the page.
+ *
+ * @return string
+ */
+ public function url(): string
+ {
+ return '/rooms';
+ }
+
+ /**
+ * Assert that the browser is on the page.
+ *
+ * @param \Laravel\Dusk\Browser $browser The browser object
+ *
+ * @return void
+ */
+ public function assert($browser)
+ {
+ $browser->waitForLocation($this->url())
+ ->waitUntilMissing('@app .app-loader')
+ ->assertSeeIn('@list .card-title', 'Voice & video conferencing rooms');
+ }
+
+ /**
+ * Get the element shortcuts for the page.
+ *
+ * @return array
+ */
+ public function elements(): array
+ {
+ return [
+ '@app' => '#app',
+ '@list' => '#rooms-list',
+ '@table' => '#rooms-list table',
+ ];
+ }
+}
diff --git a/src/tests/Browser/Reseller/UserTest.php b/src/tests/Browser/Reseller/UserTest.php
index 46b4dd0a..cf40b706 100644
--- a/src/tests/Browser/Reseller/UserTest.php
+++ b/src/tests/Browser/Reseller/UserTest.php
@@ -1,577 +1,575 @@
<?php
namespace Tests\Browser\Reseller;
use App\Auth\SecondFactor;
use App\Discount;
use App\Sku;
use App\User;
use Tests\Browser;
use Tests\Browser\Components\Dialog;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Admin\User as UserPage;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
use Tests\TestCaseDusk;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class UserTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
self::useResellerUrl();
$john = $this->getTestUser('john@kolab.org');
$john->setSettings([
'phone' => '+48123123123',
'external_email' => 'john.doe.external@gmail.com',
]);
if ($john->isSuspended()) {
User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]);
}
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
$wallet->save();
$this->deleteTestGroup('group-test@kolab.org');
- $this->clearMeetEntitlements();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$john = $this->getTestUser('john@kolab.org');
$john->setSettings([
'phone' => null,
'external_email' => 'john.doe.external@gmail.com',
]);
if ($john->isSuspended()) {
User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]);
}
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
$wallet->save();
$this->deleteTestGroup('group-test@kolab.org');
- $this->clearMeetEntitlements();
parent::tearDown();
}
/**
* Test user info page (unauthenticated)
*/
public function testUserUnauth(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$jack = $this->getTestUser('jack@kolab.org');
$browser->visit('/user/' . $jack->id)->on(new Home());
});
}
/**
* Test user info page
*/
public function testUserInfo(): void
{
$this->browse(function (Browser $browser) {
$jack = $this->getTestUser('jack@kolab.org');
$page = new UserPage($jack->id);
$browser->visit(new Home())
->submitLogon('reseller@' . \config('app.domain'), \App\Utils::generatePassphrase(), true)
->on(new Dashboard())
->visit($page)
->on($page);
// Assert main info box content
$browser->assertSeeIn('@user-info .card-title', $jack->email)
->with('@user-info form', function (Browser $browser) use ($jack) {
$browser->assertElementsCount('.row', 7)
->assertSeeIn('.row:nth-child(1) label', 'Managed by')
->assertSeeIn('.row:nth-child(1) #manager a', 'john@kolab.org')
->assertSeeIn('.row:nth-child(2) label', 'ID (Created)')
->assertSeeIn('.row:nth-child(2) #userid', "{$jack->id} ({$jack->created_at})")
->assertSeeIn('.row:nth-child(3) label', 'Status')
->assertSeeIn('.row:nth-child(3) #status span.text-success', 'Active')
->assertSeeIn('.row:nth-child(4) label', 'First Name')
->assertSeeIn('.row:nth-child(4) #first_name', 'Jack')
->assertSeeIn('.row:nth-child(5) label', 'Last Name')
->assertSeeIn('.row:nth-child(5) #last_name', 'Daniels')
->assertSeeIn('.row:nth-child(6) label', 'External Email')
->assertMissing('.row:nth-child(6) #external_email a')
->assertSeeIn('.row:nth-child(7) label', 'Country')
->assertSeeIn('.row:nth-child(7) #country', 'United States');
});
// Some tabs are loaded in background, wait a second
$browser->pause(500)
->assertElementsCount('@nav a', 9);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
// Assert Aliases tab
$browser->assertSeeIn('@nav #tab-aliases', 'Aliases (1)')
->click('@nav #tab-aliases')
->whenAvailable('@user-aliases', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 1)
->assertSeeIn('table tbody tr:first-child td:first-child', 'jack.daniels@kolab.org')
->assertMissing('table tfoot');
});
// Assert Subscriptions tab
$browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (3)')
->click('@nav #tab-subscriptions')
->with('@user-subscriptions', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 3)
->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox')
->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '5,00 CHF/month')
->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 5 GB')
->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month')
->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features')
->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,90 CHF/month')
->assertMissing('table tfoot')
->assertMissing('#reset2fa');
});
// Assert Domains tab
$browser->assertSeeIn('@nav #tab-domains', 'Domains (0)')
->click('@nav #tab-domains')
->with('@user-domains', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no domains in this account.');
});
// Assert Users tab
$browser->assertSeeIn('@nav #tab-users', 'Users (0)')
->click('@nav #tab-users')
->with('@user-users', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no users in this account.');
});
// Assert Distribution lists tab
$browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (0)')
->click('@nav #tab-distlists')
->with('@user-distlists', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no distribution lists in this account.');
});
// Assert Resources tab
$browser->assertSeeIn('@nav #tab-resources', 'Resources (0)')
->click('@nav #tab-resources')
->with('@user-resources', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no resources in this account.');
});
// Assert Shared folders tab
$browser->assertSeeIn('@nav #tab-folders', 'Shared folders (0)')
->click('@nav #tab-folders')
->with('@user-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')
->whenAvailable('@user-settings form', function (Browser $browser) {
$browser->assertElementsCount('.row', 1)
->assertSeeIn('.row:first-child label', 'Greylisting')
->assertSeeIn('.row:first-child .text-success', 'enabled');
});
});
}
/**
* Test user info page (continue)
*
* @depends testUserInfo
*/
public function testUserInfo2(): void
{
$this->browse(function (Browser $browser) {
$john = $this->getTestUser('john@kolab.org');
$page = new UserPage($john->id);
$discount = Discount::where('code', 'TEST')->first();
$wallet = $john->wallet();
$wallet->discount()->associate($discount);
$wallet->debit(2010);
$wallet->save();
$group = $this->getTestGroup('group-test@kolab.org', ['name' => 'Test Group']);
$group->assignToWallet($john->wallets->first());
// Click the managed-by link on Jack's page
$browser->click('@user-info #manager a')
->on($page);
// Assert main info box content
$browser->assertSeeIn('@user-info .card-title', $john->email)
->with('@user-info form', function (Browser $browser) use ($john) {
$ext_email = $john->getSetting('external_email');
$browser->assertElementsCount('.row', 9)
->assertSeeIn('.row:nth-child(1) label', 'ID (Created)')
->assertSeeIn('.row:nth-child(1) #userid', "{$john->id} ({$john->created_at})")
->assertSeeIn('.row:nth-child(2) label', 'Status')
->assertSeeIn('.row:nth-child(2) #status span.text-success', 'Active')
->assertSeeIn('.row:nth-child(3) label', 'First Name')
->assertSeeIn('.row:nth-child(3) #first_name', 'John')
->assertSeeIn('.row:nth-child(4) label', 'Last Name')
->assertSeeIn('.row:nth-child(4) #last_name', 'Doe')
->assertSeeIn('.row:nth-child(5) label', 'Organization')
->assertSeeIn('.row:nth-child(5) #organization', 'Kolab Developers')
->assertSeeIn('.row:nth-child(6) label', 'Phone')
->assertSeeIn('.row:nth-child(6) #phone', $john->getSetting('phone'))
->assertSeeIn('.row:nth-child(7) label', 'External Email')
->assertSeeIn('.row:nth-child(7) #external_email a', $ext_email)
->assertAttribute('.row:nth-child(7) #external_email a', 'href', "mailto:$ext_email")
->assertSeeIn('.row:nth-child(8) label', 'Address')
->assertSeeIn('.row:nth-child(8) #billing_address', $john->getSetting('billing_address'))
->assertSeeIn('.row:nth-child(9) label', 'Country')
->assertSeeIn('.row:nth-child(9) #country', 'United States');
});
// Some tabs are loaded in background, wait a second
$browser->pause(500)
->assertElementsCount('@nav a', 9);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
// Assert Aliases tab
$browser->assertSeeIn('@nav #tab-aliases', 'Aliases (1)')
->click('@nav #tab-aliases')
->whenAvailable('@user-aliases', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 1)
->assertSeeIn('table tbody tr:first-child td:first-child', 'john.doe@kolab.org')
->assertMissing('table tfoot');
});
// Assert Subscriptions tab
$browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (3)')
->click('@nav #tab-subscriptions')
->with('@user-subscriptions', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 3)
->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox')
->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '4,50 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 5 GB')
->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features')
->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,41 CHF/month¹')
->assertMissing('table tfoot')
->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')
->with('@user-domains table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 1)
->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'kolab.org')
->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success')
->assertMissing('tfoot');
});
// Assert Distribution lists tab
$browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (1)')
->click('@nav #tab-distlists')
->with('@user-distlists table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 1)
->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'Test Group')
->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-danger')
->assertSeeIn('tbody tr:nth-child(1) td:last-child a', 'group-test@kolab.org')
->assertMissing('tfoot');
});
// Assert Resources tab
$browser->assertSeeIn('@nav #tab-resources', 'Resources (2)')
->click('@nav #tab-resources')
->with('@user-resources', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 2)
->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'Conference Room #1')
->assertSeeIn('table tbody tr:nth-child(1) td:last-child', 'resource-test1@kolab.org')
->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Conference Room #2')
->assertSeeIn('table tbody tr:nth-child(2) td:last-child', 'resource-test2@kolab.org')
->assertMissing('table tfoot');
});
// Assert Shared folders tab
$browser->assertSeeIn('@nav #tab-folders', 'Shared folders (2)')
->click('@nav #tab-folders')
->with('@user-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');
});
});
// Now we go to Ned's info page, he's a controller on John's wallet
$this->browse(function (Browser $browser) {
$ned = $this->getTestUser('ned@kolab.org');
$ned->setSetting('greylist_enabled', 'false');
$page = new UserPage($ned->id);
$browser->click('@nav #tab-users')
->click('@user-users tbody tr:nth-child(4) td:first-child a')
->on($page);
// Assert main info box content
$browser->assertSeeIn('@user-info .card-title', $ned->email)
->with('@user-info form', function (Browser $browser) use ($ned) {
$browser->assertSeeIn('.row:nth-child(2) label', 'ID (Created)')
->assertSeeIn('.row:nth-child(2) #userid', "{$ned->id} ({$ned->created_at})");
});
// Some tabs are loaded in background, wait a second
$browser->pause(500)
->assertElementsCount('@nav a', 9);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
// Assert Aliases tab
$browser->assertSeeIn('@nav #tab-aliases', 'Aliases (0)')
->click('@nav #tab-aliases')
->whenAvailable('@user-aliases', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'This user has no email aliases.');
});
// Assert Subscriptions tab, we expect John's discount here
$browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (5)')
->click('@nav #tab-subscriptions')
->with('@user-subscriptions', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 5)
->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox')
->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '4,50 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 5 GB')
->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features')
->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,41 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(4) td:first-child', 'Activesync')
->assertSeeIn('table tbody tr:nth-child(4) td:last-child', '0,00 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(5) td:first-child', '2-Factor Authentication')
->assertSeeIn('table tbody tr:nth-child(5) td:last-child', '0,00 CHF/month¹')
->assertMissing('table tfoot')
->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher')
->assertSeeIn('#reset2fa', 'Reset 2-Factor Auth');
});
// We don't expect John's domains here
$browser->assertSeeIn('@nav #tab-domains', 'Domains (0)')
->click('@nav #tab-domains')
->with('@user-domains', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no domains in this account.');
});
// We don't expect John's users here
$browser->assertSeeIn('@nav #tab-users', 'Users (0)')
->click('@nav #tab-users')
->with('@user-users', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no users in this account.');
});
// We don't expect John's distribution lists here
$browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (0)')
->click('@nav #tab-distlists')
->with('@user-distlists', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no distribution lists in this account.');
});
// Assert Resources tab
$browser->assertSeeIn('@nav #tab-resources', 'Resources (0)')
->click('@nav #tab-resources')
->with('@user-resources', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no resources in this account.');
});
// Assert Shared folders tab
$browser->assertSeeIn('@nav #tab-folders', 'Shared folders (0)')
->click('@nav #tab-folders')
->with('@user-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')
->whenAvailable('@user-settings form', function (Browser $browser) {
$browser->assertElementsCount('.row', 1)
->assertSeeIn('.row:first-child label', 'Greylisting')
->assertSeeIn('.row:first-child .text-danger', 'disabled');
});
});
}
/**
* Test editing an external email
*
* @depends testUserInfo2
*/
public function testExternalEmail(): void
{
$this->browse(function (Browser $browser) {
$john = $this->getTestUser('john@kolab.org');
$browser->visit(new UserPage($john->id))
->waitFor('@user-info #external_email button')
->click('@user-info #external_email button')
// Test dialog content, and closing it with Cancel button
->with(new Dialog('#email-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'External Email')
->assertFocused('@body input')
->assertValue('@body input', 'john.doe.external@gmail.com')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Submit')
->click('@button-cancel');
})
->assertMissing('#email-dialog')
->click('@user-info #external_email button')
// Test email validation error handling, and email update
->with(new Dialog('#email-dialog'), function (Browser $browser) {
$browser->type('@body input', 'test')
->click('@button-action')
->waitFor('@body input.is-invalid')
->assertSeeIn(
'@body input + .invalid-feedback',
'The external email must be a valid email address.'
)
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->type('@body input', 'test@test.com')
->click('@button-action');
})
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.')
->assertSeeIn('@user-info #external_email a', 'test@test.com')
->click('@user-info #external_email button')
->with(new Dialog('#email-dialog'), function (Browser $browser) {
$browser->assertValue('@body input', 'test@test.com')
->assertMissing('@body input.is-invalid')
->assertMissing('@body input + .invalid-feedback')
->click('@button-cancel');
})
->assertSeeIn('@user-info #external_email a', 'test@test.com');
// $john->getSetting() may not work here as it uses internal cache
// read the value form database
$current_ext_email = $john->settings()->where('key', 'external_email')->first()->value;
$this->assertSame('test@test.com', $current_ext_email);
});
}
/**
* Test suspending/unsuspending the user
*/
public function testSuspendAndUnsuspend(): void
{
$this->browse(function (Browser $browser) {
$john = $this->getTestUser('john@kolab.org');
$browser->visit(new UserPage($john->id))
->assertVisible('@user-info #button-suspend')
->assertMissing('@user-info #button-unsuspend')
->click('@user-info #button-suspend')
->assertToast(Toast::TYPE_SUCCESS, 'User suspended successfully.')
->assertSeeIn('@user-info #status span.text-warning', 'Suspended')
->assertMissing('@user-info #button-suspend')
->click('@user-info #button-unsuspend')
->assertToast(Toast::TYPE_SUCCESS, 'User unsuspended successfully.')
->assertSeeIn('@user-info #status span.text-success', 'Active')
->assertVisible('@user-info #button-suspend')
->assertMissing('@user-info #button-unsuspend');
});
}
/**
* Test resetting 2FA for the user
*/
public function testReset2FA(): void
{
$this->browse(function (Browser $browser) {
$this->deleteTestUser('userstest1@kolabnow.com');
$user = $this->getTestUser('userstest1@kolabnow.com');
$sku2fa = Sku::withEnvTenantContext()->where(['title' => '2fa'])->first();
$user->assignSku($sku2fa);
SecondFactor::seed('userstest1@kolabnow.com');
$browser->visit(new UserPage($user->id))
->click('@nav #tab-subscriptions')
->with('@user-subscriptions', function (Browser $browser) use ($sku2fa) {
$browser->waitFor('#reset2fa')
->assertVisible('#sku' . $sku2fa->id);
})
->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (1)')
->click('#reset2fa')
->with(new Dialog('#reset-2fa-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', '2-Factor Authentication Reset')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Reset')
->click('@button-action');
})
->assertToast(Toast::TYPE_SUCCESS, '2-Factor authentication reset successfully.')
->assertMissing('#sku' . $sku2fa->id)
->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (0)');
});
}
/**
* Test adding the beta SKU for the user
*/
public function testAddBetaSku(): void
{
$this->browse(function (Browser $browser) {
$this->deleteTestUser('userstest1@kolabnow.com');
$user = $this->getTestUser('userstest1@kolabnow.com');
$sku = Sku::withEnvTenantContext()->where('title', 'beta')->first();
$browser->visit(new UserPage($user->id))
->click('@nav #tab-subscriptions')
->waitFor('@user-subscriptions #addbetasku')
->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (0)')
->assertSeeIn('#addbetasku', 'Enable beta program')
->click('#addbetasku')
->assertToast(Toast::TYPE_SUCCESS, 'The subscription added successfully.')
->waitFor('#sku' . $sku->id)
->assertSeeIn("#sku{$sku->id} td:first-child", 'Private Beta (invitation only)')
->assertSeeIn("#sku{$sku->id} td:last-child", '0,00 CHF/month')
->assertMissing('#addbetasku')
->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (1)');
});
}
}
diff --git a/src/tests/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php
index 95f1fc9a..1cee3c02 100644
--- a/src/tests/Browser/UsersTest.php
+++ b/src/tests/Browser/UsersTest.php
@@ -1,785 +1,769 @@
<?php
namespace Tests\Browser;
use App\Discount;
use App\Entitlement;
use App\Sku;
use App\User;
use App\UserAlias;
use Tests\Browser;
use Tests\Browser\Components\Dialog;
use Tests\Browser\Components\ListInput;
use Tests\Browser\Components\QuotaInput;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
use Tests\Browser\Pages\UserInfo;
use Tests\Browser\Pages\UserList;
use Tests\Browser\Pages\Wallet as WalletPage;
use Tests\TestCaseDusk;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class UsersTest extends TestCaseDusk
{
private $profile = [
'first_name' => 'John',
'last_name' => 'Doe',
'organization' => 'Kolab Developers',
];
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('julia.roberts@kolab.org');
$john = User::where('email', 'john@kolab.org')->first();
$john->setSettings($this->profile);
UserAlias::where('user_id', $john->id)
->where('alias', 'john.test@kolab.org')->delete();
$activesync_sku = Sku::withEnvTenantContext()->where('title', 'activesync')->first();
$storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first();
Entitlement::where('entitleable_id', $john->id)->where('sku_id', $activesync_sku->id)->delete();
Entitlement::where('cost', '>=', 5000)->delete();
Entitlement::where('cost', '=', 25)->where('sku_id', $storage_sku->id)->delete();
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
$wallet->currency = 'CHF';
$wallet->save();
$this->clearBetaEntitlements();
- $this->clearMeetEntitlements();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('julia.roberts@kolab.org');
$john = User::where('email', 'john@kolab.org')->first();
$john->setSettings($this->profile);
UserAlias::where('user_id', $john->id)
->where('alias', 'john.test@kolab.org')->delete();
$activesync_sku = Sku::withEnvTenantContext()->where('title', 'activesync')->first();
$storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first();
Entitlement::where('entitleable_id', $john->id)->where('sku_id', $activesync_sku->id)->delete();
Entitlement::where('cost', '>=', 5000)->delete();
Entitlement::where('cost', '=', 25)->where('sku_id', $storage_sku->id)->delete();
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
$wallet->currency = 'CHF';
$wallet->save();
$this->clearBetaEntitlements();
- $this->clearMeetEntitlements();
parent::tearDown();
}
/**
* Test user account editing page (not profile page)
*/
public function testInfo(): void
{
$this->browse(function (Browser $browser) {
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$john->verificationcodes()->delete();
$jack->verificationcodes()->delete();
$john->setSetting('password_policy', 'min:10,upper,digit');
// Test that the page requires authentication
$browser->visit('/user/' . $john->id)
->on(new Home())
->submitLogon('john@kolab.org', 'simple123', false)
->on(new UserInfo())
->assertSeeIn('#user-info .card-title', 'User account')
->with('@general', function (Browser $browser) {
// Assert form content
$browser->assertSeeIn('div.row:nth-child(1) label', 'Status')
->assertSeeIn('div.row:nth-child(1) #status', 'Active')
->assertFocused('div.row:nth-child(2) input')
->assertSeeIn('div.row:nth-child(2) label', 'First Name')
->assertValue('div.row:nth-child(2) input[type=text]', $this->profile['first_name'])
->assertSeeIn('div.row:nth-child(3) label', 'Last Name')
->assertValue('div.row:nth-child(3) input[type=text]', $this->profile['last_name'])
->assertSeeIn('div.row:nth-child(4) label', 'Organization')
->assertValue('div.row:nth-child(4) input[type=text]', $this->profile['organization'])
->assertSeeIn('div.row:nth-child(5) label', 'Email')
->assertValue('div.row:nth-child(5) input[type=text]', 'john@kolab.org')
->assertDisabled('div.row:nth-child(5) input[type=text]')
->assertSeeIn('div.row:nth-child(6) label', 'Email Aliases')
->assertVisible('div.row:nth-child(6) .list-input')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertListInputValue(['john.doe@kolab.org'])
->assertValue('@input', '');
})
->assertSeeIn('div.row:nth-child(7) label', 'Password')
->assertValue('div.row:nth-child(7) input#password', '')
->assertValue('div.row:nth-child(7) input#password_confirmation', '')
->assertAttribute('#password', 'placeholder', 'Password')
->assertAttribute('#password_confirmation', 'placeholder', 'Confirm Password')
->assertMissing('div.row:nth-child(7) .btn-group')
->assertMissing('div.row:nth-child(7) #password-link')
->assertSeeIn('button[type=submit]', 'Submit')
// Clear some fields and submit
->vueClear('#first_name')
->vueClear('#last_name')
->click('button[type=submit]');
})
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.')
->on(new UserList())
->click('@table tr:nth-child(3) a')
->on(new UserInfo())
->assertSeeIn('#user-info .card-title', 'User account')
->with('@general', function (Browser $browser) {
// Test error handling (password)
$browser->type('#password', 'aaaaaA')
->vueClear('#password_confirmation')
->whenAvailable('#password_policy', function (Browser $browser) {
$browser->assertElementsCount('li', 3)
->assertMissing('li:nth-child(1) svg.text-success')
->assertSeeIn('li:nth-child(1) small', "Minimum password length: 10 characters")
->waitFor('li:nth-child(2) svg.text-success')
->assertSeeIn('li:nth-child(2) small', "Password contains an upper-case character")
->assertMissing('li:nth-child(3) svg.text-success')
->assertSeeIn('li:nth-child(3) small', "Password contains a digit");
})
->click('button[type=submit]')
->waitFor('#password_confirmation + .invalid-feedback')
->assertSeeIn(
'#password_confirmation + .invalid-feedback',
'The password confirmation does not match.'
)
->assertFocused('#password')
->assertToast(Toast::TYPE_ERROR, 'Form validation error');
// TODO: Test password change
// Test form error handling (aliases)
$browser->vueClear('#password')
->vueClear('#password_confirmation')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->addListEntry('invalid address');
})
->scrollTo('button[type=submit]')->pause(500)
->click('button[type=submit]')
->assertToast(Toast::TYPE_ERROR, 'Form validation error');
$browser->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertFormError(2, 'The specified alias is invalid.', false);
});
// Test adding aliases
$browser->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->removeListEntry(2)
->addListEntry('john.test@kolab.org');
})
->click('button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
})
->on(new UserList())
->click('@table tr:nth-child(3) a')
->on(new UserInfo());
$alias = $john->aliases()->where('alias', 'john.test@kolab.org')->first();
$this->assertTrue(!empty($alias));
// Test subscriptions
$browser->with('@general', function (Browser $browser) {
$browser->assertSeeIn('div.row:nth-child(8) label', 'Subscriptions')
->assertVisible('@skus.row:nth-child(8)')
->with('@skus', function ($browser) {
- $browser->assertElementsCount('tbody tr', 6)
+ $browser->assertElementsCount('tbody tr', 5)
// Mailbox SKU
->assertSeeIn('tbody tr:nth-child(1) td.name', 'User Mailbox')
->assertSeeIn('tbody tr:nth-child(1) td.price', '5,00 CHF/month')
->assertChecked('tbody tr:nth-child(1) td.selection input')
->assertDisabled('tbody tr:nth-child(1) td.selection input')
->assertTip(
'tbody tr:nth-child(1) td.buttons button',
'Just a mailbox'
)
// Storage SKU
->assertSeeIn('tbody tr:nth-child(2) td.name', 'Storage Quota')
->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month')
->assertChecked('tbody tr:nth-child(2) td.selection input')
->assertDisabled('tbody tr:nth-child(2) td.selection input')
->assertTip(
'tbody tr:nth-child(2) td.buttons button',
'Some wiggle room'
)
->with(new QuotaInput('tbody tr:nth-child(2) .range-input'), function ($browser) {
$browser->assertQuotaValue(5)->setQuotaValue(6);
})
->assertSeeIn('tr:nth-child(2) td.price', '0,25 CHF/month')
// groupware SKU
->assertSeeIn('tbody tr:nth-child(3) td.name', 'Groupware Features')
->assertSeeIn('tbody tr:nth-child(3) td.price', '4,90 CHF/month')
->assertChecked('tbody tr:nth-child(3) td.selection input')
->assertEnabled('tbody tr:nth-child(3) td.selection input')
->assertTip(
'tbody tr:nth-child(3) td.buttons button',
'Groupware functions like Calendar, Tasks, Notes, etc.'
)
// ActiveSync SKU
->assertSeeIn('tbody tr:nth-child(4) td.name', 'Activesync')
->assertSeeIn('tbody tr:nth-child(4) td.price', '0,00 CHF/month')
->assertNotChecked('tbody tr:nth-child(4) td.selection input')
->assertEnabled('tbody tr:nth-child(4) td.selection input')
->assertTip(
'tbody tr:nth-child(4) td.buttons button',
'Mobile synchronization'
)
// 2FA SKU
->assertSeeIn('tbody tr:nth-child(5) td.name', '2-Factor Authentication')
->assertSeeIn('tbody tr:nth-child(5) td.price', '0,00 CHF/month')
->assertNotChecked('tbody tr:nth-child(5) td.selection input')
->assertEnabled('tbody tr:nth-child(5) td.selection input')
->assertTip(
'tbody tr:nth-child(5) td.buttons button',
'Two factor authentication for webmail and administration panel'
)
- // Meet SKU
- ->assertSeeIn('tbody tr:nth-child(6) td.name', 'Voice & Video Conferencing (public beta)')
- ->assertSeeIn('tbody tr:nth-child(6) td.price', '0,00 CHF/month')
- ->assertNotChecked('tbody tr:nth-child(6) td.selection input')
- ->assertEnabled('tbody tr:nth-child(6) td.selection input')
- ->assertTip(
- 'tbody tr:nth-child(6) td.buttons button',
- 'Video conferencing tool'
- )
->click('tbody tr:nth-child(4) td.selection input');
})
->assertMissing('@skus table + .hint')
->click('button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
})
->on(new UserList())
->click('@table tr:nth-child(3) a')
->on(new UserInfo());
$expected = ['activesync', 'groupware', 'mailbox',
'storage', 'storage', 'storage', 'storage', 'storage', 'storage'];
$this->assertEntitlements($john->fresh(), $expected);
// Test subscriptions interaction
$browser->with('@general', function (Browser $browser) {
$browser->with('@skus', function ($browser) {
// Uncheck 'groupware', expect activesync unchecked
$browser->click('#sku-input-groupware')
->assertNotChecked('#sku-input-groupware')
->assertNotChecked('#sku-input-activesync')
->assertEnabled('#sku-input-activesync')
->assertNotReadonly('#sku-input-activesync')
// Check 'activesync', expect an alert
->click('#sku-input-activesync')
->assertDialogOpened('Activesync requires Groupware Features.')
->acceptDialog()
->assertNotChecked('#sku-input-activesync')
- // Check 'meet', expect an alert
- ->click('#sku-input-meet')
- ->assertDialogOpened('Voice & Video Conferencing (public beta) requires Groupware Features.')
- ->acceptDialog()
- ->assertNotChecked('#sku-input-meet')
// Check '2FA', expect 'activesync' unchecked and readonly
->click('#sku-input-2fa')
->assertChecked('#sku-input-2fa')
->assertNotChecked('#sku-input-activesync')
->assertReadonly('#sku-input-activesync')
// Uncheck '2FA'
->click('#sku-input-2fa')
->assertNotChecked('#sku-input-2fa')
->assertNotReadonly('#sku-input-activesync');
});
});
// Test password reset link delete and create
$code = new \App\VerificationCode(['mode' => 'password-reset']);
$jack->verificationcodes()->save($code);
$browser->visit('/user/' . $jack->id)
->on(new UserInfo())
->with('@general', function (Browser $browser) use ($jack, $john, $code) {
// Test displaying an existing password reset link
$link = Browser::$baseUrl . '/password-reset/' . $code->short_code . '-' . $code->code;
$browser->assertSeeIn('div.row:nth-child(7) label', 'Password')
->assertMissing('#password')
->assertMissing('#password_confirmation')
->assertMissing('#pass-mode-link:checked')
->assertMissing('#pass-mode-input:checked')
->assertSeeIn('#password-link code', $link)
->assertVisible('#password-link button.text-danger')
->assertVisible('#password-link button:not(.text-danger)')
->assertAttribute('#password-link button:not(.text-danger)', 'title', 'Copy')
->assertAttribute('#password-link button.text-danger', 'title', 'Delete')
->assertMissing('#password-link div.form-text');
// Test deleting an existing password reset link
$browser->click('#password-link button.text-danger')
->assertToast(Toast::TYPE_SUCCESS, 'Password reset code deleted successfully.')
->assertMissing('#password-link')
->assertMissing('#pass-mode-link:checked')
->assertMissing('#pass-mode-input:checked')
->assertMissing('#password');
$this->assertSame(0, $jack->verificationcodes()->count());
// Test creating a password reset link
$link = preg_replace('|/[a-z0-9A-Z-]+$|', '', $link) . '/';
$browser->click('#pass-mode-link + label')
->assertMissing('#password')
->assertMissing('#password_confirmation')
->waitFor('#password-link code')
->assertSeeIn('#password-link code', $link)
->assertSeeIn('#password-link div.form-text', "Press Submit to activate the link");
// Test copy to clipboard
/* TODO: Figure out how to give permission to do this operation
$code = $john->verificationcodes()->first();
$link .= $code->short_code . '-' . $code->code;
$browser->assertMissing('#password-link button.text-danger')
->click('#password-link button:not(.text-danger)')
->keys('#organization', ['{left_control}', 'v'])
->assertAttribute('#organization', 'value', $link)
->vueClear('#organization');
*/
// Finally submit the form
$browser->click('button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
$this->assertSame(1, $jack->verificationcodes()->where('active', true)->count());
$this->assertSame(0, $john->verificationcodes()->count());
});
});
}
/**
* Test user settings tab
*
* @depends testInfo
*/
public function testUserSettings(): void
{
$john = $this->getTestUser('john@kolab.org');
$john->setSetting('greylist_enabled', null);
$this->browse(function (Browser $browser) use ($john) {
$browser->visit('/user/' . $john->id)
->on(new UserInfo())
->assertElementsCount('@nav a', 2)
->assertSeeIn('@nav #tab-general', 'General')
->assertSeeIn('@nav #tab-settings', 'Settings')
->click('@nav #tab-settings')
->with('#settings form', function (Browser $browser) {
$browser->assertSeeIn('div.row:nth-child(1) label', 'Greylisting')
->click('div.row:nth-child(1) input[type=checkbox]:checked')
->click('button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User settings updated successfully.');
});
});
$this->assertSame('false', $john->fresh()->getSetting('greylist_enabled'));
}
/**
* Test user adding page
*
* @depends testInfo
*/
public function testNewUser(): void
{
$john = $this->getTestUser('john@kolab.org');
$john->setSetting('password_policy', null);
$this->browse(function (Browser $browser) {
$browser->visit(new UserList())
->assertSeeIn('button.user-new', 'Create user')
->click('button.user-new')
->on(new UserInfo())
->assertSeeIn('#user-info .card-title', 'New user account')
->with('@general', function (Browser $browser) {
// Assert form content
$browser->assertFocused('div.row:nth-child(1) input')
->assertSeeIn('div.row:nth-child(1) label', 'First Name')
->assertValue('div.row:nth-child(1) input[type=text]', '')
->assertSeeIn('div.row:nth-child(2) label', 'Last Name')
->assertValue('div.row:nth-child(2) input[type=text]', '')
->assertSeeIn('div.row:nth-child(3) label', 'Organization')
->assertValue('div.row:nth-child(3) input[type=text]', '')
->assertSeeIn('div.row:nth-child(4) label', 'Email')
->assertValue('div.row:nth-child(4) input[type=text]', '')
->assertEnabled('div.row:nth-child(4) input[type=text]')
->assertSeeIn('div.row:nth-child(5) label', 'Email Aliases')
->assertVisible('div.row:nth-child(5) .list-input')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertListInputValue([])
->assertValue('@input', '');
})
->assertSeeIn('div.row:nth-child(6) label', 'Password')
->assertValue('div.row:nth-child(6) input#password', '')
->assertValue('div.row:nth-child(6) input#password_confirmation', '')
->assertAttribute('#password', 'placeholder', 'Password')
->assertAttribute('#password_confirmation', 'placeholder', 'Confirm Password')
->assertSeeIn('div.row:nth-child(6) .btn-group input:first-child + label', 'Enter password')
->assertSeeIn('div.row:nth-child(6) .btn-group input:not(:first-child) + label', 'Set via link')
->assertChecked('div.row:nth-child(6) .btn-group input:first-child')
->assertMissing('div.row:nth-child(6) #password-link')
->assertSeeIn('div.row:nth-child(7) label', 'Package')
// assert packages list widget, select "Lite Account"
->with('@packages', function ($browser) {
$browser->assertElementsCount('tbody tr', 2)
->assertSeeIn('tbody tr:nth-child(1)', 'Groupware Account')
->assertSeeIn('tbody tr:nth-child(2)', 'Lite Account')
->assertSeeIn('tbody tr:nth-child(1) .price', '9,90 CHF/month')
->assertSeeIn('tbody tr:nth-child(2) .price', '5,00 CHF/month')
->assertChecked('tbody tr:nth-child(1) input')
->click('tbody tr:nth-child(2) input')
->assertNotChecked('tbody tr:nth-child(1) input')
->assertChecked('tbody tr:nth-child(2) input');
})
->assertMissing('@packages table + .hint')
->assertSeeIn('button[type=submit]', 'Submit');
// Test browser-side required fields and error handling
$browser->click('button[type=submit]')
->assertFocused('#email')
->type('#email', 'invalid email')
->type('#password', 'simple123')
->type('#password_confirmation', 'simple')
->click('button[type=submit]')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->assertSeeIn('#email + .invalid-feedback', 'The specified email is invalid.')
->assertSeeIn(
'#password_confirmation + .invalid-feedback',
'The password confirmation does not match.'
);
});
// Test form error handling (aliases)
$browser->with('@general', function (Browser $browser) {
$browser->type('#email', 'julia.roberts@kolab.org')
->type('#password_confirmation', 'simple123')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->addListEntry('invalid address');
})
->click('button[type=submit]')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertFormError(1, 'The specified alias is invalid.', false);
});
});
// Successful account creation
$browser->with('@general', function (Browser $browser) {
$browser->type('#first_name', 'Julia')
->type('#last_name', 'Roberts')
->type('#organization', 'Test Org')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->removeListEntry(1)
->addListEntry('julia.roberts2@kolab.org');
})
->click('button[type=submit]');
})
->assertToast(Toast::TYPE_SUCCESS, 'User created successfully.')
// check redirection to users list
->on(new UserList())
->whenAvailable('@table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 5)
->assertSeeIn('tbody tr:nth-child(4) a', 'julia.roberts@kolab.org');
});
$julia = User::where('email', 'julia.roberts@kolab.org')->first();
$alias = UserAlias::where('user_id', $julia->id)->where('alias', 'julia.roberts2@kolab.org')->first();
$this->assertTrue(!empty($alias));
$this->assertEntitlements($julia, ['mailbox', 'storage', 'storage', 'storage', 'storage', 'storage']);
$this->assertSame('Julia', $julia->getSetting('first_name'));
$this->assertSame('Roberts', $julia->getSetting('last_name'));
$this->assertSame('Test Org', $julia->getSetting('organization'));
// Some additional tests for the list input widget
$browser->click('@table tbody tr:nth-child(4) a')
->on(new UserInfo())
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertListInputValue(['julia.roberts2@kolab.org'])
->addListEntry('invalid address')
->type('.input-group:nth-child(2) input', '@kolab.org')
->keys('.input-group:nth-child(2) input', '{enter}');
})
// TODO: Investigate why this click does not work, for now we
// submit the form with Enter key above
//->click('@general button[type=submit]')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertVisible('.input-group:nth-child(2) input.is-invalid')
->assertVisible('.input-group:nth-child(3) input.is-invalid')
->type('.input-group:nth-child(2) input', 'julia.roberts3@kolab.org')
->type('.input-group:nth-child(3) input', 'julia.roberts4@kolab.org')
->keys('.input-group:nth-child(3) input', '{enter}');
})
// TODO: Investigate why this click does not work, for now we
// submit the form with Enter key above
//->click('@general button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
$julia = User::where('email', 'julia.roberts@kolab.org')->first();
$aliases = $julia->aliases()->orderBy('alias')->get()->pluck('alias')->all();
$this->assertSame(['julia.roberts3@kolab.org', 'julia.roberts4@kolab.org'], $aliases);
});
}
/**
* Test user delete
*
* @depends testNewUser
*/
public function testDeleteUser(): void
{
// First create a new user
$john = $this->getTestUser('john@kolab.org');
$julia = $this->getTestUser('julia.roberts@kolab.org');
$package_kolab = \App\Package::where('title', 'kolab')->first();
$john->assignPackage($package_kolab, $julia);
// Test deleting non-controller user
$this->browse(function (Browser $browser) use ($julia) {
$browser->visit('/user/' . $julia->id)
->on(new UserInfo())
->assertSeeIn('button.button-delete', 'Delete user')
->click('button.button-delete')
->with(new Dialog('#delete-warning'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Delete julia.roberts@kolab.org')
->assertFocused('@button-cancel')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Delete')
->click('@button-cancel');
})
->waitUntilMissing('#delete-warning')
->click('button.button-delete')
->with(new Dialog('#delete-warning'), function (Browser $browser) {
$browser->click('@button-action');
})
->waitUntilMissing('#delete-warning')
->assertToast(Toast::TYPE_SUCCESS, 'User deleted successfully.')
->on(new UserList())
->with('@table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 4)
->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org')
->assertSeeIn('tbody tr:nth-child(2) a', 'joe@kolab.org')
->assertSeeIn('tbody tr:nth-child(3) a', 'john@kolab.org')
->assertSeeIn('tbody tr:nth-child(4) a', 'ned@kolab.org');
});
$julia = User::where('email', 'julia.roberts@kolab.org')->first();
$this->assertTrue(empty($julia));
});
// Test that non-controller user cannot see/delete himself on the users list
$this->browse(function (Browser $browser) {
$browser->visit('/logout')
->on(new Home())
->submitLogon('jack@kolab.org', 'simple123', true)
->visit('/users')
->assertErrorPage(403);
});
// Test that controller user (Ned) can see all the users
$this->browse(function (Browser $browser) {
$browser->visit('/logout')
->on(new Home())
->submitLogon('ned@kolab.org', 'simple123', true)
->visit(new UserList())
->whenAvailable('@table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 4);
});
// TODO: Test the delete action in details
});
// TODO: Test what happens with the logged in user session after he's been deleted by another user
}
/**
* Test discounted sku/package prices in the UI
*/
public function testDiscountedPrices(): void
{
// Add 10% discount
$discount = Discount::where('code', 'TEST')->first();
$john = User::where('email', 'john@kolab.org')->first();
$wallet = $john->wallet();
$wallet->discount()->associate($discount);
$wallet->save();
// SKUs on user edit page
$this->browse(function (Browser $browser) {
$browser->visit('/logout')
->on(new Home())
->submitLogon('john@kolab.org', 'simple123', true)
->visit(new UserList())
->waitFor('@table tr:nth-child(2)')
->click('@table tr:nth-child(2) a') // joe@kolab.org
->on(new UserInfo())
->with('@general', function (Browser $browser) {
$browser->whenAvailable('@skus', function (Browser $browser) {
$quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input');
$browser->waitFor('tbody tr')
- ->assertElementsCount('tbody tr', 6)
+ ->assertElementsCount('tbody tr', 5)
// Mailbox SKU
->assertSeeIn('tbody tr:nth-child(1) td.price', '4,50 CHF/month¹')
// Storage SKU
->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month¹')
->with($quota_input, function (Browser $browser) {
$browser->setQuotaValue(100);
})
->assertSeeIn('tr:nth-child(2) td.price', '21,37 CHF/month¹')
- // groupware SKU
+ // Groupware SKU
->assertSeeIn('tbody tr:nth-child(3) td.price', '4,41 CHF/month¹')
// ActiveSync SKU
->assertSeeIn('tbody tr:nth-child(4) td.price', '0,00 CHF/month¹')
// 2FA SKU
->assertSeeIn('tbody tr:nth-child(5) td.price', '0,00 CHF/month¹');
})
->assertSeeIn('@skus table + .hint', '¹ applied discount: 10% - Test voucher');
});
});
// Packages on new user page
$this->browse(function (Browser $browser) {
$browser->visit(new UserList())
->click('button.user-new')
->on(new UserInfo())
->with('@general', function (Browser $browser) {
$browser->whenAvailable('@packages', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 2)
->assertSeeIn('tbody tr:nth-child(1) .price', '8,91 CHF/month¹') // Groupware
->assertSeeIn('tbody tr:nth-child(2) .price', '4,50 CHF/month¹'); // Lite
})
->assertSeeIn('@packages table + .hint', '¹ applied discount: 10% - Test voucher');
});
});
// Test using entitlement cost instead of the SKU cost
$this->browse(function (Browser $browser) use ($wallet) {
$joe = User::where('email', 'joe@kolab.org')->first();
$beta_sku = Sku::withEnvTenantContext()->where('title', 'beta')->first();
$storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first();
// Add an extra storage and beta entitlement with different prices
Entitlement::create([
'wallet_id' => $wallet->id,
'sku_id' => $beta_sku->id,
'cost' => 5010,
'entitleable_id' => $joe->id,
'entitleable_type' => User::class
]);
Entitlement::create([
'wallet_id' => $wallet->id,
'sku_id' => $storage_sku->id,
'cost' => 5000,
'entitleable_id' => $joe->id,
'entitleable_type' => User::class
]);
$browser->visit('/user/' . $joe->id)
->on(new UserInfo())
->with('@general', function (Browser $browser) {
$browser->whenAvailable('@skus', function (Browser $browser) {
$quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input');
$browser->waitFor('tbody tr')
// Beta SKU
- ->assertSeeIn('tbody tr:nth-child(7) td.price', '45,09 CHF/month¹')
+ ->assertSeeIn('tbody tr:nth-child(6) td.price', '45,09 CHF/month¹')
// Storage SKU
->assertSeeIn('tr:nth-child(2) td.price', '45,00 CHF/month¹')
->with($quota_input, function (Browser $browser) {
$browser->setQuotaValue(7);
})
->assertSeeIn('tr:nth-child(2) td.price', '45,22 CHF/month¹')
->with($quota_input, function (Browser $browser) {
$browser->setQuotaValue(5);
})
->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month¹');
})
->assertSeeIn('@skus table + .hint', '¹ applied discount: 10% - Test voucher');
});
});
}
/**
* Test non-default currency in the UI
*/
public function testCurrency(): void
{
// Add 10% discount
$john = User::where('email', 'john@kolab.org')->first();
$wallet = $john->wallet();
$wallet->balance = -1000;
$wallet->currency = 'EUR';
$wallet->save();
// On Dashboard and the wallet page
$this->browse(function (Browser $browser) {
$browser->visit('/logout')
->on(new Home())
->submitLogon('john@kolab.org', 'simple123', true)
->on(new Dashboard())
->assertSeeIn('@links .link-wallet .badge', '-10,00 €')
->click('@links .link-wallet')
->on(new WalletPage())
->assertSeeIn('#wallet .card-title', 'Account balance -10,00 €');
});
// SKUs on user edit page
$this->browse(function (Browser $browser) {
$browser->visit(new UserList())
->waitFor('@table tr:nth-child(2)')
->click('@table tr:nth-child(2) a') // joe@kolab.org
->on(new UserInfo())
->with('@general', function (Browser $browser) {
$browser->whenAvailable('@skus', function (Browser $browser) {
$quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input');
$browser->waitFor('tbody tr')
- ->assertElementsCount('tbody tr', 6)
+ ->assertElementsCount('tbody tr', 5)
// Mailbox SKU
->assertSeeIn('tbody tr:nth-child(1) td.price', '5,00 €/month')
// Storage SKU
->assertSeeIn('tr:nth-child(2) td.price', '0,00 €/month')
->with($quota_input, function (Browser $browser) {
$browser->setQuotaValue(100);
})
->assertSeeIn('tr:nth-child(2) td.price', '23,75 €/month');
});
});
});
// Packages on new user page
$this->browse(function (Browser $browser) {
$browser->visit(new UserList())
->click('button.user-new')
->on(new UserInfo())
->with('@general', function (Browser $browser) {
$browser->whenAvailable('@packages', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 2)
->assertSeeIn('tbody tr:nth-child(1) .price', '9,90 €/month') // Groupware
->assertSeeIn('tbody tr:nth-child(2) .price', '5,00 €/month'); // Lite
});
});
});
}
}
diff --git a/src/tests/Feature/Console/Sku/ListUsersTest.php b/src/tests/Feature/Console/Sku/ListUsersTest.php
index e91726d1..be634572 100644
--- a/src/tests/Feature/Console/Sku/ListUsersTest.php
+++ b/src/tests/Feature/Console/Sku/ListUsersTest.php
@@ -1,88 +1,72 @@
<?php
namespace Tests\Feature\Console\Sku;
use Illuminate\Contracts\Console\Kernel;
use Tests\TestCase;
class ListUsersTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('sku-list-users@kolabnow.com');
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('sku-list-users@kolabnow.com');
parent::tearDown();
}
/**
* Test command runs
*/
public function testHandle(): void
{
// Warning: We're not using artisan() here, as this will not
// allow us to test "empty output" cases
- $code = \Artisan::call('sku:list-users meet');
+ $code = \Artisan::call('sku:list-users domain-registration');
$output = trim(\Artisan::output());
$this->assertSame(0, $code);
$this->assertSame('', $output);
$code = \Artisan::call('sku:list-users unknown');
$output = trim(\Artisan::output());
$this->assertSame(1, $code);
$this->assertSame("Unable to find the SKU.", $output);
$code = \Artisan::call('sku:list-users 2fa');
$output = trim(\Artisan::output());
$this->assertSame(0, $code);
$this->assertSame("ned@kolab.org", $output);
$code = \Artisan::call('sku:list-users mailbox');
$output = trim(\Artisan::output());
$this->assertSame(0, $code);
$expected = [
"fred@" . \config('app.domain'),
"jack@kolab.org",
"joe@kolab.org",
"john@kolab.org",
"ned@kolab.org",
"reseller@" . \config('app.domain')
];
$this->assertSame(implode("\n", $expected), $output);
$code = \Artisan::call('sku:list-users domain-hosting');
$output = trim(\Artisan::output());
$this->assertSame(0, $code);
$this->assertSame("john@kolab.org", $output);
-
- $sku = \App\Sku::where('title', 'meet')->first();
- $user = $this->getTestUser('sku-list-users@kolabnow.com');
- $user->assignSku($sku);
-
- $code = \Artisan::call('sku:list-users meet');
- $output = trim(\Artisan::output());
- $this->assertSame(0, $code);
- $this->assertSame($user->email, $output);
-
- $user->assignSku($sku);
-
- $code = \Artisan::call('sku:list-users meet');
- $output = trim(\Artisan::output());
- $this->assertSame(0, $code);
- $this->assertSame($user->email, $output);
}
}
diff --git a/src/tests/Feature/Console/User/AssignSkuTest.php b/src/tests/Feature/Console/User/AssignSkuTest.php
index 0ff6a5ee..2815f138 100644
--- a/src/tests/Feature/Console/User/AssignSkuTest.php
+++ b/src/tests/Feature/Console/User/AssignSkuTest.php
@@ -1,63 +1,63 @@
<?php
namespace Tests\Feature\Console\User;
use Tests\TestCase;
class AssignSkuTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('add-entitlement@kolabnow.com');
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('add-entitlement@kolabnow.com');
parent::tearDown();
}
/**
* Test command runs
*/
public function testHandle(): void
{
- $sku = \App\Sku::where('title', 'meet')->first();
+ $sku = \App\Sku::withEnvTenantContext()->where('title', 'groupware')->first();
$user = $this->getTestUser('add-entitlement@kolabnow.com');
$this->artisan('user:assign-sku unknown@unknown.org ' . $sku->id)
->assertExitCode(1)
->expectsOutput("User not found.");
$this->artisan('user:assign-sku ' . $user->email . ' unknownsku')
->assertExitCode(1)
->expectsOutput("Unable to find the SKU unknownsku.");
$this->artisan('user:assign-sku ' . $user->email . ' ' . $sku->id)
->assertExitCode(0);
$this->assertCount(1, $user->entitlements()->where('sku_id', $sku->id)->get());
// Try again (also test sku by title)
$this->artisan('user:assign-sku ' . $user->email . ' ' . $sku->title)
->assertExitCode(1)
->expectsOutput("The entitlement already exists. Maybe try with --qty=X?");
$this->assertCount(1, $user->entitlements()->where('sku_id', $sku->id)->get());
// Try again with --qty option, to force the assignment
$this->artisan('user:assign-sku ' . $user->email . ' ' . $sku->title . ' --qty=1')
->assertExitCode(0);
$this->assertCount(2, $user->entitlements()->where('sku_id', $sku->id)->get());
}
}
diff --git a/src/tests/Feature/Controller/Admin/SkusTest.php b/src/tests/Feature/Controller/Admin/SkusTest.php
index ab836444..2c096373 100644
--- a/src/tests/Feature/Controller/Admin/SkusTest.php
+++ b/src/tests/Feature/Controller/Admin/SkusTest.php
@@ -1,124 +1,122 @@
<?php
namespace Tests\Feature\Controller\Admin;
use App\Sku;
use Tests\TestCase;
class SkusTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
self::useAdminUrl();
Sku::where('title', 'test')->delete();
$this->clearBetaEntitlements();
- $this->clearMeetEntitlements();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
Sku::where('title', 'test')->delete();
$this->clearBetaEntitlements();
- $this->clearMeetEntitlements();
parent::tearDown();
}
/**
* Test fetching SKUs list for a domain (GET /domains/<id>/skus)
*/
public function testDomainSkus(): void
{
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$user = $this->getTestUser('john@kolab.org');
$domain = $this->getTestDOmain('kolab.org');
// Unauth access not allowed
$response = $this->get("api/v4/domains/{$domain->id}/skus");
$response->assertStatus(401);
// Non-admin access not allowed
$response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}/skus");
$response->assertStatus(403);
$response = $this->actingAs($admin)->get("api/v4/domains/{$domain->id}/skus");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(1, $json);
// Note: Details are tested where we test API\V4\SkusController
}
/**
* Test fetching SKUs list
*/
public function testIndex(): void
{
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$user = $this->getTestUser('john@kolab.org');
$sku = Sku::withEnvTenantContext()->where('title', 'mailbox')->first();
// Unauth access not allowed
$response = $this->get("api/v4/skus");
$response->assertStatus(401);
// User access not allowed on admin API
$response = $this->actingAs($user)->get("api/v4/skus");
$response->assertStatus(403);
$response = $this->actingAs($admin)->get("api/v4/skus");
$response->assertStatus(200);
$json = $response->json();
- $this->assertCount(10, $json);
+ $this->assertCount(11, $json);
$this->assertSame(100, $json[0]['prio']);
$this->assertSame($sku->id, $json[0]['id']);
$this->assertSame($sku->title, $json[0]['title']);
$this->assertSame($sku->name, $json[0]['name']);
$this->assertSame($sku->description, $json[0]['description']);
$this->assertSame($sku->cost, $json[0]['cost']);
$this->assertSame($sku->units_free, $json[0]['units_free']);
$this->assertSame($sku->period, $json[0]['period']);
$this->assertSame($sku->active, $json[0]['active']);
$this->assertSame('user', $json[0]['type']);
$this->assertSame('Mailbox', $json[0]['handler']);
}
/**
* Test fetching SKUs list for a user (GET /users/<id>/skus)
*/
public function testUserSkus(): void
{
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$user = $this->getTestUser('john@kolab.org');
// Unauth access not allowed
$response = $this->get("api/v4/users/{$user->id}/skus");
$response->assertStatus(401);
// Non-admin access not allowed
$response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus");
$response->assertStatus(403);
$response = $this->actingAs($admin)->get("api/v4/users/{$user->id}/skus");
$response->assertStatus(200);
$json = $response->json();
- $this->assertCount(6, $json);
+ $this->assertCount(5, $json);
// Note: Details are tested where we test API\V4\SkusController
}
}
diff --git a/src/tests/Feature/Controller/MeetTest.php b/src/tests/Feature/Controller/MeetTest.php
index 45b6bd10..e8d76a38 100644
--- a/src/tests/Feature/Controller/MeetTest.php
+++ b/src/tests/Feature/Controller/MeetTest.php
@@ -1,450 +1,339 @@
<?php
namespace Tests\Feature\Controller;
use App\Http\Controllers\API\V4\MeetController;
use App\Meet\Room;
use Tests\TestCase;
class MeetTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
- $this->clearMeetEntitlements();
$room = Room::where('name', 'john')->first();
$room->setSettings(['password' => null, 'locked' => null, 'nomedia' => null]);
}
+ /**
+ * {@inheritDoc}
+ */
public function tearDown(): void
{
- $this->clearMeetEntitlements();
$room = Room::where('name', 'john')->first();
$room->setSettings(['password' => null, 'locked' => null, 'nomedia' => null]);
parent::tearDown();
}
- /**
- * Test listing user rooms
- *
- * @group meet
- */
- public function testIndex(): void
- {
- $john = $this->getTestUser('john@kolab.org');
- $jack = $this->getTestUser('jack@kolab.org');
- Room::where('user_id', $jack->id)->delete();
-
- // Unauth access not allowed
- $response = $this->get("api/v4/meet/rooms");
- $response->assertStatus(401);
-
- // John has one room
- $response = $this->actingAs($john)->get("api/v4/meet/rooms");
- $response->assertStatus(200);
-
- $json = $response->json();
-
- $this->assertSame(1, $json['count']);
- $this->assertCount(1, $json['list']);
- $this->assertSame('john', $json['list'][0]['name']);
-
- // Jack has no room, but it will be auto-created
- $response = $this->actingAs($jack)->get("api/v4/meet/rooms");
- $response->assertStatus(200);
-
- $json = $response->json();
-
- $this->assertSame(1, $json['count']);
- $this->assertCount(1, $json['list']);
- $this->assertMatchesRegularExpression('/^[0-9a-z-]{11}$/', $json['list'][0]['name']);
- }
-
/**
* Test joining the room
*
* @group meet
*/
public function testJoinRoom(): void
{
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$room = Room::where('name', 'john')->first();
$room->session_id = null;
$room->save();
- $this->assignMeetEntitlement($john);
-
// Unauth access, no session yet
$response = $this->post("api/v4/meet/rooms/{$room->name}");
$response->assertStatus(422);
$json = $response->json();
$this->assertSame(323, $json['code']);
// Non-existing room name
$response = $this->actingAs($john)->post("api/v4/meet/rooms/non-existing");
$response->assertStatus(404);
// TODO: Test accessing an existing room of deleted owner
// Non-owner, no session yet
$response = $this->actingAs($jack)->post("api/v4/meet/rooms/{$room->name}");
$response->assertStatus(422);
$json = $response->json();
$this->assertSame(323, $json['code']);
// Room owner, no session yet
$response = $this->actingAs($john)->post("api/v4/meet/rooms/{$room->name}");
$response->assertStatus(422);
$json = $response->json();
$this->assertSame(324, $json['code']);
$response = $this->actingAs($john)->post("api/v4/meet/rooms/{$room->name}", ['init' => 1]);
$response->assertStatus(200);
$json = $response->json();
$session_id = $room->fresh()->session_id;
$this->assertSame(Room::ROLE_SUBSCRIBER | Room::ROLE_MODERATOR | Room::ROLE_OWNER, $json['role']);
$this->assertMatchesRegularExpression('|^wss?://|', $json['token']);
$this->assertMatchesRegularExpression('|&roomId=' . $session_id . '|', $json['token']);
$john_token = $json['token'];
// Non-owner, now the session exists, no 'init' argument
$response = $this->actingAs($jack)->post("api/v4/meet/rooms/{$room->name}");
$response->assertStatus(422);
$json = $response->json();
$this->assertSame(322, $json['code']);
$this->assertTrue(empty($json['token']));
// Non-owner, now the session exists, with 'init', but no 'canPublish' argument
$response = $this->actingAs($jack)->post("api/v4/meet/rooms/{$room->name}", ['init' => 1]);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(Room::ROLE_SUBSCRIBER, $json['role']);
$this->assertMatchesRegularExpression('|^wss?://|', $json['token']);
$this->assertMatchesRegularExpression('|&roomId=' . $session_id . '|', $json['token']);
$this->assertTrue($json['token'] != $john_token);
// Non-owner, now the session exists, with 'init', and with 'role=PUBLISHER'
$post = ['canPublish' => true, 'init' => 1];
$response = $this->actingAs($jack)->post("api/v4/meet/rooms/{$room->name}", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(Room::ROLE_PUBLISHER, $json['role']);
$this->assertMatchesRegularExpression('|^wss?://|', $json['token']);
$this->assertMatchesRegularExpression('|&roomId=' . $session_id . '|', $json['token']);
$this->assertTrue($json['token'] != $john_token);
$this->assertEmpty($json['config']['password']);
$this->assertEmpty($json['config']['requires_password']);
// Non-owner, password protected room, password not provided
$room->setSettings(['password' => 'pass']);
$response = $this->actingAs($jack)->post("api/v4/meet/rooms/{$room->name}");
$response->assertStatus(422);
$json = $response->json();
$this->assertCount(4, $json);
$this->assertSame(325, $json['code']);
$this->assertSame('error', $json['status']);
$this->assertSame('Failed to join the session. Invalid password.', $json['message']);
$this->assertEmpty($json['config']['password']);
$this->assertTrue($json['config']['requires_password']);
// Non-owner, password protected room, invalid provided
$response = $this->actingAs($jack)->post("api/v4/meet/rooms/{$room->name}", ['password' => 'aa']);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame(325, $json['code']);
// Non-owner, password protected room, valid password provided
// TODO: Test without init=1
$post = ['password' => 'pass', 'init' => 'init'];
$response = $this->actingAs($jack)->post("api/v4/meet/rooms/{$room->name}", $post);
$response->assertStatus(200);
// Make sure the room owner can access the password protected room w/o password
// TODO: Test without init=1
$post = ['init' => 'init'];
$response = $this->actingAs($john)->post("api/v4/meet/rooms/{$room->name}", $post);
$response->assertStatus(200);
// Test 'nomedia' room option
$room->setSettings(['nomedia' => 'true', 'password' => null]);
$post = ['init' => 'init', 'canPublish' => true];
$response = $this->actingAs($john)->post("api/v4/meet/rooms/{$room->name}", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(Room::ROLE_PUBLISHER & $json['role'], Room::ROLE_PUBLISHER);
$post = ['init' => 'init', 'canPublish' => true];
$response = $this->actingAs($jack)->post("api/v4/meet/rooms/{$room->name}", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(Room::ROLE_PUBLISHER & $json['role'], 0);
+
+ // Test opening the session as a sharee of a room
+ $room = Room::where('name', 'shared')->first();
+ $response = $this->actingAs($jack)->post("api/v4/meet/rooms/{$room->name}", ['init' => 1]);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(Room::ROLE_SUBSCRIBER | Room::ROLE_MODERATOR | Room::ROLE_OWNER, $json['role']);
+ $this->assertMatchesRegularExpression('|^wss?://|', $json['token']);
}
/**
* Test locked room and join requests
*
* @group meet
*/
public function testJoinRequests(): void
{
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$room = Room::where('name', 'john')->first();
$room->session_id = null;
$room->save();
$room->setSettings(['password' => null, 'locked' => 'true']);
- $this->assignMeetEntitlement($john);
-
// Create the session (also makes sure the owner can access a locked room)
$response = $this->actingAs($john)->post("api/v4/meet/rooms/{$room->name}", ['init' => 1]);
$response->assertStatus(200);
// Non-owner, locked room, invalid/missing input
$response = $this->actingAs($jack)->post("api/v4/meet/rooms/{$room->name}");
$response->assertStatus(422);
$json = $response->json();
$this->assertCount(4, $json);
$this->assertSame(326, $json['code']);
$this->assertSame('error', $json['status']);
$this->assertSame('Failed to join the session. Room locked.', $json['message']);
$this->assertTrue($json['config']['locked']);
// Non-owner, locked room, invalid requestId
$post = ['nickname' => 'name', 'requestId' => '-----', 'init' => 1];
$response = $this->actingAs($jack)->post("api/v4/meet/rooms/{$room->name}", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame(326, $json['code']);
// Non-owner, locked room, invalid requestId
$post = ['nickname' => 'name', 'init' => 1];
$response = $this->actingAs($jack)->post("api/v4/meet/rooms/{$room->name}", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame(326, $json['code']);
// Non-owner, locked room, valid input
$reqId = '12345678';
$post = ['nickname' => 'name', 'requestId' => $reqId, 'picture' => 'data:image/png;base64,01234'];
$response = $this->actingAs($jack)->post("api/v4/meet/rooms/{$room->name}", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertCount(4, $json);
$this->assertSame(327, $json['code']);
$this->assertSame('error', $json['status']);
$this->assertSame('Failed to join the session. Room locked.', $json['message']);
$this->assertTrue($json['config']['locked']);
$room->refresh();
$request = $room->requestGet($reqId);
$this->assertSame($post['nickname'], $request['nickname']);
$this->assertSame($post['requestId'], $request['requestId']);
$room->requestAccept($reqId);
// Non-owner, locked room, join request accepted
$post['init'] = 1;
$post['canPublish'] = true;
$response = $this->actingAs($jack)->post("api/v4/meet/rooms/{$room->name}", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(Room::ROLE_PUBLISHER, $json['role']);
$this->assertMatchesRegularExpression('|^wss?://|', $json['token']);
// TODO: Test a scenario where both password and lock are enabled
// TODO: Test accepting/denying as a non-owner moderator
// TODO: Test somehow websocket communication
$this->markTestIncomplete();
}
/**
* Test joining the room
*
* @group meet
* @depends testJoinRoom
*/
public function testJoinRoomGuest(): void
{
- $this->assignMeetEntitlement('john@kolab.org');
-
// There's no easy way to logout the user in the same test after
// using actingAs(). That's why this is moved to a separate test
$room = Room::where('name', 'john')->first();
// Guest, request with screenShare token
$post = ['canPublish' => true, 'screenShare' => 1, 'init' => 1];
$response = $this->post("api/v4/meet/rooms/{$room->name}", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(Room::ROLE_PUBLISHER, $json['role']);
$this->assertMatchesRegularExpression('|^wss?://|', $json['token']);
}
- /**
- * Test configuring the room (session)
- *
- * @group meet
- */
- public function testSetRoomConfig(): void
- {
- $john = $this->getTestUser('john@kolab.org');
- $jack = $this->getTestUser('jack@kolab.org');
- $room = Room::where('name', 'john')->first();
-
- // Unauth access not allowed
- $response = $this->post("api/v4/meet/rooms/{$room->name}/config", []);
- $response->assertStatus(401);
-
- // Non-existing room name
- $response = $this->actingAs($john)->post("api/v4/meet/rooms/non-existing/config", []);
- $response->assertStatus(404);
-
- // TODO: Test a room with a deleted owner
-
- // Non-owner
- $response = $this->actingAs($jack)->post("api/v4/meet/rooms/{$room->name}/config", []);
- $response->assertStatus(403);
-
- // Room owner
- $response = $this->actingAs($john)->post("api/v4/meet/rooms/{$room->name}/config", []);
- $response->assertStatus(200);
-
- $json = $response->json();
-
- $this->assertCount(2, $json);
- $this->assertSame('success', $json['status']);
- $this->assertSame("Room configuration updated successfully.", $json['message']);
-
- // Set password and room lock
- $post = ['password' => 'aaa', 'locked' => 1];
- $response = $this->actingAs($john)->post("api/v4/meet/rooms/{$room->name}/config", $post);
- $response->assertStatus(200);
-
- $json = $response->json();
-
- $this->assertCount(2, $json);
- $this->assertSame('success', $json['status']);
- $this->assertSame("Room configuration updated successfully.", $json['message']);
- $room->refresh();
- $this->assertSame('aaa', $room->getSetting('password'));
- $this->assertSame('true', $room->getSetting('locked'));
-
- // Unset password and room lock
- $post = ['password' => '', 'locked' => 0];
- $response = $this->actingAs($john)->post("api/v4/meet/rooms/{$room->name}/config", $post);
- $response->assertStatus(200);
-
- $json = $response->json();
-
- $this->assertCount(2, $json);
- $this->assertSame('success', $json['status']);
- $this->assertSame("Room configuration updated successfully.", $json['message']);
- $room->refresh();
- $this->assertSame(null, $room->getSetting('password'));
- $this->assertSame(null, $room->getSetting('locked'));
-
- // Test invalid option error
- $post = ['password' => 'eee', 'unknown' => 0];
- $response = $this->actingAs($john)->post("api/v4/meet/rooms/{$room->name}/config", $post);
- $response->assertStatus(422);
-
- $json = $response->json();
-
- $this->assertCount(2, $json);
- $this->assertSame('error', $json['status']);
- $this->assertSame("Invalid room configuration option.", $json['errors']['unknown']);
-
- $room->refresh();
- $this->assertSame(null, $room->getSetting('password'));
- }
-
/**
* Test the webhook
*
* @group meet
*/
public function testWebhook(): void
{
- $this->assignMeetEntitlement('john@kolab.org');
-
$john = $this->getTestUser('john@kolab.org');
$room = Room::where('name', 'john')->first();
$headers = ['X-Auth-Token' => \config('meet.webhook_token')];
// First, create the session
$post = ['init' => 1];
$response = $this->actingAs($john)->post("api/v4/meet/rooms/{$room->name}", $post);
$response->assertStatus(200);
$sessionId = $room->fresh()->session_id;
// Test accepting a join request
$room->requestSave('1234', ['nickname' => 'test']);
$post = ['roomId' => $sessionId, 'requestId' => '1234', 'event' => 'joinRequestAccepted'];
$response = $this->post("api/webhooks/meet", $post);
$response->assertStatus(403); // 403 because no auth token
$response = $this->withHeaders($headers)->post("api/webhooks/meet", $post);
$response->assertStatus(200);
$request = $room->requestGet('1234');
$this->assertSame(Room::REQUEST_ACCEPTED, $request['status']);
// Test denying a join request
$room->requestSave('1234', ['nickname' => 'test']);
$post = ['roomId' => $sessionId, 'requestId' => '1234', 'event' => 'joinRequestDenied'];
$response = $this->withHeaders($headers)->post("api/webhooks/meet", $post);
$response->assertStatus(200);
$request = $room->requestGet('1234');
$this->assertSame(Room::REQUEST_DENIED, $request['status']);
// Test closing the session
$post = ['roomId' => $sessionId, 'event' => 'roomClosed'];
$response = $this->withHeaders($headers)->post("api/webhooks/meet", $post);
$response->assertStatus(200);
$this->assertNull($room->fresh()->session_id);
}
}
diff --git a/src/tests/Feature/Controller/Reseller/SkusTest.php b/src/tests/Feature/Controller/Reseller/SkusTest.php
index 801a9abe..896193ee 100644
--- a/src/tests/Feature/Controller/Reseller/SkusTest.php
+++ b/src/tests/Feature/Controller/Reseller/SkusTest.php
@@ -1,173 +1,171 @@
<?php
namespace Tests\Feature\Controller\Reseller;
use App\Sku;
use Tests\TestCase;
class SkusTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
self::useResellerUrl();
Sku::where('title', 'test')->delete();
$this->clearBetaEntitlements();
- $this->clearMeetEntitlements();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
Sku::where('title', 'test')->delete();
$this->clearBetaEntitlements();
- $this->clearMeetEntitlements();
parent::tearDown();
}
/**
* Test fetching SKUs list for a domain (GET /domains/<id>/skus)
*/
public function testDomainSkus(): void
{
$reseller1 = $this->getTestUser('reseller@' . \config('app.domain'));
$reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$user = $this->getTestUser('john@kolab.org');
$domain = $this->getTestDomain('kolab.org');
// Unauth access not allowed
$response = $this->get("api/v4/domains/{$domain->id}/skus");
$response->assertStatus(401);
// User access not allowed
$response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}/skus");
$response->assertStatus(403);
// Admin access not allowed
$response = $this->actingAs($admin)->get("api/v4/domains/{$domain->id}/skus");
$response->assertStatus(403);
// Reseller from another tenant
$response = $this->actingAs($reseller2)->get("api/v4/domains/{$domain->id}/skus");
$response->assertStatus(404);
// Reseller access
$response = $this->actingAs($reseller1)->get("api/v4/domains/{$domain->id}/skus");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(1, $json);
// Note: Details are tested where we test API\V4\SkusController
}
/**
* Test fetching SKUs list
*/
public function testIndex(): void
{
$reseller1 = $this->getTestUser('reseller@' . \config('app.domain'));
$reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$user = $this->getTestUser('john@kolab.org');
$sku = Sku::withEnvTenantContext()->where('title', 'mailbox')->first();
// Unauth access not allowed
$response = $this->get("api/v4/skus");
$response->assertStatus(401);
// User access not allowed
$response = $this->actingAs($user)->get("api/v4/skus");
$response->assertStatus(403);
// Admin access not allowed
$response = $this->actingAs($admin)->get("api/v4/skus");
$response->assertStatus(403);
$response = $this->actingAs($reseller1)->get("api/v4/skus");
$response->assertStatus(200);
$json = $response->json();
- $this->assertCount(10, $json);
+ $this->assertCount(11, $json);
$this->assertSame(100, $json[0]['prio']);
$this->assertSame($sku->id, $json[0]['id']);
$this->assertSame($sku->title, $json[0]['title']);
$this->assertSame($sku->name, $json[0]['name']);
$this->assertSame($sku->description, $json[0]['description']);
$this->assertSame($sku->cost, $json[0]['cost']);
$this->assertSame($sku->units_free, $json[0]['units_free']);
$this->assertSame($sku->period, $json[0]['period']);
$this->assertSame($sku->active, $json[0]['active']);
$this->assertSame('user', $json[0]['type']);
$this->assertSame('Mailbox', $json[0]['handler']);
// Test with another tenant
$sku = Sku::where('title', 'mailbox')->where('tenant_id', $reseller2->tenant_id)->first();
$response = $this->actingAs($reseller2)->get("api/v4/skus");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(6, $json);
$this->assertSame(100, $json[0]['prio']);
$this->assertSame($sku->id, $json[0]['id']);
$this->assertSame($sku->title, $json[0]['title']);
$this->assertSame($sku->name, $json[0]['name']);
$this->assertSame($sku->description, $json[0]['description']);
$this->assertSame($sku->cost, $json[0]['cost']);
$this->assertSame($sku->units_free, $json[0]['units_free']);
$this->assertSame($sku->period, $json[0]['period']);
$this->assertSame($sku->active, $json[0]['active']);
$this->assertSame('user', $json[0]['type']);
$this->assertSame('Mailbox', $json[0]['handler']);
}
/**
* Test fetching SKUs list for a user (GET /users/<id>/skus)
*/
public function testUserSkus(): void
{
$reseller1 = $this->getTestUser('reseller@' . \config('app.domain'));
$reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$user = $this->getTestUser('john@kolab.org');
// Unauth access not allowed
$response = $this->get("api/v4/users/{$user->id}/skus");
$response->assertStatus(401);
// User access not allowed
$response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus");
$response->assertStatus(403);
// Admin access not allowed
$response = $this->actingAs($admin)->get("api/v4/users/{$user->id}/skus");
$response->assertStatus(403);
// Reseller from another tenant
$response = $this->actingAs($reseller2)->get("api/v4/users/{$user->id}/skus");
$response->assertStatus(404);
// Reseller access
$response = $this->actingAs($reseller1)->get("api/v4/users/{$user->id}/skus");
$response->assertStatus(200);
$json = $response->json();
- $this->assertCount(6, $json);
+ $this->assertCount(5, $json);
// Note: Details are tested where we test API\V4\SkusController
}
}
diff --git a/src/tests/Feature/Controller/RoomsTest.php b/src/tests/Feature/Controller/RoomsTest.php
new file mode 100644
index 00000000..b19cae63
--- /dev/null
+++ b/src/tests/Feature/Controller/RoomsTest.php
@@ -0,0 +1,506 @@
+<?php
+
+namespace Tests\Feature\Controller;
+
+use App\Meet\Room;
+use Tests\TestCase;
+
+class RoomsTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ Room::withTrashed()->whereNotIn('name', ['shared', 'john'])->forceDelete();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ Room::withTrashed()->whereNotIn('name', ['shared', 'john'])->forceDelete();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test deleting a room (DELETE /api/v4/rooms/<room-id>)
+ */
+ public function testDestroy(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $room = $this->getTestRoom('test', $john->wallets()->first(), [], [], 'group-room');
+ $room->setConfig(['acl' => 'jack@kolab.org, full']);
+
+ // Unauth access not allowed
+ $response = $this->delete("api/v4/rooms/{$room->id}");
+ $response->assertStatus(401);
+
+ // Non-existing room name
+ $response = $this->actingAs($john)->delete("api/v4/rooms/non-existing");
+ $response->assertStatus(404);
+
+ // Non-owner (sharee also can't delete the room)
+ $response = $this->actingAs($jack)->delete("api/v4/rooms/{$room->id}");
+ $response->assertStatus(403);
+
+ // Room owner
+ $response = $this->actingAs($john)->delete("api/v4/rooms/{$room->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("Room deleted successfully.", $json['message']);
+ }
+
+ /**
+ * Test listing user rooms (GET /api/v4/rooms)
+ */
+ public function testIndex(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $ned = $this->getTestUser('ned@kolab.org');
+
+ // Unauth access not allowed
+ $response = $this->get("api/v4/rooms");
+ $response->assertStatus(401);
+
+ // John has two rooms
+ $response = $this->actingAs($john)->get("api/v4/rooms");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(2, $json['count']);
+ $this->assertCount(2, $json['list']);
+ $this->assertSame('john', $json['list'][0]['name']);
+ $this->assertSame("Standard room", $json['list'][0]['description']);
+ $this->assertSame('shared', $json['list'][1]['name']);
+ $this->assertSame("Shared room", $json['list'][1]['description']);
+
+ // Ned has no rooms, but is the John's wallet controller
+ $response = $this->actingAs($ned)->get("api/v4/rooms");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(2, $json['count']);
+ $this->assertCount(2, $json['list']);
+ $this->assertSame('john', $json['list'][0]['name']);
+ $this->assertSame('shared', $json['list'][1]['name']);
+
+ // Jack has no rooms (one will be aot-created), but John shares one with him
+ $response = $this->actingAs($jack)->get("api/v4/rooms");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(2, $json['count']);
+ $this->assertCount(2, $json['list']);
+ $jack_room = $jack->rooms()->first();
+ $this->assertTrue(in_array($jack_room->name, [$json['list'][0]['name'], $json['list'][1]['name']]));
+ $this->assertTrue(in_array('shared', [$json['list'][0]['name'], $json['list'][1]['name']]));
+ }
+
+ /**
+ * Test configuring the room (POST api/v4/rooms/<room-id>/config)
+ */
+ public function testSetConfig(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $ned = $this->getTestUser('ned@kolab.org');
+ $room = $this->getTestRoom('test', $john->wallets()->first(), [], [], 'group-room');
+
+ // Unauth access not allowed
+ $response = $this->post("api/v4/rooms/{$room->name}/config", []);
+ $response->assertStatus(401);
+
+ // Non-existing room name
+ $response = $this->actingAs($john)->post("api/v4/rooms/non-existing/config", []);
+ $response->assertStatus(404);
+
+ // Non-owner
+ $response = $this->actingAs($jack)->post("api/v4/rooms/{$room->name}/config", []);
+ $response->assertStatus(403);
+
+ // Room owner
+ $response = $this->actingAs($john)->post("api/v4/rooms/{$room->name}/config", []);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("Room configuration updated successfully.", $json['message']);
+
+ // Set password and room lock
+ $post = ['password' => 'aaa', 'locked' => 1];
+ $response = $this->actingAs($john)->post("api/v4/rooms/{$room->name}/config", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("Room configuration updated successfully.", $json['message']);
+ $room->refresh();
+ $this->assertSame('aaa', $room->getSetting('password'));
+ $this->assertSame('true', $room->getSetting('locked'));
+
+ // Unset password and room lock
+ $post = ['password' => '', 'locked' => 0];
+ $response = $this->actingAs($john)->post("api/v4/rooms/{$room->name}/config", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("Room configuration updated successfully.", $json['message']);
+ $this->assertSame(null, $room->getSetting('password'));
+ $this->assertSame(null, $room->getSetting('locked'));
+
+ // Test invalid option error
+ $post = ['password' => 'eee', 'unknown' => 0];
+ $response = $this->actingAs($john)->post("api/v4/rooms/{$room->name}/config", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('error', $json['status']);
+ $this->assertSame("The requested configuration parameter is not supported.", $json['errors']['unknown']);
+
+ $room->refresh();
+ $this->assertSame('eee', $room->getSetting('password'));
+
+ // Test ACL
+ $post = ['acl' => ['jack@kolab.org, full', 'test, full', 'ned@kolab.org, read-only']];
+ $response = $this->actingAs($john)->post("api/v4/rooms/{$room->id}/config", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(2, $json['errors']['acl']);
+ $this->assertSame("The specified email address is invalid.", $json['errors']['acl'][1]);
+ $this->assertSame("The specified permission is invalid.", $json['errors']['acl'][2]);
+ $this->assertSame([], $room->getConfig()['acl']);
+
+ $post = ['acl' => ['jack@kolab.org, full']];
+ $response = $this->actingAs($john)->post("api/v4/rooms/{$room->id}/config", $post);
+ $response->assertStatus(200);
+
+ $this->assertSame(['jack@kolab.org, full'], $room->getConfig()['acl']);
+
+ // Acting as Jack
+ $post = ['password' => '123', 'acl' => ['joe@kolab.org, full']];
+ $response = $this->actingAs($jack)->post("api/v4/rooms/{$room->id}/config", $post);
+ $response->assertStatus(200);
+
+ $this->assertSame('123', $room->getConfig()['password']);
+ $this->assertSame(['jack@kolab.org, full'], $room->getConfig()['acl']);
+
+ // Acting as Ned
+ $post = ['password' => '123456'];
+ $response = $this->actingAs($ned)->post("api/v4/rooms/{$room->id}/config", $post);
+ $response->assertStatus(200);
+
+ $this->assertSame('123456', $room->getConfig()['password']);
+ }
+
+ /**
+ * Test getting a room info (GET /api/v4/rooms/<room-id>)
+ */
+ public function testShow(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $ned = $this->getTestUser('ned@kolab.org');
+ $wallet = $john->wallets()->first();
+ $room = $this->getTestRoom(
+ 'test',
+ $wallet,
+ ['description' => 'desc'],
+ ['password' => 'pass', 'locked' => true, 'acl' => []],
+ 'group-room'
+ );
+
+ // Unauth access not allowed
+ $response = $this->get("api/v4/rooms/{$room->id}");
+ $response->assertStatus(401);
+
+ // Non-existing room name
+ $response = $this->actingAs($john)->get("api/v4/rooms/non-existing");
+ $response->assertStatus(404);
+
+ // Non-owner
+ $response = $this->actingAs($jack)->get("api/v4/rooms/{$room->id}");
+ $response->assertStatus(403);
+
+ // Room owner
+ $response = $this->actingAs($john)->get("api/v4/rooms/{$room->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame($room->id, $json['id']);
+ $this->assertSame('test', $json['name']);
+ $this->assertSame('desc', $json['description']);
+ $this->assertSame(false, $json['isDeleted']);
+ $this->assertTrue($json['isOwner']);
+ $this->assertTrue($json['canUpdate']);
+ $this->assertTrue($json['canDelete']);
+ $this->assertTrue($json['canShare']);
+ $this->assertCount(1, $json['skus']);
+ $this->assertSame([], $json['config']['acl']);
+ $this->assertSame('pass', $json['config']['password']);
+ $this->assertSame(true, $json['config']['locked']);
+ $this->assertSame($wallet->id, $json['wallet']['id']);
+ $this->assertSame($wallet->currency, $json['wallet']['currency']);
+ $this->assertSame($wallet->balance, $json['wallet']['balance']);
+
+ // Another wallet controller
+ $response = $this->actingAs($ned)->get("api/v4/rooms/{$room->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame($room->id, $json['id']);
+ $this->assertTrue($json['isOwner']);
+ $this->assertTrue($json['canUpdate']);
+ $this->assertTrue($json['canDelete']);
+ $this->assertTrue($json['canShare']);
+
+ // Privileged user
+ $room->setConfig(['acl' => ['jack@kolab.org, full']]);
+ $response = $this->actingAs($jack)->get("api/v4/rooms/{$room->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame($room->id, $json['id']);
+ $this->assertFalse($json['isOwner']);
+ $this->assertTrue($json['canUpdate']);
+ $this->assertFalse($json['canDelete']);
+ $this->assertFalse($json['canShare']);
+ $this->assertSame('pass', $json['config']['password']);
+ $this->assertTrue(empty($json['config']['acl']));
+ }
+
+ /**
+ * Test getting a room entitlements (GET /api/v4/rooms/<room-id>/skus)
+ */
+ public function testSkus(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $ned = $this->getTestUser('ned@kolab.org');
+ $room = Room::where('name', 'john')->first();
+
+ // Unauth access not allowed
+ $response = $this->get("api/v4/rooms/{$room->id}/skus");
+ $response->assertStatus(401);
+
+ // Non-existing room name
+ $response = $this->actingAs($john)->get("api/v4/rooms/non-existing/skus");
+ $response->assertStatus(404);
+
+ // Non-owner (the room is shared with Jack, but he should not see entitlements)
+ $response = $this->actingAs($jack)->get("api/v4/rooms/{$room->id}/skus");
+ $response->assertStatus(403);
+
+ // Room owner
+ $response = $this->actingAs($john)->get("api/v4/rooms/{$room->id}/skus");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('room', $json[0]['title']);
+ $this->assertSame(true, $json[0]['enabled']);
+ $this->assertSame('group-room', $json[1]['title']);
+ $this->assertSame(false, $json[1]['enabled']);
+
+ // Room's wallet controller, not owner
+ $response = $this->actingAs($ned)->get("api/v4/rooms/{$room->id}/skus");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('room', $json[0]['title']);
+ $this->assertSame(true, $json[0]['enabled']);
+ $this->assertSame('group-room', $json[1]['title']);
+ $this->assertSame(false, $json[1]['enabled']);
+
+ // Test non-controller user, expect no group-room SKU on the list
+ $room = $this->getTestRoom('test', $jack->wallets()->first());
+
+ $response = $this->actingAs($jack)->get("api/v4/rooms/{$room->id}/skus");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(1, $json);
+ $this->assertSame('room', $json[0]['title']);
+ $this->assertSame(true, $json[0]['enabled']);
+ }
+
+ /**
+ * Test creating a room (POST /api/v4/rooms)
+ */
+ public function testStore(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $ned = $this->getTestUser('ned@kolab.org');
+
+ // Unauth access not allowed
+ $response = $this->post("api/v4/rooms", []);
+ $response->assertStatus(401);
+
+ // Only wallet controllers can create rooms (for now)
+ $response = $this->actingAs($jack)->post("api/v4/rooms", []);
+ $response->assertStatus(403);
+
+ // Description too long
+ $post = ['description' => str_repeat('.', 192)];
+ $response = $this->actingAs($john)->post("api/v4/rooms", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('error', $json['status']);
+ $this->assertSame("The description may not be greater than 191 characters.", $json['errors']['description'][0]);
+
+ // Successful room creation
+ $post = ['description' => 'test123'];
+ $response = $this->actingAs($john)->post("api/v4/rooms", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("Room created successfully.", $json['message']);
+
+ $room = Room::where('description', $post['description'])->first();
+
+ $this->assertSame($room->wallet()->id, $john->wallet()->id);
+ $this->assertSame('room', $room->entitlements()->first()->sku->title);
+
+ // Successful room creation (acting as a room controller), non-default SKU
+ $sku = \App\Sku::withObjectTenantContext($ned)->where('title', 'group-room')->first();
+ $post = ['description' => 'test456', 'skus' => [$sku->id => 1]];
+ $response = $this->actingAs($ned)->post("api/v4/rooms", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("Room created successfully.", $json['message']);
+
+ $room = Room::where('description', $post['description'])->first();
+
+ $this->assertSame($room->wallet()->id, $john->wallet()->id);
+ $this->assertSame($sku->id, $room->entitlements()->first()->sku_id);
+ }
+
+ /**
+ * Test updating a room (PUT /api/v4/rooms</room-id>)
+ */
+ public function testUpdate(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $ned = $this->getTestUser('ned@kolab.org');
+ $wallet = $john->wallets()->first();
+ $room = $this->getTestRoom('test', $wallet, [], [], 'group-room');
+
+ // Unauth access not allowed
+ $response = $this->put("api/v4/rooms/{$room->id}", []);
+ $response->assertStatus(401);
+
+ // Only wallet controllers can create rooms (for now)
+ $response = $this->actingAs($jack)->put("api/v4/rooms/{$room->id}", []);
+ $response->assertStatus(403);
+
+ // Description too long
+ $post = ['description' => str_repeat('.', 192)];
+ $response = $this->actingAs($john)->put("api/v4/rooms/{$room->id}", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('error', $json['status']);
+ $this->assertSame("The description may not be greater than 191 characters.", $json['errors']['description'][0]);
+
+ // Successful room update (room owner)
+ $post = ['description' => '123'];
+ $response = $this->actingAs($john)->put("api/v4/rooms/{$room->id}", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("Room updated successfully.", $json['message']);
+
+ $room->refresh();
+ $this->assertSame($post['description'], $room->description);
+
+ // Successful room update (acting as a room controller)
+ $post = ['description' => '456'];
+ $response = $this->actingAs($ned)->put("api/v4/rooms/{$room->id}", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("Room updated successfully.", $json['message']);
+
+ $room->refresh();
+ $this->assertSame($post['description'], $room->description);
+
+ // Successful room update (acting as a sharee)
+ $room->setConfig(['acl' => 'jack@kolab.org, full']);
+ $post = ['description' => '789'];
+ $response = $this->actingAs($jack)->put("api/v4/rooms/{$room->id}", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("Room updated successfully.", $json['message']);
+
+ $room->refresh();
+ $this->assertSame($post['description'], $room->description);
+
+ // Test changing the room SKU (from 'group-room' to 'room')
+ $sku = \App\Sku::withObjectTenantContext($ned)->where('title', 'room')->first();
+ $post = ['skus' => [$sku->id => 1]];
+ $response = $this->actingAs($ned)->put("api/v4/rooms/{$room->id}", $post);
+ $response->assertStatus(200);
+
+ $entitlements = $room->entitlements()->get();
+ $this->assertCount(1, $entitlements);
+ $this->assertSame($sku->id, $entitlements[0]->sku_id);
+ $this->assertNull($room->getSetting('acl'));
+ }
+}
diff --git a/src/tests/Feature/Controller/SkusTest.php b/src/tests/Feature/Controller/SkusTest.php
index 7d907223..73d2d9e0 100644
--- a/src/tests/Feature/Controller/SkusTest.php
+++ b/src/tests/Feature/Controller/SkusTest.php
@@ -1,107 +1,103 @@
<?php
namespace Tests\Feature\Controller;
-use App\Http\Controllers\API\V4\SkusController;
use App\Sku;
use App\Tenant;
use Tests\TestCase;
class SkusTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('jane@kolabnow.com');
$this->clearBetaEntitlements();
- $this->clearMeetEntitlements();
Sku::where('title', 'test')->delete();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('jane@kolabnow.com');
$this->clearBetaEntitlements();
- $this->clearMeetEntitlements();
Sku::where('title', 'test')->delete();
parent::tearDown();
}
/**
* Test fetching SKUs list
*/
public function testIndex(): void
{
// Unauth access not allowed
$response = $this->get("api/v4/skus");
$response->assertStatus(401);
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$sku = Sku::withEnvTenantContext()->where('title', 'mailbox')->first();
// Create an sku for another tenant, to make sure it is not included in the result
$nsku = Sku::create([
'title' => 'test',
'name' => 'Test',
'description' => '',
'active' => true,
'cost' => 100,
'handler_class' => 'App\Handlers\Mailbox',
]);
$tenant = Tenant::whereNotIn('id', [\config('app.tenant_id')])->first();
$nsku->tenant_id = $tenant->id;
$nsku->save();
$response = $this->actingAs($john)->get("api/v4/skus");
$response->assertStatus(200);
$json = $response->json();
- $this->assertCount(10, $json);
+ $this->assertCount(11, $json);
$this->assertSame(100, $json[0]['prio']);
$this->assertSame($sku->id, $json[0]['id']);
$this->assertSame($sku->title, $json[0]['title']);
$this->assertSame($sku->name, $json[0]['name']);
$this->assertSame($sku->description, $json[0]['description']);
$this->assertSame($sku->cost, $json[0]['cost']);
$this->assertSame($sku->units_free, $json[0]['units_free']);
$this->assertSame($sku->period, $json[0]['period']);
$this->assertSame($sku->active, $json[0]['active']);
$this->assertSame('user', $json[0]['type']);
$this->assertSame('Mailbox', $json[0]['handler']);
// Test the type filter, and nextCost property (user with one domain)
$response = $this->actingAs($john)->get("api/v4/skus?type=domain");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(1, $json);
$this->assertSame('domain-hosting', $json[0]['title']);
$this->assertSame(100, $json[0]['nextCost']); // second domain costs 100
// Test the type filter, and nextCost property (user with no domain)
$jane = $this->getTestUser('jane@kolabnow.com');
$kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first();
$jane->assignPackage($kolab);
$response = $this->actingAs($jane)->get("api/v4/skus?type=domain");
- $response->assertStatus(200);
$json = $response->json();
$this->assertCount(1, $json);
$this->assertSame('domain-hosting', $json[0]['title']);
$this->assertSame(0, $json[0]['nextCost']); // first domain costs 0
}
}
diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php
index 1d303124..d1fcccb0 100644
--- a/src/tests/Feature/Controller/UsersTest.php
+++ b/src/tests/Feature/Controller/UsersTest.php
@@ -1,1627 +1,1612 @@
<?php
namespace Tests\Feature\Controller;
use App\Discount;
use App\Domain;
use App\Http\Controllers\API\V4\UsersController;
use App\Package;
use App\Sku;
use App\Tenant;
use App\User;
use App\Wallet;
use Carbon\Carbon;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
use Tests\TestCase;
class UsersTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->clearBetaEntitlements();
$this->deleteTestUser('jane@kolabnow.com');
$this->deleteTestUser('UsersControllerTest1@userscontroller.com');
$this->deleteTestUser('UsersControllerTest2@userscontroller.com');
$this->deleteTestUser('UsersControllerTest3@userscontroller.com');
$this->deleteTestUser('UserEntitlement2A@UserEntitlement.com');
$this->deleteTestUser('john2.doe2@kolab.org');
$this->deleteTestUser('deleted@kolab.org');
$this->deleteTestUser('deleted@kolabnow.com');
$this->deleteTestDomain('userscontroller.com');
$this->deleteTestGroup('group-test@kolabnow.com');
$this->deleteTestGroup('group-test@kolab.org');
$this->deleteTestSharedFolder('folder-test@kolabnow.com');
$this->deleteTestResource('resource-test@kolabnow.com');
Sku::where('title', 'test')->delete();
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
$wallet->discount()->dissociate();
$wallet->settings()->whereIn('key', ['mollie_id', 'stripe_id'])->delete();
$wallet->save();
$user->status |= User::STATUS_IMAP_READY;
$user->save();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->clearBetaEntitlements();
$this->deleteTestUser('jane@kolabnow.com');
$this->deleteTestUser('UsersControllerTest1@userscontroller.com');
$this->deleteTestUser('UsersControllerTest2@userscontroller.com');
$this->deleteTestUser('UsersControllerTest3@userscontroller.com');
$this->deleteTestUser('UserEntitlement2A@UserEntitlement.com');
$this->deleteTestUser('john2.doe2@kolab.org');
$this->deleteTestUser('deleted@kolab.org');
$this->deleteTestUser('deleted@kolabnow.com');
$this->deleteTestDomain('userscontroller.com');
$this->deleteTestGroup('group-test@kolabnow.com');
$this->deleteTestGroup('group-test@kolab.org');
$this->deleteTestSharedFolder('folder-test@kolabnow.com');
$this->deleteTestResource('resource-test@kolabnow.com');
Sku::where('title', 'test')->delete();
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
$wallet->discount()->dissociate();
$wallet->settings()->whereIn('key', ['mollie_id', 'stripe_id'])->delete();
$wallet->save();
$user->settings()->whereIn('key', ['greylist_enabled'])->delete();
$user->status |= User::STATUS_IMAP_READY;
$user->save();
parent::tearDown();
}
/**
* Test user deleting (DELETE /api/v4/users/<id>)
*/
public function testDestroy(): void
{
// First create some users/accounts to delete
$package_kolab = \App\Package::where('title', 'kolab')->first();
$package_domain = \App\Package::where('title', 'domain-hosting')->first();
$john = $this->getTestUser('john@kolab.org');
$user1 = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$user2 = $this->getTestUser('UsersControllerTest2@userscontroller.com');
$user3 = $this->getTestUser('UsersControllerTest3@userscontroller.com');
$domain = $this->getTestDomain('userscontroller.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_PUBLIC,
]);
$user1->assignPackage($package_kolab);
$domain->assignPackage($package_domain, $user1);
$user1->assignPackage($package_kolab, $user2);
$user1->assignPackage($package_kolab, $user3);
// Test unauth access
$response = $this->delete("api/v4/users/{$user2->id}");
$response->assertStatus(401);
// Test access to other user/account
$response = $this->actingAs($john)->delete("api/v4/users/{$user2->id}");
$response->assertStatus(403);
$response = $this->actingAs($john)->delete("api/v4/users/{$user1->id}");
$response->assertStatus(403);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertSame("Access denied", $json['message']);
$this->assertCount(2, $json);
// Test that non-controller cannot remove himself
$response = $this->actingAs($user3)->delete("api/v4/users/{$user3->id}");
$response->assertStatus(403);
// Test removing a non-controller user
$response = $this->actingAs($user1)->delete("api/v4/users/{$user3->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertEquals('success', $json['status']);
$this->assertEquals('User deleted successfully.', $json['message']);
// Test removing self (an account with users)
$response = $this->actingAs($user1)->delete("api/v4/users/{$user1->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertEquals('success', $json['status']);
$this->assertEquals('User deleted successfully.', $json['message']);
}
/**
* Test user deleting (DELETE /api/v4/users/<id>)
*/
public function testDestroyByController(): void
{
// Create an account with additional controller - $user2
$package_kolab = \App\Package::where('title', 'kolab')->first();
$package_domain = \App\Package::where('title', 'domain-hosting')->first();
$user1 = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$user2 = $this->getTestUser('UsersControllerTest2@userscontroller.com');
$user3 = $this->getTestUser('UsersControllerTest3@userscontroller.com');
$domain = $this->getTestDomain('userscontroller.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_PUBLIC,
]);
$user1->assignPackage($package_kolab);
$domain->assignPackage($package_domain, $user1);
$user1->assignPackage($package_kolab, $user2);
$user1->assignPackage($package_kolab, $user3);
$user1->wallets()->first()->addController($user2);
// TODO/FIXME:
// For now controller can delete himself, as well as
// the whole account he has control to, including the owner
// Probably he should not be able to do none of those
// However, this is not 0-regression scenario as we
// do not fully support additional controllers.
//$response = $this->actingAs($user2)->delete("api/v4/users/{$user2->id}");
//$response->assertStatus(403);
$response = $this->actingAs($user2)->delete("api/v4/users/{$user3->id}");
$response->assertStatus(200);
$response = $this->actingAs($user2)->delete("api/v4/users/{$user1->id}");
$response->assertStatus(200);
// Note: More detailed assertions in testDestroy() above
$this->assertTrue($user1->fresh()->trashed());
$this->assertTrue($user2->fresh()->trashed());
$this->assertTrue($user3->fresh()->trashed());
}
/**
* Test user listing (GET /api/v4/users)
*/
public function testIndex(): void
{
// Test unauth access
$response = $this->get("api/v4/users");
$response->assertStatus(401);
$jack = $this->getTestUser('jack@kolab.org');
$joe = $this->getTestUser('joe@kolab.org');
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$response = $this->actingAs($jack)->get("/api/v4/users");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(false, $json['hasMore']);
$this->assertSame(0, $json['count']);
$this->assertCount(0, $json['list']);
$response = $this->actingAs($john)->get("/api/v4/users");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(false, $json['hasMore']);
$this->assertSame(4, $json['count']);
$this->assertCount(4, $json['list']);
$this->assertSame($jack->email, $json['list'][0]['email']);
$this->assertSame($joe->email, $json['list'][1]['email']);
$this->assertSame($john->email, $json['list'][2]['email']);
$this->assertSame($ned->email, $json['list'][3]['email']);
// Values below are tested by Unit tests
$this->assertArrayHasKey('isDeleted', $json['list'][0]);
$this->assertArrayHasKey('isDegraded', $json['list'][0]);
$this->assertArrayHasKey('isAccountDegraded', $json['list'][0]);
$this->assertArrayHasKey('isSuspended', $json['list'][0]);
$this->assertArrayHasKey('isActive', $json['list'][0]);
$this->assertArrayHasKey('isLdapReady', $json['list'][0]);
$this->assertArrayHasKey('isImapReady', $json['list'][0]);
$response = $this->actingAs($ned)->get("/api/v4/users");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(false, $json['hasMore']);
$this->assertSame(4, $json['count']);
$this->assertCount(4, $json['list']);
$this->assertSame($jack->email, $json['list'][0]['email']);
$this->assertSame($joe->email, $json['list'][1]['email']);
$this->assertSame($john->email, $json['list'][2]['email']);
$this->assertSame($ned->email, $json['list'][3]['email']);
// Search by user email
$response = $this->actingAs($john)->get("/api/v4/users?search=jack@k");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(false, $json['hasMore']);
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($jack->email, $json['list'][0]['email']);
// Search by alias
$response = $this->actingAs($john)->get("/api/v4/users?search=monster");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(false, $json['hasMore']);
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($joe->email, $json['list'][0]['email']);
// Search by name
$response = $this->actingAs($john)->get("/api/v4/users?search=land");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(false, $json['hasMore']);
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($ned->email, $json['list'][0]['email']);
// TODO: Test paging
}
/**
* Test fetching user data/profile (GET /api/v4/users/<user-id>)
*/
public function testShow(): void
{
$userA = $this->getTestUser('UserEntitlement2A@UserEntitlement.com');
// Test getting profile of self
$response = $this->actingAs($userA)->get("/api/v4/users/{$userA->id}");
$json = $response->json();
$response->assertStatus(200);
$this->assertEquals($userA->id, $json['id']);
$this->assertEquals($userA->email, $json['email']);
$this->assertTrue(is_array($json['statusInfo']));
$this->assertTrue(is_array($json['settings']));
$this->assertTrue($json['config']['greylist_enabled']);
$this->assertSame([], $json['skus']);
$this->assertSame([], $json['aliases']);
// Values below are tested by Unit tests
$this->assertArrayHasKey('isDeleted', $json);
$this->assertArrayHasKey('isDegraded', $json);
$this->assertArrayHasKey('isAccountDegraded', $json);
$this->assertArrayHasKey('isSuspended', $json);
$this->assertArrayHasKey('isActive', $json);
$this->assertArrayHasKey('isLdapReady', $json);
$this->assertArrayHasKey('isImapReady', $json);
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
// Test unauthorized access to a profile of other user
$response = $this->actingAs($jack)->get("/api/v4/users/{$userA->id}");
$response->assertStatus(403);
// Test authorized access to a profile of other user
// Ned: Additional account controller
$response = $this->actingAs($ned)->get("/api/v4/users/{$john->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(['john.doe@kolab.org'], $json['aliases']);
$response = $this->actingAs($ned)->get("/api/v4/users/{$jack->id}");
$response->assertStatus(200);
// John: Account owner
$response = $this->actingAs($john)->get("/api/v4/users/{$jack->id}");
$response->assertStatus(200);
$response = $this->actingAs($john)->get("/api/v4/users/{$ned->id}");
$response->assertStatus(200);
$json = $response->json();
$storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first();
$groupware_sku = Sku::withEnvTenantContext()->where('title', 'groupware')->first();
$mailbox_sku = Sku::withEnvTenantContext()->where('title', 'mailbox')->first();
$secondfactor_sku = Sku::withEnvTenantContext()->where('title', '2fa')->first();
$this->assertCount(5, $json['skus']);
$this->assertSame(5, $json['skus'][$storage_sku->id]['count']);
$this->assertSame([0,0,0,0,0], $json['skus'][$storage_sku->id]['costs']);
$this->assertSame(1, $json['skus'][$groupware_sku->id]['count']);
$this->assertSame([490], $json['skus'][$groupware_sku->id]['costs']);
$this->assertSame(1, $json['skus'][$mailbox_sku->id]['count']);
$this->assertSame([500], $json['skus'][$mailbox_sku->id]['costs']);
$this->assertSame(1, $json['skus'][$secondfactor_sku->id]['count']);
$this->assertSame([0], $json['skus'][$secondfactor_sku->id]['costs']);
$this->assertSame([], $json['aliases']);
}
/**
* Test fetching SKUs list for a user (GET /users/<id>/skus)
*/
public function testSkus(): void
{
$user = $this->getTestUser('john@kolab.org');
// Unauth access not allowed
$response = $this->get("api/v4/users/{$user->id}/skus");
$response->assertStatus(401);
// Create an sku for another tenant, to make sure it is not included in the result
$nsku = Sku::create([
'title' => 'test',
'name' => 'Test',
'description' => '',
'active' => true,
'cost' => 100,
'handler_class' => 'Mailbox',
]);
$tenant = Tenant::whereNotIn('id', [\config('app.tenant_id')])->first();
$nsku->tenant_id = $tenant->id;
$nsku->save();
$response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus");
$response->assertStatus(200);
$json = $response->json();
- $this->assertCount(6, $json);
+ $this->assertCount(5, $json);
$this->assertSkuElement('mailbox', $json[0], [
'prio' => 100,
'type' => 'user',
'handler' => 'Mailbox',
'enabled' => true,
'readonly' => true,
]);
$this->assertSkuElement('storage', $json[1], [
'prio' => 90,
'type' => 'user',
'handler' => 'Storage',
'enabled' => true,
'readonly' => true,
'range' => [
'min' => 5,
'max' => 100,
'unit' => 'GB',
]
]);
$this->assertSkuElement('groupware', $json[2], [
'prio' => 80,
'type' => 'user',
'handler' => 'Groupware',
'enabled' => false,
'readonly' => false,
]);
$this->assertSkuElement('activesync', $json[3], [
'prio' => 70,
'type' => 'user',
'handler' => 'Activesync',
'enabled' => false,
'readonly' => false,
'required' => ['Groupware'],
]);
$this->assertSkuElement('2fa', $json[4], [
'prio' => 60,
'type' => 'user',
'handler' => 'Auth2F',
'enabled' => false,
'readonly' => false,
'forbidden' => ['Activesync'],
]);
- $this->assertSkuElement('meet', $json[5], [
- 'prio' => 50,
- 'type' => 'user',
- 'handler' => 'Meet',
- 'enabled' => false,
- 'readonly' => false,
- 'required' => ['Groupware'],
- ]);
-
// Test inclusion of beta SKUs
$sku = Sku::withEnvTenantContext()->where('title', 'beta')->first();
$user->assignSku($sku);
$response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus");
$response->assertStatus(200);
$json = $response->json();
- $this->assertCount(7, $json);
+ $this->assertCount(6, $json);
- $this->assertSkuElement('beta', $json[6], [
+ $this->assertSkuElement('beta', $json[5], [
'prio' => 10,
'type' => 'user',
'handler' => 'Beta',
'enabled' => false,
'readonly' => false,
]);
}
/**
* Test fetching user status (GET /api/v4/users/<user-id>/status)
* and forcing setup process update (?refresh=1)
*
* @group imap
* @group dns
*/
public function testStatus(): void
{
Queue::fake();
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
// Test unauthorized access
$response = $this->actingAs($jack)->get("/api/v4/users/{$john->id}/status");
$response->assertStatus(403);
if ($john->isImapReady()) {
$john->status ^= User::STATUS_IMAP_READY;
$john->save();
}
// Get user status
$response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status");
$response->assertStatus(200);
$json = $response->json();
$this->assertFalse($json['isImapReady']);
$this->assertFalse($json['isReady']);
$this->assertCount(7, $json['process']);
$this->assertSame('user-imap-ready', $json['process'][2]['label']);
$this->assertSame(false, $json['process'][2]['state']);
$this->assertTrue(empty($json['status']));
$this->assertTrue(empty($json['message']));
// Make sure the domain is confirmed (other test might unset that status)
$domain = $this->getTestDomain('kolab.org');
$domain->status |= Domain::STATUS_CONFIRMED;
$domain->save();
// Now "reboot" the process and verify the user in imap synchronously
$response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status?refresh=1");
$response->assertStatus(200);
$json = $response->json();
$this->assertTrue($json['isImapReady']);
$this->assertTrue($json['isReady']);
$this->assertCount(7, $json['process']);
$this->assertSame('user-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']);
Queue::size(1);
// Test case for when the verify job is dispatched to the worker
$john->refresh();
$john->status ^= User::STATUS_IMAP_READY;
$john->save();
\config(['imap.admin_password' => null]);
$response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status?refresh=1");
$response->assertStatus(200);
$json = $response->json();
$this->assertFalse($json['isImapReady']);
$this->assertFalse($json['isReady']);
$this->assertSame('success', $json['status']);
$this->assertSame('waiting', $json['processState']);
$this->assertSame('Setup process has been pushed. Please wait.', $json['message']);
Queue::assertPushed(\App\Jobs\User\VerifyJob::class, 1);
}
/**
* Test UsersController::statusInfo()
*/
public function testStatusInfo(): void
{
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$domain = $this->getTestDomain('userscontroller.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_PUBLIC,
]);
$user->created_at = Carbon::now();
$user->status = User::STATUS_NEW;
$user->save();
$result = UsersController::statusInfo($user);
$this->assertFalse($result['isReady']);
$this->assertSame([], $result['skus']);
$this->assertCount(3, $result['process']);
$this->assertSame('user-new', $result['process'][0]['label']);
$this->assertSame(true, $result['process'][0]['state']);
$this->assertSame('user-ldap-ready', $result['process'][1]['label']);
$this->assertSame(false, $result['process'][1]['state']);
$this->assertSame('user-imap-ready', $result['process'][2]['label']);
$this->assertSame(false, $result['process'][2]['state']);
$this->assertSame('running', $result['processState']);
$user->created_at = Carbon::now()->subSeconds(181);
$user->save();
$result = UsersController::statusInfo($user);
$this->assertSame('failed', $result['processState']);
$user->status |= User::STATUS_LDAP_READY | User::STATUS_IMAP_READY;
$user->save();
$result = UsersController::statusInfo($user);
$this->assertTrue($result['isReady']);
$this->assertCount(3, $result['process']);
$this->assertSame('user-new', $result['process'][0]['label']);
$this->assertSame(true, $result['process'][0]['state']);
$this->assertSame('user-ldap-ready', $result['process'][1]['label']);
$this->assertSame(true, $result['process'][1]['state']);
$this->assertSame('user-imap-ready', $result['process'][2]['label']);
$this->assertSame(true, $result['process'][2]['state']);
$this->assertSame('done', $result['processState']);
$domain->status |= Domain::STATUS_VERIFIED;
$domain->type = Domain::TYPE_EXTERNAL;
$domain->save();
$result = UsersController::statusInfo($user);
$this->assertFalse($result['isReady']);
$this->assertSame([], $result['skus']);
$this->assertCount(7, $result['process']);
$this->assertSame('user-new', $result['process'][0]['label']);
$this->assertSame(true, $result['process'][0]['state']);
$this->assertSame('user-ldap-ready', $result['process'][1]['label']);
$this->assertSame(true, $result['process'][1]['state']);
$this->assertSame('user-imap-ready', $result['process'][2]['label']);
$this->assertSame(true, $result['process'][2]['state']);
$this->assertSame('domain-new', $result['process'][3]['label']);
$this->assertSame(true, $result['process'][3]['state']);
$this->assertSame('domain-ldap-ready', $result['process'][4]['label']);
$this->assertSame(false, $result['process'][4]['state']);
$this->assertSame('domain-verified', $result['process'][5]['label']);
$this->assertSame(true, $result['process'][5]['state']);
$this->assertSame('domain-confirmed', $result['process'][6]['label']);
$this->assertSame(false, $result['process'][6]['state']);
// Test 'skus' property
$user->assignSku(Sku::withEnvTenantContext()->where('title', 'beta')->first());
$result = UsersController::statusInfo($user);
$this->assertSame(['beta'], $result['skus']);
- $user->assignSku(Sku::withEnvTenantContext()->where('title', 'meet')->first());
-
- $result = UsersController::statusInfo($user);
-
- $this->assertSame(['beta', 'meet'], $result['skus']);
-
- $user->assignSku(Sku::withEnvTenantContext()->where('title', 'meet')->first());
+ $user->assignSku(Sku::withEnvTenantContext()->where('title', 'groupware')->first());
$result = UsersController::statusInfo($user);
- $this->assertSame(['beta', 'meet'], $result['skus']);
+ $this->assertSame(['beta', 'groupware'], $result['skus']);
}
/**
* Test user config update (POST /api/v4/users/<user>/config)
*/
public function testSetConfig(): void
{
$jack = $this->getTestUser('jack@kolab.org');
$john = $this->getTestUser('john@kolab.org');
$john->setSetting('greylist_enabled', null);
$john->setSetting('password_policy', null);
$john->setSetting('max_password_age', null);
// Test unknown user id
$post = ['greylist_enabled' => 1];
$response = $this->actingAs($john)->post("/api/v4/users/123/config", $post);
$json = $response->json();
$response->assertStatus(404);
// Test access by user not being a wallet controller
$post = ['greylist_enabled' => 1];
$response = $this->actingAs($jack)->post("/api/v4/users/{$john->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 = ['grey' => 1, 'password_policy' => 'min:1,max:255'];
$response = $this->actingAs($john)->post("/api/v4/users/{$john->id}/config", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json);
$this->assertCount(2, $json['errors']);
$this->assertSame("The requested configuration parameter is not supported.", $json['errors']['grey']);
$this->assertSame("Minimum password length cannot be less than 6.", $json['errors']['password_policy']);
$this->assertNull($john->fresh()->getSetting('greylist_enabled'));
// Test some valid data
$post = [
'greylist_enabled' => 1,
'password_policy' => 'min:10,max:255,upper,lower,digit,special',
'max_password_age' => 6,
];
$response = $this->actingAs($john)->post("/api/v4/users/{$john->id}/config", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(2, $json);
$this->assertSame('success', $json['status']);
$this->assertSame('User settings updated successfully.', $json['message']);
$this->assertSame('true', $john->getSetting('greylist_enabled'));
$this->assertSame('min:10,max:255,upper,lower,digit,special', $john->getSetting('password_policy'));
$this->assertSame('6', $john->getSetting('max_password_age'));
// Test some valid data, acting as another account controller
$ned = $this->getTestUser('ned@kolab.org');
$post = ['greylist_enabled' => 0, 'password_policy' => 'min:10,max:255,upper,last:1'];
$response = $this->actingAs($ned)->post("/api/v4/users/{$john->id}/config", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(2, $json);
$this->assertSame('success', $json['status']);
$this->assertSame('User settings updated successfully.', $json['message']);
$this->assertSame('false', $john->fresh()->getSetting('greylist_enabled'));
$this->assertSame('min:10,max:255,upper,last:1', $john->fresh()->getSetting('password_policy'));
}
/**
* Test user creation (POST /api/v4/users)
*/
public function testStore(): void
{
Queue::fake();
$jack = $this->getTestUser('jack@kolab.org');
$john = $this->getTestUser('john@kolab.org');
$john->setSetting('password_policy', 'min:8,max:100,digit');
$deleted_priv = $this->getTestUser('deleted@kolab.org');
$deleted_priv->delete();
// Test empty request
$response = $this->actingAs($john)->post("/api/v4/users", []);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertSame("The email field is required.", $json['errors']['email']);
$this->assertSame("The password field is required.", $json['errors']['password'][0]);
$this->assertCount(2, $json);
// Test access by user not being a wallet controller
$post = ['first_name' => 'Test'];
$response = $this->actingAs($jack)->post("/api/v4/users", $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 = ['password' => '12345678', 'email' => 'invalid'];
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json);
$this->assertSame('The password confirmation does not match.', $json['errors']['password'][0]);
$this->assertSame('The specified email is invalid.', $json['errors']['email']);
// Test existing user email
$post = [
'password' => 'simple123',
'password_confirmation' => 'simple123',
'first_name' => 'John2',
'last_name' => 'Doe2',
'email' => 'jack.daniels@kolab.org',
];
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json);
$this->assertSame('The specified email is not available.', $json['errors']['email']);
$package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first();
$package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$post = [
'password' => 'simple123',
'password_confirmation' => 'simple123',
'first_name' => 'John2',
'last_name' => 'Doe2',
'email' => 'john2.doe2@kolab.org',
'organization' => 'TestOrg',
'aliases' => ['useralias1@kolab.org', 'deleted@kolab.org'],
];
// Missing package
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertSame("Package is required.", $json['errors']['package']);
$this->assertCount(2, $json);
// Invalid package
$post['package'] = $package_domain->id;
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertSame("Invalid package selected.", $json['errors']['package']);
$this->assertCount(2, $json);
// Test password policy checking
$post['package'] = $package_kolab->id;
$post['password'] = 'password';
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertSame("The password confirmation does not match.", $json['errors']['password'][0]);
$this->assertSame("Specified password does not comply with the policy.", $json['errors']['password'][1]);
$this->assertCount(2, $json);
// Test password confirmation
$post['password_confirmation'] = 'password';
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertSame("Specified password does not comply with the policy.", $json['errors']['password'][0]);
$this->assertCount(2, $json);
// Test full and valid data
$post['password'] = 'password123';
$post['password_confirmation'] = 'password123';
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$json = $response->json();
$response->assertStatus(200);
$this->assertSame('success', $json['status']);
$this->assertSame("User created successfully.", $json['message']);
$this->assertCount(2, $json);
$user = User::where('email', 'john2.doe2@kolab.org')->first();
$this->assertInstanceOf(User::class, $user);
$this->assertSame('John2', $user->getSetting('first_name'));
$this->assertSame('Doe2', $user->getSetting('last_name'));
$this->assertSame('TestOrg', $user->getSetting('organization'));
/** @var \App\UserAlias[] $aliases */
$aliases = $user->aliases()->orderBy('alias')->get();
$this->assertCount(2, $aliases);
$this->assertSame('deleted@kolab.org', $aliases[0]->alias);
$this->assertSame('useralias1@kolab.org', $aliases[1]->alias);
// Assert the new user entitlements
$this->assertEntitlements($user, ['groupware', 'mailbox',
'storage', 'storage', 'storage', 'storage', 'storage']);
// Assert the wallet to which the new user should be assigned to
$wallet = $user->wallet();
$this->assertSame($john->wallets->first()->id, $wallet->id);
// Attempt to create a user previously deleted
$user->delete();
$post['package'] = $package_kolab->id;
$post['aliases'] = [];
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$json = $response->json();
$response->assertStatus(200);
$this->assertSame('success', $json['status']);
$this->assertSame("User created successfully.", $json['message']);
$this->assertCount(2, $json);
$user = User::where('email', 'john2.doe2@kolab.org')->first();
$this->assertInstanceOf(User::class, $user);
$this->assertSame('John2', $user->getSetting('first_name'));
$this->assertSame('Doe2', $user->getSetting('last_name'));
$this->assertSame('TestOrg', $user->getSetting('organization'));
$this->assertCount(0, $user->aliases()->get());
$this->assertEntitlements($user, ['groupware', 'mailbox',
'storage', 'storage', 'storage', 'storage', 'storage']);
// Test password reset link "mode"
$code = new \App\VerificationCode(['mode' => 'password-reset', 'active' => false]);
$john->verificationcodes()->save($code);
$post = [
'first_name' => 'John2',
'last_name' => 'Doe2',
'email' => 'deleted@kolab.org',
'organization' => '',
'aliases' => [],
'passwordLinkCode' => $code->short_code . '-' . $code->code,
'package' => $package_kolab->id,
];
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$json = $response->json();
$response->assertStatus(200);
$this->assertSame('success', $json['status']);
$this->assertSame("User created successfully.", $json['message']);
$this->assertCount(2, $json);
$user = $this->getTestUser('deleted@kolab.org');
$code->refresh();
$this->assertSame($user->id, $code->user_id);
$this->assertTrue($code->active);
$this->assertTrue(is_string($user->password) && strlen($user->password) >= 60);
// Test acting as account controller not owner, which is not yet supported
$john->wallets->first()->addController($user);
$response = $this->actingAs($user)->post("/api/v4/users", []);
$response->assertStatus(403);
}
/**
* Test user update (PUT /api/v4/users/<user-id>)
*/
public function testUpdate(): void
{
$userA = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$userA->setSetting('password_policy', 'min:8,digit');
$jack = $this->getTestUser('jack@kolab.org');
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$domain = $this->getTestDomain(
'userscontroller.com',
['status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL]
);
// Test unauthorized update of other user profile
$response = $this->actingAs($jack)->get("/api/v4/users/{$userA->id}", []);
$response->assertStatus(403);
// Test authorized update of account owner by account controller
$response = $this->actingAs($ned)->get("/api/v4/users/{$john->id}", []);
$response->assertStatus(200);
// Test updating of self (empty request)
$response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", []);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame("User data updated successfully.", $json['message']);
$this->assertTrue(!empty($json['statusInfo']));
$this->assertCount(3, $json);
// Test some invalid data
$post = ['password' => '1234567', 'currency' => 'invalid'];
$response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json);
$this->assertSame("The password confirmation does not match.", $json['errors']['password'][0]);
$this->assertSame("Specified password does not comply with the policy.", $json['errors']['password'][1]);
$this->assertSame("The currency must be 3 characters.", $json['errors']['currency'][0]);
// Test full profile update including password
$post = [
'password' => 'simple123',
'password_confirmation' => 'simple123',
'first_name' => 'John2',
'last_name' => 'Doe2',
'organization' => 'TestOrg',
'phone' => '+123 123 123',
'external_email' => 'external@gmail.com',
'billing_address' => 'billing',
'country' => 'CH',
'currency' => 'CHF',
'aliases' => ['useralias1@' . \config('app.domain'), 'useralias2@' . \config('app.domain')]
];
$response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post);
$json = $response->json();
$response->assertStatus(200);
$this->assertSame('success', $json['status']);
$this->assertSame("User data updated successfully.", $json['message']);
$this->assertTrue(!empty($json['statusInfo']));
$this->assertCount(3, $json);
$this->assertTrue($userA->password != $userA->fresh()->password);
unset($post['password'], $post['password_confirmation'], $post['aliases']);
foreach ($post as $key => $value) {
$this->assertSame($value, $userA->getSetting($key));
}
$aliases = $userA->aliases()->orderBy('alias')->get();
$this->assertCount(2, $aliases);
$this->assertSame('useralias1@' . \config('app.domain'), $aliases[0]->alias);
$this->assertSame('useralias2@' . \config('app.domain'), $aliases[1]->alias);
// Test unsetting values
$post = [
'first_name' => '',
'last_name' => '',
'organization' => '',
'phone' => '',
'external_email' => '',
'billing_address' => '',
'country' => '',
'currency' => '',
'aliases' => ['useralias2@' . \config('app.domain')]
];
$response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post);
$json = $response->json();
$response->assertStatus(200);
$this->assertSame('success', $json['status']);
$this->assertSame("User data updated successfully.", $json['message']);
$this->assertTrue(!empty($json['statusInfo']));
$this->assertCount(3, $json);
unset($post['aliases']);
foreach ($post as $key => $value) {
$this->assertNull($userA->getSetting($key));
}
$aliases = $userA->aliases()->get();
$this->assertCount(1, $aliases);
$this->assertSame('useralias2@' . \config('app.domain'), $aliases[0]->alias);
// Test error on some invalid aliases missing password confirmation
$post = [
'password' => 'simple123',
'aliases' => [
'useralias2@' . \config('app.domain'),
'useralias1@kolab.org',
'@kolab.org',
]
];
$response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json['errors']);
$this->assertCount(2, $json['errors']['aliases']);
$this->assertSame("The specified domain is not available.", $json['errors']['aliases'][1]);
$this->assertSame("The specified alias is invalid.", $json['errors']['aliases'][2]);
$this->assertSame("The password confirmation does not match.", $json['errors']['password'][0]);
// Test authorized update of other user
$response = $this->actingAs($ned)->put("/api/v4/users/{$jack->id}", []);
$response->assertStatus(200);
$json = $response->json();
$this->assertTrue(empty($json['statusInfo']));
// TODO: Test error on aliases with invalid/non-existing/other-user's domain
// Create entitlements and additional user for following tests
$owner = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$user = $this->getTestUser('UsersControllerTest2@userscontroller.com');
$package_domain = Package::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$package_kolab = Package::withEnvTenantContext()->where('title', 'kolab')->first();
$package_lite = Package::withEnvTenantContext()->where('title', 'lite')->first();
$sku_mailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first();
$sku_storage = Sku::withEnvTenantContext()->where('title', 'storage')->first();
$sku_groupware = Sku::withEnvTenantContext()->where('title', 'groupware')->first();
$domain = $this->getTestDomain(
'userscontroller.com',
[
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_EXTERNAL,
]
);
$domain->assignPackage($package_domain, $owner);
$owner->assignPackage($package_kolab);
$owner->assignPackage($package_lite, $user);
// Non-controller cannot update his own entitlements
$post = ['skus' => []];
$response = $this->actingAs($user)->put("/api/v4/users/{$user->id}", $post);
$response->assertStatus(422);
// Test updating entitlements
$post = [
'skus' => [
$sku_mailbox->id => 1,
$sku_storage->id => 6,
$sku_groupware->id => 1,
],
];
$response = $this->actingAs($owner)->put("/api/v4/users/{$user->id}", $post);
$response->assertStatus(200);
$json = $response->json();
$storage_cost = $user->entitlements()
->where('sku_id', $sku_storage->id)
->orderBy('cost')
->pluck('cost')->all();
$this->assertEntitlements(
$user,
['groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage']
);
$this->assertSame([0, 0, 0, 0, 0, 25], $storage_cost);
$this->assertTrue(empty($json['statusInfo']));
// Test password reset link "mode"
$code = new \App\VerificationCode(['mode' => 'password-reset', 'active' => false]);
$owner->verificationcodes()->save($code);
$post = ['passwordLinkCode' => $code->short_code . '-' . $code->code];
$response = $this->actingAs($owner)->put("/api/v4/users/{$user->id}", $post);
$json = $response->json();
$response->assertStatus(200);
$code->refresh();
$this->assertSame($user->id, $code->user_id);
$this->assertTrue($code->active);
$this->assertSame($user->password, $user->fresh()->password);
}
/**
* Test UsersController::updateEntitlements()
*/
public function testUpdateEntitlements(): void
{
$jane = $this->getTestUser('jane@kolabnow.com');
$kolab = Package::withEnvTenantContext()->where('title', 'kolab')->first();
$storage = Sku::withEnvTenantContext()->where('title', 'storage')->first();
$activesync = Sku::withEnvTenantContext()->where('title', 'activesync')->first();
$groupware = Sku::withEnvTenantContext()->where('title', 'groupware')->first();
$mailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first();
// standard package, 1 mailbox, 1 groupware, 2 storage
$jane->assignPackage($kolab);
// add 2 storage, 1 activesync
$post = [
'skus' => [
$mailbox->id => 1,
$groupware->id => 1,
$storage->id => 7,
$activesync->id => 1
]
];
$response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post);
$response->assertStatus(200);
$this->assertEntitlements(
$jane,
[
'activesync',
'groupware',
'mailbox',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage'
]
);
// add 2 storage, remove 1 activesync
$post = [
'skus' => [
$mailbox->id => 1,
$groupware->id => 1,
$storage->id => 9,
$activesync->id => 0
]
];
$response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post);
$response->assertStatus(200);
$this->assertEntitlements(
$jane,
[
'groupware',
'mailbox',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage'
]
);
// add mailbox
$post = [
'skus' => [
$mailbox->id => 2,
$groupware->id => 1,
$storage->id => 9,
$activesync->id => 0
]
];
$response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post);
$response->assertStatus(500);
$this->assertEntitlements(
$jane,
[
'groupware',
'mailbox',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage'
]
);
// remove mailbox
$post = [
'skus' => [
$mailbox->id => 0,
$groupware->id => 1,
$storage->id => 9,
$activesync->id => 0
]
];
$response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post);
$response->assertStatus(500);
$this->assertEntitlements(
$jane,
[
'groupware',
'mailbox',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage'
]
);
// less than free storage
$post = [
'skus' => [
$mailbox->id => 1,
$groupware->id => 1,
$storage->id => 1,
$activesync->id => 0
]
];
$response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post);
$response->assertStatus(200);
$this->assertEntitlements(
$jane,
[
'groupware',
'mailbox',
'storage',
'storage',
'storage',
'storage',
'storage'
]
);
}
/**
* Test user data response used in show and info actions
*/
public function testUserResponse(): void
{
$provider = \config('services.payment_provider') ?: 'mollie';
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
$wallet->setSettings(['mollie_id' => null, 'stripe_id' => null]);
$result = $this->invokeMethod(new UsersController(), 'userResponse', [$user]);
$this->assertEquals($user->id, $result['id']);
$this->assertEquals($user->email, $result['email']);
$this->assertEquals($user->status, $result['status']);
$this->assertTrue(is_array($result['statusInfo']));
$this->assertTrue(is_array($result['settings']));
$this->assertSame('US', $result['settings']['country']);
$this->assertSame('USD', $result['settings']['currency']);
$this->assertTrue(is_array($result['accounts']));
$this->assertTrue(is_array($result['wallets']));
$this->assertCount(0, $result['accounts']);
$this->assertCount(1, $result['wallets']);
$this->assertSame($wallet->id, $result['wallet']['id']);
$this->assertArrayNotHasKey('discount', $result['wallet']);
$this->assertTrue($result['statusInfo']['enableDomains']);
$this->assertTrue($result['statusInfo']['enableWallets']);
$this->assertTrue($result['statusInfo']['enableUsers']);
$this->assertTrue($result['statusInfo']['enableSettings']);
// Ned is John's wallet controller
$ned = $this->getTestUser('ned@kolab.org');
$ned_wallet = $ned->wallets()->first();
$result = $this->invokeMethod(new UsersController(), 'userResponse', [$ned]);
$this->assertEquals($ned->id, $result['id']);
$this->assertEquals($ned->email, $result['email']);
$this->assertTrue(is_array($result['accounts']));
$this->assertTrue(is_array($result['wallets']));
$this->assertCount(1, $result['accounts']);
$this->assertCount(1, $result['wallets']);
$this->assertSame($wallet->id, $result['wallet']['id']);
$this->assertSame($wallet->id, $result['accounts'][0]['id']);
$this->assertSame($ned_wallet->id, $result['wallets'][0]['id']);
$this->assertSame($provider, $result['wallet']['provider']);
$this->assertSame($provider, $result['wallets'][0]['provider']);
$this->assertTrue($result['statusInfo']['enableDomains']);
$this->assertTrue($result['statusInfo']['enableWallets']);
$this->assertTrue($result['statusInfo']['enableUsers']);
$this->assertTrue($result['statusInfo']['enableSettings']);
// Test discount in a response
$discount = Discount::where('code', 'TEST')->first();
$wallet->discount()->associate($discount);
$wallet->save();
$mod_provider = $provider == 'mollie' ? 'stripe' : 'mollie';
$wallet->setSetting($mod_provider . '_id', 123);
$user->refresh();
$result = $this->invokeMethod(new UsersController(), 'userResponse', [$user]);
$this->assertEquals($user->id, $result['id']);
$this->assertSame($discount->id, $result['wallet']['discount_id']);
$this->assertSame($discount->discount, $result['wallet']['discount']);
$this->assertSame($discount->description, $result['wallet']['discount_description']);
$this->assertSame($mod_provider, $result['wallet']['provider']);
$this->assertSame($discount->id, $result['wallets'][0]['discount_id']);
$this->assertSame($discount->discount, $result['wallets'][0]['discount']);
$this->assertSame($discount->description, $result['wallets'][0]['discount_description']);
$this->assertSame($mod_provider, $result['wallets'][0]['provider']);
// Jack is not a John's wallet controller
$jack = $this->getTestUser('jack@kolab.org');
$result = $this->invokeMethod(new UsersController(), 'userResponse', [$jack]);
$this->assertFalse($result['statusInfo']['enableDomains']);
$this->assertFalse($result['statusInfo']['enableWallets']);
$this->assertFalse($result['statusInfo']['enableUsers']);
$this->assertFalse($result['statusInfo']['enableSettings']);
}
/**
* User email address validation.
*
* Note: Technically these include unit tests, but let's keep it here for now.
* FIXME: Shall we do a http request for each case?
*/
public function testValidateEmail(): void
{
Queue::fake();
$public_domains = Domain::getPublicDomains();
$domain = reset($public_domains);
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$folder = $this->getTestSharedFolder('folder-event@kolab.org');
$folder->setAliases(['folder-alias1@kolab.org']);
$folder_del = $this->getTestSharedFolder('folder-test@kolabnow.com');
$folder_del->setAliases(['folder-alias2@kolabnow.com']);
$folder_del->delete();
$pub_group = $this->getTestGroup('group-test@kolabnow.com');
$pub_group->delete();
$priv_group = $this->getTestGroup('group-test@kolab.org');
$resource = $this->getTestResource('resource-test@kolabnow.com');
$resource->delete();
$cases = [
// valid (user domain)
["admin@kolab.org", $john, null],
// valid (public domain)
["test.test@$domain", $john, null],
// Invalid format
["$domain", $john, 'The specified email is invalid.'],
[".@$domain", $john, 'The specified email is invalid.'],
["test123456@localhost", $john, 'The specified domain is invalid.'],
["test123456@unknown-domain.org", $john, 'The specified domain is invalid.'],
["$domain", $john, 'The specified email is invalid.'],
[".@$domain", $john, 'The specified email is invalid.'],
// forbidden local part on public domains
["admin@$domain", $john, 'The specified email is not available.'],
["administrator@$domain", $john, 'The specified email is not available.'],
// forbidden (other user's domain)
["testtest@kolab.org", $user, 'The specified domain is not available.'],
// existing alias of other user
["jack.daniels@kolab.org", $john, 'The specified email is not available.'],
// An existing shared folder or folder alias
["folder-event@kolab.org", $john, 'The specified email is not available.'],
["folder-alias1@kolab.org", $john, 'The specified email is not available.'],
// A soft-deleted shared folder or folder alias
["folder-test@kolabnow.com", $john, 'The specified email is not available.'],
["folder-alias2@kolabnow.com", $john, 'The specified email is not available.'],
// A group
["group-test@kolab.org", $john, 'The specified email is not available.'],
// A soft-deleted group
["group-test@kolabnow.com", $john, 'The specified email is not available.'],
// A resource
["resource-test1@kolab.org", $john, 'The specified email is not available.'],
// A soft-deleted resource
["resource-test@kolabnow.com", $john, 'The specified email is not available.'],
];
foreach ($cases as $idx => $case) {
list($email, $user, $expected) = $case;
$deleted = null;
$result = UsersController::validateEmail($email, $user, $deleted);
$this->assertSame($expected, $result, "Case {$email}");
$this->assertNull($deleted, "Case {$email}");
}
}
/**
* User email validation - tests for $deleted argument
*
* Note: Technically these include unit tests, but let's keep it here for now.
* FIXME: Shall we do a http request for each case?
*/
public function testValidateEmailDeleted(): void
{
Queue::fake();
$john = $this->getTestUser('john@kolab.org');
$deleted_priv = $this->getTestUser('deleted@kolab.org');
$deleted_priv->delete();
$deleted_pub = $this->getTestUser('deleted@kolabnow.com');
$deleted_pub->delete();
$result = UsersController::validateEmail('deleted@kolab.org', $john, $deleted);
$this->assertSame(null, $result);
$this->assertSame($deleted_priv->id, $deleted->id);
$result = UsersController::validateEmail('deleted@kolabnow.com', $john, $deleted);
$this->assertSame('The specified email is not available.', $result);
$this->assertSame(null, $deleted);
$result = UsersController::validateEmail('jack@kolab.org', $john, $deleted);
$this->assertSame('The specified email is not available.', $result);
$this->assertSame(null, $deleted);
$pub_group = $this->getTestGroup('group-test@kolabnow.com');
$priv_group = $this->getTestGroup('group-test@kolab.org');
// A group in a public domain, existing
$result = UsersController::validateEmail($pub_group->email, $john, $deleted);
$this->assertSame('The specified email is not available.', $result);
$this->assertNull($deleted);
$pub_group->delete();
// A group in a public domain, deleted
$result = UsersController::validateEmail($pub_group->email, $john, $deleted);
$this->assertSame('The specified email is not available.', $result);
$this->assertNull($deleted);
// A group in a private domain, existing
$result = UsersController::validateEmail($priv_group->email, $john, $deleted);
$this->assertSame('The specified email is not available.', $result);
$this->assertNull($deleted);
$priv_group->delete();
// A group in a private domain, deleted
$result = UsersController::validateEmail($priv_group->email, $john, $deleted);
$this->assertSame(null, $result);
$this->assertSame($priv_group->id, $deleted->id);
// TODO: Test the same with a resource and shared folder
}
/**
* User email alias validation.
*
* Note: Technically these include unit tests, but let's keep it here for now.
* FIXME: Shall we do a http request for each case?
*/
public function testValidateAlias(): void
{
Queue::fake();
$public_domains = Domain::getPublicDomains();
$domain = reset($public_domains);
$john = $this->getTestUser('john@kolab.org');
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$deleted_priv = $this->getTestUser('deleted@kolab.org');
$deleted_priv->setAliases(['deleted-alias@kolab.org']);
$deleted_priv->delete();
$deleted_pub = $this->getTestUser('deleted@kolabnow.com');
$deleted_pub->setAliases(['deleted-alias@kolabnow.com']);
$deleted_pub->delete();
$folder = $this->getTestSharedFolder('folder-event@kolab.org');
$folder->setAliases(['folder-alias1@kolab.org']);
$folder_del = $this->getTestSharedFolder('folder-test@kolabnow.com');
$folder_del->setAliases(['folder-alias2@kolabnow.com']);
$folder_del->delete();
$group_priv = $this->getTestGroup('group-test@kolab.org');
$group = $this->getTestGroup('group-test@kolabnow.com');
$group->delete();
$resource = $this->getTestResource('resource-test@kolabnow.com');
$resource->delete();
$cases = [
// Invalid format
["$domain", $john, 'The specified alias is invalid.'],
[".@$domain", $john, 'The specified alias is invalid.'],
["test123456@localhost", $john, 'The specified domain is invalid.'],
["test123456@unknown-domain.org", $john, 'The specified domain is invalid.'],
["$domain", $john, 'The specified alias is invalid.'],
[".@$domain", $john, 'The specified alias is invalid.'],
// forbidden local part on public domains
["admin@$domain", $john, 'The specified alias is not available.'],
["administrator@$domain", $john, 'The specified alias is not available.'],
// forbidden (other user's domain)
["testtest@kolab.org", $user, 'The specified domain is not available.'],
// existing alias of other user, to be an alias, user in the same group account
["jack.daniels@kolab.org", $john, null],
// existing user
["jack@kolab.org", $john, 'The specified alias is not available.'],
// valid (user domain)
["admin@kolab.org", $john, null],
// valid (public domain)
["test.test@$domain", $john, null],
// An alias that was a user email before is allowed, but only for custom domains
["deleted@kolab.org", $john, null],
["deleted-alias@kolab.org", $john, null],
["deleted@kolabnow.com", $john, 'The specified alias is not available.'],
["deleted-alias@kolabnow.com", $john, 'The specified alias is not available.'],
// An existing shared folder or folder alias
["folder-event@kolab.org", $john, 'The specified alias is not available.'],
["folder-alias1@kolab.org", $john, null],
// A soft-deleted shared folder or folder alias
["folder-test@kolabnow.com", $john, 'The specified alias is not available.'],
["folder-alias2@kolabnow.com", $john, 'The specified alias is not available.'],
// A group with the same email address exists
["group-test@kolab.org", $john, 'The specified alias is not available.'],
// A soft-deleted group
["group-test@kolabnow.com", $john, 'The specified alias is not available.'],
// A resource
["resource-test1@kolab.org", $john, 'The specified alias is not available.'],
// A soft-deleted resource
["resource-test@kolabnow.com", $john, 'The specified alias is not available.'],
];
foreach ($cases as $idx => $case) {
list($alias, $user, $expected) = $case;
$result = UsersController::validateAlias($alias, $user);
$this->assertSame($expected, $result, "Case {$alias}");
}
}
}
diff --git a/src/tests/Feature/EntitlementTest.php b/src/tests/Feature/EntitlementTest.php
index 15c4684b..570cbb42 100644
--- a/src/tests/Feature/EntitlementTest.php
+++ b/src/tests/Feature/EntitlementTest.php
@@ -1,225 +1,225 @@
<?php
namespace Tests\Feature;
use App\Domain;
use App\Entitlement;
use App\Package;
use App\Sku;
use App\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class EntitlementTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('entitlement-test@kolabnow.com');
$this->deleteTestUser('entitled-user@custom-domain.com');
$this->deleteTestGroup('test-group@custom-domain.com');
$this->deleteTestDomain('custom-domain.com');
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('entitlement-test@kolabnow.com');
$this->deleteTestUser('entitled-user@custom-domain.com');
$this->deleteTestGroup('test-group@custom-domain.com');
$this->deleteTestDomain('custom-domain.com');
parent::tearDown();
}
/**
* Test for Entitlement::costsPerDay()
*/
public function testCostsPerDay(): void
{
// 500
// 28 days: 17.86
// 31 days: 16.129
$user = $this->getTestUser('entitlement-test@kolabnow.com');
$package = Package::withEnvTenantContext()->where('title', 'kolab')->first();
$mailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first();
$user->assignPackage($package);
$entitlement = $user->entitlements->where('sku_id', $mailbox->id)->first();
$costsPerDay = $entitlement->costsPerDay();
$this->assertTrue($costsPerDay < 17.86);
$this->assertTrue($costsPerDay > 16.12);
}
/**
* Tests for entitlements
* @todo This really should be in User or Wallet tests file
*/
public function testEntitlements(): void
{
$packageDomain = Package::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$packageKolab = Package::withEnvTenantContext()->where('title', 'kolab')->first();
$skuDomain = Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$skuMailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first();
$owner = $this->getTestUser('entitlement-test@kolabnow.com');
$user = $this->getTestUser('entitled-user@custom-domain.com');
$domain = $this->getTestDomain(
'custom-domain.com',
[
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_EXTERNAL,
]
);
$domain->assignPackage($packageDomain, $owner);
$owner->assignPackage($packageKolab);
$owner->assignPackage($packageKolab, $user);
$wallet = $owner->wallets->first();
$this->assertCount(7, $owner->entitlements()->get());
$this->assertCount(1, $skuDomain->entitlements()->where('wallet_id', $wallet->id)->get());
$this->assertCount(2, $skuMailbox->entitlements()->where('wallet_id', $wallet->id)->get());
$this->assertCount(15, $wallet->entitlements);
$this->backdateEntitlements(
$owner->entitlements,
Carbon::now()->subMonthsWithoutOverflow(1)
);
$wallet->chargeEntitlements();
$this->assertTrue($wallet->fresh()->balance < 0);
}
/**
* @todo This really should be in User or Wallet tests file
*/
public function testBillDeletedEntitlement(): void
{
$user = $this->getTestUser('entitlement-test@kolabnow.com');
$package = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first();
$storage = \App\Sku::withEnvTenantContext()->where('title', 'storage')->first();
$user->assignPackage($package);
// some additional SKUs so we have something to delete.
$user->assignSku($storage, 4);
// the mailbox, the groupware, the 5 original storage and the additional 4
$this->assertCount(11, $user->fresh()->entitlements);
$wallet = $user->wallets()->first();
$backdate = Carbon::now()->subWeeks(7);
$this->backdateEntitlements($user->entitlements, $backdate);
$charge = $wallet->chargeEntitlements();
$this->assertSame(-1090, $wallet->balance);
$balance = $wallet->balance;
$discount = \App\Discount::withEnvTenantContext()->where('discount', 30)->first();
$wallet->discount()->associate($discount);
$wallet->save();
$user->removeSku($storage, 4);
// we expect the wallet to have been charged for ~3 weeks of use of
// 4 deleted storage entitlements, it should also take discount into account
$backdate->addMonthsWithoutOverflow(1);
$diffInDays = $backdate->diffInDays(Carbon::now());
// entitlements-num * cost * discount * days-in-month
$max = intval(4 * 25 * 0.7 * $diffInDays / 28);
$min = intval(4 * 25 * 0.7 * $diffInDays / 31);
$wallet->refresh();
$this->assertTrue($wallet->balance >= $balance - $max);
$this->assertTrue($wallet->balance <= $balance - $min);
$transactions = \App\Transaction::where('object_id', $wallet->id)
->where('object_type', \App\Wallet::class)->get();
// one round of the monthly invoicing, four sku deletions getting invoiced
$this->assertCount(5, $transactions);
// Test that deleting an entitlement on a degraded account costs nothing
$balance = $wallet->balance;
User::where('id', $user->id)->update(['status' => $user->status | User::STATUS_DEGRADED]);
$backdate = Carbon::now()->subWeeks(7);
$this->backdateEntitlements($user->entitlements()->get(), $backdate);
$groupware = \App\Sku::withEnvTenantContext()->where('title', 'groupware')->first();
$entitlement = $wallet->entitlements()->where('sku_id', $groupware->id)->first();
$entitlement->delete();
$this->assertSame($wallet->refresh()->balance, $balance);
}
/**
- * Test Entitlement::entitlementTitle()
+ * Test EntitleableTrait::toString()
*/
public function testEntitleableTitle(): void
{
Queue::fake();
$packageDomain = Package::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$packageKolab = Package::withEnvTenantContext()->where('title', 'kolab')->first();
$user = $this->getTestUser('entitled-user@custom-domain.com');
$group = $this->getTestGroup('test-group@custom-domain.com');
$domain = $this->getTestDomain(
'custom-domain.com',
[
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_EXTERNAL,
]
);
$wallet = $user->wallets->first();
$domain->assignPackage($packageDomain, $user);
$user->assignPackage($packageKolab);
$group->assignToWallet($wallet);
$sku_mailbox = \App\Sku::withEnvTenantContext()->where('title', 'mailbox')->first();
$sku_group = \App\Sku::withEnvTenantContext()->where('title', 'group')->first();
$sku_domain = \App\Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$entitlement = Entitlement::where('wallet_id', $wallet->id)
->where('sku_id', $sku_mailbox->id)->first();
- $this->assertSame($user->email, $entitlement->entitleableTitle());
+ $this->assertSame($user->email, $entitlement->entitleable->toString());
$entitlement = Entitlement::where('wallet_id', $wallet->id)
->where('sku_id', $sku_group->id)->first();
- $this->assertSame($group->email, $entitlement->entitleableTitle());
+ $this->assertSame($group->email, $entitlement->entitleable->toString());
$entitlement = Entitlement::where('wallet_id', $wallet->id)
->where('sku_id', $sku_domain->id)->first();
- $this->assertSame($domain->namespace, $entitlement->entitleableTitle());
+ $this->assertSame($domain->namespace, $entitlement->entitleable->toString());
// Make sure it still works if the entitleable is deleted
$domain->delete();
$entitlement->refresh();
- $this->assertSame($domain->namespace, $entitlement->entitleableTitle());
+ $this->assertSame($domain->namespace, $entitlement->entitleable->toString());
$this->assertNotNull($entitlement->entitleable);
}
}
diff --git a/src/tests/Feature/MeetRoomTest.php b/src/tests/Feature/MeetRoomTest.php
new file mode 100644
index 00000000..6f1ea4f2
--- /dev/null
+++ b/src/tests/Feature/MeetRoomTest.php
@@ -0,0 +1,143 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Meet\Room;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class MeetRoomTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestUser('room-test@' . \config('app.domain'));
+ Room::withTrashed()->whereNotIn('name', ['shared', 'john'])->forceDelete();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $this->deleteTestUser('room-test@' . \config('app.domain'));
+ Room::withTrashed()->whereNotIn('name', ['shared', 'john'])->forceDelete();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test room/user creation
+ */
+ public function testCreate(): void
+ {
+ Queue::fake();
+
+ // Test default room name generation
+ $room = new Room();
+ $room->save();
+
+ $this->assertMatchesRegularExpression('/^[0-9a-z]{3}-[0-9a-z]{3}-[0-9a-z]{3}$/', $room->name);
+
+ // Test keeping the specified room name
+ $room = new Room();
+ $room->name = 'test';
+ $room->save();
+
+ $this->assertSame('test', $room->name);
+ }
+
+ /**
+ * Test room/user deletion
+ */
+ public function testDelete(): void
+ {
+ Queue::fake();
+
+ $user = $this->getTestUser('room-test@' . \config('app.domain'));
+ $wallet = $user->wallets()->first();
+ $room = $this->getTestRoom('test', $wallet, [], [
+ 'password' => 'test',
+ 'acl' => ['john@kolab.org, full'],
+ ], 'group-room');
+
+ $this->assertCount(1, $room->entitlements()->get());
+ $this->assertCount(1, $room->permissions()->get());
+ $this->assertCount(1, $room->settings()->get());
+
+ // First delete the room, see if it deletes the room permissions, entitlements and settings
+ $room->delete();
+
+ $this->assertTrue($room->fresh()->trashed());
+ $this->assertCount(0, $room->entitlements()->get());
+ $this->assertCount(0, $room->permissions()->get());
+ $this->assertCount(1, $room->settings()->get());
+
+ $room->forceDelete();
+
+ $this->assertCount(0, Room::where('name', 'test')->get());
+ $this->assertCount(0, $room->settings()->get());
+
+ // Now test if deleting a user deletes the room
+ $room = $this->getTestRoom('test', $wallet, [], [
+ 'password' => 'test',
+ 'acl' => ['john@kolab.org, full'],
+ ], 'group-room');
+
+ $user->delete();
+
+ $this->assertTrue($room->fresh()->trashed());
+ $this->assertCount(0, $room->entitlements()->get());
+ $this->assertCount(0, $room->permissions()->get());
+ $this->assertCount(1, $room->settings()->get());
+ }
+
+ /**
+ * Test getConfig()/setConfig() (\App\Meet\RoomConfigTrait)
+ */
+ public function testConfig(): void
+ {
+ $room = $this->getTestRoom('test');
+ $user = $this->getTestUser('room-test@' . \config('app.domain'));
+ $wallet = $user->wallets()->first();
+
+ // Test input validation (acl can be set on a group room only
+ $result = $room->setConfig($input = [
+ 'acl' => ['jack@kolab.org, full'],
+ ]);
+
+ $this->assertCount(1, $result);
+ $this->assertSame("The requested configuration parameter is not supported.", $result['acl']);
+
+ $room->entitlements()->delete();
+ $room->assignToWallet($wallet, 'group-room');
+
+ // Test input validation
+ $result = $room->setConfig($input = [
+ 'acl' => ['jack@kolab.org, read-only', 'test@unknown.org, full'],
+ ]);
+
+ $this->assertCount(2, $result['acl']);
+ $this->assertSame("The specified permission is invalid.", $result['acl'][0]);
+ $this->assertSame("The specified email address does not exist.", $result['acl'][1]);
+
+ $room->setConfig($input = [
+ 'password' => 'test-pass',
+ 'nomedia' => true,
+ 'locked' => true,
+ 'acl' => ['john@kolab.org, full']
+ ]);
+
+ $config = $room->getConfig();
+
+ $this->assertCount(4, $config);
+ $this->assertSame($input['password'], $config['password']);
+ $this->assertSame($input['nomedia'], $config['nomedia']);
+ $this->assertSame($input['locked'], $config['locked']);
+ $this->assertSame($input['acl'], $config['acl']);
+ }
+}
diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php
index b2c92e98..67b6b3c5 100644
--- a/src/tests/Feature/UserTest.php
+++ b/src/tests/Feature/UserTest.php
@@ -1,1348 +1,1362 @@
<?php
namespace Tests\Feature;
use App\Domain;
use App\Group;
use App\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class UserTest extends TestCase
{
+ /**
+ * {@inheritDoc}
+ */
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('user-test@' . \config('app.domain'));
$this->deleteTestUser('UserAccountA@UserAccount.com');
$this->deleteTestUser('UserAccountB@UserAccount.com');
$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');
}
+ /**
+ * {@inheritDoc}
+ */
public function tearDown(): void
{
\App\TenantSetting::truncate();
$this->deleteTestUser('user-test@' . \config('app.domain'));
$this->deleteTestUser('UserAccountA@UserAccount.com');
$this->deleteTestUser('UserAccountB@UserAccount.com');
$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');
parent::tearDown();
}
/**
* Tests for User::assignPackage()
*/
public function testAssignPackage(): void
{
$user = $this->getTestUser('user-test@' . \config('app.domain'));
$wallet = $user->wallets()->first();
$package = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first();
$user->assignPackage($package);
$sku = \App\Sku::withEnvTenantContext()->where('title', 'mailbox')->first();
$entitlement = \App\Entitlement::where('wallet_id', $wallet->id)
->where('sku_id', $sku->id)->first();
$this->assertNotNull($entitlement);
$this->assertSame($sku->id, $entitlement->sku->id);
$this->assertSame($wallet->id, $entitlement->wallet->id);
$this->assertEquals($user->id, $entitlement->entitleable->id);
$this->assertTrue($entitlement->entitleable instanceof \App\User);
$this->assertCount(7, $user->entitlements()->get());
}
/**
* Tests for User::assignPlan()
*/
public function testAssignPlan(): void
{
$this->markTestIncomplete();
}
/**
* Tests for User::assignSku()
*/
public function testAssignSku(): void
{
$this->markTestIncomplete();
}
/**
* Verify a wallet assigned a controller is among the accounts of the assignee.
*/
public function testAccounts(): void
{
$userA = $this->getTestUser('UserAccountA@UserAccount.com');
$userB = $this->getTestUser('UserAccountB@UserAccount.com');
$this->assertTrue($userA->wallets()->count() == 1);
$userA->wallets()->each(
function ($wallet) use ($userB) {
$wallet->addController($userB);
}
);
$this->assertTrue($userB->accounts()->get()[0]->id === $userA->wallets()->get()[0]->id);
}
/**
* Test User::canDelete() method
*/
public function testCanDelete(): void
{
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$reseller1 = $this->getTestUser('reseller@' . \config('app.domain'));
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$domain = $this->getTestDomain('kolab.org');
// Admin
$this->assertTrue($admin->canDelete($admin));
$this->assertFalse($admin->canDelete($john));
$this->assertFalse($admin->canDelete($jack));
$this->assertFalse($admin->canDelete($reseller1));
$this->assertFalse($admin->canDelete($domain));
$this->assertFalse($admin->canDelete($domain->wallet()));
// Reseller - kolabnow
$this->assertFalse($reseller1->canDelete($john));
$this->assertFalse($reseller1->canDelete($jack));
$this->assertTrue($reseller1->canDelete($reseller1));
$this->assertFalse($reseller1->canDelete($domain));
$this->assertFalse($reseller1->canDelete($domain->wallet()));
$this->assertFalse($reseller1->canDelete($admin));
// Normal user - account owner
$this->assertTrue($john->canDelete($john));
$this->assertTrue($john->canDelete($ned));
$this->assertTrue($john->canDelete($jack));
$this->assertTrue($john->canDelete($domain));
$this->assertFalse($john->canDelete($domain->wallet()));
$this->assertFalse($john->canDelete($reseller1));
$this->assertFalse($john->canDelete($admin));
// Normal user - a non-owner and non-controller
$this->assertFalse($jack->canDelete($jack));
$this->assertFalse($jack->canDelete($john));
$this->assertFalse($jack->canDelete($domain));
$this->assertFalse($jack->canDelete($domain->wallet()));
$this->assertFalse($jack->canDelete($reseller1));
$this->assertFalse($jack->canDelete($admin));
// Normal user - John's wallet controller
$this->assertTrue($ned->canDelete($ned));
$this->assertTrue($ned->canDelete($john));
$this->assertTrue($ned->canDelete($jack));
$this->assertTrue($ned->canDelete($domain));
$this->assertFalse($ned->canDelete($domain->wallet()));
$this->assertFalse($ned->canDelete($reseller1));
$this->assertFalse($ned->canDelete($admin));
}
/**
* Test User::canRead() method
*/
public function testCanRead(): void
{
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$reseller1 = $this->getTestUser('reseller@' . \config('app.domain'));
$reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$domain = $this->getTestDomain('kolab.org');
// Admin
$this->assertTrue($admin->canRead($admin));
$this->assertTrue($admin->canRead($john));
$this->assertTrue($admin->canRead($jack));
$this->assertTrue($admin->canRead($reseller1));
$this->assertTrue($admin->canRead($reseller2));
$this->assertTrue($admin->canRead($domain));
$this->assertTrue($admin->canRead($domain->wallet()));
// Reseller - kolabnow
$this->assertTrue($reseller1->canRead($john));
$this->assertTrue($reseller1->canRead($jack));
$this->assertTrue($reseller1->canRead($reseller1));
$this->assertTrue($reseller1->canRead($domain));
$this->assertTrue($reseller1->canRead($domain->wallet()));
$this->assertFalse($reseller1->canRead($reseller2));
$this->assertFalse($reseller1->canRead($admin));
// Reseller - different tenant
$this->assertTrue($reseller2->canRead($reseller2));
$this->assertFalse($reseller2->canRead($john));
$this->assertFalse($reseller2->canRead($jack));
$this->assertFalse($reseller2->canRead($reseller1));
$this->assertFalse($reseller2->canRead($domain));
$this->assertFalse($reseller2->canRead($domain->wallet()));
$this->assertFalse($reseller2->canRead($admin));
// Normal user - account owner
$this->assertTrue($john->canRead($john));
$this->assertTrue($john->canRead($ned));
$this->assertTrue($john->canRead($jack));
$this->assertTrue($john->canRead($domain));
$this->assertTrue($john->canRead($domain->wallet()));
$this->assertFalse($john->canRead($reseller1));
$this->assertFalse($john->canRead($reseller2));
$this->assertFalse($john->canRead($admin));
// Normal user - a non-owner and non-controller
$this->assertTrue($jack->canRead($jack));
$this->assertFalse($jack->canRead($john));
$this->assertFalse($jack->canRead($domain));
$this->assertFalse($jack->canRead($domain->wallet()));
$this->assertFalse($jack->canRead($reseller1));
$this->assertFalse($jack->canRead($reseller2));
$this->assertFalse($jack->canRead($admin));
// Normal user - John's wallet controller
$this->assertTrue($ned->canRead($ned));
$this->assertTrue($ned->canRead($john));
$this->assertTrue($ned->canRead($jack));
$this->assertTrue($ned->canRead($domain));
$this->assertTrue($ned->canRead($domain->wallet()));
$this->assertFalse($ned->canRead($reseller1));
$this->assertFalse($ned->canRead($reseller2));
$this->assertFalse($ned->canRead($admin));
}
/**
* Test User::canUpdate() method
*/
public function testCanUpdate(): void
{
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$reseller1 = $this->getTestUser('reseller@' . \config('app.domain'));
$reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$domain = $this->getTestDomain('kolab.org');
// Admin
$this->assertTrue($admin->canUpdate($admin));
$this->assertTrue($admin->canUpdate($john));
$this->assertTrue($admin->canUpdate($jack));
$this->assertTrue($admin->canUpdate($reseller1));
$this->assertTrue($admin->canUpdate($reseller2));
$this->assertTrue($admin->canUpdate($domain));
$this->assertTrue($admin->canUpdate($domain->wallet()));
// Reseller - kolabnow
$this->assertTrue($reseller1->canUpdate($john));
$this->assertTrue($reseller1->canUpdate($jack));
$this->assertTrue($reseller1->canUpdate($reseller1));
$this->assertTrue($reseller1->canUpdate($domain));
$this->assertTrue($reseller1->canUpdate($domain->wallet()));
$this->assertFalse($reseller1->canUpdate($reseller2));
$this->assertFalse($reseller1->canUpdate($admin));
// Reseller - different tenant
$this->assertTrue($reseller2->canUpdate($reseller2));
$this->assertFalse($reseller2->canUpdate($john));
$this->assertFalse($reseller2->canUpdate($jack));
$this->assertFalse($reseller2->canUpdate($reseller1));
$this->assertFalse($reseller2->canUpdate($domain));
$this->assertFalse($reseller2->canUpdate($domain->wallet()));
$this->assertFalse($reseller2->canUpdate($admin));
// Normal user - account owner
$this->assertTrue($john->canUpdate($john));
$this->assertTrue($john->canUpdate($ned));
$this->assertTrue($john->canUpdate($jack));
$this->assertTrue($john->canUpdate($domain));
$this->assertFalse($john->canUpdate($domain->wallet()));
$this->assertFalse($john->canUpdate($reseller1));
$this->assertFalse($john->canUpdate($reseller2));
$this->assertFalse($john->canUpdate($admin));
// Normal user - a non-owner and non-controller
$this->assertTrue($jack->canUpdate($jack));
$this->assertFalse($jack->canUpdate($john));
$this->assertFalse($jack->canUpdate($domain));
$this->assertFalse($jack->canUpdate($domain->wallet()));
$this->assertFalse($jack->canUpdate($reseller1));
$this->assertFalse($jack->canUpdate($reseller2));
$this->assertFalse($jack->canUpdate($admin));
// Normal user - John's wallet controller
$this->assertTrue($ned->canUpdate($ned));
$this->assertTrue($ned->canUpdate($john));
$this->assertTrue($ned->canUpdate($jack));
$this->assertTrue($ned->canUpdate($domain));
$this->assertFalse($ned->canUpdate($domain->wallet()));
$this->assertFalse($ned->canUpdate($reseller1));
$this->assertFalse($ned->canUpdate($reseller2));
$this->assertFalse($ned->canUpdate($admin));
}
/**
* Test user created/creating/updated observers
*/
public function testCreateAndUpdate(): void
{
Queue::fake();
$domain = \config('app.domain');
$user = User::create([
'email' => 'USER-test@' . \strtoupper($domain),
'password' => 'test',
]);
$result = User::where('email', "user-test@$domain")->first();
$this->assertSame("user-test@$domain", $result->email);
$this->assertSame($user->id, $result->id);
$this->assertSame(User::STATUS_NEW | User::STATUS_ACTIVE, $result->status);
$this->assertSame(0, $user->passwords()->count());
Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1);
Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 0);
Queue::assertPushed(
\App\Jobs\User\CreateJob::class,
function ($job) use ($user) {
$userEmail = TestCase::getObjectProperty($job, 'userEmail');
$userId = TestCase::getObjectProperty($job, 'userId');
return $userEmail === $user->email
&& $userId === $user->id;
}
);
Queue::assertPushedWithChain(
\App\Jobs\User\CreateJob::class,
[
\App\Jobs\User\VerifyJob::class,
]
);
/*
FIXME: Looks like we can't really do detailed assertions on chained jobs
Another thing to consider is if we maybe should run these jobs
independently (not chained) and make sure there's no race-condition
in status update
Queue::assertPushed(\App\Jobs\User\VerifyJob::class, 1);
Queue::assertPushed(\App\Jobs\User\VerifyJob::class, function ($job) use ($user) {
$userEmail = TestCase::getObjectProperty($job, 'userEmail');
$userId = TestCase::getObjectProperty($job, 'userId');
return $userEmail === $user->email
&& $userId === $user->id;
});
*/
// Test invoking KeyCreateJob
$this->deleteTestUser("user-test@$domain");
\App\Tenant::find(\config('app.tenant_id'))->setSetting('pgp.enable', 1);
$user = User::create(['email' => "user-test@$domain", 'password' => 'test']);
Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1);
Queue::assertPushed(
\App\Jobs\PGP\KeyCreateJob::class,
function ($job) use ($user) {
$userEmail = TestCase::getObjectProperty($job, 'userEmail');
$userId = TestCase::getObjectProperty($job, 'userId');
return $userEmail === $user->email
&& $userId === $user->id;
}
);
// Update the user, test the password change
$user->setSetting('password_expiration_warning', '2020-10-10 10:10:10');
$oldPassword = $user->password;
$user->password = 'test123';
$user->save();
$this->assertNotEquals($oldPassword, $user->password);
$this->assertSame(0, $user->passwords()->count());
$this->assertNull($user->getSetting('password_expiration_warning'));
$this->assertMatchesRegularExpression(
'/^' . now()->format('Y-m-d') . ' [0-9]{2}:[0-9]{2}:[0-9]{2}$/',
$user->getSetting('password_update')
);
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1);
Queue::assertPushed(
\App\Jobs\User\UpdateJob::class,
function ($job) use ($user) {
$userEmail = TestCase::getObjectProperty($job, 'userEmail');
$userId = TestCase::getObjectProperty($job, 'userId');
return $userEmail === $user->email
&& $userId === $user->id;
}
);
// Update the user, test the password history
$user->setSetting('password_policy', 'last:3');
$oldPassword = $user->password;
$user->password = 'test1234';
$user->save();
$this->assertSame(1, $user->passwords()->count());
$this->assertSame($oldPassword, $user->passwords()->first()->password);
$user->password = 'test12345';
$user->save();
$oldPassword = $user->password;
$user->password = 'test123456';
$user->save();
$this->assertSame(2, $user->passwords()->count());
$this->assertSame($oldPassword, $user->passwords()->latest()->first()->password);
}
/**
* Tests for User::domains()
*/
public function testDomains(): void
{
$user = $this->getTestUser('john@kolab.org');
$domain = $this->getTestDomain('useraccount.com', [
'status' => Domain::STATUS_NEW | Domain::STATUS_ACTIVE,
'type' => Domain::TYPE_PUBLIC,
]);
$domains = $user->domains()->pluck('namespace')->all();
$this->assertContains($domain->namespace, $domains);
$this->assertContains('kolab.org', $domains);
// Jack is not the wallet controller, so for him the list should not
// include John's domains, kolab.org specifically
$user = $this->getTestUser('jack@kolab.org');
$domains = $user->domains()->pluck('namespace')->all();
$this->assertContains($domain->namespace, $domains);
$this->assertNotContains('kolab.org', $domains);
// Public domains of other tenants should not be returned
$tenant = \App\Tenant::where('id', '!=', \config('app.tenant_id'))->first();
$domain->tenant_id = $tenant->id;
$domain->save();
$domains = $user->domains()->pluck('namespace')->all();
$this->assertNotContains($domain->namespace, $domains);
}
/**
* Test User::getConfig() and setConfig() methods
*/
public function testConfigTrait(): void
{
$john = $this->getTestUser('john@kolab.org');
$john->setSetting('greylist_enabled', null);
$john->setSetting('password_policy', null);
$john->setSetting('max_password_age', null);
// greylist_enabled
$this->assertSame(true, $john->getConfig()['greylist_enabled']);
$result = $john->setConfig(['greylist_enabled' => false, 'unknown' => false]);
$this->assertSame(['unknown' => "The requested configuration parameter is not supported."], $result);
$this->assertSame(false, $john->getConfig()['greylist_enabled']);
$this->assertSame('false', $john->getSetting('greylist_enabled'));
$result = $john->setConfig(['greylist_enabled' => true]);
$this->assertSame([], $result);
$this->assertSame(true, $john->getConfig()['greylist_enabled']);
$this->assertSame('true', $john->getSetting('greylist_enabled'));
// max_apssword_age
$this->assertSame(null, $john->getConfig()['max_password_age']);
$result = $john->setConfig(['max_password_age' => -1]);
$this->assertSame([], $result);
$this->assertSame(null, $john->getConfig()['max_password_age']);
$this->assertSame(null, $john->getSetting('max_password_age'));
$result = $john->setConfig(['max_password_age' => 12]);
$this->assertSame([], $result);
$this->assertSame('12', $john->getConfig()['max_password_age']);
$this->assertSame('12', $john->getSetting('max_password_age'));
// password_policy
$result = $john->setConfig(['password_policy' => true]);
$this->assertSame(['password_policy' => "Specified password policy is invalid."], $result);
$this->assertSame(null, $john->getConfig()['password_policy']);
$this->assertSame(null, $john->getSetting('password_policy'));
$result = $john->setConfig(['password_policy' => 'min:-1']);
$this->assertSame(['password_policy' => "Specified password policy is invalid."], $result);
$result = $john->setConfig(['password_policy' => 'min:-1']);
$this->assertSame(['password_policy' => "Specified password policy is invalid."], $result);
$result = $john->setConfig(['password_policy' => 'min:10,unknown']);
$this->assertSame(['password_policy' => "Specified password policy is invalid."], $result);
\config(['app.password_policy' => 'min:5,max:100']);
$result = $john->setConfig(['password_policy' => 'min:4,max:255']);
$this->assertSame(['password_policy' => "Minimum password length cannot be less than 5."], $result);
\config(['app.password_policy' => 'min:5,max:100']);
$result = $john->setConfig(['password_policy' => 'min:10,max:255']);
$this->assertSame(['password_policy' => "Maximum password length cannot be more than 100."], $result);
\config(['app.password_policy' => 'min:5,max:255']);
$result = $john->setConfig(['password_policy' => 'min:10,max:255']);
$this->assertSame([], $result);
$this->assertSame('min:10,max:255', $john->getConfig()['password_policy']);
$this->assertSame('min:10,max:255', $john->getSetting('password_policy'));
}
/**
* Test user account degradation and un-degradation
*/
public function testDegradeAndUndegrade(): void
{
Queue::fake();
// Test an account with users, domain
$userA = $this->getTestUser('UserAccountA@UserAccount.com');
$userB = $this->getTestUser('UserAccountB@UserAccount.com');
$package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first();
$package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$domain = $this->getTestDomain('UserAccount.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_HOSTED,
]);
$userA->assignPackage($package_kolab);
$domain->assignPackage($package_domain, $userA);
$userA->assignPackage($package_kolab, $userB);
$entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id);
$entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id);
$entitlementsDomain = \App\Entitlement::where('entitleable_id', $domain->id);
$yesterday = Carbon::now()->subDays(1);
$this->backdateEntitlements($entitlementsA->get(), $yesterday, Carbon::now()->subMonthsWithoutOverflow(1));
$this->backdateEntitlements($entitlementsB->get(), $yesterday, Carbon::now()->subMonthsWithoutOverflow(1));
$wallet = $userA->wallets->first();
$this->assertSame(7, $entitlementsA->count());
$this->assertSame(7, $entitlementsB->count());
$this->assertSame(7, $entitlementsA->whereDate('updated_at', $yesterday->toDateString())->count());
$this->assertSame(7, $entitlementsB->whereDate('updated_at', $yesterday->toDateString())->count());
$this->assertSame(0, $wallet->balance);
Queue::fake(); // reset queue state
// Degrade the account/wallet owner
$userA->degrade();
$entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id);
$entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id);
$this->assertTrue($userA->fresh()->isDegraded());
$this->assertTrue($userA->fresh()->isDegraded(true));
$this->assertFalse($userB->fresh()->isDegraded());
$this->assertTrue($userB->fresh()->isDegraded(true));
$balance = $wallet->fresh()->balance;
$this->assertTrue($balance <= -64);
$this->assertSame(7, $entitlementsA->whereDate('updated_at', Carbon::now()->toDateString())->count());
$this->assertSame(7, $entitlementsB->whereDate('updated_at', Carbon::now()->toDateString())->count());
// Expect one update job for every user
// @phpstan-ignore-next-line
$userIds = Queue::pushed(\App\Jobs\User\UpdateJob::class)->map(function ($job) {
return TestCase::getObjectProperty($job, 'userId');
})->all();
$this->assertSame([$userA->id, $userB->id], $userIds);
// Un-Degrade the account/wallet owner
$entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id);
$entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id);
$yesterday = Carbon::now()->subDays(1);
$this->backdateEntitlements($entitlementsA->get(), $yesterday, Carbon::now()->subMonthsWithoutOverflow(1));
$this->backdateEntitlements($entitlementsB->get(), $yesterday, Carbon::now()->subMonthsWithoutOverflow(1));
Queue::fake(); // reset queue state
$userA->undegrade();
$this->assertFalse($userA->fresh()->isDegraded());
$this->assertFalse($userA->fresh()->isDegraded(true));
$this->assertFalse($userB->fresh()->isDegraded());
$this->assertFalse($userB->fresh()->isDegraded(true));
// Expect no balance change, degraded account entitlements are free
$this->assertSame($balance, $wallet->fresh()->balance);
$this->assertSame(7, $entitlementsA->whereDate('updated_at', Carbon::now()->toDateString())->count());
$this->assertSame(7, $entitlementsB->whereDate('updated_at', Carbon::now()->toDateString())->count());
// Expect one update job for every user
// @phpstan-ignore-next-line
$userIds = Queue::pushed(\App\Jobs\User\UpdateJob::class)->map(function ($job) {
return TestCase::getObjectProperty($job, 'userId');
})->all();
$this->assertSame([$userA->id, $userB->id], $userIds);
}
/**
* Test user deletion
*/
public function testDelete(): void
{
Queue::fake();
$user = $this->getTestUser('user-test@' . \config('app.domain'));
$package = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first();
$user->assignPackage($package);
$id = $user->id;
$this->assertCount(7, $user->entitlements()->get());
$user->delete();
$this->assertCount(0, $user->entitlements()->get());
$this->assertTrue($user->fresh()->trashed());
$this->assertFalse($user->fresh()->isDeleted());
// Delete the user for real
$job = new \App\Jobs\User\DeleteJob($id);
$job->handle();
$this->assertTrue(User::withTrashed()->where('id', $id)->first()->isDeleted());
$user->forceDelete();
$this->assertCount(0, User::withTrashed()->where('id', $id)->get());
// Test an account with users, domain, and group, and resource
$userA = $this->getTestUser('UserAccountA@UserAccount.com');
$userB = $this->getTestUser('UserAccountB@UserAccount.com');
$userC = $this->getTestUser('UserAccountC@UserAccount.com');
$package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first();
$package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$domain = $this->getTestDomain('UserAccount.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_HOSTED,
]);
$userA->assignPackage($package_kolab);
$domain->assignPackage($package_domain, $userA);
$userA->assignPackage($package_kolab, $userB);
$userA->assignPackage($package_kolab, $userC);
$group = $this->getTestGroup('test-group@UserAccount.com');
$group->assignToWallet($userA->wallets->first());
$resource = $this->getTestResource('test-resource@UserAccount.com', ['name' => 'test']);
$resource->assignToWallet($userA->wallets->first());
$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);
$entitlementsC = \App\Entitlement::where('entitleable_id', $userC->id);
$entitlementsDomain = \App\Entitlement::where('entitleable_id', $domain->id);
$entitlementsGroup = \App\Entitlement::where('entitleable_id', $group->id);
$entitlementsResource = \App\Entitlement::where('entitleable_id', $resource->id);
$entitlementsFolder = \App\Entitlement::where('entitleable_id', $folder->id);
$this->assertSame(7, $entitlementsA->count());
$this->assertSame(7, $entitlementsB->count());
$this->assertSame(7, $entitlementsC->count());
$this->assertSame(1, $entitlementsDomain->count());
$this->assertSame(1, $entitlementsGroup->count());
$this->assertSame(1, $entitlementsResource->count());
$this->assertSame(1, $entitlementsFolder->count());
// Delete non-controller user
$userC->delete();
$this->assertTrue($userC->fresh()->trashed());
$this->assertFalse($userC->fresh()->isDeleted());
$this->assertSame(0, $entitlementsC->count());
// Delete the controller (and expect "sub"-users to be deleted too)
$userA->delete();
$this->assertSame(0, $entitlementsA->count());
$this->assertSame(0, $entitlementsB->count());
$this->assertSame(0, $entitlementsDomain->count());
$this->assertSame(0, $entitlementsGroup->count());
$this->assertSame(0, $entitlementsResource->count());
$this->assertSame(0, $entitlementsFolder->count());
$this->assertSame(7, $entitlementsA->withTrashed()->count());
$this->assertSame(7, $entitlementsB->withTrashed()->count());
$this->assertSame(7, $entitlementsC->withTrashed()->count());
$this->assertSame(1, $entitlementsDomain->withTrashed()->count());
$this->assertSame(1, $entitlementsGroup->withTrashed()->count());
$this->assertSame(1, $entitlementsResource->withTrashed()->count());
$this->assertSame(1, $entitlementsFolder->withTrashed()->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();
$all_entitlements = \App\Entitlement::where('wallet_id', $userA->wallets->first()->id);
$transactions = \App\Transaction::where('object_id', $userA->wallets->first()->id);
$this->assertSame(0, $all_entitlements->withTrashed()->count());
$this->assertSame(0, $transactions->count());
$this->assertCount(0, User::withTrashed()->where('id', $userA->id)->get());
$this->assertCount(0, User::withTrashed()->where('id', $userB->id)->get());
$this->assertCount(0, User::withTrashed()->where('id', $userC->id)->get());
$this->assertCount(0, Domain::withTrashed()->where('id', $domain->id)->get());
$this->assertCount(0, Group::withTrashed()->where('id', $group->id)->get());
$this->assertCount(0, \App\Resource::withTrashed()->where('id', $resource->id)->get());
$this->assertCount(0, \App\SharedFolder::withTrashed()->where('id', $folder->id)->get());
}
/**
* Test user deletion vs. group membership
*/
public function testDeleteAndGroups(): void
{
Queue::fake();
$package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first();
$userA = $this->getTestUser('UserAccountA@UserAccount.com');
$userB = $this->getTestUser('UserAccountB@UserAccount.com');
$userA->assignPackage($package_kolab, $userB);
$group = $this->getTestGroup('test-group@UserAccount.com');
$group->members = ['test@gmail.com', $userB->email];
$group->assignToWallet($userA->wallets->first());
$group->save();
Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1);
$userGroups = $userA->groups()->get();
$this->assertSame(1, $userGroups->count());
$this->assertSame($group->id, $userGroups->first()->id);
$userB->delete();
$this->assertSame(['test@gmail.com'], $group->fresh()->members);
// Twice, one for save() and one for delete() above
Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 2);
}
/**
* Test handling negative balance on user deletion
*/
public function testDeleteWithNegativeBalance(): void
{
$user = $this->getTestUser('user-test@' . \config('app.domain'));
$wallet = $user->wallets()->first();
$wallet->balance = -1000;
$wallet->save();
$reseller_wallet = $user->tenant->wallet();
$reseller_wallet->balance = 0;
$reseller_wallet->save();
\App\Transaction::where('object_id', $reseller_wallet->id)->where('object_type', \App\Wallet::class)->delete();
$user->delete();
$reseller_transactions = \App\Transaction::where('object_id', $reseller_wallet->id)
->where('object_type', \App\Wallet::class)->get();
$this->assertSame(-1000, $reseller_wallet->fresh()->balance);
$this->assertCount(1, $reseller_transactions);
$trans = $reseller_transactions[0];
$this->assertSame("Deleted user {$user->email}", $trans->description);
$this->assertSame(-1000, $trans->amount);
$this->assertSame(\App\Transaction::WALLET_DEBIT, $trans->type);
}
/**
* Test handling positive balance on user deletion
*/
public function testDeleteWithPositiveBalance(): void
{
$user = $this->getTestUser('user-test@' . \config('app.domain'));
$wallet = $user->wallets()->first();
$wallet->balance = 1000;
$wallet->save();
$reseller_wallet = $user->tenant->wallet();
$reseller_wallet->balance = 0;
$reseller_wallet->save();
$user->delete();
$this->assertSame(0, $reseller_wallet->fresh()->balance);
}
/**
* Test user deletion with PGP/WOAT enabled
*/
public function testDeleteWithPGP(): void
{
Queue::fake();
// Test with PGP disabled
$user = $this->getTestUser('user-test@' . \config('app.domain'));
$user->tenant->setSetting('pgp.enable', 0);
$user->delete();
Queue::assertPushed(\App\Jobs\PGP\KeyDeleteJob::class, 0);
// Test with PGP enabled
$this->deleteTestUser('user-test@' . \config('app.domain'));
$user = $this->getTestUser('user-test@' . \config('app.domain'));
$user->tenant->setSetting('pgp.enable', 1);
$user->delete();
$user->tenant->setSetting('pgp.enable', 0);
Queue::assertPushed(\App\Jobs\PGP\KeyDeleteJob::class, 1);
Queue::assertPushed(
\App\Jobs\PGP\KeyDeleteJob::class,
function ($job) use ($user) {
$userId = TestCase::getObjectProperty($job, 'userId');
$userEmail = TestCase::getObjectProperty($job, 'userEmail');
return $userId == $user->id && $userEmail === $user->email;
}
);
}
+ /**
+ * Test user deletion vs. rooms
+ */
+ public function testDeleteWithRooms(): void
+ {
+ $this->markTestIncomplete();
+ }
+
/**
* Tests for User::aliasExists()
*/
public function testAliasExists(): void
{
$this->assertTrue(User::aliasExists('jack.daniels@kolab.org'));
$this->assertFalse(User::aliasExists('j.daniels@kolab.org'));
$this->assertFalse(User::aliasExists('john@kolab.org'));
}
/**
* Tests for User::emailExists()
*/
public function testEmailExists(): void
{
$this->assertFalse(User::emailExists('jack.daniels@kolab.org'));
$this->assertFalse(User::emailExists('j.daniels@kolab.org'));
$this->assertTrue(User::emailExists('john@kolab.org'));
$user = User::emailExists('john@kolab.org', true);
$this->assertSame('john@kolab.org', $user->email);
}
/**
* Tests for User::findByEmail()
*/
public function testFindByEmail(): void
{
$user = $this->getTestUser('john@kolab.org');
$result = User::findByEmail('john');
$this->assertNull($result);
$result = User::findByEmail('non-existing@email.com');
$this->assertNull($result);
$result = User::findByEmail('john@kolab.org');
$this->assertInstanceOf(User::class, $result);
$this->assertSame($user->id, $result->id);
// Use an alias
$result = User::findByEmail('john.doe@kolab.org');
$this->assertInstanceOf(User::class, $result);
$this->assertSame($user->id, $result->id);
Queue::fake();
// A case where two users have the same alias
$ned = $this->getTestUser('ned@kolab.org');
$ned->setAliases(['joe.monster@kolab.org']);
$result = User::findByEmail('joe.monster@kolab.org');
$this->assertNull($result);
$ned->setAliases([]);
// TODO: searching by external email (setting)
$this->markTestIncomplete();
}
/**
* Test User::hasSku() method
*/
public function testHasSku(): void
{
$john = $this->getTestUser('john@kolab.org');
$this->assertTrue($john->hasSku('mailbox'));
$this->assertTrue($john->hasSku('storage'));
$this->assertFalse($john->hasSku('beta'));
$this->assertFalse($john->hasSku('unknown'));
}
/**
* Test User::name()
*/
public function testName(): void
{
Queue::fake();
$user = $this->getTestUser('user-test@' . \config('app.domain'));
$this->assertSame('', $user->name());
$this->assertSame($user->tenant->title . ' User', $user->name(true));
$user->setSetting('first_name', 'First');
$this->assertSame('First', $user->name());
$this->assertSame('First', $user->name(true));
$user->setSetting('last_name', 'Last');
$this->assertSame('First Last', $user->name());
$this->assertSame('First Last', $user->name(true));
}
/**
* Test resources() method
*/
public function testResources(): void
{
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$resources = $john->resources()->orderBy('email')->get();
$this->assertSame(2, $resources->count());
$this->assertSame('resource-test1@kolab.org', $resources[0]->email);
$this->assertSame('resource-test2@kolab.org', $resources[1]->email);
$resources = $ned->resources()->orderBy('email')->get();
$this->assertSame(2, $resources->count());
$this->assertSame('resource-test1@kolab.org', $resources[0]->email);
$this->assertSame('resource-test2@kolab.org', $resources[1]->email);
$resources = $jack->resources()->get();
$this->assertSame(0, $resources->count());
}
/**
* Test 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
{
Queue::fake();
// Test an account with users and domain
$userA = $this->getTestUser('UserAccountA@UserAccount.com', [
'status' => User::STATUS_LDAP_READY | User::STATUS_IMAP_READY | User::STATUS_SUSPENDED,
]);
$userB = $this->getTestUser('UserAccountB@UserAccount.com');
$package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first();
$package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$domainA = $this->getTestDomain('UserAccount.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_HOSTED,
]);
$domainB = $this->getTestDomain('UserAccountAdd.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_HOSTED,
]);
$userA->assignPackage($package_kolab);
$domainA->assignPackage($package_domain, $userA);
$domainB->assignPackage($package_domain, $userA);
$userA->assignPackage($package_kolab, $userB);
$storage_sku = \App\Sku::withEnvTenantContext()->where('title', 'storage')->first();
$now = \Carbon\Carbon::now();
$wallet_id = $userA->wallets->first()->id;
// add an extra storage entitlement
$ent1 = \App\Entitlement::create([
'wallet_id' => $wallet_id,
'sku_id' => $storage_sku->id,
'cost' => 0,
'entitleable_id' => $userA->id,
'entitleable_type' => User::class,
]);
$entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id);
$entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id);
$entitlementsDomain = \App\Entitlement::where('entitleable_id', $domainA->id);
// First delete the user
$userA->delete();
$this->assertSame(0, $entitlementsA->count());
$this->assertSame(0, $entitlementsB->count());
$this->assertSame(0, $entitlementsDomain->count());
$this->assertTrue($userA->fresh()->trashed());
$this->assertTrue($userB->fresh()->trashed());
$this->assertTrue($domainA->fresh()->trashed());
$this->assertTrue($domainB->fresh()->trashed());
$this->assertFalse($userA->isDeleted());
$this->assertFalse($userB->isDeleted());
$this->assertFalse($domainA->isDeleted());
// Backdate one storage entitlement (it's not expected to be restored)
\App\Entitlement::withTrashed()->where('id', $ent1->id)
->update(['deleted_at' => $now->copy()->subMinutes(2)]);
// Backdate entitlements to assert that they were restored with proper updated_at timestamp
\App\Entitlement::withTrashed()->where('wallet_id', $wallet_id)
->update(['updated_at' => $now->subMinutes(10)]);
Queue::fake();
// Then restore it
$userA->restore();
$userA->refresh();
$this->assertFalse($userA->trashed());
$this->assertFalse($userA->isDeleted());
$this->assertFalse($userA->isSuspended());
$this->assertFalse($userA->isLdapReady());
$this->assertFalse($userA->isImapReady());
$this->assertTrue($userA->isActive());
$this->assertTrue($userB->fresh()->trashed());
$this->assertTrue($domainB->fresh()->trashed());
$this->assertFalse($domainA->fresh()->trashed());
// Assert entitlements
$this->assertSame(7, $entitlementsA->count()); // mailbox + groupware + 5 x storage
$this->assertTrue($ent1->fresh()->trashed());
$entitlementsA->get()->each(function ($ent) {
$this->assertTrue($ent->updated_at->greaterThan(\Carbon\Carbon::now()->subSeconds(5)));
});
// We expect only CreateJob + UpdateJob pair for both user and domain.
// Because how Illuminate/Database/Eloquent/SoftDeletes::restore() method
// is implemented we cannot skip the UpdateJob in any way.
// I don't want to overwrite this method, the extra job shouldn't do any harm.
$this->assertCount(4, Queue::pushedJobs()); // @phpstan-ignore-line
Queue::assertPushed(\App\Jobs\Domain\UpdateJob::class, 1);
Queue::assertPushed(\App\Jobs\Domain\CreateJob::class, 1);
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1);
Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1);
Queue::assertPushed(
\App\Jobs\User\CreateJob::class,
function ($job) use ($userA) {
return $userA->id === TestCase::getObjectProperty($job, 'userId');
}
);
Queue::assertPushedWithChain(
\App\Jobs\User\CreateJob::class,
[
\App\Jobs\User\VerifyJob::class,
]
);
}
/**
* Tests for AliasesTrait::setAliases()
*/
public function testSetAliases(): void
{
Queue::fake();
Queue::assertNothingPushed();
$user = $this->getTestUser('UserAccountA@UserAccount.com');
$domain = $this->getTestDomain('UserAccount.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_HOSTED,
]);
$this->assertCount(0, $user->aliases->all());
$user->tenant->setSetting('pgp.enable', 1);
// Add an alias
$user->setAliases(['UserAlias1@UserAccount.com']);
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1);
Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1);
$user->tenant->setSetting('pgp.enable', 0);
$aliases = $user->aliases()->get();
$this->assertCount(1, $aliases);
$this->assertSame('useralias1@useraccount.com', $aliases[0]['alias']);
// Add another alias
$user->setAliases(['UserAlias1@UserAccount.com', 'UserAlias2@UserAccount.com']);
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 2);
Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1);
$aliases = $user->aliases()->orderBy('alias')->get();
$this->assertCount(2, $aliases);
$this->assertSame('useralias1@useraccount.com', $aliases[0]->alias);
$this->assertSame('useralias2@useraccount.com', $aliases[1]->alias);
$user->tenant->setSetting('pgp.enable', 1);
// Remove an alias
$user->setAliases(['UserAlias1@UserAccount.com']);
$user->tenant->setSetting('pgp.enable', 0);
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 3);
Queue::assertPushed(\App\Jobs\PGP\KeyDeleteJob::class, 1);
Queue::assertPushed(
\App\Jobs\PGP\KeyDeleteJob::class,
function ($job) use ($user) {
$userId = TestCase::getObjectProperty($job, 'userId');
$userEmail = TestCase::getObjectProperty($job, 'userEmail');
return $userId == $user->id && $userEmail === 'useralias2@useraccount.com';
}
);
$aliases = $user->aliases()->get();
$this->assertCount(1, $aliases);
$this->assertSame('useralias1@useraccount.com', $aliases[0]['alias']);
// Remove all aliases
$user->setAliases([]);
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 4);
$this->assertCount(0, $user->aliases()->get());
}
/**
* Tests for UserSettingsTrait::setSettings() and getSetting() and getSettings()
*/
public function testUserSettings(): void
{
Queue::fake();
Queue::assertNothingPushed();
$user = $this->getTestUser('UserAccountA@UserAccount.com');
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 0);
// Test default settings
// Note: Technicly this tests UserObserver::created() behavior
$all_settings = $user->settings()->orderBy('key')->get();
$this->assertCount(2, $all_settings);
$this->assertSame('country', $all_settings[0]->key);
$this->assertSame('CH', $all_settings[0]->value);
$this->assertSame('currency', $all_settings[1]->key);
$this->assertSame('CHF', $all_settings[1]->value);
// Add a setting
$user->setSetting('first_name', 'Firstname');
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1);
// Note: We test both current user as well as fresh user object
// to make sure cache works as expected
$this->assertSame('Firstname', $user->getSetting('first_name'));
$this->assertSame('Firstname', $user->fresh()->getSetting('first_name'));
// Update a setting
$user->setSetting('first_name', 'Firstname1');
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 2);
// Note: We test both current user as well as fresh user object
// to make sure cache works as expected
$this->assertSame('Firstname1', $user->getSetting('first_name'));
$this->assertSame('Firstname1', $user->fresh()->getSetting('first_name'));
// Delete a setting (null)
$user->setSetting('first_name', null);
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 3);
// Note: We test both current user as well as fresh user object
// to make sure cache works as expected
$this->assertSame(null, $user->getSetting('first_name'));
$this->assertSame(null, $user->fresh()->getSetting('first_name'));
// Delete a setting (empty string)
$user->setSetting('first_name', 'Firstname1');
$user->setSetting('first_name', '');
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 5);
// Note: We test both current user as well as fresh user object
// to make sure cache works as expected
$this->assertSame(null, $user->getSetting('first_name'));
$this->assertSame(null, $user->fresh()->getSetting('first_name'));
// Set multiple settings at once
$user->setSettings([
'first_name' => 'Firstname2',
'last_name' => 'Lastname2',
'country' => null,
]);
// TODO: This really should create a single UserUpdate job, not 3
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 7);
// Note: We test both current user as well as fresh user object
// to make sure cache works as expected
$this->assertSame('Firstname2', $user->getSetting('first_name'));
$this->assertSame('Firstname2', $user->fresh()->getSetting('first_name'));
$this->assertSame('Lastname2', $user->getSetting('last_name'));
$this->assertSame('Lastname2', $user->fresh()->getSetting('last_name'));
$this->assertSame(null, $user->getSetting('country'));
$this->assertSame(null, $user->fresh()->getSetting('country'));
$all_settings = $user->settings()->orderBy('key')->get();
$this->assertCount(3, $all_settings);
// Test getSettings() method
$this->assertSame(
[
'first_name' => 'Firstname2',
'last_name' => 'Lastname2',
'unknown' => null,
],
$user->getSettings(['first_name', 'last_name', 'unknown'])
);
}
/**
* Tests for User::users()
*/
public function testUsers(): void
{
$jack = $this->getTestUser('jack@kolab.org');
$joe = $this->getTestUser('joe@kolab.org');
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$wallet = $john->wallets()->first();
$users = $john->users()->orderBy('email')->get();
$this->assertCount(4, $users);
$this->assertEquals($jack->id, $users[0]->id);
$this->assertEquals($joe->id, $users[1]->id);
$this->assertEquals($john->id, $users[2]->id);
$this->assertEquals($ned->id, $users[3]->id);
$users = $jack->users()->orderBy('email')->get();
$this->assertCount(0, $users);
$users = $ned->users()->orderBy('email')->get();
$this->assertCount(4, $users);
}
/**
* Tests for User::walletOwner() (from EntitleableTrait)
*/
public function testWalletOwner(): void
{
$jack = $this->getTestUser('jack@kolab.org');
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$this->assertSame($john->id, $john->walletOwner()->id);
$this->assertSame($john->id, $jack->walletOwner()->id);
$this->assertSame($john->id, $ned->walletOwner()->id);
// User with no entitlements
$user = $this->getTestUser('UserAccountA@UserAccount.com');
$this->assertSame($user->id, $user->walletOwner()->id);
}
/**
* Tests for User::wallets()
*/
public function testWallets(): void
{
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$this->assertSame(1, $john->wallets()->count());
$this->assertCount(1, $john->wallets);
$this->assertInstanceOf(\App\Wallet::class, $john->wallets->first());
$this->assertSame(1, $ned->wallets()->count());
$this->assertCount(1, $ned->wallets);
$this->assertInstanceOf(\App\Wallet::class, $ned->wallets->first());
}
}
diff --git a/src/tests/Feature/WalletTest.php b/src/tests/Feature/WalletTest.php
index 5a3f2542..cc87aedb 100644
--- a/src/tests/Feature/WalletTest.php
+++ b/src/tests/Feature/WalletTest.php
@@ -1,431 +1,447 @@
<?php
namespace Tests\Feature;
use App\Package;
use App\User;
use App\Sku;
use App\Transaction;
use App\Wallet;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class WalletTest extends TestCase
{
private $users = [
'UserWallet1@UserWallet.com',
'UserWallet2@UserWallet.com',
'UserWallet3@UserWallet.com',
'UserWallet4@UserWallet.com',
'UserWallet5@UserWallet.com',
'WalletControllerA@WalletController.com',
'WalletControllerB@WalletController.com',
'WalletController2A@WalletController.com',
'WalletController2B@WalletController.com',
'jane@kolabnow.com'
];
public function setUp(): void
{
parent::setUp();
foreach ($this->users as $user) {
$this->deleteTestUser($user);
}
}
public function tearDown(): void
{
foreach ($this->users as $user) {
$this->deleteTestUser($user);
}
Sku::select()->update(['fee' => 0]);
parent::tearDown();
}
/**
* Test that turning wallet balance from negative to positive
* unsuspends and undegrades the account
*/
public function testBalanceTurnsPositive(): void
{
Queue::fake();
$user = $this->getTestUser('UserWallet1@UserWallet.com');
$user->suspend();
$user->degrade();
$wallet = $user->wallets()->first();
$wallet->balance = -100;
$wallet->save();
$this->assertTrue($user->isSuspended());
$this->assertTrue($user->isDegraded());
$this->assertNotNull($wallet->getSetting('balance_negative_since'));
$wallet->balance = 100;
$wallet->save();
$user->refresh();
$this->assertFalse($user->isSuspended());
$this->assertFalse($user->isDegraded());
$this->assertNull($wallet->getSetting('balance_negative_since'));
// TODO: Test group account and unsuspending domain/members/groups
}
/**
* Test for Wallet::balanceLastsUntil()
*/
public function testBalanceLastsUntil(): void
{
// Monthly cost of all entitlements: 990
// 28 days: 35.36 per day
// 31 days: 31.93 per day
$user = $this->getTestUser('jane@kolabnow.com');
$package = Package::withEnvTenantContext()->where('title', 'kolab')->first();
$user->assignPackage($package);
$wallet = $user->wallets()->first();
// User/entitlements created today, balance=0
$until = $wallet->balanceLastsUntil();
$this->assertSame(
Carbon::now()->addMonthsWithoutOverflow(1)->toDateString(),
$until->toDateString()
);
// User/entitlements created today, balance=-10 CHF
$wallet->balance = -1000;
$until = $wallet->balanceLastsUntil();
$this->assertSame(null, $until);
// User/entitlements created today, balance=-9,99 CHF (monthly cost)
$wallet->balance = 990;
$until = $wallet->balanceLastsUntil();
$daysInLastMonth = \App\Utils::daysInLastMonth();
$delta = Carbon::now()->addMonthsWithoutOverflow(1)->addDays($daysInLastMonth)->diff($until)->days;
$this->assertTrue($delta <= 1);
$this->assertTrue($delta >= -1);
// Old entitlements, 100% discount
$this->backdateEntitlements($wallet->entitlements, Carbon::now()->subDays(40));
$discount = \App\Discount::withEnvTenantContext()->where('discount', 100)->first();
$wallet->discount()->associate($discount);
$until = $wallet->refresh()->balanceLastsUntil();
$this->assertSame(null, $until);
// User with no entitlements
$wallet->discount()->dissociate($discount);
$wallet->entitlements()->delete();
$until = $wallet->refresh()->balanceLastsUntil();
$this->assertSame(null, $until);
}
/**
* Test for Wallet::costsPerDay()
*/
public function testCostsPerDay(): void
{
// 990
// 28 days: 35.36
// 31 days: 31.93
$user = $this->getTestUser('jane@kolabnow.com');
$package = Package::withEnvTenantContext()->where('title', 'kolab')->first();
$mailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first();
$user->assignPackage($package);
$wallet = $user->wallets()->first();
$costsPerDay = $wallet->costsPerDay();
$this->assertTrue($costsPerDay < 35.38);
$this->assertTrue($costsPerDay > 31.93);
}
/**
* Verify a wallet is created, when a user is created.
*/
public function testCreateUserCreatesWallet(): void
{
$user = $this->getTestUser('UserWallet1@UserWallet.com');
$this->assertCount(1, $user->wallets);
$this->assertSame(\config('app.currency'), $user->wallets[0]->currency);
$this->assertSame(0, $user->wallets[0]->balance);
}
/**
* Verify a user can haz more wallets.
*/
public function testAddWallet(): void
{
$user = $this->getTestUser('UserWallet2@UserWallet.com');
$user->wallets()->save(
new Wallet(['currency' => 'USD'])
);
$this->assertCount(2, $user->wallets);
$user->wallets()->each(
function ($wallet) {
$this->assertEquals(0, $wallet->balance);
}
);
// For now all wallets use system currency
$this->assertFalse($user->wallets()->where('currency', 'USD')->exists());
}
/**
* Verify we can not delete a user wallet that holds balance.
*/
public function testDeleteWalletWithCredit(): void
{
$user = $this->getTestUser('UserWallet3@UserWallet.com');
$user->wallets()->each(
function ($wallet) {
$wallet->credit(100)->save();
}
);
$user->wallets()->each(
function ($wallet) {
$this->assertFalse($wallet->delete());
}
);
}
/**
* Verify we can not delete a wallet that is the last wallet.
*/
public function testDeleteLastWallet(): void
{
$user = $this->getTestUser('UserWallet4@UserWallet.com');
$this->assertCount(1, $user->wallets);
$user->wallets()->each(
function ($wallet) {
$this->assertFalse($wallet->delete());
}
);
}
/**
* Verify we can remove a wallet that is an additional wallet.
*/
public function testDeleteAddtWallet(): void
{
$user = $this->getTestUser('UserWallet5@UserWallet.com');
$user->wallets()->save(
new Wallet(['currency' => 'USD'])
);
// For now additional wallets with a different currency is not allowed
$this->assertFalse($user->wallets()->where('currency', 'USD')->exists());
/*
$user->wallets()->each(
function ($wallet) {
if ($wallet->currency == 'USD') {
$this->assertNotFalse($wallet->delete());
}
}
);
*/
}
/**
* Verify a wallet can be assigned a controller.
*/
public function testAddWalletController(): void
{
$userA = $this->getTestUser('WalletControllerA@WalletController.com');
$userB = $this->getTestUser('WalletControllerB@WalletController.com');
$userA->wallets()->each(
function ($wallet) use ($userB) {
$wallet->addController($userB);
}
);
$this->assertCount(1, $userB->accounts);
$aWallet = $userA->wallets()->first();
$bAccount = $userB->accounts()->first();
$this->assertTrue($bAccount->id === $aWallet->id);
}
+ /**
+ * Test Wallet::isController()
+ */
+ public function testIsController(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $ned = $this->getTestUser('ned@kolab.org');
+
+ $wallet = $jack->wallet();
+
+ $this->assertTrue($wallet->isController($john));
+ $this->assertTrue($wallet->isController($ned));
+ $this->assertFalse($wallet->isController($jack));
+ }
+
/**
* Verify controllers can also be removed from wallets.
*/
public function testRemoveWalletController(): void
{
$userA = $this->getTestUser('WalletController2A@WalletController.com');
$userB = $this->getTestUser('WalletController2B@WalletController.com');
$userA->wallets()->each(
function ($wallet) use ($userB) {
$wallet->addController($userB);
}
);
$userB->refresh();
$userB->accounts()->each(
function ($wallet) use ($userB) {
$wallet->removeController($userB);
}
);
$this->assertCount(0, $userB->accounts);
}
/**
* Test for charging and removing entitlements (including tenant commission calculations)
*/
public function testChargeAndDeleteEntitlements(): void
{
$user = $this->getTestUser('jane@kolabnow.com');
$wallet = $user->wallets()->first();
$discount = \App\Discount::withEnvTenantContext()->where('discount', 30)->first();
$wallet->discount()->associate($discount);
$wallet->save();
// Add 40% fee to all SKUs
Sku::select()->update(['fee' => DB::raw("`cost` * 0.4")]);
$package = Package::withEnvTenantContext()->where('title', 'kolab')->first();
$storage = Sku::withEnvTenantContext()->where('title', 'storage')->first();
$user->assignPackage($package);
$user->assignSku($storage, 5);
$user->refresh();
// Reset reseller's wallet balance and transactions
$reseller_wallet = $user->tenant->wallet();
$reseller_wallet->balance = 0;
$reseller_wallet->save();
Transaction::where('object_id', $reseller_wallet->id)->where('object_type', \App\Wallet::class)->delete();
// ------------------------------------
// Test normal charging of entitlements
// ------------------------------------
// Backdate and charge entitlements, we're expecting one month to be charged
// Set fake NOW date to make simpler asserting results that depend on number of days in current/last month
Carbon::setTestNow(Carbon::create(2021, 5, 21, 12));
$backdate = Carbon::now()->subWeeks(7);
$this->backdateEntitlements($user->entitlements, $backdate);
$charge = $wallet->chargeEntitlements();
$wallet->refresh();
$reseller_wallet->refresh();
// TODO: Update these comments with what is actually being used to calculate these numbers
// 388 + 310 + 17 + 17 = 732
$this->assertSame(-778, $wallet->balance);
// 388 - 555 x 40% + 310 - 444 x 40% + 34 - 50 x 40% = 312
$this->assertSame(332, $reseller_wallet->balance);
$transactions = Transaction::where('object_id', $wallet->id)
->where('object_type', \App\Wallet::class)->get();
$reseller_transactions = Transaction::where('object_id', $reseller_wallet->id)
->where('object_type', \App\Wallet::class)->get();
$this->assertCount(1, $reseller_transactions);
$trans = $reseller_transactions[0];
$this->assertSame("Charged user jane@kolabnow.com", $trans->description);
$this->assertSame(332, $trans->amount);
$this->assertSame(Transaction::WALLET_CREDIT, $trans->type);
$this->assertCount(1, $transactions);
$trans = $transactions[0];
$this->assertSame('', $trans->description);
$this->assertSame(-778, $trans->amount);
$this->assertSame(Transaction::WALLET_DEBIT, $trans->type);
// TODO: Test entitlement transaction records
// -----------------------------------
// Test charging on entitlement delete
// -----------------------------------
$reseller_wallet->balance = 0;
$reseller_wallet->save();
$transactions = Transaction::where('object_id', $wallet->id)
->where('object_type', \App\Wallet::class)->delete();
$reseller_transactions = Transaction::where('object_id', $reseller_wallet->id)
->where('object_type', \App\Wallet::class)->delete();
$user->removeSku($storage, 2);
// we expect the wallet to have been charged for 19 days of use of
// 2 deleted storage entitlements
$wallet->refresh();
$reseller_wallet->refresh();
// 2 x round(25 / 31 * 19 * 0.7) = 22
$this->assertSame(-(778 + 22), $wallet->balance);
// 22 - 2 x round(25 * 0.4 / 31 * 19) = 10
$this->assertSame(10, $reseller_wallet->balance);
$transactions = Transaction::where('object_id', $wallet->id)
->where('object_type', \App\Wallet::class)->get();
$reseller_transactions = Transaction::where('object_id', $reseller_wallet->id)
->where('object_type', \App\Wallet::class)->get();
$this->assertCount(2, $reseller_transactions);
$trans = $reseller_transactions[0];
$this->assertSame("Charged user jane@kolabnow.com", $trans->description);
$this->assertSame(5, $trans->amount);
$this->assertSame(Transaction::WALLET_CREDIT, $trans->type);
$trans = $reseller_transactions[1];
$this->assertSame("Charged user jane@kolabnow.com", $trans->description);
$this->assertSame(5, $trans->amount);
$this->assertSame(Transaction::WALLET_CREDIT, $trans->type);
$this->assertCount(2, $transactions);
$trans = $transactions[0];
$this->assertSame('', $trans->description);
$this->assertSame(-11, $trans->amount);
$this->assertSame(Transaction::WALLET_DEBIT, $trans->type);
$trans = $transactions[1];
$this->assertSame('', $trans->description);
$this->assertSame(-11, $trans->amount);
$this->assertSame(Transaction::WALLET_DEBIT, $trans->type);
// TODO: Test entitlement transaction records
}
/**
* Tests for updateEntitlements()
*/
public function testUpdateEntitlements(): void
{
$this->markTestIncomplete();
}
}
diff --git a/src/tests/TestCase.php b/src/tests/TestCase.php
index af598b98..e2ead157 100644
--- a/src/tests/TestCase.php
+++ b/src/tests/TestCase.php
@@ -1,95 +1,94 @@
<?php
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Routing\Middleware\ThrottleRequests;
abstract class TestCase extends BaseTestCase
{
use TestCaseTrait;
- use TestCaseMeetTrait;
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
// Disable throttling
$this->withoutMiddleware(ThrottleRequests::class);
}
/**
* Set baseURL to the regular UI location
*/
protected static function useRegularUrl(): void
{
// This will set base URL for all tests in a file.
// If we wanted to access both user and admin in one test
// we can also just call post/get/whatever with full url
\config(
[
'app.url' => str_replace(
['//admin.', '//reseller.', '//services.'],
['//', '//', '//'],
\config('app.url')
)
]
);
url()->forceRootUrl(config('app.url'));
}
/**
* Set baseURL to the admin UI location
*/
protected static function useAdminUrl(): void
{
// This will set base URL for all tests in a file.
// If we wanted to access both user and admin in one test
// we can also just call post/get/whatever with full url
// reset to base
self::useRegularUrl();
// then modify it
\config(['app.url' => str_replace('//', '//admin.', \config('app.url'))]);
url()->forceRootUrl(config('app.url'));
}
/**
* Set baseURL to the reseller UI location
*/
protected static function useResellerUrl(): void
{
// This will set base URL for all tests in a file.
// If we wanted to access both user and admin in one test
// we can also just call post/get/whatever with full url
// reset to base
self::useRegularUrl();
// then modify it
\config(['app.url' => str_replace('//', '//reseller.', \config('app.url'))]);
url()->forceRootUrl(config('app.url'));
}
/**
* Set baseURL to the services location
*/
protected static function useServicesUrl(): void
{
// This will set base URL for all tests in a file.
// If we wanted to access both user and admin in one test
// we can also just call post/get/whatever with full url
// reset to base
self::useRegularUrl();
// then modify it
\config(['app.url' => str_replace('//', '//services.', \config('app.url'))]);
url()->forceRootUrl(config('app.url'));
}
}
diff --git a/src/tests/TestCaseDusk.php b/src/tests/TestCaseDusk.php
index f0c3337f..79ca403c 100644
--- a/src/tests/TestCaseDusk.php
+++ b/src/tests/TestCaseDusk.php
@@ -1,117 +1,116 @@
<?php
namespace Tests;
use Facebook\WebDriver\Chrome\ChromeOptions;
use Facebook\WebDriver\Remote\RemoteWebDriver;
use Facebook\WebDriver\Remote\DesiredCapabilities;
use Laravel\Dusk\TestCase as BaseTestCase;
abstract class TestCaseDusk extends BaseTestCase
{
use TestCaseTrait;
- use TestCaseMeetTrait;
/**
* Prepare for Dusk test execution.
*
* @beforeClass
* @return void
*/
public static function prepare()
{
static::startChromeDriver();
}
/**
* Create the RemoteWebDriver instance.
*
* @return \Facebook\WebDriver\Remote\RemoteWebDriver
*/
protected function driver()
{
$options = (new ChromeOptions())->addArguments([
'--lang=en_US',
'--disable-gpu',
'--headless',
'--no-sandbox',
'--use-fake-ui-for-media-stream',
'--use-fake-device-for-media-stream',
'--enable-usermedia-screen-capturing',
// '--auto-select-desktop-capture-source="Entire screen"',
'--ignore-certificate-errors',
'--incognito',
]);
// For file download handling
$prefs = [
'profile.default_content_settings.popups' => 0,
'download.default_directory' => __DIR__ . '/Browser/downloads',
];
$options->setExperimentalOption('prefs', $prefs);
if (getenv('TESTS_MODE') == 'phone') {
// Fake User-Agent string for mobile mode
$ua = 'Mozilla/5.0 (Linux; Android 4.0.4; Galaxy Nexus Build/IMM76B) AppleWebKit/537.36'
. ' (KHTML, like Gecko) Chrome/60.0.3112.90 Mobile Safari/537.36';
$options->setExperimentalOption('mobileEmulation', ['userAgent' => $ua]);
$options->addArguments(['--window-size=375,667']);
} elseif (getenv('TESTS_MODE') == 'tablet') {
// Fake User-Agent string for mobile mode
$ua = 'Mozilla/5.0 (Linux; Android 6.0.1; vivo 1603 Build/MMB29M) AppleWebKit/537.36 '
. ' (KHTML, like Gecko) Chrome/58.0.3029.83 Mobile Safari/537.36';
$options->setExperimentalOption('mobileEmulation', ['userAgent' => $ua]);
$options->addArguments(['--window-size=800,640']);
} else {
$options->addArguments(['--window-size=1280,1024']);
}
// Make sure downloads dir exists and is empty
if (!file_exists(__DIR__ . '/Browser/downloads')) {
mkdir(__DIR__ . '/Browser/downloads', 0777, true);
} else {
foreach (glob(__DIR__ . '/Browser/downloads/*') as $file) {
@unlink($file);
}
}
return RemoteWebDriver::create(
'http://localhost:9515',
DesiredCapabilities::chrome()->setCapability(
ChromeOptions::CAPABILITY,
$options
)
);
}
/**
* Replace Dusk's Browser with our (extended) Browser
*/
protected function newBrowser($driver)
{
return new Browser($driver);
}
/**
* Set baseURL to the admin UI location
*/
protected static function useAdminUrl(): void
{
// This will set baseURL for all tests in this file
// If we wanted to visit both user and admin in one test
// we can also just call visit() with full url
Browser::$baseUrl = str_replace('//', '//admin.', \config('app.url'));
}
/**
* Set baseURL to the reseller UI location
*/
protected static function useResellerUrl(): void
{
// This will set baseURL for all tests in this file
// If we wanted to visit both user and admin in one test
// we can also just call visit() with full url
Browser::$baseUrl = str_replace('//', '//reseller.', \config('app.url'));
}
}
diff --git a/src/tests/TestCaseMeetTrait.php b/src/tests/TestCaseMeetTrait.php
deleted file mode 100644
index 2f4a2265..00000000
--- a/src/tests/TestCaseMeetTrait.php
+++ /dev/null
@@ -1,56 +0,0 @@
-<?php
-
-namespace Tests;
-
-use App\Meet\Room;
-
-trait TestCaseMeetTrait
-{
- /**
- * Assign 'meet' entitlement to a user.
- *
- * @param string|\App\User $user The user
- */
- protected function assignMeetEntitlement($user): void
- {
- if (is_string($user)) {
- $user = $this->getTestUser($user);
- }
-
- $user->assignSku(\App\Sku::where('title', 'meet')->first());
- }
-
- /**
- * Removes all 'meet' entitlements from the database
- */
- protected function clearMeetEntitlements(): void
- {
- $meet_sku = \App\Sku::where('title', 'meet')->first();
- \App\Entitlement::where('sku_id', $meet_sku->id)->delete();
- }
-
- /**
- * Reset a room after tests
- */
- public function resetTestRoom($room_name = 'john'): void
- {
- $this->clearMeetEntitlements();
-
- $room = Room::where('name', $room_name)->first();
- $room->setSettings(['password' => null, 'locked' => null, 'nomedia' => null]);
-
- if ($room->session_id) {
- $room->session_id = null;
- $room->save();
- }
- }
-
- /**
- * Prepare a room for testing
- */
- public function setupTestRoom($room_name = 'john'): void
- {
- $this->resetTestRoom($room_name);
- $this->assignMeetEntitlement('john@kolab.org');
- }
-}
diff --git a/src/tests/TestCaseTrait.php b/src/tests/TestCaseTrait.php
index d41bbd59..c0ae4778 100644
--- a/src/tests/TestCaseTrait.php
+++ b/src/tests/TestCaseTrait.php
@@ -1,694 +1,753 @@
<?php
namespace Tests;
use App\Backends\LDAP;
use App\CompanionApp;
use App\Domain;
use App\Group;
use App\Resource;
use App\SharedFolder;
use App\Sku;
use App\Transaction;
use App\User;
use Carbon\Carbon;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\Queue;
use PHPUnit\Framework\Assert;
trait TestCaseTrait
{
/**
* A domain that is hosted.
*
* @var ?\App\Domain
*/
protected $domainHosted;
/**
* The hosted domain owner.
*
* @var ?\App\User
*/
protected $domainOwner;
/**
* Some profile details for an owner of a domain
*
* @var array
*/
protected $domainOwnerSettings = [
'first_name' => 'John',
'last_name' => 'Doe',
'organization' => 'Test Domain Owner',
];
/**
* Some users for the hosted domain, ultimately including the owner.
*
* @var \App\User[]
*/
protected $domainUsers = [];
/**
* A specific user that is a regular user in the hosted domain.
*
* @var ?\App\User
*/
protected $jack;
/**
* A specific user that is a controller on the wallet to which the hosted domain is charged.
*
* @var ?\App\User
*/
protected $jane;
/**
* A specific user that has a second factor configured.
*
* @var ?\App\User
*/
protected $joe;
/**
* One of the domains that is available for public registration.
*
* @var ?\App\Domain
*/
protected $publicDomain;
/**
* A newly generated user in a public domain.
*
* @var ?\App\User
*/
protected $publicDomainUser;
/**
* A placeholder for a password that can be generated.
*
* Should be generated with `\App\Utils::generatePassphrase()`.
*
* @var ?string
*/
protected $userPassword;
/**
* Register the beta entitlement for a user
*/
protected function addBetaEntitlement($user, $titles = []): void
{
// Add beta + $title entitlements
$beta_sku = Sku::withEnvTenantContext()->where('title', 'beta')->first();
$user->assignSku($beta_sku);
if (!empty($titles)) {
Sku::withEnvTenantContext()->whereIn('title', (array) $titles)->get()
->each(function ($sku) use ($user) {
$user->assignSku($sku);
});
}
}
/**
* Assert that the entitlements for the user match the expected list of entitlements.
*
* @param \App\User|\App\Domain $object The object for which the entitlements need to be pulled.
* @param array $expected An array of expected \App\Sku titles.
*/
protected function assertEntitlements($object, $expected)
{
// Assert the user entitlements
$skus = $object->entitlements()->get()
->map(function ($ent) {
return $ent->sku->title;
})
->toArray();
sort($skus);
Assert::assertSame($expected, $skus);
}
/**
* Assert content of the SKU element in an API response
*
* @param string $sku_title The SKU title
* @param array $result The result to assert
* @param array $other Other items the SKU itself does not include
*/
protected function assertSkuElement($sku_title, $result, $other = []): void
{
$sku = Sku::withEnvTenantContext()->where('title', $sku_title)->first();
$this->assertSame($sku->id, $result['id']);
$this->assertSame($sku->title, $result['title']);
$this->assertSame($sku->name, $result['name']);
$this->assertSame($sku->description, $result['description']);
$this->assertSame($sku->cost, $result['cost']);
$this->assertSame($sku->units_free, $result['units_free']);
$this->assertSame($sku->period, $result['period']);
$this->assertSame($sku->active, $result['active']);
foreach ($other as $key => $value) {
$this->assertSame($value, $result[$key]);
}
$this->assertCount(8 + count($other), $result);
}
protected function backdateEntitlements($entitlements, $targetDate, $targetCreatedDate = null)
{
$wallets = [];
$ids = [];
foreach ($entitlements as $entitlement) {
$ids[] = $entitlement->id;
$wallets[] = $entitlement->wallet_id;
}
\App\Entitlement::whereIn('id', $ids)->update([
'created_at' => $targetCreatedDate ?: $targetDate,
'updated_at' => $targetDate,
]);
if (!empty($wallets)) {
$wallets = array_unique($wallets);
$owners = \App\Wallet::whereIn('id', $wallets)->pluck('user_id')->all();
\App\User::whereIn('id', $owners)->update([
'created_at' => $targetCreatedDate ?: $targetDate
]);
}
}
/**
* Removes all beta entitlements from the database
*/
protected function clearBetaEntitlements(): void
{
$beta_handlers = [
'App\Handlers\Beta',
];
$betas = Sku::whereIn('handler_class', $beta_handlers)->pluck('id')->all();
\App\Entitlement::whereIn('sku_id', $betas)->delete();
}
/**
* Creates the application.
*
* @return \Illuminate\Foundation\Application
*/
public function createApplication()
{
$app = require __DIR__ . '/../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
return $app;
}
/**
* Create a set of transaction log entries for a wallet
*/
protected function createTestTransactions($wallet)
{
$result = [];
$date = Carbon::now();
$debit = 0;
$entitlementTransactions = [];
foreach ($wallet->entitlements as $entitlement) {
if ($entitlement->cost) {
$debit += $entitlement->cost;
$entitlementTransactions[] = $entitlement->createTransaction(
Transaction::ENTITLEMENT_BILLED,
$entitlement->cost
);
}
}
$transaction = Transaction::create(
[
'user_email' => 'jeroen@jeroen.jeroen',
'object_id' => $wallet->id,
'object_type' => \App\Wallet::class,
'type' => Transaction::WALLET_DEBIT,
'amount' => $debit * -1,
'description' => 'Payment',
]
);
$result[] = $transaction;
Transaction::whereIn('id', $entitlementTransactions)->update(['transaction_id' => $transaction->id]);
$transaction = Transaction::create(
[
'user_email' => null,
'object_id' => $wallet->id,
'object_type' => \App\Wallet::class,
'type' => Transaction::WALLET_CREDIT,
'amount' => 2000,
'description' => 'Payment',
]
);
$transaction->created_at = $date->next(Carbon::MONDAY);
$transaction->save();
$result[] = $transaction;
$types = [
Transaction::WALLET_AWARD,
Transaction::WALLET_PENALTY,
];
// The page size is 10, so we generate so many to have at least two pages
$loops = 10;
while ($loops-- > 0) {
$type = $types[count($result) % count($types)];
$transaction = Transaction::create([
'user_email' => 'jeroen.@jeroen.jeroen',
'object_id' => $wallet->id,
'object_type' => \App\Wallet::class,
'type' => $type,
'amount' => 11 * (count($result) + 1) * ($type == Transaction::WALLET_PENALTY ? -1 : 1),
'description' => 'TRANS' . $loops,
]);
$transaction->created_at = $date->next(Carbon::MONDAY);
$transaction->save();
$result[] = $transaction;
}
return $result;
}
/**
* Delete a test domain whatever it takes.
*
* @coversNothing
*/
protected function deleteTestDomain($name)
{
Queue::fake();
$domain = Domain::withTrashed()->where('namespace', $name)->first();
if (!$domain) {
return;
}
$job = new \App\Jobs\Domain\DeleteJob($domain->id);
$job->handle();
$domain->forceDelete();
}
/**
* Delete a test group whatever it takes.
*
* @coversNothing
*/
protected function deleteTestGroup($email)
{
Queue::fake();
$group = Group::withTrashed()->where('email', $email)->first();
if (!$group) {
return;
}
LDAP::deleteGroup($group);
$group->forceDelete();
}
/**
* Delete a test resource whatever it takes.
*
* @coversNothing
*/
protected function deleteTestResource($email)
{
Queue::fake();
$resource = Resource::withTrashed()->where('email', $email)->first();
if (!$resource) {
return;
}
LDAP::deleteResource($resource);
$resource->forceDelete();
}
+ /**
+ * Delete a test room whatever it takes.
+ *
+ * @coversNothing
+ */
+ protected function deleteTestRoom($name)
+ {
+ Queue::fake();
+
+ $room = \App\Meet\Room::withTrashed()->where('name', $name)->first();
+
+ if (!$room) {
+ return;
+ }
+
+ $room->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
*/
protected function deleteTestUser($email)
{
Queue::fake();
$user = User::withTrashed()->where('email', $email)->first();
if (!$user) {
return;
}
LDAP::deleteUser($user);
$user->forceDelete();
}
/**
* Delete a test companion app whatever it takes.
*
* @coversNothing
*/
protected function deleteTestCompanionApp($deviceId)
{
Queue::fake();
$companionApp = CompanionApp::where('device_id', $deviceId)->first();
if (!$companionApp) {
return;
}
$companionApp->forceDelete();
}
/**
* Helper to access protected property of an object
*/
protected static function getObjectProperty($object, $property_name)
{
$reflection = new \ReflectionClass($object);
$property = $reflection->getProperty($property_name);
$property->setAccessible(true);
return $property->getValue($object);
}
/**
* Get Domain object by namespace, create it if needed.
* Skip LDAP jobs.
*
* @coversNothing
*/
protected function getTestDomain($name, $attrib = [])
{
// Disable jobs (i.e. skip LDAP oprations)
Queue::fake();
return Domain::firstOrCreate(['namespace' => $name], $attrib);
}
/**
* Get Group object by email, create it if needed.
* Skip LDAP jobs.
*/
protected function getTestGroup($email, $attrib = [])
{
// Disable jobs (i.e. skip LDAP oprations)
Queue::fake();
return Group::firstOrCreate(['email' => $email], $attrib);
}
/**
* Get Resource object by email, create it if needed.
* Skip LDAP jobs.
*/
protected function getTestResource($email, $attrib = [])
{
// Disable jobs (i.e. skip LDAP oprations)
Queue::fake();
$resource = Resource::where('email', $email)->first();
if (!$resource) {
list($local, $domain) = explode('@', $email, 2);
$resource = new Resource();
$resource->email = $email;
$resource->domainName = $domain;
if (!isset($attrib['name'])) {
$resource->name = $local;
}
}
foreach ($attrib as $key => $val) {
$resource->{$key} = $val;
}
$resource->save();
return $resource;
}
+ /**
+ * Get Room object by name, create it if needed.
+ *
+ * @coversNothing
+ */
+ protected function getTestRoom($name, $wallet = null, $attrib = [], $config = [], $title = null)
+ {
+ $attrib['name'] = $name;
+ $room = \App\Meet\Room::create($attrib);
+
+ if ($wallet) {
+ $room->assignToWallet($wallet, $title);
+ }
+
+ if (!empty($config)) {
+ $room->setConfig($config);
+ }
+
+ return $room;
+ }
+
/**
* 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->domainName = $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.
*
* @coversNothing
*/
protected function getTestUser($email, $attrib = [])
{
// Disable jobs (i.e. skip LDAP oprations)
Queue::fake();
$user = User::firstOrCreate(['email' => $email], $attrib);
if ($user->trashed()) {
// Note: we do not want to use user restore here
User::where('id', $user->id)->forceDelete();
$user = User::create(['email' => $email] + $attrib);
}
return $user;
}
/**
* Get CompanionApp object by deviceId, create it if needed.
* Skip LDAP jobs.
*
* @coversNothing
*/
protected function getTestCompanionApp($deviceId, $user, $attrib = [])
{
// Disable jobs (i.e. skip LDAP oprations)
Queue::fake();
$companionApp = CompanionApp::firstOrCreate(
[
'device_id' => $deviceId,
'user_id' => $user->id,
'notification_token' => '',
'mfa_enabled' => 1
],
$attrib
);
return $companionApp;
}
/**
* Call protected/private method of a class.
*
* @param object $object Instantiated object that we will run method on.
* @param string $methodName Method name to call
* @param array $parameters Array of parameters to pass into method.
*
* @return mixed Method return.
*/
protected function invokeMethod($object, $methodName, array $parameters = [])
{
$reflection = new \ReflectionClass(get_class($object));
$method = $reflection->getMethod($methodName);
$method->setAccessible(true);
return $method->invokeArgs($object, $parameters);
}
/**
* Extract content of an email message.
*
* @param \Illuminate\Mail\Mailable $mail Mailable object
*
* @return array Parsed message data:
* - 'plain': Plain text body
* - 'html: HTML body
* - 'subject': Mail subject
*/
protected function renderMail(\Illuminate\Mail\Mailable $mail): array
{
$mail->build(); // @phpstan-ignore-line
$result = $this->invokeMethod($mail, 'renderForAssertions');
return [
'plain' => $result[1],
'html' => $result[0],
'subject' => $mail->subject,
];
}
+ /**
+ * Reset a room after tests
+ */
+ public function resetTestRoom(string $room_name = 'john', $config = [])
+ {
+ $room = \App\Meet\Room::where('name', $room_name)->first();
+ $room->setSettings(['password' => null, 'locked' => null, 'nomedia' => null]);
+
+ if ($room->session_id) {
+ $room->session_id = null;
+ $room->save();
+ }
+
+ if (!empty($config)) {
+ $room->setConfig($config);
+ }
+
+ return $room;
+ }
+
protected function setUpTest()
{
$this->userPassword = \App\Utils::generatePassphrase();
$this->domainHosted = $this->getTestDomain(
'test.domain',
[
'type' => \App\Domain::TYPE_EXTERNAL,
'status' => \App\Domain::STATUS_ACTIVE | \App\Domain::STATUS_CONFIRMED | \App\Domain::STATUS_VERIFIED
]
);
$this->getTestDomain(
'test2.domain2',
[
'type' => \App\Domain::TYPE_EXTERNAL,
'status' => \App\Domain::STATUS_ACTIVE | \App\Domain::STATUS_CONFIRMED | \App\Domain::STATUS_VERIFIED
]
);
$packageKolab = \App\Package::where('title', 'kolab')->first();
$this->domainOwner = $this->getTestUser('john@test.domain', ['password' => $this->userPassword]);
$this->domainOwner->assignPackage($packageKolab);
$this->domainOwner->setSettings($this->domainOwnerSettings);
$this->domainOwner->setAliases(['alias1@test2.domain2']);
// separate for regular user
$this->jack = $this->getTestUser('jack@test.domain', ['password' => $this->userPassword]);
// separate for wallet controller
$this->jane = $this->getTestUser('jane@test.domain', ['password' => $this->userPassword]);
$this->joe = $this->getTestUser('joe@test.domain', ['password' => $this->userPassword]);
$this->domainUsers[] = $this->jack;
$this->domainUsers[] = $this->jane;
$this->domainUsers[] = $this->joe;
$this->domainUsers[] = $this->getTestUser('jill@test.domain', ['password' => $this->userPassword]);
foreach ($this->domainUsers as $user) {
$this->domainOwner->assignPackage($packageKolab, $user);
}
$this->domainUsers[] = $this->domainOwner;
// assign second factor to joe
$this->joe->assignSku(Sku::where('title', '2fa')->first());
\App\Auth\SecondFactor::seed($this->joe->email);
usort(
$this->domainUsers,
function ($a, $b) {
return $a->email > $b->email;
}
);
$this->domainHosted->assignPackage(
\App\Package::where('title', 'domain-hosting')->first(),
$this->domainOwner
);
$wallet = $this->domainOwner->wallets()->first();
$wallet->addController($this->jane);
$this->publicDomain = \App\Domain::where('type', \App\Domain::TYPE_PUBLIC)->first();
$this->publicDomainUser = $this->getTestUser(
'john@' . $this->publicDomain->namespace,
['password' => $this->userPassword]
);
$this->publicDomainUser->assignPackage($packageKolab);
}
public function tearDown(): void
{
foreach ($this->domainUsers as $user) {
if ($user == $this->domainOwner) {
continue;
}
$this->deleteTestUser($user->email);
}
if ($this->domainOwner) {
$this->deleteTestUser($this->domainOwner->email);
}
if ($this->domainHosted) {
$this->deleteTestDomain($this->domainHosted->namespace);
}
if ($this->publicDomainUser) {
$this->deleteTestUser($this->publicDomainUser->email);
}
parent::tearDown();
}
}
diff --git a/src/tests/Unit/TransactionTest.php b/src/tests/Unit/TransactionTest.php
index c0318809..aae5257c 100644
--- a/src/tests/Unit/TransactionTest.php
+++ b/src/tests/Unit/TransactionTest.php
@@ -1,205 +1,205 @@
<?php
namespace Tests\Unit;
use App\Entitlement;
use App\Sku;
use App\Transaction;
use App\Wallet;
use Tests\TestCase;
class TransactionTest extends TestCase
{
/**
* Test transaction short and long labels
*/
public function testLabels(): void
{
// Prepare test environment
Transaction::where('amount', '<', 20)->delete();
$user = $this->getTestUser('jane@kolabnow.com');
$wallet = $user->wallets()->first();
// Create transactions
$transaction = Transaction::create([
'object_id' => $wallet->id,
'object_type' => Wallet::class,
'type' => Transaction::WALLET_PENALTY,
'amount' => -10,
'description' => "A test penalty"
]);
$transaction = Transaction::create([
'object_id' => $wallet->id,
'object_type' => Wallet::class,
'type' => Transaction::WALLET_DEBIT,
'amount' => -9
]);
$transaction = Transaction::create([
'object_id' => $wallet->id,
'object_type' => Wallet::class,
'type' => Transaction::WALLET_CREDIT,
'amount' => 11
]);
$transaction = Transaction::create([
'object_id' => $wallet->id,
'object_type' => Wallet::class,
'type' => Transaction::WALLET_AWARD,
'amount' => 12,
'description' => "A test award"
]);
$sku = Sku::withEnvTenantContext()->where('title', 'mailbox')->first();
$entitlement = Entitlement::where('sku_id', $sku->id)->first();
$transaction = Transaction::create([
'user_email' => 'test@test.com',
'object_id' => $entitlement->id,
'object_type' => Entitlement::class,
'type' => Transaction::ENTITLEMENT_CREATED,
'amount' => 13
]);
$sku = Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$entitlement = Entitlement::where('sku_id', $sku->id)->first();
$transaction = Transaction::create([
'user_email' => 'test@test.com',
'object_id' => $entitlement->id,
'object_type' => Entitlement::class,
'type' => Transaction::ENTITLEMENT_BILLED,
'amount' => 14
]);
$sku = Sku::withEnvTenantContext()->where('title', 'storage')->first();
$entitlement = Entitlement::where('sku_id', $sku->id)->first();
$transaction = Transaction::create([
'user_email' => 'test@test.com',
'object_id' => $entitlement->id,
'object_type' => Entitlement::class,
'type' => Transaction::ENTITLEMENT_DELETED,
'amount' => 15
]);
$transactions = Transaction::where('amount', '<', 20)->orderBy('amount')->get();
$this->assertSame(-10, $transactions[0]->amount);
$this->assertSame(Transaction::WALLET_PENALTY, $transactions[0]->type);
$this->assertSame(
"The balance of Default wallet was reduced by 0,10 CHF; A test penalty",
$transactions[0]->toString()
);
$this->assertSame(
"Charge: A test penalty",
$transactions[0]->shortDescription()
);
$this->assertSame(-9, $transactions[1]->amount);
$this->assertSame(Transaction::WALLET_DEBIT, $transactions[1]->type);
$this->assertSame(
"0,09 CHF was deducted from the balance of Default wallet",
$transactions[1]->toString()
);
$this->assertSame(
"Deduction",
$transactions[1]->shortDescription()
);
$this->assertSame(11, $transactions[2]->amount);
$this->assertSame(Transaction::WALLET_CREDIT, $transactions[2]->type);
$this->assertSame(
"0,11 CHF was added to the balance of Default wallet",
$transactions[2]->toString()
);
$this->assertSame(
"Payment",
$transactions[2]->shortDescription()
);
$this->assertSame(12, $transactions[3]->amount);
$this->assertSame(Transaction::WALLET_AWARD, $transactions[3]->type);
$this->assertSame(
"Bonus of 0,12 CHF awarded to Default wallet; A test award",
$transactions[3]->toString()
);
$this->assertSame(
"Bonus: A test award",
$transactions[3]->shortDescription()
);
$ent = $transactions[4]->entitlement();
$this->assertSame(13, $transactions[4]->amount);
$this->assertSame(Transaction::ENTITLEMENT_CREATED, $transactions[4]->type);
$this->assertSame(
- "test@test.com created mailbox for " . $ent->entitleableTitle(),
+ "test@test.com created mailbox for " . $ent->entitleable->toString(),
$transactions[4]->toString()
);
$this->assertSame(
- "Added mailbox for " . $ent->entitleableTitle(),
+ "Added mailbox for " . $ent->entitleable->toString(),
$transactions[4]->shortDescription()
);
$ent = $transactions[5]->entitlement();
$this->assertSame(14, $transactions[5]->amount);
$this->assertSame(Transaction::ENTITLEMENT_BILLED, $transactions[5]->type);
$this->assertSame(
- sprintf("%s for %s is billed at 0,14 CHF", $ent->sku->title, $ent->entitleableTitle()),
+ sprintf("%s for %s is billed at 0,14 CHF", $ent->sku->title, $ent->entitleable->toString()),
$transactions[5]->toString()
);
$this->assertSame(
- sprintf("Billed %s for %s", $ent->sku->title, $ent->entitleableTitle()),
+ sprintf("Billed %s for %s", $ent->sku->title, $ent->entitleable->toString()),
$transactions[5]->shortDescription()
);
$ent = $transactions[6]->entitlement();
$this->assertSame(15, $transactions[6]->amount);
$this->assertSame(Transaction::ENTITLEMENT_DELETED, $transactions[6]->type);
$this->assertSame(
- sprintf("test@test.com deleted %s for %s", $ent->sku->title, $ent->entitleableTitle()),
+ sprintf("test@test.com deleted %s for %s", $ent->sku->title, $ent->entitleable->toString()),
$transactions[6]->toString()
);
$this->assertSame(
- sprintf("Deleted %s for %s", $ent->sku->title, $ent->entitleableTitle()),
+ sprintf("Deleted %s for %s", $ent->sku->title, $ent->entitleable->toString()),
$transactions[6]->shortDescription()
);
}
/**
* Test that an exception is being thrown on invalid type
*/
public function testInvalidType(): void
{
$this->expectException(\Exception::class);
$transaction = Transaction::create(
[
'object_id' => 'fake-id',
'object_type' => Wallet::class,
'type' => 'invalid',
'amount' => 9
]
);
}
public function testEntitlementForWallet(): void
{
$transaction = \App\Transaction::where('object_type', \App\Wallet::class)
->whereIn('object_id', \App\Wallet::pluck('id'))->first();
$entitlement = $transaction->entitlement();
$this->assertNull($entitlement);
$this->assertNotNull($transaction->wallet());
}
public function testWalletForEntitlement(): void
{
$transaction = \App\Transaction::where('object_type', \App\Entitlement::class)
->whereIn('object_id', \App\Entitlement::pluck('id'))->first();
$wallet = $transaction->wallet();
$this->assertNull($wallet);
$this->assertNotNull($transaction->entitlement());
}
}

File Metadata

Mime Type
text/x-diff
Expires
Sat, Jan 31, 3:33 PM (1 d, 5 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
426362
Default Alt Text
(860 KB)

Event Timeline