From 3ac8dd6b88e7449ddc83fa4e498f11bc5e7bc971 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20St=C3=B6ckel?= Date: Fri, 28 Jun 2024 15:28:14 +0200 Subject: [PATCH 01/33] no message --- app/Enum/User/FriendCheckinSetting.php | 20 +++++ .../Controllers/API/v1/SettingsController.php | 75 ++++++++++--------- app/Http/Resources/UserBaseResource.php | 3 +- app/Models/User.php | 53 +++++++------ app/Policies/UserPolicy.php | 17 +++++ ...00_add_friend_checkin_setting_to_users.php | 20 +++++ resources/views/includes/status.blade.php | 8 +- 7 files changed, 131 insertions(+), 65 deletions(-) create mode 100644 app/Enum/User/FriendCheckinSetting.php create mode 100644 database/migrations/2024_06_28_000000_add_friend_checkin_setting_to_users.php diff --git a/app/Enum/User/FriendCheckinSetting.php b/app/Enum/User/FriendCheckinSetting.php new file mode 100644 index 000000000..a5a78a395 --- /dev/null +++ b/app/Enum/User/FriendCheckinSetting.php @@ -0,0 +1,20 @@ +user()->sendEmailVerificationNotification(); - $this->sendResponse('', 204); - } catch (RateLimitExceededException) { - $this->sendError(error: __('email.verification.too-many-requests'), code: 429); - } - } - - /** - * @throws ValidationException - */ - public function updatePassword(Request $request): UserProfileSettingsResource|JsonResponse { - $userHasPassword = auth()->user()->password !== null; - - $validated = $request->validate([ - 'currentPassword' => [Rule::requiredIf($userHasPassword)], - 'password' => ['required', 'string', 'min:8', 'confirmed'] - ]); - - if ($userHasPassword && !Hash::check($validated['currentPassword'], auth()->user()->password)) { - throw ValidationException::withMessages([__('controller.user.password-wrong')]); - } - - $validated['password'] = Hash::make($validated['password']); - - try { - return new UserProfileSettingsResource(BackendSettingsController::updateSettings($validated)); - } catch (RateLimitExceededException) { - return $this->sendError(error: __('email.verification.too-many-requests'), code: 400); - } - } - /** * @OA\Put( * path="/settings/profile", @@ -146,10 +114,9 @@ public function updatePassword(Request $request): UserProfileSettingsResource|Js */ public function updateSettings(Request $request): UserProfileSettingsResource|JsonResponse { $validated = $request->validate([ - 'username' => ['required', - 'string', - 'max:25', - 'regex:/^[a-zA-Z0-9_]*$/'], + 'username' => [ + 'required', 'string', 'max:25', 'regex:/^[a-zA-Z0-9_]*$/' + ], 'displayName' => ['required', 'string', 'max:50'], 'privateProfile' => ['boolean', 'nullable'], 'preventIndex' => ['boolean', 'nullable'], @@ -163,6 +130,7 @@ public function updateSettings(Request $request): UserProfileSettingsResource|Js new Enum(MastodonVisibility::class), ], 'mapProvider' => ['nullable', new Enum(MapProvider::class)], + 'friendCheckin' => ['nullable', new Enum(FriendCheckinSetting::class)] ]); try { @@ -172,6 +140,39 @@ public function updateSettings(Request $request): UserProfileSettingsResource|Js } } + public function resendMail(): void { + try { + auth()->user()->sendEmailVerificationNotification(); + $this->sendResponse('', 204); + } catch (RateLimitExceededException) { + $this->sendError(error: __('email.verification.too-many-requests'), code: 429); + } + } + + /** + * @throws ValidationException + */ + public function updatePassword(Request $request): UserProfileSettingsResource|JsonResponse { + $userHasPassword = auth()->user()->password !== null; + + $validated = $request->validate([ + 'currentPassword' => [Rule::requiredIf($userHasPassword)], + 'password' => ['required', 'string', 'min:8', 'confirmed'] + ]); + + if ($userHasPassword && !Hash::check($validated['currentPassword'], auth()->user()->password)) { + throw ValidationException::withMessages([__('controller.user.password-wrong')]); + } + + $validated['password'] = Hash::make($validated['password']); + + try { + return new UserProfileSettingsResource(BackendSettingsController::updateSettings($validated)); + } catch (RateLimitExceededException) { + return $this->sendError(error: __('email.verification.too-many-requests'), code: 400); + } + } + /** * Undocumented and unofficial API Endpoint * diff --git a/app/Http/Resources/UserBaseResource.php b/app/Http/Resources/UserBaseResource.php index bdcb75d0f..2a45997b2 100644 --- a/app/Http/Resources/UserBaseResource.php +++ b/app/Http/Resources/UserBaseResource.php @@ -27,7 +27,8 @@ public function toArray($request): array { [ 'home' => $this->home, 'language' => $this->language, - 'defaultStatusVisibility' => $this->default_status_visibility + 'defaultStatusVisibility' => $this->default_status_visibility, + 'friendCheckin' => $this->friend_checkin, ]), $this->mergeWhen(isset($this->UserResource), [ diff --git a/app/Models/User.php b/app/Models/User.php index 7c97a39da..665714b25 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,6 +4,7 @@ use App\Enum\MapProvider; use App\Enum\StatusVisibility; +use App\Enum\User\FriendCheckinSetting; use App\Exceptions\RateLimitExceededException; use App\Http\Controllers\Backend\Social\MastodonProfileDetails; use App\Jobs\SendVerificationEmail; @@ -25,25 +26,27 @@ use Spatie\Permission\Traits\HasRoles; /** - * @property int id - * @property string username - * @property string name - * @property string avatar - * @property string email - * @property Carbon email_verified_at - * @property string password - * @property int home_id - * @property Carbon privacy_ack_at - * @property integer default_status_visibility - * @property boolean private_profile - * @property boolean prevent_index - * @property boolean likes_enabled - * @property MapProvider mapprovider - * @property int privacy_hide_days - * @property string language - * @property Carbon last_login - * @property Status[] $statuses - * @property SocialLoginProfile socialProfile + * @property int id + * @property string username + * @property string name + * @property string avatar + * @property string email + * @property Carbon email_verified_at + * @property string password + * @property int home_id + * @property Carbon privacy_ack_at + * @property integer default_status_visibility + * @property boolean private_profile + * @property boolean prevent_index + * @property boolean likes_enabled + * @property MapProvider mapprovider + * @property string timezone + * @property FriendCheckinSetting friend_checkin + * @property int privacy_hide_days + * @property string language + * @property Carbon last_login + * @property Status[] $statuses + * @property SocialLoginProfile socialProfile * * @todo replace "role" with an explicit permission system - e.g. spatie/laravel-permission * @todo replace "experimental" also with an explicit permission system - user can add self to "experimental" group @@ -60,7 +63,7 @@ class User extends Authenticatable implements MustVerifyEmail protected $fillable = [ 'username', 'name', 'avatar', 'email', 'email_verified_at', 'password', 'home_id', 'privacy_ack_at', 'default_status_visibility', 'likes_enabled', 'private_profile', 'prevent_index', 'privacy_hide_days', - 'language', 'last_login', 'mapprovider', 'timezone', + 'language', 'last_login', 'mapprovider', 'timezone', 'friend_checkin', ]; protected $hidden = [ 'password', 'remember_token', 'email', 'email_verified_at', 'privacy_ack_at', @@ -82,16 +85,14 @@ class User extends Authenticatable implements MustVerifyEmail 'privacy_hide_days' => 'integer', 'last_login' => 'datetime', 'mapprovider' => MapProvider::class, + 'timezone' => 'string', + 'friend_checkin' => FriendCheckinSetting::class, ]; public function getTrainDistanceAttribute(): float { return Checkin::where('user_id', $this->id)->sum('distance'); } - public function statuses(): HasMany { - return $this->hasMany(Status::class); - } - public function trainCheckins(): HasMany { return $this->hasMany(Checkin::class, 'user_id', 'id'); } @@ -172,6 +173,10 @@ public function getPointsAttribute(): int { ->sum('points'); } + public function statuses(): HasMany { + return $this->hasMany(Status::class); + } + /** * @untested * @todo test diff --git a/app/Policies/UserPolicy.php b/app/Policies/UserPolicy.php index de790f832..eb03eb622 100644 --- a/app/Policies/UserPolicy.php +++ b/app/Policies/UserPolicy.php @@ -2,6 +2,7 @@ namespace App\Policies; +use App\Enum\User\FriendCheckinSetting; use App\Http\Controllers\Backend\User\BlockController; use App\Models\User; use Illuminate\Auth\Access\HandlesAuthorization; @@ -78,4 +79,20 @@ public function update(User $user, User $model): bool { public function delete(User $user, User $model): bool { return $user->id === $model->id; } + + public function checkin(User $user, User $userToCheckin): bool { + if ($user->is($userToCheckin)) { + return true; + } + if ($userToCheckin->friend_checkin === FriendCheckinSetting::FORBIDDEN) { + return false; + } + if ($userToCheckin->friend_checkin === FriendCheckinSetting::FRIENDS) { + //TODO + } + if ($userToCheckin->friend_checkin === FriendCheckinSetting::LIST) { + //TODO + } + return false; + } } diff --git a/database/migrations/2024_06_28_000000_add_friend_checkin_setting_to_users.php b/database/migrations/2024_06_28_000000_add_friend_checkin_setting_to_users.php new file mode 100644 index 000000000..cbe136b96 --- /dev/null +++ b/database/migrations/2024_06_28_000000_add_friend_checkin_setting_to_users.php @@ -0,0 +1,20 @@ +string('friend_checkin')->default('forbidden')->after('timezone'); + }); + } + + public function down(): void { + Schema::table('users', function(Blueprint $table) { + $table->dropColumn('friend_checkin'); + }); + } +}; diff --git a/resources/views/includes/status.blade.php b/resources/views/includes/status.blade.php index 58045b96e..dadf286ff 100644 --- a/resources/views/includes/status.blade.php +++ b/resources/views/includes/status.blade.php @@ -1,7 +1,9 @@ @php use App\Enum\Business; - use App\Http\Controllers\Backend\Helper\StatusHelper;use App\Http\Controllers\Backend\Transport\StationController; - use App\Http\Controllers\Backend\Transport\StatusController;use App\Http\Controllers\Backend\User\ProfilePictureController;use Illuminate\Support\Facades\Gate; + use App\Http\Controllers\Backend\Helper\StatusHelper; + use App\Http\Controllers\Backend\Transport\StationController; + use App\Http\Controllers\Backend\Transport\StatusController; + use App\Http\Controllers\Backend\User\ProfilePictureController; @endphp @php /** @var App\Models\Status $status */ @endphp
@endif

- + @if(!empty($status->body))

{!! StatusController::getPrintableEscapedBody($status) !!} From ef2e57a2efb35143c7b93a0a44070553a8d687e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20St=C3=B6ckel?= Date: Mon, 1 Jul 2024 21:16:29 +0200 Subject: [PATCH 02/33] add policy and tests --- app/Models/User.php | 25 ++++++++- app/Policies/UserPolicy.php | 19 +++++-- ...4_07_28_000001_add_trusted_users_table.php | 24 ++++++++ resources/views/settings/profile.blade.php | 4 +- tests/Feature/APIv1/FriendCheckinTest.php | 55 +++++++++++++++++++ 5 files changed, 118 insertions(+), 9 deletions(-) create mode 100644 database/migrations/2024_07_28_000001_add_trusted_users_table.php create mode 100644 tests/Feature/APIv1/FriendCheckinTest.php diff --git a/app/Models/User.php b/app/Models/User.php index 665714b25..7dff0ef0e 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -19,6 +19,7 @@ use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\DatabaseNotification; use Illuminate\Notifications\Notifiable; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\RateLimiter; use Laravel\Passport\HasApiTokens; @@ -26,6 +27,7 @@ use Spatie\Permission\Traits\HasRoles; /** + * // properties * @property int id * @property string username * @property string name @@ -45,8 +47,25 @@ * @property int privacy_hide_days * @property string language * @property Carbon last_login - * @property Status[] $statuses + * + * // relationships + * @property Collection trainCheckins * @property SocialLoginProfile socialProfile + * @property Station home + * @property Collection likes + * @property Collection follows + * @property Collection blockedUsers + * @property Collection blockedByUsers + * @property Collection mutedUsers + * @property Collection followRequests + * @property Collection followers + * @property Collection followings + * @property Collection sessions + * @property Collection icsTokens + * @property Collection webhooks + * @property Collection notifications + * @property Collection statuses + * @property Collection trustedUsers * * @todo replace "role" with an explicit permission system - e.g. spatie/laravel-permission * @todo replace "experimental" also with an explicit permission system - user can add self to "experimental" group @@ -177,6 +196,10 @@ public function statuses(): HasMany { return $this->hasMany(Status::class); } + public function trustedUsers(): BelongsToMany { + return $this->belongsToMany(__CLASS__, 'trusted_users', 'user_id', 'trusted_id'); + } + /** * @untested * @todo test diff --git a/app/Policies/UserPolicy.php b/app/Policies/UserPolicy.php index eb03eb622..6aceef929 100644 --- a/app/Policies/UserPolicy.php +++ b/app/Policies/UserPolicy.php @@ -80,19 +80,26 @@ public function delete(User $user, User $model): bool { return $user->id === $model->id; } + /** + * Check if user can check in another user + * + * @param User $user + * @param User $userToCheckin + * + * @return bool + */ public function checkin(User $user, User $userToCheckin): bool { - if ($user->is($userToCheckin)) { - return true; - } if ($userToCheckin->friend_checkin === FriendCheckinSetting::FORBIDDEN) { return false; } if ($userToCheckin->friend_checkin === FriendCheckinSetting::FRIENDS) { - //TODO + $userIsFollowingUserToCheckin = $user->follows->contains('id', $userToCheckin->id); + $userIsFollowedByUserToCheckin = $user->followers->contains('id', $user->id); + return $userIsFollowingUserToCheckin && $userIsFollowedByUserToCheckin; } if ($userToCheckin->friend_checkin === FriendCheckinSetting::LIST) { - //TODO + return $userToCheckin->trustedUsers->contains('id', $user->id); } - return false; + return $user->is($userToCheckin); } } diff --git a/database/migrations/2024_07_28_000001_add_trusted_users_table.php b/database/migrations/2024_07_28_000001_add_trusted_users_table.php new file mode 100644 index 000000000..70ca7d0ff --- /dev/null +++ b/database/migrations/2024_07_28_000001_add_trusted_users_table.php @@ -0,0 +1,24 @@ +foreignId('user_id')->constrained()->onDelete('cascade'); + $table->foreignId('trusted_id')->constrained('users')->onDelete('cascade'); + $table->timestamps(); + + $table->primary(['user_id', 'trusted_id']); + + $table->comment('This table is used to store trusted users for friend checkin.'); + }); + } + + public function down(): void { + Schema::dropIfExists('trusted_users'); + } +}; diff --git a/resources/views/settings/profile.blade.php b/resources/views/settings/profile.blade.php index c9bdd5868..ce7a8b78f 100644 --- a/resources/views/settings/profile.blade.php +++ b/resources/views/settings/profile.blade.php @@ -23,11 +23,11 @@ />

- {{__('settings.upload-image')}} - +
create(); + $this->assertTrue(Gate::forUser($user)->allows('checkin', $user)); + } + + public function testUserCanForbidFriendCheckins(): void { + $userToCheckin = User::factory(['friend_checkin' => FriendCheckinSetting::FORBIDDEN->value])->create(); + $user = User::factory()->create(); + $this->assertFalse(Gate::forUser($user)->allows('checkin', $userToCheckin)); + } + + public function testUserCanAllowCheckinsForFriends(): void { + $userToCheckin = User::factory(['friend_checkin' => FriendCheckinSetting::FRIENDS->value])->create(); + $user = User::factory()->create(); + + $this->assertFalse(Gate::forUser($user->fresh())->allows('checkin', $userToCheckin->fresh())); + + // Create a follow relationship between the two users (following each other = friends) + FollowController::createOrRequestFollow($user, $userToCheckin); + FollowController::createOrRequestFollow($userToCheckin, $user); + + $this->assertTrue(Gate::forUser($user->fresh())->allows('checkin', $userToCheckin->fresh())); + } + + public function testUserCanAllowCheckinsForTrustedUsers(): void { + $userToCheckin = User::factory(['friend_checkin' => FriendCheckinSetting::LIST->value])->create(); + $user = User::factory()->create(); + + $this->assertFalse(Gate::forUser($user->fresh())->allows('checkin', $userToCheckin->fresh())); + + // Create a trusted relationship between the two users + // TODO: use backend function to create trusted relationship + $userToCheckin->trustedUsers()->attach($user); + + $this->assertTrue(Gate::forUser($user->fresh())->allows('checkin', $userToCheckin->fresh())); + } +} + From ab5c343a55993e4909166f4efd948124c1fa76cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20St=C3=B6ckel?= Date: Mon, 1 Jul 2024 21:36:50 +0200 Subject: [PATCH 03/33] improve api docs --- .../API/v1/TransportController.php | 8 +- app/Virtual/Models/CheckinRequestBody.php | 162 ++---------------- storage/api-docs/api-docs.json | 79 +++++---- 3 files changed, 66 insertions(+), 183 deletions(-) diff --git a/app/Http/Controllers/API/v1/TransportController.php b/app/Http/Controllers/API/v1/TransportController.php index 335d90eca..2d4c6f8b3 100644 --- a/app/Http/Controllers/API/v1/TransportController.php +++ b/app/Http/Controllers/API/v1/TransportController.php @@ -361,9 +361,9 @@ public function getNextStationByCoordinates(Request $request): JsonResponse { /** * @OA\Post( * path="/trains/checkin", - * operationId="createTrainCheckin", + * operationId="createCheckin", * tags={"Checkin"}, - * summary="Create a checkin", + * summary="Check in to a trip.", * @OA\RequestBody( * required=true, * @OA\JsonContent(ref="#/components/schemas/CheckinRequestBody") @@ -378,7 +378,6 @@ public function getNextStationByCoordinates(Request $request): JsonResponse { * @OA\Response(response=401, description="Unauthorized"), * security={ * {"passport": {"create-statuses"}}, {"token": {}} - * * } * ) * @@ -402,7 +401,8 @@ public function create(Request $request): JsonResponse { 'destination' => ['required', 'numeric'], 'departure' => ['required', 'date'], 'arrival' => ['required', 'date'], - 'force' => ['nullable', 'boolean'] + 'force' => ['nullable', 'boolean'], + 'userId' => ['nullable', 'integer', 'exists:users,id'], // if set: checkin for another user ]); try { diff --git a/app/Virtual/Models/CheckinRequestBody.php b/app/Virtual/Models/CheckinRequestBody.php index 4044d6701..c07422e21 100644 --- a/app/Virtual/Models/CheckinRequestBody.php +++ b/app/Virtual/Models/CheckinRequestBody.php @@ -2,161 +2,29 @@ namespace App\Virtual\Models; -use Carbon\Carbon; - /** * @OA\Schema( * title="CheckinRequestBody", * description="Fields for creating a train checkin", - * @OA\Xml( - * name="CheckinRequestBody" - * ) + * @OA\Property(property="body", type="string", maxLength=280, nullable=true, example="Meine erste Fahrt nach Knuffingen!"), + * @OA\Property(property="business", ref="#/components/schemas/Business",), + * @OA\Property(property="visibility", ref="#/components/schemas/StatusVisibility",), + * @OA\Property(property="eventId", type="integer", nullable=true, example="1", description="Id of an event the status should be connected to"), + * @OA\Property(property="toot", type="boolean", nullable=true, example="false", description="Should this status be posted to mastodon?"), + * @OA\Property(property="chainPost", type="boolean", nullable=true, example="false", description="Should this status be posted to mastodon as a chained post?"), + * @OA\Property(property="ibnr", type="boolean", nullable=true, example="true", description="If true, the `start` and `destination` properties can be supplied as an ibnr. Otherwise they should be given as the Träwelling-ID. Default behavior is `false`."), + * @OA\Property(property="tripId", type="string", nullable=true, example="b37ff515-22e1-463c-94de-3ad7964b5cb8", description="The tripId for the to be checked in train"), + * @OA\Property(property="lineName", type="string", nullable=true, example="S 4", description="The line name for the to be checked in train"), + * @OA\Property(property="start", type="integer", example="8000191", description="The Station-ID of the starting point (see `ibnr`)"), + * @OA\Property(property="destination", type="integer", example="8000192", description="The Station-ID of the destination point (see `ibnr`)"), + * @OA\Property(property="departure", type="string", format="date-time", example="2022-12-19T20:41:00+01:00", description="Timestamp of the departure"), + * @OA\Property(property="arrival", type="string", format="date-time", example="2022-12-19T20:42:00+01:00", description="Timestamp of the arrival"), + * @OA\Property(property="force", type="boolean", nullable=true, example="false", description="If true, the checkin will be created, even if a colliding checkin exists. No points will be awarded."), + * @OA\Property(property="userId", type="array", @OA\Items(type="integer", example="1"), example="[1, 2]", nullable=true, description="If set, the checkin will be created for all given users. If not set, the checkin will be created for the authenticated user. Please note, that you have to add the authenticated userId here too, if you want to checkin the authenticated User AND other users."), * ) */ class CheckinRequestBody { - /** - * @OA\Property( - * title="body", - * description="Text that should be added to the post", - * type="string", - * maxLength=280, - * nullable=true, - * example="Meine erste Fahrt nach Knuffingen!" - * ) - * - * @var string - */ - private $body; - - /** - * @OA\Property ( - * ref="#/components/schemas/Business", - * ) - * - * @var integer - */ - private $business; - - /** - * @OA\Property ( - * ref="#/components/schemas/StatusVisibility" - * ) - * - * @var integer - */ - private $visibility; - - /** - * @OA\Property ( - * title="eventId", - * nullable=true, - * description="Id of an event the status should be connected to", - * type="integer" - * ) - */ - private $eventId; - - /** - * @OA\Property ( - * title="toot", - * nullable=true, - * description="Should this status be posted to mastodon?", - * type="boolean", - * example="false" - * ) - */ - private $toot; - - /** - * @OA\Property ( - * title="chainPost", - * nullable=true, - * description="Should this status be posted to mastodon as a chained post?", - * type="boolean", - * example="false" - * ) - */ - private $chainPost; - - /** - * @OA\Property ( - * title="ibnr", - * nullable=true, - * description="If true, the `start` and `destination` properties can be supplied as an ibnr. Otherwise they - * should be given as the Träwelling-ID. Default behavior is `false`.", type="boolean", example="true", - * ) - */ - private $ibnr; - - /** - * @OA\Property ( - * title="tripId", - * description="The HAFAS tripId for the to be checked in train", - * example="1|323306|1|80|17072022" - * ) - */ - private $tripId; - - /** - * @OA\Property ( - * title="lineName", - * description="The line name for the to be checked in train", - * example="S 4" - * ) - */ - private $lineName; - - /** - * @OA\Property ( - * title="start", - * description="The Station-ID of the starting point (see `ibnr`)", - * example="8000191", - * type="integer" - * ) - */ - private $start; - - /** - * @OA\Property ( - * title="destination", - * description="The Station-ID of the destination (see `ibnr`)", - * example="8079045", - * type="integer" - * ) - */ - private $destination; - - /** - * @OA\Property ( - * title="departure", - * description="Timestamp of the departure", - * example="2022-12-19T20:41:00+01:00", - * ) - * - * @var Carbon - */ - private $departure; - - /** - * @OA\Property ( - * title="arrival", - * description="Timestamp of the arrival", - * example="2022-12-19T20:42:00+01:00", - * ) - * - * @var Carbon - */ - private $arrival; - /** - * @OA\Property ( - * title="force", - * nullable=true, - * description="If true, the checkin will be created, even if a colliding checkin exists. No points will be - * awarded.", type="boolean", example="false", - * ) - */ - private $force; } diff --git a/storage/api-docs/api-docs.json b/storage/api-docs/api-docs.json index e8deb353f..7878c6b27 100644 --- a/storage/api-docs/api-docs.json +++ b/storage/api-docs/api-docs.json @@ -18,7 +18,7 @@ "description": "Production Server" }, { - "url": "https://traewelling.de/api/v1/api/v1", + "url": "http://localhost:8000/api/v1", "description": "This instance" } ], @@ -3440,8 +3440,8 @@ "tags": [ "Checkin" ], - "summary": "Create a checkin", - "operationId": "createTrainCheckin", + "summary": "Check in to a trip.", + "operationId": "createCheckin", "requestBody": { "required": true, "content": { @@ -4491,7 +4491,8 @@ "ferry", "subway", "tram", - "taxi" + "taxi", + "plane" ], "example": "suburban" }, @@ -4554,10 +4555,21 @@ "ferry", "subway", "tram", - "taxi" + "taxi", + "plane" ], "example": "suburban" }, + "FriendCheckinSetting": { + "title": "FriendCheckinSetting", + "type": "string", + "enum": [ + "forbidden", + "friends", + "list" + ], + "example": "forbidden" + }, "ClientResource": { "title": "Client", "properties": { @@ -4938,8 +4950,6 @@ "description": "Fields for creating a train checkin", "properties": { "body": { - "title": "body", - "description": "Text that should be added to the post", "type": "string", "maxLength": 280, "example": "Meine erste Fahrt nach Knuffingen!", @@ -4952,76 +4962,81 @@ "$ref": "#/components/schemas/StatusVisibility" }, "eventId": { - "title": "eventId", "description": "Id of an event the status should be connected to", "type": "integer", + "example": "1", "nullable": true }, "toot": { - "title": "toot", "description": "Should this status be posted to mastodon?", "type": "boolean", "example": "false", "nullable": true }, "chainPost": { - "title": "chainPost", "description": "Should this status be posted to mastodon as a chained post?", "type": "boolean", "example": "false", "nullable": true }, "ibnr": { - "title": "ibnr", - "description": "If true, the `start` and `destination` properties can be supplied as an ibnr. Otherwise they\n * should be given as the Träwelling-ID. Default behavior is `false`.", + "description": "If true, the `start` and `destination` properties can be supplied as an ibnr. Otherwise they should be given as the Träwelling-ID. Default behavior is `false`.", "type": "boolean", "example": "true", "nullable": true }, "tripId": { - "title": "tripId", - "description": "The HAFAS tripId for the to be checked in train", - "example": "1|323306|1|80|17072022" + "description": "The tripId for the to be checked in train", + "type": "string", + "example": "b37ff515-22e1-463c-94de-3ad7964b5cb8", + "nullable": true }, "lineName": { - "title": "lineName", "description": "The line name for the to be checked in train", - "example": "S 4" + "type": "string", + "example": "S 4", + "nullable": true }, "start": { - "title": "start", "description": "The Station-ID of the starting point (see `ibnr`)", "type": "integer", "example": "8000191" }, "destination": { - "title": "destination", - "description": "The Station-ID of the destination (see `ibnr`)", + "description": "The Station-ID of the destination point (see `ibnr`)", "type": "integer", - "example": "8079045" + "example": "8000192" }, "departure": { - "title": "departure", "description": "Timestamp of the departure", + "type": "string", + "format": "date-time", "example": "2022-12-19T20:41:00+01:00" }, "arrival": { - "title": "arrival", "description": "Timestamp of the arrival", + "type": "string", + "format": "date-time", "example": "2022-12-19T20:42:00+01:00" }, "force": { - "title": "force", - "description": "If true, the checkin will be created, even if a colliding checkin exists. No points will be\n * awarded.", + "description": "If true, the checkin will be created, even if a colliding checkin exists. No points will be awarded.", "type": "boolean", "example": "false", "nullable": true + }, + "userId": { + "description": "If set, the checkin will be created for all given users. If not set, the checkin will be created for the authenticated user. Please note, that you have to add the authenticated userId here too, if you want to checkin the authenticated User AND other users.", + "type": "array", + "items": { + "type": "integer", + "example": "1" + }, + "example": "[1, 2]", + "nullable": true } }, - "type": "object", - "xml": { - "name": "CheckinRequestBody" - } + "type": "object" }, "CheckinResponse": { "title": "CheckinResponse", @@ -5967,9 +5982,9 @@ "scheme": "https", "flows": { "authorizationCode": { - "authorizationUrl": "https://traewelling.de/api/v1/oauth/authorize", - "tokenUrl": "https://traewelling.de/api/v1/oauth/token", - "refreshUrl": "https://traewelling.de/api/v1/auth/refresh", + "authorizationUrl": "http://localhost:8000/oauth/authorize", + "tokenUrl": "http://localhost:8000/oauth/token", + "refreshUrl": "http://localhost:8000/auth/refresh", "scopes": { "read-statuses": "see all statuses", "read-notifications": "see your notifications", From de4f78ec8276d503ac95adc163c5fbb74eb65af2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20St=C3=B6ckel?= Date: Mon, 1 Jul 2024 22:56:22 +0200 Subject: [PATCH 04/33] no message --- .../API/v1/TransportController.php | 47 ++++++++++++++-- app/Models/User.php | 2 +- app/Notifications/YouHaveBeenCheckedIn.php | 56 +++++++++++++++++++ app/Virtual/Models/CheckinRequestBody.php | 2 +- lang/de.json | 4 +- lang/en.json | 2 + storage/api-docs/api-docs.json | 4 +- tests/ApiTestCase.php | 8 ++- tests/Feature/APIv1/FriendCheckinTest.php | 33 ++++++++++- 9 files changed, 142 insertions(+), 16 deletions(-) create mode 100644 app/Notifications/YouHaveBeenCheckedIn.php diff --git a/app/Http/Controllers/API/v1/TransportController.php b/app/Http/Controllers/API/v1/TransportController.php index 2d4c6f8b3..c0d38803c 100644 --- a/app/Http/Controllers/API/v1/TransportController.php +++ b/app/Http/Controllers/API/v1/TransportController.php @@ -21,6 +21,8 @@ use App\Http\Resources\TripResource; use App\Models\Event; use App\Models\Station; +use App\Models\User; +use App\Notifications\YouHaveBeenCheckedIn; use Carbon\Carbon; use Exception; use Illuminate\Database\Eloquent\ModelNotFoundException; @@ -402,25 +404,42 @@ public function create(Request $request): JsonResponse { 'departure' => ['required', 'date'], 'arrival' => ['required', 'date'], 'force' => ['nullable', 'boolean'], - 'userId' => ['nullable', 'integer', 'exists:users,id'], // if set: checkin for another user + 'with' => ['nullable', 'array'], ]); + if (isset($validated['with'])) { + if (count($validated['with']) > 10) { + return $this->sendError('You can only checkin for up to 10 users at once.', 400); + } + $withUsers = User::whereIn('id', $validated['with'])->get(); + foreach ($withUsers as $user) { + if (!Auth::user()?->can('checkin', $user)) { + return $this->sendError('You are not allowed to checkin for the given user.', 401); + } + } + } try { $searchKey = empty($validated['ibnr']) ? 'id' : 'ibnr'; + $trip = HafasController::getHafasTrip($validated['tripId'], $validated['lineName']); $originStation = Station::where($searchKey, $validated['start'])->first(); + $departure = Carbon::parse($validated['departure']); $destinationStation = Station::where($searchKey, $validated['destination'])->first(); + $arrival = Carbon::parse($validated['arrival']); + $travelReason = Business::tryFrom($validated['business'] ?? Business::PRIVATE->value); + $event = isset($validated['eventId']) ? Event::find($validated['eventId']) : null; + // check in the authenticated user $checkinResponse = TrainCheckinController::checkin( user: Auth::user(), - trip: HafasController::getHafasTrip($validated['tripId'], $validated['lineName']), + trip: $trip, origin: $originStation, - departure: Carbon::parse($validated['departure']), + departure: $departure, destination: $destinationStation, - arrival: Carbon::parse($validated['arrival']), - travelReason: Business::tryFrom($validated['business'] ?? Business::PRIVATE->value), + arrival: $arrival, + travelReason: $travelReason, visibility: StatusVisibility::tryFrom($validated['visibility'] ?? StatusVisibility::PUBLIC->value), body: $validated['body'] ?? null, - event: isset($validated['eventId']) ? Event::find($validated['eventId']) : null, + event: $event, force: isset($validated['force']) && $validated['force'], postOnMastodon: isset($validated['toot']) && $validated['toot'], shouldChain: isset($validated['chainPost']) && $validated['chainPost'] @@ -440,6 +459,22 @@ public function create(Request $request): JsonResponse { 'additional' => null, //unused old attribute (not removed so this isn't breaking) ]; + // if isset, check in the other users with their default values + foreach ($withUsers ?? [] as $user) { + $checkin = TrainCheckinController::checkin( + user: $user, + trip: $trip, + origin: $originStation, + departure: $departure, + destination: $destinationStation, + arrival: $arrival, + travelReason: $travelReason, + visibility: $user->default_status_visibility, + event: $event, + ); + $user->notify(new YouHaveBeenCheckedIn($checkin['status'], auth()->user())); + } + return $this->sendResponse($checkinResponse, 201); //ToDo: Check if documented structure has changed } catch (CheckInCollisionException $exception) { return $this->sendError([ diff --git a/app/Models/User.php b/app/Models/User.php index 7dff0ef0e..3982bdf0d 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -37,7 +37,7 @@ * @property string password * @property int home_id * @property Carbon privacy_ack_at - * @property integer default_status_visibility + * @property StatusVisibility default_status_visibility * @property boolean private_profile * @property boolean prevent_index * @property boolean likes_enabled diff --git a/app/Notifications/YouHaveBeenCheckedIn.php b/app/Notifications/YouHaveBeenCheckedIn.php new file mode 100644 index 000000000..86e9d0fbf --- /dev/null +++ b/app/Notifications/YouHaveBeenCheckedIn.php @@ -0,0 +1,56 @@ +status = $status; + $this->userCheckedIn = $userCheckedIn; + } + + public function via(): array { + return ['database']; + } + + public function toArray(): array { + return [ + 'status' => $this->status->only(['id']), + 'checkin' => [ + 'line' => $this->status->checkin->trip->linename, + 'origin' => $this->status->checkin->originStopover->station->name, + 'destination' => $this->status->checkin->destinationStopover->station->name, + ], + 'user' => $this->userCheckedIn->only(['id', 'username', 'name']), + ]; + } + + public static function getLead(array $data): string { + return __('notifications.youHaveBeenCheckedIn.lead', [ + 'username' => $data['user']['username'], + ]); + } + + public static function getNotice(array $data): ?string { + return __('notifications.userJoinedConnection.notice', [ + 'line' => $data['checkin']['line'], + 'origin' => $data['checkin']['origin'], + 'destination' => $data['checkin']['destination'], + ] + ); + } + + public static function getLink(array $data): ?string { + return route('status', ['id' => $data['status']['id']]); + } +} diff --git a/app/Virtual/Models/CheckinRequestBody.php b/app/Virtual/Models/CheckinRequestBody.php index c07422e21..e3835b8c9 100644 --- a/app/Virtual/Models/CheckinRequestBody.php +++ b/app/Virtual/Models/CheckinRequestBody.php @@ -20,7 +20,7 @@ * @OA\Property(property="departure", type="string", format="date-time", example="2022-12-19T20:41:00+01:00", description="Timestamp of the departure"), * @OA\Property(property="arrival", type="string", format="date-time", example="2022-12-19T20:42:00+01:00", description="Timestamp of the arrival"), * @OA\Property(property="force", type="boolean", nullable=true, example="false", description="If true, the checkin will be created, even if a colliding checkin exists. No points will be awarded."), - * @OA\Property(property="userId", type="array", @OA\Items(type="integer", example="1"), example="[1, 2]", nullable=true, description="If set, the checkin will be created for all given users. If not set, the checkin will be created for the authenticated user. Please note, that you have to add the authenticated userId here too, if you want to checkin the authenticated User AND other users."), + * @OA\Property(property="traewellingWith", type="array", @OA\Items(type="integer", example="1"), example="[1, 2]", nullable=true, description="If set, the checkin will be created for all given users as well. The user creating the checkin must be allowed to checkin for the other users. Max. 10 users."), * ) */ class CheckinRequestBody diff --git a/lang/de.json b/lang/de.json index 596728924..e2e2608bd 100644 --- a/lang/de.json +++ b/lang/de.json @@ -794,5 +794,7 @@ "action.error": "Diese Aktion konnte leider nicht ausgeführt werden. Bitte versuche es später noch einmal.", "action.like": "Status liken", "action.dislike": "Status nicht mehr liken", - "action.set-home": "Heimathaltestelle setzen" + "action.set-home": "Heimathaltestelle setzen", + "notifications.youHaveBeenCheckedIn.lead": "Du wurdest von :username eingecheckt", + "notifications.youHaveBeenCheckedIn.notice": "Fahrt in :line von :origin nach :destination" } diff --git a/lang/en.json b/lang/en.json index abbb25b51..96794aa7a 100644 --- a/lang/en.json +++ b/lang/en.json @@ -249,6 +249,8 @@ "notifications.statusLiked.lead": "@:likerUsername liked your check-in.", "notifications.statusLiked.notice": "Journey in :line on :createdDate|Journey in line :line on :createdDate", "notifications.userFollowed.lead": "@:followerUsername follows you now.", + "notifications.youHaveBeenCheckedIn.lead": "You have been checked in by @:username", + "notifications.youHaveBeenCheckedIn.notice": "Journey in :line from :origin to :destination", "notifications.userRequestedFollow.lead": "@:followerRequestUsername wants to follow you.", "notifications.userRequestedFollow.notice": "To accept or decline the follow request, click here or go to your settings.", "notifications.userApprovedFollow.lead": "@:followerRequestUsername has approved your follow request.", diff --git a/storage/api-docs/api-docs.json b/storage/api-docs/api-docs.json index 7878c6b27..f6bca2ec1 100644 --- a/storage/api-docs/api-docs.json +++ b/storage/api-docs/api-docs.json @@ -5025,8 +5025,8 @@ "example": "false", "nullable": true }, - "userId": { - "description": "If set, the checkin will be created for all given users. If not set, the checkin will be created for the authenticated user. Please note, that you have to add the authenticated userId here too, if you want to checkin the authenticated User AND other users.", + "traewellingWith": { + "description": "If set, the checkin will be created for all given users as well. The user creating the checkin must be allowed to checkin for the other users. Max. 10 users.", "type": "array", "items": { "type": "integer", diff --git a/tests/ApiTestCase.php b/tests/ApiTestCase.php index 65671d6dd..463c4d2a0 100644 --- a/tests/ApiTestCase.php +++ b/tests/ApiTestCase.php @@ -3,7 +3,6 @@ namespace Tests; use App\Models\User; -use App\Providers\AuthServiceProvider; use Illuminate\Testing\TestResponse; use Laravel\Passport\Passport; @@ -17,8 +16,11 @@ public function setUp(): void { $this->artisan('passport:keys', ['--no-interaction' => true]); } - protected function actAsApiUserWithAllScopes(): void { - Passport::actingAs(User::factory()->create(), ['*']); + protected function actAsApiUserWithAllScopes(User $user = null): void { + if ($user === null) { + $user = User::factory()->create(); + } + Passport::actingAs($user, ['*']); } protected function assertUserResource(TestResponse $response): void { diff --git a/tests/Feature/APIv1/FriendCheckinTest.php b/tests/Feature/APIv1/FriendCheckinTest.php index e5d8d5b62..45d7266b1 100644 --- a/tests/Feature/APIv1/FriendCheckinTest.php +++ b/tests/Feature/APIv1/FriendCheckinTest.php @@ -4,7 +4,9 @@ use App\Enum\User\FriendCheckinSetting; use App\Http\Controllers\Backend\User\FollowController; +use App\Models\Trip; use App\Models\User; +use App\Notifications\YouHaveBeenCheckedIn; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Gate; use Tests\ApiTestCase; @@ -30,13 +32,40 @@ public function testUserCanAllowCheckinsForFriends(): void { $userToCheckin = User::factory(['friend_checkin' => FriendCheckinSetting::FRIENDS->value])->create(); $user = User::factory()->create(); - $this->assertFalse(Gate::forUser($user->fresh())->allows('checkin', $userToCheckin->fresh())); + $this->assertFalse(Gate::forUser($user->refresh())->allows('checkin', $userToCheckin->refresh())); // Create a follow relationship between the two users (following each other = friends) FollowController::createOrRequestFollow($user, $userToCheckin); FollowController::createOrRequestFollow($userToCheckin, $user); - $this->assertTrue(Gate::forUser($user->fresh())->allows('checkin', $userToCheckin->fresh())); + $this->assertTrue(Gate::forUser($user->refresh())->allows('checkin', $userToCheckin->refresh())); + + // check that there are currently no checkins + $this->assertDatabaseCount('train_checkins', 0); + + // check in both users + $trip = Trip::factory()->create(); + + $this->actAsApiUserWithAllScopes($user); + $response = $this->postJson( + uri: '/api/v1/trains/checkin', + data: [ + 'tripId' => $trip->trip_id, + 'lineName' => $trip->linename, + 'start' => $trip->originStation->id, + 'departure' => $trip->departure, + 'destination' => $trip->destinationStation->id, + 'arrival' => $trip->arrival, + 'with' => [ + $userToCheckin->id + ] + ], + ); + $response->assertCreated(); + + $this->assertDatabaseHas('train_checkins', ['user_id' => $user->id, 'trip_id' => $trip->trip_id]); + $this->assertDatabaseHas('train_checkins', ['user_id' => $userToCheckin->id, 'trip_id' => $trip->trip_id]); + $this->assertDatabaseHas('notifications', ['type' => YouHaveBeenCheckedIn::class, 'notifiable_id' => $userToCheckin->id]); } public function testUserCanAllowCheckinsForTrustedUsers(): void { From 41bba53d5cc9fce655f7e3251a3bd91e92827027 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20St=C3=B6ckel?= Date: Mon, 1 Jul 2024 23:01:50 +0200 Subject: [PATCH 05/33] no message --- app/Virtual/Models/CheckinRequestBody.php | 2 +- storage/api-docs/api-docs.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Virtual/Models/CheckinRequestBody.php b/app/Virtual/Models/CheckinRequestBody.php index e3835b8c9..4bad40506 100644 --- a/app/Virtual/Models/CheckinRequestBody.php +++ b/app/Virtual/Models/CheckinRequestBody.php @@ -20,7 +20,7 @@ * @OA\Property(property="departure", type="string", format="date-time", example="2022-12-19T20:41:00+01:00", description="Timestamp of the departure"), * @OA\Property(property="arrival", type="string", format="date-time", example="2022-12-19T20:42:00+01:00", description="Timestamp of the arrival"), * @OA\Property(property="force", type="boolean", nullable=true, example="false", description="If true, the checkin will be created, even if a colliding checkin exists. No points will be awarded."), - * @OA\Property(property="traewellingWith", type="array", @OA\Items(type="integer", example="1"), example="[1, 2]", nullable=true, description="If set, the checkin will be created for all given users as well. The user creating the checkin must be allowed to checkin for the other users. Max. 10 users."), + * @OA\Property(property="with", type="array", @OA\Items(type="integer", example="1"), example="[1, 2]", nullable=true, description="If set, the checkin will be created for all given users as well. The user creating the checkin must be allowed to checkin for the other users. Max. 10 users."), * ) */ class CheckinRequestBody diff --git a/storage/api-docs/api-docs.json b/storage/api-docs/api-docs.json index f6bca2ec1..71c8d98f7 100644 --- a/storage/api-docs/api-docs.json +++ b/storage/api-docs/api-docs.json @@ -5025,7 +5025,7 @@ "example": "false", "nullable": true }, - "traewellingWith": { + "with": { "description": "If set, the checkin will be created for all given users as well. The user creating the checkin must be allowed to checkin for the other users. Max. 10 users.", "type": "array", "items": { From 351367179f7f300ac5c7e1ae303aeb3a2051ef37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20St=C3=B6ckel?= Date: Mon, 1 Jul 2024 23:14:17 +0200 Subject: [PATCH 06/33] :white_check_mark: Adding tests. --- tests/Feature/APIv1/FriendCheckinTest.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/Feature/APIv1/FriendCheckinTest.php b/tests/Feature/APIv1/FriendCheckinTest.php index 45d7266b1..2f115506c 100644 --- a/tests/Feature/APIv1/FriendCheckinTest.php +++ b/tests/Feature/APIv1/FriendCheckinTest.php @@ -65,7 +65,12 @@ public function testUserCanAllowCheckinsForFriends(): void { $this->assertDatabaseHas('train_checkins', ['user_id' => $user->id, 'trip_id' => $trip->trip_id]); $this->assertDatabaseHas('train_checkins', ['user_id' => $userToCheckin->id, 'trip_id' => $trip->trip_id]); - $this->assertDatabaseHas('notifications', ['type' => YouHaveBeenCheckedIn::class, 'notifiable_id' => $userToCheckin->id]); + + $notification = $userToCheckin->refresh()->notifications->last(); + $this->assertEquals(YouHaveBeenCheckedIn::class, $notification->type); + $this->assertStringContainsString($user->username, YouHaveBeenCheckedIn::getLead($notification->data)); + $this->assertStringContainsString($trip->originStation->name, YouHaveBeenCheckedIn::getNotice($notification->data)); + $this->assertStringContainsString($userToCheckin->statuses->last()->id, YouHaveBeenCheckedIn::getLink($notification->data)); } public function testUserCanAllowCheckinsForTrustedUsers(): void { From 4f8833b6fd0e9d3e770900993c00b903420cf797 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20St=C3=B6ckel?= Date: Mon, 1 Jul 2024 23:16:58 +0200 Subject: [PATCH 07/33] fix notification selection --- tests/Feature/APIv1/FriendCheckinTest.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/Feature/APIv1/FriendCheckinTest.php b/tests/Feature/APIv1/FriendCheckinTest.php index 2f115506c..0d1fdfe1d 100644 --- a/tests/Feature/APIv1/FriendCheckinTest.php +++ b/tests/Feature/APIv1/FriendCheckinTest.php @@ -66,8 +66,7 @@ public function testUserCanAllowCheckinsForFriends(): void { $this->assertDatabaseHas('train_checkins', ['user_id' => $user->id, 'trip_id' => $trip->trip_id]); $this->assertDatabaseHas('train_checkins', ['user_id' => $userToCheckin->id, 'trip_id' => $trip->trip_id]); - $notification = $userToCheckin->refresh()->notifications->last(); - $this->assertEquals(YouHaveBeenCheckedIn::class, $notification->type); + $notification = $userToCheckin->refresh()->notifications->where('type', YouHaveBeenCheckedIn::class)->last(); $this->assertStringContainsString($user->username, YouHaveBeenCheckedIn::getLead($notification->data)); $this->assertStringContainsString($trip->originStation->name, YouHaveBeenCheckedIn::getNotice($notification->data)); $this->assertStringContainsString($userToCheckin->statuses->last()->id, YouHaveBeenCheckedIn::getLink($notification->data)); From cf5ed27ed4b28fb1edc2086b2878739f78da9ef0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20St=C3=B6ckel?= Date: Tue, 2 Jul 2024 00:03:16 +0200 Subject: [PATCH 08/33] add endpoints for trusted users --- .../API/v1/TrustedUserController.php | 92 +++++++++++ app/Policies/UserPolicy.php | 2 +- routes/api.php | 2 + storage/api-docs/api-docs.json | 143 ++++++++++++++++++ tests/Feature/APIv1/TrustedUserTest.php | 78 ++++++++++ 5 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 app/Http/Controllers/API/v1/TrustedUserController.php create mode 100644 tests/Feature/APIv1/TrustedUserTest.php diff --git a/app/Http/Controllers/API/v1/TrustedUserController.php b/app/Http/Controllers/API/v1/TrustedUserController.php new file mode 100644 index 000000000..a00005619 --- /dev/null +++ b/app/Http/Controllers/API/v1/TrustedUserController.php @@ -0,0 +1,92 @@ +authorize('update', $user); + return LightUserResource::collection($user->trustedUsers()->orderBy('id')->cursorPaginate(10)); + } + + /** + * @OA\Post( + * path="/user/{userId}/trusted", + * summary="Add a user to the trusted users for a user", + * description="Add a user to the trusted users for the current user or a specific user (admin only).", + * tags={"User"}, + * @OA\Parameter(name="user", in="path", required=true, description="ID of the user who want's to trust.", @OA\Schema(type="integer")), + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * required={"user_id"}, + * @OA\Property(property="user_id", type="integer", example="1"), + * ) + * ), + * @OA\Response(response="201", description="User added to trusted users"), + * @OA\Response(response="400", description="Bad Request"), + * @OA\Response(response="401", description="Unauthorized"), + * @OA\Response(response="403", description="Forbidden"), + * @OA\Response(response="404", description="User not found"), + * @OA\Response(response="500", description="Internal Server Error"), + * ) + * @throws AuthorizationException + */ + public function store(Request $request, User $user): Response { + $validated = $request->validate([ + 'user_id' => ['required', 'exists:users,id'], + ]); + $trustedUser = User::find($validated['user_id']); + $this->authorize('update', $user); + $user->trustedUsers()->attach($trustedUser); + return response()->noContent(201); + } + + /** + * @OA\Delete( + * path="/user/{userId}/trusted/{trustedId}", + * summary="Remove a user from the trusted users for a user", + * description="Remove a user from the trusted users for the current user or a specific user (admin only).", + * tags={"User"}, + * @OA\Parameter(name="user", in="path", required=true, description="ID of the user", @OA\Schema(type="integer")), + * @OA\Parameter(name="trusted", in="path", required=true, description="ID of the trusted user", @OA\Schema(type="integer")), + * @OA\Response(response="204", description="User removed from trusted users"), + * @OA\Response(response="401", description="Unauthorized"), + * @OA\Response(response="403", description="Forbidden"), + * @OA\Response(response="404", description="User not found"), + * @OA\Response(response="500", description="Internal Server Error"), + * ) + * @throws AuthorizationException + */ + public function destroy(int $user, int $trusted): Response { + $user = User::findOrFail($user); + $trusted = User::findOrFail($trusted); + $this->authorize('update', $user); + $user->trustedUsers()->detach($trusted); + return response()->noContent(); + } +} diff --git a/app/Policies/UserPolicy.php b/app/Policies/UserPolicy.php index 6aceef929..06fedbee1 100644 --- a/app/Policies/UserPolicy.php +++ b/app/Policies/UserPolicy.php @@ -65,7 +65,7 @@ public function view(?User $user, User $model): Response { * @return bool */ public function update(User $user, User $model): bool { - return $user->id === $model->id; + return $user->id === $model->id || $user->hasRole('admin'); } /** diff --git a/routes/api.php b/routes/api.php index 3e906d245..d441e47fa 100644 --- a/routes/api.php +++ b/routes/api.php @@ -31,6 +31,7 @@ use App\Http\Controllers\API\v1\TokenController; use App\Http\Controllers\API\v1\TransportController; use App\Http\Controllers\API\v1\TripController; +use App\Http\Controllers\API\v1\TrustedUserController; use App\Http\Controllers\API\v1\UserController; use App\Http\Controllers\API\v1\WebhookController; use App\Http\Controllers\API\v1\YearInReviewController; @@ -176,6 +177,7 @@ Route::apiResource('station', StationController::class); // currently admin/backend only Route::put('station/{oldStationId}/merge/{newStationId}', [StationController::class, 'merge']); // currently admin/backend only + Route::apiResource('user.trusted', TrustedUserController::class)->only(['index', 'store', 'destroy']); Route::apiResource('report', ReportController::class); Route::apiResource('operators', OperatorController::class)->only(['index']); }); diff --git a/storage/api-docs/api-docs.json b/storage/api-docs/api-docs.json index 71c8d98f7..3f3c0ab58 100644 --- a/storage/api-docs/api-docs.json +++ b/storage/api-docs/api-docs.json @@ -3639,6 +3639,149 @@ ] } }, + "/user/{userId}/trusted": { + "get": { + "tags": [ + "User" + ], + "summary": "Get all trusted users for a user", + "description": "Get all trusted users for the current user or a specific user (admin only).", + "operationId": "5d1e940f6900373497a76522f3bcd9bc", + "parameters": [ + { + "name": "user", + "in": "path", + "description": "ID of the user", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "List of trusted users" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "User not found" + }, + "500": { + "description": "Internal Server Error" + } + } + }, + "post": { + "tags": [ + "User" + ], + "summary": "Add a user to the trusted users for a user", + "description": "Add a user to the trusted users for the current user or a specific user (admin only).", + "operationId": "f367af889d00c939741b2d93646e7fd1", + "parameters": [ + { + "name": "user", + "in": "path", + "description": "ID of the user who want's to trust.", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "required": [ + "user_id" + ], + "properties": { + "user_id": { + "type": "integer", + "example": "1" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "User added to trusted users" + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "User not found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/user/{userId}/trusted/{trustedId}": { + "delete": { + "tags": [ + "User" + ], + "summary": "Remove a user from the trusted users for a user", + "description": "Remove a user from the trusted users for the current user or a specific user (admin only).", + "operationId": "73fed32ea6148c7ad95aebf8a1cdc786", + "parameters": [ + { + "name": "user", + "in": "path", + "description": "ID of the user", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "trusted", + "in": "path", + "description": "ID of the trusted user", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "204": { + "description": "User removed from trusted users" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "User not found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, "/settings/account": { "delete": { "tags": [ diff --git a/tests/Feature/APIv1/TrustedUserTest.php b/tests/Feature/APIv1/TrustedUserTest.php new file mode 100644 index 000000000..50e2afeea --- /dev/null +++ b/tests/Feature/APIv1/TrustedUserTest.php @@ -0,0 +1,78 @@ +create(); + $trustedUser = User::factory()->create(); + $this->actAsApiUserWithAllScopes($user); + + // trust user + $response = $this->postJson("/api/v1/user/{$user->id}/trusted", ['user_id' => $trustedUser->id]); + $response->assertCreated(); + $this->assertDatabaseHas('trusted_users', ['user_id' => $user->id, 'trusted_id' => $trustedUser->id]); + + // list trusted users + $response = $this->getJson("/api/v1/user/{$user->id}/trusted"); + $response->assertOk(); + $response->assertJsonCount(1, 'data'); + $response->assertJsonFragment(['id' => $trustedUser->id]); + + // untrust user + $response = $this->deleteJson("/api/v1/user/{$user->id}/trusted/{$trustedUser->id}"); + $response->assertNoContent(); + $this->assertDatabaseMissing('trusted_users', ['user_id' => $user->id, 'trusted_id' => $trustedUser->id]); + } + + public function testStoreAndDeleteTrustedUserForOtherUsersAsNonAdmin(): void { + $user = User::factory()->create(); + $truster = User::factory()->create(); + $trustedUser = User::factory()->create(); + $this->actAsApiUserWithAllScopes($user); + + // trust user + $response = $this->postJson("/api/v1/user/{$truster->id}/trusted", ['user_id' => $trustedUser->id]); + $response->assertForbidden(); + + // list trusted users + $response = $this->getJson("/api/v1/user/{$truster->id}/trusted"); + $response->assertForbidden(); + + // untrust user + $response = $this->deleteJson("/api/v1/user/{$truster->id}/trusted/{$trustedUser->id}"); + $response->assertForbidden(); + } + + public function testStoreAndDeleteTrustedUserForOtherUsersAsAdmin(): void { + $user = User::factory()->create()->assignRole('admin'); + $truster = User::factory()->create(); + $trustedUser = User::factory()->create(); + $this->actAsApiUserWithAllScopes($user); + + // trust user + $response = $this->postJson("/api/v1/user/{$truster->id}/trusted", ['user_id' => $trustedUser->id]); + $response->assertCreated(); + $this->assertDatabaseHas('trusted_users', ['user_id' => $truster->id, 'trusted_id' => $trustedUser->id]); + + // list trusted users + $response = $this->getJson("/api/v1/user/{$truster->id}/trusted"); + $response->assertOk(); + $response->assertJsonCount(1, 'data'); + $response->assertJsonFragment(['id' => $trustedUser->id]); + + // untrust user + $response = $this->deleteJson("/api/v1/user/{$truster->id}/trusted/{$trustedUser->id}"); + $response->assertNoContent(); + $this->assertDatabaseMissing('trusted_users', ['user_id' => $truster->id, 'trusted_id' => $trustedUser->id]); + } +} + From e0f7836223f60b534d7083e9319b5fd60adf6c02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20St=C3=B6ckel?= Date: Tue, 2 Jul 2024 09:51:29 +0200 Subject: [PATCH 09/33] add expire date --- .../DatabaseCleaner/DatabaseCleaner.php | 1 + .../Commands/DatabaseCleaner/TrustedUser.php | 18 +++++++++ .../API/v1/TrustedUserController.php | 21 +++++++--- app/Http/Resources/TrustedUserResource.php | 22 ++++++++++ app/Models/TrustedUser.php | 40 +++++++++++++++++++ app/Models/User.php | 6 ++- app/Policies/UserPolicy.php | 2 +- ...4_07_28_000001_add_trusted_users_table.php | 8 +++- tests/Feature/APIv1/FriendCheckinTest.php | 10 +++-- tests/Feature/APIv1/TrustedUserTest.php | 26 +++++++++--- 10 files changed, 136 insertions(+), 18 deletions(-) create mode 100644 app/Console/Commands/DatabaseCleaner/TrustedUser.php create mode 100644 app/Http/Resources/TrustedUserResource.php create mode 100644 app/Models/TrustedUser.php diff --git a/app/Console/Commands/DatabaseCleaner/DatabaseCleaner.php b/app/Console/Commands/DatabaseCleaner/DatabaseCleaner.php index fc227ca59..96ee599b5 100644 --- a/app/Console/Commands/DatabaseCleaner/DatabaseCleaner.php +++ b/app/Console/Commands/DatabaseCleaner/DatabaseCleaner.php @@ -15,6 +15,7 @@ public function handle(): int { $this->call(Polylines::class); $this->call(PolylinesBrouter::class); $this->call(User::class); + $this->call(TrustedUser::class); $this->call(Trips::class); $this->call('queue-monitor:purge', ['--beforeDays' => 7]); diff --git a/app/Console/Commands/DatabaseCleaner/TrustedUser.php b/app/Console/Commands/DatabaseCleaner/TrustedUser.php new file mode 100644 index 000000000..de493fd0d --- /dev/null +++ b/app/Console/Commands/DatabaseCleaner/TrustedUser.php @@ -0,0 +1,18 @@ +delete(); + $this->info($affectedRows . ' expired trusted users deleted.'); + return 0; + } +} diff --git a/app/Http/Controllers/API/v1/TrustedUserController.php b/app/Http/Controllers/API/v1/TrustedUserController.php index a00005619..a331cfe0a 100644 --- a/app/Http/Controllers/API/v1/TrustedUserController.php +++ b/app/Http/Controllers/API/v1/TrustedUserController.php @@ -3,7 +3,8 @@ namespace App\Http\Controllers\API\v1; -use App\Http\Resources\LightUserResource; +use App\Http\Resources\TrustedUserResource; +use App\Models\TrustedUser; use App\Models\User; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\Request; @@ -30,7 +31,7 @@ class TrustedUserController extends Controller */ public function index(User $user): AnonymousResourceCollection { $this->authorize('update', $user); - return LightUserResource::collection($user->trustedUsers()->orderBy('id')->cursorPaginate(10)); + return TrustedUserResource::collection($user->trustedUsers()->orderBy('id')->cursorPaginate(10)); } /** @@ -45,6 +46,7 @@ public function index(User $user): AnonymousResourceCollection { * @OA\JsonContent( * required={"user_id"}, * @OA\Property(property="user_id", type="integer", example="1"), + * @OA\Property(property="expires_at", type="string", format="date-time", example="2024-07-28T00:00:00Z") * ) * ), * @OA\Response(response="201", description="User added to trusted users"), @@ -58,11 +60,20 @@ public function index(User $user): AnonymousResourceCollection { */ public function store(Request $request, User $user): Response { $validated = $request->validate([ - 'user_id' => ['required', 'exists:users,id'], + 'user_id' => ['required', 'exists:users,id'], + 'expires_at' => ['nullable', 'date', 'after:now'], ]); $trustedUser = User::find($validated['user_id']); $this->authorize('update', $user); - $user->trustedUsers()->attach($trustedUser); + TrustedUser::updateOrCreate( + [ + 'user_id' => $user->id, + 'trusted_id' => $trustedUser->id, + ], + [ + 'expires_at' => $validated['expires_at'] ?? null, + ] + ); return response()->noContent(201); } @@ -86,7 +97,7 @@ public function destroy(int $user, int $trusted): Response { $user = User::findOrFail($user); $trusted = User::findOrFail($trusted); $this->authorize('update', $user); - $user->trustedUsers()->detach($trusted); + TrustedUser::where('user_id', $user->id)->where('trusted_id', $trusted->id)->delete(); return response()->noContent(); } } diff --git a/app/Http/Resources/TrustedUserResource.php b/app/Http/Resources/TrustedUserResource.php new file mode 100644 index 000000000..1ab948120 --- /dev/null +++ b/app/Http/Resources/TrustedUserResource.php @@ -0,0 +1,22 @@ + new LightUserResource($this->trusted), + 'expires_at' => $this->expires_at?->toIso8601String() + ]; + } +} diff --git a/app/Models/TrustedUser.php b/app/Models/TrustedUser.php new file mode 100644 index 000000000..d7e1723f5 --- /dev/null +++ b/app/Models/TrustedUser.php @@ -0,0 +1,40 @@ + 'string', + 'user_id' => 'integer', + 'trusted_id' => 'integer', + 'expires_at' => 'datetime', + ]; + + public function trusted(): BelongsTo { + return $this->belongsTo(User::class, 'trusted_id', 'id'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 3982bdf0d..1f817c534 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -196,8 +196,10 @@ public function statuses(): HasMany { return $this->hasMany(Status::class); } - public function trustedUsers(): BelongsToMany { - return $this->belongsToMany(__CLASS__, 'trusted_users', 'user_id', 'trusted_id'); + public function trustedUsers(): HasMany { + return $this->hasMany(TrustedUser::class, 'user_id', 'id') + ->with(['trusted']) + ->whereNull('expires_at')->orWhere('expires_at', '>', now()); } /** diff --git a/app/Policies/UserPolicy.php b/app/Policies/UserPolicy.php index 06fedbee1..5f7383dfc 100644 --- a/app/Policies/UserPolicy.php +++ b/app/Policies/UserPolicy.php @@ -98,7 +98,7 @@ public function checkin(User $user, User $userToCheckin): bool { return $userIsFollowingUserToCheckin && $userIsFollowedByUserToCheckin; } if ($userToCheckin->friend_checkin === FriendCheckinSetting::LIST) { - return $userToCheckin->trustedUsers->contains('id', $user->id); + return $userToCheckin->trustedUsers->contains('trusted_id', $user->id); } return $user->is($userToCheckin); } diff --git a/database/migrations/2024_07_28_000001_add_trusted_users_table.php b/database/migrations/2024_07_28_000001_add_trusted_users_table.php index 70ca7d0ff..7bec63e32 100644 --- a/database/migrations/2024_07_28_000001_add_trusted_users_table.php +++ b/database/migrations/2024_07_28_000001_add_trusted_users_table.php @@ -8,11 +8,15 @@ { public function up(): void { Schema::create('trusted_users', static function(Blueprint $table) { - $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->uuid('id')->primary(); + + $table->foreignId('user_id')->constrained('users')->onDelete('cascade'); $table->foreignId('trusted_id')->constrained('users')->onDelete('cascade'); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); - $table->primary(['user_id', 'trusted_id']); + $table->unique(['user_id', 'trusted_id']); $table->comment('This table is used to store trusted users for friend checkin.'); }); diff --git a/tests/Feature/APIv1/FriendCheckinTest.php b/tests/Feature/APIv1/FriendCheckinTest.php index 0d1fdfe1d..a1ad2ff18 100644 --- a/tests/Feature/APIv1/FriendCheckinTest.php +++ b/tests/Feature/APIv1/FriendCheckinTest.php @@ -75,12 +75,16 @@ public function testUserCanAllowCheckinsForFriends(): void { public function testUserCanAllowCheckinsForTrustedUsers(): void { $userToCheckin = User::factory(['friend_checkin' => FriendCheckinSetting::LIST->value])->create(); $user = User::factory()->create(); - + $this->assertFalse(Gate::forUser($user->fresh())->allows('checkin', $userToCheckin->fresh())); // Create a trusted relationship between the two users - // TODO: use backend function to create trusted relationship - $userToCheckin->trustedUsers()->attach($user); + $this->actAsApiUserWithAllScopes($userToCheckin); + $response = $this->postJson( + uri: "/api/v1/user/{$userToCheckin->id}/trusted", + data: ['user_id' => $user->id] + ); + $response->assertCreated(); $this->assertTrue(Gate::forUser($user->fresh())->allows('checkin', $userToCheckin->fresh())); } diff --git a/tests/Feature/APIv1/TrustedUserTest.php b/tests/Feature/APIv1/TrustedUserTest.php index 50e2afeea..bd7cc7a08 100644 --- a/tests/Feature/APIv1/TrustedUserTest.php +++ b/tests/Feature/APIv1/TrustedUserTest.php @@ -17,7 +17,10 @@ public function testStoreAndDeleteTrustedUserForYourself(): void { $this->actAsApiUserWithAllScopes($user); // trust user - $response = $this->postJson("/api/v1/user/{$user->id}/trusted", ['user_id' => $trustedUser->id]); + $response = $this->postJson("/api/v1/user/{$user->id}/trusted", [ + 'user_id' => $trustedUser->id, + 'expires_at' => now()->addDay()->toIso8601String(), + ]); $response->assertCreated(); $this->assertDatabaseHas('trusted_users', ['user_id' => $user->id, 'trusted_id' => $trustedUser->id]); @@ -27,10 +30,23 @@ public function testStoreAndDeleteTrustedUserForYourself(): void { $response->assertJsonCount(1, 'data'); $response->assertJsonFragment(['id' => $trustedUser->id]); - // untrust user - $response = $this->deleteJson("/api/v1/user/{$user->id}/trusted/{$trustedUser->id}"); - $response->assertNoContent(); - $this->assertDatabaseMissing('trusted_users', ['user_id' => $user->id, 'trusted_id' => $trustedUser->id]); + // test, that the cleanup script does not delete the trusted user + $this->assertDatabaseCount('trusted_users', 1); + $this->assertEquals(0, $this->artisan('app:clean-db:trusted-user')); + $this->assertDatabaseCount('trusted_users', 1); + + $this->travel(2)->days(); + + // should not list expired trusted users, even if in database. + $response = $this->getJson("/api/v1/user/{$user->id}/trusted"); + $response->assertOk(); + $response->assertJsonCount(0, 'data'); + $response->assertJsonMissing(['id' => $trustedUser->id]); + + // now the cleanup script should delete the trusted user + $this->assertDatabaseCount('trusted_users', 1); + $this->assertEquals(0, $this->artisan('app:clean-db:trusted-user')); + $this->assertDatabaseCount('trusted_users', 0); } public function testStoreAndDeleteTrustedUserForOtherUsersAsNonAdmin(): void { From f2996d51307da1e35bd09a2e45bfbbfb76ebbc5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20St=C3=B6ckel?= Date: Tue, 2 Jul 2024 10:06:36 +0200 Subject: [PATCH 10/33] pagination test (failing) --- .../API/v1/TrustedUserController.php | 2 +- tests/Feature/APIv1/TrustedUserTest.php | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/app/Http/Controllers/API/v1/TrustedUserController.php b/app/Http/Controllers/API/v1/TrustedUserController.php index a331cfe0a..e8ea6ad36 100644 --- a/app/Http/Controllers/API/v1/TrustedUserController.php +++ b/app/Http/Controllers/API/v1/TrustedUserController.php @@ -31,7 +31,7 @@ class TrustedUserController extends Controller */ public function index(User $user): AnonymousResourceCollection { $this->authorize('update', $user); - return TrustedUserResource::collection($user->trustedUsers()->orderBy('id')->cursorPaginate(10)); + return TrustedUserResource::collection($user->trustedUsers()->orderBy('trusted_id')->cursorPaginate(10)); } /** diff --git a/tests/Feature/APIv1/TrustedUserTest.php b/tests/Feature/APIv1/TrustedUserTest.php index bd7cc7a08..1ac71e772 100644 --- a/tests/Feature/APIv1/TrustedUserTest.php +++ b/tests/Feature/APIv1/TrustedUserTest.php @@ -11,6 +11,34 @@ class TrustedUserTest extends ApiTestCase use RefreshDatabase; + public function testListPagination(): void { + $user = User::factory()->create(); + $trustedUser = User::factory()->count(12)->create(); + $this->actAsApiUserWithAllScopes($user); + + foreach ($trustedUser as $userToTrust) { + $response = $this->postJson("/api/v1/user/{$user->id}/trusted", ['user_id' => $userToTrust->id]); + $response->assertCreated(); + } + + // list trusted users + $response = $this->getJson("/api/v1/user/{$user->id}/trusted"); + $response->assertOk(); + $response->assertJsonCount(10, 'data'); + $response->assertJsonStructure([ + 'data', + 'links' => ['first', 'last', 'prev', 'next'], + 'meta' => ['path', 'per_page', 'next_cursor', 'prev_cursor'], + ]); + + //try next cursor + $nextCursorResponse = $this->getJson($response->json('links.next')); + $nextCursorResponse->assertOk(); + + $nextCursorResponse->dump(); + //TODO: why isn't the cursor working? Every request is showing from the beginning. + } + public function testStoreAndDeleteTrustedUserForYourself(): void { $user = User::factory()->create(); $trustedUser = User::factory()->create(); From 5b772a15b5175839bcacb295a756c999b2055f9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20St=C3=B6ckel?= Date: Fri, 5 Jul 2024 12:40:07 +0200 Subject: [PATCH 11/33] fix docs --- .../Controllers/API/v1/SettingsController.php | 75 +++++++------------ app/Http/Resources/TrustedUserResource.php | 2 +- storage/api-docs/api-docs.json | 28 ++++++- 3 files changed, 56 insertions(+), 49 deletions(-) diff --git a/app/Http/Controllers/API/v1/SettingsController.php b/app/Http/Controllers/API/v1/SettingsController.php index 810f8abeb..b932018e1 100644 --- a/app/Http/Controllers/API/v1/SettingsController.php +++ b/app/Http/Controllers/API/v1/SettingsController.php @@ -65,52 +65,35 @@ public function updateMail(Request $request): UserProfileSettingsResource|JsonRe /** * @OA\Put( - * path="/settings/profile", - * tags={"Settings"}, - * summary="Update the current user's profile settings", - * description="Update the current user's profile settings", - * @OA\RequestBody( - * required=true, - * @OA\JsonContent( - * @OA\Property(property="username", type="string", example="gertrud123", maxLength=25), - * @OA\Property(property="displayName", type="string", example="Gertrud", maxLength=50), - * @OA\Property(property="privateProfile", type="boolean", example=false, nullable=true), - * @OA\Property(property="preventIndex", type="boolean", example=false, nullable=true), - * @OA\Property(property="privacyHideDays", type="integer", example=1, nullable=true), - * @OA\Property( - * property="defaultStatusVisibility", - * type="integer", - * nullable=true, - * @OA\Schema(ref="#/components/schemas/StatusVisibility") - * ), - * @OA\Property( - * property="mastodonVisibility", - * type="integer", - * nullable=true, - * @OA\Schema(ref="#/components/schemas/MastodonVisibility") - * ), - * @OA\Property( - * property="mapProvider", - * type="string", - * nullable=true, - * @OA\Schema(ref="#/components/schemas/MapProvider") - * ) - * ) - * ), - * @OA\Response( - * response=200, - * description="Success", - * @OA\JsonContent( - * @OA\Property(property="data", type="object", ref="#/components/schemas/UserProfileSettings") - * ) - * ), - * @OA\Response(response=401, description="Unauthorized"), - * @OA\Response(response=422, description="Unprocessable Entity"), - * @OA\Response(response=400, description="Bad Request"), - * security={ - * {"passport": {"write-settings"}}, {"token": {}} - * } - * ) + * path="/settings/profile", + * tags={"Settings"}, + * summary="Update the current user's profile settings", + * @OA\RequestBody( + * required=true, + * @OA\JsonContent( + * @OA\Property(property="username", type="string", example="Gertrud123", maxLength=25), + * @OA\Property(property="displayName", type="string", example="Gertrud", maxLength=50), + * @OA\Property(property="privateProfile", type="boolean", example=false, nullable=true), + * @OA\Property(property="preventIndex", type="boolean", example=false, nullable=true), + * @OA\Property(property="privacyHideDays", type="integer", example=1, nullable=true), + * @OA\Property(property="defaultStatusVisibility", type="integer", nullable=true, @OA\Schema(ref="#/components/schemas/StatusVisibility")), + * @OA\Property(property="mastodonVisibility", type="integer", nullable=true, @OA\Schema(ref="#/components/schemas/MastodonVisibility")), + * @OA\Property(property="mapProvider", type="string", nullable=true, @OA\Schema(ref="#/components/schemas/MapProvider"), example="cargo"), + * @OA\Property(property="friendCheckin", type="string", nullable=true, @OA\Schema(ref="#/components/schemas/FriendCheckinSetting"), example="forbidden") + * ) + * ), + * @OA\Response( + * response=200, + * description="Success", + * @OA\JsonContent( + * @OA\Property(property="data", type="object", ref="#/components/schemas/UserProfileSettings") + * ) + * ), + * @OA\Response(response=401, description="Unauthorized"), + * @OA\Response(response=422, description="Unprocessable Entity"), + * @OA\Response(response=400, description="Bad Request"), + * security={{"passport": {"write-settings"}}, {"token": {}}} + * ) */ public function updateSettings(Request $request): UserProfileSettingsResource|JsonResponse { $validated = $request->validate([ diff --git a/app/Http/Resources/TrustedUserResource.php b/app/Http/Resources/TrustedUserResource.php index 1ab948120..560103f96 100644 --- a/app/Http/Resources/TrustedUserResource.php +++ b/app/Http/Resources/TrustedUserResource.php @@ -7,7 +7,7 @@ /** * @OA\Schema( * title="TrustedUser", - * @OA\Property(property="user", ref="#/components/schemas/LightUser"), + * @OA\Property(property="user", ref="#/components/schemas/LightUserResource"), * @OA\Property(property="expires_at", type="string", format="date-time", example="2024-07-28T00:00:00Z") * ) */ diff --git a/storage/api-docs/api-docs.json b/storage/api-docs/api-docs.json index 317d9937c..53f4285a5 100644 --- a/storage/api-docs/api-docs.json +++ b/storage/api-docs/api-docs.json @@ -1472,7 +1472,6 @@ "Settings" ], "summary": "Update the current user's profile settings", - "description": "Update the current user's profile settings", "operationId": "1d28c273d1e6e8c15b8655008d0de07f", "requestBody": { "required": true, @@ -1483,7 +1482,7 @@ "username": { "type": "string", "maxLength": 25, - "example": "gertrud123" + "example": "Gertrud123" }, "displayName": { "type": "string", @@ -1515,6 +1514,12 @@ }, "mapProvider": { "type": "string", + "example": "cargo", + "nullable": true + }, + "friendCheckin": { + "type": "string", + "example": "forbidden", "nullable": true } }, @@ -3706,6 +3711,11 @@ "user_id": { "type": "integer", "example": "1" + }, + "expires_at": { + "type": "string", + "format": "date-time", + "example": "2024-07-28T00:00:00Z" } }, "type": "object" @@ -4987,6 +4997,20 @@ }, "type": "object" }, + "TrustedUserResource": { + "title": "TrustedUser", + "properties": { + "user": { + "$ref": "#/components/schemas/LightUserResource" + }, + "expires_at": { + "type": "string", + "format": "date-time", + "example": "2024-07-28T00:00:00Z" + } + }, + "type": "object" + }, "UserAuthResource": { "title": "UserAuth", "properties": { From dff683597a85133744e010d2f20340193e28a725 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20St=C3=B6ckel?= Date: Fri, 5 Jul 2024 12:45:15 +0200 Subject: [PATCH 12/33] improve validator with max array attributes --- .../API/v1/TransportController.php | 6 +---- tests/Feature/APIv1/FriendCheckinTest.php | 25 ++++++++++++++++++- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/app/Http/Controllers/API/v1/TransportController.php b/app/Http/Controllers/API/v1/TransportController.php index 45436244b..c6d4fbead 100644 --- a/app/Http/Controllers/API/v1/TransportController.php +++ b/app/Http/Controllers/API/v1/TransportController.php @@ -11,7 +11,6 @@ use App\Exceptions\HafasException; use App\Exceptions\NotConnectedException; use App\Exceptions\StationNotOnTripException; -use App\Http\Controllers\Backend\Transport\HomeController; use App\Http\Controllers\Backend\Transport\StationController; use App\Http\Controllers\Backend\Transport\TrainCheckinController; use App\Http\Controllers\HafasController; @@ -372,12 +371,9 @@ public function create(Request $request): JsonResponse { 'departure' => ['required', 'date'], 'arrival' => ['required', 'date'], 'force' => ['nullable', 'boolean'], - 'with' => ['nullable', 'array'], + 'with' => ['nullable', 'array', 'max:10'], ]); if (isset($validated['with'])) { - if (count($validated['with']) > 10) { - return $this->sendError('You can only checkin for up to 10 users at once.', 400); - } $withUsers = User::whereIn('id', $validated['with'])->get(); foreach ($withUsers as $user) { if (!Auth::user()?->can('checkin', $user)) { diff --git a/tests/Feature/APIv1/FriendCheckinTest.php b/tests/Feature/APIv1/FriendCheckinTest.php index a1ad2ff18..7637ff71e 100644 --- a/tests/Feature/APIv1/FriendCheckinTest.php +++ b/tests/Feature/APIv1/FriendCheckinTest.php @@ -75,7 +75,7 @@ public function testUserCanAllowCheckinsForFriends(): void { public function testUserCanAllowCheckinsForTrustedUsers(): void { $userToCheckin = User::factory(['friend_checkin' => FriendCheckinSetting::LIST->value])->create(); $user = User::factory()->create(); - + $this->assertFalse(Gate::forUser($user->fresh())->allows('checkin', $userToCheckin->fresh())); // Create a trusted relationship between the two users @@ -88,5 +88,28 @@ public function testUserCanAllowCheckinsForTrustedUsers(): void { $this->assertTrue(Gate::forUser($user->fresh())->allows('checkin', $userToCheckin->fresh())); } + + public function testUserCannotCheckinMoreThen10Users(): void { + $usersToCheckin = User::factory()->count(11)->create(); + $user = User::factory()->create(); + + $trip = Trip::factory()->create(); + + $this->actAsApiUserWithAllScopes($user); + $response = $this->postJson( + uri: '/api/v1/trains/checkin', + data: [ + 'tripId' => $trip->trip_id, + 'lineName' => $trip->linename, + 'start' => $trip->originStation->id, + 'departure' => $trip->departure, + 'destination' => $trip->destinationStation->id, + 'arrival' => $trip->arrival, + 'with' => $usersToCheckin->pluck('id')->toArray() + ], + ); + $response->assertStatus(422); + $response->assertJsonValidationErrors('with'); + } } From 12f9e487632f32af11016c69a3e56e7daa8d737d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20St=C3=B6ckel?= Date: Fri, 5 Jul 2024 12:47:19 +0200 Subject: [PATCH 13/33] add operationId to api docs --- app/Http/Controllers/API/v1/TrustedUserController.php | 3 +++ storage/api-docs/api-docs.json | 6 +++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/Http/Controllers/API/v1/TrustedUserController.php b/app/Http/Controllers/API/v1/TrustedUserController.php index e8ea6ad36..f9cb8b88b 100644 --- a/app/Http/Controllers/API/v1/TrustedUserController.php +++ b/app/Http/Controllers/API/v1/TrustedUserController.php @@ -17,6 +17,7 @@ class TrustedUserController extends Controller /** * @OA\Get( * path="/user/{userId}/trusted", + * operationId="trustedUserIndex", * summary="Get all trusted users for a user", * description="Get all trusted users for the current user or a specific user (admin only).", * tags={"User"}, @@ -37,6 +38,7 @@ public function index(User $user): AnonymousResourceCollection { /** * @OA\Post( * path="/user/{userId}/trusted", + * operationId="trustedUserStore", * summary="Add a user to the trusted users for a user", * description="Add a user to the trusted users for the current user or a specific user (admin only).", * tags={"User"}, @@ -80,6 +82,7 @@ public function store(Request $request, User $user): Response { /** * @OA\Delete( * path="/user/{userId}/trusted/{trustedId}", + * operationId="trustedUserDestroy", * summary="Remove a user from the trusted users for a user", * description="Remove a user from the trusted users for the current user or a specific user (admin only).", * tags={"User"}, diff --git a/storage/api-docs/api-docs.json b/storage/api-docs/api-docs.json index 53f4285a5..2949f807e 100644 --- a/storage/api-docs/api-docs.json +++ b/storage/api-docs/api-docs.json @@ -3651,7 +3651,7 @@ ], "summary": "Get all trusted users for a user", "description": "Get all trusted users for the current user or a specific user (admin only).", - "operationId": "5d1e940f6900373497a76522f3bcd9bc", + "operationId": "trustedUserIndex", "parameters": [ { "name": "user", @@ -3687,7 +3687,7 @@ ], "summary": "Add a user to the trusted users for a user", "description": "Add a user to the trusted users for the current user or a specific user (admin only).", - "operationId": "f367af889d00c939741b2d93646e7fd1", + "operationId": "trustedUserStore", "parameters": [ { "name": "user", @@ -3752,7 +3752,7 @@ ], "summary": "Remove a user from the trusted users for a user", "description": "Remove a user from the trusted users for the current user or a specific user (admin only).", - "operationId": "73fed32ea6148c7ad95aebf8a1cdc786", + "operationId": "trustedUserDestroy", "parameters": [ { "name": "user", From 53d441f707cdeb7e810659ae4a233fd7e3687a52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20St=C3=B6ckel?= Date: Fri, 5 Jul 2024 12:48:31 +0200 Subject: [PATCH 14/33] fix api attribute --- app/Http/Resources/TrustedUserResource.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/Http/Resources/TrustedUserResource.php b/app/Http/Resources/TrustedUserResource.php index 560103f96..3602787d6 100644 --- a/app/Http/Resources/TrustedUserResource.php +++ b/app/Http/Resources/TrustedUserResource.php @@ -8,15 +8,15 @@ * @OA\Schema( * title="TrustedUser", * @OA\Property(property="user", ref="#/components/schemas/LightUserResource"), - * @OA\Property(property="expires_at", type="string", format="date-time", example="2024-07-28T00:00:00Z") + * @OA\Property(property="expiresAt", type="string", format="date-time", example="2024-07-28T00:00:00Z") * ) */ class TrustedUserResource extends JsonResource { public function toArray($request): array { return [ - 'user' => new LightUserResource($this->trusted), - 'expires_at' => $this->expires_at?->toIso8601String() + 'user' => new LightUserResource($this->trusted), + 'expiresAt' => $this->expires_at?->toIso8601String() ]; } } From 5e6e0fc47702d010472d69a2f7dc8df21c95dc3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20St=C3=B6ckel?= Date: Fri, 5 Jul 2024 13:14:38 +0200 Subject: [PATCH 15/33] add self api attribute --- app/Http/Controllers/API/v1/Controller.php | 9 +++++++ .../API/v1/TrustedUserController.php | 24 ++++++++++--------- storage/api-docs/api-docs.json | 12 +++++----- tests/Feature/APIv1/TrustedUserTest.php | 4 ++-- 4 files changed, 30 insertions(+), 19 deletions(-) diff --git a/app/Http/Controllers/API/v1/Controller.php b/app/Http/Controllers/API/v1/Controller.php index a7d2c59e7..ad4ccc28e 100644 --- a/app/Http/Controllers/API/v1/Controller.php +++ b/app/Http/Controllers/API/v1/Controller.php @@ -3,6 +3,8 @@ namespace App\Http\Controllers\API\v1; use App\Models\OAuthClient; +use App\Models\User; +use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Log; use Throwable; @@ -133,4 +135,11 @@ public static function getCurrentOAuthClient(): OAuthClient|null { return null; } } + + protected function getUserOrSelf(string|int $userIdOrSelf): Authenticatable { + if ($userIdOrSelf === 'self') { + return auth()->user(); + } + return User::findOrFail($userIdOrSelf); + } } diff --git a/app/Http/Controllers/API/v1/TrustedUserController.php b/app/Http/Controllers/API/v1/TrustedUserController.php index f9cb8b88b..46b4ea2ad 100644 --- a/app/Http/Controllers/API/v1/TrustedUserController.php +++ b/app/Http/Controllers/API/v1/TrustedUserController.php @@ -1,4 +1,4 @@ -getUserOrSelf($userIdOrSelf); $this->authorize('update', $user); return TrustedUserResource::collection($user->trustedUsers()->orderBy('trusted_id')->cursorPaginate(10)); } /** * @OA\Post( - * path="/user/{userId}/trusted", + * path="/user/{self|userId}/trusted", * operationId="trustedUserStore", * summary="Add a user to the trusted users for a user", * description="Add a user to the trusted users for the current user or a specific user (admin only).", * tags={"User"}, - * @OA\Parameter(name="user", in="path", required=true, description="ID of the user who want's to trust.", @OA\Schema(type="integer")), + * @OA\Parameter(name="user", in="path", required=true, description="ID of the user (or string 'self' for current user) who want's to trust.", @OA\Schema(type="integer")), * @OA\RequestBody( * required=true, * @OA\JsonContent( @@ -60,7 +61,8 @@ public function index(User $user): AnonymousResourceCollection { * ) * @throws AuthorizationException */ - public function store(Request $request, User $user): Response { + public function store(Request $request, string|int $userIdOrSelf): Response { + $user = $this->getUserOrSelf($userIdOrSelf); $validated = $request->validate([ 'user_id' => ['required', 'exists:users,id'], 'expires_at' => ['nullable', 'date', 'after:now'], @@ -81,12 +83,12 @@ public function store(Request $request, User $user): Response { /** * @OA\Delete( - * path="/user/{userId}/trusted/{trustedId}", + * path="/user/{self|userId}/trusted/{trustedId}", * operationId="trustedUserDestroy", * summary="Remove a user from the trusted users for a user", * description="Remove a user from the trusted users for the current user or a specific user (admin only).", * tags={"User"}, - * @OA\Parameter(name="user", in="path", required=true, description="ID of the user", @OA\Schema(type="integer")), + * @OA\Parameter(name="user", in="path", required=true, description="ID of the user (or string 'self' for current user)", @OA\Schema(type="integer")), * @OA\Parameter(name="trusted", in="path", required=true, description="ID of the trusted user", @OA\Schema(type="integer")), * @OA\Response(response="204", description="User removed from trusted users"), * @OA\Response(response="401", description="Unauthorized"), @@ -96,8 +98,8 @@ public function store(Request $request, User $user): Response { * ) * @throws AuthorizationException */ - public function destroy(int $user, int $trusted): Response { - $user = User::findOrFail($user); + public function destroy(string|int $userIdOrSelf, int $trusted): Response { + $user = $this->getUserOrSelf($userIdOrSelf); $trusted = User::findOrFail($trusted); $this->authorize('update', $user); TrustedUser::where('user_id', $user->id)->where('trusted_id', $trusted->id)->delete(); diff --git a/storage/api-docs/api-docs.json b/storage/api-docs/api-docs.json index 2949f807e..c260c0ca5 100644 --- a/storage/api-docs/api-docs.json +++ b/storage/api-docs/api-docs.json @@ -3644,7 +3644,7 @@ ] } }, - "/user/{userId}/trusted": { + "/user/{self|userId}/trusted": { "get": { "tags": [ "User" @@ -3656,7 +3656,7 @@ { "name": "user", "in": "path", - "description": "ID of the user", + "description": "ID of the user (or string 'self' for current user)", "required": true, "schema": { "type": "integer" @@ -3692,7 +3692,7 @@ { "name": "user", "in": "path", - "description": "ID of the user who want's to trust.", + "description": "ID of the user (or string 'self' for current user) who want's to trust.", "required": true, "schema": { "type": "integer" @@ -3745,7 +3745,7 @@ } } }, - "/user/{userId}/trusted/{trustedId}": { + "/user/{self|userId}/trusted/{trustedId}": { "delete": { "tags": [ "User" @@ -3757,7 +3757,7 @@ { "name": "user", "in": "path", - "description": "ID of the user", + "description": "ID of the user (or string 'self' for current user)", "required": true, "schema": { "type": "integer" @@ -5003,7 +5003,7 @@ "user": { "$ref": "#/components/schemas/LightUserResource" }, - "expires_at": { + "expiresAt": { "type": "string", "format": "date-time", "example": "2024-07-28T00:00:00Z" diff --git a/tests/Feature/APIv1/TrustedUserTest.php b/tests/Feature/APIv1/TrustedUserTest.php index 1ac71e772..61d89146e 100644 --- a/tests/Feature/APIv1/TrustedUserTest.php +++ b/tests/Feature/APIv1/TrustedUserTest.php @@ -17,12 +17,12 @@ public function testListPagination(): void { $this->actAsApiUserWithAllScopes($user); foreach ($trustedUser as $userToTrust) { - $response = $this->postJson("/api/v1/user/{$user->id}/trusted", ['user_id' => $userToTrust->id]); + $response = $this->postJson("/api/v1/user/self/trusted", ['user_id' => $userToTrust->id]); $response->assertCreated(); } // list trusted users - $response = $this->getJson("/api/v1/user/{$user->id}/trusted"); + $response = $this->getJson("/api/v1/user/self/trusted"); $response->assertOk(); $response->assertJsonCount(10, 'data'); $response->assertJsonStructure([ From 6ad92d24547873e2d813bb16255f9e0521951785 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20St=C3=B6ckel?= Date: Sun, 7 Jul 2024 19:35:47 +0200 Subject: [PATCH 16/33] add operationId --- app/Http/Controllers/API/v1/SettingsController.php | 1 + 1 file changed, 1 insertion(+) diff --git a/app/Http/Controllers/API/v1/SettingsController.php b/app/Http/Controllers/API/v1/SettingsController.php index b932018e1..3f9e30293 100644 --- a/app/Http/Controllers/API/v1/SettingsController.php +++ b/app/Http/Controllers/API/v1/SettingsController.php @@ -66,6 +66,7 @@ public function updateMail(Request $request): UserProfileSettingsResource|JsonRe /** * @OA\Put( * path="/settings/profile", + * operationId="putProfileSettings", * tags={"Settings"}, * summary="Update the current user's profile settings", * @OA\RequestBody( From f610b0cb32c0eb826c055776401e0249cbdaec6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20St=C3=B6ckel?= Date: Sun, 7 Jul 2024 19:35:51 +0200 Subject: [PATCH 17/33] fix http response --- app/Http/Controllers/API/v1/TransportController.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/Http/Controllers/API/v1/TransportController.php b/app/Http/Controllers/API/v1/TransportController.php index c6d4fbead..317127261 100644 --- a/app/Http/Controllers/API/v1/TransportController.php +++ b/app/Http/Controllers/API/v1/TransportController.php @@ -343,8 +343,9 @@ public function getNextStationByCoordinates(Request $request): JsonResponse { * @OA\JsonContent(ref="#/components/schemas/CheckinResponse") * ), * @OA\Response(response=400, description="Bad request"), - * @OA\Response(response=409, description="Checkin collision"), * @OA\Response(response=401, description="Unauthorized"), + * @OA\Response(response=403, description="Forbidden"), + * @OA\Response(response=409, description="Checkin collision"), * security={ * {"passport": {"create-statuses"}}, {"token": {}} * } @@ -377,7 +378,7 @@ public function create(Request $request): JsonResponse { $withUsers = User::whereIn('id', $validated['with'])->get(); foreach ($withUsers as $user) { if (!Auth::user()?->can('checkin', $user)) { - return $this->sendError('You are not allowed to checkin for the given user.', 401); + return $this->sendError('You are not allowed to checkin for the given user.', 403); } } } From 914a8955d29d00cb27c06ef0270acefe8b3049e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20St=C3=B6ckel?= Date: Sun, 7 Jul 2024 19:36:34 +0200 Subject: [PATCH 18/33] fix parameter --- app/Http/Controllers/API/v1/TrustedUserController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Http/Controllers/API/v1/TrustedUserController.php b/app/Http/Controllers/API/v1/TrustedUserController.php index 46b4ea2ad..21f948c10 100644 --- a/app/Http/Controllers/API/v1/TrustedUserController.php +++ b/app/Http/Controllers/API/v1/TrustedUserController.php @@ -21,7 +21,7 @@ class TrustedUserController extends Controller * summary="Get all trusted users for a user", * description="Get all trusted users for the current user or a specific user (admin only).", * tags={"User"}, - * @OA\Parameter(name="user", in="path", required=true, description="ID of the user (or string 'self' for current user)", @OA\Schema(type="integer")), + * @OA\Parameter(name="user", in="path", required=true, description="ID of the user (or string 'self' for current user)", @OA\Schema(type="string")), * @OA\Response(response="200", description="List of trusted users"), * @OA\Response(response="401", description="Unauthorized"), * @OA\Response(response="403", description="Forbidden"), From c237d800d10afc8b66242a11c18e0814d1b138ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20St=C3=B6ckel?= Date: Sun, 7 Jul 2024 19:36:53 +0200 Subject: [PATCH 19/33] fix parameter --- app/Http/Controllers/API/v1/TrustedUserController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Http/Controllers/API/v1/TrustedUserController.php b/app/Http/Controllers/API/v1/TrustedUserController.php index 21f948c10..a36e7e134 100644 --- a/app/Http/Controllers/API/v1/TrustedUserController.php +++ b/app/Http/Controllers/API/v1/TrustedUserController.php @@ -43,7 +43,7 @@ public function index(string|int $userIdOrSelf): AnonymousResourceCollection { * summary="Add a user to the trusted users for a user", * description="Add a user to the trusted users for the current user or a specific user (admin only).", * tags={"User"}, - * @OA\Parameter(name="user", in="path", required=true, description="ID of the user (or string 'self' for current user) who want's to trust.", @OA\Schema(type="integer")), + * @OA\Parameter(name="user", in="path", required=true, description="ID of the user (or string 'self' for current user) who want's to trust.", @OA\Schema(type="string")), * @OA\RequestBody( * required=true, * @OA\JsonContent( From d159ba0be1c9a0e6dba2231adcf575c9a5e18dde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20St=C3=B6ckel?= Date: Sun, 7 Jul 2024 19:37:10 +0200 Subject: [PATCH 20/33] fix parameter --- app/Http/Controllers/API/v1/TrustedUserController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Http/Controllers/API/v1/TrustedUserController.php b/app/Http/Controllers/API/v1/TrustedUserController.php index a36e7e134..9a6217762 100644 --- a/app/Http/Controllers/API/v1/TrustedUserController.php +++ b/app/Http/Controllers/API/v1/TrustedUserController.php @@ -88,7 +88,7 @@ public function store(Request $request, string|int $userIdOrSelf): Response { * summary="Remove a user from the trusted users for a user", * description="Remove a user from the trusted users for the current user or a specific user (admin only).", * tags={"User"}, - * @OA\Parameter(name="user", in="path", required=true, description="ID of the user (or string 'self' for current user)", @OA\Schema(type="integer")), + * @OA\Parameter(name="user", in="path", required=true, description="ID of the user (or string 'self' for current user)", @OA\Schema(type="string")), * @OA\Parameter(name="trusted", in="path", required=true, description="ID of the trusted user", @OA\Schema(type="integer")), * @OA\Response(response="204", description="User removed from trusted users"), * @OA\Response(response="401", description="Unauthorized"), From 8c754bb69cc7040394deff10263f39754124e55f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20St=C3=B6ckel?= Date: Sun, 7 Jul 2024 19:37:55 +0200 Subject: [PATCH 21/33] generate docs --- storage/api-docs/api-docs.json | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/storage/api-docs/api-docs.json b/storage/api-docs/api-docs.json index c260c0ca5..6aecf7d6a 100644 --- a/storage/api-docs/api-docs.json +++ b/storage/api-docs/api-docs.json @@ -18,7 +18,7 @@ "description": "Production Server" }, { - "url": "http://localhost:8000/api/v1", + "url": "http://localhost:8000/api/v1/api/v1", "description": "This instance" } ], @@ -1472,7 +1472,7 @@ "Settings" ], "summary": "Update the current user's profile settings", - "operationId": "1d28c273d1e6e8c15b8655008d0de07f", + "operationId": "putProfileSettings", "requestBody": { "required": true, "content": { @@ -3471,11 +3471,14 @@ "400": { "description": "Bad request" }, - "409": { - "description": "Checkin collision" - }, "401": { "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "409": { + "description": "Checkin collision" } }, "security": [ @@ -3659,7 +3662,7 @@ "description": "ID of the user (or string 'self' for current user)", "required": true, "schema": { - "type": "integer" + "type": "string" } } ], @@ -3695,7 +3698,7 @@ "description": "ID of the user (or string 'self' for current user) who want's to trust.", "required": true, "schema": { - "type": "integer" + "type": "string" } } ], @@ -3760,7 +3763,7 @@ "description": "ID of the user (or string 'self' for current user)", "required": true, "schema": { - "type": "integer" + "type": "string" } }, { @@ -4699,6 +4702,7 @@ }, "TravelType": { "title": "travelType", + "description": "When adding a new travel type, make sure to add it to the translation file as well.", "type": "string", "enum": [ "express", @@ -6142,9 +6146,9 @@ "scheme": "https", "flows": { "authorizationCode": { - "authorizationUrl": "http://localhost:8000/oauth/authorize", - "tokenUrl": "http://localhost:8000/oauth/token", - "refreshUrl": "http://localhost:8000/auth/refresh", + "authorizationUrl": "http://localhost:8000/api/v1/oauth/authorize", + "tokenUrl": "http://localhost:8000/api/v1/oauth/token", + "refreshUrl": "http://localhost:8000/api/v1/auth/refresh", "scopes": { "read-statuses": "see all statuses", "read-notifications": "see your notifications", From 0a2e96d1cd6a32817e8b1ef874ab45178bf2d3b1 Mon Sep 17 00:00:00 2001 From: Kris Date: Sat, 13 Jul 2024 14:54:10 +0200 Subject: [PATCH 22/33] Update app/Http/Controllers/API/v1/TrustedUserController.php Co-authored-by: Levin Herr --- app/Http/Controllers/API/v1/TrustedUserController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Http/Controllers/API/v1/TrustedUserController.php b/app/Http/Controllers/API/v1/TrustedUserController.php index 9a6217762..c0c25ec87 100644 --- a/app/Http/Controllers/API/v1/TrustedUserController.php +++ b/app/Http/Controllers/API/v1/TrustedUserController.php @@ -83,7 +83,7 @@ public function store(Request $request, string|int $userIdOrSelf): Response { /** * @OA\Delete( - * path="/user/{self|userId}/trusted/{trustedId}", + * path="/user/{user}/trusted/{trustedId}", * operationId="trustedUserDestroy", * summary="Remove a user from the trusted users for a user", * description="Remove a user from the trusted users for the current user or a specific user (admin only).", From 336c4ac363a1c4ac4c3ea799555f1082477ae297 Mon Sep 17 00:00:00 2001 From: Kris Date: Sat, 13 Jul 2024 14:54:34 +0200 Subject: [PATCH 23/33] Update app/Http/Controllers/API/v1/TrustedUserController.php Co-authored-by: Levin Herr --- app/Http/Controllers/API/v1/TrustedUserController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Http/Controllers/API/v1/TrustedUserController.php b/app/Http/Controllers/API/v1/TrustedUserController.php index c0c25ec87..7ecc219a8 100644 --- a/app/Http/Controllers/API/v1/TrustedUserController.php +++ b/app/Http/Controllers/API/v1/TrustedUserController.php @@ -16,7 +16,7 @@ class TrustedUserController extends Controller /** * @OA\Get( - * path="/user/{self|userId}/trusted", + * path="/user/{user}/trusted", * operationId="trustedUserIndex", * summary="Get all trusted users for a user", * description="Get all trusted users for the current user or a specific user (admin only).", From 2d324ccb55da0029e2cdd48884a1e920f37003dc Mon Sep 17 00:00:00 2001 From: Kris Date: Sat, 13 Jul 2024 14:54:40 +0200 Subject: [PATCH 24/33] Update app/Http/Controllers/API/v1/TrustedUserController.php Co-authored-by: Levin Herr --- app/Http/Controllers/API/v1/TrustedUserController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Http/Controllers/API/v1/TrustedUserController.php b/app/Http/Controllers/API/v1/TrustedUserController.php index 7ecc219a8..d1e05a570 100644 --- a/app/Http/Controllers/API/v1/TrustedUserController.php +++ b/app/Http/Controllers/API/v1/TrustedUserController.php @@ -38,7 +38,7 @@ public function index(string|int $userIdOrSelf): AnonymousResourceCollection { /** * @OA\Post( - * path="/user/{self|userId}/trusted", + * path="/user/{user}/trusted", * operationId="trustedUserStore", * summary="Add a user to the trusted users for a user", * description="Add a user to the trusted users for the current user or a specific user (admin only).", From e06265668023991c44081274390b4fe4903dc7dd Mon Sep 17 00:00:00 2001 From: Levin Date: Tue, 30 Jul 2024 21:26:46 +0200 Subject: [PATCH 25/33] Adapt with hydrator --- .../API/v1/TransportController.php | 22 +- app/Models/User.php | 22 +- storage/api-docs/api-docs.json | 241 +++++++++++++++--- 3 files changed, 227 insertions(+), 58 deletions(-) diff --git a/app/Http/Controllers/API/v1/TransportController.php b/app/Http/Controllers/API/v1/TransportController.php index 892c4a499..976b75ec1 100644 --- a/app/Http/Controllers/API/v1/TransportController.php +++ b/app/Http/Controllers/API/v1/TransportController.php @@ -2,7 +2,6 @@ namespace App\Http\Controllers\API\v1; -use App\Dto\Internal\CheckinSuccessDto; use App\Dto\Transport\Station as StationDto; use App\Enum\Business; use App\Enum\StatusVisibility; @@ -10,7 +9,6 @@ use App\Exceptions\Checkin\AlreadyCheckedInException; use App\Exceptions\CheckInCollisionException; use App\Exceptions\HafasException; -use App\Exceptions\NotConnectedException; use App\Exceptions\StationNotOnTripException; use App\Http\Controllers\Backend\Transport\StationController; use App\Http\Controllers\Backend\Transport\TrainCheckinController; @@ -18,10 +16,8 @@ use App\Http\Controllers\TransportController as TransportBackend; use App\Http\Resources\CheckinSuccessResource; use App\Http\Resources\StationResource; -use App\Http\Resources\StatusResource; use App\Http\Resources\TripResource; use App\Hydrators\CheckinRequestHydrator; -use App\Models\Event; use App\Models\Station; use App\Models\User; use App\Notifications\YouHaveBeenCheckedIn; @@ -386,22 +382,14 @@ public function create(Request $request): JsonResponse { } try { - $checkinResponse = TrainCheckinController::checkin((new CheckinRequestHydrator($validated))->hydrateFromApi()); + $dto = (new CheckinRequestHydrator($validated))->hydrateFromApi(); + $checkinResponse = TrainCheckinController::checkin($dto); - //ToDo: Check if documented structure has changed // if isset, check in the other users with their default values foreach ($withUsers ?? [] as $user) { - $checkin = TrainCheckinController::checkin( - user: $user, - trip: $trip, - origin: $originStation, - departure: $departure, - destination: $destinationStation, - arrival: $arrival, - travelReason: $travelReason, - visibility: $user->default_status_visibility, - event: $event, - ); + $dto->setUser($user); + $dto->setStatusVisibility($user->default_status_visibility); + $checkin = TrainCheckinController::checkin($dto); $user->notify(new YouHaveBeenCheckedIn($checkin['status'], auth()->user())); } diff --git a/app/Models/User.php b/app/Models/User.php index 467902d68..2a08179c4 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -48,18 +48,16 @@ * @property int privacy_hide_days * @property string language * @property Carbon last_login - * @property Status[] $statuses - * @property SocialLoginProfile socialProfile - * @property int points - * @property boolean userInvisibleToMe - * @property string mastodonUrl - * @property int train_distance - * @property int train_duration - * @property boolean following - * @property boolean followPending - * @property boolean muted - * @property boolean isAuthUserBlocked - * @property boolean isBlockedByAuthUser + * @property int points + * @property boolean userInvisibleToMe + * @property string mastodonUrl + * @property int train_distance + * @property int train_duration + * @property boolean following + * @property boolean followPending + * @property boolean muted + * @property boolean isAuthUserBlocked + * @property boolean isBlockedByAuthUser * * // relationships * @property Collection trainCheckins diff --git a/storage/api-docs/api-docs.json b/storage/api-docs/api-docs.json index ec495b9a2..8480cc750 100644 --- a/storage/api-docs/api-docs.json +++ b/storage/api-docs/api-docs.json @@ -1514,6 +1514,11 @@ "mapProvider": { "type": "string", "nullable": true + }, + "friendCheckin": { + "type": "string", + "example": "forbidden", + "nullable": true } }, "type": "object" @@ -3447,8 +3452,8 @@ "tags": [ "Checkin" ], - "summary": "Create a checkin", - "operationId": "createTrainCheckin", + "summary": "Check in to a trip.", + "operationId": "createCheckin", "requestBody": { "required": true, "content": { @@ -3473,11 +3478,14 @@ "400": { "description": "Bad request" }, - "409": { - "description": "Checkin collision" - }, "401": { "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "409": { + "description": "Checkin collision" } }, "security": [ @@ -3646,6 +3654,154 @@ ] } }, + "/user/{user}/trusted": { + "get": { + "tags": [ + "User" + ], + "summary": "Get all trusted users for a user", + "description": "Get all trusted users for the current user or a specific user (admin only).", + "operationId": "trustedUserIndex", + "parameters": [ + { + "name": "user", + "in": "path", + "description": "ID of the user (or string 'self' for current user)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of trusted users" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "User not found" + }, + "500": { + "description": "Internal Server Error" + } + } + }, + "post": { + "tags": [ + "User" + ], + "summary": "Add a user to the trusted users for a user", + "description": "Add a user to the trusted users for the current user or a specific user (admin only).", + "operationId": "trustedUserStore", + "parameters": [ + { + "name": "user", + "in": "path", + "description": "ID of the user (or string 'self' for current user) who want's to trust.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "required": [ + "user_id" + ], + "properties": { + "user_id": { + "type": "integer", + "example": "1" + }, + "expires_at": { + "type": "string", + "format": "date-time", + "example": "2024-07-28T00:00:00Z" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "User added to trusted users" + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "User not found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/user/{user}/trusted/{trustedId}": { + "delete": { + "tags": [ + "User" + ], + "summary": "Remove a user from the trusted users for a user", + "description": "Remove a user from the trusted users for the current user or a specific user (admin only).", + "operationId": "trustedUserDestroy", + "parameters": [ + { + "name": "user", + "in": "path", + "description": "ID of the user (or string 'self' for current user)", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "trusted", + "in": "path", + "description": "ID of the trusted user", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "204": { + "description": "User removed from trusted users" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "User not found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, "/settings/account": { "delete": { "tags": [ @@ -4569,6 +4725,16 @@ ], "example": "suburban" }, + "FriendCheckinSetting": { + "title": "FriendCheckinSetting", + "type": "string", + "enum": [ + "forbidden", + "friends", + "list" + ], + "example": "forbidden" + }, "CheckinSuccessResource": { "title": "CheckinResponse", "properties": { @@ -5039,6 +5205,20 @@ }, "type": "object" }, + "TrustedUserResource": { + "title": "TrustedUser", + "properties": { + "user": { + "$ref": "#/components/schemas/LightUserResource" + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "example": "2024-07-28T00:00:00Z" + } + }, + "type": "object" + }, "UserAuthResource": { "title": "UserAuth", "properties": { @@ -5138,8 +5318,6 @@ "description": "Fields for creating a train checkin", "properties": { "body": { - "title": "body", - "description": "Text that should be added to the post", "type": "string", "maxLength": 280, "example": "Meine erste Fahrt nach Knuffingen!", @@ -5152,76 +5330,81 @@ "$ref": "#/components/schemas/StatusVisibility" }, "eventId": { - "title": "eventId", "description": "Id of an event the status should be connected to", "type": "integer", + "example": "1", "nullable": true }, "toot": { - "title": "toot", "description": "Should this status be posted to mastodon?", "type": "boolean", "example": "false", "nullable": true }, "chainPost": { - "title": "chainPost", "description": "Should this status be posted to mastodon as a chained post?", "type": "boolean", "example": "false", "nullable": true }, "ibnr": { - "title": "ibnr", - "description": "If true, the `start` and `destination` properties can be supplied as an ibnr. Otherwise they\n * should be given as the Träwelling-ID. Default behavior is `false`.", + "description": "If true, the `start` and `destination` properties can be supplied as an ibnr. Otherwise they should be given as the Träwelling-ID. Default behavior is `false`.", "type": "boolean", "example": "true", "nullable": true }, "tripId": { - "title": "tripId", - "description": "The HAFAS tripId for the to be checked in train", - "example": "1|323306|1|80|17072022" + "description": "The tripId for the to be checked in train", + "type": "string", + "example": "b37ff515-22e1-463c-94de-3ad7964b5cb8", + "nullable": true }, "lineName": { - "title": "lineName", "description": "The line name for the to be checked in train", - "example": "S 4" + "type": "string", + "example": "S 4", + "nullable": true }, "start": { - "title": "start", "description": "The Station-ID of the starting point (see `ibnr`)", "type": "integer", "example": "8000191" }, "destination": { - "title": "destination", - "description": "The Station-ID of the destination (see `ibnr`)", + "description": "The Station-ID of the destination point (see `ibnr`)", "type": "integer", - "example": "8079045" + "example": "8000192" }, "departure": { - "title": "departure", "description": "Timestamp of the departure", + "type": "string", + "format": "date-time", "example": "2022-12-19T20:41:00+01:00" }, "arrival": { - "title": "arrival", "description": "Timestamp of the arrival", + "type": "string", + "format": "date-time", "example": "2022-12-19T20:42:00+01:00" }, "force": { - "title": "force", - "description": "If true, the checkin will be created, even if a colliding checkin exists. No points will be\n * awarded.", + "description": "If true, the checkin will be created, even if a colliding checkin exists. No points will be awarded.", "type": "boolean", "example": "false", "nullable": true + }, + "with": { + "description": "If set, the checkin will be created for all given users as well. The user creating the checkin must be allowed to checkin for the other users. Max. 10 users.", + "type": "array", + "items": { + "type": "integer", + "example": "1" + }, + "example": "[1, 2]", + "nullable": true } }, - "type": "object", - "xml": { - "name": "CheckinRequestBody" - } + "type": "object" }, "EventSuggestion": { "title": "EventSuggestion", From 8045273783dafec236d94f9950dca956ff4bb518 Mon Sep 17 00:00:00 2001 From: Levin Date: Tue, 30 Jul 2024 21:35:43 +0200 Subject: [PATCH 26/33] fix tests --- app/Http/Controllers/API/v1/TransportController.php | 2 +- tests/Unit/Services/ReportServiceTest.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/Http/Controllers/API/v1/TransportController.php b/app/Http/Controllers/API/v1/TransportController.php index 976b75ec1..2014004ae 100644 --- a/app/Http/Controllers/API/v1/TransportController.php +++ b/app/Http/Controllers/API/v1/TransportController.php @@ -390,7 +390,7 @@ public function create(Request $request): JsonResponse { $dto->setUser($user); $dto->setStatusVisibility($user->default_status_visibility); $checkin = TrainCheckinController::checkin($dto); - $user->notify(new YouHaveBeenCheckedIn($checkin['status'], auth()->user())); + $user->notify(new YouHaveBeenCheckedIn($checkin->status, auth()->user())); } return $this->sendResponse(new CheckinSuccessResource($checkinResponse), 201); diff --git a/tests/Unit/Services/ReportServiceTest.php b/tests/Unit/Services/ReportServiceTest.php index 0fea1a859..814db3917 100644 --- a/tests/Unit/Services/ReportServiceTest.php +++ b/tests/Unit/Services/ReportServiceTest.php @@ -26,7 +26,7 @@ public function testCheckString(array $expected, string $haystack): void { /** - * @dataProvider testCheckStringProvider + * @dataProvider checkStringProvider */ public function testCheckAndReport(array $expected, string $haystack): void { $repository = $this->mock(ReportRepository::class); @@ -50,7 +50,7 @@ public function testCheckAndReport(array $expected, string $haystack): void { $reportService->checkAndReport($haystack, ReportableSubject::TRIP, 1); } - public static function testCheckStringProvider(): array { + public static function checkStringProvider(): array { return [ 'match first word' => [ ['auto'], From fb246ee8d79b1d73dc4e82d371b0b8012daa0012 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20St=C3=B6ckel?= Date: Wed, 31 Jul 2024 13:16:06 +0200 Subject: [PATCH 27/33] fix "fix tests" --- tests/Unit/Services/ReportServiceTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Unit/Services/ReportServiceTest.php b/tests/Unit/Services/ReportServiceTest.php index 814db3917..583854999 100644 --- a/tests/Unit/Services/ReportServiceTest.php +++ b/tests/Unit/Services/ReportServiceTest.php @@ -12,7 +12,7 @@ class ReportServiceTest extends UnitTestCase { /** - * @dataProvider testCheckStringProvider + * @dataProvider checkStringProvider */ public function testCheckString(array $expected, string $haystack): void { $reportService = new ReportService(); From 3924e34dfe16ec9aa52df932d1104794872687b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20St=C3=B6ckel?= Date: Fri, 2 Aug 2024 11:28:31 +0200 Subject: [PATCH 28/33] :test_tube: add failing test --- .../API/v1/TransportController.php | 17 ++++++++-- .../Backend/User/FollowController.php | 2 +- tests/Feature/APIv1/FriendCheckinTest.php | 33 +++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/app/Http/Controllers/API/v1/TransportController.php b/app/Http/Controllers/API/v1/TransportController.php index 2014004ae..1dd5591ba 100644 --- a/app/Http/Controllers/API/v1/TransportController.php +++ b/app/Http/Controllers/API/v1/TransportController.php @@ -373,12 +373,25 @@ public function create(Request $request): JsonResponse { 'with' => ['nullable', 'array', 'max:10'], ]); if (isset($validated['with'])) { - $withUsers = User::whereIn('id', $validated['with'])->get(); + $withUsers = User::whereIn('id', $validated['with'])->get(); + $forbiddenUsers = collect(); foreach ($withUsers as $user) { if (!Auth::user()?->can('checkin', $user)) { - return $this->sendError('You are not allowed to checkin for the given user.', 403); + $forbiddenUsers->push($user); } } + if ($forbiddenUsers->isNotEmpty()) { + $forbiddenUserIds = $forbiddenUsers->pluck('id')->toArray(); + return response()->json( + data: [ + 'message' => 'You are not allowed to check in the following users: ' . implode(',', $forbiddenUserIds), + 'meta' => [ + 'invalidUsers' => $forbiddenUserIds + ] + ], + status: 403 + ); + } } try { diff --git a/app/Http/Controllers/Backend/User/FollowController.php b/app/Http/Controllers/Backend/User/FollowController.php index fe1e5aea4..883e6700d 100644 --- a/app/Http/Controllers/Backend/User/FollowController.php +++ b/app/Http/Controllers/Backend/User/FollowController.php @@ -118,7 +118,7 @@ public static function createOrRequestFollow(User $user, User $userToFollow): Us 'user_id' => $user->id, 'follow_id' => $userToFollow->id ]); - $userToFollow->fresh(); + $userToFollow->refresh(); $userToFollow->notify(new UserFollowed($follow)); Cache::forget(CacheKey::getFriendsLeaderboardKey($user->id)); return $userToFollow; diff --git a/tests/Feature/APIv1/FriendCheckinTest.php b/tests/Feature/APIv1/FriendCheckinTest.php index 7637ff71e..ea4aaf56a 100644 --- a/tests/Feature/APIv1/FriendCheckinTest.php +++ b/tests/Feature/APIv1/FriendCheckinTest.php @@ -4,6 +4,7 @@ use App\Enum\User\FriendCheckinSetting; use App\Http\Controllers\Backend\User\FollowController; +use App\Models\Follow; use App\Models\Trip; use App\Models\User; use App\Notifications\YouHaveBeenCheckedIn; @@ -111,5 +112,37 @@ public function testUserCannotCheckinMoreThen10Users(): void { $response->assertStatus(422); $response->assertJsonValidationErrors('with'); } + + public function testErrorResponseShouldContainForbiddenUsers(): void { + $forbiddenUser = User::factory()->create(['friend_checkin' => FriendCheckinSetting::FORBIDDEN->value]); + $allowedUser = User::factory()->create(['friend_checkin' => FriendCheckinSetting::FRIENDS->value]); + $user = User::factory()->create(); + $this->actAsApiUserWithAllScopes($user); + + Follow::create(['user_id' => $user->id, 'follow_id' => $allowedUser->id]); + Follow::create(['user_id' => $allowedUser->id, 'follow_id' => $user->id]); + + $trip = Trip::factory()->create(); + + $response = $this->postJson( + uri: '/api/v1/trains/checkin', + data: [ + 'tripId' => $trip->trip_id, + 'lineName' => $trip->linename, + 'start' => $trip->originStation->id, + 'departure' => $trip->departure, + 'destination' => $trip->destinationStation->id, + 'arrival' => $trip->arrival, + 'with' => [ + $forbiddenUser->id, + $allowedUser->id + ] + ], + ); + $response->assertStatus(403); + $response->assertJsonStructure(['message', 'meta' => ['invalidUsers']]); + $this->assertContains($forbiddenUser->id, $response->json('meta.invalidUsers')); + $this->assertNotContains($allowedUser->id, $response->json('meta.invalidUsers')); + } } From 97f1b9da47164fdb0455dba59bf908a4698f59c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20St=C3=B6ckel?= Date: Fri, 2 Aug 2024 11:37:07 +0200 Subject: [PATCH 29/33] :white_check_mark: test follow relationships --- .../Backend/User/FollowController.php | 5 +++ app/Models/User.php | 16 +++------ app/Policies/UserPolicy.php | 5 ++- .../Profile/UserModelRelationshipTest.php | 35 +++++++++++++++++++ 4 files changed, 46 insertions(+), 15 deletions(-) create mode 100644 tests/Feature/Profile/UserModelRelationshipTest.php diff --git a/app/Http/Controllers/Backend/User/FollowController.php b/app/Http/Controllers/Backend/User/FollowController.php index 883e6700d..42b4ebfcf 100644 --- a/app/Http/Controllers/Backend/User/FollowController.php +++ b/app/Http/Controllers/Backend/User/FollowController.php @@ -123,4 +123,9 @@ public static function createOrRequestFollow(User $user, User $userToFollow): Us Cache::forget(CacheKey::getFriendsLeaderboardKey($user->id)); return $userToFollow; } + + public static function isFollowingEachOther(User $user, User $otherUser): bool { + return $user->userFollowers->contains('id', $otherUser->id) + && $user->userFollowings->contains('id', $otherUser->id); + } } diff --git a/app/Models/User.php b/app/Models/User.php index 2a08179c4..6759f6d4c 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -69,8 +69,8 @@ * @property Collection blockedByUsers * @property Collection mutedUsers * @property Collection followRequests - * @property Collection followers - * @property Collection followings + * @property Collection userFollowers + * @property Collection userFollowings * @property Collection sessions * @property Collection icsTokens * @property Collection webhooks @@ -170,14 +170,14 @@ public function followRequests(): HasMany { } /** - * @deprecated + * @deprecated use ->userFollowers instead to get the users directly */ public function followers(): HasMany { return $this->hasMany(Follow::class, 'follow_id', 'id'); } /** - * @deprecated + * @deprecated use ->userFollowing instead to get the users directly */ public function followings(): HasMany { return $this->hasMany(Follow::class, 'user_id', 'id'); @@ -212,18 +212,10 @@ public function trustedUsers(): HasMany { ->whereNull('expires_at')->orWhere('expires_at', '>', now()); } - /** - * @untested - * @todo test - */ public function userFollowings(): BelongsToMany { return $this->belongsToMany(__CLASS__, 'follows', 'user_id', 'follow_id'); } - /** - * @untested - * @todo test - */ public function userFollowers(): BelongsToMany { return $this->belongsToMany(__CLASS__, 'follows', 'follow_id', 'user_id'); } diff --git a/app/Policies/UserPolicy.php b/app/Policies/UserPolicy.php index 5f7383dfc..c52f3c7dd 100644 --- a/app/Policies/UserPolicy.php +++ b/app/Policies/UserPolicy.php @@ -4,6 +4,7 @@ use App\Enum\User\FriendCheckinSetting; use App\Http\Controllers\Backend\User\BlockController; +use App\Http\Controllers\Backend\User\FollowController; use App\Models\User; use Illuminate\Auth\Access\HandlesAuthorization; use Illuminate\Auth\Access\Response; @@ -93,9 +94,7 @@ public function checkin(User $user, User $userToCheckin): bool { return false; } if ($userToCheckin->friend_checkin === FriendCheckinSetting::FRIENDS) { - $userIsFollowingUserToCheckin = $user->follows->contains('id', $userToCheckin->id); - $userIsFollowedByUserToCheckin = $user->followers->contains('id', $user->id); - return $userIsFollowingUserToCheckin && $userIsFollowedByUserToCheckin; + return FollowController::isFollowingEachOther($user, $userToCheckin); } if ($userToCheckin->friend_checkin === FriendCheckinSetting::LIST) { return $userToCheckin->trustedUsers->contains('trusted_id', $user->id); diff --git a/tests/Feature/Profile/UserModelRelationshipTest.php b/tests/Feature/Profile/UserModelRelationshipTest.php new file mode 100644 index 000000000..d2e5ac1d9 --- /dev/null +++ b/tests/Feature/Profile/UserModelRelationshipTest.php @@ -0,0 +1,35 @@ +create(); + $follower = User::factory()->create(); + FollowController::createOrRequestFollow($follower, $user); + + $user->refresh(); + + $this->assertCount(1, $user->userFollowers); + $this->assertEquals($follower->id, $user->userFollowers->first()->id); + } + + public function testFollowingRelationship(): void { + $user = User::factory()->create(); + $following = User::factory()->create(); + FollowController::createOrRequestFollow($user, $following); + + $user->refresh(); + + $this->assertCount(1, $user->userFollowings); + $this->assertEquals($following->id, $user->userFollowings->first()->id); + } +} From 4f0bd01ee772f455b05be447159976b16556a387 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20St=C3=B6ckel?= Date: Fri, 2 Aug 2024 11:39:50 +0200 Subject: [PATCH 30/33] rename api attributes --- .../Controllers/API/v1/TrustedUserController.php | 12 ++++++------ tests/Feature/APIv1/TrustedUserTest.php | 10 +++++----- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/app/Http/Controllers/API/v1/TrustedUserController.php b/app/Http/Controllers/API/v1/TrustedUserController.php index d1e05a570..c20fcb0c3 100644 --- a/app/Http/Controllers/API/v1/TrustedUserController.php +++ b/app/Http/Controllers/API/v1/TrustedUserController.php @@ -48,8 +48,8 @@ public function index(string|int $userIdOrSelf): AnonymousResourceCollection { * required=true, * @OA\JsonContent( * required={"user_id"}, - * @OA\Property(property="user_id", type="integer", example="1"), - * @OA\Property(property="expires_at", type="string", format="date-time", example="2024-07-28T00:00:00Z") + * @OA\Property(property="userId", type="integer", example="1"), + * @OA\Property(property="expiresAt", type="string", format="date-time", example="2024-07-28T00:00:00Z") * ) * ), * @OA\Response(response="201", description="User added to trusted users"), @@ -64,10 +64,10 @@ public function index(string|int $userIdOrSelf): AnonymousResourceCollection { public function store(Request $request, string|int $userIdOrSelf): Response { $user = $this->getUserOrSelf($userIdOrSelf); $validated = $request->validate([ - 'user_id' => ['required', 'exists:users,id'], - 'expires_at' => ['nullable', 'date', 'after:now'], + 'userId' => ['required', 'exists:users,id'], + 'expiresAt' => ['nullable', 'date', 'after:now'], ]); - $trustedUser = User::find($validated['user_id']); + $trustedUser = User::find($validated['userId']); $this->authorize('update', $user); TrustedUser::updateOrCreate( [ @@ -75,7 +75,7 @@ public function store(Request $request, string|int $userIdOrSelf): Response { 'trusted_id' => $trustedUser->id, ], [ - 'expires_at' => $validated['expires_at'] ?? null, + 'expires_at' => $validated['expiresAt'] ?? null, ] ); return response()->noContent(201); diff --git a/tests/Feature/APIv1/TrustedUserTest.php b/tests/Feature/APIv1/TrustedUserTest.php index 61d89146e..899021a7f 100644 --- a/tests/Feature/APIv1/TrustedUserTest.php +++ b/tests/Feature/APIv1/TrustedUserTest.php @@ -17,7 +17,7 @@ public function testListPagination(): void { $this->actAsApiUserWithAllScopes($user); foreach ($trustedUser as $userToTrust) { - $response = $this->postJson("/api/v1/user/self/trusted", ['user_id' => $userToTrust->id]); + $response = $this->postJson("/api/v1/user/self/trusted", ['userId' => $userToTrust->id]); $response->assertCreated(); } @@ -46,8 +46,8 @@ public function testStoreAndDeleteTrustedUserForYourself(): void { // trust user $response = $this->postJson("/api/v1/user/{$user->id}/trusted", [ - 'user_id' => $trustedUser->id, - 'expires_at' => now()->addDay()->toIso8601String(), + 'userId' => $trustedUser->id, + 'expiresAt' => now()->addDay()->toIso8601String(), ]); $response->assertCreated(); $this->assertDatabaseHas('trusted_users', ['user_id' => $user->id, 'trusted_id' => $trustedUser->id]); @@ -84,7 +84,7 @@ public function testStoreAndDeleteTrustedUserForOtherUsersAsNonAdmin(): void { $this->actAsApiUserWithAllScopes($user); // trust user - $response = $this->postJson("/api/v1/user/{$truster->id}/trusted", ['user_id' => $trustedUser->id]); + $response = $this->postJson("/api/v1/user/{$truster->id}/trusted", ['userId' => $trustedUser->id]); $response->assertForbidden(); // list trusted users @@ -103,7 +103,7 @@ public function testStoreAndDeleteTrustedUserForOtherUsersAsAdmin(): void { $this->actAsApiUserWithAllScopes($user); // trust user - $response = $this->postJson("/api/v1/user/{$truster->id}/trusted", ['user_id' => $trustedUser->id]); + $response = $this->postJson("/api/v1/user/{$truster->id}/trusted", ['userId' => $trustedUser->id]); $response->assertCreated(); $this->assertDatabaseHas('trusted_users', ['user_id' => $truster->id, 'trusted_id' => $trustedUser->id]); From bb2edcf9ba082a5b1ebfcd0087d24877b659e151 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20St=C3=B6ckel?= Date: Fri, 2 Aug 2024 11:42:32 +0200 Subject: [PATCH 31/33] rename api attributes --- storage/api-docs/api-docs.json | 4 ++-- tests/Feature/APIv1/FriendCheckinTest.php | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/storage/api-docs/api-docs.json b/storage/api-docs/api-docs.json index 8480cc750..6a289e793 100644 --- a/storage/api-docs/api-docs.json +++ b/storage/api-docs/api-docs.json @@ -3718,11 +3718,11 @@ "user_id" ], "properties": { - "user_id": { + "userId": { "type": "integer", "example": "1" }, - "expires_at": { + "expiresAt": { "type": "string", "format": "date-time", "example": "2024-07-28T00:00:00Z" diff --git a/tests/Feature/APIv1/FriendCheckinTest.php b/tests/Feature/APIv1/FriendCheckinTest.php index ea4aaf56a..e2eaccede 100644 --- a/tests/Feature/APIv1/FriendCheckinTest.php +++ b/tests/Feature/APIv1/FriendCheckinTest.php @@ -83,7 +83,7 @@ public function testUserCanAllowCheckinsForTrustedUsers(): void { $this->actAsApiUserWithAllScopes($userToCheckin); $response = $this->postJson( uri: "/api/v1/user/{$userToCheckin->id}/trusted", - data: ['user_id' => $user->id] + data: ['userId' => $user->id] ); $response->assertCreated(); From d74d42b1ec7ef45ebf7a7caacde3b20b2b50ad92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20St=C3=B6ckel?= Date: Fri, 2 Aug 2024 11:48:35 +0200 Subject: [PATCH 32/33] add 403 docs --- .../API/v1/TransportController.php | 2 +- .../CheckinForbiddenWithUsersResponse.php | 28 +++++++++++++++++ storage/api-docs/api-docs.json | 31 ++++++++++++++++++- 3 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 app/Virtual/Models/Response/CheckinForbiddenWithUsersResponse.php diff --git a/app/Http/Controllers/API/v1/TransportController.php b/app/Http/Controllers/API/v1/TransportController.php index 1dd5591ba..eaa1f0874 100644 --- a/app/Http/Controllers/API/v1/TransportController.php +++ b/app/Http/Controllers/API/v1/TransportController.php @@ -343,7 +343,7 @@ public function getNextStationByCoordinates(Request $request): JsonResponse { * ), * @OA\Response(response=400, description="Bad request"), * @OA\Response(response=401, description="Unauthorized"), - * @OA\Response(response=403, description="Forbidden"), + * @OA\Response(response=403, description="Forbidden", @OA\JsonContent(ref="#/components/schemas/CheckinForbiddenWithUsersResponse")), * @OA\Response(response=409, description="Checkin collision"), * security={ * {"passport": {"create-statuses"}}, {"token": {}} diff --git a/app/Virtual/Models/Response/CheckinForbiddenWithUsersResponse.php b/app/Virtual/Models/Response/CheckinForbiddenWithUsersResponse.php new file mode 100644 index 000000000..7bc6d9843 --- /dev/null +++ b/app/Virtual/Models/Response/CheckinForbiddenWithUsersResponse.php @@ -0,0 +1,28 @@ + Date: Fri, 2 Aug 2024 11:58:52 +0200 Subject: [PATCH 33/33] update 403 docs --- .../Models/Response/CheckinForbiddenWithUsersResponse.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Virtual/Models/Response/CheckinForbiddenWithUsersResponse.php b/app/Virtual/Models/Response/CheckinForbiddenWithUsersResponse.php index 7bc6d9843..c0e2cc296 100644 --- a/app/Virtual/Models/Response/CheckinForbiddenWithUsersResponse.php +++ b/app/Virtual/Models/Response/CheckinForbiddenWithUsersResponse.php @@ -7,7 +7,7 @@ * title="CheckinForbiddenWithUsersResponse", * @OA\Property ( * property="message", - * example="You are not allowed to check in the following users: 1,2" + * example="You are not allowed to check in the following users: 1" * ), * @OA\Property ( * property="meta",