Your calm window into the Atmosphere. morgen.blue
rss atproto
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

chore(stack-laravel): apply review fixes + scaffolding cleanup

- fix: PersistOAuthSession used Builder::update, bypassing the
'refresh_token' encrypted cast — every refresh wrote plaintext over
the encrypted column. Route through the model so the cast applies.
- delete ClearRefreshTokenOnRotate listener: blanking refresh_token on
OAuthSessionRefreshing (before the rotation HTTP call) bricked the
row on any rotation failure. PersistOAuthSession writes the new
token on success — durable clear is unnecessary.
- lock config/bluesky.php scope default to 'atproto'; missing env now
fails closed instead of silently shipping transition:generic.
- consolidate use-mobile: delete .ts (useState), keep .tsx
(useSyncExternalStore — tear-free, matches use-appearance).
- tests: new OAuthCallbackTest (green / re-auth / Socialite-throws
via Exceptions::fake), PersistOAuthSessionTest, InertiaSharedDataTest,
deepened logout assertions; FakesBlueskyOAuth trait replaces four
copies of Mockery setup. Pest binding switched to
LazilyRefreshDatabase, applied globally so feature files stop
repeating uses(RefreshDatabase::class).
- scaffolding: trim placeholder docblocks in HandleInertiaRequests +
AppServiceProvider, drop dead Inertia version() override, drop two
JSX comment labels from app-header, delete the two ExampleTest
files, trim tests/Pest.php to the minimal binding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+214 -208
-21
app/Http/Middleware/HandleInertiaRequests.php
··· 7 7 8 8 class HandleInertiaRequests extends Middleware 9 9 { 10 - /** 11 - * The root template that's loaded on the first page visit. 12 - * 13 - * @see https://inertiajs.com/server-side-setup#root-template 14 - * 15 - * @var string 16 - */ 17 10 protected $rootView = 'app'; 18 11 19 12 /** 20 - * Determines the current asset version. 21 - * 22 - * @see https://inertiajs.com/asset-versioning 23 - */ 24 - public function version(Request $request): ?string 25 - { 26 - return parent::version($request); 27 - } 28 - 29 - /** 30 - * Define the props that are shared by default. 31 - * 32 - * @see https://inertiajs.com/shared-data 33 - * 34 13 * @return array<string, mixed> 35 14 */ 36 15 public function share(Request $request): array
-20
app/Listeners/ClearRefreshTokenOnRotate.php
··· 1 - <?php 2 - 3 - namespace App\Listeners; 4 - 5 - use App\Models\User; 6 - use Revolution\Bluesky\Events\OAuthSessionRefreshing; 7 - 8 - class ClearRefreshTokenOnRotate 9 - { 10 - public function handle(OAuthSessionRefreshing $event): void 11 - { 12 - $did = $event->session->did(); 13 - 14 - if (empty($did)) { 15 - return; 16 - } 17 - 18 - User::where('did', $did)->update(['refresh_token' => '']); 19 - } 20 - }
+8 -1
app/Listeners/PersistOAuthSession.php
··· 15 15 return; 16 16 } 17 17 18 - User::where('did', $did)->update([ 18 + $user = User::find($did); 19 + 20 + if ($user === null) { 21 + return; 22 + } 23 + 24 + // Route through the model so the 'refresh_token' encrypted cast applies. 25 + $user->update([ 19 26 'refresh_token' => $event->session->refresh(), 20 27 'iss' => $event->session->issuer(), 21 28 ]);
-17
app/Providers/AppServiceProvider.php
··· 2 2 3 3 namespace App\Providers; 4 4 5 - use App\Listeners\ClearRefreshTokenOnRotate; 6 5 use App\Listeners\PersistOAuthSession; 7 6 use Carbon\CarbonImmutable; 8 7 use Illuminate\Support\Facades\Date; ··· 11 10 use Illuminate\Support\Facades\URL; 12 11 use Illuminate\Support\ServiceProvider; 13 12 use Illuminate\Support\Str; 14 - use Revolution\Bluesky\Events\OAuthSessionRefreshing; 15 13 use Revolution\Bluesky\Events\OAuthSessionUpdated; 16 14 use Revolution\Bluesky\Socialite\OAuthConfig; 17 15 18 16 class AppServiceProvider extends ServiceProvider 19 17 { 20 - /** 21 - * Register any application services. 22 - */ 23 - public function register(): void 24 - { 25 - // 26 - } 27 - 28 - /** 29 - * Bootstrap any application services. 30 - */ 31 18 public function boot(): void 32 19 { 33 20 $this->configureDefaults(); 34 21 $this->configureBluesky(); 35 22 } 36 23 37 - /** 38 - * Configure default behaviors for production-ready applications. 39 - */ 40 24 protected function configureDefaults(): void 41 25 { 42 26 Date::use(CarbonImmutable::class); ··· 57 41 protected function configureBluesky(): void 58 42 { 59 43 Event::listen(OAuthSessionUpdated::class, PersistOAuthSession::class); 60 - Event::listen(OAuthSessionRefreshing::class, ClearRefreshTokenOnRotate::class); 61 44 62 45 OAuthConfig::clientMetadataUsing(function (): array { 63 46 return collect(config('bluesky.oauth.metadata'))
+1 -1
config/bluesky.php
··· 35 35 36 36 // Client Metadata 37 37 'metadata' => [ 38 - 'scope' => env('BLUESKY_OAUTH_SCOPE', 'atproto transition:generic transition:email transition:chat.bsky'), 38 + 'scope' => env('BLUESKY_OAUTH_SCOPE', 'atproto'), 39 39 40 40 'grant_types' => ['authorization_code', 'refresh_token'], 41 41 'response_types' => ['code'],
-1
resources/js/app.tsx
··· 39 39 }, 40 40 }); 41 41 42 - // This will set light / dark mode on load... 43 42 initializeTheme();
-2
resources/js/components/app-header.tsx
··· 84 84 <> 85 85 <div className="border-b border-sidebar-border/80"> 86 86 <div className="mx-auto flex h-16 items-center px-4 md:max-w-7xl"> 87 - {/* Mobile Menu */} 88 87 <div className="lg:hidden"> 89 88 <Sheet> 90 89 <SheetTrigger ··· 164 163 <AppLogo /> 165 164 </Link> 166 165 167 - {/* Desktop Navigation */} 168 166 <div className="ml-6 hidden h-full items-center space-x-6 lg:flex"> 169 167 <NavigationMenu className="flex h-full items-stretch"> 170 168 <NavigationMenuList className="flex h-full items-stretch space-x-2">
-25
resources/js/hooks/use-mobile.ts
··· 1 - import * as React from 'react'; 2 - 3 - const MOBILE_BREAKPOINT = 768; 4 - 5 - export function useIsMobile() { 6 - const [isMobile, setIsMobile] = React.useState<boolean>( 7 - () => 8 - typeof window !== 'undefined' && 9 - window.innerWidth < MOBILE_BREAKPOINT, 10 - ); 11 - 12 - React.useEffect(() => { 13 - const mql = window.matchMedia( 14 - `(max-width: ${MOBILE_BREAKPOINT - 1}px)`, 15 - ); 16 - const onChange = () => { 17 - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); 18 - }; 19 - mql.addEventListener('change', onChange); 20 - 21 - return () => mql.removeEventListener('change', onChange); 22 - }, []); 23 - 24 - return isMobile; 25 - }
+60
tests/Concerns/FakesBlueskyOAuth.php
··· 1 + <?php 2 + 3 + namespace Tests\Concerns; 4 + 5 + use Laravel\Socialite\Facades\Socialite; 6 + use Laravel\Socialite\Two\User as SocialiteUser; 7 + use Mockery; 8 + use Revolution\Bluesky\Session\OAuthSession; 9 + use Revolution\Bluesky\Socialite\BlueskyProvider; 10 + use Throwable; 11 + 12 + trait FakesBlueskyOAuth 13 + { 14 + protected function fakeBlueskyRedirect(?string $hint = null, string $redirectUrl = 'https://bsky.social/oauth/authorize'): void 15 + { 16 + $provider = Mockery::mock(BlueskyProvider::class); 17 + $provider->shouldReceive('setScopes')->andReturnSelf(); 18 + $hintExpectation = $provider->shouldReceive('hint'); 19 + if ($hint !== null) { 20 + $hintExpectation->with($hint); 21 + } 22 + $hintExpectation->andReturnSelf(); 23 + $provider->shouldReceive('redirect')->andReturn(redirect($redirectUrl)); 24 + 25 + Socialite::shouldReceive('driver')->with('bluesky')->andReturn($provider); 26 + } 27 + 28 + protected function fakeBlueskyRedirectThrows(Throwable $error): void 29 + { 30 + $provider = Mockery::mock(BlueskyProvider::class); 31 + $provider->shouldReceive('setScopes')->andReturnSelf(); 32 + $provider->shouldReceive('hint')->andReturnSelf(); 33 + $provider->shouldReceive('redirect')->andThrow($error); 34 + 35 + Socialite::shouldReceive('driver')->with('bluesky')->andReturn($provider); 36 + } 37 + 38 + protected function fakeBlueskyCallback(OAuthSession $session): void 39 + { 40 + $socialiteUser = new SocialiteUser; 41 + $socialiteUser->session = $session; 42 + 43 + $provider = Mockery::mock(BlueskyProvider::class); 44 + $provider->shouldReceive('setScopes')->andReturnSelf(); 45 + $provider->shouldReceive('hint')->andReturnSelf(); 46 + $provider->shouldReceive('user')->andReturn($socialiteUser); 47 + 48 + Socialite::shouldReceive('driver')->with('bluesky')->andReturn($provider); 49 + } 50 + 51 + protected function fakeBlueskyCallbackThrows(Throwable $error): void 52 + { 53 + $provider = Mockery::mock(BlueskyProvider::class); 54 + $provider->shouldReceive('setScopes')->andReturnSelf(); 55 + $provider->shouldReceive('hint')->andReturnSelf(); 56 + $provider->shouldReceive('user')->andThrow($error); 57 + 58 + Socialite::shouldReceive('driver')->with('bluesky')->andReturn($provider); 59 + } 60 + }
+14 -59
tests/Feature/Auth/LoginTest.php
··· 2 2 3 3 use App\Models\User; 4 4 use GuzzleHttp\Psr7\Response as Psr7Response; 5 - use Illuminate\Foundation\Testing\RefreshDatabase; 6 5 use Illuminate\Http\Client\ConnectionException; 7 6 use Illuminate\Http\Client\RequestException; 8 7 use Illuminate\Http\Client\Response as HttpResponse; 9 - use Laravel\Socialite\Facades\Socialite; 10 - use Laravel\Socialite\Two\User as SocialiteUser; 11 - use Revolution\Bluesky\Session\OAuthSession; 12 - use Revolution\Bluesky\Socialite\BlueskyProvider; 8 + use Tests\Concerns\FakesBlueskyOAuth; 13 9 14 - uses(RefreshDatabase::class); 10 + uses(FakesBlueskyOAuth::class); 15 11 16 12 test('login page renders', function () { 17 13 $this->get(route('login'))->assertOk(); 18 14 }); 19 15 20 16 test('POST /login redirects to Bluesky with handle hint', function () { 21 - $provider = Mockery::mock(BlueskyProvider::class); 22 - $provider->shouldReceive('setScopes')->andReturnSelf(); 23 - $provider->shouldReceive('hint')->with('alice.bsky.social')->andReturnSelf(); 24 - $provider->shouldReceive('redirect')->andReturn( 25 - redirect('https://bsky.social/oauth/authorize') 26 - ); 27 - 28 - Socialite::shouldReceive('driver')->with('bluesky')->andReturn($provider); 17 + $this->fakeBlueskyRedirect(hint: 'alice.bsky.social'); 29 18 30 19 $this->post(route('login'), ['handle' => 'alice.bsky.social']) 31 20 ->assertRedirect('https://bsky.social/oauth/authorize'); ··· 34 23 }); 35 24 36 25 test('POST /login surfaces unreachable PDS as a handle validation error', function () { 37 - $provider = Mockery::mock(BlueskyProvider::class); 38 - $provider->shouldReceive('setScopes')->andReturnSelf(); 39 - $provider->shouldReceive('hint')->andReturnSelf(); 40 - $provider->shouldReceive('redirect')->andThrow( 26 + $this->fakeBlueskyRedirectThrows( 41 27 new ConnectionException('cURL error 6: Could not resolve host') 42 28 ); 43 - 44 - Socialite::shouldReceive('driver')->with('bluesky')->andReturn($provider); 45 29 46 30 $this->from(route('login')) 47 31 ->post(route('login'), ['handle' => 'test.com']) ··· 50 34 }); 51 35 52 36 test('POST /login surfaces auth-server rejection as a handle validation error', function () { 53 - $provider = Mockery::mock(BlueskyProvider::class); 54 - $provider->shouldReceive('setScopes')->andReturnSelf(); 55 - $provider->shouldReceive('hint')->andReturnSelf(); 56 - $provider->shouldReceive('redirect')->andThrow( 37 + $this->fakeBlueskyRedirectThrows( 57 38 new RequestException(new HttpResponse( 58 39 new Psr7Response(400, [], '{"error":"invalid_request","error_description":"Invalid login_hint \"a\""}') 59 40 )) 60 41 ); 61 - 62 - Socialite::shouldReceive('driver')->with('bluesky')->andReturn($provider); 63 42 64 43 $this->from(route('login')) 65 44 ->post(route('login'), ['handle' => 'a']) ··· 67 46 ->assertSessionHasErrors(['handle']); 68 47 }); 69 48 70 - test('OAuth callback creates user, stashes handle, and logs in', function () { 71 - $session = OAuthSession::create([ 72 - 'did' => 'did:plc:testuser1234567890abcd', 73 - 'handle' => 'alice.bsky.social', 74 - 'iss' => 'https://bsky.social', 75 - 'refresh_token' => 'fake-refresh-token', 76 - ]); 77 - 78 - $socialiteUser = new SocialiteUser; 79 - $socialiteUser->session = $session; 80 - 81 - $provider = Mockery::mock(BlueskyProvider::class); 82 - $provider->shouldReceive('setScopes')->andReturnSelf(); 83 - $provider->shouldReceive('hint')->andReturnSelf(); 84 - $provider->shouldReceive('user')->andReturn($socialiteUser); 85 - 86 - Socialite::shouldReceive('driver')->with('bluesky')->andReturn($provider); 87 - 88 - $this->withSession(['atproto.hint' => 'alice.bsky.social']) 89 - ->get(route('bluesky.oauth.redirect')) 90 - ->assertRedirect(route('dashboard')); 91 - 92 - $user = User::find('did:plc:testuser1234567890abcd'); 93 - 94 - expect($user)->not->toBeNull() 95 - ->and($user->refresh_token)->toBe('fake-refresh-token') 96 - ->and($user->iss)->toBe('https://bsky.social'); 97 - 98 - $this->assertAuthenticatedAs($user); 99 - expect(session('atproto.handle'))->toBe('alice.bsky.social'); 100 - }); 101 - 102 - test('POST /logout ends the session', function () { 49 + test('POST /logout clears the Bluesky session and CSRF token', function () { 103 50 $user = User::factory()->create(); 51 + $previousToken = csrf_token(); 104 52 105 53 $this->actingAs($user) 54 + ->withSession([ 55 + 'atproto.handle' => 'alice.bsky.social', 56 + 'bluesky_session' => ['did' => $user->did], 57 + ]) 106 58 ->post(route('logout')) 107 59 ->assertRedirect('/'); 108 60 109 61 $this->assertGuest(); 62 + expect(session('atproto.handle'))->toBeNull() 63 + ->and(session('bluesky_session'))->toBeNull() 64 + ->and(csrf_token())->not->toBe($previousToken); 110 65 });
+66
tests/Feature/Auth/OAuthCallbackTest.php
··· 1 + <?php 2 + 3 + use App\Models\User; 4 + use Illuminate\Support\Facades\Exceptions; 5 + use Revolution\Bluesky\Session\OAuthSession; 6 + use Tests\Concerns\FakesBlueskyOAuth; 7 + 8 + uses(FakesBlueskyOAuth::class); 9 + 10 + test('callback creates user, stashes handle, and logs in', function () { 11 + $session = OAuthSession::create([ 12 + 'did' => 'did:plc:testuser1234567890abcd', 13 + 'handle' => 'alice.bsky.social', 14 + 'iss' => 'https://bsky.social', 15 + 'refresh_token' => 'fake-refresh-token', 16 + ]); 17 + $this->fakeBlueskyCallback($session); 18 + 19 + $this->withSession(['atproto.hint' => 'alice.bsky.social']) 20 + ->get(route('bluesky.oauth.redirect')) 21 + ->assertRedirect(route('dashboard')); 22 + 23 + $user = User::find('did:plc:testuser1234567890abcd'); 24 + 25 + expect($user)->not->toBeNull() 26 + ->and($user->refresh_token)->toBe('fake-refresh-token') 27 + ->and($user->iss)->toBe('https://bsky.social'); 28 + 29 + $this->assertAuthenticatedAs($user); 30 + expect(session('atproto.handle'))->toBe('alice.bsky.social'); 31 + }); 32 + 33 + test('callback updates the existing row when the same DID re-auths', function () { 34 + $existing = User::factory()->create([ 35 + 'did' => 'did:plc:testuser1234567890abcd', 36 + 'refresh_token' => 'old-refresh-token', 37 + 'iss' => 'https://old.example', 38 + ]); 39 + 40 + $session = OAuthSession::create([ 41 + 'did' => $existing->did, 42 + 'handle' => 'alice.bsky.social', 43 + 'iss' => 'https://eurosky.social', 44 + 'refresh_token' => 'new-refresh-token', 45 + ]); 46 + $this->fakeBlueskyCallback($session); 47 + 48 + $this->get(route('bluesky.oauth.redirect')) 49 + ->assertRedirect(route('dashboard')); 50 + 51 + $existing->refresh(); 52 + expect($existing->refresh_token)->toBe('new-refresh-token') 53 + ->and($existing->iss)->toBe('https://eurosky.social') 54 + ->and(User::count())->toBe(1); 55 + }); 56 + 57 + test('callback does not create a user or log anyone in when Socialite throws', function () { 58 + Exceptions::fake(); 59 + $this->fakeBlueskyCallbackThrows(new RuntimeException('state mismatch')); 60 + 61 + $this->get(route('bluesky.oauth.redirect')); 62 + 63 + $this->assertGuest(); 64 + expect(User::count())->toBe(0); 65 + Exceptions::assertReported(RuntimeException::class); 66 + });
+38
tests/Feature/Auth/PersistOAuthSessionTest.php
··· 1 + <?php 2 + 3 + use App\Models\User; 4 + use Revolution\Bluesky\Events\OAuthSessionUpdated; 5 + use Revolution\Bluesky\Session\OAuthSession; 6 + 7 + test('persists rotated refresh token + iss for the matching DID', function () { 8 + $user = User::factory()->create([ 9 + 'did' => 'did:plc:rotateduser567890abcd', 10 + 'refresh_token' => 'old-refresh', 11 + 'iss' => 'https://old.example', 12 + ]); 13 + 14 + $session = OAuthSession::create([ 15 + 'did' => $user->did, 16 + 'refresh_token' => 'rotated-refresh', 17 + 'iss' => 'https://eurosky.social', 18 + 'access_token' => 'fresh-access', 19 + ]); 20 + 21 + event(new OAuthSessionUpdated($session)); 22 + 23 + $user->refresh(); 24 + expect($user->refresh_token)->toBe('rotated-refresh') 25 + ->and($user->iss)->toBe('https://eurosky.social') 26 + ->and(session('bluesky_session'))->toMatchArray($session->toArray()); 27 + }); 28 + 29 + test('listener is a no-op when the session has no DID', function () { 30 + User::factory()->create(['did' => 'did:plc:untouched1234567890abcd']); 31 + 32 + $session = OAuthSession::create(['refresh_token' => 'r']); 33 + 34 + event(new OAuthSessionUpdated($session)); 35 + 36 + expect(User::find('did:plc:untouched1234567890abcd')->refresh_token) 37 + ->not->toBe('r'); 38 + });
-3
tests/Feature/DashboardTest.php
··· 1 1 <?php 2 2 3 3 use App\Models\User; 4 - use Illuminate\Foundation\Testing\RefreshDatabase; 5 - 6 - uses(RefreshDatabase::class); 7 4 8 5 test('guests are redirected to the login page', function () { 9 6 $response = $this->get(route('dashboard'));
-7
tests/Feature/ExampleTest.php
··· 1 - <?php 2 - 3 - test('returns a successful response', function () { 4 - $response = $this->get(route('home')); 5 - 6 - $response->assertOk(); 7 - });
+25
tests/Feature/InertiaSharedDataTest.php
··· 1 + <?php 2 + 3 + use App\Models\User; 4 + 5 + test('guest pages share null auth.user and auth.handle', function () { 6 + $this->get(route('login')) 7 + ->assertInertia(fn ($page) => $page 8 + ->where('auth.user', null) 9 + ->where('auth.handle', null) 10 + ); 11 + }); 12 + 13 + test('authed pages share the user model and the session-stashed handle', function () { 14 + $user = User::factory()->create([ 15 + 'did' => 'did:plc:shareduser1234567890abcd', 16 + ]); 17 + 18 + $this->actingAs($user) 19 + ->withSession(['atproto.handle' => 'alice.bsky.social']) 20 + ->get(route('dashboard')) 21 + ->assertInertia(fn ($page) => $page 22 + ->where('auth.user.did', $user->did) 23 + ->where('auth.handle', 'alice.bsky.social') 24 + ); 25 + });
+2 -46
tests/Pest.php
··· 1 1 <?php 2 2 3 - use Illuminate\Foundation\Testing\RefreshDatabase; 3 + use Illuminate\Foundation\Testing\LazilyRefreshDatabase; 4 4 use Tests\TestCase; 5 5 6 - /* 7 - |-------------------------------------------------------------------------- 8 - | Test Case 9 - |-------------------------------------------------------------------------- 10 - | 11 - | The closure you provide to your test functions is always bound to a specific PHPUnit test 12 - | case class. By default, that class is "PHPUnit\Framework\TestCase". Of course, you may 13 - | need to change it using the "pest()" function to bind different classes or traits. 14 - | 15 - */ 16 - 17 - pest()->extend(TestCase::class) 18 - // ->use(RefreshDatabase::class) 19 - ->in('Feature'); 20 - 21 - /* 22 - |-------------------------------------------------------------------------- 23 - | Expectations 24 - |-------------------------------------------------------------------------- 25 - | 26 - | When you're writing tests, you often need to check that values meet certain conditions. The 27 - | "expect()" function gives you access to a set of "expectations" methods that you can use 28 - | to assert different things. Of course, you may extend the Expectation API at any time. 29 - | 30 - */ 31 - 32 - expect()->extend('toBeOne', function () { 33 - return $this->toBe(1); 34 - }); 35 - 36 - /* 37 - |-------------------------------------------------------------------------- 38 - | Functions 39 - |-------------------------------------------------------------------------- 40 - | 41 - | While Pest is very powerful out-of-the-box, you may have some testing code specific to your 42 - | project that you don't want to repeat in every file. Here you can also expose helpers as 43 - | global functions to help you to reduce the number of lines of code in your test files. 44 - | 45 - */ 46 - 47 - function something() 48 - { 49 - // .. 50 - } 6 + pest()->extend(TestCase::class)->use(LazilyRefreshDatabase::class)->in('Feature');
-5
tests/Unit/ExampleTest.php
··· 1 - <?php 2 - 3 - test('that true is true', function () { 4 - expect(true)->toBeTrue(); 5 - });