diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index 25de77d..591e1a8 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -6,7 +6,7 @@ jobs: run: uses: flarum/framework/.github/workflows/REUSABLE_backend.yml@1.x with: - enable_backend_testing: false + enable_backend_testing: true enable_phpstan: true backend_directory: . diff --git a/.gitignore b/.gitignore index befc0a7..1766afc 100755 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules composer.lock vendor +.phpunit.result.cache diff --git a/composer.json b/composer.json index 01c203a..31d9faf 100755 --- a/composer.json +++ b/composer.json @@ -19,7 +19,7 @@ } ], "require": { - "flarum/core": "^1.8.3" + "flarum/core": "^1.8.6" }, "authors": [ { @@ -51,19 +51,37 @@ }, "flarum-cli": { "modules": { - "githubActions": true + "githubActions": true, + "backendTesting": true } } }, "require-dev": { "flarum/phpstan": "*", - "flarum/tags": "*" + "flarum/tags": "*", + "flarum/testing": "^1.0.0" }, "scripts": { "analyse:phpstan": "phpstan analyse", - "clear-cache:phpstan": "phpstan clear-result-cache" + "clear-cache:phpstan": "phpstan clear-result-cache", + "test": [ + "@test:unit", + "@test:integration" + ], + "test:unit": "phpunit -c tests/phpunit.unit.xml", + "test:integration": "phpunit -c tests/phpunit.integration.xml", + "test:setup": "@php tests/integration/setup.php" }, "scripts-descriptions": { - "analyse:phpstan": "Run static analysis" + "analyse:phpstan": "Run static analysis", + "test": "Runs all tests.", + "test:unit": "Runs all unit tests.", + "test:integration": "Runs all integration tests.", + "test:setup": "Sets up a database for use with integration tests. Execute this only once." + }, + "autoload-dev": { + "psr-4": { + "FoF\\Links\\Tests\\": "tests/" + } } } diff --git a/extend.php b/extend.php index 0dbadeb..29635da 100755 --- a/extend.php +++ b/extend.php @@ -9,12 +9,14 @@ * file that was distributed with this source code. */ +namespace FoF\Links; + use Flarum\Api\Controller\ShowForumController; use Flarum\Api\Serializer\ForumSerializer; use Flarum\Extend; use FoF\Links\Api\Controller; use FoF\Links\Api\Serializer\LinkSerializer; -use FoF\Links\LoadForumLinksRelationship; +use FoF\Links\Event\PermissionChanged; return [ new Extend\Locales(__DIR__.'/locale'), @@ -31,11 +33,16 @@ ->post('/links', 'links.create', Controller\CreateLinkController::class) ->post('/links/order', 'links.order', Controller\OrderLinksController::class) ->patch('/links/{id}', 'links.update', Controller\UpdateLinkController::class) - ->delete('/links/{id}', 'links.delete', Controller\DeleteLinkController::class), + ->delete('/links/{id}', 'links.delete', Controller\DeleteLinkController::class) + ->remove('permission') + ->post('/permission', 'permission', Controller\SetPermissionController::class), (new Extend\ApiSerializer(ForumSerializer::class)) ->hasMany('links', LinkSerializer::class), + (new Extend\Event()) + ->listen(PermissionChanged::class, Listener\LinkPermissionChanged::class), + (new Extend\ApiController(ShowForumController::class)) ->addInclude(['links', 'links.parent']) ->prepareDataForSerialization(LoadForumLinksRelationship::class), @@ -44,4 +51,10 @@ ->registerLessConfigVar('fof-links-show-only-icons-on-mobile', 'fof-links.show_icons_only_on_mobile', function ($value) { return $value ? 'true' : 'false'; }), + + (new Extend\ModelVisibility(Link::class)) + ->scope(Access\ScopeLinkVisibility::class), + + (new Extend\Policy()) + ->modelPolicy(Link::class, Access\LinkPolicy::class), ]; diff --git a/js/src/admin/components/EditLinkModal.js b/js/src/admin/components/EditLinkModal.js index d9728f1..0f49f63 100755 --- a/js/src/admin/components/EditLinkModal.js +++ b/js/src/admin/components/EditLinkModal.js @@ -8,8 +8,10 @@ import Stream from 'flarum/common/utils/Stream'; import icon from 'flarum/common/helpers/icon'; import withAttr from 'flarum/common/utils/withAttr'; import ItemList from 'flarum/common/utils/ItemList'; -import Select from 'flarum/common/components/Select'; - +import PermissionDropdown from 'flarum/admin/components/PermissionDropdown'; +import Alert from 'flarum/common/components/Alert'; +import Group from 'flarum/common/models/Group'; +import Link from 'flarum/common/components/Link'; /** * The `EditlinksModal` component shows a modal dialog which allows the user * to create or edit a link. @@ -26,7 +28,7 @@ export default class EditlinksModal extends Modal { this.isInternal = Stream(this.link.isInternal() && true); this.isNewtab = Stream(this.link.isNewtab() && true); this.useRelMe = Stream(this.link.useRelMe() && true); - this.visibility = Stream(this.link.visibility() || 'everyone'); + this.guestOnly = Stream(this.link.guestOnly() && true); if (this.isInternal()) { this.updateInternalUrl(); @@ -65,9 +67,54 @@ export default class EditlinksModal extends Modal { ); } + getGroup(id) { + return app.store.getById('groups', id); + } + items() { const items = new ItemList(); + const permissionPriority = 200; + if (this.link.exists) { + const adminLabel = this.getGroup(Group.ADMINISTRATOR_ID).nameSingular(); + const guestLabel = this.getGroup(Group.GUEST_ID).namePlural(); + const everyoneLabel = app.translator.trans('core.admin.permissions_controls.everyone_button'); + + items.add( + 'visibility-permission', + [ +
{app.translator.trans('fof-links.admin.edit_link.visibility.help', { admin: adminLabel })}
++ {app.translator.trans('fof-links.admin.edit_link.visibility.guest-only.help', { guest: guestLabel, everyone: everyoneLabel })} +
+rel="me"
attribute for identity verification on other sites
- visibility: Link visibility
+ visibility:
+ help: Links by default are visible to only {admin}
users. Adjust the permissions to specify who can see this link.
+ help-disabled: Save the link before changing visibility settings.
+ label: Link visibility
+ guest-only:
+ label: "{guest} only?"
+ help: "Only {guest} can see this link. The permission above should be set to '{everyone}'."
# These strings are used in the Links page.
links:
diff --git a/migrations/2020_03_16_000000_add_child_links.php b/migrations/2020_03_16_000000_add_child_links.php
index 13bd9d2..9054e3c 100644
--- a/migrations/2020_03_16_000000_add_child_links.php
+++ b/migrations/2020_03_16_000000_add_child_links.php
@@ -25,6 +25,10 @@
});
},
'down' => function (Builder $schema) {
+ if (!$schema->hasColumn('links', 'parent_id')) {
+ return;
+ }
+
$schema->table('links', function (Blueprint $table) {
$table->dropForeign(['parent_id']);
diff --git a/migrations/2024_10_10_000000-add_is_restricted_to_links_table.php b/migrations/2024_10_10_000000-add_is_restricted_to_links_table.php
new file mode 100644
index 0000000..ad19353
--- /dev/null
+++ b/migrations/2024_10_10_000000-add_is_restricted_to_links_table.php
@@ -0,0 +1,16 @@
+ ['boolean', 'default' => 0],
+]);
diff --git a/migrations/2024_10_11_000000_add_guest_only_to_links_table.php b/migrations/2024_10_11_000000_add_guest_only_to_links_table.php
new file mode 100644
index 0000000..607e192
--- /dev/null
+++ b/migrations/2024_10_11_000000_add_guest_only_to_links_table.php
@@ -0,0 +1,16 @@
+ ['boolean', 'default' => 0],
+]);
diff --git a/migrations/2024_10_11_000001_migrate_visibility_to_permissions.php b/migrations/2024_10_11_000001_migrate_visibility_to_permissions.php
new file mode 100644
index 0000000..7bab5c0
--- /dev/null
+++ b/migrations/2024_10_11_000001_migrate_visibility_to_permissions.php
@@ -0,0 +1,85 @@
+ function (Builder $schema) {
+ $connection = $schema->getConnection();
+
+ // Fetch all links and map the `visibility` column to permissions.
+ $links = $connection->table('links')->get();
+
+ // Look over each row, and check the `visibility` column. Map the following values and create entries on the `group_permission` table:
+ // `X` is a placeholder for the link ID.
+ // - `everyone` -> ['group_id' = Group::GUEST_ID, 'permission' = 'linkX.view', 'createdAt' = Carbon::now()]
+ // - `members` -> ['group_id' = Group::MEMBER_ID, 'permission' = 'linkX.view', 'createdAt' = Carbon::now()]
+ // - `guests` -> ['group_id' = Group::GUEST_ID, 'permission' = 'linkX.view', 'createdAt' = Carbon::now()]
+
+ foreach ($links as $link) {
+ $permission = 'link'.$link->id.'.view';
+ $createdAt = Carbon::now();
+
+ switch ($link->visibility) {
+ case 'everyone':
+ $connection->table('group_permission')->insert([
+ ['group_id' => Group::GUEST_ID, 'permission' => $permission, 'created_at' => $createdAt],
+ ]);
+ $connection->table('links')->where('id', $link->id)->update([
+ 'is_restricted' => false,
+ ]);
+ break;
+
+ case 'members':
+ $connection->table('group_permission')->insert([
+ ['group_id' => Group::MEMBER_ID, 'permission' => $permission, 'created_at' => $createdAt],
+ ]);
+ // Also add to the link row the `is_restricted` = true.
+ $connection->table('links')->where('id', $link->id)->update([
+ 'is_restricted' => true,
+ ]);
+ break;
+
+ case 'guests':
+ $connection->table('group_permission')->insert([
+ ['group_id' => Group::GUEST_ID, 'permission' => $permission, 'created_at' => $createdAt],
+ ]);
+ // Also add to the link row the `is_restricted` = true and `guest_only` = true.
+ $connection->table('links')->where('id', $link->id)->update([
+ 'is_restricted' => false,
+ 'guest_only' => true,
+ ]);
+ break;
+ }
+ }
+ },
+
+ 'down' => function (Builder $schema) {
+ $connection = $schema->getConnection();
+
+ // Remove all entries from `group_permission` that were added in the `up` function.
+ $links = $connection->table('links')->get();
+
+ foreach ($links as $link) {
+ $permission = 'link'.$link->id.'.view';
+
+ $connection->table('group_permission')->where('permission', $permission)->delete();
+
+ // Reverse the changes to `is_restricted` and `guest_only`.
+ $connection->table('links')->where('id', $link->id)->update([
+ 'is_restricted' => false,
+ 'guest_only' => false,
+ ]);
+ }
+ },
+];
diff --git a/migrations/2024_10_11_000002_drop_visibility_from_links_table.php b/migrations/2024_10_11_000002_drop_visibility_from_links_table.php
new file mode 100644
index 0000000..aad5e8a
--- /dev/null
+++ b/migrations/2024_10_11_000002_drop_visibility_from_links_table.php
@@ -0,0 +1,30 @@
+ function (Builder $schema) {
+ if ($schema->hasColumn('links', 'visibility')) {
+ $schema->table('links', function (Blueprint $table) {
+ $table->dropColumn('visibility');
+ });
+ }
+ },
+
+ 'down' => function (Builder $schema) {
+ $schema->table('links', function (Blueprint $table) {
+ $table->enum('visibility', ['everyone', 'members', 'guests'])->default('everyone');
+ $table->index(['visibility']);
+ });
+ },
+];
diff --git a/src/Access/LinkPolicy.php b/src/Access/LinkPolicy.php
new file mode 100644
index 0000000..f9b569a
--- /dev/null
+++ b/src/Access/LinkPolicy.php
@@ -0,0 +1,32 @@
+parent_id !== null && !$actor->can('view', $link->parent)) {
+ return $this->deny();
+ }
+
+ if ($link->is_restricted) {
+ $id = $link->id;
+
+ return $actor->hasPermission("link$id.view");
+ }
+ }
+}
diff --git a/src/Access/ScopeLinkVisibility.php b/src/Access/ScopeLinkVisibility.php
new file mode 100644
index 0000000..5c10851
--- /dev/null
+++ b/src/Access/ScopeLinkVisibility.php
@@ -0,0 +1,33 @@
+whereIn('id', function ($query) use ($actor) {
+ Link::query()
+ ->setQuery($query->from('links'))
+ ->whereHasPermission($actor, 'view')
+ ->select('links.id');
+ });
+ }
+}
diff --git a/src/Api/Controller/CreateLinkController.php b/src/Api/Controller/CreateLinkController.php
index 44c496e..0521482 100755
--- a/src/Api/Controller/CreateLinkController.php
+++ b/src/Api/Controller/CreateLinkController.php
@@ -12,6 +12,7 @@
namespace FoF\Links\Api\Controller;
use Flarum\Api\Controller\AbstractCreateController;
+use Flarum\Foundation\ValidationException;
use Flarum\Http\RequestUtil;
use FoF\Links\Api\Serializer\LinkSerializer;
use FoF\Links\Command\CreateLink;
@@ -45,8 +46,19 @@ public function __construct(Dispatcher $bus)
*/
protected function data(ServerRequestInterface $request, Document $document)
{
+ $actor = RequestUtil::getActor($request);
+ $actor->assertAdmin();
+
+ $data = Arr::get($request->getParsedBody(), 'data');
+
+ if (!$data) {
+ throw new ValidationException([
+ 'data' => 'Invalid payload',
+ ]);
+ }
+
return $this->bus->dispatch(
- new CreateLink(RequestUtil::getActor($request), Arr::get($request->getParsedBody(), 'data'))
+ new CreateLink($actor, $data)
);
}
}
diff --git a/src/Api/Controller/SetPermissionController.php b/src/Api/Controller/SetPermissionController.php
new file mode 100644
index 0000000..9b06c8f
--- /dev/null
+++ b/src/Api/Controller/SetPermissionController.php
@@ -0,0 +1,61 @@
+events = $events;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function handle(ServerRequestInterface $request): ResponseInterface
+ {
+ $actor = RequestUtil::getActor($request);
+ $actor->assertAdmin();
+
+ $body = $request->getParsedBody();
+ $permission = Arr::get($body, 'permission');
+ $groupIds = Arr::get($body, 'groupIds');
+
+ Permission::where('permission', $permission)->delete();
+
+ Permission::insert(array_map(function ($groupId) use ($permission) {
+ return [
+ 'permission' => $permission,
+ 'group_id' => $groupId,
+ ];
+ }, $groupIds));
+
+ $this->events->dispatch(new PermissionChanged($permission, $actor, $body));
+
+ return new EmptyResponse(204);
+ }
+}
diff --git a/src/Api/Controller/UpdateLinkController.php b/src/Api/Controller/UpdateLinkController.php
index ac7623d..344c6e6 100755
--- a/src/Api/Controller/UpdateLinkController.php
+++ b/src/Api/Controller/UpdateLinkController.php
@@ -12,6 +12,7 @@
namespace FoF\Links\Api\Controller;
use Flarum\Api\Controller\AbstractShowController;
+use Flarum\Foundation\ValidationException;
use Flarum\Http\RequestUtil;
use FoF\Links\Api\Serializer\LinkSerializer;
use FoF\Links\Command\EditLink;
@@ -49,6 +50,12 @@ protected function data(ServerRequestInterface $request, Document $document)
$actor = RequestUtil::getActor($request);
$data = Arr::get($request->getParsedBody(), 'data');
+ if (!$data) {
+ throw new ValidationException([
+ 'data' => 'Invalid payload',
+ ]);
+ }
+
return $this->bus->dispatch(
new EditLink($id, $actor, $data)
);
diff --git a/src/Api/Serializer/LinkSerializer.php b/src/Api/Serializer/LinkSerializer.php
index 99180e1..d31e0f3 100755
--- a/src/Api/Serializer/LinkSerializer.php
+++ b/src/Api/Serializer/LinkSerializer.php
@@ -25,7 +25,7 @@ class LinkSerializer extends AbstractSerializer
*/
protected function getDefaultAttributes($link)
{
- return [
+ $attributes = [
'id' => $link->id,
'title' => $link->title,
'icon' => $link->icon,
@@ -35,8 +35,14 @@ protected function getDefaultAttributes($link)
'isNewtab' => $link->is_newtab,
'useRelMe' => $link->use_relme,
'isChild' => (bool) $link->parent_id,
- 'visibility' => $link->visibility,
];
+
+ if ($this->actor->isAdmin()) {
+ $attributes['isRestricted'] = (bool) $link->is_restricted;
+ $attributes['guestOnly'] = (bool) $link->guest_only;
+ }
+
+ return $attributes;
}
/**
diff --git a/src/Command/CreateLinkHandler.php b/src/Command/CreateLinkHandler.php
index 6e5dce3..d97a3b4 100755
--- a/src/Command/CreateLinkHandler.php
+++ b/src/Command/CreateLinkHandler.php
@@ -57,8 +57,8 @@ public function handle(CreateLink $command)
Arr::get($data, 'attributes.url'),
Arr::get($data, 'attributes.isInternal'),
Arr::get($data, 'attributes.isNewtab'),
- Arr::get($data, 'attributes.visibility'),
- Arr::get($data, 'attributes.useRelMe')
+ Arr::get($data, 'attributes.useRelMe'),
+ Arr::get($data, 'attributes.guestOnly'),
);
$parentId = Arr::get($data, 'relationships.parent.data.id');
diff --git a/src/Command/EditLinkHandler.php b/src/Command/EditLinkHandler.php
index 9728a39..f66e5b1 100755
--- a/src/Command/EditLinkHandler.php
+++ b/src/Command/EditLinkHandler.php
@@ -87,15 +87,17 @@ public function handle(EditLink $command)
$link->use_relme = $attributes['useRelMe'];
}
- if (isset($attributes['visibility'])) {
- $link->visibility = $attributes['visibility'];
+ if (isset($attributes['guestOnly'])) {
+ $link->guest_only = $attributes['guestOnly'];
}
$this->events->dispatch(new Saving($link, $actor, $data));
$this->validator->assertValid($link->getDirty());
- $link->save();
+ if ($link->isDirty()) {
+ $link->save();
+ }
return $link;
}
diff --git a/src/Event/PermissionChanged.php b/src/Event/PermissionChanged.php
new file mode 100644
index 0000000..bca1dec
--- /dev/null
+++ b/src/Event/PermissionChanged.php
@@ -0,0 +1,36 @@
+permission = $permission;
+ $this->actor = $actor;
+ $this->data = $data;
+ }
+}
diff --git a/src/Link.php b/src/Link.php
index 3fda2a9..19f0196 100755
--- a/src/Link.php
+++ b/src/Link.php
@@ -12,6 +12,10 @@
namespace FoF\Links;
use Flarum\Database\AbstractModel;
+use Flarum\Database\ScopeVisibilityTrait;
+use Flarum\Group\Permission;
+use Flarum\User\User;
+use Illuminate\Database\Eloquent\Builder;
/**
* @property int $id
@@ -22,13 +26,15 @@
* @property bool $is_internal
* @property bool $is_newtab
* @property bool $use_relme
- * @property bool $registered_users_only
* @property int $parent_id
* @property Link $parent
- * @property string $visibility
+ * @property bool $is_restricted
+ * @property bool $guest_only
*/
class Link extends AbstractModel
{
+ use ScopeVisibilityTrait;
+
/**
* {@inheritdoc}
*/
@@ -40,8 +46,26 @@ class Link extends AbstractModel
protected $casts = [
'is_internal' => 'boolean',
'is_newtab' => 'boolean',
+ 'use_relme' => 'boolean',
+ 'is_restricted' => 'boolean',
+ 'guest_only' => 'boolean',
];
+ public static function boot()
+ {
+ parent::boot();
+
+ static::saved(function (self $link) {
+ if ($link->wasUnrestricted()) {
+ $link->deletePermissions();
+ }
+ });
+
+ static::deleted(function (self $link) {
+ $link->deletePermissions();
+ });
+ }
+
/**
* Create a new link.
*
@@ -53,7 +77,7 @@ class Link extends AbstractModel
*
* @return static
*/
- public static function build($name, $icon, $url, $isInternal, $isNewtab, $visibility, $useRelMe = false)
+ public static function build($name, $icon, $url, $isInternal, $isNewtab, $useRelMe = false, $guestOnly = false)
{
$link = new static();
@@ -63,7 +87,7 @@ public static function build($name, $icon, $url, $isInternal, $isNewtab, $visibi
$link->is_internal = (bool) $isInternal;
$link->is_newtab = (bool) $isNewtab;
$link->use_relme = (bool) $useRelMe;
- $link->visibility = $visibility;
+ $link->guest_only = (bool) $guestOnly;
return $link;
}
@@ -90,4 +114,74 @@ public function delete()
return $result;
}
+
+ public function scopeWhereHasPermission(Builder $query, User $user, string $currPermission): Builder
+ {
+ $isAdmin = $user->isAdmin();
+ $allPermissions = $user->getPermissions();
+ $linkIdsWithPermission = collect($allPermissions)
+ ->filter(function ($permission) use ($currPermission) {
+ return substr($permission, 0, 4) === 'link' && strpos($permission, $currPermission) !== false;
+ })
+ ->map(function ($permission) {
+ $scopeFragment = explode('.', $permission, 2)[0];
+
+ return substr($scopeFragment, 4);
+ })
+ ->values();
+
+ return $query
+ ->where(function ($query) use ($isAdmin, $linkIdsWithPermission) {
+ $query
+ ->whereIn('links.id', function ($query) use ($isAdmin, $linkIdsWithPermission) {
+ static::buildPermissionSubquery($query, $isAdmin, $linkIdsWithPermission);
+ })
+ ->where(function ($query) use ($isAdmin, $linkIdsWithPermission) {
+ $query
+ ->whereIn('links.parent_id', function ($query) use ($isAdmin, $linkIdsWithPermission) {
+ static::buildPermissionSubquery($query, $isAdmin, $linkIdsWithPermission);
+ })
+ ->orWhere('links.parent_id', null);
+ });
+ });
+ }
+
+ protected static function buildPermissionSubquery($base, $isAdmin, $linkIdsWithPermission)
+ {
+ $base
+ ->from('links as perm_links')
+ ->select('perm_links.id');
+
+ // This needs to be a special case, as `linkIdsWithPermissions`
+ // won't include admin perms (which are all perms by default).
+ if ($isAdmin) {
+ return;
+ }
+
+ $base->where(function ($query) use ($linkIdsWithPermission) {
+ $query
+ ->where('perm_links.is_restricted', true)
+ ->whereIn('perm_links.id', $linkIdsWithPermission);
+ });
+
+ $base->orWhere('perm_links.is_restricted', false);
+ }
+
+ /**
+ * Has this link been unrestricted recently?
+ *
+ * @return bool
+ */
+ public function wasUnrestricted()
+ {
+ return !$this->is_restricted && $this->wasChanged('is_restricted');
+ }
+
+ /**
+ * Delete all permissions belonging to this link.
+ */
+ public function deletePermissions()
+ {
+ Permission::where('permission', 'like', "link{$this->id}.%")->delete();
+ }
}
diff --git a/src/LinkRepository.php b/src/LinkRepository.php
index 96ddaa5..cf2fba4 100755
--- a/src/LinkRepository.php
+++ b/src/LinkRepository.php
@@ -13,13 +13,13 @@
use Flarum\User\User;
use Illuminate\Contracts\Cache\Store as Cache;
+use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
class LinkRepository
{
protected static $cacheKeyPrefix = 'fof-links.links.';
protected static $cacheGuestLinksKey = 'guest';
- protected static $cacheMemberLinksKey = 'member';
/**
* @var Cache
@@ -31,6 +31,21 @@ public function __construct(Cache $cache)
$this->cache = $cache;
}
+ /**
+ * Get a new query builder for the links table.
+ *
+ * @return Builder
+ */
+ public function query()
+ {
+ return Link::query();
+ }
+
+ public function queryVisibleTo(?User $actor = null): Builder
+ {
+ return $this->scopeVisibleTo($this->query(), $actor);
+ }
+
/**
* Find a link by ID.
*
@@ -43,77 +58,74 @@ public function __construct(Cache $cache)
*/
public function findOrFail($id, User $actor = null)
{
- return Link::where('id', $id)->firstOrFail();
- }
+ $query = Link::where('id', $id);
- /**
- * Get all links.
- */
- public function all()
- {
- return Link::query()->get();
+ return $this->scopeVisibleTo($query, $actor)->firstOrFail();
}
/**
- * Gets the cache key for the appropriate links for the given user.
+ * Get all links, optionally making sure they are visible to a
+ * certain user.
*
- * @param User $actor
+ * @param User|null $user
*
- * @return string
+ * @return \Illuminate\Database\Eloquent\Collection
*/
- public function cacheKey(User $actor): string
+ public function all(User $user = null)
{
- return self::$cacheKeyPrefix.($actor->isGuest() ? self::$cacheGuestLinksKey : self::$cacheMemberLinksKey);
+ $query = Link::query();
+
+ return $this->scopeVisibleTo($query, $user)->get();
}
/**
- * Get the links for the given user.
+ * Scope a query to only include records that are visible to a user.
*
- * @param User $actor
+ * @param Builder $query
+ * @param User|null $user
*
- * @return Collection
+ * @return Builder
*/
- public function getLinks(User $actor): Collection
+ protected function scopeVisibleTo(Builder $query, ?User $user = null)
{
- return $actor->isGuest() ? $this->getGuestLinks($actor) : $this->getMemberLinks($actor);
+ if ($user !== null) {
+ $query->whereVisibleTo($user);
+ }
+
+ return $query;
}
/**
- * Get the links for guests.
- *
- * If the links are cached, they will be returned from the cache, else the cache will be populated from the database.
+ * Gets the cache key for the appropriate links for the given user. Only applicable for guests.
*
* @param User $actor
*
- * @return Collection
+ * @return string
*/
- public function getGuestLinks(User $actor): Collection
+ public function cacheKey(User $actor): string
{
- if ($links = $this->cache->get($this->cacheKey($actor))) {
- return $links;
+ if ($actor->isGuest()) {
+ return self::$cacheKeyPrefix.self::$cacheGuestLinksKey;
} else {
- $links = $this->getGuestLinksFromDatabase();
- $this->cache->forever($this->cacheKey($actor), $links);
-
- return $links;
+ throw new \InvalidArgumentException('Only guests can have cached links at this time.');
}
}
/**
- * Get the links for guests from the database.
+ * Get the links for the given user.
+ *
+ * @param User $actor
*
* @return Collection
*/
- protected function getGuestLinksFromDatabase(): Collection
+ public function getLinks(User $actor): Collection
{
- return Link::query()
- ->where('visibility', 'guests')
- ->orWhere('visibility', 'everyone')
- ->get();
+ return $this->getLinksFromDatabase($actor);
+ //return $actor->isGuest() ? $this->getGuestLinks($actor) : $this->getLinksFromDatabase($actor);
}
/**
- * Get the links for members.
+ * Get the links for guests.
*
* If the links are cached, they will be returned from the cache, else the cache will be populated from the database.
*
@@ -121,12 +133,12 @@ protected function getGuestLinksFromDatabase(): Collection
*
* @return Collection
*/
- public function getMemberLinks(User $actor): Collection
+ public function getGuestLinks(User $actor): Collection
{
if ($links = $this->cache->get($this->cacheKey($actor))) {
return $links;
} else {
- $links = $this->getMemberLinksFromDatabase($actor);
+ $links = $this->getLinksFromDatabase($actor);
$this->cache->forever($this->cacheKey($actor), $links);
return $links;
@@ -134,18 +146,15 @@ public function getMemberLinks(User $actor): Collection
}
/**
- * Get the links for members from the database.
- *
- * @param User $actor
+ * Get the links for guests from the database.
*
* @return Collection
*/
- protected function getMemberLinksFromDatabase(User $actor): Collection
+ protected function getLinksFromDatabase(User $actor): Collection
{
return Link::query()
- ->where('visibility', 'members')
- ->orWhere('visibility', 'everyone')
- ->get();
+ ->whereVisibleTo($actor)
+ ->get();
}
/**
@@ -154,6 +163,5 @@ protected function getMemberLinksFromDatabase(User $actor): Collection
public function clearLinksCache(): void
{
$this->cache->forget(self::$cacheKeyPrefix.self::$cacheGuestLinksKey);
- $this->cache->forget(self::$cacheKeyPrefix.self::$cacheMemberLinksKey);
}
}
diff --git a/src/LinkValidator.php b/src/LinkValidator.php
index a79ba08..9ae1939 100755
--- a/src/LinkValidator.php
+++ b/src/LinkValidator.php
@@ -19,8 +19,8 @@ class LinkValidator extends AbstractValidator
* {@inheritdoc}
*/
protected $rules = [
- 'title' => ['required', 'string', 'max:50'],
- 'url' => ['string', 'max:255'],
- 'icon' => ['string', 'max:100'],
+ 'title' => ['required', 'string', 'max:50'],
+ 'url' => ['string', 'max:255'],
+ 'icon' => ['string', 'max:100'],
];
}
diff --git a/src/Listener/LinkPermissionChanged.php b/src/Listener/LinkPermissionChanged.php
new file mode 100644
index 0000000..bb90fd1
--- /dev/null
+++ b/src/Listener/LinkPermissionChanged.php
@@ -0,0 +1,48 @@
+permission;
+ $groupIds = Arr::get($event->data, 'groupIds');
+
+ // Example permission name: `link1.view`
+ if (preg_match('/^link(\d+)\.view$/', $permission, $matches)) {
+ $linkId = $matches[1];
+ $link = Link::findOrFail($linkId);
+
+ if ($this->isGuestPermission($groupIds)) {
+ $link->is_restricted = false;
+ } else {
+ $link->is_restricted = true;
+ }
+
+ if ($link->isDirty('is_restricted')) {
+ $link->save();
+ }
+ }
+ }
+
+ protected function isGuestPermission(array $groups): bool
+ {
+ // If the array contains the value of the guest group ID, then the permission is for guests.
+ return in_array(Group::GUEST_ID, $groups);
+ }
+}
diff --git a/src/LoadForumLinksRelationship.php b/src/LoadForumLinksRelationship.php
index 241a2e4..bdd3feb 100644
--- a/src/LoadForumLinksRelationship.php
+++ b/src/LoadForumLinksRelationship.php
@@ -43,14 +43,29 @@ public function __construct(Config $config, LinkRepository $links)
public function __invoke(ShowForumController $controller, &$data, ServerRequestInterface $request)
{
$actor = RequestUtil::getActor($request);
- $adminPath = Arr::get($this->config, 'paths.admin');
// So that admins don't have to see guest only items but can manage them in admin panel,
// we only serialize all links if we're visiting the admin panel
- if ($actor->isAdmin() && $request->getServerParams()['REQUEST_URI'] === "/$adminPath") {
+ if ($actor->isAdmin() && $this->isAdminPath($request)) {
return $data['links'] = Link::all();
}
- $data['links'] = $this->links->getLinks($actor);
+ $links = $this->links->getLinks($actor);
+
+ if (!$actor->isGuest()) {
+ // If the user is not a guest, and link that has the valued `guests_only` = true should be removed.
+ $links = $links->reject(function ($link) {
+ return $link->guest_only;
+ });
+ }
+
+ $data['links'] = $links;
+ }
+
+ private function isAdminPath(ServerRequestInterface $request): bool
+ {
+ $adminPath = Arr::get($this->config, 'paths.admin');
+
+ return Arr::get($request->getServerParams(), 'REQUEST_URI') === "/$adminPath";
}
}
diff --git a/tests/fixtures/.gitkeep b/tests/fixtures/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/tests/fixtures/LinkUsersTrait.php b/tests/fixtures/LinkUsersTrait.php
new file mode 100644
index 0000000..7e65be9
--- /dev/null
+++ b/tests/fixtures/LinkUsersTrait.php
@@ -0,0 +1,30 @@
+extension('fof-links');
+
+ $this->prepareDatabase([
+ 'users' => [
+ $this->normalUser(),
+ ],
+ 'links' => [
+ ['id' => 1, 'title' => 'Google', 'icon' => 'fab fa-google', 'url' => 'https://google.com', 'position' => null, 'is_internal' => false, 'is_newtab' => true, 'use_relme' => false, 'parent_id' => null, 'is_restricted' => false, 'guest_only' => false],
+ ['id' => 2, 'title' => 'Minimal'],
+ ],
+ ]);
+ }
+
+ public function payload(): array
+ {
+ return [
+ 'data' => [
+ 'type' => 'links',
+ 'attributes' => [
+ 'title' => 'Facebook',
+ 'url' => 'https://facebook.com',
+ 'icon' => 'fab fa-facebook',
+ 'position' => 0,
+ 'isInternal' => true,
+ 'isNewtab' => true,
+ 'useRelMe' => true,
+ 'guestOnly' => true,
+ ],
+ ],
+ ];
+ }
+
+ public function minimalPayload(): array
+ {
+ return [
+ 'data' => [
+ 'type' => 'links',
+ 'attributes' => [
+ 'title' => 'Facebook',
+ 'url' => 'https://facebook.com',
+ 'icon' => 'fab fa-facebook',
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @test
+ *
+ * @dataProvider authorizedUsers
+ */
+ public function authorized_user_cannot_create_link_with_no_data(int $userId)
+ {
+ $response = $this->send(
+ $this->request('POST', '/api/links', [
+ 'authenticatedAs' => $userId,
+ 'json' => [],
+ ])
+ );
+
+ $this->assertEquals(422, $response->getStatusCode());
+
+ $response = json_decode($response->getBody()->getContents(), true);
+
+ $this->assertArrayHasKey('errors', $response);
+ }
+
+ /**
+ * @test
+ *
+ * @dataProvider authorizedUsers
+ */
+ public function authorized_user_can_create_link(int $userId)
+ {
+ $response = $this->send(
+ $this->request('POST', '/api/links', [
+ 'authenticatedAs' => $userId,
+ 'json' => $this->payload(),
+ ])
+ );
+
+ $this->assertEquals(201, $response->getStatusCode());
+
+ $response = json_decode($response->getBody()->getContents(), true);
+
+ $this->assertArrayHasKey('data', $response);
+ $this->assertArrayHasKey('id', $response['data']);
+ $this->assertEquals('Facebook', $response['data']['attributes']['title']);
+ $this->assertEquals('https://facebook.com', $response['data']['attributes']['url']);
+ $this->assertEquals('fab fa-facebook', $response['data']['attributes']['icon']);
+ $this->assertEquals(0, $response['data']['attributes']['position']);
+ $this->assertTrue($response['data']['attributes']['isInternal']);
+ $this->assertTrue($response['data']['attributes']['isNewtab']);
+ $this->assertTrue($response['data']['attributes']['useRelMe']);
+ $this->assertFalse($response['data']['attributes']['isChild']);
+ $this->assertTrue($response['data']['attributes']['guestOnly']);
+ $this->assertFalse($response['data']['attributes']['isRestricted']);
+
+ $id = $response['data']['id'];
+
+ $link = Link::find($id);
+
+ $this->assertNotNull($link);
+ $this->assertEquals('Facebook', $link->title);
+ $this->assertEquals('https://facebook.com', $link->url);
+ $this->assertEquals('fab fa-facebook', $link->icon);
+ $this->assertEquals(0, $link->position);
+ $this->assertTrue($link->is_internal);
+ $this->assertTrue($link->is_newtab);
+ $this->assertTrue($link->use_relme);
+ $this->assertFalse($link->is_restricted);
+ $this->assertTrue($link->guest_only);
+ }
+
+ /**
+ * @test
+ *
+ * @dataProvider authorizedUsers
+ */
+ public function authorized_user_can_create_link_with_minimal_data(int $userId)
+ {
+ $response = $this->send(
+ $this->request('POST', '/api/links', [
+ 'authenticatedAs' => $userId,
+ 'json' => $this->minimalPayload(),
+ ])
+ );
+
+ $this->assertEquals(201, $response->getStatusCode());
+
+ $response = json_decode($response->getBody()->getContents(), true);
+
+ $this->assertArrayHasKey('data', $response);
+ $this->assertArrayHasKey('id', $response['data']);
+ $this->assertEquals('Facebook', $response['data']['attributes']['title']);
+ $this->assertEquals('https://facebook.com', $response['data']['attributes']['url']);
+ $this->assertEquals('fab fa-facebook', $response['data']['attributes']['icon']);
+ $this->assertFalse($response['data']['attributes']['isRestricted']);
+ $this->assertFalse($response['data']['attributes']['guestOnly']);
+
+ $id = $response['data']['id'];
+
+ $link = Link::find($id);
+
+ $this->assertNotNull($link);
+ $this->assertEquals('Facebook', $link->title);
+ $this->assertEquals('https://facebook.com', $link->url);
+ $this->assertEquals('fab fa-facebook', $link->icon);
+ $this->assertFalse($response['data']['attributes']['isChild']);
+
+ // check defaults of optional fields
+ $this->assertFalse($link->is_internal);
+ $this->assertFalse($link->is_newtab);
+ $this->assertFalse($link->use_relme);
+ $this->assertNull($link->parent_id);
+ $this->assertNull($link->position);
+ $this->assertFalse($link->is_restricted);
+ $this->assertFalse($link->guest_only);
+ }
+
+ /**
+ * @test
+ *
+ * @dataProvider unauthorizedUsers
+ */
+ public function unauthorized_cannot_create_link(?int $userId)
+ {
+ if (!$userId) {
+ $this->extend(
+ (new Extend\Csrf())
+ ->exemptRoute('links.create')
+ );
+ }
+
+ $response = $this->send(
+ $this->request('POST', '/api/links', [
+ 'authenticatedAs' => $userId,
+ 'json' => $this->payload(),
+ ])
+ );
+
+ $this->assertEquals(403, $response->getStatusCode());
+
+ $link = Link::where('title', 'Facebook')->first();
+
+ $this->assertNull($link);
+ }
+}
diff --git a/tests/integration/Api/DeleteLinkTest.php b/tests/integration/Api/DeleteLinkTest.php
new file mode 100644
index 0000000..b0b2fb5
--- /dev/null
+++ b/tests/integration/Api/DeleteLinkTest.php
@@ -0,0 +1,130 @@
+extension('fof-links');
+
+ $this->prepareDatabase([
+ 'users' => [
+ $this->normalUser(),
+ ],
+ 'links' => [
+ ['id' => 1, 'title' => 'Google', 'icon' => 'fab fa-google', 'url' => 'https://google.com', 'position' => null, 'is_internal' => false, 'is_newtab' => true, 'use_relme' => false, 'parent_id' => null],
+ ],
+ ]);
+ }
+
+ /**
+ * @test
+ *
+ * @dataProvider authorizedUsers
+ */
+ public function authorized_user_can_delete_link(int $userId)
+ {
+ $response = $this->send(
+ $this->request('DELETE', '/api/links/1', [
+ 'authenticatedAs' => $userId,
+ ])
+ );
+
+ $this->assertEquals(204, $response->getStatusCode());
+
+ $this->assertNull(Link::find(1));
+ $this->assertEquals(0, Link::count());
+ }
+
+ /**
+ * @test
+ *
+ * @dataProvider authorizedUsers
+ */
+ public function authorized_user_cannot_delete_nonexistent_link(int $userId)
+ {
+ $response = $this->send(
+ $this->request('DELETE', '/api/links/2', [
+ 'authenticatedAs' => $userId,
+ ])
+ );
+
+ $this->assertEquals(404, $response->getStatusCode());
+
+ $this->assertNull(Link::find(2));
+ $this->assertEquals(1, Link::count());
+ }
+
+ /**
+ * @test
+ *
+ * @dataProvider unauthorizedUsers
+ */
+ public function unauthorized_user_cannot_delete_link(?int $userId)
+ {
+ if (!$userId) {
+ $this->extend(
+ (new Extend\Csrf())
+ ->exemptRoute('links.delete')
+ );
+ }
+
+ $response = $this->send(
+ $this->request('DELETE', '/api/links/1', [
+ 'authenticatedAs' => $userId,
+ ])
+ );
+
+ $this->assertEquals(403, $response->getStatusCode());
+
+ $this->assertNotNull(Link::find(1));
+ $this->assertEquals(1, Link::count());
+ }
+
+ /**
+ * @test
+ *
+ * @dataProvider unauthorizedUsers
+ */
+ public function unauthorized_user_cannot_delete_nonexistent_link(?int $userId)
+ {
+ if (!$userId) {
+ $this->extend(
+ (new Extend\Csrf())
+ ->exemptRoute('links.delete')
+ );
+ }
+
+ $response = $this->send(
+ $this->request('DELETE', '/api/links/2', [
+ 'authenticatedAs' => $userId,
+ ])
+ );
+
+ $this->assertEquals(404, $response->getStatusCode());
+
+ $this->assertNull(Link::find(2));
+ $this->assertEquals(1, Link::count());
+ }
+}
diff --git a/tests/integration/Api/EditLinkTest.php b/tests/integration/Api/EditLinkTest.php
new file mode 100644
index 0000000..f315513
--- /dev/null
+++ b/tests/integration/Api/EditLinkTest.php
@@ -0,0 +1,174 @@
+extension('fof-links');
+
+ $this->prepareDatabase([
+ 'users' => [
+ $this->normalUser(),
+ ],
+ 'links' => [
+ ['id' => 1, 'title' => 'Google', 'icon' => 'fab fa-google', 'url' => 'https://google.com', 'position' => null, 'is_internal' => false, 'is_newtab' => true, 'use_relme' => false, 'parent_id' => null, 'is_restricted' => false, 'guest_only' => false],
+ ],
+ ]);
+ }
+
+ /**
+ * @test
+ *
+ * @dataProvider authorizedUsers
+ */
+ public function authorized_user_can_edit_link(int $userId)
+ {
+ $response = $this->send(
+ $this->request('PATCH', '/api/links/1', [
+ 'authenticatedAs' => $userId,
+ 'json' => [
+ 'data' => [
+ 'type' => 'links',
+ 'id' => '1',
+ 'attributes' => [
+ 'title' => 'Facebook',
+ 'url' => 'https://facebook.com',
+ 'icon' => 'fab fa-facebook',
+ 'guestOnly' => true,
+ ],
+ ],
+ ],
+ ])
+ );
+
+ $this->assertEquals(200, $response->getStatusCode());
+
+ $link = json_decode($response->getBody(), true)['data'];
+
+ $this->assertEquals('Facebook', $link['attributes']['title']);
+ $this->assertEquals('https://facebook.com', $link['attributes']['url']);
+ $this->assertEquals('fab fa-facebook', $link['attributes']['icon']);
+ $this->assertTrue($link['attributes']['guestOnly']);
+
+ $link = Link::find(1);
+
+ $this->assertEquals('Facebook', $link->title);
+ $this->assertEquals('https://facebook.com', $link->url);
+ $this->assertEquals('fab fa-facebook', $link->icon);
+ $this->assertTrue($link->guest_only);
+ }
+
+ /**
+ * @test
+ *
+ * @dataProvider authorizedUsers
+ */
+ public function authorized_user_cannot_edit_link_with_no_data(int $userId)
+ {
+ $response = $this->send(
+ $this->request('PATCH', '/api/links/1', [
+ 'authenticatedAs' => $userId,
+ 'json' => [],
+ ])
+ );
+
+ $this->assertEquals(422, $response->getStatusCode());
+
+ $response = json_decode($response->getBody()->getContents(), true);
+
+ $this->assertArrayHasKey('errors', $response);
+ $this->assertCount(1, $response['errors']);
+ }
+
+ /**
+ * @test
+ *
+ * @dataProvider authorizedUsers
+ */
+ public function authorized_user_cannot_edit_nonexistent_link(int $userId)
+ {
+ $response = $this->send(
+ $this->request('PATCH', '/api/links/2', [
+ 'authenticatedAs' => $userId,
+ 'json' => [
+ 'data' => [
+ 'type' => 'links',
+ 'id' => '2',
+ 'attributes' => [
+ 'title' => 'Facebook',
+ 'url' => 'https://facebook.com',
+ 'icon' => 'fab fa-facebook',
+ ],
+ ],
+ ],
+ ])
+ );
+
+ $this->assertEquals(404, $response->getStatusCode());
+
+ $this->assertNull(Link::find(2));
+ $this->assertEquals(1, Link::count());
+ }
+
+ /**
+ * @test
+ *
+ * @dataProvider unauthorizedUsers
+ */
+ public function unauthorized_user_cannot_edit_link(?int $userId)
+ {
+ if (!$userId) {
+ $this->extend(
+ (new Extend\Csrf())
+ ->exemptRoute('links.update')
+ );
+ }
+
+ $response = $this->send(
+ $this->request('PATCH', '/api/links/1', [
+ 'authenticatedAs' => $userId,
+ 'json' => [
+ 'data' => [
+ 'type' => 'links',
+ 'id' => '1',
+ 'attributes' => [
+ 'title' => 'Facebook',
+ 'url' => 'https://facebook.com',
+ 'icon' => 'fab fa-facebook',
+ ],
+ ],
+ ],
+ ])
+ );
+
+ $this->assertEquals(403, $response->getStatusCode());
+
+ $link = Link::find(1);
+
+ $this->assertEquals('Google', $link->title);
+ $this->assertEquals('https://google.com', $link->url);
+ $this->assertEquals('fab fa-google', $link->icon);
+ }
+}
diff --git a/tests/integration/Api/LinkVisibilityTest.php b/tests/integration/Api/LinkVisibilityTest.php
new file mode 100644
index 0000000..9017133
--- /dev/null
+++ b/tests/integration/Api/LinkVisibilityTest.php
@@ -0,0 +1,160 @@
+extension('fof-links');
+
+ $this->prepareDatabase($this->dbData());
+ }
+
+ protected function dbData(): array
+ {
+ return [
+ 'users' => [
+ $this->normalUser(),
+ ['id' => 3, 'username' => 'moderator', 'email' => 'mod@machine.local', 'is_email_confirmed' => true],
+ ['id' => 4, 'username' => 'evelated', 'email' => 'elevated@machine.local', 'is_email_confirmed' => true],
+ ['id' => 5, 'username' => 'elevatedplus', 'email' => 'elevatedplus@machine.local', 'is_email_confirmed' => true],
+ ],
+ 'links' => [
+ ['id' => 1, 'title' => 'Google', 'icon' => 'fab fa-google', 'url' => 'https://google.com', 'position' => null, 'is_internal' => false, 'is_newtab' => true, 'use_relme' => false, 'parent_id' => null, 'is_restricted' => false],
+ ['id' => 2, 'title' => 'Facebook', 'url' => 'https://facebook.com', 'is_restricted' => true],
+ ['id' => 3, 'title' => 'Twitter', 'url' => 'https://twitter.com', 'is_restricted' => true],
+ ['id' => 4, 'title' => 'Reddit', 'url' => 'https://reddit.com', 'is_restricted' => true],
+ ['id' => 5, 'title' => 'FooBar', 'url' => 'https://foobar.com', 'is_restricted' => true],
+ ['id' => 6, 'title' => 'BazQux', 'url' => 'https://bazqux.com', 'is_restricted' => true, 'parent_id' => 5, 'position' => 0],
+ ['id' => 7, 'title' => 'QuuxQuuz', 'url' => 'https://quuxquuz.com', 'is_restricted' => true, 'parent_id' => 5, 'position' => 1],
+ ['id' => 8, 'title' => 'GuestOnly', 'url' => 'https://guestonly.com', 'is_restricted' => false, 'guest_only' => true],
+
+ ],
+ 'groups' => [
+ ['id' => 5, 'name_singular' => 'FooBar', 'name_plural' => 'FooBars'],
+ ['id' => 6, 'name_singular' => 'BazQux', 'name_plural' => 'BazQuux'],
+ ],
+ 'group_user' => [
+ ['user_id' => 3, 'group_id' => Group::MODERATOR_ID],
+ ['user_id' => 4, 'group_id' => 5],
+ ['user_id' => 5, 'group_id' => 6],
+ ],
+ 'group_permission' => [
+ ['permission' => 'link1.view', 'group_id' => Group::GUEST_ID],
+ ['permission' => 'link2.view', 'group_id' => 5],
+ ['permission' => 'link2.view', 'group_id' => 6],
+ ['permission' => 'link3.view', 'group_id' => Group::MEMBER_ID],
+ ['permission' => 'link4.view', 'group_id' => Group::MODERATOR_ID],
+ ['permission' => 'link4.view', 'group_id' => 5],
+ // Parent & child scenarios
+ ['permission' => 'link5.view', 'group_id' => 4],
+ ['permission' => 'link5.view', 'group_id' => 5],
+ ['permission' => 'link5.view', 'group_id' => 6],
+ ['permission' => 'link6.view', 'group_id' => 5],
+ ['permission' => 'link6.view', 'group_id' => 6],
+ ['permission' => 'link7.view', 'group_id' => 6],
+ // Guest only
+ ['permission' => 'link8.view', 'group_id' => Group::GUEST_ID],
+ ],
+ ];
+ }
+
+ public function forumUsersDataProvider(): array
+ {
+ return [
+ [null, [1, 8]],
+ [1, [1, 2, 3, 4, 5, 6, 7]],
+ [2, [1, 3]],
+ [3, [1, 3, 4, 5]],
+ [4, [1, 2, 3, 4, 5, 6]],
+ [5, [1, 2, 3, 5, 6, 7]],
+ ];
+ }
+
+ public function extractLinksFromIncluded(array $data): array
+ {
+ $this->assertArrayHasKey('included', $data);
+ $this->assertIsArray($data['included']);
+
+ return array_filter($data['included'], function ($item) {
+ return $item['type'] === 'links';
+ });
+ }
+
+ public function getLinkById(int $id, array $links): array
+ {
+ $result = array_filter($links, function ($link) use ($id) {
+ return $link['attributes']['id'] === $id;
+ });
+
+ $this->assertCount(1, $result);
+
+ // Return the first item
+ return reset($result);
+ }
+
+ /**
+ * @test
+ *
+ * @param int|null $userId
+ * @param array