diff --git a/app/Filament/Resources/UserResource.php b/app/Filament/Resources/UserResource.php index a9f82440..8320ba24 100644 --- a/app/Filament/Resources/UserResource.php +++ b/app/Filament/Resources/UserResource.php @@ -2,7 +2,6 @@ namespace App\Filament\Resources; -use App\Enums\StripeConnectStatus; use App\Filament\Resources\UserResource\Pages; use App\Filament\Resources\UserResource\RelationManagers; use App\Models\User; @@ -38,11 +37,11 @@ public static function form(Schema $schema): Schema ->email() ->required() ->maxLength(255), - Forms\Components\DateTimePicker::make('email_verified_at'), Forms\Components\TextInput::make('password') ->password() ->dehydrated(fn ($state) => filled($state)) ->required(fn (string $context): bool => $context === 'create') + ->hidden(fn (string $context): bool => $context === 'edit') ->maxLength(255), ]), Schemas\Components\Section::make('Billing Information') @@ -64,15 +63,26 @@ public static function form(Schema $schema): Schema ->maxLength(255) ->disabled(), ]), + Schemas\Components\Section::make('Notifications') + ->description('Once these are disabled, they cannot be re-enabled by an admin.') + ->inlineLabel() + ->columns(1) + ->schema([ + Forms\Components\Toggle::make('receives_notification_emails') + ->label('Email notifications') + ->disabled(fn (?User $record) => $record && ! $record->receives_notification_emails), + Forms\Components\Toggle::make('receives_new_plugin_notifications') + ->label('New plugin notifications') + ->disabled(fn (?User $record) => $record && ! $record->receives_new_plugin_notifications), + ]), Schemas\Components\Section::make('Developer Account') ->inlineLabel() ->columns(1) ->visible(fn (?User $record) => $record?->developerAccount !== null) ->schema([ - Forms\Components\Select::make('developerAccount.stripe_connect_status') + Forms\Components\Placeholder::make('developerAccount.stripe_connect_status') ->label('Stripe Connect Status') - ->options(StripeConnectStatus::class) - ->disabled(), + ->content(fn (User $record) => $record->developerAccount->stripe_connect_status?->label() ?? '—'), Forms\Components\Placeholder::make('developerAccount.stripe_connect_account_id') ->label('Stripe Connect Account') ->content(fn (User $record) => new HtmlString( diff --git a/app/Http/Controllers/Auth/CustomerAuthController.php b/app/Http/Controllers/Auth/CustomerAuthController.php index 67a5e7e8..8f933b20 100644 --- a/app/Http/Controllers/Auth/CustomerAuthController.php +++ b/app/Http/Controllers/Auth/CustomerAuthController.php @@ -9,6 +9,7 @@ use App\Models\TeamUser; use App\Models\User; use App\Services\CartService; +use Illuminate\Auth\Events\Registered; use Illuminate\Auth\Passwords\PasswordBroker; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -45,6 +46,8 @@ public function register(Request $request): RedirectResponse 'password' => Hash::make($request->password), ]); + event(new Registered($user)); + Auth::login($user); // Transfer guest cart to user diff --git a/app/Http/Controllers/Auth/EmailVerificationController.php b/app/Http/Controllers/Auth/EmailVerificationController.php new file mode 100644 index 00000000..09aee200 --- /dev/null +++ b/app/Http/Controllers/Auth/EmailVerificationController.php @@ -0,0 +1,30 @@ +fulfill(); + + return to_route('dashboard')->with('success', 'Your email address has been verified.'); + } + + public function resend(Request $request): RedirectResponse + { + $request->user()->sendEmailVerificationNotification(); + + return back()->with('status', 'A new verification link has been sent to your email address.'); + } +} diff --git a/app/Http/Controllers/NotificationUnsubscribeController.php b/app/Http/Controllers/NotificationUnsubscribeController.php new file mode 100644 index 00000000..d9cdaceb --- /dev/null +++ b/app/Http/Controllers/NotificationUnsubscribeController.php @@ -0,0 +1,65 @@ +update(['receives_new_plugin_notifications' => false]); + + if ($request->user()?->is($user)) { + return redirect() + ->route('customer.settings', ['tab' => 'notifications']) + ->with('new-plugin-notifications-disabled', true); + } + + return view('notifications.unsubscribed', [ + 'maskedEmail' => $this->maskEmail($user->email), + 'resubscribeUrl' => $this->signedResubscribeUrl($user), + ]); + } + + public function resubscribe(Request $request, User $user): RedirectResponse|View + { + $user->update(['receives_new_plugin_notifications' => true]); + + if ($request->user()?->is($user)) { + return redirect() + ->route('customer.settings', ['tab' => 'notifications']) + ->with('new-plugin-notifications-enabled', true); + } + + return view('notifications.resubscribed', [ + 'maskedEmail' => $this->maskEmail($user->email), + ]); + } + + public static function signedUnsubscribeUrl(User $user): string + { + return url()->signedRoute('notifications.unsubscribe', ['user' => $user]); + } + + private function signedResubscribeUrl(User $user): string + { + return url()->signedRoute('notifications.resubscribe', ['user' => $user]); + } + + private function maskEmail(string $email): string + { + [$local, $domain] = explode('@', $email); + + if (strlen($local) <= 2) { + $maskedLocal = $local[0].str_repeat('*', max(1, strlen($local) - 1)); + } else { + $maskedLocal = $local[0].str_repeat('*', strlen($local) - 2).$local[strlen($local) - 1]; + } + + return $maskedLocal.'@'.$domain; + } +} diff --git a/app/Listeners/SuppressMailNotificationListener.php b/app/Listeners/SuppressMailNotificationListener.php index 234eb0d1..ad9e015c 100644 --- a/app/Listeners/SuppressMailNotificationListener.php +++ b/app/Listeners/SuppressMailNotificationListener.php @@ -3,6 +3,7 @@ namespace App\Listeners; use App\Models\User; +use Illuminate\Auth\Notifications\VerifyEmail; use Illuminate\Notifications\Events\NotificationSending; class SuppressMailNotificationListener @@ -17,6 +18,11 @@ public function handle(NotificationSending $event): bool return true; } - return $event->notifiable->receives_notification_emails; + // System notifications like email verification should always be sent + if ($event->notification instanceof VerifyEmail) { + return true; + } + + return (bool) $event->notifiable->receives_notification_emails; } } diff --git a/app/Models/User.php b/app/Models/User.php index 298de6bf..face4f89 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,13 +2,13 @@ namespace App\Models; -// use Illuminate\Contracts\Auth\MustVerifyEmail; use App\Enums\PriceTier; use App\Enums\Subscription; use App\Enums\TeamUserStatus; use Filament\Models\Contracts\FilamentUser; use Filament\Models\Contracts\HasName; use Filament\Panel; +use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -19,7 +19,7 @@ use Laravel\Cashier\Billable; use Laravel\Sanctum\HasApiTokens; -class User extends Authenticatable implements FilamentUser, HasName +class User extends Authenticatable implements FilamentUser, HasName, MustVerifyEmail { use Billable, HasApiTokens, HasFactory, Notifiable; diff --git a/app/Notifications/NewPluginAvailable.php b/app/Notifications/NewPluginAvailable.php index ea8cf415..c031a396 100644 --- a/app/Notifications/NewPluginAvailable.php +++ b/app/Notifications/NewPluginAvailable.php @@ -2,7 +2,9 @@ namespace App\Notifications; +use App\Http\Controllers\NotificationUnsubscribeController; use App\Models\Plugin; +use App\Models\User; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Notifications\Messages\MailMessage; @@ -30,12 +32,15 @@ public function via(object $notifiable): array public function toMail(object $notifiable): MailMessage { + /** @var User $notifiable */ + $unsubscribeUrl = NotificationUnsubscribeController::signedUnsubscribeUrl($notifiable); + return (new MailMessage) ->subject("New Plugin: {$this->plugin->name}") ->greeting('A new plugin is available!') ->line("**{$this->plugin->name}** has just been added to the NativePHP Plugin Marketplace.") ->action('View Plugin', route('plugins.show', $this->plugin->routeParams())) - ->line('[Manage your notification preferences]('.route('customer.settings', ['tab' => 'notifications']).').'); + ->line('[Unsubscribe from new plugin notifications]('.$unsubscribeUrl.').'); } /** diff --git a/resources/views/livewire/customer/dashboard.blade.php b/resources/views/livewire/customer/dashboard.blade.php index 8aa292c7..83960b45 100644 --- a/resources/views/livewire/customer/dashboard.blade.php +++ b/resources/views/livewire/customer/dashboard.blade.php @@ -4,6 +4,26 @@ Welcome back, {{ auth()->user()->first_name ?? auth()->user()->name }} + {{-- Email Verification Banner --}} + @if (!auth()->user()->hasVerifiedEmail()) + + Please verify your email address. + + We sent a verification email when you registered. Click the link in that email to verify your account. + + @if (session('status')) + {{ session('status') }} + @endif + + + + @csrf + Resend verification email + + + + @endif + {{-- Session Messages --}} @if (session('success')) diff --git a/resources/views/livewire/customer/settings.blade.php b/resources/views/livewire/customer/settings.blade.php index b7692f11..85323134 100644 --- a/resources/views/livewire/customer/settings.blade.php +++ b/resources/views/livewire/customer/settings.blade.php @@ -127,6 +127,18 @@ class="{{ $tab === 'notifications' ? 'border-b-2 border-zinc-800 dark:border-whi @endif @if ($tab === 'notifications') + @if (session('new-plugin-notifications-disabled')) + + New plugin notifications have been disabled. + + @endif + + @if (session('new-plugin-notifications-enabled')) + + New plugin notifications have been re-enabled. + + @endif + + Notifications Enabled + + + + + + + Notifications Enabled + + + New plugin notifications have been turned back on for {{ $maskedEmail }}. + + + + + Go to Homepage + + + + diff --git a/resources/views/notifications/unsubscribed.blade.php b/resources/views/notifications/unsubscribed.blade.php new file mode 100644 index 00000000..b07de7d1 --- /dev/null +++ b/resources/views/notifications/unsubscribed.blade.php @@ -0,0 +1,25 @@ + + Notifications Disabled + + + + + + + Notifications Disabled + + + New plugin notifications have been turned off for {{ $maskedEmail }}. + + + + Did this happen by mistake? + + + + + Re-enable Notifications + + + + diff --git a/routes/web.php b/routes/web.php index b476bdc5..4cf3413d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -4,6 +4,7 @@ use App\Features\ShowPlugins; use App\Http\Controllers\ApplinksController; use App\Http\Controllers\Auth\CustomerAuthController; +use App\Http\Controllers\Auth\EmailVerificationController; use App\Http\Controllers\BundleController; use App\Http\Controllers\CartController; use App\Http\Controllers\CustomerLicenseController; @@ -13,6 +14,7 @@ use App\Http\Controllers\GitHubAuthController; use App\Http\Controllers\GitHubIntegrationController; use App\Http\Controllers\LicenseRenewalController; +use App\Http\Controllers\NotificationUnsubscribeController; use App\Http\Controllers\OpenCollectiveWebhookController; use App\Http\Controllers\PluginDirectoryController; use App\Http\Controllers\PluginWebhookController; @@ -288,6 +290,13 @@ Route::get('auth/github/login', [GitHubAuthController::class, 'redirect'])->name('login.github'); }); +// Email verification routes +Route::middleware('auth')->group(function (): void { + Route::get('email/verify', [EmailVerificationController::class, 'notice'])->name('verification.notice'); + Route::get('email/verify/{id}/{hash}', [EmailVerificationController::class, 'verify'])->middleware(['signed', 'throttle:6,1'])->name('verification.verify'); + Route::post('email/verification-notification', [EmailVerificationController::class, 'resend'])->middleware('throttle:6,1')->name('verification.send'); +}); + Route::post('logout', [CustomerAuthController::class, 'logout']) ->middleware(EnsureFeaturesAreActive::using(ShowAuthButtons::class)) ->name('customer.logout'); @@ -406,6 +415,12 @@ Route::post('licenses/{licenseKey}/sub-licenses/{subLicense}/send-email', [CustomerSubLicenseController::class, 'sendEmail'])->name('licenses.sub-licenses.send-email'); }); +// Notification unsubscribe/resubscribe (signed URLs, no auth required) +Route::middleware('signed')->group(function (): void { + Route::get('notifications/unsubscribe/{user}', [NotificationUnsubscribeController::class, 'unsubscribe'])->name('notifications.unsubscribe'); + Route::get('notifications/resubscribe/{user}', [NotificationUnsubscribeController::class, 'resubscribe'])->name('notifications.resubscribe'); +}); + Route::get('.well-known/assetlinks.json', [ApplinksController::class, 'assetLinks']); Route::post('webhooks/plugins/{secret}', PluginWebhookController::class)->name('webhooks.plugins'); diff --git a/tests/Feature/Auth/EmailVerificationTest.php b/tests/Feature/Auth/EmailVerificationTest.php new file mode 100644 index 00000000..8eb6c43f --- /dev/null +++ b/tests/Feature/Auth/EmailVerificationTest.php @@ -0,0 +1,168 @@ +post('/register', [ + 'name' => 'Test User', + 'email' => 'newuser@gmail.com', + 'password' => 'password123', + 'password_confirmation' => 'password123', + ]); + + Event::assertDispatched(Registered::class, function (Registered $event) { + return $event->user->email === 'newuser@gmail.com'; + }); + } + + public function test_registration_sends_verification_email(): void + { + Notification::fake(); + + $this->post('/register', [ + 'name' => 'Test User', + 'email' => 'verifyuser@gmail.com', + 'password' => 'password123', + 'password_confirmation' => 'password123', + ]); + + $user = User::where('email', 'verifyuser@gmail.com')->first(); + + Notification::assertSentTo($user, VerifyEmail::class); + } + + public function test_verification_link_marks_user_as_verified(): void + { + $user = User::factory()->unverified()->create(); + + $verificationUrl = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(60), + ['id' => $user->id, 'hash' => sha1($user->getEmailForVerification())] + ); + + $response = $this->actingAs($user)->get($verificationUrl); + + $response->assertRedirect(route('dashboard')); + $response->assertSessionHas('success', 'Your email address has been verified.'); + $this->assertTrue($user->fresh()->hasVerifiedEmail()); + } + + public function test_resend_endpoint_sends_verification_email(): void + { + Notification::fake(); + + $user = User::factory()->unverified()->create(); + + $response = $this->actingAs($user)->post(route('verification.send')); + + $response->assertRedirect(); + $response->assertSessionHas('status', 'A new verification link has been sent to your email address.'); + Notification::assertSentTo($user, VerifyEmail::class); + } + + public function test_invalid_signature_is_rejected(): void + { + $user = User::factory()->unverified()->create(); + + $response = $this->actingAs($user)->get('/email/verify/'.$user->id.'/'.sha1($user->getEmailForVerification()).'?signature=invalid'); + + $response->assertStatus(403); + $this->assertFalse($user->fresh()->hasVerifiedEmail()); + } + + public function test_wrong_hash_is_rejected(): void + { + $user = User::factory()->unverified()->create(); + + $verificationUrl = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(60), + ['id' => $user->id, 'hash' => sha1('wrong-email@example.com')] + ); + + $response = $this->actingAs($user)->get($verificationUrl); + + $response->assertStatus(403); + $this->assertFalse($user->fresh()->hasVerifiedEmail()); + } + + public function test_dashboard_banner_visible_for_unverified_users(): void + { + $user = User::factory()->unverified()->create(); + + Livewire::actingAs($user) + ->withoutLazyLoading() + ->test(Dashboard::class) + ->assertSee('Please verify your email address.'); + } + + public function test_dashboard_banner_hidden_for_verified_users(): void + { + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->withoutLazyLoading() + ->test(Dashboard::class) + ->assertDontSee('Please verify your email address.'); + } + + public function test_already_verified_user_clicking_verify_link_is_handled_gracefully(): void + { + $user = User::factory()->create(); // already verified + + $verificationUrl = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(60), + ['id' => $user->id, 'hash' => sha1($user->getEmailForVerification())] + ); + + $response = $this->actingAs($user)->get($verificationUrl); + + $response->assertRedirect(route('dashboard')); + $this->assertTrue($user->fresh()->hasVerifiedEmail()); + } + + public function test_verification_notice_redirects_to_dashboard(): void + { + $user = User::factory()->unverified()->create(); + + $response = $this->actingAs($user)->get(route('verification.notice')); + + $response->assertRedirect(route('dashboard')); + } + + public function test_unauthenticated_user_cannot_access_verification_routes(): void + { + $response = $this->post(route('verification.send')); + + $response->assertRedirect(route('customer.login')); + } +} diff --git a/tests/Feature/Filament/UserResourceNotificationTest.php b/tests/Feature/Filament/UserResourceNotificationTest.php new file mode 100644 index 00000000..48ee9320 --- /dev/null +++ b/tests/Feature/Filament/UserResourceNotificationTest.php @@ -0,0 +1,90 @@ +admin = User::factory()->create(['email' => 'admin@test.com']); + config(['filament.users' => ['admin@test.com']]); + } + + public function test_notification_toggles_are_visible_on_edit_page(): void + { + $user = User::factory()->create(); + + $this->actingAs($this->admin) + ->get(EditUser::getUrl(['record' => $user])) + ->assertSee('Notifications') + ->assertSee('Email notifications') + ->assertSee('New plugin notifications'); + } + + public function test_admin_can_disable_email_notifications(): void + { + $user = User::factory()->create(['receives_notification_emails' => true]); + + Livewire::actingAs($this->admin) + ->test(EditUser::class, ['record' => $user->id]) + ->assertFormFieldExists('receives_notification_emails') + ->fillForm(['receives_notification_emails' => false]) + ->call('save') + ->assertHasNoFormErrors(); + + $this->assertFalse($user->fresh()->receives_notification_emails); + } + + public function test_admin_can_disable_new_plugin_notifications(): void + { + $user = User::factory()->create(['receives_new_plugin_notifications' => true]); + + Livewire::actingAs($this->admin) + ->test(EditUser::class, ['record' => $user->id]) + ->assertFormFieldExists('receives_new_plugin_notifications') + ->fillForm(['receives_new_plugin_notifications' => false]) + ->call('save') + ->assertHasNoFormErrors(); + + $this->assertFalse($user->fresh()->receives_new_plugin_notifications); + } + + public function test_admin_cannot_enable_user_disabled_email_notifications(): void + { + $user = User::factory()->create(['receives_notification_emails' => false]); + + Livewire::actingAs($this->admin) + ->test(EditUser::class, ['record' => $user->id]) + ->assertFormFieldIsDisabled('receives_notification_emails'); + } + + public function test_admin_cannot_enable_user_disabled_new_plugin_notifications(): void + { + $user = User::factory()->create(['receives_new_plugin_notifications' => false]); + + Livewire::actingAs($this->admin) + ->test(EditUser::class, ['record' => $user->id]) + ->assertFormFieldIsDisabled('receives_new_plugin_notifications'); + } + + public function test_notifications_section_shows_description(): void + { + $user = User::factory()->create(); + + $this->actingAs($this->admin) + ->get(EditUser::getUrl(['record' => $user])) + ->assertSee('Once these are disabled, they cannot be re-enabled by an admin.'); + } +} diff --git a/tests/Feature/NotificationUnsubscribeTest.php b/tests/Feature/NotificationUnsubscribeTest.php new file mode 100644 index 00000000..22e274ad --- /dev/null +++ b/tests/Feature/NotificationUnsubscribeTest.php @@ -0,0 +1,198 @@ +create(); + + $this->withoutVite() + ->get(route('notifications.unsubscribe', ['user' => $user])) + ->assertForbidden(); + } + + public function test_unauthenticated_user_can_unsubscribe_via_signed_url(): void + { + $user = User::factory()->create(['receives_new_plugin_notifications' => true]); + + $url = NotificationUnsubscribeController::signedUnsubscribeUrl($user); + + $this->withoutVite() + ->get($url) + ->assertOk() + ->assertViewIs('notifications.unsubscribed') + ->assertSee('Notifications Disabled'); + + $this->assertFalse($user->fresh()->receives_new_plugin_notifications); + } + + public function test_unsubscribe_shows_masked_email_for_guest(): void + { + $user = User::factory()->create([ + 'email' => 'janedoe@example.com', + 'receives_new_plugin_notifications' => true, + ]); + + $url = NotificationUnsubscribeController::signedUnsubscribeUrl($user); + + $this->withoutVite() + ->get($url) + ->assertOk() + ->assertSee('j*****e@example.com') + ->assertDontSee('janedoe@example.com'); + } + + public function test_unsubscribe_shows_resubscribe_button_for_guest(): void + { + $user = User::factory()->create(['receives_new_plugin_notifications' => true]); + + $url = NotificationUnsubscribeController::signedUnsubscribeUrl($user); + + $this->withoutVite() + ->get($url) + ->assertOk() + ->assertSee('Re-enable Notifications'); + } + + public function test_authenticated_user_unsubscribe_redirects_to_settings(): void + { + $user = User::factory()->create(['receives_new_plugin_notifications' => true]); + + $url = NotificationUnsubscribeController::signedUnsubscribeUrl($user); + + $this->withoutVite() + ->actingAs($user) + ->get($url) + ->assertRedirect(route('customer.settings', ['tab' => 'notifications'])) + ->assertSessionHas('new-plugin-notifications-disabled', true); + + $this->assertFalse($user->fresh()->receives_new_plugin_notifications); + } + + public function test_unsubscribe_does_not_create_session_for_guest(): void + { + $user = User::factory()->create(['receives_new_plugin_notifications' => true]); + + $url = NotificationUnsubscribeController::signedUnsubscribeUrl($user); + + $this->withoutVite() + ->get($url) + ->assertOk(); + + $this->assertGuest(); + } + + public function test_unsubscribe_is_idempotent(): void + { + $user = User::factory()->create(['receives_new_plugin_notifications' => false]); + + $url = NotificationUnsubscribeController::signedUnsubscribeUrl($user); + + $this->withoutVite() + ->get($url) + ->assertOk(); + + $this->assertFalse($user->fresh()->receives_new_plugin_notifications); + } + + // --- Resubscribe --- + + public function test_resubscribe_requires_valid_signature(): void + { + $user = User::factory()->create(); + + $this->withoutVite() + ->get(route('notifications.resubscribe', ['user' => $user])) + ->assertForbidden(); + } + + public function test_unauthenticated_user_can_resubscribe_via_signed_url(): void + { + $user = User::factory()->create(['receives_new_plugin_notifications' => false]); + + $url = url()->signedRoute('notifications.resubscribe', ['user' => $user]); + + $this->withoutVite() + ->get($url) + ->assertOk() + ->assertViewIs('notifications.resubscribed') + ->assertSee('Notifications Enabled'); + + $this->assertTrue($user->fresh()->receives_new_plugin_notifications); + } + + public function test_resubscribe_shows_masked_email_for_guest(): void + { + $user = User::factory()->create([ + 'email' => 'janedoe@example.com', + 'receives_new_plugin_notifications' => false, + ]); + + $url = url()->signedRoute('notifications.resubscribe', ['user' => $user]); + + $this->withoutVite() + ->get($url) + ->assertOk() + ->assertSee('j*****e@example.com') + ->assertDontSee('janedoe@example.com'); + } + + public function test_authenticated_user_resubscribe_redirects_to_settings(): void + { + $user = User::factory()->create(['receives_new_plugin_notifications' => false]); + + $url = url()->signedRoute('notifications.resubscribe', ['user' => $user]); + + $this->withoutVite() + ->actingAs($user) + ->get($url) + ->assertRedirect(route('customer.settings', ['tab' => 'notifications'])) + ->assertSessionHas('new-plugin-notifications-enabled', true); + + $this->assertTrue($user->fresh()->receives_new_plugin_notifications); + } + + // --- Email masking edge cases --- + + public function test_short_email_local_part_is_masked_correctly(): void + { + $user = User::factory()->create([ + 'email' => 'ab@example.com', + 'receives_new_plugin_notifications' => true, + ]); + + $url = NotificationUnsubscribeController::signedUnsubscribeUrl($user); + + $this->withoutVite() + ->get($url) + ->assertOk() + ->assertSee('a*@example.com') + ->assertDontSee('ab@example.com'); + } + + public function test_single_char_email_local_part_is_masked_correctly(): void + { + $user = User::factory()->create([ + 'email' => 'x@example.com', + 'receives_new_plugin_notifications' => true, + ]); + + $url = NotificationUnsubscribeController::signedUnsubscribeUrl($user); + + $this->withoutVite() + ->get($url) + ->assertOk() + ->assertDontSee('x@example.com'); + } +} diff --git a/tests/Feature/Notifications/NewPluginAvailableTest.php b/tests/Feature/Notifications/NewPluginAvailableTest.php index 1ff1389c..07c14157 100644 --- a/tests/Feature/Notifications/NewPluginAvailableTest.php +++ b/tests/Feature/Notifications/NewPluginAvailableTest.php @@ -111,7 +111,7 @@ public function test_database_notification_contains_plugin_data(): void $this->assertEquals('View Plugin', $data['action_label']); } - public function test_mail_contains_notification_preferences_link(): void + public function test_mail_contains_signed_unsubscribe_link(): void { $user = User::factory()->create(); $plugin = Plugin::factory()->for($user)->create(); @@ -119,12 +119,13 @@ public function test_mail_contains_notification_preferences_link(): void $notification = new NewPluginAvailable($plugin); $mail = $notification->toMail($user); - $expectedUrl = route('customer.settings', ['tab' => 'notifications']); - $found = collect($mail->introLines)->concat($mail->outroLines)->contains(function ($line) use ($expectedUrl) { - return str_contains($line, $expectedUrl); + $baseUrl = route('notifications.unsubscribe', ['user' => $user]); + $found = collect($mail->introLines)->concat($mail->outroLines)->contains(function ($line) use ($baseUrl) { + return str_contains($line, 'Unsubscribe from new plugin notifications') + && str_contains($line, $baseUrl); }); - $this->assertTrue($found, 'Mail should contain a link to the notification preferences page.'); + $this->assertTrue($found, 'Mail should contain a signed unsubscribe link.'); } public function test_new_users_receive_new_plugin_notifications_by_default(): void