diff --git a/app/Conductors/EventConductor.php b/app/Conductors/EventConductor.php index a47bc9b..4cc6982 100644 --- a/app/Conductors/EventConductor.php +++ b/app/Conductors/EventConductor.php @@ -30,7 +30,7 @@ class EventConductor extends Conductor public function scope(Builder $builder) { $user = auth()->user(); - if ($user === null || $user->has_permission('admin/events') === false) { + if ($user === null || $user->hasPermission('admin/events') === false) { $builder ->where('status', '!=', 'draft') ->where('publish_at', '<=', now()); @@ -47,7 +47,7 @@ class EventConductor extends Conductor { if (strtolower($model->status) === 'draft' || Carbon::parse($model->publish_at)->isFuture() === true) { $user = auth()->user(); - if ($user === null || $user->has_permission('admin/events') === false) { + if ($user === null || $user->hasPermission('admin/events') === false) { return false; } } @@ -63,7 +63,7 @@ class EventConductor extends Conductor public static function creatable() { $user = auth()->user(); - return ($user !== null && $user->has_permission('admin/events') === true); + return ($user !== null && $user->hasPermission('admin/events') === true); } /** @@ -75,18 +75,18 @@ class EventConductor extends Conductor public static function updatable(Model $model) { $user = auth()->user(); - return ($user !== null && $user->has_permission('admin/events') === true); + return ($user !== null && $user->hasPermission('admin/events') === true); } /** - * Return if the current model is deletable. + * Return if the current model is destroyable. * * @param Model $model The model. * @return boolean Allow deleting model. */ - public static function deletable(Model $model) + public static function destroyable(Model $model) { $user = auth()->user(); - return ($user !== null && $user->has_permission('admin/events') === true); + return ($user !== null && $user->hasPermission('admin/events') === true); } } diff --git a/app/Conductors/MediaConductor.php b/app/Conductors/MediaConductor.php index 6ad3de8..dd88d5d 100644 --- a/app/Conductors/MediaConductor.php +++ b/app/Conductors/MediaConductor.php @@ -64,7 +64,7 @@ class MediaConductor extends Conductor { if ($model->permission !== null) { $user = auth()->user(); - if ($user === null || $user->has_permission($model->permission) === false) { + if ($user === null || $user->hasPermission($model->permission) === false) { return false; } } @@ -92,18 +92,18 @@ class MediaConductor extends Conductor public static function updatable(Model $model) { $user = auth()->user(); - return ($user !== null && (strcasecmp($model->user_id, $user->id) === 0 || $user->has_permission('admin/media') === true)); + return ($user !== null && (strcasecmp($model->user_id, $user->id) === 0 || $user->hasPermission('admin/media') === true)); } /** - * Return if the current model is deletable. + * Return if the current model is destroyable. * * @param Model $model The model. * @return boolean Allow deleting model. */ - public static function deletable(Model $model) + public static function destroyable(Model $model) { $user = auth()->user(); - return ($user !== null && ($model->user_id === $user->id || $user->has_permission('admin/media') === true)); + return ($user !== null && ($model->user_id === $user->id || $user->hasPermission('admin/media') === true)); } } diff --git a/app/Conductors/PostConductor.php b/app/Conductors/PostConductor.php index 63b4988..bfaa40f 100644 --- a/app/Conductors/PostConductor.php +++ b/app/Conductors/PostConductor.php @@ -30,7 +30,7 @@ class PostConductor extends Conductor public function scope(Builder $builder) { $user = auth()->user(); - if ($user === null || $user->has_permission('admin/posts') === false) { + if ($user === null || $user->hasPermission('admin/posts') === false) { $builder ->where('publish_at', '<=', now()); } @@ -46,7 +46,7 @@ class PostConductor extends Conductor { if (Carbon::parse($model->publish_at)->isFuture() === true) { $user = auth()->user(); - if ($user === null || $user->has_permission('admin/posts') === false) { + if ($user === null || $user->hasPermission('admin/posts') === false) { return false; } } @@ -62,7 +62,7 @@ class PostConductor extends Conductor public static function creatable() { $user = auth()->user(); - return ($user !== null && $user->has_permission('admin/posts') === true); + return ($user !== null && $user->hasPermission('admin/posts') === true); } /** @@ -74,18 +74,18 @@ class PostConductor extends Conductor public static function updatable(Model $model) { $user = auth()->user(); - return ($user !== null && $user->has_permission('admin/posts') === true); + return ($user !== null && $user->hasPermission('admin/posts') === true); } /** - * Return if the current model is deletable. + * Return if the current model is destroyable. * * @param Model $model The model. * @return boolean Allow deleting model. */ - public static function deletable(Model $model) + public static function destroyable(Model $model) { $user = auth()->user(); - return ($user !== null && $user->has_permission('admin/posts') === true); + return ($user !== null && $user->hasPermission('admin/posts') === true); } } diff --git a/app/Conductors/SubscriptionConductor.php b/app/Conductors/SubscriptionConductor.php index 3f447d2..191eed6 100644 --- a/app/Conductors/SubscriptionConductor.php +++ b/app/Conductors/SubscriptionConductor.php @@ -22,18 +22,18 @@ class SubscriptionConductor extends Conductor public static function updatable(Model $model) { $user = auth()->user(); - return ($user !== null && ((strcasecmp($model->email, $user->email) === 0 && $user->email_verified_at !== null) || $user->has_permission('admin/subscriptions') === true)); + return ($user !== null && ((strcasecmp($model->email, $user->email) === 0 && $user->email_verified_at !== null) || $user->hasPermission('admin/subscriptions') === true)); } /** - * Return if the current model is deletable. + * Return if the current model is destroyable. * * @param Model $model The model. * @return boolean Allow deleting model. */ - public static function deletable(Model $model) + public static function destroyable(Model $model) { $user = auth()->user(); - return ($user !== null && ((strcasecmp($model->email, $user->email) === 0 && $user->email_verified_at !== null) || $user->has_permission('admin/subscriptions') === true)); + return ($user !== null && ((strcasecmp($model->email, $user->email) === 0 && $user->email_verified_at !== null) || $user->hasPermission('admin/subscriptions') === true)); } } diff --git a/app/Conductors/UserConductor.php b/app/Conductors/UserConductor.php index 7c3252e..cc8d125 100644 --- a/app/Conductors/UserConductor.php +++ b/app/Conductors/UserConductor.php @@ -65,7 +65,7 @@ class UserConductor extends Conductor } /** - * Return if the current model is deletable. + * Return if the current model is destroyable. * * @param Model $model The model. * @return boolean Allow deleting model. diff --git a/app/Http/Controllers/Api/ApiController.php b/app/Http/Controllers/Api/ApiController.php index 27b1e4d..9cb5269 100644 --- a/app/Http/Controllers/Api/ApiController.php +++ b/app/Http/Controllers/Api/ApiController.php @@ -121,13 +121,15 @@ class ApiController extends Controller /** * Return resource data * - * @param array|Model|Collection $data Resource data. - * @param array|null $appendData Data to append to response. - * @param integer $respondCode Resource code. + * @param array|Model|Collection $data Resource data. + * @param boolean $isCollection If the data is a group of items. + * @param array|null $appendData Data to append to response. + * @param integer $respondCode Resource code. * @return \Illuminate\Http\JsonResponse */ protected function respondAsResource( mixed $data, + bool $isCollection = false, mixed $appendData = null, int $respondCode = HttpResponseCodes::HTTP_OK ) { @@ -144,8 +146,6 @@ class ApiController extends Controller $resourceName = strtolower($resourceName); } - $is_multiple = true; - $dataArray = []; if ($data instanceof Collection) { $dataArray = $data->toArray(); @@ -157,7 +157,7 @@ class ApiController extends Controller } $resource = []; - if ($is_multiple === true) { + if ($isCollection === true) { $resource = [Str::plural($resourceName) => $dataArray]; } else { $resource = [Str::singular($resourceName) => $dataArray]; diff --git a/app/Http/Controllers/Api/AuthController.php b/app/Http/Controllers/Api/AuthController.php index cd1b8d1..463e01d 100644 --- a/app/Http/Controllers/Api/AuthController.php +++ b/app/Http/Controllers/Api/AuthController.php @@ -73,6 +73,7 @@ class AuthController extends ApiController return $this->respondAsResource( $user->makeVisible(['permissions']), + false, ['token' => $token] ); }//end if diff --git a/app/Http/Controllers/Api/EventController.php b/app/Http/Controllers/Api/EventController.php index 2f4febb..ba3a7a5 100644 --- a/app/Http/Controllers/Api/EventController.php +++ b/app/Http/Controllers/Api/EventController.php @@ -31,6 +31,7 @@ class EventController extends ApiController return $this->respondAsResource( $collection, + true, ['total' => $total] ); } @@ -63,6 +64,7 @@ class EventController extends ApiController $event = Event::create($request->all()); return $this->respondAsResource( EventConductor::model($request, $event), + false, null, HttpResponseCodes::HTTP_CREATED ); diff --git a/app/Http/Controllers/Api/MediaController.php b/app/Http/Controllers/Api/MediaController.php index 5fde469..a381aae 100644 --- a/app/Http/Controllers/Api/MediaController.php +++ b/app/Http/Controllers/Api/MediaController.php @@ -33,6 +33,7 @@ class MediaController extends ApiController return $this->respondAsResource( $collection, + true, ['total' => $total] ); } @@ -103,6 +104,7 @@ class MediaController extends ApiController $media = $request->user()->media()->create($request->all()); return $this->respondAsResource( MediaConductor::model($request, $media), + false, null, HttpResponseCodes::HTTP_CREATED ); diff --git a/app/Http/Controllers/Api/PostController.php b/app/Http/Controllers/Api/PostController.php index 717bfab..239dce2 100644 --- a/app/Http/Controllers/Api/PostController.php +++ b/app/Http/Controllers/Api/PostController.php @@ -35,6 +35,7 @@ class PostController extends ApiController return $this->respondAsResource( $collection, + true, ['total' => $total] ); } @@ -67,6 +68,7 @@ class PostController extends ApiController $post = Post::create($request->all()); return $this->respondAsResource( PostConductor::model($request, $post), + false, null, HttpResponseCodes::HTTP_CREATED ); diff --git a/app/Http/Controllers/Api/SubscriptionController.php b/app/Http/Controllers/Api/SubscriptionController.php index 49451cd..0593032 100644 --- a/app/Http/Controllers/Api/SubscriptionController.php +++ b/app/Http/Controllers/Api/SubscriptionController.php @@ -34,6 +34,7 @@ class SubscriptionController extends ApiController return $this->respondAsResource( $collection, + true, ['total' => $total] ); } diff --git a/app/Http/Controllers/Api/UserController.php b/app/Http/Controllers/Api/UserController.php index 85a05d7..024d93b 100644 --- a/app/Http/Controllers/Api/UserController.php +++ b/app/Http/Controllers/Api/UserController.php @@ -56,6 +56,7 @@ class UserController extends ApiController return $this->respondAsResource( $collection, + true, ['total' => $total] ); } @@ -70,7 +71,7 @@ class UserController extends ApiController { if (UserConductor::creatable() === true) { $user = User::create($request->all()); - return $this->respondAsResource(UserConductor::model($request, $user), [], HttpResponseCodes::HTTP_CREATED); + return $this->respondAsResource(UserConductor::model($request, $user), false, [], HttpResponseCodes::HTTP_CREATED); } else { return $this->respondForbidden(); } diff --git a/app/Http/Requests/PostRequest.php b/app/Http/Requests/PostRequest.php index dd15f51..b8593c5 100644 --- a/app/Http/Requests/PostRequest.php +++ b/app/Http/Requests/PostRequest.php @@ -14,10 +14,12 @@ class PostRequest extends BaseRequest public function postRules() { return [ - 'slug' => 'string|min:6|unique:posts', - 'title' => 'string|min:6|max:255', - 'publish_at' => 'date', - 'user_id' => 'uuid|exists:users,id', + 'slug' => 'required|string|min:6|unique:posts', + 'title' => 'required|string|min:6|max:255', + 'publish_at' => 'required|date', + 'user_id' => 'required|uuid|exists:users,id', + 'content' => 'required|string|min:6', + 'hero' => 'required|uuid|exists:media,id', ]; } @@ -37,6 +39,8 @@ class PostRequest extends BaseRequest 'title' => 'string|min:6|max:255', 'publish_at' => 'date', 'user_id' => 'uuid|exists:users,id', + 'content' => 'string|min:6', + 'hero' => 'uuid|exists:media,id', ]; } } diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 55eb40e..a23540f 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -46,8 +46,28 @@ class RouteServiceProvider extends ServiceProvider */ protected function configureRateLimiting() { - RateLimiter::for('api', function (Request $request) { - return Limit::perMinute(60)->by($request->user()?->id !== null ?: $request->ip()); - }); + // RateLimiter::for('api', function (Request $request) { + // return Limit::perMinute(60)->by($request->user()?->id !== null ?: $request->ip()); + // }); + + $rateLimitEnabled = true; + $user = auth()->user(); + + if (app()->environment('testing')) { + $rateLimitEnabled = false; + } elseif ($user !== null && $user->hasPermission('admin/ratelimit') === true) { + // Admin users with the "admin/ratelimit" permission are not rate limited + $rateLimitEnabled = false; + } + + if ($rateLimitEnabled === true) { + RateLimiter::for('api', function (Request $request) { + return Limit::perMinute(180)->by($request->user()?->id ?: $request->ip()); + }); + } else { + RateLimiter::for('api', function () { + return Limit::none(); + }); + } } } diff --git a/database/factories/EventFactory.php b/database/factories/EventFactory.php new file mode 100644 index 0000000..d4b1976 --- /dev/null +++ b/database/factories/EventFactory.php @@ -0,0 +1,40 @@ + + */ +class EventFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition() + { + $startDate = Carbon::parse($this->faker->dateTimeBetween('now', '+1 year')); + $endDate = Carbon::parse($this->faker->dateTimeBetween($startDate, '+1 year')); + $publishDate = Carbon::parse($this->faker->dateTimeBetween('-1 month', '+1 month')); + + return [ + 'title' => $this->faker->sentence(), + 'location' => $this->faker->randomElement(['online', 'physical']), + 'address' => $this->faker->address, + 'start_at' => $startDate, + 'end_at' => $endDate, + 'publish_at' => $publishDate, + 'status' => $this->faker->randomElement(['draft', 'soon', 'open', 'closed', 'cancelled']), + 'registration_type' => $this->faker->randomElement(['none', 'email', 'link', 'message']), + 'registration_data' => $this->faker->sentence(), + 'hero' => $this->faker->uuid, + 'content' => $this->faker->paragraphs(3, true), + 'price' => $this->faker->numberBetween(0, 150), + 'ages' => $this->faker->regexify('\d+(\+|\-\d+)?'), + ]; + } +} diff --git a/database/factories/MediaFactory.php b/database/factories/MediaFactory.php new file mode 100644 index 0000000..c3c257a --- /dev/null +++ b/database/factories/MediaFactory.php @@ -0,0 +1,29 @@ + + */ +class MediaFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition() + { + return [ + 'title' => $this->faker->sentence(), + 'name' => storage_path('app/public/') . $this->faker->slug() . '.' . $this->faker->fileExtension, + 'mime' => $this->faker->mimeType, + 'user_id' => $this->faker->uuid, + 'size' => $this->faker->numberBetween(1000, 1000000), + 'permission' => null + ]; + } +} diff --git a/database/factories/PostFactory.php b/database/factories/PostFactory.php new file mode 100644 index 0000000..80405e2 --- /dev/null +++ b/database/factories/PostFactory.php @@ -0,0 +1,31 @@ + + */ +class PostFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition() + { + $publishDate = Carbon::parse($this->faker->dateTimeBetween('-1 month', '+1 month')); + + return [ + 'title' => $this->faker->sentence(), + 'slug' => $this->faker->slug(), + 'publish_at' => $publishDate, + 'content' => $this->faker->paragraphs(3, true), + 'user_id' => $this->faker->uuid, + 'hero' => $this->faker->uuid, + ]; + } +} diff --git a/tests/Feature/AuthEndpointTest.php b/tests/Feature/AuthApiTest.php similarity index 97% rename from tests/Feature/AuthEndpointTest.php rename to tests/Feature/AuthApiTest.php index e4dcc5f..e8a0e98 100644 --- a/tests/Feature/AuthEndpointTest.php +++ b/tests/Feature/AuthApiTest.php @@ -3,7 +3,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; use App\Models\User; -class AuthEndpointTest extends TestCase +class AuthApiTest extends TestCase { use RefreshDatabase; diff --git a/tests/Feature/EventsApiTest.php b/tests/Feature/EventsApiTest.php new file mode 100644 index 0000000..9e6a7a1 --- /dev/null +++ b/tests/Feature/EventsApiTest.php @@ -0,0 +1,136 @@ +faker = FakerFactory::create(); + } + + public function testAnyUserCanViewEvent() + { + // Create an event + $event = Event::factory()->create([ + 'publish_at' => Carbon::parse($this->faker->dateTimeBetween('-2 months', '-1 month')), + ]); + + // Create a future event + $futureEvent = Event::factory()->create([ + 'publish_at' => Carbon::parse($this->faker->dateTimeBetween('+1 month', '+2 months')), + ]); + + // Send GET request to the /api/events endpoint + $response = $this->getJson('/api/events'); + $response->assertStatus(200); + + // Assert that the event is in the response data + $response->assertJsonCount(1, 'events'); + $response->assertJsonFragment([ + 'id' => $event->id, + 'title' => $event->title, + ]); + + $response->assertJsonMissing([ + 'id' => $futureEvent->id, + 'title' => $futureEvent->title, + ]); + } + + public function testAdminCanCreateUpdateDeleteEvent() + { + // Create a user with the admin/events permission + $adminUser = User::factory()->create(); + $adminUser->givePermission('admin/events'); + + // Create media data + $media = Media::factory()->create(['user_id' => $adminUser->id]); + + // Create event data + $eventData = Event::factory()->make([ + 'start_at' => now()->addDays(7), + 'end_at' => now()->addDays(7)->addHours(2), + 'hero' => $media->id, + ])->toArray(); + + // Test creating event + $response = $this->actingAs($adminUser)->postJson('/api/events', $eventData); + $response->assertStatus(201); + $this->assertDatabaseHas('events', [ + 'title' => $eventData['title'], + 'content' => $eventData['content'], + ]); + + // Test viewing event + $event = Event::where('title', $eventData['title'])->first(); + $response = $this->get("/api/events/$event->id"); + $response->assertStatus(200); + $response->assertJsonStructure([ + 'event' => [ + 'id', + 'title', + 'content', + 'start_at', + 'end_at', + ] + ]); + + // Test updating event + $eventData['title'] = 'Updated Event'; + $response = $this->actingAs($adminUser)->putJson("/api/events/$event->id", $eventData); + $response->assertStatus(200); + $this->assertDatabaseHas('events', [ + 'title' => 'Updated Event', + ]); + + // Test deleting event + $response = $this->actingAs($adminUser)->delete("/api/events/$event->id"); + $response->assertStatus(204); + $this->assertDatabaseMissing('events', [ + 'title' => 'Updated Event', + ]); + } + + public function testNonAdminCannotCreateUpdateDeleteEvent() + { + // Create a user without admin/events permission + $user = User::factory()->create(); + + // Authenticate as the user + $this->actingAs($user); + + // Try to create a new event + $media = Media::factory()->create(['user_id' => $user->id]); + + $newEventData = Event::factory()->make(['hero' => $media->id])->toArray(); + + $response = $this->postJson('/api/events', $newEventData); + $response->assertStatus(403); + + // Try to update an event + $event = Event::factory()->create(); + $updatedEventData = [ + 'title' => 'Updated Event', + 'content' => 'This is an updated event.', + // Add more fields as needed + ]; + $response = $this->putJson('/api/events/' . $event->id, $updatedEventData); + $response->assertStatus(403); + + // Try to delete an event + $event = Event::factory()->create(); + $response = $this->deleteJson('/api/events/' . $event->id); + $response->assertStatus(403); + } +} diff --git a/tests/Feature/PostsApiTest.php b/tests/Feature/PostsApiTest.php new file mode 100644 index 0000000..c77a086 --- /dev/null +++ b/tests/Feature/PostsApiTest.php @@ -0,0 +1,134 @@ +faker = FakerFactory::create(); + } + + public function testAnyUserCanViewPost() + { + // Create an event + $post = Post::factory()->create([ + 'publish_at' => $this->faker->dateTimeBetween('-2 months', '-1 month'), + ]); + + // Create a future event + $futurePost = Post::factory()->create([ + 'publish_at' => $this->faker->dateTimeBetween('+1 month', '+2 months'), + ]); + + // Send GET request to the /api/posts endpoint + $response = $this->getJson('/api/posts'); + $response->assertStatus(200); + + // Assert that the event is in the response data + $response->assertJsonCount(1, 'posts'); + $response->assertJsonFragment([ + 'id' => $post->id, + 'title' => $post->title, + 'content' => $post->content, + ]); + + $response->assertJsonMissing([ + 'id' => $futurePost->id, + 'title' => $futurePost->title, + 'content' => $futurePost->content, + ]); + } + + public function testAdminCanCreateUpdateDeletePost() + { + // Create a user with the admin/events permission + $adminUser = User::factory()->create(); + $adminUser->givePermission('admin/posts'); + + // Create media data + $media = Media::factory()->create(['user_id' => $adminUser->id]); + + // Create event data + $postData = Post::factory()->make([ + 'user_id' => $adminUser->id, + 'hero' => $media->id, + ])->toArray(); + + // Test creating event + $response = $this->actingAs($adminUser)->postJson('/api/posts', $postData); + $response->assertStatus(201); + $this->assertDatabaseHas('posts', [ + 'title' => $postData['title'], + 'content' => $postData['content'], + ]); + + // Test viewing event + $post = Post::where('title', $postData['title'])->first(); + $response = $this->get("/api/posts/$post->id"); + $response->assertStatus(200); + $response->assertJsonStructure([ + 'post' => [ + 'id', + 'title', + 'content', + ] + ]); + + // Test updating event + $postData['title'] = 'Updated Post'; + $response = $this->actingAs($adminUser)->putJson("/api/posts/$post->id", $postData); + $response->assertStatus(200); + $this->assertDatabaseHas('posts', [ + 'title' => 'Updated Post', + ]); + + // Test deleting event + $response = $this->actingAs($adminUser)->delete("/api/posts/$post->id"); + $response->assertStatus(204); + $this->assertDatabaseMissing('posts', [ + 'title' => 'Updated Post', + ]); + } + + public function testNonAdminCannotCreateUpdateDeletePost() + { + // Create a user without admin/events permission + $user = User::factory()->create(); + + // Authenticate as the user + $this->actingAs($user); + + // Try to create a new post + $media = Media::factory()->create(['user_id' => $user->id]); + + $newPostData = Post::factory()->make(['user_id' => $user->id, 'hero' => $media->id])->toArray(); + + $response = $this->postJson('/api/posts', $newPostData); + $response->assertStatus(403); + + // Try to update an event + $post = Post::factory()->create(); + $updatedPostData = [ + 'title' => 'Updated Event', + 'content' => 'This is an updated event.', + // Add more fields as needed + ]; + $response = $this->putJson('/api/posts/' . $post->id, $updatedPostData); + $response->assertStatus(403); + + // Try to delete an event + $post = Post::factory()->create(); + $response = $this->deleteJson('/api/posts/' . $post->id); + $response->assertStatus(403); + } +} diff --git a/tests/Feature/UsersEndpointTest.php b/tests/Feature/UsersApiTest.php similarity index 99% rename from tests/Feature/UsersEndpointTest.php rename to tests/Feature/UsersApiTest.php index 7fdeaf6..65a1d63 100644 --- a/tests/Feature/UsersEndpointTest.php +++ b/tests/Feature/UsersApiTest.php @@ -4,7 +4,7 @@ use Illuminate\Foundation\Testing\WithFaker; use Tests\TestCase; use App\Models\User; -class UsersEndpointTest extends TestCase +class UsersApiTest extends TestCase { use RefreshDatabase;