diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 2d9ded45b..6184d488c 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -98,13 +98,13 @@ Sources: [onboarding page](resources/js/pages/onboarding/show.tsx), [sign-in tes ### Profiles and GitHub sync -Every developer has a profile at `/@{username}`, with their GitHub identity, bio and other public profile information, commit/following/follower counts, a **Follows you** badge when they follow you, and an activity graph. Across a block in either direction, the profile is shown as unavailable. The Commits tab lists top-level commits; Replies lists that developer's replies with parent context. Follow and follower counts open the corresponding people lists. The profile links to GitHub and, when available, GitHub Sponsors. Share uses the device's share sheet when supported, or copies the profile link. +Every developer has a profile at `/@{username}`, with their GitHub identity, bio and other public profile information, commit/following/follower counts, a **Follows you** badge when they follow you, and an activity graph. Across a block in either direction, the profile is shown as unavailable. The Commits tab lists top-level commits; Replies lists that developer's replies with parent context; README shows the README of their GitHub profile repository (the one named after their username), as GitHub renders it, refreshed daily. It keeps only plain formatting, links, and images hosted by GitHub: no scripts, styles, or classes. When GitHub doesn't answer, the tab says so rather than showing no README. Follow and follower counts open the corresponding people lists. The profile links to GitHub and, when available, GitHub Sponsors. Share uses the device's share sheet when supported, or copies the profile link. Change your name, avatar, bio, company, location, or website on GitHub, not on /dev/null. Settings → Profile and the **Edit profile** menu on your own profile offer **Edit on GitHub** and **Sync from GitHub**. Profile details also refresh on sign-in. Manual sync is limited to once a minute. The GitHub Sponsors link is refreshed on sign-in only. A GitHub username rename is picked up on sign-in or sync; if a stale account still holds the new username because its owner also renamed, that account moves to a `_renamed-` placeholder until its owner signs in or syncs again. Accounts are matched by GitHub id and never merged. Profiles also show [achievement badges and an XP-based Unix rank](#achievement-badges-and-xp). Click a badge or rank to inspect unlock requirements and progress. -Sources: [profile page](resources/js/pages/user/show.tsx), [profile settings](resources/js/pages/user-profile/edit.tsx), [profile sharing](resources/js/components/profile-share-button.tsx), [identity sync](app/Actions/UpdateOrCreateUser.php). +Sources: [profile page](resources/js/pages/user/show.tsx), [profile settings](resources/js/pages/user-profile/edit.tsx), [profile sharing](resources/js/components/profile-share-button.tsx), [profile README](app/Queries/GitHubReadmeQuery.php), [identity sync](app/Actions/UpdateOrCreateUser.php). ### Going live diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 11bd1b65d..8315cdb7b 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -10,6 +10,7 @@ use App\Http\Requests\ShowUserRequest; use App\Models\Commit; use App\Models\User; +use App\Queries\GitHubReadmeQuery; use App\Queries\PinnedCommitQuery; use App\Queries\PushAccessQuery; use App\Queries\UserAchievementsQuery; @@ -20,6 +21,7 @@ use App\Services\PageMetadata; use Illuminate\Container\Attributes\CurrentUser; use Illuminate\Http\RedirectResponse; +use Illuminate\Pagination\CursorPaginator; use Illuminate\Support\Facades\Auth; use Inertia\Inertia; use Inertia\Response; @@ -29,7 +31,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, PushAccessQuery $pushAccess, UserCommitsQuery $commits, PinnedCommitQuery $pinned, UserModerationQuery $moderation, PageMetadata $metadata): Response + public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser, User $user, UserActivityQuery $activity, UserStreakQuery $streaks, UserAchievementsQuery $achievements, PushAccessQuery $pushAccess, UserCommitsQuery $commits, PinnedCommitQuery $pinned, UserModerationQuery $moderation, GitHubReadmeQuery $readme, PageMetadata $metadata): Response { $blockerId = $currentUser?->blockerIdWith($user); @@ -64,10 +66,14 @@ public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser 'achievements' => fn (): array => $achievements->get($user, $currentUser, $getStreaks()), 'moderation' => fn (): ?array => $currentUser?->isAdmin() === true && ! $user->isAdmin() ? $moderation->get([$user])[$user->id] : null, 'tab' => $request->tab(...), - '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()])), + 'pinnedCommit' => fn (): ?Commit => $request->tab() === 'commits' ? $pinned->get($user, $currentUser) : null, + 'readme' => fn (): string|false|null => $request->tab() === 'readme' ? $readme->get($user) : null, + // The README tab shows no commits, so it doesn't look for any. + 'commits' => Inertia::scroll(fn () => $request->tab() === 'readme' + ? new CursorPaginator([], 20) + : $commits->builder($user, $currentUser, $request->tab() === 'replies') + ->cursorPaginate(20) + ->appends(['tab' => $request->tab()])), ])->withViewData(['preview' => $currentUser instanceof User || $request->inertia() ? null : $metadata->user($getCountedUser())]); } diff --git a/app/Http/Requests/ShowUserRequest.php b/app/Http/Requests/ShowUserRequest.php index faf0eee49..a6cef64f6 100644 --- a/app/Http/Requests/ShowUserRequest.php +++ b/app/Http/Requests/ShowUserRequest.php @@ -19,12 +19,16 @@ public function authorize(): bool public function rules(): array { return [ - 'tab' => ['nullable', 'string', 'in:commits,replies'], + 'tab' => ['nullable', 'string', 'in:commits,replies,readme'], ]; } public function tab(): string { - return $this->input('tab') === 'replies' ? 'replies' : 'commits'; + return match ($this->input('tab')) { + 'replies' => 'replies', + 'readme' => 'readme', + default => 'commits', + }; } } diff --git a/app/Queries/GitHubReadmeQuery.php b/app/Queries/GitHubReadmeQuery.php new file mode 100644 index 000000000..92f25ff6d --- /dev/null +++ b/app/Queries/GitHubReadmeQuery.php @@ -0,0 +1,134 @@ +github_id}"; + $failedKey = "github-readme-failed:{$user->github_id}"; + + /** @var string|null $html */ + $html = Cache::get($key); + + if ($html === null) { + if (Cache::has($failedKey)) { + return false; + } + + try { + $html = $this->fetch($user); + } catch (HttpClientException) { + Cache::put($failedKey, true, self::FailureTtl); + + return false; + } + + Cache::put($key, $html, str_contains($html, 'private-user-images.githubusercontent.com') ? self::SignedImagesTtl : 86400); + } + + return $html === '' ? null : $html; + } + + /** + * @throws HttpClientException + */ + private function fetch(User $user): string + { + $login = $this->github->login($user->github_id); + + if ($login === null) { + return ''; + } + + $response = $this->github->request() + ->replaceHeaders(['Accept' => 'application/vnd.github.html+json']) + ->get("repos/{$login}/{$login}/readme"); + + return $response->notFound() ? '' : $this->sanitize($response->throw()->body()); + } + + /** + * Never trusted as is: only plain formatting, links and images from GitHub's hosts are kept. Classes, ids and + * styles are dropped, so a README can't take on the page's own styles, cover it or pose as part of it. + */ + private function sanitize(string $html): string + { + $config = new HtmlSanitizerConfig() + // Only what a README is written with: a fixed list, so attributes like srcset or name, and elements like + // dialog, never come along with the browser's idea of what is safe. + ->allowElement('a', ['href', 'title']) + ->allowElement('img', ['src', 'alt', 'title', 'width', 'height', 'align']) + ->allowElement('p', ['align']) + ->allowElement('div', ['align']) + ->allowElement('h1', ['align']) + ->allowElement('h2', ['align']) + ->allowElement('h3', ['align']) + ->allowElement('h4', ['align']) + ->allowElement('h5', ['align']) + ->allowElement('h6', ['align']) + ->allowElement('th', ['align', 'colspan', 'rowspan']) + ->allowElement('td', ['align', 'colspan', 'rowspan']) + ->allowElement('ol', ['start']) + ->allowElement('details', ['open']); + + foreach (['article', 'span', 'ul', 'li', 'table', 'thead', 'tbody', 'tr', 'pre', 'code', 'blockquote', 'summary', 'picture', 'br', 'hr', 'b', 'strong', 'i', 'em', 'sub', 'sup', 'del', 's', 'kbd'] as $element) { + $config = $config->allowElement($element); + } + + // Any other element goes, but what it holds stays: GitHub's own wrappers like g-emoji or themed-picture, and + // the rarer text elements like samp or dl, keep their words. Only these go with everything in them. + $config = $config->defaultAction(HtmlSanitizerAction::Block); + + foreach (['script', 'style', 'template', 'noscript', 'iframe', 'object', 'embed', 'svg', 'math', 'dialog', 'map', 'form', 'textarea', 'select', 'button', 'head', 'title', 'source', 'video', 'audio'] as $element) { + $config = $config->dropElement($element); + } + + $config = $config + ->allowLinkSchemes(['https', 'http', 'mailto']) + ->allowRelativeLinks(false) + ->allowMediaSchemes(['https']) + ->allowMediaHosts(['camo.githubusercontent.com', 'github.com', 'raw.githubusercontent.com', 'user-images.githubusercontent.com', 'private-user-images.githubusercontent.com']) + ->allowRelativeMedias(false) + ->forceAttribute('a', 'rel', 'noopener noreferrer nofollow ugc') + ->forceAttribute('a', 'target', '_blank') + ->withMaxInputLength(self::MaxLength); + + return mb_trim(new HtmlSanitizer($config)->sanitize($html)); + } +} diff --git a/composer.json b/composer.json index 13b6d5152..68296d82a 100644 --- a/composer.json +++ b/composer.json @@ -22,6 +22,7 @@ "nunomaduro/essentials": "^1.2.0", "phpseclib/phpseclib": "^4.0.2", "resend/resend-php": "^1.17", + "symfony/html-sanitizer": "^8.0", "tempest/highlight": "^2.29.0" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 80c8c1c69..c0543560b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "169e47b41d0e981a7238dc3cb9e0b900", + "content-hash": "57d5dbde21fa224cebbfd54964e30eef", "packages": [ { "name": "aws/aws-crt-php", @@ -5442,6 +5442,78 @@ ], "time": "2026-09-26T06:33:49+00:00" }, + { + "name": "symfony/html-sanitizer", + "version": "v8.1.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/html-sanitizer.git", + "reference": "c67061a71d3f8b55f899faee3167422efa4db027" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/c67061a71d3f8b55f899faee3167422efa4db027", + "reference": "c67061a71d3f8b55f899faee3167422efa4db027", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "league/uri": "^6.5|^7.0", + "php": ">=8.4.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HtmlSanitizer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Titouan Galopin", + "email": "galopintitouan@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to sanitize untrusted HTML input for safe insertion into a document's DOM.", + "homepage": "https://symfony.com", + "keywords": [ + "Purifier", + "html", + "sanitizer" + ], + "support": { + "source": "https://github.com/symfony/html-sanitizer/tree/v8.1.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-28T08:00:04+00:00" + }, { "name": "symfony/http-foundation", "version": "v8.1.9", diff --git a/resources/css/app.css b/resources/css/app.css index b21124a20..72eed366d 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -701,6 +701,115 @@ html body[data-scroll-locked] { .hl-deletion { color: var(--destructive); } + + /* A GitHub profile README, sanitized on the server: it keeps GitHub's layout without GitHub's stylesheet. */ + .readme { + overflow-wrap: anywhere; + font-size: 15px; + line-height: 1.6; + + & > * + *, + & article > * + * { + margin-top: 1em; + } + + & h1, + & h2, + & h3, + & h4 { + margin-top: 1.5em; + font-weight: 600; + line-height: 1.3; + } + + & h1 { + font-size: 1.5em; + } + + & h2 { + border-bottom: 1px solid var(--border); + padding-bottom: 0.3em; + font-size: 1.25em; + } + + & a { + color: var(--primary); + } + + & a:hover { + text-decoration: underline; + } + + & img { + display: inline-block; + max-width: 100%; + height: auto; + } + + & ul, + & ol { + padding-left: 1.5em; + } + + & ul { + list-style: disc; + } + + & ol { + list-style: decimal; + } + + & blockquote { + border-left: 3px solid var(--border); + padding-left: 1em; + color: var(--muted-foreground); + } + + & code { + border-radius: 0.25rem; + background: var(--muted); + padding: 0.1em 0.3em; + font-family: var(--font-mono); + font-size: 0.875em; + } + + & pre { + overflow-x: auto; + border-radius: 0.5rem; + background: var(--muted); + padding: 0.75rem 1rem; + + & code { + background: none; + padding: 0; + } + } + + & table { + display: block; + max-width: 100%; + overflow-x: auto; + border-collapse: collapse; + } + + & th, + & td { + border: 1px solid var(--border); + padding: 0.4em 0.75em; + } + + & hr { + border-color: var(--border); + } + + & [align='center'] { + text-align: center; + } + + & [align='right'] { + text-align: right; + } + } } /* Commits that just arrived on their own, until the reader moves on. */ diff --git a/resources/js/components/profile-readme.tsx b/resources/js/components/profile-readme.tsx new file mode 100644 index 000000000..68c850622 --- /dev/null +++ b/resources/js/components/profile-readme.tsx @@ -0,0 +1,70 @@ +import { EmptyFeed } from '@/components/commit-feed'; + +/** + * The README of the developer's GitHub profile repository. The server only sends it once sanitized, with plain + * formatting, links and images from GitHub's own hosts. False when GitHub didn't answer, which is not the same as + * having no README. + */ +export function ProfileReadme({ + readme, + username, + isCurrentUser, +}: { + readme: string | false | null; + username: string; + isCurrentUser: boolean; +}) { + if (readme === false) { + return ( +
+ + GitHub didn't answer. Try again in a few minutes. + +
+ ); + } + + if (readme === null) { + return ( +
+ + {isCurrentUser ? ( + <> + Add a README.md to a GitHub repository named{' '} + {username}, and + it shows up here.{' '} + + How to + + + ) : ( + `@${username} doesn't have a profile README on GitHub.` + )} + +
+ ); + } + + return ( +
+
+ + {username}/README.md on GitHub + +
+ ); +} diff --git a/resources/js/pages/user/show.tsx b/resources/js/pages/user/show.tsx index 5e099961c..42eddacdd 100644 --- a/resources/js/pages/user/show.tsx +++ b/resources/js/pages/user/show.tsx @@ -30,6 +30,7 @@ import { LocalTime } from '@/components/local-time'; import { PageTabs } from '@/components/page-tabs'; import { PinCommitButton } from '@/components/pin-commit-button'; import { ProfileActionsMenu } from '@/components/profile-actions-menu'; +import { ProfileReadme } from '@/components/profile-readme'; import { ProfileShareButton } from '@/components/profile-share-button'; import { Button } from '@/components/ui/button'; import { UserAvatar } from '@/components/user-avatar'; @@ -71,6 +72,7 @@ export default function Show({ commits, tab, pinnedCommit, + readme, }: { user: Profile; pasteisReceived: number | null; @@ -83,8 +85,9 @@ export default function Show({ streaks: Streaks; achievements: Achievements; commits: CursorPaginated; - tab: 'commits' | 'replies'; + tab: 'commits' | 'replies' | 'readme'; pinnedCommit: Commit | null; + readme: string | false | null; }) { const { auth } = usePage().props; const isCurrentUser = auth.user?.id === user.id; @@ -428,7 +431,7 @@ export default function Show({ - ( - - ) - : undefined - } - highlightedPeriod={highlightedPeriod} - end={ - tab === 'replies' - ? isCurrentUser - ? 'That was your first reply.' - : `That's @${user.handle}'s first reply.` - : isCurrentUser - ? 'That was your first commit. Everyone starts somewhere.' - : `That's @${user.handle}'s root commit.` - } - empty={ - tab === 'replies' ? ( - - {isCurrentUser - ? 'Your replies show up here. Join a conversation from your log.' - : `@${user.handle} hasn't replied to anything yet.`} - - ) : isCurrentUser ? ( - - Your commits show up here. Make your first one - from your log. - - ) : ( - - @{user.handle} hasn't committed anything yet. - - ) - } - /> + {tab === 'readme' ? ( + + ) : ( + ( + + ) + : undefined + } + highlightedPeriod={highlightedPeriod} + end={ + tab === 'replies' + ? isCurrentUser + ? 'That was your first reply.' + : `That's @${user.handle}'s first reply.` + : isCurrentUser + ? 'That was your first commit. Everyone starts somewhere.' + : `That's @${user.handle}'s root commit.` + } + empty={ + tab === 'replies' ? ( + + {isCurrentUser + ? 'Your replies show up here. Join a conversation from your log.' + : `@${user.handle} hasn't replied to anything yet.`} + + ) : isCurrentUser ? ( + + Your commits show up here. Make your first + one from your log. + + ) : ( + + @{user.handle} hasn't committed anything + yet. + + ) + } + /> + )}
); diff --git a/tests/Feature/Controllers/UserControllerTest.php b/tests/Feature/Controllers/UserControllerTest.php index 4f167176c..fde0e559f 100644 --- a/tests/Feature/Controllers/UserControllerTest.php +++ b/tests/Feature/Controllers/UserControllerTest.php @@ -8,6 +8,7 @@ use App\Models\LinkPreview; use App\Models\User; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; use Inertia\Testing\AssertableInertia; @@ -701,3 +702,36 @@ $this->actingAs($user)->get(route('user.show', $user)) ->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page->where('canPush', false)); }); + +it('shows the github profile readme on the readme tab, without looking for commits', function (): void { + $user = User::factory()->create(['username' => 'octocat']); + Commit::factory()->for($user)->create(); + + Http::fake([ + "api.github.com/user/{$user->github_id}" => Http::response(['login' => 'octocat']), + 'api.github.com/repos/octocat/octocat/readme' => Http::response('

Hi there

'), + ]); + + $this->get(route('user.show', ['user' => $user, 'tab' => 'readme']))->assertOk()->assertInertia(fn ($page) => $page + ->where('tab', 'readme') + ->where('readme', '

Hi there

') + ->where('pinnedCommit', null) + ->where('commits.data', [])); +}); + +it('tells the readme tab when github did not answer', function (): void { + $user = User::factory()->create(['username' => 'octocat']); + + Http::fake(["api.github.com/user/{$user->github_id}" => Http::response(status: 502)]); + + $this->get(route('user.show', ['user' => $user, 'tab' => 'readme']))->assertOk()->assertInertia(fn ($page) => $page + ->where('readme', false)); +}); + +it('does not ask github for the readme on the other tabs', function (string $tab): void { + Http::preventStrayRequests(); + $user = User::factory()->create(['username' => 'octocat']); + + $this->get(route('user.show', ['user' => $user, 'tab' => $tab]))->assertOk()->assertInertia(fn ($page) => $page + ->where('readme', null)); +})->with(['commits', 'replies']); diff --git a/tests/Unit/Queries/GitHubReadmeQueryTest.php b/tests/Unit/Queries/GitHubReadmeQueryTest.php new file mode 100644 index 000000000..a03d2be14 --- /dev/null +++ b/tests/Unit/Queries/GitHubReadmeQueryTest.php @@ -0,0 +1,164 @@ +github_id}" => Http::response(['login' => 'octocat']), + 'api.github.com/repos/octocat/octocat/readme' => $readme, + ]); +} + +it('keeps the formatting, links and proxied images of the readme', function (): void { + $user = User::factory()->create(); + + fakeGitHubReadme($user, Http::response(<<<'HTML' +
+

Hi there

+

site banner

+
PHP
+
+ HTML)); + + expect(resolve(GitHubReadmeQuery::class)->get($user)) + ->toContain('

Hi there

') + ->toContain('site') + ->toContain('banner') + ->toContain('
PHP
') + ->not->toContain('class=') + ->not->toContain('id='); +}); + +it('asks GitHub for the readme as html only', function (): void { + $user = User::factory()->create(); + + fakeGitHubReadme($user, Http::response('

Hi

')); + + resolve(GitHubReadmeQuery::class)->get($user); + + Http::assertSent(fn (Request $request): bool => $request->url() === 'https://api.github.com/repos/octocat/octocat/readme' + && $request->header('Accept') === ['application/vnd.github.html+json'] + && $request->hasHeader('X-GitHub-Api-Version', '2022-11-28')); +}); + +it('removes scripts, styles, event handlers and images from elsewhere', function (): void { + $user = User::factory()->create(); + + fakeGitHubReadme($user, Http::response(<<<'HTML' +

Hi

+ click + + + + + HTML)); + + expect(resolve(GitHubReadmeQuery::class)->get($user)) + ->toContain('

Hi

') + ->toContain('') + ->not->toContain('script') + ->not->toContain('style') + ->not->toContain('onclick') + ->not->toContain('javascript') + ->not->toContain('tracker.example.com') + ->not->toContain('relative.png') + ->not->toContain('iframe'); +}); + +it('remembers that there is no readme', function (): void { + $user = User::factory()->create(); + + fakeGitHubReadme($user, Http::response(status: 404)); + + expect(resolve(GitHubReadmeQuery::class)->get($user))->toBeNull() + ->and(resolve(GitHubReadmeQuery::class)->get($user))->toBeNull(); + Http::assertSentCount(2); +}); + +it('remembers a failed read for a few minutes only', function (): void { + $user = User::factory()->create(); + + Http::fake([ + "api.github.com/user/{$user->github_id}" => Http::response(['login' => 'octocat']), + 'api.github.com/repos/octocat/octocat/readme' => Http::sequence()->pushStatus(502)->push('

Hi

'), + ]); + + expect(resolve(GitHubReadmeQuery::class)->get($user))->toBeFalse() + ->and(resolve(GitHubReadmeQuery::class)->get($user))->toBeFalse(); + Http::assertSentCount(2); + + $this->travel(5)->minutes(); + + expect(resolve(GitHubReadmeQuery::class)->get($user))->toBe('

Hi

'); +}); + +it('keeps a readme with uploaded images for a few minutes, as their links expire', function (string $html, int $minutes, bool $isFetchedAgain): void { + $user = User::factory()->create(); + + fakeGitHubReadme($user, Http::response($html)); + + resolve(GitHubReadmeQuery::class)->get($user); + $this->travel($minutes)->minutes(); + resolve(GitHubReadmeQuery::class)->get($user); + + Http::assertSentCount($isFetchedAgain ? 4 : 2); +})->with([ + 'uploaded image, after 4 minutes' => ['', 4, true], + 'uploaded image, after 3 minutes' => ['', 3, false], + 'proxied image, after 4 minutes' => ['', 4, false], +]); + +it('keeps only the attributes a readme is written with', function (): void { + $user = User::factory()->create(); + + fakeGitHubReadme($user, Http::response(<<<'HTML' +

badge

+ Sign in again +

👋 Hi

+ + HTML)); + + expect(resolve(GitHubReadmeQuery::class)->get($user)) + ->toContain('

badge

') + ->toContain('

👋 Hi

') + ->not->toContain('srcset') + ->not->toContain('tracker.example.com') + ->not->toContain('name=') + ->not->toContain('dialog') + ->not->toContain('Sign in again') + ->not->toContain('area'); +}); + +it('keeps the words of the rarer text elements, without the elements', function (): void { + $user = User::factory()->create(); + + fakeGitHubReadme($user, Http::response(<<<'HTML' +

A resourceful programmer with five years of PHP.

+
Stack
Laravel
+
chart
Commits
+ secret + HTML)); + + expect(resolve(GitHubReadmeQuery::class)->get($user)) + ->toContain('

A resourceful programmer with five years of PHP.

') + ->toContain('StackLaravel') + ->toContain('chartCommits') + ->not->toContain('samp') + ->not->toContain('not->toContain('secret') + ->not->toContain('hidden'); +}); + +it('shows no readme for a github account that no longer exists', function (): void { + $user = User::factory()->create(); + + Http::fake(["api.github.com/user/{$user->github_id}" => Http::response(status: 404)]); + + expect(resolve(GitHubReadmeQuery::class)->get($user))->toBeNull(); +});