diff --git a/resources/views/add.blade.php b/resources/views/add.blade.php index ce3e27641..707ef2082 100644 --- a/resources/views/add.blade.php +++ b/resources/views/add.blade.php @@ -1,4 +1,8 @@ + {{-- Opens the sidenav pin list, which is an admin control, + so it is gated with it. --}} + @if($enable_auth_admin_controls)
{{ __('app.dash.pin_item') }}
+ @endif diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 3ad988992..cc34abf96 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -40,6 +40,11 @@
+ {{-- The pin list toggles global item state, so it belongs with + #config-buttons behind the admin check rather than beside it. + Under roles mode every visitor reaches this template, and an + ungated list hands each of them a pintoggle link per item. --}} + @if($enable_auth_admin_controls) + @endif
    diff --git a/tests/Feature/DashRolesTest.php b/tests/Feature/DashRolesTest.php new file mode 100644 index 000000000..f231b898c --- /dev/null +++ b/tests/Feature/DashRolesTest.php @@ -0,0 +1,212 @@ + true, + 'app.auth_roles_header' => self::ROLES_HEADER, + 'app.auth_roles_http_header' => self::ROLES_SERVER_KEY, + 'app.auth_roles_admin' => 'admin', + 'app.auth_roles_delimiter' => ',', + ]); + + // Under php-fpm the proxy's header lands in $_SERVER, which is where + // the view composer reads the admin role from. Test requests do not + // populate the superglobal, so do it here the way the webserver would. + $_SERVER[self::ROLES_SERVER_KEY] = $callerRoles; + } + + private function getDashAs(string $callerRoles) + { + $this->actingWithRoles($callerRoles); + + return $this->get('/', [self::ROLES_HEADER => $callerRoles]); + } + + private function addItemForRole(string $title, ?string $role, int $pinned = 1): Item + { + $item = Item::factory()->create([ + 'title' => $title, + 'role' => $role, + 'pinned' => $pinned, + ]); + + ItemTag::factory()->create([ + 'item_id' => $item->id, + 'tag_id' => 0, + ]); + + return $item; + } + + /** + * The leak: items the visitor has no role for + */ + + public function test_sidenav_pin_list_only_lists_items_the_visitor_has_a_role_for(): void + { + $this->seed(); + + $this->addItemForRole('Plex', 'media'); + $theirs = $this->addItemForRole('Unifi Controller', 'admin'); + + $response = $this->getDashAs('media'); + + $response->assertStatus(200); + $response->assertSee('Plex'); + // The title of an item this visitor cannot see must not appear + // anywhere in the response, sidenav included. + $response->assertDontSee('Unifi Controller'); + // Nor may the response hand out a state-changing link for it. + $response->assertDontSee(route('items.pintoggle', [$theirs->id]), false); + } + + public function test_sidenav_pin_list_is_empty_of_other_roles_items_when_the_visitor_has_none(): void + { + $this->seed(); + + $this->addItemForRole('Unifi Controller', 'admin'); + $this->addItemForRole('Nzbget', 'admin'); + + $response = $this->getDashAs('guest'); + + $response->assertStatus(200); + $response->assertDontSee('Unifi Controller'); + $response->assertDontSee('Nzbget'); + } + + /** + * The pin control + */ + + public function test_pin_item_control_is_hidden_from_non_admin_roles(): void + { + $this->seed(); + + $this->addItemForRole('Plex', 'media'); + + $response = $this->getDashAs('media'); + + $response->assertStatus(200); + // #config-buttons is already gated on the admin role; #add-item opens + // the sidenav pin list, so both it and the list have to be gated with + // it. Pinning is global state in roles mode, not a per-visitor + // preference, so it is an admin action however few items are listed. + $response->assertDontSee('id="config-buttons"', false); + $response->assertDontSee('id="add-item"', false); + $response->assertDontSee('id="pinlist"', false); + $response->assertDontSee('items/pintoggle', false); + } + + /** + * Control cases: the admin role must keep everything + */ + + public function test_admin_role_still_sees_every_item_and_the_pin_control(): void + { + $this->seed(); + + $this->addItemForRole('Plex', 'media'); + $theirs = $this->addItemForRole('Unifi Controller', 'admin'); + + $response = $this->getDashAs('admin'); + + $response->assertStatus(200); + $response->assertSee('Unifi Controller'); + $response->assertSee('id="config-buttons"', false); + $response->assertSee('id="add-item"', false); + $response->assertSee('id="pinlist"', false); + $response->assertSee(route('items.pintoggle', [$theirs->id]), false); + } + + public function test_admin_keeps_the_pin_control_for_items_of_other_roles(): void + { + $this->seed(); + + $other = $this->addItemForRole('Plex', 'media'); + + $response = $this->getDashAs('admin'); + + $response->assertStatus(200); + // The pin list is how items get pinned and unpinned, so an admin needs + // every item in it and not just the ones carrying the admin role. + // Role-filtering the list itself would also close the leak, and would + // quietly take away the admin's ability to unpin anything else. + $response->assertSee('Plex'); + $response->assertSee(route('items.pintoggle', [$other->id]), false); + } + + public function test_a_visitor_holding_several_roles_sees_all_of_them(): void + { + $this->seed(); + + $this->addItemForRole('Plex', 'media'); + $this->addItemForRole('Nzbget', 'downloads'); + $this->addItemForRole('Unifi Controller', 'admin'); + + $response = $this->getDashAs('media,downloads'); + + $response->assertStatus(200); + $response->assertSee('Plex'); + $response->assertSee('Nzbget'); + $response->assertDontSee('Unifi Controller'); + } + + /** + * Control case: roles mode off changes nothing + */ + + public function test_with_roles_disabled_every_item_is_still_listed(): void + { + $this->seed(); + + $this->addItemForRole('Plex', 'media'); + $this->addItemForRole('Unifi Controller', 'admin'); + + $response = $this->get('/'); + + $response->assertStatus(200); + $response->assertSee('Plex'); + $response->assertSee('Unifi Controller'); + $response->assertSee('id="add-item"', false); + $response->assertSee('id="pinlist"', false); + } +}