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 })} +

+
, + ], + permissionPriority + ); + } else { + items.add( + 'visibility-permission-disabled', + [ +
+ + + {app.translator.trans('fof-links.admin.edit_link.visibility.help-disabled')} + +
, + ], + permissionPriority + ); + } + items.add( 'title', [ @@ -86,7 +133,9 @@ export default class EditlinksModal extends Modal {
{app.translator.trans('fof-links.admin.edit_link.icon_text', { - a: , + a: ( + + ), })}
{app.translator.trans('fof-links.admin.edit_link.icon_additional_text')} @@ -170,21 +219,6 @@ export default class EditlinksModal extends Modal { 40 ); - items.add( - 'visibility', - [ -
- - {Select.component({ - value: this.visibility(), - onchange: this.visibility, - options: this.typeOptions(), - })} -
, - ], - 20 - ); - items.add( 'actions', [ @@ -212,16 +246,6 @@ export default class EditlinksModal extends Modal { return items; } - typeOptions() { - let opts; - opts = ['everyone', 'members', 'guests'].reduce((o, key) => { - o[key] = app.translator.trans(`fof-links.admin.edit_link.${key}-label`); - - return o; - }, {}); - return opts; - } - submitData() { return { title: this.itemTitle(), @@ -230,7 +254,7 @@ export default class EditlinksModal extends Modal { isInternal: this.isInternal(), isNewtab: this.isNewtab(), useRelMe: this.useRelMe(), - visibility: this.visibility(), + guestOnly: this.guestOnly(), }; } diff --git a/js/src/common/models/Link.ts b/js/src/common/models/Link.ts index 6483a9e..55e46c3 100644 --- a/js/src/common/models/Link.ts +++ b/js/src/common/models/Link.ts @@ -41,7 +41,11 @@ export default class Link extends Model { return Model.hasOne('parent').call(this); } - visibility() { - return Model.attribute('visibility').call(this); + isRestricted() { + return Model.attribute('isRestricted').call(this); + } + + guestOnly() { + return Model.attribute('guestOnly').call(this); } } diff --git a/js/src/forum/extendHeader.tsx b/js/src/forum/extendHeader.tsx index 8dc39c3..df4b243 100644 --- a/js/src/forum/extendHeader.tsx +++ b/js/src/forum/extendHeader.tsx @@ -12,9 +12,15 @@ export default function extendHeader() { extend(HeaderPrimary.prototype, 'items', function (items: ItemList) { const allLinks = app.store.all('links'); const links = allLinks.filter((link) => !link.isChild()); + const addLink = (parent: Link | null | undefined) => { const hasChildren = allLinks.some((link) => link.parent() == parent); + // If the link has no URL and no children, do not display it. + if (!parent?.url() && !hasChildren) { + return; + } + items.add(`link${parent?.id()}`, hasChildren ? LinkDropdown.component({ link: parent }) : LinkItem.component({ link: parent })); }; diff --git a/locale/en.yml b/locale/en.yml index ee1603b..ceb9d3c 100755 --- a/locale/en.yml +++ b/locale/en.yml @@ -11,10 +11,7 @@ fof-links: edit_link: delete_link_button: Delete Link delete_link_confirmation: "Are you sure you want to delete this link?" - everyone-label: Everyone - guests-label: Guests internal_link: "Is it an internal link?" - members-label: Registered users open_newtab: "Open link in new tab" submit_button: => core.ref.save_changes title: => fof-links.ref.create_link @@ -27,7 +24,13 @@ fof-links: url_label: => fof-links.ref.url url_placeholder: => fof-links.ref.url use_rel_me: Add 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 $expectedLinks + * + * @dataProvider forumUsersDataProvider + */ + public function user_type_sees_expected_links(?int $userId, array $expectedLinks) + { + $response = $this->send( + $this->request('GET', '/api', [ + 'authenticatedAs' => $userId, + ]) + ); + + $this->assertEquals(200, $response->getStatusCode()); + + $data = json_decode($response->getBody(), true); + + // Extract links from response data + $links = $this->extractLinksFromIncluded($data); + + // Extract IDs from the links array + $linkIds = array_column($links, 'id'); + + // Sort both arrays to ensure order doesn't matter + sort($linkIds); + sort($expectedLinks); + + // Ensure the arrays contain exactly the same IDs + $this->assertEquals($expectedLinks, $linkIds); + + // Now check for each individual link's data + foreach ($expectedLinks as $expectedLink) { + $link = $this->getLinkById($expectedLink, $links); + $this->assertNotNull($link); + + $dbLink = Link::find($expectedLink); + $this->assertNotNull($dbLink); + + $this->assertEquals($dbLink->title, $link['attributes']['title']); + $this->assertEquals($dbLink->url, $link['attributes']['url']); + } + } +} diff --git a/tests/integration/setup.php b/tests/integration/setup.php new file mode 100644 index 0000000..32982f7 --- /dev/null +++ b/tests/integration/setup.php @@ -0,0 +1,18 @@ +run(); diff --git a/tests/phpunit.integration.xml b/tests/phpunit.integration.xml new file mode 100644 index 0000000..90fbbff --- /dev/null +++ b/tests/phpunit.integration.xml @@ -0,0 +1,25 @@ + + + + + ../src/ + + + + + ./integration + ./integration/tmp + + + diff --git a/tests/phpunit.unit.xml b/tests/phpunit.unit.xml new file mode 100644 index 0000000..d3a4a3e --- /dev/null +++ b/tests/phpunit.unit.xml @@ -0,0 +1,27 @@ + + + + + ../src/ + + + + + ./unit + + + + + + diff --git a/tests/unit/.gitkeep b/tests/unit/.gitkeep new file mode 100644 index 0000000..e69de29