diff --git a/PRODUCT.md b/PRODUCT.md index 47ce0eb86..7c5102eb0 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -23,6 +23,7 @@ Identity is GitHub. Every account is a real GitHub user, and the profile (avatar - Sign-in is GitHub OAuth only. Profile changes happen on GitHub ("Edit on GitHub"), then sync at next login or with "Sync from GitHub" (once a minute). - Everything except the landing page, the public read API, and profile feeds requires login. - Public read API: read-only JSON under `/api/v1`, no authentication, for public profiles (with counts), a user's commits or replies, and single commits. It returns exactly what a guest's link preview may show: approved, non-deleted commits only (with their images), approved reply counts, and a parent only while it is approved. Never emails or other hidden profile fields. 60 requests a minute per IP. +- Signed API: the mobile app uses the same `/api/v1`, signing every request with an SSH key it keeps on the device, which the user approved as a device (no passwords or API tokens). The app signs in like an OAuth app with PKCE: it opens `/mobile/authorize` in a browser, the user signs in with GitHub if they need to and approves it, and the app redeems the one-time code for the device with a request signed by that key. The approval is the proof, so the key doesn't need to be on GitHub, and a key that is on GitHub, like one made for the GitHub Action, can't use the API without it. Devices are listed, with when each was last used, and revoked under Settings → Devices. A signed request to a public endpoint gets what the web shows that user, such as their own pending commits. Signed-only endpoints read the log, replies, notifications, and the people the user mutes and blocks, and commit, reply, delete, follow, mute, and block, with the web's validation, moderation, and rate limits. A bad or expired signature is a 401, never the public view. 120 requests a minute per user. - Profile feeds: every profile has an Atom feed of its latest 50 approved top-level commits at `/@{username}/feed.atom`, their images as enclosures, advertised with `` on the profile's guest preview. - First sign-in leads to a short setup at `/onboarding`: confirm the mirrored profile, pick a color, follow a few suggested developers, learn the composer, `?` shortcuts and terminal commits, then land on the log (or the page the user was headed to). Skippable at any step (Skip setup or Esc); finishing or skipping ends it for good. Accounts that existed before it never see it. - Profile links (`/@{username}`) and links to approved commits (`/@{username}/commits/{id}`) unfurl with a link preview (the author and their bio or counts, or the commit body and reply count) for guests and crawlers; a guest opening one sees the landing page, and the profile or commit itself stays behind login. Pending and rejected commits are a 404 for guests. @@ -31,7 +32,7 @@ Identity is GitHub. Every account is a real GitHub user, and the profile (avatar - Profiles live at `/@{username}` with commit, following, and follower counts, a commit activity graph with longest and current streaks, and Commits and Replies tabs. Commits is the default and lists the user's top-level commits; `?tab=replies` lists their authored replies with visible parent context. Both feeds use infinite scroll. - Operations (`/operations`, admins only; a 404 for everyone else): database and cache connectivity, the GitHub API rate limit when GitHub credentials are configured, the database queue backlog per queue with the age of its oldest job, the most recent failed jobs (each can be retried or forgotten), pending moderation and link previews, link previews that came back without metadata in the last 24 hours, and signups, commits, and replies over the last 24 hours and 7 days. It refreshes every 15 seconds. - Settings: read-only profile, color (pick one of the six graph lanes or keep the automatic one; the only profile setting /dev/null owns), appearance (light/dark/system), delete account. -- Settings: read-only profile, color (pick one of the six graph lanes or keep the automatic one; the only profile setting /dev/null owns), the Markdown snippet and live preview to embed the activity graph SVG, appearance (light/dark/system), delete account. +- Settings: read-only profile, color (pick one of the six graph lanes or keep the automatic one; the only profile setting /dev/null owns), the Markdown snippet and live preview to embed the activity graph SVG, appearance (light/dark/system), devices (the mobile app on each device the user approved, when it was last used, and revoking it), delete account. ## Capabilities and Constraints @@ -51,7 +52,7 @@ Identity is GitHub. Every account is a real GitHub user, and the profile (avatar - People search: find developers by name or GitHub username (⌘K). Commits are not searchable. - Rate limits, per user: stars and saves 60/min each, pins 20/min, follows 30/min, people lookups (search, mention suggestions, hover cards) 120/min combined, exports 3/hour. Throttled actions keep the page and show a toast; throttled pages show the 429 page. - Public read API and Atom feeds: read-only, so nothing can be created, starred, or followed through them, and there are no API tokens. Lists are cursor-paginated, 20 per page. -- Deliberately excluded for now: likes/reactions, reposts without a comment of your own (quotes are in), images/media, hashtags, searching commits, DMs, other OAuth providers, a write API or API tokens, moderation tools, blocking/muting, header/banner images. Future work must not design for these as if they exist. +- Deliberately excluded for now: likes/reactions, reposts without a comment of your own (quotes are in), images/media, hashtags, searching commits, DMs, other OAuth providers, API tokens, moderation tools, blocking/muting, header/banner images. Future work must not design for these as if they exist. - GitHub username renames: signing in takes the current GitHub username. If a stale account still holds it because its owner renamed on GitHub, that account moves to a `_renamed-` placeholder until its owner signs in again. Accounts are never merged. ## Brand Commitments diff --git a/README.md b/README.md index cfc9af051..b5520b6c2 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,95 @@ curl -s https://dev-null.sh/api/v1/users/nunomaduro/commits | jq '.data[] | {sho Every profile also has an Atom feed of its latest 50 top-level commits at `/@{username}/feed.atom`, with their images as enclosures, cached for five minutes. +## Signed requests + +The mobile app signs its requests to `/api/v1` with an SSH key it keeps on the device, which the user approved on the web (see [Signing in](#signing-in)). A signed request to the endpoints above gets what the web shows that user. The log, replies, notifications, committing, deleting, following, muting and blocking only take signed requests. Signed requests are limited to 120 a minute per user. + +Commits in a signed response carry what the web's cards show: `preview_segments`, the excerpt a list shows of a long commit (or `null`), the `poll` with the user's view of its votes and each option's `percentage`, the `quoted_commit` (a tombstone once deleted, or `null` when the user may not see it, while `quoted_commit_id` still says a commit was quoted), and in lists the `reply_preview` the web shows under each commit. + +A signed profile also says `is_muting` and has the web's `achievements`, with the Unix rank. + +`POST` and `DELETE /api/v1/users/{username}/mute` mute and unmute someone, and `POST` and `DELETE /api/v1/users/{username}/block` block and unblock them, as the web does: nobody can mute or block themselves, muting is silent, and blocking ends the follows both ways, which unblocking doesn't bring back. Each answers with the user and the new `is_muting` or `is_blocking`. `GET /api/v1/me/muted` and `GET /api/v1/me/blocked` list the people the user mutes and blocks, last first, as Settings → Safety does, 20 a page. + +`GET /api/v1/log/{log}` serves the web's three logs, `following`, `latest` and `trending`, with the same queries and 20 commits a page. Trending is ranked, so it also says `is_ranked` and the `newest_commit_id` when it was loaded. With `?since={id}`, every log counts the `new_commits_count` newer than that commit, and Trending also returns them as `latest_commits`, the way the web offers them above it. An empty Following log comes with a few `suggestions`. + +`GET /api/v1/notifications` groups notifications like the web and takes the web's `?type=` filter: `all` (the default), `replies`, `quotes`, `stars`, `mentions`, `follows` or `moderation`. The response echoes it as `filters.type`. Listing them marks every notification as read, whatever the filter, and `read_at` says when, so the groups read at or after it are the new ones. `GET /api/v1/notifications/{id}/group` lists the notifications of one group, with the same filter, the way the web expands it. + +Each request sends three headers: + +| Header | Value | +|---|---| +| `X-Devnull-Timestamp` | Unix time in seconds, within 5 minutes of the server's clock | +| `X-Devnull-Nonce` | 32 random hex characters, used once | +| `X-SSH-Signature` | The SSHSIG signature, base64 on one line | + +The signature is made with `ssh-keygen -Y sign -n devnull-api` over these lines, joined by `\n`: the timestamp, the nonce, the method, the path with its query string exactly as sent, and the raw body. + +```text +1760534400 +3f9c2a7e5b1d4c8f9a0b2c3d4e5f6a7b +POST +/api/v1/commits +{"body":"Hello from my phone"} +``` + +A request with any of these headers has to be signed right. A bad or expired signature is a `401`, never the public view. A request without them always gets the public view, and across a block a signed request gets neither profile: it is a `403`, `user_blocked` for the one who blocked and `user_unavailable` for the other, and their commits are hidden as on the web. + +A key signs in once its user approved it as a device. It doesn't have to be on their GitHub account, and being on it isn't enough, so a key made for the GitHub Action can't use the API. Users see their devices, when each was last used, and revoke them under Settings → Devices (`/settings/devices`). A key that isn't approved, or was revoked, gets a `401` with `device_not_approved`, and the app signs in again. + +### Errors + +An error is JSON with a `message` to show the user. The errors the app has to tell apart also have a `code`, which stays the same when the message changes: + +```json +{"message": "This key isn't approved for the /dev/null app. Sign in again in the app.", "code": "device_not_approved"} +``` + +| Status | `code` | When | +|---|---|---| +| `400` | `invalid_grant` | `POST /api/v1/devices` only: the code is wrong, expired or used, was handed out for another key, or the verifier doesn't match. | +| `401` | `invalid_signature` | The signature is missing, malformed or wrong, has expired, or was used already. | +| `401` | `device_not_approved` | Nobody approved the key as a device, or it was revoked. | +| `403` | `user_blocked` | The user blocked the profile they asked for. Neither of them sees the other's commits. The error comes with the `user` they blocked, as the web shows them. | +| `403` | `user_unavailable` | The profile isn't available to the user, because it blocked them. | +| `429` | `rate_limited` | Over 120 signed requests a minute for the user, 10 commits a minute, or 30 follows and unfollows a minute. `Retry-After` says when to try again. | + +Other errors have no `code`, like a `422` for a commit that fails validation or a `404` for one that doesn't exist. Unsigned requests to the public endpoints get the same errors as before, without codes. + +### Signing in + +The app signs in the way an OAuth app does, with the authorization code flow and PKCE ([RFC 7636](https://www.rfc-editor.org/rfc/rfc7636)), and /dev/null as the provider. The code it gets is bound to its key, so only the app that holds the key can redeem it. + +1. The app makes an ed25519 key and a random `code_verifier`, and opens this page in a browser session (`ASWebAuthenticationSession` on iOS): + + ```text + https://dev-null.sh/mobile/authorize + ?fingerprint=SHA256:hPEdU4FVZ1lfjzbPyBhqTWG2qxUu3ZbCnD8mZw9t2Vk + &device_name=devnull%20on%20iPhone + &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM + &code_challenge_method=S256 + &state=af0ifjsldkj + &redirect_uri=devnull://auth/callback + ``` + + `code_challenge` is `base64url(sha256(code_verifier))` without padding. `redirect_uri` has to start with `devnull://`. A guest signs in with GitHub first and comes back to the page. A request with a missing or bad parameter gets an error page and is never redirected. +2. The page asks "Approve devnull on iPhone?", with the key's fingerprint and the account it signs in as. **Approve** redirects (`303`) to `devnull://auth/callback?code=…&state=af0ifjsldkj`. **Cancel** goes to `devnull://auth/callback?error=access_denied&state=af0ifjsldkj`. The app checks that `state` is the one it sent. A code is good for 5 minutes and only once, and each user can approve 10 times a minute. +3. The app redeems the code with `POST /api/v1/devices`, signed with the key like any other request, though the key isn't approved yet: + + ```json + {"code": "…", "code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"} + ``` + + The key becomes an approved device of the user who approved it, and the answer is `200` with the user, like `GET /api/v1/me`. From then on every request it signs is theirs, until they revoke the device. + +| Status | `code` | Means | +|---|---|---| +| `200` | | The device is approved. The body is the user, with `unread_notifications_count`. | +| `400` | `invalid_grant` | The code is wrong, expired or used, was handed out for another key, or the verifier doesn't match. Sign in again. | +| `401` | `invalid_signature` | The signature is bad, expired or used already. | +| `422` | | `code` or `code_verifier` is missing, or the verifier isn't 43 to 128 characters of `A-Z a-z 0-9 - . _ ~`. | +| `429` | `rate_limited` | Over 12 tries a minute for one key, or 60 from one IP, counted before signatures are checked. `Retry-After` says when to try again. | + ## Available Tooling ### Development diff --git a/app/Actions/CreateAuthorizationCode.php b/app/Actions/CreateAuthorizationCode.php new file mode 100644 index 000000000..8cf4d771e --- /dev/null +++ b/app/Actions/CreateAuthorizationCode.php @@ -0,0 +1,32 @@ +authorizationCodes()->create([ + 'code_hash' => AuthorizationCode::hash($code), + 'fingerprint' => $authorization->fingerprint, + 'device_name' => $authorization->deviceName, + 'code_challenge' => $authorization->codeChallenge, + 'redirect_uri' => $authorization->redirectUri, + 'expires_at' => now()->addMinutes(AuthorizationCode::EXPIRES_AFTER_MINUTES), + ]); + + return $code; + } +} diff --git a/app/Actions/CreateDevice.php b/app/Actions/CreateDevice.php new file mode 100644 index 000000000..bd3e20531 --- /dev/null +++ b/app/Actions/CreateDevice.php @@ -0,0 +1,43 @@ +whereKey($authorizationCode->id) + ->where('expires_at', '>', now()) + ->lockForUpdate() + ->first(); + + if (! $code instanceof AuthorizationCode) { + return null; + } + + $code->delete(); + + return Device::query()->updateOrCreate(['fingerprint' => $code->fingerprint], [ + 'user_id' => $code->user_id, + 'name' => $code->device_name, + 'approved_at' => now(), + 'last_used_at' => null, + ]); + }); + } +} diff --git a/app/Actions/DeleteDevice.php b/app/Actions/DeleteDevice.php new file mode 100644 index 000000000..2d278fd9d --- /dev/null +++ b/app/Actions/DeleteDevice.php @@ -0,0 +1,18 @@ +delete(); + } +} diff --git a/app/Actions/UpdateDeviceLastUsedAt.php b/app/Actions/UpdateDeviceLastUsedAt.php new file mode 100644 index 000000000..77020de78 --- /dev/null +++ b/app/Actions/UpdateDeviceLastUsedAt.php @@ -0,0 +1,22 @@ +last_used_at?->isAfter(now()->subMinute()) === true) { + return; + } + + $device->update(['last_used_at' => now()]); + } +} diff --git a/app/Dto/MobileAuthorization.php b/app/Dto/MobileAuthorization.php new file mode 100644 index 000000000..340d783d8 --- /dev/null +++ b/app/Dto/MobileAuthorization.php @@ -0,0 +1,49 @@ + $parameters + */ + public function redirectTo(array $parameters): string + { + return $this->redirectUri.(str_contains($this->redirectUri, '?') ? '&' : '?').http_build_query([...$parameters, 'state' => $this->state]); + } + + /** + * The parameters as the app sent them, for the approval form to send back. + * + * @return array{fingerprint: string, device_name: string, code_challenge: string, code_challenge_method: string, state: string, redirect_uri: string} + */ + public function toParameters(): array + { + return [ + 'fingerprint' => $this->fingerprint, + 'device_name' => $this->deviceName, + 'code_challenge' => $this->codeChallenge, + 'code_challenge_method' => 'S256', + 'state' => $this->state, + 'redirect_uri' => $this->redirectUri, + ]; + } +} diff --git a/app/Enums/ApiErrorCode.php b/app/Enums/ApiErrorCode.php new file mode 100644 index 000000000..05650da54 --- /dev/null +++ b/app/Enums/ApiErrorCode.php @@ -0,0 +1,29 @@ + 400, + self::InvalidSignature, self::DeviceNotApproved => 401, + self::UserBlocked, self::UserUnavailable => 403, + self::RateLimited => 429, + }; + } +} diff --git a/app/Exceptions/ApiException.php b/app/Exceptions/ApiException.php new file mode 100644 index 000000000..5bd364129 --- /dev/null +++ b/app/Exceptions/ApiException.php @@ -0,0 +1,57 @@ + $headers + * @param array $data + */ + public function __construct(public readonly ApiErrorCode $errorCode, string $message, array $headers = [], public readonly array $data = []) + { + parent::__construct($errorCode->status(), $message, headers: $headers); + } + + /** + * A profile across a block is unavailable to a signed request, as on the web. Only the one who blocked the other + * is told why, with the user they blocked as the API shows users, for the blocked page the web shows them. + */ + public static function acrossBlock(User $user, bool $blockedByViewer, JsonSerializable $shown): self + { + return $blockedByViewer + ? new self(ApiErrorCode::UserBlocked, __("You blocked :name. Neither of you can follow, reply to, or star the other, or see each other's commits.", [ + 'name' => $user->name, + ]), data: ['user' => $shown]) + : new self(ApiErrorCode::UserUnavailable, __("This profile isn't available.")); + } + + /** + * The one answer to a code the app can't redeem, whatever is wrong with it, as OAuth answers it. + */ + public static function invalidGrant(): self + { + return new self(ApiErrorCode::InvalidGrant, __('This sign-in has expired or was already used. Sign in again in the app.')); + } + + public function render(): JsonResponse + { + return response()->json([ + 'message' => $this->getMessage(), + 'code' => $this->errorCode->value, + ...$this->data, + ], $this->getStatusCode(), $this->getHeaders()); + } +} diff --git a/app/Http/Controllers/Api/CommitController.php b/app/Http/Controllers/Api/CommitController.php index f0adc46d3..0960e4ba7 100644 --- a/app/Http/Controllers/Api/CommitController.php +++ b/app/Http/Controllers/Api/CommitController.php @@ -4,17 +4,57 @@ namespace App\Http\Controllers\Api; +use App\Actions\CreateCommit; +use App\Actions\DeleteCommit; +use App\Http\Requests\CreateApiCommitRequest; +use App\Http\Requests\DeleteApiCommitRequest; use App\Http\Requests\ShowApiCommitRequest; use App\Http\Resources\CommitResource; use App\Models\Commit; +use App\Models\User; +use App\Queries\CommitAncestorsQuery; +use App\Queries\CommitPageQuery; +use Illuminate\Container\Attributes\CurrentUser; +use Illuminate\Http\JsonResponse; +use Illuminate\Http\Response; final readonly class CommitController { - public function show(ShowApiCommitRequest $request, Commit $commit): CommitResource + /** + * A signed request gets the commit as its page shows it to that user, with the commits it replies to above it. + */ + public function show(ShowApiCommitRequest $request, #[CurrentUser] ?User $currentUser, Commit $commit, CommitPageQuery $page, CommitAncestorsQuery $ancestors): CommitResource { + if ($currentUser instanceof User) { + $commit = $page->get($commit, $currentUser); + + return CommitResource::make($commit)->additional([ + 'ancestors' => CommitResource::collection($ancestors->get($commit, $currentUser)), + ]); + } + return CommitResource::make(Commit::query() ->withPublicPreview() ->whereKey($commit->id) ->sole()); } + + /** + * Answers with the new commit as its page shows it, pending review unless the user is an admin. + */ + public function store(CreateApiCommitRequest $request, #[CurrentUser] User $user, CreateCommit $action, CommitPageQuery $page): JsonResponse + { + $commit = $action->handle($user, $request->string('body')->value(), $request->parent()); + + return CommitResource::make($page->get($commit, $user)) + ->response() + ->setStatusCode(201); + } + + public function destroy(DeleteApiCommitRequest $request, Commit $commit, DeleteCommit $action): Response + { + $action->handle($commit); + + return response()->noContent(); + } } diff --git a/app/Http/Controllers/Api/CommitReplyController.php b/app/Http/Controllers/Api/CommitReplyController.php new file mode 100644 index 000000000..851a3ed86 --- /dev/null +++ b/app/Http/Controllers/Api/CommitReplyController.php @@ -0,0 +1,21 @@ +paginate($commit, $user)); + } +} diff --git a/app/Http/Controllers/Api/CurrentUserController.php b/app/Http/Controllers/Api/CurrentUserController.php new file mode 100644 index 000000000..beaae6724 --- /dev/null +++ b/app/Http/Controllers/Api/CurrentUserController.php @@ -0,0 +1,23 @@ +loadCounts())->additional([ + 'unread_notifications_count' => $notifications->unreadCount($user), + ]); + } +} diff --git a/app/Http/Controllers/Api/DeviceController.php b/app/Http/Controllers/Api/DeviceController.php new file mode 100644 index 000000000..05d5d80db --- /dev/null +++ b/app/Http/Controllers/Api/DeviceController.php @@ -0,0 +1,36 @@ +handle($request->authorizationCode()); + + if (! $device instanceof Device) { + return ApiException::invalidGrant()->render(); + } + + Auth::setUser($device->user); + + return UserResource::make($device->user->loadCounts())->additional([ + 'unread_notifications_count' => $notifications->unreadCount($device->user), + ]); + } +} diff --git a/app/Http/Controllers/Api/LogController.php b/app/Http/Controllers/Api/LogController.php new file mode 100644 index 000000000..d91c3763c --- /dev/null +++ b/app/Http/Controllers/Api/LogController.php @@ -0,0 +1,48 @@ +since(); + $commits = $log->query()->builder($user)->cursorPaginate(20); + $latestCommits = $since instanceof Commit ? $latest->get($log, $user, $since, null) : new Collection(); + + $replyPreviews->load([...$commits->items(), ...$latestCommits], $user); + + return CommitResource::collection($commits)->additional([ + 'log' => $log, + 'is_ranked' => $log->isRanked(), + 'newest_commit_id' => $log->isRanked() ? $latest->newestId($log, $user) : null, + ...$since instanceof Commit ? [ + 'latest_commits' => CommitResource::collection($latestCommits), + 'new_commits_count' => $latest->count($log, $user, $since), + ] : [], + 'suggestions' => CommitResource::collection($log === Log::Following && ! $user->following()->exists() + ? $suggested->get($user) + : new Collection()), + ]); + } +} diff --git a/app/Http/Controllers/Api/NotificationController.php b/app/Http/Controllers/Api/NotificationController.php new file mode 100644 index 000000000..1e88f77ac --- /dev/null +++ b/app/Http/Controllers/Api/NotificationController.php @@ -0,0 +1,47 @@ +handle($user); + + $notifications = $query->paginate($user, $request->type(), $request->through()); + + /** @var array $items */ + $items = $notifications->items(); + + new Collection($items)->loadMissing([ + 'commit.user', + 'commit.parent' => fn (BelongsTo $query): BelongsTo => $query + ->where(fn (CommitBuilder $query): CommitBuilder => $query->visibleTo($user)) + ->with('user'), + ]); + + return NotificationResource::collection($notifications)->additional([ + 'filters' => ['type' => $request->type()], + 'read_at' => $readAt->toIso8601ZuluString(), + ]); + } +} diff --git a/app/Http/Controllers/Api/NotificationGroupController.php b/app/Http/Controllers/Api/NotificationGroupController.php new file mode 100644 index 000000000..bb28b7216 --- /dev/null +++ b/app/Http/Controllers/Api/NotificationGroupController.php @@ -0,0 +1,37 @@ +members($user, $notification, $request->type()) + ->with([ + 'commit.user', + 'commit.parent' => fn (BelongsTo $query): BelongsTo => $query + ->where(fn (CommitBuilder $query): CommitBuilder => $query->visibleTo($user)) + ->with('user'), + ]) + ->latest() + ->latest('id') + ->cursorPaginate(20) + ->withQueryString()); + } +} diff --git a/app/Http/Controllers/Api/UserBlockController.php b/app/Http/Controllers/Api/UserBlockController.php new file mode 100644 index 000000000..b9b4ad712 --- /dev/null +++ b/app/Http/Controllers/Api/UserBlockController.php @@ -0,0 +1,46 @@ +blocking()->latest('blocks.created_at')->latest('users.id')->cursorPaginate(20)); + } + + /** + * Blocks the user, which ends the follows both ways, as on the web, and answers with them and that they are now + * blocked. + */ + public function store(CreateApiUserBlockRequest $request, #[CurrentUser] User $currentUser, User $user, CreateUserBlock $action): UserResource + { + $action->handle($currentUser, $user); + + return UserResource::make($user)->additional(['is_blocking' => true]); + } + + /** + * Unblocking doesn't bring back the follows, as on the web. + */ + public function destroy(DeleteApiUserBlockRequest $request, #[CurrentUser] User $currentUser, User $user, DeleteUserBlock $action): UserResource + { + $action->handle($currentUser, $user); + + return UserResource::make($user)->additional(['is_blocking' => false]); + } +} diff --git a/app/Http/Controllers/Api/UserCommitController.php b/app/Http/Controllers/Api/UserCommitController.php index b53a94209..fcbdd549b 100644 --- a/app/Http/Controllers/Api/UserCommitController.php +++ b/app/Http/Controllers/Api/UserCommitController.php @@ -8,12 +8,28 @@ use App\Http\Resources\CommitResource; use App\Models\User; use App\Queries\CommitBuilder; +use App\Queries\ReplyPreviewsQuery; +use App\Queries\UserCommitsQuery; +use Illuminate\Container\Attributes\CurrentUser; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; final readonly class UserCommitController { - public function index(ListApiUserCommitsRequest $request, User $user): AnonymousResourceCollection + /** + * A signed request gets the profile's Commits or Replies tab as the web shows it to that user. + */ + public function index(ListApiUserCommitsRequest $request, #[CurrentUser] ?User $currentUser, User $user, UserCommitsQuery $commits, ReplyPreviewsQuery $replyPreviews): AnonymousResourceCollection { + if ($currentUser instanceof User) { + $signedCommits = $commits->builder($user, $currentUser, $request->replies()) + ->cursorPaginate(20) + ->appends($request->only('replies')); + + $replyPreviews->load($signedCommits->items(), $currentUser); + + return CommitResource::collection($signedCommits); + } + return CommitResource::collection($user->commits() ->approved() ->when( diff --git a/app/Http/Controllers/Api/UserController.php b/app/Http/Controllers/Api/UserController.php index 7d6374154..67ebbd8ab 100644 --- a/app/Http/Controllers/Api/UserController.php +++ b/app/Http/Controllers/Api/UserController.php @@ -4,13 +4,45 @@ namespace App\Http\Controllers\Api; +use App\Http\Requests\ShowApiUserRequest; +use App\Http\Resources\CommitResource; use App\Http\Resources\UserResource; +use App\Models\Commit; use App\Models\User; +use App\Queries\PinnedCommitQuery; +use App\Queries\ReplyPreviewsQuery; +use App\Queries\UserAchievementsQuery; +use App\Queries\UserActivityQuery; +use App\Queries\UserStreakQuery; +use Illuminate\Container\Attributes\CurrentUser; final readonly class UserController { - public function show(User $user): UserResource + /** + * A signed request gets the profile as the web shows it to that user, with whether they mute it, and its + * achievements and Unix rank. The pinned commit is left out of the commits list, so it comes with the profile, as + * on the web. + */ + public function show(ShowApiUserRequest $request, #[CurrentUser] ?User $currentUser, User $user, UserActivityQuery $activity, UserStreakQuery $streaks, UserAchievementsQuery $achievements, PinnedCommitQuery $pinned, ReplyPreviewsQuery $replyPreviews): UserResource { + if ($currentUser instanceof User) { + $pinnedCommit = $pinned->get($user, $currentUser); + + $replyPreviews->load(array_filter([$pinnedCommit]), $currentUser); + + $userStreaks = $streaks->get($user, $currentUser); + + return UserResource::make($user->loadCounts($currentUser))->additional([ + 'is_following' => $currentUser->isFollowing($user), + 'follows_you' => $user->isFollowing($currentUser), + 'is_muting' => $currentUser->isMuting($user), + 'activity' => $activity->get($user, $currentUser), + 'streaks' => $userStreaks, + 'achievements' => $achievements->get($user, $currentUser, $userStreaks), + 'pinned_commit' => $pinnedCommit instanceof Commit ? CommitResource::make($pinnedCommit) : null, + ]); + } + return UserResource::make($user->loadPublicCounts()); } } diff --git a/app/Http/Controllers/Api/UserFollowController.php b/app/Http/Controllers/Api/UserFollowController.php new file mode 100644 index 000000000..246365ed3 --- /dev/null +++ b/app/Http/Controllers/Api/UserFollowController.php @@ -0,0 +1,30 @@ +handle($follower, $user); + + return response()->noContent(); + } + + public function destroy(DeleteApiUserFollowRequest $request, #[CurrentUser] User $follower, User $user, DeleteUserFollow $action): Response + { + $action->handle($follower, $user); + + return response()->noContent(); + } +} diff --git a/app/Http/Controllers/Api/UserMuteController.php b/app/Http/Controllers/Api/UserMuteController.php new file mode 100644 index 000000000..83c0ef267 --- /dev/null +++ b/app/Http/Controllers/Api/UserMuteController.php @@ -0,0 +1,42 @@ +muting()->latest('mutes.created_at')->latest('users.id')->cursorPaginate(20)); + } + + /** + * Mutes the user silently, as on the web, and answers with them and that they are now muted. + */ + public function store(CreateApiUserMuteRequest $request, #[CurrentUser] User $currentUser, User $user, CreateUserMute $action): UserResource + { + $action->handle($currentUser, $user); + + return UserResource::make($user)->additional(['is_muting' => true]); + } + + public function destroy(DeleteApiUserMuteRequest $request, #[CurrentUser] User $currentUser, User $user, DeleteUserMute $action): UserResource + { + $action->handle($currentUser, $user); + + return UserResource::make($user)->additional(['is_muting' => false]); + } +} diff --git a/app/Http/Controllers/CommitController.php b/app/Http/Controllers/CommitController.php index fcac4cefa..5fde18581 100644 --- a/app/Http/Controllers/CommitController.php +++ b/app/Http/Controllers/CommitController.php @@ -13,6 +13,7 @@ use App\Models\Commit; use App\Models\User; use App\Queries\CommitAncestorsQuery; +use App\Queries\CommitPageQuery; use App\Queries\CommitRepliesQuery; use App\Services\PageMetadata; use Illuminate\Container\Attributes\CurrentUser; @@ -26,7 +27,7 @@ /** * Commits are public, so guests and crawlers read the same page, with the commit's link preview and the replies shown under it as structured data. */ - public function show(ShowCommitRequest $request, #[CurrentUser] ?User $currentUser, User $user, Commit $commit, CommitAncestorsQuery $ancestors, CommitRepliesQuery $replies, PageMetadata $metadata): Response + public function show(ShowCommitRequest $request, #[CurrentUser] ?User $currentUser, User $user, Commit $commit, CommitPageQuery $page, CommitAncestorsQuery $ancestors, CommitRepliesQuery $replies, PageMetadata $metadata): Response { $threadReplies = $replies->paginate($commit, $currentUser); @@ -35,10 +36,7 @@ public function show(ShowCommitRequest $request, #[CurrentUser] ?User $currentUs $threadReplies->items(), ); - $commit = Commit::query() - ->withVisibleReplyCount($currentUser) - ->withCardDataFor($currentUser) - ->findOrFail($commit->id); + $commit = $page->get($commit, $currentUser); return Inertia::render('commit/show', [ 'commit' => $commit, diff --git a/app/Http/Controllers/DeviceController.php b/app/Http/Controllers/DeviceController.php new file mode 100644 index 000000000..7009fdea1 --- /dev/null +++ b/app/Http/Controllers/DeviceController.php @@ -0,0 +1,34 @@ + $user->devices()->latest('approved_at')->get(), + ]); + } + + public function destroy(DeleteDeviceRequest $request, Device $device, DeleteDevice $action): RedirectResponse + { + $action->handle($device); + + return back(); + } +} diff --git a/app/Http/Controllers/MobileAuthorizationController.php b/app/Http/Controllers/MobileAuthorizationController.php new file mode 100644 index 000000000..2c8057d37 --- /dev/null +++ b/app/Http/Controllers/MobileAuthorizationController.php @@ -0,0 +1,44 @@ +authorization(); + + return Inertia::render('mobile-authorization/show', [ + 'authorization' => $authorization->toParameters(), + 'cancelUrl' => $authorization->redirectTo(['error' => 'access_denied']), + 'csrfToken' => csrf_token(), + ]); + } + + /** + * Sends the user back to the app with a one-time code, which the app redeems for a device with its PKCE verifier. + */ + public function store(CreateMobileAuthorizationRequest $request, #[CurrentUser] User $user, CreateAuthorizationCode $action): RedirectResponse + { + $authorization = $request->authorization(); + + $code = $action->handle($user, $authorization); + + return redirect()->away($authorization->redirectTo(['code' => $code]), 303); + } +} diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index f96885742..69501ffac 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -9,13 +9,13 @@ use App\Http\Requests\ShowUserRequest; use App\Models\Commit; use App\Models\User; -use App\Queries\CommitBuilder; +use App\Queries\PinnedCommitQuery; use App\Queries\UserAchievementsQuery; use App\Queries\UserActivityQuery; +use App\Queries\UserCommitsQuery; use App\Queries\UserStreakQuery; use App\Services\PageMetadata; use Illuminate\Container\Attributes\CurrentUser; -use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Http\RedirectResponse; use Illuminate\Support\Facades\Auth; use Inertia\Inertia; @@ -26,7 +26,7 @@ /** * Profiles are public, so guests and crawlers read the same page, with the profile's link preview. */ - public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser, User $user, UserActivityQuery $activity, UserStreakQuery $streaks, UserAchievementsQuery $achievements, PageMetadata $metadata): Response + public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser, User $user, UserActivityQuery $activity, UserStreakQuery $streaks, UserAchievementsQuery $achievements, UserCommitsQuery $commits, PinnedCommitQuery $pinned, PageMetadata $metadata): Response { $blockerId = $currentUser?->blockerIdWith($user); @@ -51,30 +51,8 @@ public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser 'streaks' => $getStreaks, 'achievements' => fn (): array => $achievements->get($user, $currentUser, $getStreaks()), 'tab' => $request->tab(...), - 'pinnedCommit' => fn (): ?Commit => $request->tab() === 'replies' || $user->pinned_commit_id === null ? null : $user->commits() - ->visibleTo($currentUser) - ->topLevel() - ->withVisibleReplyCount($currentUser) - ->withReplyPreviews($currentUser) - ->withCardDataFor($currentUser) - ->find($user->pinned_commit_id), - 'commits' => Inertia::scroll(fn () => $user->commits() - ->visibleTo($currentUser) - ->when( - $request->tab() === 'replies', - fn (CommitBuilder $query): CommitBuilder => $query - ->whereNotNull('parent_id') - ->with(['parent' => fn (BelongsTo $query): BelongsTo => $query - ->where(fn (CommitBuilder $query): CommitBuilder => $query->visibleTo($currentUser)) - ->with('user')]), - fn (CommitBuilder $query): CommitBuilder => $query->topLevel() - ->when($user->pinned_commit_id, fn (CommitBuilder $query, string $id): CommitBuilder => $query->whereKeyNot($id)), - ) - ->withVisibleReplyCount($currentUser) - ->withReplyPreviews($currentUser) - ->withCardDataFor($currentUser) - ->latest() - ->latest('id') + 'pinnedCommit' => fn (): ?Commit => $request->tab() === 'replies' ? null : $pinned->get($user, $currentUser), + 'commits' => Inertia::scroll(fn () => $commits->builder($user, $currentUser, $request->tab() === 'replies') ->cursorPaginate(20) ->appends(['tab' => $request->tab()])), ])->withViewData(['preview' => $currentUser instanceof User ? null : $metadata->user($user->loadPublicCounts())]); diff --git a/app/Http/Middleware/AuthenticateSignedRequest.php b/app/Http/Middleware/AuthenticateSignedRequest.php new file mode 100644 index 000000000..916fb7c58 --- /dev/null +++ b/app/Http/Middleware/AuthenticateSignedRequest.php @@ -0,0 +1,82 @@ +signedRequests->isSigned($request)) { + return $this->asGuest($request, $next); + } + + try { + ['device' => $device] = $this->signedRequests->verify($request); + } catch (InvalidArgumentException $invalidArgumentException) { + throw new ApiException(ApiErrorCode::InvalidSignature, $invalidArgumentException->getMessage()); + } + + if (! $device instanceof Device) { + throw new ApiException(ApiErrorCode::DeviceNotApproved, __("This key isn't approved for the /dev/null app. Sign in again in the app.")); + } + + Auth::setUser($device->user); + + return $next($request); + } + + /** + * Only a signature signs a request in. A request without one gets what a guest gets, even when a user is already + * signed in some other way, such as on the web in a test, and that user is signed in again afterwards. + * + * @param Closure(Request): (Response) $next + */ + private function asGuest(Request $request, Closure $next): Response + { + $user = Auth::user(); + + if (! $user instanceof User) { + return $next($request); + } + + Auth::forgetUser(); + + try { + return $next($request); + } finally { + Auth::setUser($user); + } + } +} diff --git a/app/Http/Middleware/ThrottleCommits.php b/app/Http/Middleware/ThrottleCommits.php index a3e7162ba..b29b05f23 100644 --- a/app/Http/Middleware/ThrottleCommits.php +++ b/app/Http/Middleware/ThrottleCommits.php @@ -4,6 +4,8 @@ namespace App\Http\Middleware; +use App\Enums\ApiErrorCode; +use App\Exceptions\ApiException; use App\Models\User; use Closure; use Illuminate\Http\Request; @@ -46,6 +48,11 @@ private function slowDown(Request $request, int $retryAfter): Response ->header('Content-Type', 'text/plain; charset=utf-8'); } + // The mobile app reads JSON with a code... + if ($request->routeIs('api-commit.store')) { + return new ApiException(ApiErrorCode::RateLimited, $message, ['Retry-After' => $retryAfter])->render(); + } + if ($request->expectsJson()) { return response()->json(['message' => $message], 429, ['Retry-After' => $retryAfter]); } diff --git a/app/Http/Requests/CreateApiCommitRequest.php b/app/Http/Requests/CreateApiCommitRequest.php new file mode 100644 index 000000000..9514e604a --- /dev/null +++ b/app/Http/Requests/CreateApiCommitRequest.php @@ -0,0 +1,54 @@ +> + */ + public function rules(): array + { + $user = $this->user(); + assert($user instanceof User); + + return [ + 'body' => CreateCommitRequest::BODY_RULES, + 'parent_id' => ['bail', 'nullable', 'uuid', new VisibleCommit($user)], + ]; + } + + public function parent(): ?Commit + { + $user = $this->user(); + assert($user instanceof User); + + return $this->filled('parent_id') + ? Commit::query()->visibleTo($user)->find($this->string('parent_id')->value()) + : null; + } +} diff --git a/app/Http/Requests/CreateApiDeviceRequest.php b/app/Http/Requests/CreateApiDeviceRequest.php new file mode 100644 index 000000000..63b29d59a --- /dev/null +++ b/app/Http/Requests/CreateApiDeviceRequest.php @@ -0,0 +1,91 @@ +fingerprint = $signedRequests->verifySignature($this)->fingerprint(); + } catch (InvalidArgumentException $invalidArgumentException) { + throw new ApiException(ApiErrorCode::InvalidSignature, $invalidArgumentException->getMessage()); + } + + $key = "devices:{$this->fingerprint}"; + + throw_if(RateLimiter::tooManyAttempts($key, self::RedemptionsPerKeyPerMinute), fn (): ApiException => new ApiException(ApiErrorCode::RateLimited, __('Too Many Attempts.'), [ + 'Retry-After' => RateLimiter::availableIn($key), + ])); + + RateLimiter::hit($key); + + return true; + } + + /** + * The verifier is the one the app made its PKCE challenge from (RFC 7636). + * + * @return array> + */ + public function rules(): array + { + return [ + 'code' => ['required', 'string', 'max:255'], + 'code_verifier' => ['required', 'string', 'regex:/^[A-Za-z0-9._~-]{43,128}$/'], + ]; + } + + public function authorizationCode(): AuthorizationCode + { + assert($this->authorizationCode instanceof AuthorizationCode); + + return $this->authorizationCode; + } + + /** + * Whatever is wrong with the code, it gets the same answer: one that is unknown, expired or used, handed out for + * another key, or whose challenge the verifier doesn't match. + */ + public function passedValidation(): void + { + $code = AuthorizationCode::query() + ->where('code_hash', AuthorizationCode::hash($this->string('code')->value())) + ->where('expires_at', '>', now()) + ->first(); + + throw_unless( + $code instanceof AuthorizationCode + && hash_equals($code->fingerprint, $this->fingerprint) + && hash_equals($code->code_challenge, AuthorizationCode::challengeFor($this->string('code_verifier')->value())), + ApiException::invalidGrant(), + ); + + $this->authorizationCode = $code; + } +} diff --git a/app/Http/Requests/CreateApiUserBlockRequest.php b/app/Http/Requests/CreateApiUserBlockRequest.php new file mode 100644 index 000000000..a0b3b44e6 --- /dev/null +++ b/app/Http/Requests/CreateApiUserBlockRequest.php @@ -0,0 +1,33 @@ +user(); + assert($user instanceof User); + + $blocked = $this->route('user'); + assert($blocked instanceof User); + + return $user->isNot($blocked); + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/CreateApiUserFollowRequest.php b/app/Http/Requests/CreateApiUserFollowRequest.php new file mode 100644 index 000000000..af05fcf3e --- /dev/null +++ b/app/Http/Requests/CreateApiUserFollowRequest.php @@ -0,0 +1,30 @@ +user(); + assert($follower instanceof User); + + $user = $this->route('user'); + assert($user instanceof User); + + return $follower->isNot($user) && ! $follower->hasBlockWith($user); + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/CreateApiUserMuteRequest.php b/app/Http/Requests/CreateApiUserMuteRequest.php new file mode 100644 index 000000000..3ef11509f --- /dev/null +++ b/app/Http/Requests/CreateApiUserMuteRequest.php @@ -0,0 +1,33 @@ +user(); + assert($user instanceof User); + + $muted = $this->route('user'); + assert($muted instanceof User); + + return $user->isNot($muted); + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/CreateMobileAuthorizationRequest.php b/app/Http/Requests/CreateMobileAuthorizationRequest.php new file mode 100644 index 000000000..0e0692530 --- /dev/null +++ b/app/Http/Requests/CreateMobileAuthorizationRequest.php @@ -0,0 +1,44 @@ +> + */ + public function rules(): array + { + return ShowMobileAuthorizationRequest::parameterRules(); + } + + public function authorization(): MobileAuthorization + { + return new MobileAuthorization( + $this->string('fingerprint')->value(), + $this->string('device_name')->value(), + $this->string('code_challenge')->value(), + $this->string('state')->value(), + $this->string('redirect_uri')->value(), + ); + } + + public function failedValidation(Validator $validator): never + { + throw ShowMobileAuthorizationRequest::invalid($this, $validator); + } +} diff --git a/app/Http/Requests/DeleteApiCommitRequest.php b/app/Http/Requests/DeleteApiCommitRequest.php new file mode 100644 index 000000000..3522e2e9e --- /dev/null +++ b/app/Http/Requests/DeleteApiCommitRequest.php @@ -0,0 +1,31 @@ +user(); + assert($user instanceof User); + + $commit = $this->route('commit'); + assert($commit instanceof Commit); + + return $user->id === $commit->user_id; + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/DeleteApiUserBlockRequest.php b/app/Http/Requests/DeleteApiUserBlockRequest.php new file mode 100644 index 000000000..55189b445 --- /dev/null +++ b/app/Http/Requests/DeleteApiUserBlockRequest.php @@ -0,0 +1,23 @@ +user(); + assert($user instanceof User); + + $device = $this->route('device'); + assert($device instanceof Device); + + return $device->user_id === $user->id ? Response::allow() : Response::denyAsNotFound(); + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/ListApiCommitRepliesRequest.php b/app/Http/Requests/ListApiCommitRepliesRequest.php new file mode 100644 index 000000000..39b39105d --- /dev/null +++ b/app/Http/Requests/ListApiCommitRepliesRequest.php @@ -0,0 +1,35 @@ +user(); + assert($user instanceof User); + + $commit = $this->route('commit'); + assert($commit instanceof Commit); + + return $commit->isVisibleTo($user) ? Response::allow() : Response::denyAsNotFound(); + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/ListApiUserCommitsRequest.php b/app/Http/Requests/ListApiUserCommitsRequest.php index 59299d179..6f21778ee 100644 --- a/app/Http/Requests/ListApiUserCommitsRequest.php +++ b/app/Http/Requests/ListApiUserCommitsRequest.php @@ -4,12 +4,27 @@ namespace App\Http\Requests; +use App\Exceptions\ApiException; +use App\Http\Resources\UserResource; +use App\Models\User; use Illuminate\Foundation\Http\FormRequest; final class ListApiUserCommitsRequest extends FormRequest { + /** + * Anyone may list the commits of a profile, but the web shows neither of two people across a block the other's + * commits, so a signed request across one gets a code instead. + */ public function authorize(): bool { + $user = $this->route('user'); + assert($user instanceof User); + + $viewer = $this->user(); + $blockerId = $viewer instanceof User ? $viewer->blockerIdWith($user) : null; + + throw_if($blockerId !== null, fn (): ApiException => ApiException::acrossBlock($user, blockedByViewer: $blockerId === $viewer?->id, shown: UserResource::make($user))); + return true; } diff --git a/app/Http/Requests/ShowApiCommitRequest.php b/app/Http/Requests/ShowApiCommitRequest.php index 6329f6af2..0e3d46916 100644 --- a/app/Http/Requests/ShowApiCommitRequest.php +++ b/app/Http/Requests/ShowApiCommitRequest.php @@ -4,19 +4,21 @@ namespace App\Http\Requests; -use App\Enums\CommitStatus; use App\Models\Commit; use Illuminate\Auth\Access\Response; use Illuminate\Foundation\Http\FormRequest; final class ShowApiCommitRequest extends FormRequest { + /** + * Anyone may see an approved commit. A signed request may also see its user's own pending or rejected commits. + */ public function authorize(): Response { $commit = $this->route('commit'); assert($commit instanceof Commit); - return $commit->status === CommitStatus::Approved ? Response::allow() : Response::denyAsNotFound(); + return $commit->isVisibleTo($this->user()) ? Response::allow() : Response::denyAsNotFound(); } /** diff --git a/app/Http/Requests/ShowApiUserRequest.php b/app/Http/Requests/ShowApiUserRequest.php new file mode 100644 index 000000000..99e94133d --- /dev/null +++ b/app/Http/Requests/ShowApiUserRequest.php @@ -0,0 +1,38 @@ +route('user'); + assert($user instanceof User); + + $viewer = $this->user(); + $blockerId = $viewer instanceof User ? $viewer->blockerIdWith($user) : null; + + throw_if($blockerId !== null, fn (): ApiException => ApiException::acrossBlock($user, blockedByViewer: $blockerId === $viewer?->id, shown: UserResource::make($user))); + + return true; + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/ShowMobileAuthorizationRequest.php b/app/Http/Requests/ShowMobileAuthorizationRequest.php new file mode 100644 index 000000000..872c7b0fe --- /dev/null +++ b/app/Http/Requests/ShowMobileAuthorizationRequest.php @@ -0,0 +1,76 @@ +> + */ + public static function parameterRules(): array + { + /** @var list $prefixes */ + $prefixes = config('mobile.redirect_uri_prefixes'); + + return [ + 'fingerprint' => ['required', 'string', 'regex:/^SHA256:[A-Za-z0-9+\/]{43}$/'], + 'device_name' => ['required', 'string', 'max:100'], + 'code_challenge' => ['required', 'string', 'regex:/^[A-Za-z0-9_-]{43}$/'], + 'code_challenge_method' => ['required', 'string', 'in:S256'], + 'state' => ['required', 'string', 'max:255'], + 'redirect_uri' => ['required', 'string', 'max:255', 'starts_with:'.implode(',', $prefixes), 'not_regex:/#/'], + ]; + } + + /** + * A request the app didn't make right gets a plain error page, and never a redirect, so nothing is sent anywhere. + */ + public static function invalid(FormRequest $request, Validator $validator): HttpResponseException + { + return new HttpResponseException(Inertia::render('mobile-authorization/show', [ + 'error' => $validator->errors()->first(), + ])->toResponse($request)->setStatusCode(400)); + } + + public function authorize(): bool + { + return true; + } + + /** + * @return array> + */ + public function rules(): array + { + return self::parameterRules(); + } + + public function authorization(): MobileAuthorization + { + return new MobileAuthorization( + $this->string('fingerprint')->value(), + $this->string('device_name')->value(), + $this->string('code_challenge')->value(), + $this->string('state')->value(), + $this->string('redirect_uri')->value(), + ); + } + + public function failedValidation(Validator $validator): never + { + throw self::invalid($this, $validator); + } +} diff --git a/app/Http/Resources/CommitResource.php b/app/Http/Resources/CommitResource.php index de7df67ab..d5b0dfa1f 100644 --- a/app/Http/Resources/CommitResource.php +++ b/app/Http/Resources/CommitResource.php @@ -5,10 +5,30 @@ namespace App\Http\Resources; use App\Models\Commit; +use App\Models\Poll; +use App\Models\User; +use App\Services\CommitBodies; +use App\Services\Mentions; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; /** + * A commit and its author, as anyone may see them. + * + * A signed request gets the commit as the web shows it to that user instead: parent_id is the parent the commit + * replies to, whoever may see it, and on top come the body split into prose, inline code and highlighted code + * blocks, where it mentions someone, its status, whether the author pinned it, and whether the user starred or saved + * it. Like the web's cards, it also has preview_segments, the excerpt a list shows of a long commit, the poll, with + * what the user may see of its votes, and the commit it quotes, or null once the user may no longer see that one, + * while quoted_commit_id still says it quoted something. In a list, reply_preview is the newest reply or quote the + * web shows under it. As on the web, the counts, link_preview, images, starred, is_saved, parent, poll, + * quoted_commit and reply_preview are only there when they were loaded, so ancestors and the commits of + * notifications come without some of them. + * + * A deleted commit that still has replies stays in its thread as a tombstone: its id, parent_id, deleted_at and + * replies_count, but never its author or body. Only signed requests are ever sent one. + * * @mixin Commit */ final class CommitResource extends JsonResource @@ -18,18 +38,52 @@ final class CommitResource extends JsonResource */ public function toArray(Request $request): array { - return [ + if ($this->trashed()) { + return [ + 'id' => $this->id, + 'parent_id' => $this->parent_id, + 'deleted_at' => $this->deleted_at?->toIso8601ZuluString(), + 'replies_count' => $this->whenCounted('replies'), + ]; + } + + $isSigned = $request->user() instanceof User; + + $commit = [ 'id' => $this->id, 'short_hash' => mb_substr(str_replace('-', '', $this->id), -7), 'body' => $this->body, - 'parent_id' => $this->parent?->id, + 'parent_id' => $isSigned ? $this->parent_id : $this->parent?->id, 'html_url' => route('commit.show', [$this->user, $this->resource]), 'created_at' => $this->created_at->toIso8601ZuluString(), - 'stars_count' => $this->stars_count, - 'replies_count' => $this->replies_count, - 'link_preview' => $this->linkPreview, - 'images' => $this->images, + 'stars_count' => $this->whenCounted('stars'), + 'replies_count' => $this->whenCounted('replies'), + 'link_preview' => $this->whenLoaded('linkPreview'), + 'images' => $this->whenLoaded('images'), 'author' => UserResource::make($this->user), ]; + + if (! $isSigned) { + return $commit; + } + + return [ + ...$commit, + ...resolve(CommitBodies::class)->segments($this->body), + 'mentions' => resolve(Mentions::class)->positions($this->body), + 'status' => $this->status, + 'is_pinned' => $this->user?->pinned_commit_id === $this->id, + 'starred' => $this->whenHas('starred'), + 'is_saved' => $this->whenHas('is_saved'), + 'deleted_at' => null, + 'parent' => $this->whenLoaded('parent', fn (Commit $parent): self => self::make($parent)), + 'quoted_commit_id' => $this->quoted_commit_id, + 'quoted_commit' => $this->whenLoaded('quotedCommit', fn (Commit $quoted): self => self::make($quoted)), + 'poll' => $this->whenLoaded('poll', fn (Poll $poll): PollResource => PollResource::make($poll)), + 'reply_preview' => $this->whenLoaded('replyPreview', fn (Collection $replies): array => [ + 'data' => self::collection($replies), + 'next_cursor' => data_get($this->getAttribute('reply_preview'), 'next_cursor'), + ]), + ]; } } diff --git a/app/Http/Resources/NotificationResource.php b/app/Http/Resources/NotificationResource.php new file mode 100644 index 000000000..43df53126 --- /dev/null +++ b/app/Http/Resources/NotificationResource.php @@ -0,0 +1,42 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'type' => $this->type, + 'subject_id' => $this->subject_id, + 'reason' => $this->reason, + 'read_at' => $this->read_at?->toIso8601ZuluString(), + 'created_at' => $this->created_at->toIso8601ZuluString(), + 'actor' => UserResource::make($this->actor), + 'actors' => UserResource::collection($this->whenLoaded('actors')), + 'group_id' => $this->group_id, + 'event_count' => $this->event_count, + 'actors_count' => $this->actors_count, + 'unread_count' => $this->unread_count, + 'commit' => $this->commit instanceof Commit ? CommitResource::make($this->commit) : null, + ]; + } +} diff --git a/app/Http/Resources/PollResource.php b/app/Http/Resources/PollResource.php new file mode 100644 index 000000000..82bf901a4 --- /dev/null +++ b/app/Http/Resources/PollResource.php @@ -0,0 +1,41 @@ + + */ + public function toArray(Request $request): array + { + assert($this->resource instanceof Poll); + + $poll = $this->resource->toArray(); + $total = $poll['total_votes']; + + return [ + ...$poll, + 'options' => array_map(fn (array $option): array => [ + ...$option, + 'percentage' => match (true) { + $total === null => null, + $total === 0 => 0, + default => (int) round(($option['votes_count'] ?? 0) / $total * 100), + }, + ], $poll['options']), + ]; + } +} diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php index 2ff28693b..266206258 100644 --- a/app/Http/Resources/UserResource.php +++ b/app/Http/Resources/UserResource.php @@ -9,6 +9,9 @@ use Illuminate\Http\Resources\Json\JsonResource; /** + * A user's public profile, with their counts when they were loaded. A signed request also gets the lane, the color + * the web draws them in. + * * @mixin User */ final class UserResource extends JsonResource @@ -34,6 +37,8 @@ public function toArray(Request $request): array 'commits_count' => $this->whenCounted('commits'), 'followers_count' => $this->whenCounted('followers'), 'following_count' => $this->whenCounted('following'), + 'lane' => $this->when($request->user() instanceof User, fn (): int => $this->lane()), + 'is_followed' => $this->whenHas('is_followed'), ]; } } diff --git a/app/Models/AuthorizationCode.php b/app/Models/AuthorizationCode.php new file mode 100644 index 000000000..9a96e0666 --- /dev/null +++ b/app/Models/AuthorizationCode.php @@ -0,0 +1,86 @@ + */ + use HasFactory; + + use HasUuids; + + public const int EXPIRES_AFTER_MINUTES = 5; + + /** + * The hash a code is stored and found by. Codes are long and random, so a fast hash is enough. + */ + public static function hash(string $code): string + { + return hash('sha256', $code); + } + + /** + * The PKCE S256 challenge for a verifier: base64url(sha256(verifier)), without padding. + */ + public static function challengeFor(string $verifier): string + { + return mb_rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='); + } + + /** + * @return array + */ + public function casts(): array + { + return [ + 'id' => 'string', + 'user_id' => 'string', + 'code_hash' => 'string', + 'fingerprint' => 'string', + 'device_name' => 'string', + 'code_challenge' => 'string', + 'redirect_uri' => 'string', + 'expires_at' => 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/Device.php b/app/Models/Device.php new file mode 100644 index 000000000..9eb7aa35a --- /dev/null +++ b/app/Models/Device.php @@ -0,0 +1,67 @@ + */ + use HasFactory; + + use HasUuids; + + /** + * @return array + */ + public function casts(): array + { + return [ + 'id' => 'string', + 'user_id' => 'string', + 'fingerprint' => 'string', + 'name' => 'string', + 'approved_at' => 'datetime', + 'last_used_at' => 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 10ed1ea24..6ae6f0fc7 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -50,6 +50,8 @@ * @property-read Collection $notifications * @property-read Collection $sshKeys * @property-read Collection $pollVotes + * @property-read Collection $devices + * @property-read Collection $authorizationCodes * @property-read int $commits_count * @property-read int $following_count * @property-read int $followers_count @@ -132,6 +134,22 @@ public function sshKeys(): HasMany return $this->hasMany(SshKey::class); } + /** + * @return HasMany + */ + public function devices(): HasMany + { + return $this->hasMany(Device::class); + } + + /** + * @return HasMany + */ + public function authorizationCodes(): HasMany + { + return $this->hasMany(AuthorizationCode::class); + } + /** * @return BelongsToMany */ diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 85b4dc3e9..39de96052 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -5,6 +5,8 @@ namespace App\Providers; use App\Contracts\Services\DnsResolver; +use App\Enums\ApiErrorCode; +use App\Exceptions\ApiException; use App\Models\User; use App\Services\DnsResolvers\SystemDnsResolver; use Illuminate\Cache\RateLimiting\Limit; @@ -80,7 +82,24 @@ private function bootRateLimitingDefaults(): void }); }); - RateLimiter::for('api', fn (Request $request): Limit => Limit::perMinute(60)->by((string) $request->ip())); + // The mobile app's 429 comes with a code. The public API's stays as it is... + $rateLimited = fn (Request $request, array $headers): JsonResponse => new ApiException(ApiErrorCode::RateLimited, 'Too Many Attempts.', $headers)->render(); + + RateLimiter::for('api', function (Request $request) use ($rateLimited): Limit { + $user = $request->user(); + + // Signed requests from the mobile app share one budget per user, whichever network the phone is on... + return $user instanceof User + ? Limit::perMinute(120)->by($user->id)->response($rateLimited) + : Limit::perMinute(60)->by((string) $request->ip()); + }); + + // Redeeming a code for a device is limited per key once the signature checks out. Before that, each address gets + // a ceiling, so unsigned or badly signed requests can't force signature checks... + RateLimiter::for('devices', fn (Request $request): Limit => Limit::perMinute(60)->by((string) $request->ip())->response($rateLimited)); + + // Approving the devnull app on the web hands out a code each time... + RateLimiter::for('mobile-authorizations', fn (Request $request): Limit => Limit::perMinute(10)->by($this->userId($request))); // GitHub fetches every README image through a few proxy addresses, so each graph gets its own budget... RateLimiter::for('activity-graph', fn (Request $request): Limit => Limit::perMinute(60)->by("{$request->ip()}|{$request->path()}")); diff --git a/app/Queries/CommitPageQuery.php b/app/Queries/CommitPageQuery.php new file mode 100644 index 000000000..c2f654a44 --- /dev/null +++ b/app/Queries/CommitPageQuery.php @@ -0,0 +1,23 @@ +withVisibleReplyCount($viewer) + ->withCardDataFor($viewer) + ->findOrFail($commit->id); + } +} diff --git a/app/Queries/PinnedCommitQuery.php b/app/Queries/PinnedCommitQuery.php new file mode 100644 index 000000000..ee66f9a98 --- /dev/null +++ b/app/Queries/PinnedCommitQuery.php @@ -0,0 +1,30 @@ +pinned_commit_id === null) { + return null; + } + + return Commit::query() + ->whereBelongsTo($user) + ->visibleTo($viewer) + ->topLevel() + ->withVisibleReplyCount($viewer) + ->withReplyPreviews($viewer) + ->withCardDataFor($viewer) + ->find($user->pinned_commit_id); + } +} diff --git a/app/Queries/ReplyPreviewsQuery.php b/app/Queries/ReplyPreviewsQuery.php new file mode 100644 index 000000000..3fffdcfab --- /dev/null +++ b/app/Queries/ReplyPreviewsQuery.php @@ -0,0 +1,57 @@ + $commits + */ + public function load(iterable $commits, ?User $viewer): void + { + $commits = new Collection($commits) + ->whereInstanceOf(Commit::class) + ->filter(fn (Commit $commit): bool => $commit->hasAttribute('reply_preview')); + + $ids = $commits->flatMap(fn (Commit $commit): array => array_column($this->preview($commit)['data'], 'id')); + + $replies = $ids->isEmpty() ? new Collection() : Commit::query() + ->withTrashed() + ->whereKey($ids->all()) + ->withVisibleReplyCount($viewer) + ->withCardDataFor($viewer) + ->without('quotedCommit') + ->get() + ->keyBy('id'); + + foreach ($commits as $commit) { + $commit->setRelation('replyPreview', new Collection(array_values(array_filter(array_map( + fn (array $reply): ?Commit => $replies->get($reply['id']), + $this->preview($commit)['data'], + ))))); + } + } + + /** + * @return array{data: list, next_cursor: string|null} + */ + private function preview(Commit $commit): array + { + /** @var array{data: list, next_cursor: string|null} $preview */ + $preview = $commit->getAttribute('reply_preview'); + + return $preview; + } +} diff --git a/app/Queries/UserCommitsQuery.php b/app/Queries/UserCommitsQuery.php new file mode 100644 index 000000000..6d0284520 --- /dev/null +++ b/app/Queries/UserCommitsQuery.php @@ -0,0 +1,39 @@ +whereBelongsTo($user) + ->visibleTo($viewer) + ->when( + $replies, + fn (CommitBuilder $query): CommitBuilder => $query + ->whereNotNull('parent_id') + ->with(['parent' => fn (BelongsTo $query): BelongsTo => $query + ->where(fn (CommitBuilder $query): CommitBuilder => $query->visibleTo($viewer)) + ->with('user')]), + fn (CommitBuilder $query): CommitBuilder => $query->topLevel() + ->when($user->pinned_commit_id, fn (CommitBuilder $query, string $id): CommitBuilder => $query->whereKeyNot($id)), + ) + ->withVisibleReplyCount($viewer) + ->withReplyPreviews($viewer) + ->withCardDataFor($viewer) + ->latest() + ->latest('id'); + } +} diff --git a/app/Services/Mentions.php b/app/Services/Mentions.php index 352c1c31c..cae3c974e 100644 --- a/app/Services/Mentions.php +++ b/app/Services/Mentions.php @@ -6,6 +6,8 @@ final readonly class Mentions { + private const string Pattern = '/(?codeSpans->withoutCode($text), - $matches, - ); + preg_match_all(self::Pattern, $this->codeSpans->withoutCode($text), $matches); return array_values(array_unique(array_map(strtolower(...), $matches[1]))); } + + /** + * Finds where each "@username" is in the text, by the same rules and also skipping code, so a client can link it + * to the profile like the web does. The username keeps its case, and the offset and length count characters in + * the whole text and include the "@". + * + * @return list + */ + public function positions(string $text): array + { + $positions = []; + $offset = 0; + + foreach ($this->codeSpans->split($text) as $index => $part) { + if ($index % 2 === 0) { + preg_match_all(self::Pattern, $part, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE); + + foreach ($matches as $match) { + $positions[] = [ + 'username' => $match[1][0], + 'offset' => $offset + mb_strlen(mb_strcut($part, 0, $match[0][1])), + 'length' => mb_strlen($match[0][0]), + ]; + } + } + + $offset += mb_strlen($part); + } + + return $positions; + } } diff --git a/app/Services/SignedCommits.php b/app/Services/SignedCommits.php index e86cebe27..d484bb323 100644 --- a/app/Services/SignedCommits.php +++ b/app/Services/SignedCommits.php @@ -4,11 +4,7 @@ namespace App\Services; -use App\Actions\UpdateUserSshKeys; -use App\Dto\SshPublicKey; -use App\Dto\SshSignature; use App\Models\User; -use Illuminate\Support\Facades\Cache; use InvalidArgumentException; /** @@ -20,9 +16,7 @@ { public const string Namespace = 'devnull-commit'; - private const int MaxClockSkewSeconds = 300; - - public function __construct(private UpdateUserSshKeys $updateUserSshKeys) + public function __construct(private SshSignatures $sshSignatures) { // } @@ -32,9 +26,7 @@ public function __construct(private UpdateUserSshKeys $updateUserSshKeys) */ public function verify(string $payload, string $signature): array { - $sshSignature = SshSignature::parse($signature); - - throw_unless($sshSignature->verify($payload, self::Namespace), InvalidArgumentException::class, 'Invalid signature.'); + $publicKey = $this->sshSignatures->verify($payload, $signature, self::Namespace); $parts = explode("\n", $payload, 4); @@ -42,27 +34,12 @@ public function verify(string $payload, string $signature): array [$timestamp, $nonce, $replyTo, $body] = $parts; - throw_unless( - ctype_digit($timestamp) && abs(now()->getTimestamp() - (int) $timestamp) <= self::MaxClockSkewSeconds, - InvalidArgumentException::class, - 'Signature expired. Check that your clock is right.', - ); - - throw_unless(preg_match('/^[0-9a-f]{32}$/', $nonce) === 1, InvalidArgumentException::class, 'Malformed payload.'); - - $user = $this->findKeyOwner($sshSignature->publicKey); + $user = $this->sshSignatures->authenticate($publicKey, $timestamp, $nonce); throw_if( ! $user instanceof User, InvalidArgumentException::class, - "This SSH key ({$sshSignature->publicKey->fingerprint()}) is not on the GitHub account of a /dev/null user.", - ); - - throw_unless( - // Remembered a bit longer than a signature can be valid (from -skew to +skew around its timestamp)... - Cache::add("commit-nonce:{$nonce}", true, self::MaxClockSkewSeconds * 2 + 60), - InvalidArgumentException::class, - 'This signature was already used.', + "This SSH key ({$publicKey->fingerprint()}) is not on the GitHub account of a /dev/null user.", ); return [ @@ -71,21 +48,4 @@ public function verify(string $payload, string $signature): array 'body' => mb_trim($body), ]; } - - /** - * Finds the user whose GitHub account has the key, re-checking GitHub every few minutes so that - * a key removed from GitHub stops working shortly after. - */ - private function findKeyOwner(SshPublicKey $publicKey): ?User - { - $user = User::query()->whereRelation('sshKeys', 'fingerprint', $publicKey->fingerprint())->first(); - - if ($user === null) { - return null; - } - - $this->updateUserSshKeys->handle($user, unlessRecentlySynced: true); - - return $user->sshKeys()->where('fingerprint', $publicKey->fingerprint())->exists() ? $user : null; - } } diff --git a/app/Services/SignedRequests.php b/app/Services/SignedRequests.php new file mode 100644 index 000000000..86bcc528f --- /dev/null +++ b/app/Services/SignedRequests.php @@ -0,0 +1,80 @@ +\n\n\n\n". The timestamp and nonce + * are also sent in the X-Devnull-Timestamp and X-Devnull-Nonce headers, and the signature in X-SSH-Signature. + * + * A key signs in as a user once that user approved it as a device on the web. The key doesn't have to be on their + * GitHub account: the approval is the proof. So a key made for something else, like committing from CI, can't sign in. + */ +final readonly class SignedRequests +{ + public const string Namespace = 'devnull-api'; + + private const array Headers = ['X-Devnull-Timestamp', 'X-Devnull-Nonce', 'X-SSH-Signature']; + + public function __construct(private SshSignatures $sshSignatures, private UpdateDeviceLastUsedAt $updateDeviceLastUsedAt) + { + // + } + + /** + * Whether the request comes with any of the signature headers. It still has to pass verify() to be trusted. + */ + public function isSigned(Request $request): bool + { + return array_any(self::Headers, fn (string $header): bool => $request->headers->has($header)); + } + + /** + * Checks the signature, its timestamp and its nonce, which it spends, and returns the key that signed the request, + * whether or not it is approved as a device yet. + */ + public function verifySignature(Request $request): SshPublicKey + { + $timestamp = $request->headers->get('X-Devnull-Timestamp', '') ?? ''; + $nonce = $request->headers->get('X-Devnull-Nonce', '') ?? ''; + + $publicKey = $this->sshSignatures->verify( + implode("\n", [$timestamp, $nonce, $request->getMethod(), $request->getRequestUri(), $request->getContent()]), + $request->headers->get('X-SSH-Signature', '') ?? '', + self::Namespace, + ); + + $this->sshSignatures->checkFreshness($timestamp, $nonce); + + return $publicKey; + } + + /** + * The device is null when nobody approved the key that signed the request. Using a device counts as its last use. + * + * @return array{publicKey: SshPublicKey, device: Device|null} + */ + public function verify(Request $request): array + { + $publicKey = $this->verifySignature($request); + + $device = Device::query()->with('user')->where('fingerprint', $publicKey->fingerprint())->first(); + + if ($device instanceof Device) { + $this->updateDeviceLastUsedAt->handle($device); + } + + return [ + 'publicKey' => $publicKey, + 'device' => $device, + ]; + } +} diff --git a/app/Services/SshSignatures.php b/app/Services/SshSignatures.php new file mode 100644 index 000000000..143c84434 --- /dev/null +++ b/app/Services/SshSignatures.php @@ -0,0 +1,107 @@ +verify($message, $namespace), InvalidArgumentException::class, 'Invalid signature.'); + + return $sshSignature->publicKey; + } + + /** + * Finds the devnull user who signed with the key, from the timestamp and nonce signed along with it. + * + * A signature expires a few minutes after its timestamp, and is only good once: its nonce is spent when it finds + * the user. Returns null when the key is not on the GitHub account of a devnull user. + */ + public function authenticate(SshPublicKey $publicKey, string $timestamp, string $nonce): ?User + { + $this->ensureRecent($timestamp, $nonce); + + $user = $this->findKeyOwner($publicKey); + + if (! $user instanceof User) { + return null; + } + + $this->spendNonce($nonce); + + return $user; + } + + /** + * Checks the timestamp and nonce signed along with a message and spends the nonce, whoever the key belongs to: a + * signature expires a few minutes after its timestamp, and is only good once. + */ + public function checkFreshness(string $timestamp, string $nonce): void + { + $this->ensureRecent($timestamp, $nonce); + + $this->spendNonce($nonce); + } + + /** + * Finds the user whose GitHub account has the key, re-checking GitHub every few minutes so that + * a key removed from GitHub stops working shortly after. + */ + private function findKeyOwner(SshPublicKey $publicKey): ?User + { + $user = User::query()->whereRelation('sshKeys', 'fingerprint', $publicKey->fingerprint())->first(); + + if ($user === null) { + return null; + } + + $this->updateUserSshKeys->handle($user, unlessRecentlySynced: true); + + return $user->sshKeys()->where('fingerprint', $publicKey->fingerprint())->exists() ? $user : null; + } + + private function ensureRecent(string $timestamp, string $nonce): void + { + throw_unless( + ctype_digit($timestamp) && abs(now()->getTimestamp() - (int) $timestamp) <= self::MaxClockSkewSeconds, + InvalidArgumentException::class, + 'Signature expired. Check that your clock is right.', + ); + + throw_unless(preg_match('/^[0-9a-f]{32}$/', $nonce) === 1, InvalidArgumentException::class, 'Malformed payload.'); + } + + private function spendNonce(string $nonce): void + { + throw_unless( + // Remembered a bit longer than a signature can be valid (from -skew to +skew around its timestamp)... + Cache::add("ssh-signature-nonce:{$nonce}", true, self::MaxClockSkewSeconds * 2 + 60), + InvalidArgumentException::class, + 'This signature was already used.', + ); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 7c0d1327e..f97929b0e 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -2,17 +2,23 @@ declare(strict_types=1); +use App\Enums\ApiErrorCode; use App\Enums\Log; +use App\Exceptions\ApiException; +use App\Http\Middleware\AuthenticateSignedRequest; use App\Http\Middleware\AuthenticateSshSignature; use App\Http\Middleware\HandleAppearance; use App\Http\Middleware\HandleInertiaRequests; use App\Http\Middleware\PreventFraming; +use App\Models\User; use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\RequestException; use Illuminate\Http\Exceptions\HttpResponseException; +use Illuminate\Http\Exceptions\ThrottleRequestsException; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets; use Illuminate\Http\Request; use Illuminate\Http\Response; @@ -37,6 +43,7 @@ // Rate limits apply to the user authenticated by their SSH signature... $middleware->prependToPriorityList(before: ThrottleRequests::class, prepend: AuthenticateSshSignature::class); + $middleware->prependToPriorityList(before: ThrottleRequests::class, prepend: AuthenticateSignedRequest::class); $middleware->web(append: [ HandleAppearance::class, @@ -67,4 +74,9 @@ return response(($message ?: Response::$statusTexts[$status] ?? 'Error.')."\n", $status, $headers) ->header('Content-Type', 'text/plain; charset=utf-8'); }); + + // The limits the web puts on social actions apply to the mobile app too, and answer it with a code... + $exceptions->render(fn (ThrottleRequestsException $exception, Request $request): ?JsonResponse => $request->is('api/v1/*') && $request->user() instanceof User + ? new ApiException(ApiErrorCode::RateLimited, $exception->getMessage(), $exception->getHeaders())->render() + : null); })->create(); diff --git a/config/mobile.php b/config/mobile.php new file mode 100644 index 000000000..c0a953c58 --- /dev/null +++ b/config/mobile.php @@ -0,0 +1,13 @@ + ['devnull://'], + +]; diff --git a/database/factories/AuthorizationCodeFactory.php b/database/factories/AuthorizationCodeFactory.php new file mode 100644 index 000000000..52d34fc6b --- /dev/null +++ b/database/factories/AuthorizationCodeFactory.php @@ -0,0 +1,66 @@ + + */ +final class AuthorizationCodeFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'code_hash' => AuthorizationCode::hash(bin2hex(random_bytes(32))), + 'fingerprint' => 'SHA256:'.mb_rtrim(base64_encode(random_bytes(32)), '='), + 'device_name' => 'devnull on iPhone', + 'code_challenge' => AuthorizationCode::challengeFor(bin2hex(random_bytes(32))), + 'redirect_uri' => 'devnull://auth/callback', + 'expires_at' => now()->addMinutes(AuthorizationCode::EXPIRES_AFTER_MINUTES), + ]; + } + + /** + * The code the app was handed, whose hash is stored. + */ + public function withCode(string $code): self + { + return $this->state(fn (array $attributes): array => [ + 'code_hash' => AuthorizationCode::hash($code), + ]); + } + + /** + * The PKCE challenge for the verifier the app keeps. + */ + public function withVerifier(string $verifier): self + { + return $this->state(fn (array $attributes): array => [ + 'code_challenge' => AuthorizationCode::challengeFor($verifier), + ]); + } + + public function withPublicKey(SshPublicKey $publicKey): self + { + return $this->state(fn (array $attributes): array => [ + 'fingerprint' => $publicKey->fingerprint(), + ]); + } + + public function expired(): self + { + return $this->state(fn (array $attributes): array => [ + 'expires_at' => now()->subSecond(), + ]); + } +} diff --git a/database/factories/DeviceFactory.php b/database/factories/DeviceFactory.php new file mode 100644 index 000000000..3a5ca31a8 --- /dev/null +++ b/database/factories/DeviceFactory.php @@ -0,0 +1,36 @@ + + */ +final class DeviceFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'fingerprint' => 'SHA256:'.mb_rtrim(base64_encode(random_bytes(32)), '='), + 'name' => 'devnull on iPhone', + 'approved_at' => now(), + ]; + } + + public function withPublicKey(SshPublicKey $publicKey): self + { + return $this->state(fn (array $attributes): array => [ + 'fingerprint' => $publicKey->fingerprint(), + ]); + } +} diff --git a/database/migrations/2026_10_16_170000_create_devices_table.php b/database/migrations/2026_10_16_170000_create_devices_table.php new file mode 100644 index 000000000..fb6d6baa6 --- /dev/null +++ b/database/migrations/2026_10_16_170000_create_devices_table.php @@ -0,0 +1,25 @@ +uuid('id')->primary(); + $table->foreignUuid('user_id')->constrained()->cascadeOnDelete(); + $table->string('fingerprint')->unique(); + $table->string('name'); + $table->timestamp('approved_at'); + $table->timestamp('last_used_at')->nullable(); + $table->timestamps(); + + $table->index('user_id'); + }); + } +}; diff --git a/database/migrations/2026_10_16_170001_create_authorization_codes_table.php b/database/migrations/2026_10_16_170001_create_authorization_codes_table.php new file mode 100644 index 000000000..2642afa3b --- /dev/null +++ b/database/migrations/2026_10_16_170001_create_authorization_codes_table.php @@ -0,0 +1,27 @@ +uuid('id')->primary(); + $table->foreignUuid('user_id')->constrained()->cascadeOnDelete(); + $table->string('code_hash')->unique(); + $table->string('fingerprint'); + $table->string('device_name'); + $table->string('code_challenge'); + $table->string('redirect_uri'); + $table->timestamp('expires_at'); + $table->timestamps(); + + $table->index('user_id'); + }); + } +}; diff --git a/resources/js/layouts/settings/layout.tsx b/resources/js/layouts/settings/layout.tsx index e479ef938..cfd9871cd 100644 --- a/resources/js/layouts/settings/layout.tsx +++ b/resources/js/layouts/settings/layout.tsx @@ -2,6 +2,7 @@ import type { PropsWithChildren } from 'react'; import { PageTabs } from '@/components/page-tabs'; import { useCurrentUrl } from '@/hooks/use-current-url'; import { edit as editAppearance } from '@/routes/appearance'; +import { index as devices } from '@/routes/device'; import { edit as editSafety } from '@/routes/safety'; import { show as showTerminal } from '@/routes/terminal'; import { edit } from '@/routes/user-profile'; @@ -38,6 +39,11 @@ export default function SettingsLayout({ children }: PropsWithChildren) { href: editSafety(), isActive: isCurrentUrl(editSafety()), }, + { + title: 'Devices', + href: devices(), + isActive: isCurrentUrl(devices()), + }, ]} /> diff --git a/resources/js/pages/device/index.tsx b/resources/js/pages/device/index.tsx new file mode 100644 index 000000000..aac49eaeb --- /dev/null +++ b/resources/js/pages/device/index.tsx @@ -0,0 +1,238 @@ +import { Head, router } from '@inertiajs/react'; +import { useRef, useState } from 'react'; +import DeviceController from '@/actions/App/Http/Controllers/DeviceController'; +import { Button } from '@/components/ui/button'; +import { useNow } from '@/hooks/use-now'; +import AppLayout from '@/layouts/app-layout'; +import SettingsLayout from '@/layouts/settings/layout'; +import { cn, formatRelativeTime } from '@/lib/utils'; +import type { Device } from '@/types'; + +export default function Index({ devices }: { devices: Device[] }) { + const headingRef = useRef(null); + + // A revoked row unmounts with the focus inside it, so it moves to the next row, or the heading... + const focusAfter = (id: string) => { + const index = devices.findIndex((device) => device.id === id); + const next = devices[index + 1] ?? devices[index - 1]; + + const target = next + ? document.querySelector( + `[data-device="${next.id}"] [data-revoke]`, + ) + : null; + + (target ?? headingRef.current)?.focus({ preventScroll: true }); + }; + + return ( + <> + + +
+
+

+ Approved devices +

+

+ Each one can use /dev/null as you through the app, since + you approved it here on the web. Revoke any you don't + use or don't recognize. +

+
+ + {devices.length === 0 ? ( +
+

+ fatal: no devices approved yet +

+

+ Sign in from the /dev/null app and approve it here + to add one. +

+
+ ) : ( +
    + {devices.map((device) => ( + focusAfter(device.id)} + /> + ))} +
+ )} +
+ + ); +} + +function DeviceRow({ + device, + onRevoked, +}: { + device: Device; + onRevoked: () => void; +}) { + const now = useNow(); + const [revocation, setRevocation] = useState< + 'idle' | 'confirming' | 'revoking' + >('idle'); + const [hasCancelled, setHasCancelled] = useState(false); + + const cancel = () => { + setRevocation('idle'); + setHasCancelled(true); + }; + + const revoke = () => { + setRevocation('revoking'); + + const prefersReducedMotion = window.matchMedia( + '(prefers-reduced-motion: reduce)', + ).matches; + + window.setTimeout( + () => + router.visit(DeviceController.destroy(device), { + preserveScroll: true, + onSuccess: onRevoked, + onError: () => setRevocation('idle'), + }), + prefersReducedMotion ? 0 : 700, + ); + }; + + return ( +
  • +
    +
    +
    +

    + {device.name} +

    +

    + {device.fingerprint} +

    + + {revocation === 'idle' && ( +

    + Approved{' '} +

    + )} + + {revocation === 'confirming' && ( +

    + Revoke this device? The app on it signs out. +

    + )} + + {revocation === 'revoking' && ( +

    + rm {device.fingerprint} → /dev/null +

    + )} +
    + + {revocation === 'idle' && ( + + )} + + {revocation === 'confirming' && ( +
    { + if (event.key === 'Escape') { + cancel(); + } + }} + className="flex shrink-0 gap-2" + > + + +
    + )} +
    +
    +
  • + ); +} + +/** + * "just now", "3m ago", or a date, in mono like every time devnull prints. + */ +function Time({ date, now }: { date: string; now: Date }) { + const relative = formatRelativeTime(date, now); + + return ( + + ); +} + +Index.layout = [AppLayout, SettingsLayout]; diff --git a/resources/js/pages/mobile-authorization/show.tsx b/resources/js/pages/mobile-authorization/show.tsx new file mode 100644 index 000000000..5739e5cc6 --- /dev/null +++ b/resources/js/pages/mobile-authorization/show.tsx @@ -0,0 +1,184 @@ +import { Head } from '@inertiajs/react'; +import type { CSSProperties, ReactNode } from 'react'; +import { useState } from 'react'; +import MobileAuthorizationController from '@/actions/App/Http/Controllers/MobileAuthorizationController'; +import AppLogo from '@/components/app-logo'; +import { Button } from '@/components/ui/button'; +import { UserAvatar } from '@/components/user-avatar'; +import { useUser } from '@/hooks/use-user'; +import { laneColor } from '@/lib/utils'; + +type Authorization = { + fingerprint: string; + device_name: string; + code_challenge: string; + code_challenge_method: string; + state: string; + redirect_uri: string; +}; + +type Props = + | { error: string } + | { authorization: Authorization; cancelUrl: string; csrfToken: string }; + +export default function Show(props: Props) { + return 'error' in props ? ( + + ) : ( + + ); +} + +function Approve({ + authorization, + cancelUrl, + csrfToken, +}: { + authorization: Authorization; + cancelUrl: string; + csrfToken: string; +}) { + const { user } = useUser(); + const [isApproving, setIsApproving] = useState(false); + + return ( + +

    + Approve {authorization.device_name}? +

    + +
    +
    +
    + Signs in as +
    +
    + + + + {user.name} + + + @{user.username} + + +
    +
    +
    +
    With key
    +
    + {authorization.fingerprint} +
    +
    +
    + +

    + The app will be able to use /dev/null as you. You can revoke it + any time under Settings → Devices. +

    + + {/* A plain form, so the browser follows the redirect back into the app... */} +
    setIsApproving(true)} + className="mt-8 flex flex-wrap gap-2" + > + + {Object.entries(authorization).map(([name, value]) => ( + + ))} + + +
    +
    + ); +} + +function Invalid({ error }: { error: string }) { + return ( + +

    + This sign-in link isn't valid. +

    +

    + {error} Start signing in again from the /dev/null app. +

    +
    + ); +} + +function Page({ + title, + lane, + command, + children, +}: { + title: string; + lane: string; + command: string; + children: ReactNode; +}) { + return ( + <> + + +
    +
      +
    1. +
      + + +
      +
      + +
      +
    2. + +
    3. +
      + +
      +
      +

      + {command} +

      + {children} +
      +
    4. +
    +
    + + ); +} diff --git a/resources/js/types/device.ts b/resources/js/types/device.ts new file mode 100644 index 000000000..8d8d39fcc --- /dev/null +++ b/resources/js/types/device.ts @@ -0,0 +1,7 @@ +export type Device = { + id: string; + name: string; + fingerprint: string; + approved_at: string; + last_used_at: string | null; +}; diff --git a/resources/js/types/index.ts b/resources/js/types/index.ts index 7745bef78..7bc9f010e 100644 --- a/resources/js/types/index.ts +++ b/resources/js/types/index.ts @@ -2,6 +2,7 @@ export type * from './achievement'; export type * from './activity'; export type * from './auth'; export type * from './commit'; +export type * from './device'; export type * from './log'; export type * from './navigation'; export type * from './notification'; diff --git a/routes/api.php b/routes/api.php index f60b4e668..b7b955a69 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,10 +3,20 @@ declare(strict_types=1); use App\Http\Controllers\Api\CommitController as ApiCommitController; +use App\Http\Controllers\Api\CommitReplyController; +use App\Http\Controllers\Api\CurrentUserController; +use App\Http\Controllers\Api\DeviceController; +use App\Http\Controllers\Api\LogController; +use App\Http\Controllers\Api\NotificationController; +use App\Http\Controllers\Api\NotificationGroupController; +use App\Http\Controllers\Api\UserBlockController; use App\Http\Controllers\Api\UserCommitController; use App\Http\Controllers\Api\UserController; +use App\Http\Controllers\Api\UserFollowController; +use App\Http\Controllers\Api\UserMuteController; use App\Http\Controllers\Cli\CommitController; use App\Http\Controllers\Cli\KeyLookupController; +use App\Http\Middleware\AuthenticateSignedRequest; use App\Http\Middleware\AuthenticateSshSignature; use App\Http\Middleware\ThrottleCommits; use Illuminate\Support\Facades\Route; @@ -21,10 +31,60 @@ }); Route::prefix('v1')->middleware('throttle:api')->group(function (): void { - Route::get('users/{user:username}', [UserController::class, 'show']) - ->name('api-user.show'); - Route::get('users/{user:username}/commits', [UserCommitController::class, 'index']) - ->name('api-user-commit.index'); - Route::get('commits/{commit}', [ApiCommitController::class, 'show']) - ->name('api-commit.show'); + // Public, or as the web shows them to the user when signed by the mobile app... + Route::middleware(AuthenticateSignedRequest::optional())->group(function (): void { + Route::get('users/{user:username}', [UserController::class, 'show']) + ->name('api-user.show'); + Route::get('users/{user:username}/commits', [UserCommitController::class, 'index']) + ->name('api-user-commit.index'); + Route::get('commits/{commit}', [ApiCommitController::class, 'show']) + ->name('api-commit.show'); + }); + + // Signing in: the app redeems the code it got on the web, signed with a key that isn't approved yet... + Route::post('devices', [DeviceController::class, 'store']) + ->withoutMiddleware('throttle:api') + ->middleware('throttle:devices') + ->name('api-device.store'); + + // Signed by the mobile app with an SSH key that the user approved as a device... + Route::middleware(AuthenticateSignedRequest::class)->group(function (): void { + Route::get('me', [CurrentUserController::class, 'show'])->name('api-current-user.show'); + + // Log... + Route::get('log/{log}', [LogController::class, 'show'])->name('api-log.show'); + + // Commit... + Route::post('commits', [ApiCommitController::class, 'store']) + ->middleware(ThrottleCommits::class) + ->name('api-commit.store'); + Route::delete('commits/{commit}', [ApiCommitController::class, 'destroy'])->name('api-commit.destroy'); + + // Commit Reply... + Route::get('commits/{commit}/replies', [CommitReplyController::class, 'index']) + ->withTrashed() + ->name('api-commit-reply.index'); + + // User Follow... + Route::post('users/{user:username}/follow', [UserFollowController::class, 'store']) + ->middleware('throttle:follows') + ->name('api-user-follow.store'); + Route::delete('users/{user:username}/follow', [UserFollowController::class, 'destroy']) + ->middleware('throttle:follows') + ->name('api-user-follow.destroy'); + + // User Mute... + Route::get('me/muted', [UserMuteController::class, 'index'])->name('api-user-mute.index'); + Route::post('users/{user:username}/mute', [UserMuteController::class, 'store'])->name('api-user-mute.store'); + Route::delete('users/{user:username}/mute', [UserMuteController::class, 'destroy'])->name('api-user-mute.destroy'); + + // User Block... + Route::get('me/blocked', [UserBlockController::class, 'index'])->name('api-user-block.index'); + Route::post('users/{user:username}/block', [UserBlockController::class, 'store'])->name('api-user-block.store'); + Route::delete('users/{user:username}/block', [UserBlockController::class, 'destroy'])->name('api-user-block.destroy'); + + // Notifications... + Route::get('notifications', [NotificationController::class, 'index'])->name('api-notification.index'); + Route::get('notifications/{notification}/group', [NotificationGroupController::class, 'show'])->name('api-notification-group.show'); + }); }); diff --git a/routes/web.php b/routes/web.php index 987aa0a47..f7b0cb0dd 100644 --- a/routes/web.php +++ b/routes/web.php @@ -21,11 +21,13 @@ use App\Http\Controllers\CommitSitemapController; use App\Http\Controllers\CommitStarController; use App\Http\Controllers\CommitStarCountController; +use App\Http\Controllers\DeviceController; use App\Http\Controllers\ExportController; use App\Http\Controllers\FailedJobController; use App\Http\Controllers\FailedJobRetryController; use App\Http\Controllers\LogController; use App\Http\Controllers\MentionSuggestionController; +use App\Http\Controllers\MobileAuthorizationController; use App\Http\Controllers\ModerationController; use App\Http\Controllers\NotificationController; use App\Http\Controllers\NotificationGroupController; @@ -257,6 +259,16 @@ // Safety... Route::get('settings/safety', [SafetyController::class, 'edit'])->name('safety.edit'); + // Devices... + Route::get('settings/devices', [DeviceController::class, 'index'])->name('device.index'); + Route::delete('settings/devices/{device}', [DeviceController::class, 'destroy'])->name('device.destroy'); + + // Mobile Authorization... + Route::get('mobile/authorize', [MobileAuthorizationController::class, 'show'])->name('mobile-authorization.show'); + Route::post('mobile/authorize', [MobileAuthorizationController::class, 'store']) + ->middleware('throttle:mobile-authorizations') + ->name('mobile-authorization.store'); + // User... Route::delete('user', [UserController::class, 'destroy'])->name('user.destroy'); diff --git a/tests/Feature/ApiSignatureTest.php b/tests/Feature/ApiSignatureTest.php new file mode 100644 index 000000000..4de031fb0 --- /dev/null +++ b/tests/Feature/ApiSignatureTest.php @@ -0,0 +1,310 @@ +user = User::factory()->create(); + $this->keyPair = apiKeyPairFor($this->user); +}); + +it('authenticates a request signed with a key the user approved as a device', function (): void { + $response = signedApiRequest($this->keyPair, 'GET', route('api-current-user.show')); + + $response->assertOk() + ->assertJsonPath('data.id', $this->user->id) + ->assertCookieMissing(config('session.cookie')); + + $this->assertAuthenticatedAs($this->user); +}); + +it('accepts requests signed by ssh-keygen over the documented message', function (string $message, string $signature, int $status): void { + $this->travelTo(CarbonImmutable::createFromTimestamp(1791000000)); + + // Made with `ssh-keygen -t ed25519`, then `ssh-keygen -Y sign -n devnull-api` over each message... + $publicKey = SshPublicKey::fromOpenSsh('ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIrhcHdLGpoKRYqI6eBWDeFu3VITYyTHDGiQ7QhYXCPa vector'); + $user = User::factory()->create(); + + Device::factory()->for($user)->withPublicKey($publicKey)->create(); + + [$timestamp, $nonce, $method, $target, $body] = explode("\n", $message, 5); + + $response = $this->call($method, $target, server: [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_X_DEVNULL_TIMESTAMP' => $timestamp, + 'HTTP_X_DEVNULL_NONCE' => $nonce, + 'HTTP_X_SSH_SIGNATURE' => $signature, + ], content: $body); + + $response->assertStatus($status); + + $this->assertAuthenticatedAs($user); +})->with([ + 'with a query and no body' => [ + "1791000000\n4f3c2b1a09e8d7c6b5a4938271605f4e\nGET\n/api/v1/log/latest?cursor=abc\n", + 'U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgiuFwd0samgpFiojp4FYN4W7dUhNjJMcMaJDtCFhcI9oAAAALZGV2bnVsbC1hcGkAAAAAAAAABnNoYTUxMgAAAFMAAAALc3NoLWVkMjU1MTkAAABAKZ8la0GdO1nis8Bj7T2TApm89jTsN0of83/YJILbEsR6k/1q08FOHkSl3eZRIPoRVIjJTkXvqWhKlGMe31QwBw==', + 200, + ], + 'with the raw body, spaces and trailing newline included' => [ + "1791000000\n0123456789abcdef0123456789abcdef\nPOST\n/api/v1/commits\n{\"body\": \"It was DNS.\", \"parent_id\": null}\n", + 'U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgiuFwd0samgpFiojp4FYN4W7dUhNjJMcMaJDtCFhcI9oAAAALZGV2bnVsbC1hcGkAAAAAAAAABnNoYTUxMgAAAFMAAAALc3NoLWVkMjU1MTkAAABAZrHU0K+nsKCLlC5abo8rZbyiRE0aBSBDvXD2285XW8VE/L1Zx6VBP+Hx+XltHDV9TfGNJPDW+lmrHvsIEjU8BA==', + 201, + ], +]); + +it('rejects a request that differs from the one that was signed', function (): void { + $server = signApiRequest($this->keyPair, 'GET', route('api-current-user.show')); + + $response = $this->call('GET', route('api-current-user.show', ['tampered' => 1]), server: $server); + + $response->assertUnauthorized() + ->assertJsonPath('message', 'Invalid signature.') + ->assertJsonPath('code', 'invalid_signature'); +}); + +it('rejects a signature for another namespace', function (): void { + $response = signedApiRequest($this->keyPair, 'GET', route('api-current-user.show'), namespace: 'devnull-commit'); + + $response->assertUnauthorized() + ->assertJsonPath('message', 'Invalid signature.') + ->assertJsonPath('code', 'invalid_signature'); +}); + +it('rejects a request without a signature', function (): void { + $response = $this->getJson(route('api-current-user.show')); + + $response->assertUnauthorized() + ->assertJsonPath('message', 'Malformed SSH signature.') + ->assertJsonPath('code', 'invalid_signature'); +}); + +it('rejects an expired or future timestamp', function (int $offset): void { + $response = signedApiRequest($this->keyPair, 'GET', route('api-current-user.show'), timestamp: now()->getTimestamp() + $offset); + + $response->assertUnauthorized() + ->assertJsonPath('message', 'Signature expired. Check that your clock is right.') + ->assertJsonPath('code', 'invalid_signature'); +})->with([-301, 301]); + +it('rejects a malformed nonce', function (): void { + $response = signedApiRequest($this->keyPair, 'GET', route('api-current-user.show'), nonce: 'nonce'); + + $response->assertUnauthorized() + ->assertJsonPath('message', 'Malformed payload.') + ->assertJsonPath('code', 'invalid_signature'); +}); + +it('rejects a reused nonce', function (): void { + $nonce = bin2hex(random_bytes(16)); + + signedApiRequest($this->keyPair, 'GET', route('api-current-user.show'), nonce: $nonce)->assertOk(); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-current-user.show'), nonce: $nonce); + + $response->assertUnauthorized() + ->assertJsonPath('message', 'This signature was already used.') + ->assertJsonPath('code', 'invalid_signature'); +}); + +it('rejects a key nobody approved as a device, without asking github', function (): void { + $keyPair = SshKeyPair::generate(); + $user = User::factory()->create(); + + // Even on the user's GitHub account, a key needs approving on the web... + SshKey::factory()->for($user)->withPublicKey($keyPair->publicKey)->create(); + + $response = signedApiRequest($keyPair, 'GET', route('api-current-user.show')); + + $response->assertUnauthorized() + ->assertExactJson([ + 'message' => "This key isn't approved for the /dev/null app. Sign in again in the app.", + 'code' => 'device_not_approved', + ]); + + $this->assertGuest(); + Http::assertNothingSent(); +}); + +it('rejects a key that is not approved as a device on the public endpoints too', function (Closure $uri): void { + $keyPair = SshKeyPair::generate(); + $commit = Commit::factory()->create(); + + $response = signedApiRequest($keyPair, 'GET', $uri($commit)); + + $response->assertUnauthorized() + ->assertJsonPath('code', 'device_not_approved') + ->assertJsonMissingPath('data'); + + $this->assertGuest(); +})->with([ + 'a profile' => fn (Commit $commit): string => route('api-user.show', $commit->user), + 'a list of commits' => fn (Commit $commit): string => route('api-user-commit.index', $commit->user), + 'a commit' => fn (Commit $commit): string => route('api-commit.show', $commit), +]); + +it("signs in with an approved device whose key is on nobody's github account, without asking github", function (): void { + expect(SshKey::query()->exists())->toBeFalse(); + + signedApiRequest($this->keyPair, 'GET', route('api-current-user.show')) + ->assertOk() + ->assertJsonPath('data.id', $this->user->id); + + Http::assertNothingSent(); +}); + +it('signs in as the user who approved the device, whoever has the key on github', function (): void { + $other = User::factory()->create(); + + SshKey::factory()->for($other)->withPublicKey($this->keyPair->publicKey)->create(); + + signedApiRequest($this->keyPair, 'GET', route('api-current-user.show')) + ->assertOk() + ->assertJsonPath('data.id', $this->user->id); + + $this->assertAuthenticatedAs($this->user); +}); + +it('rejects a revoked device', function (): void { + signedApiRequest($this->keyPair, 'GET', route('api-current-user.show'))->assertOk(); + + $this->user->devices()->sole()->delete(); + Auth::forgetGuards(); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-current-user.show')); + + $response->assertUnauthorized() + ->assertJsonPath('code', 'device_not_approved'); +}); + +it('records when a device was last used, at most once a minute', function (): void { + $device = $this->user->devices()->sole(); + + expect($device->last_used_at)->toBeNull(); + + signedApiRequest($this->keyPair, 'GET', route('api-current-user.show'))->assertOk(); + + expect($device->refresh()->last_used_at?->toDateTimeString())->toBe(now()->toDateTimeString()); + + $usedAt = now(); + $this->travel(59)->seconds(); + + signedApiRequest($this->keyPair, 'GET', route('api-current-user.show'))->assertOk(); + + expect($device->refresh()->last_used_at?->toDateTimeString())->toBe($usedAt->toDateTimeString()); + + $this->travel(1)->seconds(); + + signedApiRequest($this->keyPair, 'GET', route('api-current-user.show'))->assertOk(); + + expect($device->refresh()->last_used_at?->toDateTimeString())->toBe(now()->toDateTimeString()); +}); + +it('never answers a badly signed request to a public endpoint with the public view', function (Closure $uri, Closure $server, string $message): void { + $uri = $uri(Commit::factory()->create()); + + $response = $this->call('GET', $uri, server: $server($this->keyPair, $uri)); + + $response->assertUnauthorized() + ->assertJsonPath('message', $message) + ->assertJsonPath('code', 'invalid_signature') + ->assertJsonMissingPath('data'); + + $this->assertGuest(); +})->with([ + 'a profile' => fn (Commit $commit): string => route('api-user.show', $commit->user), + 'a list of commits' => fn (Commit $commit): string => route('api-user-commit.index', $commit->user), + 'a commit' => fn (Commit $commit): string => route('api-commit.show', $commit), + 'a commit that does not exist' => fn (): string => 'api/v1/commits/01a13f6e-ebdd-72c7-bf9d-be2a9b2315a3', +])->with([ + 'a wrong signature' => [ + fn (SshKeyPair $keyPair, string $uri): array => [...signApiRequest($keyPair, 'GET', $uri), 'HTTP_X_DEVNULL_NONCE' => str_repeat('0', 32)], + 'Invalid signature.', + ], + 'an empty signature' => [ + fn (SshKeyPair $keyPair, string $uri): array => [...signApiRequest($keyPair, 'GET', $uri), 'HTTP_X_SSH_SIGNATURE' => ''], + 'Malformed SSH signature.', + ], + 'an expired signature' => [ + fn (SshKeyPair $keyPair, string $uri): array => signApiRequest($keyPair, 'GET', $uri, timestamp: now()->getTimestamp() - 301), + 'Signature expired. Check that your clock is right.', + ], +]); + +it('answers a request without a signature with the public view, whoever is signed in on the web', function (Closure $uri): void { + $commit = Commit::factory()->for($this->user)->pending()->create(); + $public = Commit::factory()->for($this->user)->create(); + + $this->actingAs($this->user) + ->getJson($uri($commit)) + ->assertJsonMissingPath('data.status') + ->assertJsonMissing(['id' => $commit->id]); + + $this->assertAuthenticatedAs($this->user); + $this->getJson(route('api-commit.show', $public))->assertOk()->assertJsonMissingPath('data.status'); +})->with([ + 'a profile' => fn (Commit $commit): string => route('api-user.show', $commit->user), + 'a list of commits' => fn (Commit $commit): string => route('api-user-commit.index', $commit->user), + 'a commit' => fn (Commit $commit): string => route('api-commit.show', $commit), +]); + +it('treats any one of the signature headers as a signed request', function (string $header): void { + $commit = Commit::factory()->create(); + + $response = $this->getJson(route('api-commit.show', $commit), [$header => '1']); + + $response->assertUnauthorized(); +})->with(['X-Devnull-Timestamp', 'X-Devnull-Nonce', 'X-SSH-Signature']); + +it('limits each signed-in user to 120 requests a minute, wherever they come from', function (): void { + $sign = fn (string $address): array => [...signApiRequest($this->keyPair, 'GET', route('api-current-user.show')), 'REMOTE_ADDR' => $address]; + + foreach (range(1, 120) as $attempt) { + $this->call('GET', route('api-current-user.show'), server: $sign('10.0.0.1'))->assertOk(); + } + + $this->call('GET', route('api-current-user.show'), server: $sign('10.0.0.1')) + ->assertTooManyRequests() + ->assertHeader('Retry-After', 60) + ->assertJsonPath('message', 'Too Many Attempts.') + ->assertJsonPath('code', 'rate_limited'); + + $this->call('GET', route('api-current-user.show'), server: $sign('10.0.0.2'))->assertTooManyRequests(); + + $this->call('GET', route('api-current-user.show'), server: [...signApiRequest(apiKeyPairFor(User::factory()->create()), 'GET', route('api-current-user.show')), 'REMOTE_ADDR' => '10.0.0.1']) + ->assertOk(); + + Auth::forgetGuards(); + + $this->getJson(route('api-user.show', $this->user), ['REMOTE_ADDR' => '10.0.0.1']) + ->assertOk() + ->assertJsonMissingPath('data.lane'); +}); + +it("leaves the public api's 429 without a code", function (): void { + $commit = Commit::factory()->create(); + + foreach (range(1, 60) as $attempt) { + $this->getJson(route('api-commit.show', $commit))->assertOk(); + } + + $this->getJson(route('api-commit.show', $commit)) + ->assertTooManyRequests() + ->assertJsonPath('message', 'Too Many Attempts.') + ->assertJsonMissingPath('code'); +}); + +it('answers unknown api routes in json', function (): void { + $response = $this->get('api/v1/unknown'); + + $response->assertNotFound() + ->assertHeader('Content-Type', 'application/json'); +}); diff --git a/tests/Feature/Controllers/Api/CommitControllerTest.php b/tests/Feature/Controllers/Api/CommitControllerTest.php index bfc2093ba..35b82a909 100644 --- a/tests/Feature/Controllers/Api/CommitControllerTest.php +++ b/tests/Feature/Controllers/Api/CommitControllerTest.php @@ -3,10 +3,16 @@ declare(strict_types=1); use App\Enums\CommitStatus; +use App\Jobs\ModerateCommit; +use App\Models\Bookmark; use App\Models\Commit; use App\Models\CommitImage; use App\Models\LinkPreview; +use App\Models\Poll; +use App\Models\PollOption; +use App\Models\PollVote; use App\Models\User; +use Illuminate\Support\Facades\Queue; it('shows an approved commit to anyone', function (): void { $user = User::factory()->create([ @@ -189,3 +195,467 @@ $this->getJson(route('api-commit.show', $commit), ['REMOTE_ADDR' => '10.0.0.2'])->assertOk(); }); + +it('shows a signed request the commit as its page shows it to its user', function (): void { + $user = User::factory()->create(['username' => 'octocat']); + $keyPair = apiKeyPairFor($user); + $root = Commit::factory()->create(['body' => 'Who broke prod?', 'created_at' => now()->subMinute()]); + $commit = Commit::factory()->for($user)->for($root, 'parent')->create([ + 'id' => '019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b', + 'body' => 'Ask @nunomaduro about `@Override`', + ]); + $image = CommitImage::factory()->for($commit)->create(); + Commit::factory()->for($commit, 'parent')->create(); + Commit::factory()->for($commit, 'parent')->pending()->create(); + $commit->stars()->attach($user); + Bookmark::factory()->create(['user_id' => $user->id, 'commit_id' => $commit->id]); + + $response = signedApiRequest($keyPair, 'GET', route('api-commit.show', $commit)); + + $author = fn (User $author): array => [ + 'id' => $author->id, + 'username' => $author->username, + 'name' => $author->name, + 'avatar_url' => $author->avatar_url, + 'bio' => $author->bio, + 'company' => $author->company, + 'location' => $author->location, + 'website' => $author->website, + 'github_url' => "https://github.com/{$author->username}", + 'html_url' => route('user.show', $author), + 'feed_url' => route('user-feed.show', $author), + 'created_at' => now()->toIso8601ZuluString(), + 'lane' => $author->lane(), + ]; + + $response->assertOk() + ->assertExactJson([ + 'data' => [ + 'id' => '019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b', + 'short_hash' => 'e3f4a5b', + 'body' => 'Ask @nunomaduro about `@Override`', + 'parent_id' => $root->id, + 'html_url' => route('commit.show', [$user, $commit]), + 'created_at' => now()->toIso8601ZuluString(), + 'stars_count' => 1, + 'replies_count' => 1, + 'link_preview' => null, + 'images' => [ + ['id' => $image->id, 'width' => 1200, 'height' => 800, 'description' => null, 'url' => route('commit-image.show', $image)], + ], + 'author' => $author($user), + 'body_segments' => [ + ['type' => 'text', 'text' => 'Ask @nunomaduro about '], + ['type' => 'code', 'code' => '@Override'], + ], + 'preview_segments' => null, + 'mentions' => [['username' => 'nunomaduro', 'offset' => 4, 'length' => 11]], + 'status' => 'approved', + 'is_pinned' => false, + 'starred' => true, + 'is_saved' => true, + 'deleted_at' => null, + 'quoted_commit_id' => null, + 'quoted_commit' => null, + 'poll' => null, + ], + 'ancestors' => [[ + 'id' => $root->id, + 'short_hash' => mb_substr(str_replace('-', '', $root->id), -7), + 'body' => 'Who broke prod?', + 'parent_id' => null, + 'html_url' => route('commit.show', [$root->user, $root]), + 'created_at' => now()->subMinute()->toIso8601ZuluString(), + 'stars_count' => 0, + 'link_preview' => null, + 'images' => [], + 'author' => $author($root->user), + 'body_segments' => [['type' => 'text', 'text' => 'Who broke prod?']], + 'preview_segments' => null, + 'mentions' => [], + 'status' => 'approved', + 'is_pinned' => false, + 'starred' => false, + 'is_saved' => false, + 'deleted_at' => null, + 'quoted_commit_id' => null, + 'quoted_commit' => null, + 'poll' => null, + ]], + ]); +}); + +it('shows a signed request the poll of a commit as the web does, with each share once the votes show', function (): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + $commit = Commit::factory()->create(['body' => 'Tabs or spaces?']); + $poll = Poll::factory()->for($commit)->withOptions('Tabs', 'Spaces', 'Both')->create(['opened_at' => now()->subHour()]); + [$tabs, $spaces, $both] = $poll->options->all(); + PollVote::factory()->for($tabs, 'option')->count(2)->create(); + + $option = fn (PollOption $option, ?int $votes, bool $voted, ?int $percentage): array => [ + 'id' => $option->id, + 'label' => $option->label, + 'votes_count' => $votes, + 'voted' => $voted, + 'percentage' => $percentage, + ]; + + signedApiRequest($keyPair, 'GET', route('api-commit.show', $commit)) + ->assertOk() + ->assertJsonPath('data.poll', [ + 'id' => $poll->id, + 'closes_at' => $poll->closesAt()?->toJSON(), + 'is_closed' => false, + 'has_voted' => false, + 'total_votes' => null, + 'options' => [ + $option($tabs, null, false, null), + $option($spaces, null, false, null), + $option($both, null, false, null), + ], + ]); + + PollVote::factory()->for($both, 'option')->for($user)->create(); + + signedApiRequest($keyPair, 'GET', route('api-commit.show', $commit)) + ->assertOk() + ->assertJsonPath('data.poll.has_voted', true) + ->assertJsonPath('data.poll.total_votes', 3) + ->assertJsonPath('data.poll.options', [ + $option($tabs, 2, false, 67), + $option($spaces, 0, false, 0), + $option($both, 1, true, 33), + ]); + + $this->getJson(route('api-commit.show', $commit)) + ->assertOk() + ->assertJsonMissingPath('data.poll'); +}); + +it('shows a signed request the shares of a closed poll nobody voted on', function (): void { + $keyPair = apiKeyPairFor(User::factory()->create()); + $poll = Poll::factory()->withOptions('Tabs', 'Spaces')->closed()->create(); + + signedApiRequest($keyPair, 'GET', route('api-commit.show', $poll->commit)) + ->assertOk() + ->assertJsonPath('data.poll.is_closed', true) + ->assertJsonPath('data.poll.total_votes', 0) + ->assertJsonPath('data.poll.options.*.percentage', [0, 0]); +}); + +it('embeds the commit a commit quotes for a signed request, with its poll', function (): void { + $keyPair = apiKeyPairFor(User::factory()->create()); + $quoted = Commit::factory()->create(['body' => 'Tabs or spaces?']); + Poll::factory()->for($quoted)->withOptions('Tabs', 'Spaces')->create(); + $quote = Commit::factory()->for($quoted, 'quotedCommit')->create(['body' => 'Neither.']); + + signedApiRequest($keyPair, 'GET', route('api-commit.show', $quote)) + ->assertOk() + ->assertJsonPath('data.quoted_commit_id', $quoted->id) + ->assertJsonPath('data.quoted_commit.id', $quoted->id) + ->assertJsonPath('data.quoted_commit.body', 'Tabs or spaces?') + ->assertJsonPath('data.quoted_commit.author.id', $quoted->user_id) + ->assertJsonPath('data.quoted_commit.poll.options.*.label', ['Tabs', 'Spaces']) + ->assertJsonMissingPath('data.quoted_commit.quoted_commit'); + + $this->getJson(route('api-commit.show', $quote)) + ->assertOk() + ->assertJsonMissingPath('data.quoted_commit_id') + ->assertJsonMissingPath('data.quoted_commit'); +}); + +it('embeds a deleted quoted commit as a tombstone for a signed request', function (): void { + $keyPair = apiKeyPairFor(User::factory()->create()); + $quoted = Commit::factory()->create(); + $quote = Commit::factory()->for($quoted, 'quotedCommit')->create(); + $quoted->delete(); + + signedApiRequest($keyPair, 'GET', route('api-commit.show', $quote)) + ->assertOk() + ->assertJsonPath('data.quoted_commit', [ + 'id' => $quoted->id, + 'parent_id' => null, + 'deleted_at' => now()->toIso8601ZuluString(), + ]); +}); + +it('leaves out a quoted commit the signed-in user may not see, but still says one was quoted', function (Closure $hide): void { + $viewer = User::factory()->create(); + $keyPair = apiKeyPairFor($viewer); + $quoted = Commit::factory()->create(); + $quote = Commit::factory()->for($quoted, 'quotedCommit')->create(); + + $hide($quoted, $viewer); + + signedApiRequest($keyPair, 'GET', route('api-commit.show', $quote)) + ->assertOk() + ->assertJsonPath('data.quoted_commit_id', $quoted->id) + ->assertJsonPath('data.quoted_commit', null); +})->with([ + 'pending' => fn (Commit $quoted) => $quoted->update(['status' => CommitStatus::Pending]), + 'rejected' => fn (Commit $quoted) => $quoted->update(['status' => CommitStatus::Rejected]), + 'across a block' => fn (Commit $quoted, User $viewer) => $quoted->user?->blocking()->attach($viewer), +]); + +it('shows a signed-in user their own pending commits, and the parents they may see', function (): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + $parent = Commit::factory()->for($user)->pending()->create(); + $commit = Commit::factory()->for($user)->for($parent, 'parent')->pending()->create(); + $image = CommitImage::factory()->for($commit)->create(); + + $this->getJson(route('api-commit.show', $commit))->assertNotFound(); + + signedApiRequest($keyPair, 'GET', route('api-commit.show', $commit)) + ->assertOk() + ->assertJsonPath('data.status', 'pending') + ->assertJsonPath('data.parent_id', $parent->id) + ->assertJsonPath('data.images.0.id', $image->id) + ->assertJsonPath('ancestors.0.id', $parent->id) + ->assertJsonPath('ancestors.0.status', 'pending'); +}); + +it('hides from a signed request the commits its user may not see', function (Closure $factory): void { + $keyPair = apiKeyPairFor(User::factory()->create()); + + signedApiRequest($keyPair, 'GET', route('api-commit.show', $factory()))->assertNotFound(); +})->with([ + 'pending' => [fn (): Commit => Commit::factory()->pending()->create()], + 'rejected' => [fn (): Commit => Commit::factory()->rejected()->create()], + 'deleted' => [fn (): Commit => tap(Commit::factory()->create())->delete()], +]); + +it('hides from a signed request the commits and ancestors across a block, as the web does', function (): void { + $viewer = User::factory()->create(); + $keyPair = apiKeyPairFor($viewer); + $blocker = User::factory()->create(); + $blocked = Commit::factory()->for($blocker)->create(); + $reply = Commit::factory()->for($blocked, 'parent')->create(); + + $blocker->blocking()->attach($viewer); + + signedApiRequest($keyPair, 'GET', route('api-commit.show', $blocked))->assertNotFound(); + signedApiRequest($keyPair, 'GET', route('api-commit.show', $reply)) + ->assertOk() + ->assertJsonPath('data.parent_id', $blocked->id) + ->assertJsonCount(0, 'ancestors'); + $this->getJson(route('api-commit.show', $blocked))->assertOk(); +}); + +it('hides from a signed request the ancestors its user may not see', function (): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + $hidden = Commit::factory()->for(Commit::factory(), 'parent')->pending()->create(); + $commit = Commit::factory()->for($user)->for($hidden, 'parent')->create(); + + signedApiRequest($keyPair, 'GET', route('api-commit.show', $commit)) + ->assertOk() + ->assertJsonPath('data.parent_id', $hidden->id) + ->assertJsonCount(0, 'ancestors'); +}); + +it('shows deleted ancestors to a signed request as tombstones', function (): void { + $keyPair = apiKeyPairFor(User::factory()->create()); + $root = Commit::factory()->create(['created_at' => now()->subMinutes(2)]); + $deleted = Commit::factory()->for($root, 'parent')->create(['body' => 'Gone', 'created_at' => now()->subMinute()]); + $commit = Commit::factory()->for($deleted, 'parent')->create(); + $deleted->delete(); + + signedApiRequest($keyPair, 'GET', route('api-commit.show', $commit)) + ->assertOk() + ->assertJsonCount(2, 'ancestors') + ->assertJsonPath('ancestors.0.id', $root->id) + ->assertJsonPath('ancestors.1', [ + 'id' => $deleted->id, + 'parent_id' => $root->id, + 'deleted_at' => now()->toIso8601ZuluString(), + ]) + ->assertDontSee('Gone'); +}); + +it('links the topmost ancestor to its parent when a thread is too deep to show it all', function (): void { + $keyPair = apiKeyPairFor(User::factory()->create()); + $parent = Commit::factory()->create(); + + foreach (range(1, 21) as $depth) { + $parent = Commit::factory()->for($parent, 'parent')->create(['created_at' => now()->subMinutes(30 - $depth)]); + } + + $response = signedApiRequest($keyPair, 'GET', route('api-commit.show', $parent)); + + $response->assertOk() + ->assertJsonCount(20, 'ancestors') + ->assertJsonPath('ancestors.0.parent.id', $response->json('ancestors.0.parent_id')) + ->assertJsonMissingPath('ancestors.1.parent'); +}); + +it('creates a commit from a signed request, pending review', function (): void { + $user = User::factory()->create(['username' => 'octocat']); + $keyPair = apiKeyPairFor($user); + + $response = signedApiRequest($keyPair, 'POST', route('api-commit.store'), ['body' => ' It was DNS :tada: ']); + + $commit = $user->commits()->sole(); + + $response->assertCreated() + ->assertJsonPath('data.id', $commit->id) + ->assertJsonPath('data.body', 'It was DNS 🎉') + ->assertJsonPath('data.status', 'pending') + ->assertJsonPath('data.parent_id', null) + ->assertJsonPath('data.replies_count', 0) + ->assertJsonPath('data.stars_count', 0) + ->assertJsonPath('data.starred', false) + ->assertJsonPath('data.is_saved', false) + ->assertJsonPath('data.images', []) + ->assertJsonPath('data.author.id', $user->id) + ->assertJsonMissingPath('ancestors'); + + expect($commit->status)->toBe(CommitStatus::Pending); +}); + +it('publishes the commits of admins right away', function (): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + + config(['app.admins' => [$user->username]]); + + signedApiRequest($keyPair, 'POST', route('api-commit.store'), ['body' => 'It was DNS.']) + ->assertCreated() + ->assertJsonPath('data.status', 'approved'); +}); + +it('replies to a commit from a signed request', function (): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + $parent = Commit::factory()->create(); + + signedApiRequest($keyPair, 'POST', route('api-commit.store'), ['body' => 'It was DNS.', 'parent_id' => $parent->id]) + ->assertCreated() + ->assertJsonPath('data.parent_id', $parent->id); + + expect($user->commits()->sole()->parent_id)->toBe($parent->id); +}); + +it('validates a commit from a signed request', function (Closure $data, string $field, string $message): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + + signedApiRequest($keyPair, 'POST', route('api-commit.store'), $data()) + ->assertUnprocessable() + ->assertJsonValidationErrors([$field => $message]); + + expect($user->commits()->exists())->toBeFalse(); +})->with([ + 'missing body' => [fn (): array => [], 'body', 'The body field is required.'], + 'too short' => [fn (): array => ['body' => ' ab '], 'body', 'The body field must be at least 3 characters.'], + 'too long' => [fn (): array => ['body' => str_repeat('a', Commit::MAX_BODY_LENGTH + 1)], 'body', 'The body field must not be greater than '.Commit::MAX_BODY_LENGTH.' characters.'], + 'parent that is not a uuid' => [fn (): array => ['body' => 'It was DNS.', 'parent_id' => 'abc'], 'parent_id', 'The parent id field must be a valid UUID.'], + 'unknown parent' => [fn (): array => ['body' => 'It was DNS.', 'parent_id' => '0199e3a4-0000-7000-8000-000000000000'], 'parent_id', 'That commit is no longer available to reply to.'], + 'hidden parent' => [fn (): array => ['body' => 'It was DNS.', 'parent_id' => Commit::factory()->pending()->create()->id], 'parent_id', 'That commit is no longer available to reply to.'], + 'deleted parent' => [fn (): array => ['body' => 'It was DNS.', 'parent_id' => tap(Commit::factory()->create())->delete()->id], 'parent_id', 'That commit is no longer available to reply to.'], +]); + +it('may not reply to a commit across a block, as on the web', function (): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + $parent = Commit::factory()->create(); + + $parent->user->blocking()->attach($user); + + signedApiRequest($keyPair, 'POST', route('api-commit.store'), ['body' => 'It was DNS.', 'parent_id' => $parent->id]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['parent_id' => 'That commit is no longer available to reply to.']); + + expect($user->commits()->exists())->toBeFalse(); +}); + +it('sends a commit from a signed request to the ai moderation the web uses', function (): void { + config(['services.openai.key' => 'sk-test']); + Queue::fake(); + + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + + $response = signedApiRequest($keyPair, 'POST', route('api-commit.store'), ['body' => 'It was DNS.']); + + $response->assertCreated(); + + Queue::assertPushed(ModerateCommit::class, fn (ModerateCommit $job): bool => $job->commit->id === $response->json('data.id')); +}); + +it('collapses blank lines before measuring a body, like the web does', function (): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + + signedApiRequest($keyPair, 'POST', route('api-commit.store'), ['body' => "It was DNS.\r\n\n\n\n\nIt always is."]) + ->assertCreated() + ->assertJsonPath('data.body', "It was DNS.\n\nIt always is."); +}); + +it('counts each emoji as one character, like the web does', function (): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + + signedApiRequest($keyPair, 'POST', route('api-commit.store'), ['body' => str_repeat('🚀', Commit::MAX_BODY_LENGTH)]) + ->assertCreated(); + + signedApiRequest($keyPair, 'POST', route('api-commit.store'), ['body' => str_repeat('🚀', Commit::MAX_BODY_LENGTH + 1)]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['body' => 'The body field must not be greater than '.Commit::MAX_BODY_LENGTH.' characters.']); + + expect($user->commits()->sole()->body)->toBe(str_repeat('🚀', Commit::MAX_BODY_LENGTH)); +}); + +it('limits a signed-in user to 10 commits a minute, in json', function (): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + + foreach (range(1, 10) as $attempt) { + signedApiRequest($keyPair, 'POST', route('api-commit.store'), ['body' => "Commit {$attempt}"])->assertCreated(); + } + + signedApiRequest($keyPair, 'POST', route('api-commit.store'), ['body' => 'One too many']) + ->assertTooManyRequests() + ->assertExactJson(['message' => 'Slow down: 10 commits a minute. Even CI takes breaks.', 'code' => 'rate_limited']) + ->assertHeader('Retry-After', '60'); + + expect($user->commits()->count())->toBe(10); +}); + +it('deletes a commit of the signed-in user', function (): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + $commit = Commit::factory()->for($user)->create(); + + signedApiRequest($keyPair, 'DELETE', route('api-commit.destroy', $commit))->assertNoContent(); + + $this->assertSoftDeleted($commit); +}); + +it('may not delete a commit that is already deleted', function (): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + $commit = tap(Commit::factory()->for($user)->create())->delete(); + + signedApiRequest($keyPair, 'DELETE', route('api-commit.destroy', $commit))->assertNotFound(); +}); + +it('may not delete the commits of others', function (): void { + $keyPair = apiKeyPairFor(User::factory()->create()); + $commit = Commit::factory()->create(); + + signedApiRequest($keyPair, 'DELETE', route('api-commit.destroy', $commit))->assertForbidden(); + + $this->assertNotSoftDeleted($commit); +}); + +it('rejects commits and deletes that are not signed', function (): void { + $commit = Commit::factory()->create(); + + $this->postJson(route('api-commit.store'), ['body' => 'It was DNS.'])->assertUnauthorized(); + $this->deleteJson(route('api-commit.destroy', $commit))->assertUnauthorized(); + + expect(Commit::query()->count())->toBe(1); + $this->assertNotSoftDeleted($commit); +}); diff --git a/tests/Feature/Controllers/Api/CommitReplyControllerTest.php b/tests/Feature/Controllers/Api/CommitReplyControllerTest.php new file mode 100644 index 000000000..17ef42134 --- /dev/null +++ b/tests/Feature/Controllers/Api/CommitReplyControllerTest.php @@ -0,0 +1,209 @@ +user = User::factory()->create(); + $this->keyPair = apiKeyPairFor($this->user); +}); + +it('lists visible replies oldest first with their own reply counts', function (): void { + $parent = Commit::factory()->create(); + + $second = Commit::factory()->for($parent, 'parent')->create(['body' => 'Second', 'created_at' => now()->subMinute()]); + Commit::factory()->for($parent, 'parent')->create(['body' => 'First', 'created_at' => now()->subMinutes(2)]); + Commit::factory()->for($parent, 'parent')->for($this->user)->pending()->create(['body' => 'Mine, pending']); + Commit::factory()->for($parent, 'parent')->pending()->create(['body' => 'Hidden']); + Commit::factory()->for($second, 'parent')->count(2)->create(); + Commit::factory()->for($second, 'parent')->pending()->create(); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-commit-reply.index', $parent)); + + $response->assertOk() + ->assertJsonCount(3, 'data') + ->assertJsonPath('data.0.body', 'First') + ->assertJsonPath('data.0.parent_id', $parent->id) + ->assertJsonPath('data.1.body', 'Second') + ->assertJsonPath('data.1.replies_count', 2) + ->assertJsonPath('data.2.body', 'Mine, pending') + ->assertJsonPath('meta.next_cursor', null); +}); + +it('shows the stars and saves of each reply as the user sees them', function (): void { + $parent = Commit::factory()->create(); + $starred = Commit::factory()->for($parent, 'parent')->create(['created_at' => now()->subMinutes(2)]); + $starred->stars()->attach([$this->user->id, User::factory()->create()->id]); + Bookmark::factory()->create(['user_id' => $this->user->id, 'commit_id' => $starred->id]); + $starredByOthers = Commit::factory()->for($parent, 'parent')->create(['created_at' => now()->subMinute()]); + $starredByOthers->stars()->attach(User::factory()->create()); + Bookmark::factory()->create(['commit_id' => $starredByOthers->id]); + Commit::factory()->for($parent, 'parent')->create(); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-commit-reply.index', $parent)); + + $response->assertOk() + ->assertJsonPath('data.0.stars_count', 2) + ->assertJsonPath('data.0.starred', true) + ->assertJsonPath('data.0.is_saved', true) + ->assertJsonPath('data.1.stars_count', 1) + ->assertJsonPath('data.1.starred', false) + ->assertJsonPath('data.1.is_saved', false) + ->assertJsonPath('data.2.stars_count', 0) + ->assertJsonPath('data.2.starred', false) + ->assertJsonPath('data.2.is_saved', false); +}); + +it('does not query each reply, its author or its stars', function (): void { + $parent = Commit::factory()->create(); + + $queries = function () use ($parent): int { + DB::flushQueryLog(); + DB::enableQueryLog(); + + signedApiRequest($this->keyPair, 'GET', route('api-commit-reply.index', $parent))->assertOk(); + + return count(DB::getQueryLog()); + }; + + $create = function (int $count) use ($parent): void { + foreach (Commit::factory()->for($parent, 'parent')->count($count)->create() as $reply) { + $reply->stars()->attach([$this->user->id, User::factory()->create()->id]); + } + }; + + // The first signed request also syncs the user's keys with GitHub... + $queries(); + + $create(2); + $few = $queries(); + + $create(8); + + expect($queries())->toBe($few); +}); + +it('lists replies 50 at a time with a cursor to the next page', function (): void { + $parent = Commit::factory()->create(); + + Commit::factory()->for($parent, 'parent')->count(52) + ->state(new Sequence(fn (Sequence $sequence): array => [ + 'body' => "Reply {$sequence->index}", + 'created_at' => now()->subMinutes(100 - $sequence->index), + ])) + ->create(); + + $first = signedApiRequest($this->keyPair, 'GET', route('api-commit-reply.index', $parent)); + + $first->assertOk() + ->assertJsonCount(50, 'data') + ->assertJsonPath('data.49.body', 'Reply 49'); + + $second = signedApiRequest($this->keyPair, 'GET', route('api-commit-reply.index', [$parent, 'cursor' => $first->json('meta.next_cursor')])); + + $second->assertOk() + ->assertJsonCount(2, 'data') + ->assertJsonPath('data.0.body', 'Reply 50'); +}); + +it('hides the replies of commits the user may not see', function (): void { + $parent = Commit::factory()->pending()->create(); + Commit::factory()->for($parent, 'parent')->create(); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-commit-reply.index', $parent)); + + $response->assertNotFound(); +}); + +it('lists deleted replies that still have replies as tombstones', function (): void { + $parent = Commit::factory()->create(); + + $tombstone = Commit::factory()->for($parent, 'parent')->create(['body' => 'Gone', 'created_at' => now()->subMinutes(2)]); + Commit::factory()->for($tombstone, 'parent')->create(); + $tombstone->delete(); + Commit::factory()->for($parent, 'parent')->create(['body' => 'Also gone', 'created_at' => now()->subMinute()])->delete(); + $pendingTombstone = Commit::factory()->for($parent, 'parent')->create(['created_at' => now()->subMinute()]); + Commit::factory()->for($pendingTombstone, 'parent')->pending()->create(); + $pendingTombstone->delete(); + $kept = Commit::factory()->for($parent, 'parent')->create(); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-commit-reply.index', $parent)); + + $response->assertOk() + ->assertJsonCount(2, 'data') + ->assertJsonPath('data.0', [ + 'id' => $tombstone->id, + 'parent_id' => $parent->id, + 'deleted_at' => now()->toIso8601ZuluString(), + 'replies_count' => 1, + ]) + ->assertJsonPath('data.1.id', $kept->id) + ->assertDontSee('Gone'); +}); + +it('lists the replies of a deleted commit', function (): void { + $parent = Commit::factory()->create(); + $reply = Commit::factory()->for($parent, 'parent')->create(); + $parent->delete(); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-commit-reply.index', $parent)); + + $response->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $reply->id); +}); + +it('hides the replies of deleted commits the user may not see', function (): void { + $parent = Commit::factory()->pending()->create(); + Commit::factory()->for($parent, 'parent')->create(); + $parent->delete(); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-commit-reply.index', $parent)); + + $response->assertNotFound(); +}); + +it('rejects requests that are not signed', function (): void { + $this->getJson(route('api-commit-reply.index', Commit::factory()->create()))->assertUnauthorized(); +}); + +it('leaves out replies by muted people and across blocks, as the web does', function (): void { + $muted = User::factory()->create(); + $blocker = User::factory()->create(); + $parent = Commit::factory()->create(); + + $this->user->muting()->attach($muted); + $blocker->blocking()->attach($this->user); + + $reply = Commit::factory()->for($parent, 'parent')->create(); + Commit::factory()->for($parent, 'parent')->for($muted)->create(); + Commit::factory()->for($parent, 'parent')->for($blocker)->create(); + + signedApiRequest($this->keyPair, 'GET', route('api-commit-reply.index', $parent)) + ->assertOk() + ->assertJsonPath('data.*.id', [$reply->id]); +}); + +it('returns not found for the replies to a commit across a block', function (): void { + $parent = Commit::factory()->create(); + Commit::factory()->for($parent, 'parent')->create(); + + $this->user->blocking()->attach($parent->user); + + signedApiRequest($this->keyPair, 'GET', route('api-commit-reply.index', $parent))->assertNotFound(); +}); + +it('lists quotes of the commit with its replies, as the web does', function (): void { + $commit = Commit::factory()->create(); + $reply = Commit::factory()->for($commit, 'parent')->create(['created_at' => now()->subMinute()]); + $quote = Commit::factory()->create(['quoted_commit_id' => $commit->id]); + + signedApiRequest($this->keyPair, 'GET', route('api-commit-reply.index', $commit)) + ->assertOk() + ->assertJsonPath('data.*.id', [$reply->id, $quote->id]); +}); diff --git a/tests/Feature/Controllers/Api/CurrentUserControllerTest.php b/tests/Feature/Controllers/Api/CurrentUserControllerTest.php new file mode 100644 index 000000000..094cf8f1a --- /dev/null +++ b/tests/Feature/Controllers/Api/CurrentUserControllerTest.php @@ -0,0 +1,68 @@ +create([ + 'username' => 'octocat', + 'name' => 'The Octocat', + 'avatar_url' => 'https://avatars.githubusercontent.com/u/583231', + 'bio' => 'I am the Octocat.', + 'company' => '@github', + 'location' => 'San Francisco', + 'website' => 'https://github.blog', + ]); + $keyPair = apiKeyPairFor($user); + + $user->following()->attach(User::factory()->count(2)->create()); + $user->followers()->attach(User::factory()->create()); + Commit::factory()->for($user)->pending()->create(); + Notification::factory()->for($user, 'recipient')->create(); + Notification::factory()->for($user, 'recipient')->read()->create(); + + $response = signedApiRequest($keyPair, 'GET', route('api-current-user.show')); + + $response->assertOk() + ->assertExactJson([ + 'data' => [ + 'id' => $user->id, + 'username' => 'octocat', + 'name' => 'The Octocat', + 'avatar_url' => 'https://avatars.githubusercontent.com/u/583231', + 'bio' => 'I am the Octocat.', + 'company' => '@github', + 'location' => 'San Francisco', + 'website' => 'https://github.blog', + 'github_url' => 'https://github.com/octocat', + 'html_url' => route('user.show', $user), + 'feed_url' => route('user-feed.show', $user), + 'created_at' => now()->toIso8601ZuluString(), + 'commits_count' => 1, + 'followers_count' => 3, + 'following_count' => 2, + 'lane' => $user->lane(), + ], + 'unread_notifications_count' => 1, + ]); +}); + +it('counts unread notifications in groups, like the web', function (): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + $commit = Commit::factory()->for($user)->create(); + + Notification::factory()->for($user, 'recipient')->starred($commit)->count(3)->create(); + + $response = signedApiRequest($keyPair, 'GET', route('api-current-user.show')); + + $response->assertOk() + ->assertJsonPath('unread_notifications_count', 1); +}); + +it('rejects requests that are not signed', function (): void { + $this->getJson(route('api-current-user.show'))->assertUnauthorized(); +}); diff --git a/tests/Feature/Controllers/Api/DeviceControllerTest.php b/tests/Feature/Controllers/Api/DeviceControllerTest.php new file mode 100644 index 000000000..13d402b96 --- /dev/null +++ b/tests/Feature/Controllers/Api/DeviceControllerTest.php @@ -0,0 +1,174 @@ +user = User::factory()->create(['username' => 'octocat']); + $this->keyPair = SshKeyPair::generate(); + $this->verifier = str_repeat('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk', 2); + + AuthorizationCode::factory() + ->for($this->user) + ->withCode('the-code') + ->withVerifier($this->verifier) + ->withPublicKey($this->keyPair->publicKey) + ->create(['device_name' => 'devnull on iPhone']); +}); + +it('approves the key that redeems the code as a device, and answers like me', function (): void { + $response = signedApiRequest($this->keyPair, 'POST', route('api-device.store'), ['code' => 'the-code', 'code_verifier' => $this->verifier]); + + $response->assertOk() + ->assertJsonPath('data.id', $this->user->id) + ->assertJsonPath('data.username', 'octocat') + ->assertJsonPath('data.lane', $this->user->lane()) + ->assertJsonPath('data.commits_count', 0) + ->assertJsonPath('unread_notifications_count', 0); + + $device = Device::query()->sole(); + + expect($device->user_id)->toBe($this->user->id) + ->and($device->fingerprint)->toBe($this->keyPair->publicKey->fingerprint()) + ->and($device->name)->toBe('devnull on iPhone') + ->and($device->approved_at->toDateTimeString())->toBe(now()->toDateTimeString()) + ->and(AuthorizationCode::query()->exists())->toBeFalse(); + + signedApiRequest($this->keyPair, 'GET', route('api-current-user.show')) + ->assertOk() + ->assertJsonPath('data.id', $this->user->id); + + Http::assertNothingSent(); +}); + +it('never needs the key to be on github', function (): void { + expect(SshKey::query()->exists())->toBeFalse(); + + signedApiRequest($this->keyPair, 'POST', route('api-device.store'), ['code' => 'the-code', 'code_verifier' => $this->verifier]) + ->assertOk(); +}); + +it('refuses a code it cannot redeem, the same way whatever is wrong with it', function (Closure $request): void { + $response = $request($this); + + $response->assertBadRequest() + ->assertExactJson([ + 'message' => 'This sign-in has expired or was already used. Sign in again in the app.', + 'code' => 'invalid_grant', + ]); + + expect(Device::query()->exists())->toBeFalse(); +})->with([ + 'a wrong verifier' => fn ($test): TestResponse => signedApiRequest($test->keyPair, 'POST', route('api-device.store'), ['code' => 'the-code', 'code_verifier' => str_repeat('a', 43)]), + 'an unknown code' => fn ($test): TestResponse => signedApiRequest($test->keyPair, 'POST', route('api-device.store'), ['code' => 'another-code', 'code_verifier' => $test->verifier]), + 'another key' => fn ($test): TestResponse => signedApiRequest(SshKeyPair::generate(), 'POST', route('api-device.store'), ['code' => 'the-code', 'code_verifier' => $test->verifier]), + 'an expired code' => function ($test): TestResponse { + $test->travel(AuthorizationCode::EXPIRES_AFTER_MINUTES)->minutes(); + + return signedApiRequest($test->keyPair, 'POST', route('api-device.store'), ['code' => 'the-code', 'code_verifier' => $test->verifier]); + }, +]); + +it('refuses a code handed out for another key, and leaves it for that key', function (): void { + $other = SshKeyPair::generate(); + + AuthorizationCode::factory()->withCode('code-for-other')->withVerifier($this->verifier)->withPublicKey($other->publicKey)->create(); + + signedApiRequest($this->keyPair, 'POST', route('api-device.store'), ['code' => 'code-for-other', 'code_verifier' => $this->verifier]) + ->assertBadRequest() + ->assertJsonPath('code', 'invalid_grant'); + + signedApiRequest($other, 'POST', route('api-device.store'), ['code' => 'code-for-other', 'code_verifier' => $this->verifier]) + ->assertOk(); +}); + +it('redeems a code only once', function (): void { + signedApiRequest($this->keyPair, 'POST', route('api-device.store'), ['code' => 'the-code', 'code_verifier' => $this->verifier])->assertOk(); + + signedApiRequest($this->keyPair, 'POST', route('api-device.store'), ['code' => 'the-code', 'code_verifier' => $this->verifier]) + ->assertBadRequest() + ->assertJsonPath('code', 'invalid_grant'); +}); + +it('approves again a key approved for someone else, for the user who approved it now', function (): void { + $previous = Device::factory()->withPublicKey($this->keyPair->publicKey)->create(); + + signedApiRequest($this->keyPair, 'POST', route('api-device.store'), ['code' => 'the-code', 'code_verifier' => $this->verifier]) + ->assertOk() + ->assertJsonPath('data.id', $this->user->id); + + expect($previous->refresh()->user_id)->toBe($this->user->id); +}); + +it('refuses a code that was redeemed by another request in the meantime', function (): void { + // The code is checked, then used up by another request before this one approves its device... + AuthorizationCode::retrieved(function (AuthorizationCode $code): void { + AuthorizationCode::query()->whereKey($code->id)->delete(); + }); + + signedApiRequest($this->keyPair, 'POST', route('api-device.store'), ['code' => 'the-code', 'code_verifier' => $this->verifier]) + ->assertBadRequest() + ->assertJsonPath('code', 'invalid_grant'); + + expect(Device::query()->exists())->toBeFalse(); +}); + +it('validates the code and the verifier', function (array $data, string $field): void { + signedApiRequest($this->keyPair, 'POST', route('api-device.store'), $data) + ->assertUnprocessable() + ->assertJsonValidationErrors($field); +})->with([ + 'no code' => [['code_verifier' => str_repeat('a', 43)], 'code'], + 'no verifier' => [['code' => 'the-code'], 'code_verifier'], + 'a short verifier' => [['code' => 'the-code', 'code_verifier' => str_repeat('a', 42)], 'code_verifier'], + 'a long verifier' => [['code' => 'the-code', 'code_verifier' => str_repeat('a', 129)], 'code_verifier'], + 'a verifier with other characters' => [['code' => 'the-code', 'code_verifier' => str_repeat('a', 42).'!'], 'code_verifier'], +]); + +it('rejects a request that is not signed right', function (Closure $server, string $message): void { + $body = json_encode(['code' => 'the-code', 'code_verifier' => $this->verifier], JSON_THROW_ON_ERROR); + + $response = $this->call('POST', route('api-device.store'), server: $server($this->keyPair, $body), content: $body); + + $response->assertUnauthorized() + ->assertExactJson(['message' => $message, 'code' => 'invalid_signature']); + + expect(Device::query()->exists())->toBeFalse(); +})->with([ + 'no signature' => [fn (): array => ['CONTENT_TYPE' => 'application/json', 'HTTP_ACCEPT' => 'application/json'], 'Malformed SSH signature.'], + 'a signature over another body' => [fn (SshKeyPair $keyPair): array => signApiRequest($keyPair, 'POST', route('api-device.store'), '{}'), 'Invalid signature.'], + 'an expired signature' => [fn (SshKeyPair $keyPair, string $body): array => signApiRequest($keyPair, 'POST', route('api-device.store'), $body, timestamp: now()->getTimestamp() - 301), 'Signature expired. Check that your clock is right.'], +]); + +it('limits each key to 12 tries a minute, wherever they come from', function (): void { + foreach (range(1, 12) as $attempt) { + $server = [...signApiRequest($this->keyPair, 'POST', route('api-device.store'), '{"code":"wrong","code_verifier":"'.str_repeat('a', 43).'"}'), 'REMOTE_ADDR' => "203.0.113.{$attempt}"]; + + $this->call('POST', route('api-device.store'), server: $server, content: '{"code":"wrong","code_verifier":"'.str_repeat('a', 43).'"}')->assertBadRequest(); + } + + signedApiRequest($this->keyPair, 'POST', route('api-device.store'), ['code' => 'the-code', 'code_verifier' => $this->verifier]) + ->assertTooManyRequests() + ->assertHeader('Retry-After', 60) + ->assertExactJson(['message' => 'Too Many Attempts.', 'code' => 'rate_limited']); + + signedApiRequest(SshKeyPair::generate(), 'POST', route('api-device.store'), ['code' => 'the-code', 'code_verifier' => $this->verifier]) + ->assertBadRequest(); +}); + +it('limits each address to 60 tries a minute, before checking signatures', function (): void { + foreach (range(1, 60) as $attempt) { + $this->postJson(route('api-device.store'))->assertUnauthorized(); + } + + $this->postJson(route('api-device.store')) + ->assertTooManyRequests() + ->assertJsonPath('code', 'rate_limited'); +}); diff --git a/tests/Feature/Controllers/Api/LogControllerTest.php b/tests/Feature/Controllers/Api/LogControllerTest.php new file mode 100644 index 000000000..d8fa2962a --- /dev/null +++ b/tests/Feature/Controllers/Api/LogControllerTest.php @@ -0,0 +1,345 @@ +user = User::factory()->create(); + $this->keyPair = apiKeyPairFor($this->user); +}); + +it('lists the latest commits of everyone, newest first', function (): void { + $author = User::factory()->create([ + 'username' => 'octocat', + 'name' => 'The Octocat', + 'avatar_url' => 'https://avatars.githubusercontent.com/u/583231', + ]); + $older = Commit::factory()->for($author)->create(['body' => 'It was DNS.', 'created_at' => now()->subMinute()]); + $newer = Commit::factory()->for($this->user)->pending()->create(['body' => 'Mine, pending']); + Commit::factory()->for($older, 'parent')->create(['created_at' => now()->subSeconds(30)]); + $reply = Commit::factory()->for($older, 'parent')->create(['created_at' => now()->subSeconds(20)]); + Commit::factory()->for($older, 'parent')->pending()->create(); + Commit::factory()->pending()->create(); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-log.show', 'latest')); + + $response->assertOk() + ->assertJsonCount(2, 'data') + ->assertJsonPath('data.0.id', $newer->id) + ->assertJsonPath('data.0.status', 'pending') + ->assertJsonPath('data.0.replies_count', 0) + ->assertJsonPath('data.0.reply_preview', ['data' => [], 'next_cursor' => null]) + ->assertJsonPath('data.1.reply_preview.data.*.id', [$reply->id]) + ->assertJsonPath('data.1.reply_preview.data.0.author.id', $reply->user_id) + ->assertJsonPath('data.1.reply_preview.data.0.parent_id', $older->id); + + expect($response->json('data.1.reply_preview.next_cursor'))->toBeString(); + + expect(collect($response->json('data.1'))->except('reply_preview')->all())->toBe([ + 'id' => $older->id, + 'short_hash' => mb_substr(str_replace('-', '', $older->id), -7), + 'body' => 'It was DNS.', + 'parent_id' => null, + 'html_url' => route('commit.show', [$author, $older]), + 'created_at' => now()->subMinute()->toIso8601ZuluString(), + 'stars_count' => 0, + 'replies_count' => 2, + 'link_preview' => null, + 'images' => [], + 'author' => [ + 'id' => $author->id, + 'username' => 'octocat', + 'name' => 'The Octocat', + 'avatar_url' => 'https://avatars.githubusercontent.com/u/583231', + 'bio' => $author->bio, + 'company' => $author->company, + 'location' => $author->location, + 'website' => $author->website, + 'github_url' => 'https://github.com/octocat', + 'html_url' => route('user.show', $author), + 'feed_url' => route('user-feed.show', $author), + 'created_at' => now()->toIso8601ZuluString(), + 'lane' => $author->lane(), + ], + 'body_segments' => [['type' => 'text', 'text' => 'It was DNS.']], + 'preview_segments' => null, + 'mentions' => [], + 'status' => 'approved', + 'is_pinned' => false, + 'starred' => false, + 'is_saved' => false, + 'deleted_at' => null, + 'quoted_commit_id' => null, + 'quoted_commit' => null, + 'poll' => null, + ]); + + $response->assertJsonPath('links.next', null) + ->assertJsonPath('meta.next_cursor', null) + ->assertJsonPath('meta.per_page', 20); +}); + +it('sends the excerpt the web shows of a long commit in a list', function (): void { + $long = Commit::factory()->create(['body' => str_repeat('It was DNS. ', 100)]); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-log.show', 'latest')); + + $response->assertOk() + ->assertJsonPath('data.0.id', $long->id) + ->assertJsonPath('data.0.body', $long->body) + ->assertJsonPath('data.0.body_segments', resolve(CommitBodies::class)->segments($long->body)['body_segments']) + ->assertJsonPath('data.0.preview_segments', resolve(CommitBodies::class)->segments($long->body)['preview_segments']); + + expect($response->json('data.0.preview_segments'))->not->toBeNull(); +}); + +it('shows what the web shows on a commit', function (): void { + $author = User::factory()->create(['username' => 'octocat', 'preferred_lane' => 4]); + $linkPreview = LinkPreview::factory()->fetched()->create(['url' => 'https://laravel.com']); + $commit = Commit::factory()->for($author)->for($linkPreview)->create(['body' => 'Ask @NunoMaduro about https://laravel.com']); + $image = CommitImage::factory()->for($commit)->create(['width' => 640, 'height' => 480]); + $commit->stars()->attach([$this->user->id, User::factory()->create()->id]); + Bookmark::factory()->create(['user_id' => $this->user->id, 'commit_id' => $commit->id]); + $author->forceFill(['pinned_commit_id' => $commit->id])->save(); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-log.show', 'latest')); + + $response->assertOk() + ->assertJsonPath('data.0.stars_count', 2) + ->assertJsonPath('data.0.starred', true) + ->assertJsonPath('data.0.is_saved', true) + ->assertJsonPath('data.0.is_pinned', true) + ->assertJsonPath('data.0.mentions', [['username' => 'NunoMaduro', 'offset' => 4, 'length' => 11]]) + ->assertJsonPath('data.0.link_preview', [ + 'url' => 'https://laravel.com', + 'title' => $linkPreview->title, + 'description' => $linkPreview->description, + 'image_url' => $linkPreview->image_url, + 'site_name' => $linkPreview->site_name, + 'embed' => null, + ]) + ->assertJsonPath('data.0.images', [[ + 'id' => $image->id, + 'width' => 640, + 'height' => 480, + 'description' => null, + 'url' => route('commit-image.show', $image), + ]]) + ->assertJsonPath('data.0.author.lane', 4); +}); + +it('only shows the saves and stars of the user', function (): void { + $commit = Commit::factory()->create(); + $commit->stars()->attach(User::factory()->create()); + Bookmark::factory()->create(['commit_id' => $commit->id]); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-log.show', 'latest')); + + $response->assertOk() + ->assertJsonPath('data.0.id', $commit->id) + ->assertJsonPath('data.0.stars_count', 1) + ->assertJsonPath('data.0.starred', false) + ->assertJsonPath('data.0.is_saved', false); +}); + +it('lists the commits of the users followed and the user', function (): void { + $followed = User::factory()->create(); + $this->user->following()->attach($followed); + + $mine = Commit::factory()->for($this->user)->create(['created_at' => now()->subMinute()]); + $theirs = Commit::factory()->for($followed)->create(); + Commit::factory()->create(); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-log.show', 'following')); + + $response->assertOk() + ->assertJsonCount(2, 'data') + ->assertJsonPath('data.0.id', $theirs->id) + ->assertJsonPath('data.1.id', $mine->id); +}); + +it('leaves deleted commits out of the log', function (): void { + $kept = Commit::factory()->create(); + Commit::factory()->for($this->user)->create()->delete(); + + $deleted = Commit::factory()->create(['created_at' => now()->subMinute()]); + Commit::factory()->for($deleted, 'parent')->create(); + $deleted->delete(); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-log.show', 'latest')); + + $response->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $kept->id); +}); + +it('lists commits 20 at a time with a cursor to the next page', function (): void { + Commit::factory()->count(22) + ->state(new Sequence(fn (Sequence $sequence): array => [ + 'body' => "Commit {$sequence->index}", + 'created_at' => now()->subMinutes($sequence->index), + ])) + ->create(); + + $first = signedApiRequest($this->keyPair, 'GET', route('api-log.show', 'latest')); + + $first->assertOk() + ->assertJsonCount(20, 'data') + ->assertJsonPath('data.0.body', 'Commit 0') + ->assertJsonPath('data.19.body', 'Commit 19'); + + $second = signedApiRequest($this->keyPair, 'GET', route('api-log.show', ['latest', 'cursor' => $first->json('meta.next_cursor')])); + + $second->assertOk() + ->assertJsonCount(2, 'data') + ->assertJsonPath('data.0.body', 'Commit 20') + ->assertJsonPath('data.1.body', 'Commit 21') + ->assertJsonPath('meta.next_cursor', null); +}); + +it('does not query each commit or author', function (): void { + signedApiRequest($this->keyPair, 'GET', route('api-log.show', 'latest'))->assertOk(); + + $queries = function (): int { + DB::flushQueryLog(); + DB::enableQueryLog(); + + signedApiRequest($this->keyPair, 'GET', route('api-log.show', 'latest'))->assertOk(); + + return count(DB::getQueryLog()); + }; + + Commit::factory()->count(2)->create(); + $few = $queries(); + + Commit::factory()->count(8)->create(); + + expect($queries())->toBe($few); +}); + +it('returns not found for unknown logs', function (string $log): void { + $response = signedApiRequest($this->keyPair, 'GET', "api/v1/log/{$log}"); + + $response->assertNotFound(); +})->with(['unknown', 'everyone']); + +it('ranks the trending log like the web, and says which commit was newest', function (): void { + $starred = Commit::factory()->create(['body' => 'Starred', 'created_at' => now()->subHour()]); + $starred->stars()->attach(User::factory()->create()); + resolve(UpdateCommitHotScores::class)->handle([$starred->id]); + $newer = Commit::factory()->create(['body' => 'Newer']); + + signedApiRequest($this->keyPair, 'GET', route('api-log.show', 'trending')) + ->assertOk() + ->assertJsonPath('data.*.id', [$starred->id, $newer->id]) + ->assertJsonPath('log', 'trending') + ->assertJsonPath('is_ranked', true) + ->assertJsonPath('newest_commit_id', $newer->id) + ->assertJsonMissingPath('latest_commits') + ->assertJsonMissingPath('new_commits_count'); + + signedApiRequest($this->keyPair, 'GET', route('api-log.show', 'latest')) + ->assertOk() + ->assertJsonPath('data.*.id', [$newer->id, $starred->id]) + ->assertJsonPath('log', 'latest') + ->assertJsonPath('is_ranked', false) + ->assertJsonPath('newest_commit_id', null); +}); + +it('pages through the trending log in its ranking', function (): void { + $commits = Commit::factory()->count(22) + ->sequence(fn (Sequence $sequence): array => ['hot_score' => 100 - $sequence->index]) + ->create(); + + $first = signedApiRequest($this->keyPair, 'GET', route('api-log.show', 'trending')); + + $first->assertOk()->assertJsonPath('data.*.id', $commits->take(20)->pluck('id')->all()); + + signedApiRequest($this->keyPair, 'GET', route('api-log.show', ['trending', 'cursor' => $first->json('meta.next_cursor')])) + ->assertOk() + ->assertJsonPath('data.*.id', $commits->slice(20)->pluck('id')->values()->all()) + ->assertJsonPath('meta.next_cursor', null); +}); + +it('offers the commits newer than the one seen above the trending log, like the web', function (): void { + $seen = Commit::factory()->create(['created_at' => now()->subMinutes(3), 'hot_score' => 10]); + $first = Commit::factory()->create(['created_at' => now()->subMinutes(2)]); + $second = Commit::factory()->create(['created_at' => now()->subMinute()]); + Commit::factory()->for($this->user)->create(); + + signedApiRequest($this->keyPair, 'GET', route('api-log.show', ['trending', 'since' => $seen->id])) + ->assertOk() + ->assertJsonPath('latest_commits.*.id', [$second->id, $first->id]) + ->assertJsonPath('latest_commits.0.author.id', $second->user_id) + ->assertJsonPath('new_commits_count', 2); +}); + +it('counts the commits newer than the one seen on a log in time order, without offering them', function (string $log): void { + $this->user->following()->attach($followed = User::factory()->create()); + $seen = Commit::factory()->for($followed)->create(['created_at' => now()->subMinute()]); + Commit::factory()->for($followed)->create(); + + signedApiRequest($this->keyPair, 'GET', route('api-log.show', [$log, 'since' => $seen->id])) + ->assertOk() + ->assertJsonPath('latest_commits', []) + ->assertJsonPath('new_commits_count', 1); +})->with(['following', 'latest']); + +it('validates the commit the user has seen', function (): void { + signedApiRequest($this->keyPair, 'GET', route('api-log.show', ['trending', 'since' => 'abc'])) + ->assertUnprocessable() + ->assertJsonValidationErrors('since'); +}); + +it('leaves out muted people and people across a block on every log, as the web does', function (Log $log): void { + $muted = User::factory()->create(); + $blocked = User::factory()->create(); + $blocker = User::factory()->create(); + $this->user->following()->attach([$muted->id, $blocked->id, $blocker->id]); + $this->user->muting()->attach($muted); + $this->user->blocking()->attach($blocked); + $blocker->blocking()->attach($this->user); + + $mine = Commit::factory()->for($this->user)->create(); + Commit::factory()->for($muted)->create(); + Commit::factory()->for($blocked)->create(); + Commit::factory()->for($blocker)->create(); + + signedApiRequest($this->keyPair, 'GET', route('api-log.show', $log)) + ->assertOk() + ->assertJsonPath('data.*.id', [$mine->id]); +})->with(Log::cases()); + +it('suggests commits on an empty following log, as the web does', function (): void { + $suggested = Commit::factory()->create(); + + signedApiRequest($this->keyPair, 'GET', route('api-log.show', 'following')) + ->assertOk() + ->assertJsonCount(0, 'data') + ->assertJsonPath('suggestions.*.id', [$suggested->id]) + ->assertJsonPath('suggestions.0.author.id', $suggested->user_id); + + $this->user->following()->attach($suggested->user); + + signedApiRequest($this->keyPair, 'GET', route('api-log.show', 'following')) + ->assertOk() + ->assertJsonPath('suggestions', []); + + signedApiRequest($this->keyPair, 'GET', route('api-log.show', 'latest')) + ->assertOk() + ->assertJsonPath('suggestions', []); +}); + +it('rejects requests that are not signed', function (): void { + $this->getJson(route('api-log.show', 'latest'))->assertUnauthorized(); +}); diff --git a/tests/Feature/Controllers/Api/NotificationControllerTest.php b/tests/Feature/Controllers/Api/NotificationControllerTest.php new file mode 100644 index 000000000..228dfeb58 --- /dev/null +++ b/tests/Feature/Controllers/Api/NotificationControllerTest.php @@ -0,0 +1,372 @@ +user = User::factory()->create(); + $this->keyPair = apiKeyPairFor($this->user); +}); + +it('lists the notifications of the user, newest first', function (): void { + $followed = Notification::factory()->for($this->user, 'recipient')->create(['created_at' => now()->subMinute()]); + $replied = Notification::factory()->for($this->user, 'recipient')->replied()->create(); + Notification::factory()->create(); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-notification.index')); + + $reply = $replied->commit; + + $user = fn (User $user): array => [ + 'id' => $user->id, + 'username' => $user->username, + 'name' => $user->name, + 'avatar_url' => $user->avatar_url, + 'bio' => $user->bio, + 'company' => $user->company, + 'location' => $user->location, + 'website' => $user->website, + 'github_url' => "https://github.com/{$user->username}", + 'html_url' => route('user.show', $user), + 'feed_url' => route('user-feed.show', $user), + 'created_at' => now()->toIso8601ZuluString(), + 'lane' => $user->lane(), + ]; + + $response->assertOk() + ->assertJsonCount(2, 'data') + ->assertJsonPath('data.0', [ + 'id' => $replied->id, + 'type' => 'replied', + 'subject_id' => $reply->parent_id, + 'reason' => null, + 'read_at' => now()->toIso8601ZuluString(), + 'created_at' => now()->toIso8601ZuluString(), + 'actor' => [...$user($replied->actor), 'is_followed' => false], + 'actors' => [[...$user($replied->actor), 'is_followed' => false]], + 'group_id' => $replied->id, + 'event_count' => 1, + 'actors_count' => 1, + 'unread_count' => 0, + 'commit' => [ + 'id' => $reply->id, + 'short_hash' => mb_substr(str_replace('-', '', $reply->id), -7), + 'body' => $reply->body, + 'parent_id' => $reply->parent_id, + 'html_url' => route('commit.show', [$replied->actor, $reply]), + 'created_at' => now()->toIso8601ZuluString(), + 'author' => $user($replied->actor), + 'body_segments' => [['type' => 'text', 'text' => $reply->body]], + 'preview_segments' => null, + 'mentions' => [], + 'status' => 'approved', + 'is_pinned' => false, + 'deleted_at' => null, + 'parent' => [ + 'id' => $reply->parent->id, + 'short_hash' => mb_substr(str_replace('-', '', $reply->parent->id), -7), + 'body' => $reply->parent->body, + 'parent_id' => null, + 'html_url' => route('commit.show', [$this->user, $reply->parent]), + 'created_at' => now()->toIso8601ZuluString(), + 'author' => $user($this->user), + 'body_segments' => [['type' => 'text', 'text' => $reply->parent->body]], + 'preview_segments' => null, + 'mentions' => [], + 'status' => 'approved', + 'is_pinned' => false, + 'deleted_at' => null, + 'quoted_commit_id' => null, + ], + 'quoted_commit_id' => null, + ], + ]) + ->assertJsonPath('data.1.id', $followed->id) + ->assertJsonPath('data.1.type', 'followed') + ->assertJsonPath('data.1.commit', null) + ->assertJsonMissingPath('data.0.key') + ->assertJsonPath('read_at', now()->toIso8601ZuluString()) + ->assertJsonPath('meta.next_cursor', null); +}); + +it('hides notifications that no longer hold', function (): void { + $notification = Notification::factory()->for($this->user, 'recipient')->create(); + + resolve(DeleteUserFollow::class)->handle($notification->actor, $this->user); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-notification.index')); + + $response->assertOk() + ->assertJsonCount(0, 'data'); +}); + +it('shows moderation updates with the reason a commit was rejected', function (): void { + $commit = Commit::factory()->for($this->user)->rejected()->create(); + Notification::factory()->for($this->user, 'recipient')->create([ + 'type' => NotificationType::Rejected, + 'commit_id' => $commit->id, + 'reason' => 'Spam.', + ]); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-notification.index')); + + $response->assertOk() + ->assertJsonPath('data.0.type', 'rejected') + ->assertJsonPath('data.0.reason', 'Spam.') + ->assertJsonPath('data.0.commit.id', $commit->id) + ->assertJsonPath('data.0.commit.status', 'rejected'); +}); + +it('hides moderation updates that no longer hold', function (): void { + $commit = Commit::factory()->for($this->user)->create(); + Notification::factory()->for($this->user, 'recipient')->create([ + 'type' => NotificationType::Rejected, + 'commit_id' => $commit->id, + ]); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-notification.index')); + + $response->assertOk() + ->assertJsonCount(0, 'data'); +}); + +it('hides mentions in commits that are not approved yet', function (): void { + $notification = Notification::factory()->for($this->user, 'recipient')->mentioned()->create(); + $notification->commit?->update(['status' => CommitStatus::Pending]); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-notification.index')); + + $response->assertOk() + ->assertJsonCount(0, 'data'); +}); + +it('hides notifications about deleted commits', function (): void { + $notification = Notification::factory()->for($this->user, 'recipient')->replied()->create(); + + $notification->commit?->delete(); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-notification.index')); + + $response->assertOk() + ->assertJsonCount(0, 'data'); +}); + +it('leaves out the commit a mention replies to when the user may not see it', function (Closure $parent): void { + $parent = $parent(); + $actor = User::factory()->create(); + $mention = Commit::factory()->for($actor)->for($parent, 'parent')->create(['body' => "Ask @{$this->user->username}"]); + Notification::factory()->for($this->user, 'recipient')->for($actor, 'actor')->create([ + 'type' => NotificationType::Mentioned, + 'commit_id' => $mention->id, + ]); + + $response = signedApiRequest($this->keyPair, 'GET', route('api-notification.index')); + + $response->assertOk() + ->assertJsonPath('data.0.type', 'mentioned') + ->assertJsonPath('data.0.commit.id', $mention->id) + ->assertJsonPath('data.0.commit.parent_id', $parent->id) + ->assertJsonPath('data.0.commit.parent', null); +})->with([ + 'pending' => fn (): Commit => Commit::factory()->pending()->create(), + 'deleted' => fn (): Commit => tap(Commit::factory()->create())->delete(), +]); + +it('marks every notification as read like the web, and says when, so the new ones read since then', function (): void { + $notification = Notification::factory()->for($this->user, 'recipient')->create(); + + signedApiRequest($this->keyPair, 'GET', route('api-notification.index')) + ->assertJsonPath('read_at', now()->toIso8601ZuluString()) + ->assertJsonPath('data.0.read_at', now()->toIso8601ZuluString()); + + expect($notification->refresh()->read_at?->toIso8601ZuluString())->toBe(now()->toIso8601ZuluString()); + + $readAt = now()->toIso8601ZuluString(); + $this->travel(1)->minute(); + + signedApiRequest($this->keyPair, 'GET', route('api-notification.index')) + ->assertJsonPath('read_at', now()->toIso8601ZuluString()) + ->assertJsonPath('data.0.read_at', $readAt); +}); + +it('does not query each notification', function (): void { + signedApiRequest($this->keyPair, 'GET', route('api-notification.index'))->assertOk(); + + $queries = function (): int { + DB::flushQueryLog(); + DB::enableQueryLog(); + + signedApiRequest($this->keyPair, 'GET', route('api-notification.index'))->assertOk(); + + return count(DB::getQueryLog()); + }; + + // Two followers make a group with stacked actors, which takes the same queries however many there are... + Notification::factory()->for($this->user, 'recipient')->count(2)->create(); + Notification::factory()->for($this->user, 'recipient')->replied()->create(); + $few = $queries(); + + Notification::factory()->for($this->user, 'recipient')->count(3)->create(); + Notification::factory()->for($this->user, 'recipient')->replied()->count(3)->create(); + + expect($queries())->toBe($few); +}); + +it('groups stars per commit, newest stargazer first, like the web', function (): void { + $commit = Commit::factory()->for($this->user)->create(); + $stargazers = User::factory()->count(4)->create(); + + foreach ($stargazers as $index => $stargazer) { + $this->travelTo(now()->subMinutes(4 - $index), fn () => resolve(CreateCommitStar::class)->handle($stargazer, $commit)); + } + + $response = signedApiRequest($this->keyPair, 'GET', route('api-notification.index')); + + $response->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.type', 'starred') + ->assertJsonPath('data.0.subject_id', $commit->id) + ->assertJsonPath('data.0.actor.id', $stargazers[3]->id) + ->assertJsonPath('data.0.commit.id', $commit->id) + ->assertJsonPath('data.0.commit.author.id', $this->user->id) + ->assertJsonPath('data.0.actors_count', 4) + ->assertJsonPath('data.0.actors.*.id', [$stargazers[3]->id, $stargazers[2]->id, $stargazers[1]->id]) + ->assertJsonMissingPath('data.0.actors.0.email'); + + expect($this->user->notifications()->unread()->exists())->toBeFalse(); +}); + +it('filters notifications by type like the web, and echoes the filter', function (?string $type, array $types): void { + $approved = Commit::factory()->for($this->user)->create(); + $rejected = Commit::factory()->for($this->user)->rejected()->create(); + + Notification::factory()->for($this->user, 'recipient')->create(['created_at' => now()->subMinutes(7)]); + Notification::factory()->for($this->user, 'recipient')->replied()->create(['created_at' => now()->subMinutes(6)]); + Notification::factory()->for($this->user, 'recipient')->quoted()->create(['created_at' => now()->subMinutes(5)]); + Notification::factory()->for($this->user, 'recipient')->starred()->create(['created_at' => now()->subMinutes(4)]); + Notification::factory()->for($this->user, 'recipient')->mentioned()->create(['created_at' => now()->subMinutes(3)]); + Notification::factory()->for($this->user, 'recipient')->for($approved, 'commit')->create(['type' => NotificationType::Approved, 'created_at' => now()->subMinutes(2)]); + Notification::factory()->for($this->user, 'recipient')->for($rejected, 'commit')->create(['type' => NotificationType::Rejected, 'reason' => 'Please remove the token.', 'created_at' => now()->subMinute()]); + + signedApiRequest($this->keyPair, 'GET', route('api-notification.index', $type === null ? [] : ['type' => $type])) + ->assertOk() + ->assertJsonPath('data.*.type', $types) + ->assertJsonPath('filters', ['type' => $type ?? 'all']); +})->with([ + 'no filter' => [null, ['rejected', 'approved', 'mentioned', 'starred', 'quoted', 'replied', 'followed']], + 'all' => ['all', ['rejected', 'approved', 'mentioned', 'starred', 'quoted', 'replied', 'followed']], + 'replies' => ['replies', ['replied']], + 'quotes' => ['quotes', ['quoted']], + 'stars' => ['stars', ['starred']], + 'mentions' => ['mentions', ['mentioned']], + 'follows' => ['follows', ['followed']], + 'moderation' => ['moderation', ['rejected', 'approved']], +]); + +it('marks every notification as read whatever the filter, as the web does', function (): void { + $followed = Notification::factory()->for($this->user, 'recipient')->create(); + $replied = Notification::factory()->for($this->user, 'recipient')->replied()->create(); + + signedApiRequest($this->keyPair, 'GET', route('api-notification.index', ['type' => 'follows'])) + ->assertOk() + ->assertJsonPath('data.*.id', [$followed->id]); + + expect($replied->refresh()->read_at)->not->toBeNull(); +}); + +it('validates the filter before marking anything as read', function (array $query, string $field): void { + $notification = Notification::factory()->for($this->user, 'recipient')->create(); + + signedApiRequest($this->keyPair, 'GET', route('api-notification.index', $query)) + ->assertUnprocessable() + ->assertJsonValidationErrors($field); + + expect($notification->refresh()->read_at)->toBeNull(); +})->with([ + 'an unknown type' => [['type' => 'secret'], 'type'], + 'a date that is not one' => [['through' => 'yesterday-ish'], 'through'], +]); + +it('lists every group through a date when asked, like the web refreshing its inbox', function (): void { + $notifications = Notification::factory()->for($this->user, 'recipient')->replied()->count(22) + ->sequence(fn ($sequence): array => ['created_at' => now()->subHours($sequence->index * 2)]) + ->create(); + + signedApiRequest($this->keyPair, 'GET', route('api-notification.index')) + ->assertJsonCount(20, 'data'); + + signedApiRequest($this->keyPair, 'GET', route('api-notification.index', ['through' => $notifications->last()->created_at->toJSON()])) + ->assertJsonCount(22, 'data'); +}); + +it('says which group a notification leads, how many events it has, and whether the user follows the actor', function (): void { + $first = Notification::factory()->for($this->user, 'recipient')->create(['created_at' => now()->subMinutes(2)]); + $latest = Notification::factory()->for($this->user, 'recipient')->create(); + $this->user->following()->attach($latest->actor); + + signedApiRequest($this->keyPair, 'GET', route('api-notification.index')) + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', $latest->id) + ->assertJsonPath('data.0.group_id', $first->id) + ->assertJsonPath('data.0.event_count', 2) + ->assertJsonPath('data.0.actors_count', 2) + ->assertJsonPath('data.0.actor.is_followed', true) + ->assertJsonPath('data.0.actors.*.id', [$latest->actor_id, $first->actor_id]) + ->assertJsonMissingPath('data.0.actors.0.is_followed'); +}); + +it('lists the notifications of a group like the web expands it', function (): void { + $commit = Commit::factory()->for($this->user)->create(); + $old = Notification::factory()->for($this->user, 'recipient')->starred($commit)->create(['created_at' => now()->subDays(2)]); + Notification::factory()->for($this->user, 'recipient')->replied($commit)->create(['created_at' => now()->subMinute()]); + $latest = Notification::factory()->for($this->user, 'recipient')->starred($commit)->create(); + + signedApiRequest($this->keyPair, 'GET', route('api-notification-group.show', [$latest, 'type' => 'stars'])) + ->assertOk() + ->assertJsonPath('data.*.id', [$latest->id, $old->id]) + ->assertJsonPath('data.0.group_id', $old->id) + ->assertJsonPath('data.0.commit.id', $commit->id) + ->assertJsonPath('data.0.commit.author.id', $this->user->id) + ->assertJsonMissingPath('data.0.actors') + ->assertJsonPath('meta.next_cursor', null); + + expect($latest->refresh()->read_at)->toBeNull(); +}); + +it('returns not found for the group of a notification the user may not see', function (): void { + $other = Notification::factory()->create(); + $hidden = Notification::factory()->for($this->user, 'recipient')->replied()->create(); + $hidden->commit->update(['status' => CommitStatus::Rejected]); + + foreach ([$other, $hidden] as $notification) { + signedApiRequest($this->keyPair, 'GET', route('api-notification-group.show', $notification))->assertNotFound(); + } +}); + +it('leaves out what muted people and people across a block did, as the web does', function (): void { + $kept = Notification::factory()->for($this->user, 'recipient')->replied()->create(); + $muted = Notification::factory()->for($this->user, 'recipient')->replied()->create(); + $blocked = Notification::factory()->for($this->user, 'recipient')->mentioned()->create(); + $this->user->muting()->attach($muted->actor); + $blocked->actor->blocking()->attach($this->user); + + signedApiRequest($this->keyPair, 'GET', route('api-notification.index')) + ->assertOk() + ->assertJsonPath('data.*.id', [$kept->id]); + + expect($muted->refresh()->read_at)->toBeNull(); +}); + +it('rejects requests that are not signed', function (): void { + $this->getJson(route('api-notification.index'))->assertUnauthorized(); + $this->getJson(route('api-notification-group.show', Notification::factory()->create()))->assertUnauthorized(); +}); diff --git a/tests/Feature/Controllers/Api/UserBlockControllerTest.php b/tests/Feature/Controllers/Api/UserBlockControllerTest.php new file mode 100644 index 000000000..5e2b29e22 --- /dev/null +++ b/tests/Feature/Controllers/Api/UserBlockControllerTest.php @@ -0,0 +1,147 @@ +user = User::factory()->create(); + $this->keyPair = apiKeyPairFor($this->user); +}); + +it('may block a user, which ends the follows both ways, and answers with them and that they are blocked', function (): void { + $user = User::factory()->create(['username' => 'octocat']); + + $this->user->following()->attach($user); + $user->following()->attach($this->user); + + $response = signedApiRequest($this->keyPair, 'POST', route('api-user-block.store', $user)); + + $response->assertOk() + ->assertJsonPath('data.id', $user->id) + ->assertJsonPath('data.username', 'octocat') + ->assertJsonPath('data.lane', $user->lane()) + ->assertJsonPath('is_blocking', true); + + expect($this->user->isBlocking($user))->toBeTrue() + ->and($this->user->isFollowing($user))->toBeFalse() + ->and($user->isFollowing($this->user))->toBeFalse() + ->and($user->notifications()->count())->toBe(0); +}); + +it('may block a user who blocks them, as on the web', function (): void { + $user = User::factory()->create(); + + $user->blocking()->attach($this->user); + + signedApiRequest($this->keyPair, 'POST', route('api-user-block.store', $user)) + ->assertOk() + ->assertJsonPath('is_blocking', true); + + expect($this->user->isBlocking($user))->toBeTrue() + ->and($user->isBlocking($this->user))->toBeTrue(); +}); + +it('keeps one block when a user is blocked again', function (): void { + $user = User::factory()->create(); + + $this->user->blocking()->attach($user); + + signedApiRequest($this->keyPair, 'POST', route('api-user-block.store', $user)) + ->assertOk() + ->assertJsonPath('is_blocking', true); + + expect($this->user->blocking()->count())->toBe(1); +}); + +it('may not block themselves', function (): void { + signedApiRequest($this->keyPair, 'POST', route('api-user-block.store', $this->user))->assertForbidden(); + + expect($this->user->blocking()->count())->toBe(0); +}); + +it('may unblock a user, which does not bring back the follows, and answers that they are not blocked', function (): void { + $user = User::factory()->create(); + + $this->user->following()->attach($user); + signedApiRequest($this->keyPair, 'POST', route('api-user-block.store', $user))->assertOk(); + + signedApiRequest($this->keyPair, 'DELETE', route('api-user-block.destroy', $user)) + ->assertOk() + ->assertJsonPath('data.id', $user->id) + ->assertJsonPath('is_blocking', false); + + expect($this->user->isBlocking($user))->toBeFalse() + ->and($this->user->isFollowing($user))->toBeFalse(); +}); + +it('may unblock a user who was not blocked', function (): void { + $user = User::factory()->create(); + + signedApiRequest($this->keyPair, 'DELETE', route('api-user-block.destroy', $user)) + ->assertOk() + ->assertJsonPath('is_blocking', false); +}); + +it('returns not found for unknown usernames', function (string $method): void { + signedApiRequest($this->keyPair, $method, 'api/v1/users/missing/block')->assertNotFound(); +})->with(['POST', 'DELETE']); + +it('lists the people the user blocks, last blocked first, as Settings → Safety does', function (): void { + $first = User::factory()->create(); + $second = User::factory()->create(); + $other = User::factory()->create(); + + Date::setTestNow(now()->subHour()); + $this->user->blocking()->attach($first); + Date::setTestNow(); + $this->user->blocking()->attach($second); + $other->blocking()->attach($first); + + signedApiRequest($this->keyPair, 'GET', route('api-user-block.index')) + ->assertOk() + ->assertJsonPath('data.*.id', [$second->id, $first->id]) + ->assertJsonPath('data.0.username', $second->username) + ->assertJsonPath('data.0.name', $second->name) + ->assertJsonPath('data.0.avatar_url', $second->avatar_url) + ->assertJsonPath('data.0.lane', $second->lane()) + ->assertJsonMissingPath('data.0.pivot') + ->assertJsonPath('meta.next_cursor', null); +}); + +it('pages through the people the user blocks, 20 at a time, even when they were blocked at once', function (): void { + $blocked = User::factory()->count(25)->create(); + + $this->user->blocking()->attach($blocked->pluck('id')); + + $expected = $blocked->sortByDesc('id')->pluck('id')->values(); + + $first = signedApiRequest($this->keyPair, 'GET', route('api-user-block.index')) + ->assertOk() + ->assertJsonCount(20, 'data') + ->assertJsonPath('data.*.id', $expected->take(20)->all()); + + signedApiRequest($this->keyPair, 'GET', route('api-user-block.index', ['cursor' => $first->json('meta.next_cursor')])) + ->assertOk() + ->assertJsonPath('data.*.id', $expected->slice(20)->values()->all()) + ->assertJsonPath('meta.next_cursor', null); +}); + +it('lists no one when the user blocks no one', function (): void { + signedApiRequest($this->keyPair, 'GET', route('api-user-block.index')) + ->assertOk() + ->assertJsonPath('data', []); +}); + +it('rejects requests that are not signed', function (string $method, string $route): void { + $user = User::factory()->create(); + + $this->json($method, route($route, $route === 'api-user-block.index' ? [] : [$user]))->assertUnauthorized(); + + expect($user->blockers()->count())->toBe(0); +})->with([ + ['GET', 'api-user-block.index'], + ['POST', 'api-user-block.store'], + ['DELETE', 'api-user-block.destroy'], +]); diff --git a/tests/Feature/Controllers/Api/UserCommitControllerTest.php b/tests/Feature/Controllers/Api/UserCommitControllerTest.php index 20af79143..0c510d871 100644 --- a/tests/Feature/Controllers/Api/UserCommitControllerTest.php +++ b/tests/Feature/Controllers/Api/UserCommitControllerTest.php @@ -104,3 +104,168 @@ expect($queries())->toBe($withOne); })->with(['commits' => '0', 'replies' => '1']); + +it('lists for a signed request the commits of a profile as the web shows them to its user', function (): void { + $viewer = User::factory()->create(); + $keyPair = apiKeyPairFor($viewer); + $user = User::factory()->create(['username' => 'octocat']); + + $older = Commit::factory()->for($user)->create(['created_at' => now()->subMinute()]); + $newer = Commit::factory()->for($user)->create(); + Commit::factory()->for($newer, 'parent')->create(); + Commit::factory()->for($newer, 'parent')->pending()->create(); + Commit::factory()->for($user)->for($older, 'parent')->create(); + Commit::factory()->for($user)->pending()->create(); + $newer->stars()->attach($viewer); + + signedApiRequest($keyPair, 'GET', '/api/v1/users/octocat/commits') + ->assertOk() + ->assertJsonPath('data.*.id', [$newer->id, $older->id]) + ->assertJsonPath('data.0.replies_count', 1) + ->assertJsonPath('data.0.stars_count', 1) + ->assertJsonPath('data.0.starred', true) + ->assertJsonPath('data.0.is_saved', false) + ->assertJsonPath('data.0.status', 'approved') + ->assertJsonPath('data.0.author.lane', $user->lane()) + ->assertJsonMissingPath('data.0.parent') + ->assertJsonPath('meta.next_cursor', null); +}); + +it('lists the pending commits of a signed-in user on their own profile', function (Closure $commit, string $replies): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + $pending = $commit($user); + + $this->getJson(route('api-user-commit.index', [$user, 'replies' => $replies])) + ->assertOk() + ->assertJsonCount(0, 'data'); + + signedApiRequest($keyPair, 'GET', route('api-user-commit.index', [$user, 'replies' => $replies])) + ->assertOk() + ->assertJsonPath('data.*.id', [$pending->id]) + ->assertJsonPath('data.0.status', 'pending'); +})->with([ + 'commits' => [fn (User $user): Commit => Commit::factory()->for($user)->pending()->create(), '0'], + 'replies' => [fn (User $user): Commit => Commit::factory()->for($user)->for(Commit::factory()->pending(), 'parent')->pending()->create(), '1'], +]); + +it('leaves the pinned commit out of the commits a signed request lists', function (): void { + $keyPair = apiKeyPairFor(User::factory()->create()); + $user = User::factory()->create(); + $pinned = Commit::factory()->for($user)->create(); + $other = Commit::factory()->for($user)->create(['created_at' => now()->subMinute()]); + $user->forceFill(['pinned_commit_id' => $pinned->id])->save(); + + signedApiRequest($keyPair, 'GET', route('api-user-commit.index', $user)) + ->assertOk() + ->assertJsonPath('data.*.id', [$other->id]); +}); + +it('lists for a signed request the replies of a user with the commits they reply to', function (): void { + $keyPair = apiKeyPairFor(User::factory()->create()); + $user = User::factory()->create(); + $parent = Commit::factory()->create(); + $reply = Commit::factory()->for($user)->for($parent, 'parent')->create(); + $hidden = Commit::factory()->for($user)->for(Commit::factory()->pending(), 'parent')->create(['created_at' => now()->subMinute()]); + Commit::factory()->for($user)->create(); + Commit::factory()->for($user)->for($parent, 'parent')->pending()->create(); + + signedApiRequest($keyPair, 'GET', route('api-user-commit.index', [$user, 'replies' => 1])) + ->assertOk() + ->assertJsonPath('data.*.id', [$reply->id, $hidden->id]) + ->assertJsonPath('data.0.parent.id', $parent->id) + ->assertJsonPath('data.0.parent.author.id', $parent->user_id) + ->assertJsonPath('data.1.parent_id', $hidden->parent_id) + ->assertJsonPath('data.1.parent', null) + ->assertJsonMissingPath('data.2'); +}); + +it('keeps the replies filter in the link to the next page of a signed request', function (): void { + $keyPair = apiKeyPairFor(User::factory()->create()); + $user = User::factory()->create(); + $parent = Commit::factory()->create(); + + Commit::factory()->for($user)->for($parent, 'parent')->count(21)->sequence( + fn ($sequence): array => ['created_at' => now()->subMinutes($sequence->index)], + )->create(); + + $first = signedApiRequest($keyPair, 'GET', route('api-user-commit.index', [$user, 'replies' => 1])) + ->assertOk() + ->assertJsonCount(20, 'data'); + + expect($first->json('links.next'))->toContain('replies=1'); + + signedApiRequest($keyPair, 'GET', $first->json('links.next')) + ->assertOk() + ->assertJsonCount(1, 'data'); +}); + +it('loads a page of commits for a signed request without a query per commit', function (string $replies): void { + $keyPair = apiKeyPairFor(User::factory()->create()); + $user = User::factory()->create(['username' => 'octocat']); + + $create = fn (int $count) => Commit::factory()->for($user)->count($count) + ->for(LinkPreview::factory()->fetched()) + ->has(CommitImage::factory()->count(2)->sequence(['position' => 0], ['position' => 1]), 'images') + ->state(fn (): array => ['parent_id' => $replies === '1' ? Commit::factory()->create()->id : null]) + ->create(); + + $queries = function () use ($keyPair, $replies): int { + DB::flushQueryLog(); + DB::enableQueryLog(); + + signedApiRequest($keyPair, 'GET', "/api/v1/users/octocat/commits?replies={$replies}")->assertOk(); + + return count(DB::getQueryLog()); + }; + + // The first signed request also syncs the user's keys with GitHub... + $queries(); + + $create(1); + $withOne = $queries(); + + $create(5); + + expect($queries())->toBe($withOne); +})->with(['commits' => '0', 'replies' => '1']); + +it('lists no commits of a profile across a block for a signed request', function (Closure $block, string $code): void { + $viewer = User::factory()->create(); + $keyPair = apiKeyPairFor($viewer); + $user = User::factory()->create(); + Commit::factory()->for($user)->create(); + + $block($viewer, $user); + + signedApiRequest($keyPair, 'GET', route('api-user-commit.index', $user)) + ->assertForbidden() + ->assertJsonPath('code', $code); + + $this->getJson(route('api-user-commit.index', $user)) + ->assertOk() + ->assertJsonCount(1, 'data'); +})->with([ + 'blocking' => [fn (User $viewer, User $user) => $viewer->blocking()->attach($user), 'user_blocked'], + 'blocked' => [fn (User $viewer, User $user) => $user->blocking()->attach($viewer), 'user_unavailable'], +]); + +it('shows a signed request the newest reply or quote under each commit, as the web does', function (): void { + $keyPair = apiKeyPairFor(User::factory()->create()); + $user = User::factory()->create(); + $commit = Commit::factory()->for($user)->create(['created_at' => now()->subMinutes(3)]); + Commit::factory()->for($commit, 'parent')->create(['created_at' => now()->subMinutes(2)]); + $quote = Commit::factory()->for($commit, 'quotedCommit')->create(['created_at' => now()->subMinute()]); + Commit::factory()->for($user)->create(['created_at' => now()->subMinutes(4)]); + + signedApiRequest($keyPair, 'GET', route('api-user-commit.index', $user)) + ->assertOk() + ->assertJsonPath('data.0.reply_preview.data.*.id', [$quote->id]) + ->assertJsonPath('data.0.reply_preview.data.0.quoted_commit_id', $commit->id) + ->assertJsonMissingPath('data.0.reply_preview.data.0.quoted_commit') + ->assertJsonPath('data.1.reply_preview', ['data' => [], 'next_cursor' => null]); + + $this->getJson(route('api-user-commit.index', $user)) + ->assertOk() + ->assertJsonMissingPath('data.0.reply_preview'); +}); diff --git a/tests/Feature/Controllers/Api/UserControllerTest.php b/tests/Feature/Controllers/Api/UserControllerTest.php index 6a1a3ead2..c75ed6a2c 100644 --- a/tests/Feature/Controllers/Api/UserControllerTest.php +++ b/tests/Feature/Controllers/Api/UserControllerTest.php @@ -3,7 +3,10 @@ declare(strict_types=1); use App\Models\Commit; +use App\Models\CommitImage; use App\Models\User; +use App\Queries\UserAchievementsQuery; +use App\Queries\UserActivityQuery; it('shows a public profile with public counts', function (): void { $user = User::factory()->create([ @@ -68,3 +71,216 @@ it('returns not found for unknown usernames', function (): void { $this->getJson('/api/v1/users/missing')->assertNotFound(); }); + +it('shows a signed request the profile as the web shows it to its user', function (): void { + $this->travelTo(now()->setDate(2026, 10, 15)->setTime(12, 0)); + + $viewer = User::factory()->create(); + $keyPair = apiKeyPairFor($viewer); + $user = User::factory()->create([ + 'username' => 'octocat', + 'name' => 'The Octocat', + 'avatar_url' => 'https://avatars.githubusercontent.com/u/583231', + 'bio' => 'I am the Octocat.', + 'company' => '@github', + 'location' => 'San Francisco', + 'website' => 'https://github.blog', + ]); + + Commit::factory()->for($user)->count(2)->create(); + Commit::factory()->for($user)->create(['created_at' => now()->subDays(3)]); + Commit::factory()->for($user)->pending()->create(); + + $user->followers()->attach($viewer); + $user->following()->attach(User::factory()->count(2)->create()); + + $response = signedApiRequest($keyPair, 'GET', route('api-user.show', $user)); + + $response->assertOk() + ->assertExactJson([ + 'data' => [ + 'id' => $user->id, + 'username' => 'octocat', + 'name' => 'The Octocat', + 'avatar_url' => 'https://avatars.githubusercontent.com/u/583231', + 'bio' => 'I am the Octocat.', + 'company' => '@github', + 'location' => 'San Francisco', + 'website' => 'https://github.blog', + 'github_url' => 'https://github.com/octocat', + 'html_url' => route('user.show', $user), + 'feed_url' => route('user-feed.show', $user), + 'created_at' => now()->toIso8601ZuluString(), + 'commits_count' => 3, + 'followers_count' => 1, + 'following_count' => 2, + 'lane' => $user->lane(), + ], + 'is_following' => true, + 'follows_you' => false, + 'is_muting' => false, + 'activity' => resolve(UserActivityQuery::class)->get($user, $viewer), + 'streaks' => ['longest' => 1, 'current' => 1], + 'achievements' => resolve(UserAchievementsQuery::class)->get($user, $viewer), + 'pinned_commit' => null, + ]) + ->assertJsonPath('achievements.rank', 'user') + ->assertJsonPath('achievements.level', 1) + ->assertJsonPath('activity.scale', 'week') + ->assertJsonCount(7, 'activity.buckets') + ->assertJsonPath('activity.buckets.3', ['date' => '2026-10-12', 'count' => 1]) + ->assertJsonPath('activity.buckets.6', ['date' => '2026-10-15', 'count' => 2]); +}); + +it('counts the pending commits of a signed-in user on their own profile', function (): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + + Commit::factory()->for($user)->pending()->create(); + + $this->getJson(route('api-user.show', $user)) + ->assertOk() + ->assertJsonPath('data.commits_count', 0) + ->assertJsonMissingPath('is_following') + ->assertJsonMissingPath('follows_you'); + + signedApiRequest($keyPair, 'GET', route('api-user.show', $user)) + ->assertOk() + ->assertJsonPath('data.commits_count', 1) + ->assertJsonPath('is_following', false); +}); + +it('tells a signed request whether the profile follows its user', function (): void { + $viewer = User::factory()->create(); + $keyPair = apiKeyPairFor($viewer); + $user = User::factory()->create(); + + $user->following()->attach($viewer); + + signedApiRequest($keyPair, 'GET', route('api-user.show', $user)) + ->assertOk() + ->assertJsonPath('is_following', false) + ->assertJsonPath('follows_you', true); +}); + +it('tells a signed request whether its user mutes the profile, which still shows', function (): void { + $viewer = User::factory()->create(); + $keyPair = apiKeyPairFor($viewer); + $user = User::factory()->create(); + $commit = Commit::factory()->for($user)->create(); + + $viewer->muting()->attach($user); + + signedApiRequest($keyPair, 'GET', route('api-user.show', $user)) + ->assertOk() + ->assertJsonPath('is_muting', true); + + signedApiRequest($keyPair, 'GET', route('api-user-commit.index', $user)) + ->assertOk() + ->assertJsonPath('data.*.id', [$commit->id]); +}); + +it('shows a signed request the achievements and unix rank of the profile', function (): void { + $viewer = User::factory()->create(); + $keyPair = apiKeyPairFor($viewer); + $user = User::factory()->create(); + Commit::factory()->for($user)->count(60)->create(); + + signedApiRequest($keyPair, 'GET', route('api-user.show', $user)) + ->assertOk() + ->assertJsonPath('achievements', resolve(UserAchievementsQuery::class)->get($user, $viewer)) + ->assertJsonPath('achievements.rank', 'maintainer') + ->assertJsonPath('achievements.level', 4) + ->assertJsonCount(6, 'achievements.ranks'); + + $this->getJson(route('api-user.show', $user)) + ->assertOk() + ->assertJsonMissingPath('achievements'); +}); + +it('shows a signed request the pinned commit of the profile', function (): void { + $viewer = User::factory()->create(); + $keyPair = apiKeyPairFor($viewer); + $user = User::factory()->create(); + $pinned = Commit::factory()->for($user)->create(['body' => 'Pinned']); + $reply = Commit::factory()->for($pinned, 'parent')->create(); + Commit::factory()->for($pinned, 'parent')->pending()->create(); + CommitImage::factory()->for($pinned)->create(); + $user->forceFill(['pinned_commit_id' => $pinned->id])->save(); + + $response = signedApiRequest($keyPair, 'GET', route('api-user.show', $user)); + + $response->assertOk() + ->assertJsonPath('pinned_commit.id', $pinned->id) + ->assertJsonPath('pinned_commit.body', 'Pinned') + ->assertJsonPath('pinned_commit.is_pinned', true) + ->assertJsonPath('pinned_commit.replies_count', 1) + ->assertJsonCount(1, 'pinned_commit.images') + ->assertJsonPath('pinned_commit.author.id', $user->id) + ->assertJsonPath('pinned_commit.reply_preview.data.*.id', [$reply->id]); +}); + +it('hides a pinned commit the signed-in user may not see', function (Closure $pinned): void { + $keyPair = apiKeyPairFor(User::factory()->create()); + $user = User::factory()->create(); + $user->forceFill(['pinned_commit_id' => $pinned($user)->id])->save(); + + signedApiRequest($keyPair, 'GET', route('api-user.show', $user)) + ->assertOk() + ->assertJsonPath('pinned_commit', null); +})->with([ + 'pending' => fn (User $user): Commit => Commit::factory()->for($user)->pending()->create(), + 'deleted' => fn (User $user): Commit => tap(Commit::factory()->for($user)->create())->delete(), +]); + +it('shows a signed-in user their own pending pinned commit', function (): void { + $user = User::factory()->create(); + $keyPair = apiKeyPairFor($user); + $pinned = Commit::factory()->for($user)->pending()->create(); + $user->forceFill(['pinned_commit_id' => $pinned->id])->save(); + + signedApiRequest($keyPair, 'GET', route('api-user.show', $user)) + ->assertOk() + ->assertJsonPath('pinned_commit.id', $pinned->id) + ->assertJsonPath('pinned_commit.status', 'pending'); +}); + +it('tells a signed request it blocked the profile, as the web does', function (): void { + $viewer = User::factory()->create(); + $keyPair = apiKeyPairFor($viewer); + $user = User::factory()->create(['name' => 'The Octocat']); + + $viewer->blocking()->attach($user); + + signedApiRequest($keyPair, 'GET', route('api-user.show', $user)) + ->assertForbidden() + ->assertJsonPath('message', "You blocked The Octocat. Neither of you can follow, reply to, or star the other, or see each other's commits.") + ->assertJsonPath('code', 'user_blocked') + // Who they blocked, as the web shows them on the blocked profile. + ->assertJsonPath('user.id', $user->id) + ->assertJsonPath('user.username', $user->username) + ->assertJsonPath('user.name', 'The Octocat') + ->assertJsonPath('user.avatar_url', $user->avatar_url) + ->assertJsonPath('user.lane', $user->lane()) + ->assertJsonMissingPath('user.followers_count') + ->assertJsonMissingPath('data'); + + $this->getJson(route('api-user.show', $user)) + ->assertOk() + ->assertJsonPath('data.id', $user->id); +}); + +it('shows a signed request a profile that blocked its user as unavailable, without saying why', function (): void { + $viewer = User::factory()->create(); + $keyPair = apiKeyPairFor($viewer); + $user = User::factory()->create(); + + $user->blocking()->attach($viewer); + + signedApiRequest($keyPair, 'GET', route('api-user.show', $user)) + ->assertForbidden() + ->assertExactJson([ + 'message' => "This profile isn't available.", + 'code' => 'user_unavailable', + ]); +}); diff --git a/tests/Feature/Controllers/Api/UserFollowControllerTest.php b/tests/Feature/Controllers/Api/UserFollowControllerTest.php new file mode 100644 index 000000000..9cb9ac5f3 --- /dev/null +++ b/tests/Feature/Controllers/Api/UserFollowControllerTest.php @@ -0,0 +1,82 @@ +user = User::factory()->create(); + $this->keyPair = apiKeyPairFor($this->user); +}); + +it('may follow a user', function (): void { + $user = User::factory()->create(); + + $response = signedApiRequest($this->keyPair, 'POST', route('api-user-follow.store', $user)); + + $response->assertNoContent(); + + expect($this->user->isFollowing($user))->toBeTrue() + ->and($user->notifications()->sole()->type)->toBe(NotificationType::Followed); +}); + +it('may not follow themselves', function (): void { + $response = signedApiRequest($this->keyPair, 'POST', route('api-user-follow.store', $this->user)); + + $response->assertForbidden(); + + expect($this->user->following()->count())->toBe(0); +}); + +it('may not follow anyone across a block, as on the web', function (Closure $block): void { + $user = User::factory()->create(); + + $block($this->user, $user); + + signedApiRequest($this->keyPair, 'POST', route('api-user-follow.store', $user))->assertForbidden(); + + expect($this->user->isFollowing($user))->toBeFalse(); +})->with([ + 'blocking' => fn (User $follower, User $user) => $follower->blocking()->attach($user), + 'blocked' => fn (User $follower, User $user) => $user->blocking()->attach($follower), +]); + +it('may unfollow a user', function (): void { + $user = User::factory()->create(); + + $this->user->following()->attach($user); + + $response = signedApiRequest($this->keyPair, 'DELETE', route('api-user-follow.destroy', $user)); + + $response->assertNoContent(); + + expect($this->user->isFollowing($user))->toBeFalse(); +}); + +it('returns not found for unknown usernames', function (): void { + $response = signedApiRequest($this->keyPair, 'POST', 'api/v1/users/missing/follow'); + + $response->assertNotFound(); +}); + +it('rejects requests that are not signed', function (string $method): void { + $user = User::factory()->create(); + + $this->json($method, route('api-user-follow.store', $user))->assertUnauthorized(); + + expect($user->followers()->count())->toBe(0); +})->with(['POST', 'DELETE']); + +it('limits follows and unfollows to 30 a minute, like the web, with a code', function (): void { + $user = User::factory()->create(); + + foreach (range(1, 30) as $attempt) { + signedApiRequest($this->keyPair, $attempt % 2 === 0 ? 'DELETE' : 'POST', route('api-user-follow.store', $user))->assertNoContent(); + } + + signedApiRequest($this->keyPair, 'POST', route('api-user-follow.store', $user)) + ->assertTooManyRequests() + ->assertHeader('Retry-After', 60) + ->assertExactJson(['message' => 'Too Many Attempts.', 'code' => 'rate_limited']); +}); diff --git a/tests/Feature/Controllers/Api/UserMuteControllerTest.php b/tests/Feature/Controllers/Api/UserMuteControllerTest.php new file mode 100644 index 000000000..d7d483a20 --- /dev/null +++ b/tests/Feature/Controllers/Api/UserMuteControllerTest.php @@ -0,0 +1,132 @@ +user = User::factory()->create(); + $this->keyPair = apiKeyPairFor($this->user); +}); + +it('may mute a user, and answers with them and that they are muted', function (): void { + $user = User::factory()->create(['username' => 'octocat']); + + $this->user->following()->attach($user); + $user->following()->attach($this->user); + + $response = signedApiRequest($this->keyPair, 'POST', route('api-user-mute.store', $user)); + + $response->assertOk() + ->assertJsonPath('data.id', $user->id) + ->assertJsonPath('data.username', 'octocat') + ->assertJsonPath('data.lane', $user->lane()) + ->assertJsonPath('is_muting', true); + + expect($this->user->isMuting($user))->toBeTrue() + ->and($this->user->isFollowing($user))->toBeTrue() + ->and($user->isFollowing($this->user))->toBeTrue() + ->and($user->notifications()->count())->toBe(0); +}); + +it('keeps one mute when a user is muted again', function (): void { + $user = User::factory()->create(); + + $this->user->muting()->attach($user); + + signedApiRequest($this->keyPair, 'POST', route('api-user-mute.store', $user)) + ->assertOk() + ->assertJsonPath('is_muting', true); + + expect($this->user->muting()->count())->toBe(1); +}); + +it('may not mute themselves', function (): void { + signedApiRequest($this->keyPair, 'POST', route('api-user-mute.store', $this->user))->assertForbidden(); + + expect($this->user->muting()->count())->toBe(0); +}); + +it('may unmute a user, and answers that they are not muted', function (): void { + $user = User::factory()->create(); + + $this->user->muting()->attach($user); + + signedApiRequest($this->keyPair, 'DELETE', route('api-user-mute.destroy', $user)) + ->assertOk() + ->assertJsonPath('data.id', $user->id) + ->assertJsonPath('is_muting', false); + + expect($this->user->isMuting($user))->toBeFalse(); +}); + +it('may unmute a user who was not muted', function (): void { + $user = User::factory()->create(); + + signedApiRequest($this->keyPair, 'DELETE', route('api-user-mute.destroy', $user)) + ->assertOk() + ->assertJsonPath('is_muting', false); +}); + +it('returns not found for unknown usernames', function (string $method): void { + signedApiRequest($this->keyPair, $method, 'api/v1/users/missing/mute')->assertNotFound(); +})->with(['POST', 'DELETE']); + +it('lists the people the user mutes, last muted first, as Settings → Safety does', function (): void { + $first = User::factory()->create(); + $second = User::factory()->create(); + $other = User::factory()->create(); + + Date::setTestNow(now()->subHour()); + $this->user->muting()->attach($first); + Date::setTestNow(); + $this->user->muting()->attach($second); + $other->muting()->attach($first); + + signedApiRequest($this->keyPair, 'GET', route('api-user-mute.index')) + ->assertOk() + ->assertJsonPath('data.*.id', [$second->id, $first->id]) + ->assertJsonPath('data.0.username', $second->username) + ->assertJsonPath('data.0.name', $second->name) + ->assertJsonPath('data.0.avatar_url', $second->avatar_url) + ->assertJsonPath('data.0.lane', $second->lane()) + ->assertJsonMissingPath('data.0.pivot') + ->assertJsonPath('meta.next_cursor', null); +}); + +it('pages through the people the user mutes, 20 at a time, even when they were muted at once', function (): void { + $muted = User::factory()->count(25)->create(); + + $this->user->muting()->attach($muted->pluck('id')); + + $expected = $muted->sortByDesc('id')->pluck('id')->values(); + + $first = signedApiRequest($this->keyPair, 'GET', route('api-user-mute.index')) + ->assertOk() + ->assertJsonCount(20, 'data') + ->assertJsonPath('data.*.id', $expected->take(20)->all()); + + signedApiRequest($this->keyPair, 'GET', route('api-user-mute.index', ['cursor' => $first->json('meta.next_cursor')])) + ->assertOk() + ->assertJsonPath('data.*.id', $expected->slice(20)->values()->all()) + ->assertJsonPath('meta.next_cursor', null); +}); + +it('lists no one when the user mutes no one', function (): void { + signedApiRequest($this->keyPair, 'GET', route('api-user-mute.index')) + ->assertOk() + ->assertJsonPath('data', []); +}); + +it('rejects requests that are not signed', function (string $method, string $route): void { + $user = User::factory()->create(); + + $this->json($method, route($route, $route === 'api-user-mute.index' ? [] : [$user]))->assertUnauthorized(); + + expect($user->mutedBy()->count())->toBe(0); +})->with([ + ['GET', 'api-user-mute.index'], + ['POST', 'api-user-mute.store'], + ['DELETE', 'api-user-mute.destroy'], +]); diff --git a/tests/Feature/Controllers/DeviceControllerTest.php b/tests/Feature/Controllers/DeviceControllerTest.php new file mode 100644 index 000000000..866e7f444 --- /dev/null +++ b/tests/Feature/Controllers/DeviceControllerTest.php @@ -0,0 +1,79 @@ +create(); + + $older = Device::factory()->for($user)->create([ + 'name' => 'devnull on iPad', + 'approved_at' => now()->subWeek(), + 'last_used_at' => now()->subDay(), + ]); + $newer = Device::factory()->for($user)->create(['name' => 'devnull on iPhone']); + + Device::factory()->create(); + + $response = $this->actingAs($user)->get(route('device.index')); + + $response->assertOk() + ->assertInertia(fn ($page) => $page + ->component('device/index') + ->has('devices', 2) + ->where('devices.0', [ + 'id' => $newer->id, + 'fingerprint' => $newer->fingerprint, + 'name' => 'devnull on iPhone', + 'approved_at' => now()->startOfSecond()->toJSON(), + 'last_used_at' => null, + ]) + ->where('devices.1.id', $older->id) + ->where('devices.1.last_used_at', now()->subDay()->startOfSecond()->toJSON())); +}); + +it('lists no devices', function (): void { + $response = $this->actingAs(User::factory()->create())->get(route('device.index')); + + $response->assertOk() + ->assertInertia(fn ($page) => $page + ->component('device/index') + ->has('devices', 0)); +}); + +it('revokes a device', function (): void { + $user = User::factory()->create(); + $device = Device::factory()->for($user)->create(); + + $response = $this->actingAs($user) + ->fromRoute('device.index') + ->delete(route('device.destroy', $device)); + + $response->assertRedirectToRoute('device.index'); + + expect($device->fresh())->toBeNull(); +}); + +it("never revokes someone else's device", function (): void { + $device = Device::factory()->create(); + + $response = $this->actingAs(User::factory()->create()) + ->delete(route('device.destroy', $device)); + + $response->assertNotFound(); + + expect($device->fresh())->not->toBeNull(); +}); + +it('requires sign in', function (string $method, Closure $uri): void { + $device = Device::factory()->create(); + + $this->{$method}($uri($device))->assertRedirectToRoute('home'); + + expect($device->fresh())->not->toBeNull(); +})->with([ + 'the list' => ['get', fn (Device $device): string => route('device.index')], + 'revoking' => ['delete', fn (Device $device): string => route('device.destroy', $device)], +]); diff --git a/tests/Feature/Controllers/MobileAuthorizationControllerTest.php b/tests/Feature/Controllers/MobileAuthorizationControllerTest.php new file mode 100644 index 000000000..55b91ab28 --- /dev/null +++ b/tests/Feature/Controllers/MobileAuthorizationControllerTest.php @@ -0,0 +1,144 @@ +keyPair = SshKeyPair::generate(); + $this->parameters = [ + 'fingerprint' => $this->keyPair->publicKey->fingerprint(), + 'device_name' => 'devnull on iPhone', + 'code_challenge' => AuthorizationCode::challengeFor(str_repeat('v', 43)), + 'code_challenge_method' => 'S256', + 'state' => 'af0ifjsldkj', + 'redirect_uri' => 'devnull://auth/callback', + ]; +}); + +it('asks the signed-in user to approve the app on the device', function (): void { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->get(route('mobile-authorization.show', $this->parameters)); + + $response->assertOk() + ->assertInertia(fn (Assert $page): Assert => $page + ->component('mobile-authorization/show') + ->where('authorization', $this->parameters) + ->where('cancelUrl', 'devnull://auth/callback?error=access_denied&state=af0ifjsldkj') + ->where('csrfToken', session()->token()) + ->where('auth.user.id', $user->id)); +}); + +it('sends a guest through the github sign-in, then back to approve the app', function (): void { + Queue::fake(); + fakeGitHubSponsorsListing(); + + $this->get(route('mobile-authorization.show', $this->parameters))->assertRedirectToRoute('home'); + + $user = User::factory()->create(['github_id' => '583231']); + + Socialite::fake('github', GitHubUser::fake([ + 'id' => '583231', + 'nickname' => $user->username, + ])); + + $location = (string) $this->get(route('login.store'))->assertRedirect()->headers->get('Location'); + parse_str((string) parse_url($location, PHP_URL_QUERY), $query); + + expect(strtok($location, '?'))->toBe(route('mobile-authorization.show')) + ->and(collect($query)->sortKeys()->all())->toBe(collect($this->parameters)->sortKeys()->all()); + + $this->assertAuthenticatedAs($user); +}); + +it('answers a request the app did not make right with a plain error page, and never a redirect', function (array $parameters, string $error): void { + $response = $this->actingAs(User::factory()->create()) + ->get(route('mobile-authorization.show', [...$this->parameters, ...$parameters])); + + $response->assertBadRequest() + ->assertInertia(fn (Assert $page): Assert => $page + ->component('mobile-authorization/show') + ->where('error', $error) + ->missing('authorization') + ->missing('cancelUrl')); +})->with([ + 'no fingerprint' => [['fingerprint' => null], 'The fingerprint field is required.'], + 'a malformed fingerprint' => [['fingerprint' => 'MD5:aa:bb'], 'The fingerprint field format is invalid.'], + 'no device name' => [['device_name' => null], 'The device name field is required.'], + 'a long device name' => [['device_name' => str_repeat('a', 101)], 'The device name field must not be greater than 100 characters.'], + 'no challenge' => [['code_challenge' => null], 'The code challenge field is required.'], + 'a malformed challenge' => [['code_challenge' => 'short'], 'The code challenge field format is invalid.'], + 'a plain challenge' => [['code_challenge_method' => 'plain'], 'The selected code challenge method is invalid.'], + 'no state' => [['state' => null], 'The state field is required.'], + 'no redirect' => [['redirect_uri' => null], 'The redirect uri field is required.'], + 'a redirect to a website' => [['redirect_uri' => 'https://evil.example/callback'], 'The redirect uri field must start with one of the following: devnull://.'], + 'a redirect to another app' => [['redirect_uri' => 'otherapp://auth/callback'], 'The redirect uri field must start with one of the following: devnull://.'], + 'a redirect with a fragment' => [['redirect_uri' => 'devnull://auth/callback#x'], 'The redirect uri field format is invalid.'], +]); + +it('sends the app back with a one-time code and its state once the user approves it', function (): void { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post(route('mobile-authorization.store'), $this->parameters); + + $response->assertStatus(303); + + $location = (string) $response->headers->get('Location'); + parse_str((string) parse_url($location, PHP_URL_QUERY), $query); + + expect($location)->toStartWith('devnull://auth/callback?code=') + ->and($query['state'])->toBe('af0ifjsldkj') + ->and($query['code'])->toBeString(); + + $code = AuthorizationCode::query()->sole(); + + expect($code->code_hash)->toBe(AuthorizationCode::hash($query['code'])) + ->and($code->user_id)->toBe($user->id) + ->and($code->fingerprint)->toBe($this->keyPair->publicKey->fingerprint()) + ->and($code->device_name)->toBe('devnull on iPhone') + ->and($code->code_challenge)->toBe($this->parameters['code_challenge']) + ->and($code->redirect_uri)->toBe('devnull://auth/callback'); +}); + +it('keeps the query a redirect already has', function (): void { + $response = $this->actingAs(User::factory()->create()) + ->post(route('mobile-authorization.store'), [...$this->parameters, 'redirect_uri' => 'devnull://auth/callback?app=1']); + + expect((string) $response->headers->get('Location'))->toStartWith('devnull://auth/callback?app=1&code=') + ->toEndWith('&state=af0ifjsldkj'); +}); + +it('approves nothing the app did not ask for right', function (): void { + $response = $this->actingAs(User::factory()->create()) + ->post(route('mobile-authorization.store'), [...$this->parameters, 'redirect_uri' => 'https://evil.example/callback']); + + $response->assertBadRequest() + ->assertInertia(fn (Assert $page): Assert => $page->where('error', 'The redirect uri field must start with one of the following: devnull://.')); + + expect(AuthorizationCode::query()->exists())->toBeFalse(); +}); + +it('approves only for a signed-in user, with a csrf token', function (): void { + $this->post(route('mobile-authorization.store'), $this->parameters)->assertRedirectToRoute('home'); + + expect(AuthorizationCode::query()->exists())->toBeFalse(); +}); + +it('lets each user approve the app 10 times a minute', function (): void { + $user = User::factory()->create(); + + foreach (range(1, 10) as $attempt) { + $this->actingAs($user)->post(route('mobile-authorization.store'), $this->parameters)->assertStatus(303); + } + + $this->actingAs($user)->post(route('mobile-authorization.store'), $this->parameters)->assertTooManyRequests(); + + expect(AuthorizationCode::query()->count())->toBe(10); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 3a7529d2c..7a3ba43bb 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -4,6 +4,7 @@ use App\Dto\SshPublicKey; use App\Jobs\UnfurlLink; +use App\Models\Device; use App\Models\User; use App\Services\OpenAI; use Carbon\CarbonInterface; @@ -13,6 +14,7 @@ use Illuminate\Support\Facades\Process; use Illuminate\Support\Sleep; use Illuminate\Support\Str; +use Illuminate\Testing\TestResponse; use Tests\Support\SshKeyPair; use Tests\TestCase; @@ -227,3 +229,50 @@ function queuedJob(string $queue, CarbonInterface $createdAt): void 'created_at' => $createdAt->getTimestamp(), ]); } + +/** + * Signs an API request the way the mobile app does, and returns its headers as server variables. + * + * @return array + */ +function signApiRequest(SshKeyPair $keyPair, string $method, string $uri, string $body = '', ?int $timestamp = null, ?string $nonce = null, string $namespace = 'devnull-api'): array +{ + $timestamp ??= now()->getTimestamp(); + $nonce ??= bin2hex(random_bytes(16)); + + $query = parse_url(url($uri), PHP_URL_QUERY); + $target = parse_url(url($uri), PHP_URL_PATH).(is_string($query) ? "?{$query}" : ''); + + return [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_ACCEPT' => 'application/json', + 'HTTP_X_DEVNULL_TIMESTAMP' => (string) $timestamp, + 'HTTP_X_DEVNULL_NONCE' => $nonce, + 'HTTP_X_SSH_SIGNATURE' => $keyPair->sign(implode("\n", [$timestamp, $nonce, mb_strtoupper($method), $target, $body]), $namespace), + ]; +} + +/** + * Sends an API request signed the way the mobile app does, with the data as its JSON body. + * + * @param array|null $data + */ +function signedApiRequest(SshKeyPair $keyPair, string $method, string $uri, ?array $data = null, ?int $timestamp = null, ?string $nonce = null, string $namespace = 'devnull-api'): TestResponse +{ + $body = $data === null ? '' : json_encode($data, JSON_THROW_ON_ERROR); + + return test()->call($method, $uri, server: signApiRequest($keyPair, $method, $uri, $body, $timestamp, $nonce, $namespace), content: $body); +} + +/** + * Gives the user a key approved as a device, that the mobile app signs API requests with. It isn't on their GitHub + * account, as the API doesn't need it to be. + */ +function apiKeyPairFor(User $user): SshKeyPair +{ + $keyPair = SshKeyPair::generate(); + + Device::factory()->for($user)->withPublicKey($keyPair->publicKey)->create(); + + return $keyPair; +} diff --git a/tests/Unit/Actions/CreateAuthorizationCodeTest.php b/tests/Unit/Actions/CreateAuthorizationCodeTest.php new file mode 100644 index 000000000..7fc6e452b --- /dev/null +++ b/tests/Unit/Actions/CreateAuthorizationCodeTest.php @@ -0,0 +1,38 @@ +create(); + $authorization = new MobileAuthorization('SHA256:key', 'devnull on iPhone', 'challenge', 'state', 'devnull://auth/callback'); + + $code = resolve(CreateAuthorizationCode::class)->handle($user, $authorization); + + $stored = AuthorizationCode::query()->sole(); + + expect($code)->toMatch('/^[0-9a-f]{64}$/') + ->and($stored->code_hash)->toBe(AuthorizationCode::hash($code)) + ->and($stored->code_hash)->not->toBe($code) + ->and($stored->user_id)->toBe($user->id) + ->and($stored->fingerprint)->toBe('SHA256:key') + ->and($stored->device_name)->toBe('devnull on iPhone') + ->and($stored->code_challenge)->toBe('challenge') + ->and($stored->redirect_uri)->toBe('devnull://auth/callback') + ->and($stored->expires_at->toDateTimeString())->toBe(now()->addMinutes(5)->toDateTimeString()); +}); + +it('hands out a different code every time', function (): void { + $user = User::factory()->create(); + $authorization = new MobileAuthorization('SHA256:key', 'devnull on iPhone', 'challenge', 'state', 'devnull://auth/callback'); + + $first = resolve(CreateAuthorizationCode::class)->handle($user, $authorization); + $second = resolve(CreateAuthorizationCode::class)->handle($user, $authorization); + + expect($first)->not->toBe($second) + ->and(AuthorizationCode::query()->count())->toBe(2); +}); diff --git a/tests/Unit/Actions/CreateDeviceTest.php b/tests/Unit/Actions/CreateDeviceTest.php new file mode 100644 index 000000000..59557e756 --- /dev/null +++ b/tests/Unit/Actions/CreateDeviceTest.php @@ -0,0 +1,49 @@ +create(); + $code = AuthorizationCode::factory()->for($user)->create(['fingerprint' => 'SHA256:key', 'device_name' => 'devnull on iPhone']); + + $device = resolve(CreateDevice::class)->handle($code); + + expect($device?->refresh()->user_id)->toBe($user->id) + ->and($device?->fingerprint)->toBe('SHA256:key') + ->and($device?->name)->toBe('devnull on iPhone') + ->and($device?->approved_at->toDateTimeString())->toBe(now()->toDateTimeString()) + ->and($device?->last_used_at)->toBeNull() + ->and($code->fresh())->toBeNull(); +}); + +it('moves a key approved by someone else to the user who approved it now', function (): void { + $user = User::factory()->create(); + $previous = Device::factory()->create(['fingerprint' => 'SHA256:key', 'last_used_at' => now()->subDay(), 'approved_at' => now()->subWeek()]); + $code = AuthorizationCode::factory()->for($user)->create(['fingerprint' => 'SHA256:key', 'device_name' => 'devnull on Pixel']); + + $device = resolve(CreateDevice::class)->handle($code); + + expect($device?->is($previous))->toBeTrue() + ->and($device?->refresh()->user_id)->toBe($user->id) + ->and($device?->name)->toBe('devnull on Pixel') + ->and($device?->approved_at->toDateTimeString())->toBe(now()->toDateTimeString()) + ->and($device?->last_used_at)->toBeNull() + ->and(Device::query()->count())->toBe(1); +}); + +it('approves nothing for a code that was used or expired since it was checked', function (Closure $change): void { + $code = AuthorizationCode::factory()->create(); + + $change($code); + + expect(resolve(CreateDevice::class)->handle($code))->toBeNull() + ->and(Device::query()->exists())->toBeFalse(); +})->with([ + 'used' => fn (AuthorizationCode $code) => $code->delete(), + 'expired' => fn () => test()->travel(AuthorizationCode::EXPIRES_AFTER_MINUTES)->minutes(), +]); diff --git a/tests/Unit/Actions/DeleteDeviceTest.php b/tests/Unit/Actions/DeleteDeviceTest.php new file mode 100644 index 000000000..557934008 --- /dev/null +++ b/tests/Unit/Actions/DeleteDeviceTest.php @@ -0,0 +1,16 @@ +create(); + $other = Device::factory()->for($device->user)->create(); + + resolve(DeleteDevice::class)->handle($device); + + expect($device->fresh())->toBeNull() + ->and($other->fresh())->not->toBeNull(); +}); diff --git a/tests/Unit/Actions/UpdateDeviceLastUsedAtTest.php b/tests/Unit/Actions/UpdateDeviceLastUsedAtTest.php new file mode 100644 index 000000000..dbb40b22c --- /dev/null +++ b/tests/Unit/Actions/UpdateDeviceLastUsedAtTest.php @@ -0,0 +1,32 @@ +create(); + $action = resolve(UpdateDeviceLastUsedAt::class); + + $action->handle($device); + + expect($device->refresh()->last_used_at?->toDateTimeString())->toBe(now()->toDateTimeString()); + + $usedAt = now(); + $this->travel(59)->seconds(); + + DB::enableQueryLog(); + + $action->handle($device); + + expect(DB::getQueryLog())->toBeEmpty() + ->and($device->refresh()->last_used_at?->toDateTimeString())->toBe($usedAt->toDateTimeString()); + + $this->travel(1)->second(); + + $action->handle($device); + + expect($device->refresh()->last_used_at?->toDateTimeString())->toBe(now()->toDateTimeString()); +}); diff --git a/tests/Unit/Models/AuthorizationCodeTest.php b/tests/Unit/Models/AuthorizationCodeTest.php new file mode 100644 index 000000000..76a26ad4a --- /dev/null +++ b/tests/Unit/Models/AuthorizationCodeTest.php @@ -0,0 +1,41 @@ +create()->refresh(); + + expect(array_keys($code->toArray())) + ->toBe([ + 'id', + 'user_id', + 'fingerprint', + 'device_name', + 'code_challenge', + 'redirect_uri', + 'expires_at', + 'created_at', + 'updated_at', + ]); +}); + +it('belongs to a user, and is deleted with them', function (): void { + $user = User::factory()->create(); + $code = AuthorizationCode::factory()->for($user)->create(); + + expect($code->user->is($user))->toBeTrue() + ->and($user->authorizationCodes()->sole()->is($code))->toBeTrue(); + + $user->delete(); + + expect($code->fresh())->toBeNull(); +}); + +it('makes the pkce s256 challenge of a verifier', function (): void { + // The example in RFC 7636, appendix B... + expect(AuthorizationCode::challengeFor('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk')) + ->toBe('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'); +}); diff --git a/tests/Unit/Models/DeviceTest.php b/tests/Unit/Models/DeviceTest.php new file mode 100644 index 000000000..d85678405 --- /dev/null +++ b/tests/Unit/Models/DeviceTest.php @@ -0,0 +1,34 @@ +create()->refresh(); + + expect(array_keys($device->toArray())) + ->toBe([ + 'id', + 'fingerprint', + 'name', + 'approved_at', + 'last_used_at', + ]); +}); + +it('belongs to a user', function (): void { + $user = User::factory()->create(); + $device = Device::factory()->for($user)->create(); + + expect($device->user->is($user))->toBeTrue(); +}); + +it('is deleted with its user', function (): void { + $device = Device::factory()->create(); + + $device->user->delete(); + + expect($device->fresh())->toBeNull(); +}); diff --git a/tests/Unit/Models/UserTest.php b/tests/Unit/Models/UserTest.php index 279797756..629956d89 100644 --- a/tests/Unit/Models/UserTest.php +++ b/tests/Unit/Models/UserTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Models\Commit; +use App\Models\Device; use App\Models\SshKey; use App\Models\User; use Illuminate\Support\Facades\DB; @@ -49,6 +50,14 @@ expect($user->sshKeys)->toHaveCount(2); }); +it('has devices', function (): void { + $user = User::factory()->create(); + Device::factory()->for($user)->count(2)->create(); + Device::factory()->create(); + + expect($user->devices)->toHaveCount(2); +}); + it('has following and followers', function (): void { $user = User::factory()->create(); $followed = User::factory()->create(); diff --git a/tests/Unit/Providers/AppServiceProviderTest.php b/tests/Unit/Providers/AppServiceProviderTest.php index 9ec33afff..5ec13c390 100644 --- a/tests/Unit/Providers/AppServiceProviderTest.php +++ b/tests/Unit/Providers/AppServiceProviderTest.php @@ -66,3 +66,54 @@ ->and($limit->maxAttempts)->toBe(60) ->and($limit->decaySeconds)->toBe(60); }); + +it('limits each signed-in user to 120 api requests a minute, whatever their address', function (): void { + $user = User::factory()->create(); + + $request = Request::create('/api/v1/users/octocat', server: ['REMOTE_ADDR' => '203.0.113.7']); + $request->setUserResolver(fn (): User => $user); + + $limiter = RateLimiter::limiter('api'); + + assert($limiter instanceof Closure); + + $limit = $limiter($request); + + expect($limit)->toBeInstanceOf(Limit::class) + ->and($limit->key)->toBe($user->id) + ->and($limit->maxAttempts)->toBe(120) + ->and($limit->decaySeconds)->toBe(60); +}); + +it('limits each address to 60 device redemptions a minute, before their signatures are checked', function (): void { + $request = Request::create('/api/v1/devices', 'POST', server: ['REMOTE_ADDR' => '203.0.113.7']); + + $limiter = RateLimiter::limiter('devices'); + + assert($limiter instanceof Closure); + + $limit = $limiter($request); + + expect($limit)->toBeInstanceOf(Limit::class) + ->and($limit->key)->toBe('203.0.113.7') + ->and($limit->maxAttempts)->toBe(60) + ->and($limit->decaySeconds)->toBe(60); +}); + +it('limits each user to 10 approvals of the devnull app a minute', function (): void { + $user = User::factory()->create(); + + $request = Request::create('/mobile/authorize', 'POST'); + $request->setUserResolver(fn (): User => $user); + + $limiter = RateLimiter::limiter('mobile-authorizations'); + + assert($limiter instanceof Closure); + + $limit = $limiter($request); + + expect($limit)->toBeInstanceOf(Limit::class) + ->and($limit->key)->toBe($user->id) + ->and($limit->maxAttempts)->toBe(10) + ->and($limit->decaySeconds)->toBe(60); +}); diff --git a/tests/Unit/Queries/CommitPageQueryTest.php b/tests/Unit/Queries/CommitPageQueryTest.php new file mode 100644 index 000000000..ee625f5d8 --- /dev/null +++ b/tests/Unit/Queries/CommitPageQueryTest.php @@ -0,0 +1,62 @@ +create(); + $commit = Commit::factory()->create(); + Commit::factory()->for($commit, 'parent')->create(); + Commit::factory()->for($commit, 'parent')->pending()->create(); + $commit->stars()->attach([$viewer->id, User::factory()->create()->id]); + Bookmark::factory()->create(['user_id' => $viewer->id, 'commit_id' => $commit->id]); + + $loaded = new CommitPageQuery()->get($commit, $viewer); + + expect($loaded->replies_count)->toBe(1) + ->and($loaded->stars_count)->toBe(2) + ->and($loaded->starred)->toBeTrue() + ->and($loaded->is_saved)->toBeTrue() + ->and($loaded->relationLoaded('user'))->toBeTrue() + ->and($loaded->relationLoaded('linkPreview'))->toBeTrue() + ->and($loaded->relationLoaded('images'))->toBeTrue(); +}); + +it('only counts the stars and saves of the viewer as theirs', function (): void { + $commit = Commit::factory()->create(); + $commit->stars()->attach(User::factory()->create()); + Bookmark::factory()->create(['commit_id' => $commit->id]); + + $loaded = new CommitPageQuery()->get($commit, User::factory()->create()); + + expect($loaded->stars_count)->toBe(1) + ->and($loaded->starred)->toBeFalse() + ->and($loaded->is_saved)->toBeFalse(); +}); + +it('loads the commit for a guest, who starred and saved nothing', function (): void { + $commit = Commit::factory()->create(); + Commit::factory()->for($commit, 'parent')->create(); + Commit::factory()->for($commit, 'parent')->pending()->create(); + $commit->stars()->attach(User::factory()->create()); + Bookmark::factory()->create(['commit_id' => $commit->id]); + + $loaded = new CommitPageQuery()->get($commit, null); + + expect($loaded->replies_count)->toBe(1) + ->and($loaded->stars_count)->toBe(1) + ->and($loaded->starred)->toBeFalse() + ->and($loaded->is_saved)->toBeFalse(); +}); + +it('does not find a deleted commit', function (): void { + $commit = Commit::factory()->create(); + $commit->delete(); + + new CommitPageQuery()->get($commit, User::factory()->create()); +})->throws(ModelNotFoundException::class); diff --git a/tests/Unit/Queries/PinnedCommitQueryTest.php b/tests/Unit/Queries/PinnedCommitQueryTest.php new file mode 100644 index 000000000..deaddc349 --- /dev/null +++ b/tests/Unit/Queries/PinnedCommitQueryTest.php @@ -0,0 +1,30 @@ +create(); + $pinned = Commit::factory()->for($user)->pending()->create(); + $user->update(['pinned_commit_id' => $pinned->id]); + + expect(resolve(PinnedCommitQuery::class)->get($user, $user)?->id)->toBe($pinned->id) + ->and(resolve(PinnedCommitQuery::class)->get($user, User::factory()->create()))->toBeNull() + ->and(resolve(PinnedCommitQuery::class)->get(User::factory()->create(), $user))->toBeNull(); +}); + +it('only finds an approved pinned commit for a guest', function (): void { + $user = User::factory()->create(); + $pinned = Commit::factory()->for($user)->create(); + $user->update(['pinned_commit_id' => $pinned->id]); + + $author = User::factory()->create(); + $pendingPinned = Commit::factory()->for($author)->pending()->create(); + $author->update(['pinned_commit_id' => $pendingPinned->id]); + + expect(resolve(PinnedCommitQuery::class)->get($user, null)?->id)->toBe($pinned->id) + ->and(resolve(PinnedCommitQuery::class)->get($author, null))->toBeNull(); +}); diff --git a/tests/Unit/Queries/ReplyPreviewsQueryTest.php b/tests/Unit/Queries/ReplyPreviewsQueryTest.php new file mode 100644 index 000000000..4b9583862 --- /dev/null +++ b/tests/Unit/Queries/ReplyPreviewsQueryTest.php @@ -0,0 +1,59 @@ +create(); + $commit = Commit::factory()->create(['created_at' => now()->subMinutes(2)]); + $reply = Commit::factory()->for($commit, 'parent')->create(['created_at' => now()->subMinute()]); + $reply->stars()->attach($viewer); + $quiet = Commit::factory()->create(); + + $commits = Commit::query()->whereKey([$commit->id, $quiet->id])->withVisibleReplyCount($viewer)->withReplyPreviews($viewer)->latest()->get(); + + resolve(ReplyPreviewsQuery::class)->load($commits, $viewer); + + expect($commits[0]->getRelation('replyPreview'))->toHaveCount(0) + ->and($commits[1]->getRelation('replyPreview')->modelKeys())->toBe([$reply->id]) + ->and($commits[1]->getRelation('replyPreview')->sole()->starred)->toBeTrue() + ->and($commits[1]->getRelation('replyPreview')->sole()->relationLoaded('user'))->toBeTrue(); +}); + +it('leaves commits without a reply preview alone', function (): void { + $commit = Commit::factory()->create(); + Commit::factory()->for($commit, 'parent')->create(); + + $commits = Commit::query()->whereKey($commit->id)->get(); + + resolve(ReplyPreviewsQuery::class)->load($commits, null); + + expect($commits->sole()->relationLoaded('replyPreview'))->toBeFalse(); +}); + +it('loads every reply preview in one query, however many commits there are', function (): void { + $queries = function (): int { + $commits = Commit::query()->topLevel()->withVisibleReplyCount(null)->withReplyPreviews(null)->get(); + + DB::flushQueryLog(); + DB::enableQueryLog(); + + resolve(ReplyPreviewsQuery::class)->load($commits, null); + + return count(DB::getQueryLog()); + }; + + $parents = Commit::factory()->count(2)->create(); + $parents->each(fn (Commit $parent) => Commit::factory()->for($parent, 'parent')->create()); + + $few = $queries(); + + $parents = Commit::factory()->count(6)->create(); + $parents->each(fn (Commit $parent) => Commit::factory()->for($parent, 'parent')->create()); + + expect($queries())->toBe($few); +}); diff --git a/tests/Unit/Queries/UserCommitsQueryTest.php b/tests/Unit/Queries/UserCommitsQueryTest.php new file mode 100644 index 000000000..ef584cff5 --- /dev/null +++ b/tests/Unit/Queries/UserCommitsQueryTest.php @@ -0,0 +1,60 @@ +create(); + + $older = Commit::factory()->for($user)->create(['created_at' => now()->subMinute()]); + $newer = Commit::factory()->for($user)->create(); + Commit::factory()->for($user)->for($older, 'parent')->create(); + Commit::factory()->create(); + + $commits = resolve(UserCommitsQuery::class)->builder($user, User::factory()->create())->get(); + + expect($commits->modelKeys())->toBe([$newer->id, $older->id]) + ->and($commits->first()->relationLoaded('user'))->toBeTrue() + ->and($commits->last()->replies_count)->toBe(1); +}); + +it('only includes the commits the viewer may see', function (): void { + $user = User::factory()->create(); + + $approved = Commit::factory()->for($user)->create(['created_at' => now()->subMinute()]); + $pending = Commit::factory()->for($user)->pending()->create(); + Commit::factory()->for($user)->rejected()->create(); + + expect(resolve(UserCommitsQuery::class)->builder($user, User::factory()->create())->get()->modelKeys())->toBe([$approved->id]) + ->and(resolve(UserCommitsQuery::class)->builder($user, $user)->get()->modelKeys())->toHaveCount(3)->toContain($pending->id) + ->and(resolve(UserCommitsQuery::class)->builder($user, null)->get()->modelKeys())->toBe([$approved->id]); +}); + +it('leaves the pinned commit out of the commits tab', function (): void { + $user = User::factory()->create(); + $pinned = Commit::factory()->for($user)->create(); + $other = Commit::factory()->for($user)->create(); + $user->update(['pinned_commit_id' => $pinned->id]); + + expect(resolve(UserCommitsQuery::class)->builder($user, $user)->get()->modelKeys())->toBe([$other->id]); +}); + +it('includes the replies of the user with the parents the viewer may see', function (): void { + $user = User::factory()->create(); + $viewer = User::factory()->create(); + $parent = Commit::factory()->create(); + $hiddenParent = Commit::factory()->pending()->create(); + $reply = Commit::factory()->for($user)->for($parent, 'parent')->create(['created_at' => now()->subMinute()]); + $replyToHidden = Commit::factory()->for($user)->for($hiddenParent, 'parent')->create(); + Commit::factory()->for($user)->create(); + + $replies = resolve(UserCommitsQuery::class)->builder($user, $viewer, replies: true)->get(); + + expect($replies->modelKeys())->toBe([$replyToHidden->id, $reply->id]) + ->and($replies->first()->parent)->toBeNull() + ->and($replies->last()->parent?->id)->toBe($parent->id) + ->and($replies->last()->parent?->relationLoaded('user'))->toBeTrue(); +}); diff --git a/tests/Unit/Services/MentionsTest.php b/tests/Unit/Services/MentionsTest.php index b74202092..f9648dd51 100644 --- a/tests/Unit/Services/MentionsTest.php +++ b/tests/Unit/Services/MentionsTest.php @@ -52,3 +52,37 @@ expect($mentions->usernames("thanks @taylorotwell for `@Override`\n```java\n@Deprecated\nvoid run() {}\n```")) ->toBe(['taylorotwell']); }); + +it('finds where every mention is, keeping its case', function (): void { + $mentions = resolve(Mentions::class); + + expect($mentions->positions('@NunoMaduro and @taylorotwell, thanks! cc @nunomaduro'))->toBe([ + ['username' => 'NunoMaduro', 'offset' => 0, 'length' => 11], + ['username' => 'taylorotwell', 'offset' => 16, 'length' => 13], + ['username' => 'nunomaduro', 'offset' => 42, 'length' => 11], + ]); +}); + +it('counts offsets in characters, not bytes', function (): void { + $mentions = resolve(Mentions::class); + + expect($mentions->positions('Café 🎉 with @nuno.'))->toBe([ + ['username' => 'nuno', 'offset' => 12, 'length' => 5], + ]); +}); + +it('finds no positions for what only looks like a mention', function (): void { + $mentions = resolve(Mentions::class); + + expect($mentions->positions('Mail nuno@laravel.com, npm i @inertiajs/react, try @vue.js'))->toBe([]); +}); + +it('skips mentions in inline code and code blocks when finding where they are', function (): void { + $mentions = resolve(Mentions::class); + + expect($mentions->positions("thanks @taylorotwell for `@Override`, @nuno\n```java\n@Deprecated\nvoid run() {}\n```\ncc @jess-archer"))->toBe([ + ['username' => 'taylorotwell', 'offset' => 7, 'length' => 13], + ['username' => 'nuno', 'offset' => 38, 'length' => 5], + ['username' => 'jess-archer', 'offset' => 85, 'length' => 12], + ]); +}); diff --git a/tests/Unit/Services/SignedRequestsTest.php b/tests/Unit/Services/SignedRequestsTest.php new file mode 100644 index 000000000..5f30545b4 --- /dev/null +++ b/tests/Unit/Services/SignedRequestsTest.php @@ -0,0 +1,123 @@ +user = User::factory()->create(); + $this->keyPair = apiKeyPairFor($this->user); +}); + +it('tells a request with any of the signature headers from one without', function (array $headers, bool $isSigned): void { + $request = Request::create('https://devnull.test/api/v1/users/octocat', server: $headers); + + expect(resolve(SignedRequests::class)->isSigned($request))->toBe($isSigned); +})->with([ + 'none' => [[], false], + 'other headers' => [['HTTP_ACCEPT' => 'application/json', 'HTTP_AUTHORIZATION' => 'Bearer token'], false], + 'a timestamp' => [['HTTP_X_DEVNULL_TIMESTAMP' => '1791000000'], true], + 'a nonce' => [['HTTP_X_DEVNULL_NONCE' => str_repeat('0', 32)], true], + 'a signature' => [['HTTP_X_SSH_SIGNATURE' => ''], true], +]); + +it('verifies a signed request', function (string $method, string $uri, string $body): void { + $request = Request::create($uri, $method, server: signApiRequest($this->keyPair, $method, $uri, $body), content: $body); + + ['publicKey' => $publicKey, 'device' => $device] = resolve(SignedRequests::class)->verify($request); + + expect($publicKey->fingerprint())->toBe($this->keyPair->publicKey->fingerprint()) + ->and($device?->is($this->user->devices()->sole()))->toBeTrue() + ->and($device?->user->is($this->user))->toBeTrue() + ->and($device?->last_used_at?->toDateTimeString())->toBe(now()->toDateTimeString()); +})->with([ + 'without a body' => ['GET', 'https://devnull.test/api/v1/log/latest', ''], + 'with a query' => ['GET', 'https://devnull.test/api/v1/log/latest?cursor=abc&x=%20y', ''], + 'with a body' => ['POST', 'https://devnull.test/api/v1/commits', '{"body":"It was DNS.","parent_id":null}'], + 'with a multi-line body' => ['POST', 'https://devnull.test/api/v1/commits', "{\"body\":\"It was DNS.\"}\n\n"], +]); + +it('returns no device for a key nobody approved, on github or not', function (bool $isOnGitHub): void { + $keyPair = SshKeyPair::generate(); + $uri = 'https://devnull.test/api/v1/me'; + + if ($isOnGitHub) { + SshKey::factory()->withPublicKey($keyPair->publicKey)->create(); + } + + $request = Request::create($uri, server: signApiRequest($keyPair, 'GET', $uri)); + + ['publicKey' => $publicKey, 'device' => $device] = resolve(SignedRequests::class)->verify($request); + + expect($publicKey->fingerprint())->toBe($keyPair->publicKey->fingerprint()) + ->and($device)->toBeNull(); +})->with([ + "on nobody's github account" => false, + "on a user's github account" => true, +]); + +it('verifies the signature of a key nobody approved yet, and spends its nonce', function (): void { + $keyPair = SshKeyPair::generate(); + $uri = 'https://devnull.test/api/v1/devices'; + $server = signApiRequest($keyPair, 'POST', $uri, '{"code":"abc"}'); + + $publicKey = resolve(SignedRequests::class)->verifySignature(Request::create($uri, 'POST', server: $server, content: '{"code":"abc"}')); + + expect($publicKey->fingerprint())->toBe($keyPair->publicKey->fingerprint()) + ->and(fn (): mixed => resolve(SignedRequests::class)->verifySignature(Request::create($uri, 'POST', server: $server, content: '{"code":"abc"}'))) + ->toThrow(InvalidArgumentException::class, 'This signature was already used.'); +}); + +it('rejects a request that differs from the one that was signed', function (string $method, string $uri, string $body, array $headers): void { + $server = signApiRequest($this->keyPair, 'POST', 'https://devnull.test/api/v1/commits?a=1', '{"body":"It was DNS."}'); + + $request = Request::create($uri, $method, server: [...$server, ...$headers], content: $body); + + expect(fn (): array => resolve(SignedRequests::class)->verify($request)) + ->toThrow(InvalidArgumentException::class, 'Invalid signature.'); +})->with([ + 'method' => ['PUT', 'https://devnull.test/api/v1/commits?a=1', '{"body":"It was DNS."}', []], + 'path' => ['POST', 'https://devnull.test/api/v1/commitz?a=1', '{"body":"It was DNS."}', []], + 'query' => ['POST', 'https://devnull.test/api/v1/commits?a=2', '{"body":"It was DNS."}', []], + 'missing query' => ['POST', 'https://devnull.test/api/v1/commits', '{"body":"It was DNS."}', []], + 'body' => ['POST', 'https://devnull.test/api/v1/commits?a=1', '{"body":"It was not DNS."}', []], + 'timestamp' => ['POST', 'https://devnull.test/api/v1/commits?a=1', '{"body":"It was DNS."}', ['HTTP_X_DEVNULL_TIMESTAMP' => '1']], + 'nonce' => ['POST', 'https://devnull.test/api/v1/commits?a=1', '{"body":"It was DNS."}', ['HTTP_X_DEVNULL_NONCE' => str_repeat('0', 32)]], +]); + +it('rejects a signature for another namespace', function (): void { + $uri = 'https://devnull.test/api/v1/me'; + + $request = Request::create($uri, server: signApiRequest($this->keyPair, 'GET', $uri, namespace: 'devnull-commit')); + + expect(fn (): array => resolve(SignedRequests::class)->verify($request)) + ->toThrow(InvalidArgumentException::class, 'Invalid signature.'); +}); + +it('rejects a request without a signature', function (): void { + expect(fn (): array => resolve(SignedRequests::class)->verify(Request::create('https://devnull.test/api/v1/me'))) + ->toThrow(InvalidArgumentException::class, 'Malformed SSH signature.'); +}); + +it('rejects an expired signature', function (): void { + $uri = 'https://devnull.test/api/v1/me'; + + $request = Request::create($uri, server: signApiRequest($this->keyPair, 'GET', $uri, timestamp: now()->getTimestamp() - 301)); + + expect(fn (): array => resolve(SignedRequests::class)->verify($request)) + ->toThrow(InvalidArgumentException::class, 'Signature expired. Check that your clock is right.'); +}); + +it('rejects a replayed request', function (): void { + $uri = 'https://devnull.test/api/v1/me'; + $server = signApiRequest($this->keyPair, 'GET', $uri); + + resolve(SignedRequests::class)->verify(Request::create($uri, server: $server)); + + expect(fn (): array => resolve(SignedRequests::class)->verify(Request::create($uri, server: $server))) + ->toThrow(InvalidArgumentException::class, 'This signature was already used.'); +}); diff --git a/tests/Unit/Services/SshSignaturesTest.php b/tests/Unit/Services/SshSignaturesTest.php new file mode 100644 index 000000000..39cd8e470 --- /dev/null +++ b/tests/Unit/Services/SshSignaturesTest.php @@ -0,0 +1,147 @@ +keyPair = SshKeyPair::generate(); + $this->user = User::factory()->create(); + + SshKey::factory()->for($this->user)->withPublicKey($this->keyPair->publicKey)->create(); + + fakeGitHubSshKeys($this->user, [$this->keyPair->publicKey]); +}); + +it('returns the key that signed the message', function (): void { + $keyPair = SshKeyPair::generate(); + + $publicKey = resolve(SshSignatures::class)->verify('It was DNS.', $keyPair->sign('It was DNS.', 'devnull-api'), 'devnull-api'); + + expect($publicKey->fingerprint())->toBe($keyPair->publicKey->fingerprint()); +}); + +it('verifies signatures made by ssh-keygen', function (): void { + $publicKey = resolve(SshSignatures::class)->verify(sshFixture('message'), sshFixture('ed25519.sig'), 'devnull-commit'); + + expect($publicKey->fingerprint())->toBe(SshPublicKey::fromOpenSsh(sshFixture('ed25519.pub'))->fingerprint()); +}); + +it('rejects a signature over another message', function (): void { + $signature = SshKeyPair::generate()->sign('It was DNS.', 'devnull-api'); + + expect(fn (): SshPublicKey => resolve(SshSignatures::class)->verify('It was not DNS.', $signature, 'devnull-api')) + ->toThrow(InvalidArgumentException::class, 'Invalid signature.'); +}); + +it('rejects a signature for another namespace', function (): void { + $signature = SshKeyPair::generate()->sign('It was DNS.', 'devnull-commit'); + + expect(fn (): SshPublicKey => resolve(SshSignatures::class)->verify('It was DNS.', $signature, 'devnull-api')) + ->toThrow(InvalidArgumentException::class, 'Invalid signature.'); +}); + +it('rejects malformed signatures', function (): void { + expect(fn (): SshPublicKey => resolve(SshSignatures::class)->verify('It was DNS.', 'not a signature', 'devnull-api')) + ->toThrow(InvalidArgumentException::class, 'Malformed SSH signature.'); +}); + +it('finds the user whose github account has the key', function (): void { + $user = resolve(SshSignatures::class)->authenticate($this->keyPair->publicKey, (string) now()->getTimestamp(), bin2hex(random_bytes(16))); + + expect($user?->is($this->user))->toBeTrue(); +}); + +it('accepts small clock differences', function (int $offset): void { + $user = resolve(SshSignatures::class)->authenticate($this->keyPair->publicKey, (string) (now()->getTimestamp() + $offset), bin2hex(random_bytes(16))); + + expect($user?->is($this->user))->toBeTrue(); +})->with([-300, 300]); + +it('rejects old, future or non-numeric timestamps', function (string $timestamp): void { + expect(fn (): ?User => resolve(SshSignatures::class)->authenticate($this->keyPair->publicKey, $timestamp, bin2hex(random_bytes(16)))) + ->toThrow(InvalidArgumentException::class, 'Signature expired. Check that your clock is right.'); +})->with([ + 'old' => fn (): string => (string) (now()->getTimestamp() - 301), + 'future' => fn (): string => (string) (now()->getTimestamp() + 301), + 'non-numeric' => 'soon', + 'missing' => '', +]); + +it('rejects malformed nonces', function (string $nonce): void { + expect(fn (): ?User => resolve(SshSignatures::class)->authenticate($this->keyPair->publicKey, (string) now()->getTimestamp(), $nonce)) + ->toThrow(InvalidArgumentException::class, 'Malformed payload.'); +})->with([ + 'too short' => 'abc123', + 'uppercase' => mb_strtoupper(str_repeat('ab', 16)), + 'missing' => '', +]); + +it('spends the nonce once it finds the user', function (): void { + $nonce = bin2hex(random_bytes(16)); + $sshSignatures = resolve(SshSignatures::class); + + $sshSignatures->authenticate($this->keyPair->publicKey, (string) now()->getTimestamp(), $nonce); + + expect(fn (): ?User => $sshSignatures->authenticate($this->keyPair->publicKey, (string) now()->getTimestamp(), $nonce)) + ->toThrow(InvalidArgumentException::class, 'This signature was already used.'); +}); + +it('returns null for keys that are not on the github account of a user, without spending the nonce', function (): void { + $keyPair = SshKeyPair::generate(); + $nonce = bin2hex(random_bytes(16)); + $sshSignatures = resolve(SshSignatures::class); + + expect($sshSignatures->authenticate($keyPair->publicKey, (string) now()->getTimestamp(), $nonce))->toBeNull(); + + $user = User::factory()->create(); + SshKey::factory()->for($user)->withPublicKey($keyPair->publicKey)->create(); + fakeGitHubSshKeys($user, [$keyPair->publicKey]); + + expect($sshSignatures->authenticate($keyPair->publicKey, (string) now()->getTimestamp(), $nonce)?->is($user))->toBeTrue(); +}); + +it('returns null for keys removed from github since the last sync', function (): void { + $keyPair = SshKeyPair::generate(); + $user = User::factory()->create(); + $sshKey = SshKey::factory()->for($user)->withPublicKey($keyPair->publicKey)->create(); + + fakeGitHubSshKeys($user); + + expect(resolve(SshSignatures::class)->authenticate($keyPair->publicKey, (string) now()->getTimestamp(), bin2hex(random_bytes(16))))->toBeNull() + ->and($sshKey->fresh())->toBeNull(); +}); + +it('checks github at most every 5 minutes', function (): void { + $sshSignatures = resolve(SshSignatures::class); + + $sshSignatures->authenticate($this->keyPair->publicKey, (string) now()->getTimestamp(), bin2hex(random_bytes(16))); + $sshSignatures->authenticate($this->keyPair->publicKey, (string) now()->getTimestamp(), bin2hex(random_bytes(16))); + + Http::assertSentCount(3); +}); + +it('checks the freshness of a signature whoever signed it, and spends its nonce', function (): void { + $nonce = bin2hex(random_bytes(16)); + $sshSignatures = resolve(SshSignatures::class); + + $sshSignatures->checkFreshness((string) now()->getTimestamp(), $nonce); + + expect(fn () => $sshSignatures->checkFreshness((string) now()->getTimestamp(), $nonce)) + ->toThrow(InvalidArgumentException::class, 'This signature was already used.'); + + Http::assertNothingSent(); +}); + +it('rejects a stale timestamp or a malformed nonce when checking freshness', function (string $timestamp, string $nonce, string $message): void { + expect(fn () => resolve(SshSignatures::class)->checkFreshness($timestamp, $nonce)) + ->toThrow(InvalidArgumentException::class, $message); +})->with([ + 'an old timestamp' => [fn (): string => (string) (now()->getTimestamp() - 301), str_repeat('a', 32), 'Signature expired. Check that your clock is right.'], + 'a malformed nonce' => [fn (): string => (string) now()->getTimestamp(), 'nonce', 'Malformed payload.'], +]);