diff --git a/.env.example b/.env.example index 6c0040ddb..b1d89f11a 100644 --- a/.env.example +++ b/.env.example @@ -210,6 +210,10 @@ NOVA_LICENSE_KEY= NOVA_PASSWORDS=null NOVA_PATH=/admin +# playlist +PLAYLIST_MAX_TRACKS=1000 +USER_MAX_PLAYLISTS=1000 + # queue QUEUE_CONNECTION=sync REDIS_QUEUE=default diff --git a/app/Constants/Config/PlaylistConstants.php b/app/Constants/Config/PlaylistConstants.php new file mode 100644 index 000000000..120a6b2ec --- /dev/null +++ b/app/Constants/Config/PlaylistConstants.php @@ -0,0 +1,15 @@ +__toString(); $this->middleware($isPlaylistManagementAllowed)->except(['index', 'show']); + $this->middleware(PlaylistExceedsTrackLimit::class)->only(['store', 'restore']); } /** diff --git a/app/Http/Controllers/Api/List/PlaylistController.php b/app/Http/Controllers/Api/List/PlaylistController.php index bca207d7e..8552f32c3 100644 --- a/app/Http/Controllers/Api/List/PlaylistController.php +++ b/app/Http/Controllers/Api/List/PlaylistController.php @@ -15,6 +15,7 @@ use App\Constants\Config\FlagConstants; use App\Enums\Models\List\PlaylistVisibility; use App\Http\Api\Query\Query; use App\Http\Controllers\Api\BaseController; +use App\Http\Middleware\Models\List\UserExceedsPlaylistLimit; use App\Http\Requests\Api\IndexRequest; use App\Http\Requests\Api\ShowRequest; use App\Http\Requests\Api\StoreRequest; @@ -45,6 +46,7 @@ class PlaylistController extends BaseController ->__toString(); $this->middleware($isPlaylistManagementAllowed)->except(['index', 'show']); + $this->middleware(UserExceedsPlaylistLimit::class)->only(['store', 'restore']); } /** diff --git a/app/Http/Middleware/Models/List/PlaylistExceedsTrackLimit.php b/app/Http/Middleware/Models/List/PlaylistExceedsTrackLimit.php new file mode 100644 index 000000000..774cefcd3 --- /dev/null +++ b/app/Http/Middleware/Models/List/PlaylistExceedsTrackLimit.php @@ -0,0 +1,43 @@ +route('playlist'); + + /** @var User|null $user */ + $user = $request->user('sanctum'); + + if ( + intval($playlist?->tracks()?->count()) >= $trackLimit + && empty($user?->can(SpecialPermission::BYPASS_FEATURE_FLAGS)) + ) { + abort(403, "Playlists cannot contain more than '$trackLimit' tracks."); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/Models/List/UserExceedsPlaylistLimit.php b/app/Http/Middleware/Models/List/UserExceedsPlaylistLimit.php new file mode 100644 index 000000000..72f56825e --- /dev/null +++ b/app/Http/Middleware/Models/List/UserExceedsPlaylistLimit.php @@ -0,0 +1,39 @@ +user('sanctum'); + + if ( + intval($user?->playlists()?->count()) >= $playlistLimit + && empty($user?->can(SpecialPermission::BYPASS_FEATURE_FLAGS)) + ) { + abort(403, "User cannot have more than '$playlistLimit' playlists."); + } + + return $next($request); + } +} diff --git a/config/playlist.php b/config/playlist.php new file mode 100644 index 000000000..2c1c53dce --- /dev/null +++ b/config/playlist.php @@ -0,0 +1,21 @@ + (int) env('PLAYLIST_MAX_TRACKS', 1000), + + 'user_max_playlists' => (int) env('USER_MAX_PLAYLISTS', 1000), +]; diff --git a/tests/Feature/Http/Api/List/Playlist/PlaylistRestoreTest.php b/tests/Feature/Http/Api/List/Playlist/PlaylistRestoreTest.php index 19cb9db04..9b88047c8 100644 --- a/tests/Feature/Http/Api/List/Playlist/PlaylistRestoreTest.php +++ b/tests/Feature/Http/Api/List/Playlist/PlaylistRestoreTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Tests\Feature\Http\Api\List\Playlist; use App\Constants\Config\FlagConstants; +use App\Constants\Config\PlaylistConstants; use App\Enums\Auth\ExtendedCrudPermission; use App\Enums\Auth\SpecialPermission; use App\Models\Auth\User; @@ -183,4 +184,68 @@ class PlaylistRestoreTest extends TestCase $response->assertOk(); static::assertNotSoftDeleted($playlist); } + + /** + * The Playlist Restore Endpoint shall forbid users from restoring playlists that exceed the user playlist limit. + * + * @return void + */ + public function testMaxTrackLimit(): void + { + $playlistLimit = $this->faker->randomDigitNotNull(); + + Config::set(PlaylistConstants::MAX_PLAYLISTS_QUALIFIED, $playlistLimit); + Config::set(FlagConstants::ALLOW_PLAYLIST_MANAGEMENT_QUALIFIED, true); + + $user = User::factory() + ->has(Playlist::factory()->count($playlistLimit)) + ->withPermissions(ExtendedCrudPermission::RESTORE()->format(Playlist::class)) + ->createOne(); + + $playlist = Playlist::factory() + ->for($user) + ->createOne(); + + $playlist->delete(); + + Sanctum::actingAs($user); + + $response = $this->patch(route('api.playlist.restore', ['playlist' => $playlist])); + + $response->assertForbidden(); + } + + /** + * The Playlist Restore Endpoint shall permit users with bypass feature flag permission + * to restore playlists that exceed the user playlist limit. + * + * @return void + */ + public function testMaxTrackLimitPermittedForBypass(): void + { + $playlistLimit = $this->faker->randomDigitNotNull(); + + Config::set(PlaylistConstants::MAX_PLAYLISTS_QUALIFIED, $playlistLimit); + Config::set(FlagConstants::ALLOW_PLAYLIST_MANAGEMENT_QUALIFIED, true); + + $user = User::factory() + ->has(Playlist::factory()->count($playlistLimit)) + ->withPermissions( + ExtendedCrudPermission::RESTORE()->format(Playlist::class), + SpecialPermission::BYPASS_FEATURE_FLAGS + ) + ->createOne(); + + $playlist = Playlist::factory() + ->for($user) + ->createOne(); + + $playlist->delete(); + + Sanctum::actingAs($user); + + $response = $this->patch(route('api.playlist.restore', ['playlist' => $playlist])); + + $response->assertOk(); + } } diff --git a/tests/Feature/Http/Api/List/Playlist/PlaylistStoreTest.php b/tests/Feature/Http/Api/List/Playlist/PlaylistStoreTest.php index f612a793e..78ccdc812 100644 --- a/tests/Feature/Http/Api/List/Playlist/PlaylistStoreTest.php +++ b/tests/Feature/Http/Api/List/Playlist/PlaylistStoreTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Tests\Feature\Http\Api\List\Playlist; use App\Constants\Config\FlagConstants; +use App\Constants\Config\PlaylistConstants; use App\Enums\Auth\CrudPermission; use App\Enums\Auth\SpecialPermission; use App\Enums\Models\List\PlaylistVisibility; @@ -158,4 +159,66 @@ class PlaylistStoreTest extends TestCase $response->assertCreated(); } + + /** + * The Playlist Store Endpoint shall forbid users from creating playlists that exceed the user playlist limit. + * + * @return void + */ + public function testMaxTrackLimit(): void + { + $playlistLimit = $this->faker->randomDigitNotNull(); + + Config::set(PlaylistConstants::MAX_PLAYLISTS_QUALIFIED, $playlistLimit); + Config::set(FlagConstants::ALLOW_PLAYLIST_MANAGEMENT_QUALIFIED, true); + + $parameters = array_merge( + Playlist::factory()->raw(), + [Playlist::ATTRIBUTE_VISIBILITY => PlaylistVisibility::getRandomInstance()->description], + ); + + $user = User::factory() + ->has(Playlist::factory()->count($playlistLimit)) + ->withPermissions(CrudPermission::CREATE()->format(Playlist::class)) + ->createOne(); + + Sanctum::actingAs($user); + + $response = $this->post(route('api.playlist.store', $parameters)); + + $response->assertForbidden(); + } + + /** + * The Playlist Store Endpoint shall permit users with bypass feature flag permission + * to create playlists that exceed the user playlist limit. + * + * @return void + */ + public function testMaxTrackLimitPermittedForBypass(): void + { + $playlistLimit = $this->faker->randomDigitNotNull(); + + Config::set(PlaylistConstants::MAX_PLAYLISTS_QUALIFIED, $playlistLimit); + Config::set(FlagConstants::ALLOW_PLAYLIST_MANAGEMENT_QUALIFIED, true); + + $parameters = array_merge( + Playlist::factory()->raw(), + [Playlist::ATTRIBUTE_VISIBILITY => PlaylistVisibility::getRandomInstance()->description], + ); + + $user = User::factory() + ->has(Playlist::factory()->count($playlistLimit)) + ->withPermissions( + CrudPermission::CREATE()->format(Playlist::class), + SpecialPermission::BYPASS_FEATURE_FLAGS + ) + ->createOne(); + + Sanctum::actingAs($user); + + $response = $this->post(route('api.playlist.store', $parameters)); + + $response->assertCreated(); + } } diff --git a/tests/Feature/Http/Api/List/Playlist/Track/TrackRestoreTest.php b/tests/Feature/Http/Api/List/Playlist/Track/TrackRestoreTest.php index cf032f849..01aa3ad3a 100644 --- a/tests/Feature/Http/Api/List/Playlist/Track/TrackRestoreTest.php +++ b/tests/Feature/Http/Api/List/Playlist/Track/TrackRestoreTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Tests\Feature\Http\Api\List\Playlist\Track; use App\Constants\Config\FlagConstants; +use App\Constants\Config\PlaylistConstants; use App\Enums\Auth\CrudPermission; use App\Enums\Auth\ExtendedCrudPermission; use App\Enums\Auth\SpecialPermission; @@ -400,4 +401,80 @@ class TrackRestoreTest extends TestCase $response->assertOk(); } + + /** + * The Track Restore Endpoint shall forbid users from restoring playlist tracks that exceed the max track limit. + * + * @return void + */ + public function testMaxTrackLimit(): void + { + $trackLimit = $this->faker->randomDigitNotNull(); + + Config::set(PlaylistConstants::MAX_TRACKS_QUALIFIED, $trackLimit); + Config::set(FlagConstants::ALLOW_PLAYLIST_MANAGEMENT_QUALIFIED, true); + + $user = User::factory() + ->withPermissions( + CrudPermission::DELETE()->format(PlaylistTrack::class), + ExtendedCrudPermission::RESTORE()->format(PlaylistTrack::class) + ) + ->createOne(); + + $playlist = Playlist::factory() + ->tracks($trackLimit) + ->for($user) + ->createOne(); + + $track = PlaylistTrack::factory() + ->for($playlist) + ->createOne(); + + Sanctum::actingAs($user); + + $this->delete(route('api.playlist.track.destroy', ['playlist' => $playlist, 'track' => $track])); + + $response = $this->patch(route('api.playlist.track.restore', ['playlist' => $playlist, 'track' => $track])); + + $response->assertForbidden(); + } + + /** + * The Track Restore Endpoint shall permit users with bypass feature flag permission + * to restore playlist tracks that exceed the max track limit. + * + * @return void + */ + public function testMaxTrackLimitPermittedForBypass(): void + { + $trackLimit = $this->faker->randomDigitNotNull(); + + Config::set(PlaylistConstants::MAX_TRACKS_QUALIFIED, $trackLimit); + Config::set(FlagConstants::ALLOW_PLAYLIST_MANAGEMENT_QUALIFIED, true); + + $user = User::factory() + ->withPermissions( + CrudPermission::DELETE()->format(PlaylistTrack::class), + ExtendedCrudPermission::RESTORE()->format(PlaylistTrack::class), + SpecialPermission::BYPASS_FEATURE_FLAGS + ) + ->createOne(); + + $playlist = Playlist::factory() + ->tracks($trackLimit) + ->for($user) + ->createOne(); + + $track = PlaylistTrack::factory() + ->for($playlist) + ->createOne(); + + Sanctum::actingAs($user); + + $this->delete(route('api.playlist.track.destroy', ['playlist' => $playlist, 'track' => $track])); + + $response = $this->patch(route('api.playlist.track.restore', ['playlist' => $playlist, 'track' => $track])); + + $response->assertOk(); + } } diff --git a/tests/Feature/Http/Api/List/Playlist/Track/TrackStoreTest.php b/tests/Feature/Http/Api/List/Playlist/Track/TrackStoreTest.php index 7b4bd4688..352ece4ac 100644 --- a/tests/Feature/Http/Api/List/Playlist/Track/TrackStoreTest.php +++ b/tests/Feature/Http/Api/List/Playlist/Track/TrackStoreTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Tests\Feature\Http\Api\List\Playlist\Track; use App\Constants\Config\FlagConstants; +use App\Constants\Config\PlaylistConstants; use App\Enums\Auth\CrudPermission; use App\Enums\Auth\SpecialPermission; use App\Models\Auth\User; @@ -517,4 +518,72 @@ class TrackStoreTest extends TestCase $response->assertCreated(); } + + /** + * The Track Store Endpoint shall forbid users from creating playlists that exceed the max track limit. + * + * @return void + */ + public function testMaxTrackLimit(): void + { + $trackLimit = $this->faker->randomDigitNotNull(); + + Config::set(PlaylistConstants::MAX_TRACKS_QUALIFIED, $trackLimit); + Config::set(FlagConstants::ALLOW_PLAYLIST_MANAGEMENT_QUALIFIED, true); + + $user = User::factory()->withPermissions(CrudPermission::CREATE()->format(PlaylistTrack::class))->createOne(); + + $playlist = Playlist::factory() + ->tracks($trackLimit) + ->for($user) + ->createOne(); + + $track = PlaylistTrack::factory() + ->for($playlist) + ->for(Video::factory()) + ->makeOne(); + + Sanctum::actingAs($user); + + $response = $this->post(route('api.playlist.track.store', ['playlist' => $playlist] + $track->toArray())); + + $response->assertForbidden(); + } + + /** + * The Track Store Endpoint shall permit users with bypass feature flag permission + * to create playlists that exceed the max track limit. + * + * @return void + */ + public function testMaxTrackLimitPermittedForBypass(): void + { + $trackLimit = $this->faker->randomDigitNotNull(); + + Config::set(PlaylistConstants::MAX_TRACKS_QUALIFIED, $trackLimit); + Config::set(FlagConstants::ALLOW_PLAYLIST_MANAGEMENT_QUALIFIED, true); + + $user = User::factory() + ->withPermissions( + CrudPermission::CREATE()->format(PlaylistTrack::class), + SpecialPermission::BYPASS_FEATURE_FLAGS + ) + ->createOne(); + + $playlist = Playlist::factory() + ->tracks($trackLimit) + ->for($user) + ->createOne(); + + $track = PlaylistTrack::factory() + ->for($playlist) + ->for(Video::factory()) + ->makeOne(); + + Sanctum::actingAs($user); + + $response = $this->post(route('api.playlist.track.store', ['playlist' => $playlist] + $track->toArray())); + + $response->assertCreated(); + } }