diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 19e85dff8..203d7bc19 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -484,6 +484,7 @@ A signed request sees what the web shows you, such as your own pending commits. - change Remotes settings, read and send messages with images, link cards, and voice notes in conversations and orgs, mark them pulled, react to messages and list your reactions, pin and unpin org messages as a maintainer, find people, and start orgs; - rename orgs, add people to them, change their roles, remove them, and leave an org, with the same maintainer rules as the web; - stash or reopen conversations and orgs, pin and mute them, merge or drop message requests, and send typing indicators; +- pin or unpin one of your commits on your profile; - switch Pastéis mode and give pastéis; - report commits; - sync from GitHub and save your lane color; diff --git a/app/Http/Controllers/Api/CommitPinController.php b/app/Http/Controllers/Api/CommitPinController.php new file mode 100644 index 000000000..c1e18b2a1 --- /dev/null +++ b/app/Http/Controllers/Api/CommitPinController.php @@ -0,0 +1,35 @@ +handle($user, $commit); + + return CommitResource::make($page->get($commit, $user)); + } + + public function destroy(DeleteCommitPinRequest $request, #[CurrentUser] User $user, Commit $commit, DeleteCommitPin $action, CommitPageQuery $page): CommitResource + { + $action->handle($user, $commit); + + return CommitResource::make($page->get($commit, $user)); + } +} diff --git a/routes/api.php b/routes/api.php index 7fa00c293..359b814a4 100644 --- a/routes/api.php +++ b/routes/api.php @@ -5,6 +5,7 @@ use App\Exceptions\ApiException; use App\Http\Controllers\Api\CommitController as ApiCommitController; use App\Http\Controllers\Api\CommitPastelController; +use App\Http\Controllers\Api\CommitPinController; use App\Http\Controllers\Api\CommitPollVoteController; use App\Http\Controllers\Api\CommitPreviewController; use App\Http\Controllers\Api\CommitReplyController; @@ -175,6 +176,14 @@ ->middleware('throttle:stars') ->name('api-commit-star.destroy'); + // Commit Pin... + Route::put('commits/{commit}/pin', [CommitPinController::class, 'update']) + ->middleware('throttle:pins') + ->name('api-commit-pin.update'); + Route::delete('commits/{commit}/pin', [CommitPinController::class, 'destroy']) + ->middleware('throttle:pins') + ->name('api-commit-pin.destroy'); + // User Follow... Route::post('users/{user:handle}/follow', [UserFollowController::class, 'store']) ->middleware('throttle:follows') diff --git a/tests/Feature/Controllers/Api/CommitPinControllerTest.php b/tests/Feature/Controllers/Api/CommitPinControllerTest.php new file mode 100644 index 000000000..b530afab8 --- /dev/null +++ b/tests/Feature/Controllers/Api/CommitPinControllerTest.php @@ -0,0 +1,126 @@ +user = User::factory()->create(); + $this->keyPair = apiKeyPairFor($this->user); +}); + +it('pins an own commit and answers with it pinned', function (): void { + $commit = Commit::factory()->for($this->user)->create(); + + $response = signedApiRequest($this->keyPair, 'PUT', route('api-commit-pin.update', $commit)); + + $response->assertOk() + ->assertJsonPath('data.id', $commit->id) + ->assertJsonPath('data.is_pinned', true); + + expect($this->user->fresh()->pinned_commit_id)->toBe($commit->id); +}); + +it('answers with the commit as the commit endpoint shows it', function (): void { + $commit = Commit::factory()->for($this->user)->create(); + Commit::factory()->for($commit, 'parent')->create(); + + $response = signedApiRequest($this->keyPair, 'PUT', route('api-commit-pin.update', $commit)); + + $response->assertOk() + ->assertExactJson(['data' => signedApiRequest($this->keyPair, 'GET', route('api-commit.show', $commit))->json('data')]); +}); + +it('replaces the pin, as on the web', function (): void { + $first = Commit::factory()->for($this->user)->create(); + $replacement = Commit::factory()->for($this->user)->pending()->create(); + + signedApiRequest($this->keyPair, 'PUT', route('api-commit-pin.update', $first))->assertOk(); + + $response = signedApiRequest($this->keyPair, 'PUT', route('api-commit-pin.update', $replacement)); + + $response->assertOk()->assertJsonPath('data.is_pinned', true); + + expect($this->user->fresh()->pinned_commit_id)->toBe($replacement->id); +}); + +it('unpins an own commit and answers with it unpinned', function (): void { + $commit = Commit::factory()->for($this->user)->create(); + $this->user->forceFill(['pinned_commit_id' => $commit->id])->save(); + + $response = signedApiRequest($this->keyPair, 'DELETE', route('api-commit-pin.destroy', $commit)); + + $response->assertOk() + ->assertJsonPath('data.id', $commit->id) + ->assertJsonPath('data.is_pinned', false); + + expect($this->user->fresh()->pinned_commit_id)->toBeNull(); +}); + +it('does not clear a replacement on a stale unpin', function (): void { + $first = Commit::factory()->for($this->user)->create(); + $replacement = Commit::factory()->for($this->user)->create(); + $this->user->forceFill(['pinned_commit_id' => $replacement->id])->save(); + + $response = signedApiRequest($this->keyPair, 'DELETE', route('api-commit-pin.destroy', $first)); + + $response->assertOk()->assertJsonPath('data.is_pinned', false); + + expect($this->user->fresh()->pinned_commit_id)->toBe($replacement->id); +}); + +it('may not pin or unpin the commit of someone else', function (string $method): void { + $commit = Commit::factory()->create(); + $commit->user->forceFill(['pinned_commit_id' => $commit->id])->save(); + + signedApiRequest($this->keyPair, $method, "api/v1/commits/{$commit->id}/pin")->assertForbidden(); + + expect($commit->user->fresh()->pinned_commit_id)->toBe($commit->id) + ->and($this->user->fresh()->pinned_commit_id)->toBeNull(); +})->with(['PUT', 'DELETE']); + +it('may not pin or unpin a reply', function (string $method): void { + $reply = Commit::factory()->for($this->user)->for(Commit::factory(), 'parent')->create(); + + signedApiRequest($this->keyPair, $method, "api/v1/commits/{$reply->id}/pin")->assertForbidden(); + + expect($this->user->fresh()->pinned_commit_id)->toBeNull(); +})->with(['PUT', 'DELETE']); + +it('may not pin or unpin a deleted commit', function (string $method): void { + $commit = Commit::factory()->for($this->user)->create(); + $commit->delete(); + + signedApiRequest($this->keyPair, $method, "api/v1/commits/{$commit->id}/pin")->assertNotFound(); + + expect($this->user->fresh()->pinned_commit_id)->toBeNull(); +})->with(['PUT', 'DELETE']); + +it('returns not found for unknown commits', function (string $method): void { + $response = signedApiRequest($this->keyPair, $method, 'api/v1/commits/0199e3a4-0000-7000-8000-000000000000/pin'); + + $response->assertNotFound(); +})->with(['PUT', 'DELETE']); + +it('requires a signed request', function (string $method): void { + $commit = Commit::factory()->for($this->user)->create(); + + $response = $this->json($method, "api/v1/commits/{$commit->id}/pin"); + + $response->assertUnauthorized(); + + expect($this->user->fresh()->pinned_commit_id)->toBeNull(); +})->with(['PUT', 'DELETE']); + +it('limits pins and unpins to 20 a minute, like the web, with a code', function (): void { + $commit = Commit::factory()->for($this->user)->create(); + + foreach (range(1, 20) as $attempt) { + signedApiRequest($this->keyPair, $attempt % 2 === 0 ? 'DELETE' : 'PUT', route('api-commit-pin.update', $commit))->assertOk(); + } + + signedApiRequest($this->keyPair, 'PUT', route('api-commit-pin.update', $commit)) + ->assertTooManyRequests() + ->assertExactJson(['message' => 'Too Many Attempts.', 'code' => 'rate_limited']); +});