Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
35 changes: 35 additions & 0 deletions app/Http/Controllers/Api/CommitPinController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Actions\DeleteCommitPin;
use App\Actions\UpdateCommitPin;
use App\Http\Requests\DeleteCommitPinRequest;
use App\Http\Requests\UpdateCommitPinRequest;
use App\Http\Resources\CommitResource;
use App\Models\Commit;
use App\Models\User;
use App\Queries\CommitPageQuery;
use Illuminate\Container\Attributes\CurrentUser;

final readonly class CommitPinController
{
/**
* Answers with the commit as its page shows it, so the app gets the new is_pinned.
*/
public function update(UpdateCommitPinRequest $request, #[CurrentUser] User $user, Commit $commit, UpdateCommitPin $action, CommitPageQuery $page): CommitResource
{
$action->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));
}
}
9 changes: 9 additions & 0 deletions routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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')
Expand Down
126 changes: 126 additions & 0 deletions tests/Feature/Controllers/Api/CommitPinControllerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<?php

declare(strict_types=1);

use App\Models\Commit;
use App\Models\User;

beforeEach(function (): void {
$this->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']);
});