diff --git a/.env.example b/.env.example index 6523fda5c..9335d53af 100644 --- a/.env.example +++ b/.env.example @@ -116,6 +116,19 @@ TURNSTILE_SECRET_KEY= # Leave empty to rely on the widget's domain list in the Cloudflare dashboard. TURNSTILE_HOSTNAMES= +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +GITHUB_TOKEN= +# When the legacy OAuth App stops working, e.g. 2026-12-31. Shown in the upgrade banner and email. +GITHUB_LEGACY_OAUTH_CUTOFF_DATE= + +GITHUB_APP_ID= +GITHUB_APP_CLIENT_ID= +GITHUB_APP_CLIENT_SECRET= +# The app's PEM private key, in double quotes. Either paste it across multiple lines or put it on one line with \n for each line break. +GITHUB_APP_PRIVATE_KEY= +GITHUB_APP_WEBHOOK_SECRET= + # TypeSafe's Jev model groups plugin marketplace searches that found nothing # into plugin ideas (see ClassifyMissedPluginSearch). TYPESAFE_API_KEY= diff --git a/app/Console/Commands/RetireLegacyGitHubOAuth.php b/app/Console/Commands/RetireLegacyGitHubOAuth.php new file mode 100644 index 000000000..7ccfa41ae --- /dev/null +++ b/app/Console/Commands/RetireLegacyGitHubOAuth.php @@ -0,0 +1,81 @@ +option('dry-run'); + $force = $this->option('force'); + + if (! $force && ! $appService->legacyOAuthHasBeenRetired()) { + $cutoffDate = $appService->legacyOAuthCutoffDate(); + + $this->error($cutoffDate + ? "The cutoff date ({$cutoffDate->format('j F Y')}) hasn't passed yet. Use --force to run it anyway." + : 'GITHUB_LEGACY_OAUTH_CUTOFF_DATE isn\'t set. Set it, or use --force to run it anyway.'); + + return self::FAILURE; + } + + $users = User::query() + ->where('github_auth_type', GitHubAuthType::OAuth) + ->whereNotNull('github_token') + ->with('plugins') + ->get(); + + $authors = $users->filter(fn (User $user): bool => $user->plugins->isNotEmpty()); + + $this->info("Found {$users->count()} user(s) with a legacy OAuth token, {$authors->count()} of them plugin authors"); + + if ($authors->isNotEmpty()) { + $this->newLine(); + $this->warn('These plugin authors never connected the GitHub App. Their plugins will only sync if the repo is public:'); + $this->table( + ['User', 'Email', 'Plugins'], + $authors->map(fn (User $user): array => [ + $user->id, + $user->email, + $user->plugins->pluck('name')->filter()->implode(', '), + ]) + ); + } + + if ($dryRun) { + $this->info('DRY RUN - No tokens were cleared'); + + return self::SUCCESS; + } + + if (! $force && ! $this->confirm("Clear {$users->count()} legacy token(s)? GitHub IDs and usernames are kept, so sign-in and repo invites keep working.")) { + $this->info('Nothing changed.'); + + return self::SUCCESS; + } + + $cleared = User::query() + ->whereKey($users->modelKeys()) + ->update([ + 'github_token' => null, + 'github_refresh_token' => null, + 'github_token_expires_at' => null, + ]); + + $this->info("Cleared {$cleared} legacy token(s)."); + $this->line('If you haven\'t already, delete the old OAuth App on GitHub and remove GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET.'); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/SendGitHubAppMigrationNotice.php b/app/Console/Commands/SendGitHubAppMigrationNotice.php new file mode 100644 index 000000000..cbd4ef446 --- /dev/null +++ b/app/Console/Commands/SendGitHubAppMigrationNotice.php @@ -0,0 +1,69 @@ +option('preview')) { + Notification::route('mail', $preview)->notifyNow(new GitHubAppMigrationRequired); + + $this->info("Sent a preview to {$preview}"); + + return self::SUCCESS; + } + + $dryRun = $this->option('dry-run'); + + if (! $dryRun && ! $appService->legacyOAuthCutoffDate()) { + $this->error('Set GITHUB_LEGACY_OAUTH_CUTOFF_DATE first, the email tells plugin authors when the old connection stops working.'); + + return self::FAILURE; + } + + if ($dryRun) { + $this->info('DRY RUN - No emails will be sent'); + } + + $users = User::query() + ->where('github_auth_type', GitHubAuthType::OAuth) + ->whereNull('github_app_migration_notified_at') + ->whereNotNull('email_verified_at') + ->withCount('plugins') + ->get(); + + $this->info("Found {$users->count()} user(s) still on the legacy OAuth App who haven't been emailed, {$users->where('plugins_count', '>', 0)->count()} of them plugin authors"); + + foreach ($users as $user) { + if ($dryRun) { + $this->line("Would send to: {$user->email} ({$user->plugins_count} plugin(s))"); + + continue; + } + + $user->notify(new GitHubAppMigrationRequired); + $user->update(['github_app_migration_notified_at' => now()]); + + $this->line("Sent to: {$user->email}"); + } + + $this->newLine(); + $this->info($dryRun ? "Would send: {$users->count()} email(s)" : "Sent: {$users->count()} email(s)"); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/SyncGitHubInstallations.php b/app/Console/Commands/SyncGitHubInstallations.php new file mode 100644 index 000000000..f45935317 --- /dev/null +++ b/app/Console/Commands/SyncGitHubInstallations.php @@ -0,0 +1,44 @@ +with('user')->chunkById(100, function ($installations) use ($appService, &$synced, &$removed, &$failed): void { + foreach ($installations as $installation) { + if ($appService->syncInstallation($installation)) { + $synced++; + } elseif (! $installation->exists) { + $removed++; + $this->line("Removed installation {$installation->installation_id} ({$installation->account_login}), GitHub no longer has it"); + } else { + $failed++; + $this->error("Failed to sync installation {$installation->installation_id} ({$installation->account_login})"); + } + + if ($installation->user) { + GitHubUserService::for($installation->user)->clearRepositoryCache(); + } + } + }); + + $this->info("Synced: {$synced}, removed: {$removed}, failed: {$failed}"); + + return self::SUCCESS; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 55812c394..08fe3bb37 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -18,6 +18,12 @@ protected function schedule(Schedule $schedule): void ->onOneServer() ->runInBackground(); + // Reconcile GitHub App installations in case we missed any installation webhooks + $schedule->command('github:sync-installations') + ->dailyAt('09:30') + ->onOneServer() + ->runInBackground(); + // Remove Discord Max role for users with expired Max licenses $schedule->command('discord:remove-expired-roles') ->dailyAt('10:30') diff --git a/app/Enums/GitHubAuthType.php b/app/Enums/GitHubAuthType.php new file mode 100644 index 000000000..555671d72 --- /dev/null +++ b/app/Enums/GitHubAuthType.php @@ -0,0 +1,17 @@ + 'OAuth App', + self::App => 'GitHub App', + }; + } +} diff --git a/app/Filament/Resources/UserResource.php b/app/Filament/Resources/UserResource.php index 888255ac3..71eead622 100644 --- a/app/Filament/Resources/UserResource.php +++ b/app/Filament/Resources/UserResource.php @@ -2,6 +2,7 @@ namespace App\Filament\Resources; +use App\Enums\GitHubAuthType; use App\Filament\Resources\UserResource\Pages; use App\Filament\Resources\UserResource\RelationManagers; use App\Models\User; @@ -147,6 +148,18 @@ public static function table(Table $table): Table ->label('Developer') ->boolean() ->getStateUsing(fn (User $record) => $record->developerAccount !== null), + Tables\Columns\TextColumn::make('github_auth_type') + ->label('GitHub') + ->badge() + ->formatStateUsing(fn (GitHubAuthType $state) => $state->label()) + ->color(fn (GitHubAuthType $state) => $state === GitHubAuthType::App ? 'success' : 'warning') + ->placeholder('—') + ->toggleable(), + Tables\Columns\TextColumn::make('github_app_migration_notified_at') + ->label('GitHub App email sent') + ->dateTime() + ->placeholder('—') + ->toggleable(isToggledHiddenByDefault: true), Tables\Columns\TextColumn::make('created_at') ->dateTime() ->sortable(), @@ -155,7 +168,14 @@ public static function table(Table $table): Table ->sortable(), ]) ->filters([ - // + Tables\Filters\SelectFilter::make('github_auth_type') + ->label('GitHub connection') + ->options(collect(GitHubAuthType::cases())->mapWithKeys( + fn (GitHubAuthType $type) => [$type->value => $type->label()] + )), + Tables\Filters\Filter::make('plugin_authors_on_legacy_oauth') + ->label('Plugin authors still on the OAuth App') + ->query(fn ($query) => $query->where('github_auth_type', GitHubAuthType::OAuth)->has('plugins')), ]) ->actions([ Impersonate::make(), diff --git a/app/Http/Controllers/GitHubAppWebhookController.php b/app/Http/Controllers/GitHubAppWebhookController.php new file mode 100644 index 000000000..9f9c3a036 --- /dev/null +++ b/app/Http/Controllers/GitHubAppWebhookController.php @@ -0,0 +1,213 @@ +verifySignature($request)) { + Log::warning('[GitHubAppWebhook] Invalid signature'); + + return response()->json(['error' => 'Invalid signature'], 403); + } + + $event = $request->header('X-GitHub-Event'); + $payload = $request->all(); + + return match ($event) { + 'installation' => $this->handleInstallation($payload), + 'installation_repositories' => $this->handleInstallationRepositories($payload), + 'push', 'release' => $this->handleRepositoryEvent($event, $payload), + default => response()->json(['status' => 'ignored']), + }; + } + + protected function verifySignature(Request $request): bool + { + $secret = config('services.github_app.webhook_secret'); + + if (! $secret) { + return false; + } + + $signature = $request->header('X-Hub-Signature-256'); + + if (! $signature) { + return false; + } + + $expected = 'sha256='.hash_hmac('sha256', $request->getContent(), $secret); + + return hash_equals($expected, $signature); + } + + protected function handleInstallation(array $payload): JsonResponse + { + $action = $payload['action'] ?? null; + $installation = $payload['installation'] ?? []; + $sender = $payload['sender'] ?? []; + + $installationId = $installation['id'] ?? null; + + if (! $installationId) { + return response()->json(['error' => 'Missing installation ID'], 400); + } + + return match ($action) { + 'created' => $this->installationCreated($installation, $sender), + 'deleted' => $this->installationDeleted($installationId), + 'suspend' => $this->installationSuspended($installationId), + 'unsuspend' => $this->installationUnsuspended($installationId), + default => response()->json(['status' => 'ignored']), + }; + } + + protected function installationCreated(array $installation, array $sender): JsonResponse + { + $user = User::where('github_id', $sender['id'] ?? null)->first(); + + if (! $user) { + Log::info('[GitHubAppWebhook] Installation created by unknown user', [ + 'sender_id' => $sender['id'] ?? null, + 'installation_id' => $installation['id'], + ]); + + return response()->json(['status' => 'user_not_found']); + } + + $repos = collect($installation['repositories'] ?? [])->pluck('full_name')->all(); + + $user->githubInstallations()->updateOrCreate( + ['installation_id' => $installation['id']], + [ + 'account_login' => $installation['account']['login'] ?? 'unknown', + 'account_type' => $installation['account']['type'] ?? 'User', + 'account_id' => $installation['account']['id'] ?? null, + 'selection_type' => $installation['repository_selection'] ?? 'all', + 'repository_selection' => ! empty($repos) ? $repos : null, + ] + ); + + GitHubUserService::for($user)->clearRepositoryCache(); + + Log::info('[GitHubAppWebhook] Installation created', [ + 'user_id' => $user->id, + 'installation_id' => $installation['id'], + ]); + + return response()->json(['status' => 'created']); + } + + protected function installationDeleted(int $installationId): JsonResponse + { + GitHubInstallation::where('installation_id', $installationId)->delete(); + + Log::info('[GitHubAppWebhook] Installation deleted', [ + 'installation_id' => $installationId, + ]); + + return response()->json(['status' => 'deleted']); + } + + protected function installationSuspended(int $installationId): JsonResponse + { + GitHubInstallation::where('installation_id', $installationId) + ->update(['suspended_at' => now()]); + + return response()->json(['status' => 'suspended']); + } + + protected function installationUnsuspended(int $installationId): JsonResponse + { + GitHubInstallation::where('installation_id', $installationId) + ->update(['suspended_at' => null]); + + return response()->json(['status' => 'unsuspended']); + } + + protected function handleInstallationRepositories(array $payload): JsonResponse + { + $installationId = $payload['installation']['id'] ?? null; + + if (! $installationId) { + return response()->json(['error' => 'Missing installation ID'], 400); + } + + $installation = GitHubInstallation::where('installation_id', $installationId)->first(); + + if (! $installation) { + return response()->json(['status' => 'installation_not_found']); + } + + $selection = $payload['repository_selection'] ?? $installation->selection_type; + $currentRepos = $installation->repository_selection ?? []; + + $addedRepos = collect($payload['repositories_added'] ?? [])->pluck('full_name')->all(); + $removedRepos = collect($payload['repositories_removed'] ?? [])->pluck('full_name')->all(); + + $updatedRepos = array_values( + array_unique( + array_diff( + array_merge($currentRepos, $addedRepos), + $removedRepos + ) + ) + ); + + $installation->update([ + 'selection_type' => $selection, + 'repository_selection' => ! empty($updatedRepos) ? $updatedRepos : null, + ]); + + GitHubUserService::for($installation->user)->clearRepositoryCache(); + + Log::info('[GitHubAppWebhook] Repositories updated', [ + 'installation_id' => $installationId, + 'added' => $addedRepos, + 'removed' => $removedRepos, + ]); + + return response()->json(['status' => 'updated']); + } + + /** + * Push and release events arrive here for every repository the app is installed on, so plugins + * the app covers stay in sync without a per-repository webhook. + */ + protected function handleRepositoryEvent(string $event, array $payload): JsonResponse + { + $fullName = $payload['repository']['full_name'] ?? null; + + if (! $fullName) { + return response()->json(['error' => 'Missing repository'], 400); + } + + $plugins = Plugin::query() + ->where('repository_url', "https://github.com/{$fullName}") + ->get() + ->filter(fn (Plugin $plugin): bool => $plugin->isActive()); + + $syncService = app(PluginSyncService::class); + + foreach ($plugins as $plugin) { + $syncService->sync($plugin); + + if ($event === 'release') { + dispatch(new SyncPluginReleases($plugin)); + } + } + + return response()->json(['status' => 'synced', 'plugins' => $plugins->count()]); + } +} diff --git a/app/Http/Controllers/GitHubAuthController.php b/app/Http/Controllers/GitHubAuthController.php index 731eb149f..90d9007fc 100644 --- a/app/Http/Controllers/GitHubAuthController.php +++ b/app/Http/Controllers/GitHubAuthController.php @@ -9,10 +9,24 @@ class GitHubAuthController extends Controller { public function redirect(): RedirectResponse { - session(['github_auth_intent' => 'login']); + $driver = $this->resolveDriver(); - return Socialite::driver('github') + session([ + 'github_auth_intent' => 'login', + 'github_auth_driver' => $driver, + ]); + + return Socialite::driver($driver) ->scopes(['read:user', 'user:email']) ->redirect(); } + + protected function resolveDriver(): string + { + if (config('services.github_app.client_id')) { + return 'github-app'; + } + + return 'github'; + } } diff --git a/app/Http/Controllers/GitHubIntegrationController.php b/app/Http/Controllers/GitHubIntegrationController.php index e113a19de..7891c5fee 100644 --- a/app/Http/Controllers/GitHubIntegrationController.php +++ b/app/Http/Controllers/GitHubIntegrationController.php @@ -2,12 +2,16 @@ namespace App\Http\Controllers; +use App\Enums\GitHubAuthType; +use App\Models\GitHubInstallation; use App\Models\Product; use App\Models\User; +use App\Services\GitHubAppService; use App\Services\GitHubUserService; use App\Support\GitHubOAuth; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; @@ -23,31 +27,42 @@ public function __construct() public function redirectToGitHub(): RedirectResponse { - session(['github_auth_intent' => 'link']); + $driver = $this->resolveDriver(); + + session([ + 'github_auth_intent' => 'link', + 'github_auth_driver' => $driver, + ]); // Store the return URL if provided if (request()->has('return')) { session(['github_return_url' => request()->get('return')]); } - return Socialite::driver('github') - ->scopes(['read:user', 'repo']) + $scopes = $driver === 'github-app' + ? ['read:user', 'user:email'] + : ['read:user', 'repo']; + + return Socialite::driver($driver) + ->scopes($scopes) ->redirect(); } public function handleCallback(): RedirectResponse { try { - $githubUser = Socialite::driver('github')->user(); + $driver = session()->pull('github_auth_driver', 'github'); + $githubUser = Socialite::driver($driver)->user(); $intent = session()->pull('github_auth_intent', 'link'); + $authType = $driver === 'github-app' ? GitHubAuthType::App : GitHubAuthType::OAuth; if (Auth::check()) { - return $this->handleLinkAccount($githubUser); + return $this->handleLinkAccount($githubUser, $authType); } if ($intent === 'login') { - return $this->handleLogin($githubUser); + return $this->handleLogin($githubUser, $authType); } return to_route('customer.login') @@ -65,17 +80,26 @@ public function handleCallback(): RedirectResponse } } - protected function handleLinkAccount($githubUser): RedirectResponse + protected function handleLinkAccount($githubUser, GitHubAuthType $authType): RedirectResponse { $user = Auth::user(); - $user->update([ + + $this->saveGitHubCredentials($user, $githubUser, $authType, [ 'github_id' => $githubUser->id, 'github_username' => $githubUser->nickname, - 'github_token' => encrypt($githubUser->token), ]); $returnUrl = session()->pull('github_return_url'); + // For GitHub App users, redirect to install the app for repo access + if ($authType === GitHubAuthType::App && $installUrl = app(GitHubAppService::class)->installationUrl()) { + if ($returnUrl) { + session(['github_return_url' => $returnUrl]); + } + + return redirect($installUrl); + } + if ($returnUrl) { return redirect($returnUrl) ->with('success', 'GitHub account connected successfully!'); @@ -85,34 +109,29 @@ protected function handleLinkAccount($githubUser): RedirectResponse ->with('success', 'GitHub account connected successfully!'); } - protected function handleLogin($githubUser): RedirectResponse + protected function handleLogin($githubUser, GitHubAuthType $authType): RedirectResponse { $user = User::where('github_id', $githubUser->id)->first(); if ($user) { - $user->update([ - 'github_token' => encrypt($githubUser->token), - ]); + $this->saveGitHubCredentials($user, $githubUser, $authType); Auth::login($user, remember: true); - return redirect()->intended(route('dashboard')) - ->with('success', 'Welcome back!'); + return $this->redirectAfterLogin($user, $authType, 'Welcome back!'); } $user = User::where('email', $githubUser->email)->first(); if ($user) { - $user->update([ + $this->saveGitHubCredentials($user, $githubUser, $authType, [ 'github_id' => $githubUser->id, 'github_username' => $githubUser->nickname, - 'github_token' => encrypt($githubUser->token), ]); Auth::login($user, remember: true); - return redirect()->intended(route('dashboard')) - ->with('success', 'GitHub account connected and logged in!'); + return $this->redirectAfterLogin($user, $authType, 'GitHub account connected and logged in!'); } $user = User::create([ @@ -120,7 +139,7 @@ protected function handleLogin($githubUser): RedirectResponse 'email' => $githubUser->email, 'github_id' => $githubUser->id, 'github_username' => $githubUser->nickname, - 'github_token' => encrypt($githubUser->token), + ...$this->credentialAttributes($githubUser, $authType), 'password' => bcrypt(Str::random(24)), 'email_verified_at' => now(), ]); @@ -131,6 +150,104 @@ protected function handleLogin($githubUser): RedirectResponse ->with('success', 'Account created successfully!'); } + /** + * @return array + */ + protected function credentialAttributes($githubUser, GitHubAuthType $authType): array + { + return [ + 'github_token' => encrypt($githubUser->token), + 'github_auth_type' => $authType, + 'github_refresh_token' => $githubUser->refreshToken ? encrypt($githubUser->refreshToken) : null, + 'github_token_expires_at' => $githubUser->expiresIn ? now()->addSeconds($githubUser->expiresIn) : null, + ]; + } + + /** + * Save the user's new GitHub credentials. When they're moving from the legacy OAuth App to the + * GitHub App, their old authorization is revoked so its broad repo access stops working. + * + * @param array $attributes + */ + protected function saveGitHubCredentials(User $user, $githubUser, GitHubAuthType $authType, array $attributes = []): void + { + $legacyToken = $user->isUsingLegacyOAuth() && $authType === GitHubAuthType::App + ? $user->getGitHubToken() + : null; + + $user->update([...$attributes, ...$this->credentialAttributes($githubUser, $authType)]); + + if ($legacyToken) { + GitHubOAuth::make()->revokeOAuthGrant($legacyToken); + } + } + + /** + * Send plugin authors whose repositories the GitHub App can't reach to the installation + * page, so signing in with the app never silently cuts off their plugin syncing. + */ + protected function redirectAfterLogin(User $user, GitHubAuthType $authType, string $message): RedirectResponse + { + $installUrl = app(GitHubAppService::class)->installationUrl(); + + if ($authType === GitHubAuthType::App && $installUrl && $user->pluginsMissingGitHubAppAccess()->isNotEmpty()) { + session(['github_return_url' => session()->pull('url.intended', route('dashboard'))]); + + return redirect($installUrl); + } + + return redirect()->intended(route('dashboard')) + ->with('success', $message); + } + + public function handleSetup(Request $request): RedirectResponse + { + $installationId = (int) $request->query('installation_id'); + + if (! $installationId) { + return to_route('customer.integrations') + ->with('error', 'No installation ID provided.'); + } + + $user = Auth::user(); + $appService = app(GitHubAppService::class); + $installation = GitHubInstallation::where('installation_id', $installationId)->first(); + + if ($installation && $installation->user_id !== $user->id) { + return to_route('customer.integrations') + ->with('error', 'That GitHub App installation is already linked to another NativePHP account.'); + } + + if (! $installation) { + if (! $user->isUsingGitHubApp() || ! $appService->userCanAccessInstallation($user, $installationId)) { + return to_route('customer.integrations') + ->with('error', "We couldn't confirm that GitHub App installation belongs to your GitHub account. Please connect GitHub and try again."); + } + + $installation = $user->githubInstallations()->create([ + 'installation_id' => $installationId, + 'account_login' => $user->github_username ?? 'unknown', + ]); + } + + if (! $appService->syncInstallation($installation) && ! $installation->exists) { + return to_route('customer.integrations') + ->with('error', 'That GitHub App installation no longer exists.'); + } + + GitHubUserService::for($user)->clearRepositoryCache(); + + $returnUrl = session()->pull('github_return_url'); + + if ($returnUrl) { + return redirect($returnUrl) + ->with('success', 'GitHub App installed successfully!'); + } + + return to_route('customer.integrations') + ->with('success', 'GitHub App installed successfully!'); + } + public function requestRepoAccess(): RedirectResponse { $user = Auth::user(); @@ -199,10 +316,15 @@ public function disconnect(): RedirectResponse $github->removeFromClaudePluginsRepo($user->github_username); } + $user->githubInstallations()->delete(); + $user->update([ 'github_id' => null, 'github_username' => null, 'github_token' => null, + 'github_auth_type' => null, + 'github_refresh_token' => null, + 'github_token_expires_at' => null, 'mobile_repo_access_granted_at' => null, 'claude_plugins_repo_access_granted_at' => null, ]); @@ -231,4 +353,13 @@ public function repositories(): JsonResponse 'repositories' => $repositories, ]); } + + protected function resolveDriver(): string + { + if (config('services.github_app.client_id')) { + return 'github-app'; + } + + return 'github'; + } } diff --git a/app/Http/Middleware/VerifyCsrfToken.php b/app/Http/Middleware/VerifyCsrfToken.php index 6d7c93823..cdabe6d48 100644 --- a/app/Http/Middleware/VerifyCsrfToken.php +++ b/app/Http/Middleware/VerifyCsrfToken.php @@ -15,5 +15,6 @@ class VerifyCsrfToken extends Middleware 'stripe/webhook', 'opencollective/contribution', 'webhooks/plugins/*', + 'webhooks/github-app', ]; } diff --git a/app/Jobs/Concerns/ResolvesGitHubToken.php b/app/Jobs/Concerns/ResolvesGitHubToken.php index e3b3dbd3a..1974904fb 100644 --- a/app/Jobs/Concerns/ResolvesGitHubToken.php +++ b/app/Jobs/Concerns/ResolvesGitHubToken.php @@ -3,6 +3,7 @@ namespace App\Jobs\Concerns; use App\Models\Plugin; +use App\Services\GitHubAppService; use Illuminate\Support\Facades\Log; trait ResolvesGitHubToken @@ -18,7 +19,29 @@ protected function getGitHubToken(): ?string protected function resolveGitHubTokenFor(Plugin $plugin): ?string { $user = $plugin->user; + $repo = $plugin->getRepositoryOwnerAndName(); + // Priority 1: Installation token (GitHub App) + if ($user && $repo && $user->isUsingGitHubApp()) { + $appService = app(GitHubAppService::class); + $installation = $appService->findInstallationForRepo($user, $repo['owner'], $repo['repo']); + + if ($installation) { + $token = $appService->getInstallationToken($installation); + + if ($token) { + Log::debug('[GitHub] Using installation token', [ + 'plugin_id' => $plugin->id, + 'user_id' => $user->id, + 'installation_id' => $installation->installation_id, + ]); + + return $token; + } + } + } + + // Priority 2: User OAuth token if ($user && $user->hasGitHubToken()) { Log::debug('[GitHub] Using plugin owner OAuth token', [ 'plugin_id' => $plugin->id, @@ -29,6 +52,7 @@ protected function resolveGitHubTokenFor(Plugin $plugin): ?string return $user->getGitHubToken(); } + // Priority 3: Platform token $platformToken = config('services.github.token'); Log::debug('[GitHub] Using platform token fallback', [ diff --git a/app/Livewire/Customer/Plugins/Create.php b/app/Livewire/Customer/Plugins/Create.php index dd432aedc..301a4a159 100644 --- a/app/Livewire/Customer/Plugins/Create.php +++ b/app/Livewire/Customer/Plugins/Create.php @@ -118,6 +118,12 @@ public function createPlugin(PluginSyncService $syncService): void { $user = auth()->user(); + if ($user->needsGitHubAppMigration()) { + session()->flash('error', 'Please upgrade your GitHub connection before creating a plugin.'); + + return; + } + if (! $user->github_id) { $this->addError('repository', 'You must connect your GitHub account to create a plugin.'); diff --git a/app/Livewire/Customer/Plugins/Show.php b/app/Livewire/Customer/Plugins/Show.php index 84659b6f3..587b3b7be 100644 --- a/app/Livewire/Customer/Plugins/Show.php +++ b/app/Livewire/Customer/Plugins/Show.php @@ -137,8 +137,12 @@ public function runPreflightChecks(): void $this->plugin->generateWebhookSecret(); } - // Verify or install webhook - if ($repoInfo && $user->hasGitHubToken()) { + // The GitHub App delivers push and release events for repos it covers, so no per-repo hook is needed + if ($this->plugin->isReachableViaGitHubApp()) { + if (! $this->plugin->webhook_installed) { + $this->plugin->update(['webhook_installed' => true]); + } + } elseif ($repoInfo && $user->hasGitHubToken()) { $githubService = GitHubUserService::for($user); $webhookUrl = $this->plugin->getWebhookUrl(); @@ -246,6 +250,15 @@ public function retryWebhook(): void $user = auth()->user(); $repoInfo = $this->plugin->getRepositoryOwnerAndName(); + if ($this->plugin->isReachableViaGitHubApp()) { + $this->plugin->update(['webhook_installed' => true]); + $this->plugin->refresh(); + + Flux::toast(variant: 'success', text: 'The NativePHP GitHub App keeps this plugin in sync, so no webhook is needed.'); + + return; + } + if (! $repoInfo || ! $user->hasGitHubToken()) { Flux::toast(variant: 'danger', text: 'Unable to register webhook automatically. Please ensure your GitHub account is connected and the repository URL is valid.'); diff --git a/app/Livewire/GitHubAppStatus.php b/app/Livewire/GitHubAppStatus.php new file mode 100644 index 000000000..31f9e15ba --- /dev/null +++ b/app/Livewire/GitHubAppStatus.php @@ -0,0 +1,45 @@ +githubInstallations()->orderBy('account_login')->get(); + $plugins = $user->plugins()->get(); + + // Check which plugin repos are covered by installations + $pluginCoverage = []; + foreach ($plugins as $plugin) { + $repo = $plugin->getRepositoryOwnerAndName(); + + if (! $repo) { + continue; + } + + $covered = $installations->contains( + fn ($installation) => $installation->hasAccessToRepo($repo['owner'], $repo['repo']) + ); + + $pluginCoverage[] = [ + 'plugin' => $plugin, + 'owner' => $repo['owner'], + 'repo' => $repo['repo'], + 'covered' => $covered, + ]; + } + + return view('livewire.git-hub-app-status', [ + 'installations' => $installations, + 'pluginCoverage' => $pluginCoverage, + 'installUrl' => app(GitHubAppService::class)->installationUrl(), + ]); + } +} diff --git a/app/Models/GitHubInstallation.php b/app/Models/GitHubInstallation.php new file mode 100644 index 000000000..63434f88e --- /dev/null +++ b/app/Models/GitHubInstallation.php @@ -0,0 +1,84 @@ + */ + use HasFactory; + + protected $table = 'github_installations'; + + protected $guarded = []; + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function isTokenExpired(): bool + { + if (! $this->token_expires_at) { + return true; + } + + return $this->token_expires_at->isPast(); + } + + public function isSuspended(): bool + { + return $this->suspended_at !== null; + } + + public function hasAccessToRepo(string $owner, string $repo): bool + { + if ($this->isSuspended()) { + return false; + } + + // Account login must match the repo owner + if (strtolower($this->account_login) !== strtolower($owner)) { + return false; + } + + // If "all" repos are selected, grant access + if ($this->selection_type === 'all') { + return true; + } + + // Check if the specific repo is in the selected list + $selectedRepos = $this->repository_selection ?? []; + + return in_array("{$owner}/{$repo}", $selectedRepos, true); + } + + public function getAccessToken(): ?string + { + if (! $this->access_token) { + return null; + } + + try { + return decrypt($this->access_token); + } catch (\Exception) { + return null; + } + } + + protected function casts(): array + { + return [ + 'repository_selection' => 'array', + 'token_expires_at' => 'datetime', + 'suspended_at' => 'datetime', + ]; + } +} diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index f64a63bfd..16f63d177 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -16,6 +16,7 @@ use App\Notifications\PluginDeveloperReplied; use App\Notifications\PluginMessageReceived; use App\Notifications\PluginRejected; +use App\Services\GitHubAppService; use App\Services\OgImageService; use App\Services\PluginSyncService; use App\Support\DemoVideo; @@ -755,6 +756,21 @@ public function generateWebhookSecret(): string return $secret; } + /** + * Whether the owner's GitHub App installation covers this repository, in which case the app + * delivers push and release events for it and no per-repository webhook is needed. + */ + public function isReachableViaGitHubApp(): bool + { + $repo = $this->getRepositoryOwnerAndName(); + + if (! $repo || ! $this->user?->isUsingGitHubApp()) { + return false; + } + + return app(GitHubAppService::class)->findInstallationForRepo($this->user, $repo['owner'], $repo['repo']) !== null; + } + public function getRepositoryOwnerAndName(): ?array { if (! $this->repository_url) { diff --git a/app/Models/User.php b/app/Models/User.php index 7ad76c055..ba4679feb 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,9 +2,11 @@ namespace App\Models; +use App\Enums\GitHubAuthType; use App\Enums\PriceTier; use App\Enums\Subscription; use App\Enums\TeamUserStatus; +use App\Services\GitHubAppService; use Filament\Models\Contracts\FilamentUser; use Filament\Models\Contracts\HasName; use Filament\Panel; @@ -50,6 +52,7 @@ public function withAccessToken(HasAbilities|ScopeAuthorizable|null $accessToken 'password', 'remember_token', 'github_token', + 'github_refresh_token', ]; public function getFilamentName(): string @@ -513,6 +516,14 @@ public function getGitHubToken(): ?string return null; } + if ($this->isUsingLegacyOAuth() && app(GitHubAppService::class)->legacyOAuthHasBeenRetired()) { + return null; + } + + if ($this->github_token_expires_at?->isBefore(now()->addMinute())) { + return app(GitHubAppService::class)->refreshUserToken($this); + } + try { return decrypt($this->github_token); } catch (\Exception) { @@ -520,11 +531,99 @@ public function getGitHubToken(): ?string } } + public function getGitHubRefreshToken(): ?string + { + if (! $this->github_refresh_token) { + return null; + } + + try { + return decrypt($this->github_refresh_token); + } catch (\Exception) { + return null; + } + } + public function hasGitHubToken(): bool { return $this->getGitHubToken() !== null; } + /** + * @return HasMany + */ + public function githubInstallations(): HasMany + { + return $this->hasMany(GitHubInstallation::class); + } + + public function isUsingGitHubApp(): bool + { + return $this->github_auth_type === GitHubAuthType::App; + } + + public function isUsingLegacyOAuth(): bool + { + return $this->github_auth_type === GitHubAuthType::OAuth; + } + + public function needsGitHubAppMigration(): bool + { + return $this->isUsingLegacyOAuth(); + } + + /** + * The "owner/repo" name of each of the user's plugin repositories. + * + * @return Collection + */ + public function pluginRepositoryNames(): Collection + { + return $this->plugins() + ->whereNotNull('repository_url') + ->get() + ->map(fn (Plugin $plugin): ?array => $plugin->getRepositoryOwnerAndName()) + ->filter() + ->map(fn (array $repo): string => "{$repo['owner']}/{$repo['repo']}") + ->unique() + ->values(); + } + + /** + * Whether the user has signed in with the GitHub App but hasn't installed it anywhere yet. + */ + public function needsGitHubAppInstallation(): bool + { + return $this->isUsingGitHubApp() + && ! $this->githubInstallations()->whereNull('suspended_at')->exists(); + } + + /** + * Plugins whose repositories aren't reachable through any of the user's active GitHub App installations. + * + * @return \Illuminate\Database\Eloquent\Collection + */ + public function pluginsMissingGitHubAppAccess(): \Illuminate\Database\Eloquent\Collection + { + if (! $this->isUsingGitHubApp()) { + return new \Illuminate\Database\Eloquent\Collection; + } + + $installations = $this->githubInstallations()->whereNull('suspended_at')->get(); + + return $this->plugins() + ->whereNotNull('repository_url') + ->get() + ->filter(function (Plugin $plugin) use ($installations): bool { + $repo = $plugin->getRepositoryOwnerAndName(); + + return $repo && ! $installations->contains( + fn (GitHubInstallation $installation): bool => $installation->hasAccessToRepo($repo['owner'], $repo['repo']) + ); + }) + ->values(); + } + /** * Plugin names that are available for free to eligible subscribers. */ @@ -610,6 +709,9 @@ protected function casts(): array 'claude_plugins_repo_access_granted_at' => 'datetime', 'discord_role_granted_at' => 'datetime', 'discord_early_adopter_role_granted_at' => 'datetime', + 'github_auth_type' => GitHubAuthType::class, + 'github_token_expires_at' => 'datetime', + 'github_app_migration_notified_at' => 'datetime', ]; } } diff --git a/app/Notifications/GitHubAppMigrationRequired.php b/app/Notifications/GitHubAppMigrationRequired.php new file mode 100644 index 000000000..717fa8f68 --- /dev/null +++ b/app/Notifications/GitHubAppMigrationRequired.php @@ -0,0 +1,39 @@ +name ?? null; + $firstName = $name ? explode(' ', $name)[0] : null; + $cutoffDate = app(GitHubAppService::class)->legacyOAuthCutoffDate(); + $deadline = $cutoffDate ? $cutoffDate->format('j F Y') : 'we switch off the old connection'; + + return (new MailMessage) + ->subject('A security improvement to how NativePHP connects to GitHub') + ->greeting($firstName ? "Hi {$firstName}," : 'Hi there,') + ->line('We\'re moving away from GitHub OAuth on nativephp.com, which gave us broad access to your repositories, to a GitHub App that gives you fine-grained control over what we can see.') + ->line('## How does this affect you?') + ->line('**If you use "Login with GitHub"**, you don\'t need to do anything. The difference is that the credentials we use no longer grant us access to any of your repositories. We simply use GitHub to confirm your identity.') + ->line("**If you are a plugin author**, you need to connect our new GitHub App from your dashboard. If you don't do it before {$deadline}, we won't be able to keep your plugins up to date and may remove them from the Marketplace.") + ->action('Connect the GitHub App', route('customer.integrations')) + ->line('This does not affect [Bifrost](https://bifrost.nativephp.com), which already uses its own GitHub App with fine-grained access to only the repositories you explicitly allow.') + ->line('Bifrost is the fastest way to ship native apps with AI and we have 30% off annual plans until the end of the year!') + ->salutation("Cheers,\n\nThe NativePHP Team"); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index fc4217f4b..a460deaad 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -14,6 +14,8 @@ use Illuminate\Support\Facades\View; use Illuminate\Support\ServiceProvider; use Laravel\Pennant\Feature; +use Laravel\Socialite\Facades\Socialite; +use Laravel\Socialite\Two\GithubProvider; use Sentry\State\Scope; use function Sentry\captureException; @@ -40,6 +42,8 @@ public function boot(): void $this->registerFeatureFlags(); + $this->registerGitHubAppSocialiteDriver(); + RateLimiter::for('anystack', function () { return Limit::perMinute(30); }); @@ -87,6 +91,24 @@ private function sendFailingJobsToSentry(): void }); } + private function registerGitHubAppSocialiteDriver(): void + { + $clientId = config('services.github_app.client_id'); + + if (! $clientId) { + return; + } + + Socialite::extend('github-app', function () use ($clientId) { + return new GithubProvider( + $this->app->make('request'), + $clientId, + config('services.github_app.client_secret'), + config('services.github_app.redirect'), + ); + }); + } + private function registerFeatureFlags(): void { Feature::define(ShowAuthButtons::class); diff --git a/app/Services/GitHubAppService.php b/app/Services/GitHubAppService.php new file mode 100644 index 000000000..9e9a434be --- /dev/null +++ b/app/Services/GitHubAppService.php @@ -0,0 +1,322 @@ +legacyOAuthCutoffDate()?->isPast() ?? false; + } + + public function generateJwt(): string + { + $privateKey = $this->privateKey(); + $appId = config('services.github_app.app_id'); + + $now = time(); + + $payload = [ + 'iat' => $now - 60, + 'exp' => $now + (9 * 60), + 'iss' => $appId, + ]; + + return JWT::encode($payload, $privateKey, 'RS256'); + } + + /** + * The app's PEM private key from GITHUB_APP_PRIVATE_KEY. Some hosting dashboards store + * multi-line values with literal "\n" sequences, so those are turned back into newlines. + */ + protected function privateKey(): string + { + $privateKey = trim((string) config('services.github_app.private_key')); + + if ($privateKey === '') { + throw new RuntimeException('The GitHub App private key is missing. Set GITHUB_APP_PRIVATE_KEY.'); + } + + return str_replace('\n', "\n", $privateKey); + } + + public function getInstallationToken(GitHubInstallation $installation): ?string + { + $cacheKey = "github_installation_token_{$installation->installation_id}"; + + return Cache::remember($cacheKey, now()->addMinutes(55), function () use ($installation) { + return $this->refreshInstallationToken($installation); + }); + } + + public function refreshInstallationToken(GitHubInstallation $installation): ?string + { + try { + $jwt = $this->generateJwt(); + + $response = Http::withHeaders([ + 'Authorization' => "Bearer {$jwt}", + 'Accept' => 'application/vnd.github+json', + ])->post("https://api.github.com/app/installations/{$installation->installation_id}/access_tokens"); + + if ($response->failed()) { + Log::warning('[GitHubApp] Failed to get installation token', [ + 'installation_id' => $installation->installation_id, + 'status' => $response->status(), + 'response' => $response->json(), + ]); + + return null; + } + + $data = $response->json(); + $token = $data['token']; + $expiresAt = $data['expires_at']; + + $installation->update([ + 'access_token' => encrypt($token), + 'token_expires_at' => $expiresAt, + ]); + + // Update cache with correct TTL + $cacheKey = "github_installation_token_{$installation->installation_id}"; + Cache::put($cacheKey, $token, now()->addMinutes(55)); + + return $token; + } catch (\Exception $e) { + Log::error('[GitHubApp] Exception getting installation token', [ + 'installation_id' => $installation->installation_id, + 'error' => $e->getMessage(), + ]); + + return null; + } + } + + public function findInstallationForRepo(User $user, string $owner, string $repo): ?GitHubInstallation + { + return $user->githubInstallations() + ->whereNull('suspended_at') + ->get() + ->first(fn (GitHubInstallation $installation) => $installation->hasAccessToRepo($owner, $repo)); + } + + /** + * Whether the installation is one the user can see on GitHub. GitHub's post-install redirect + * passes the installation ID in the query string, so it can't be trusted on its own. + */ + public function userCanAccessInstallation(User $user, int $installationId): bool + { + $token = $user->getGitHubToken(); + + if (! $token) { + return false; + } + + $page = 1; + + do { + $response = Http::withToken($token) + ->accept('application/vnd.github+json') + ->get('https://api.github.com/user/installations', [ + 'per_page' => 100, + 'page' => $page, + ]); + + if ($response->failed()) { + Log::warning('[GitHubApp] Failed to list user installations', [ + 'user_id' => $user->id, + 'status' => $response->status(), + ]); + + return false; + } + + $installations = collect($response->json('installations', [])); + + if ($installations->contains('id', $installationId)) { + return true; + } + + $page++; + } while ($installations->count() === 100 && $page <= 10); + + return false; + } + + /** + * Refresh an installation's account details and repository list from GitHub. An installation + * GitHub no longer knows about is deleted. + */ + public function syncInstallation(GitHubInstallation $installation): bool + { + try { + $response = Http::withToken($this->generateJwt()) + ->accept('application/vnd.github+json') + ->get("https://api.github.com/app/installations/{$installation->installation_id}"); + } catch (\Exception $e) { + Log::warning('[GitHubApp] Exception syncing installation', [ + 'installation_id' => $installation->installation_id, + 'error' => $e->getMessage(), + ]); + + return false; + } + + if ($response->notFound()) { + $installation->delete(); + + return false; + } + + if ($response->failed()) { + Log::warning('[GitHubApp] Failed to sync installation', [ + 'installation_id' => $installation->installation_id, + 'status' => $response->status(), + ]); + + return false; + } + + $data = $response->json(); + $selectionType = $data['repository_selection'] ?? 'all'; + + $installation->update([ + 'account_login' => $data['account']['login'] ?? $installation->account_login, + 'account_type' => $data['account']['type'] ?? $installation->account_type, + 'account_id' => $data['account']['id'] ?? $installation->account_id, + 'selection_type' => $selectionType, + 'suspended_at' => $data['suspended_at'] ?? null, + 'repository_selection' => $selectionType === 'selected' + ? ($this->fetchInstallationRepositories($installation) ?? $installation->repository_selection) + : null, + ]); + + return true; + } + + /** + * @return array|null Full names of the repositories the installation can access, or null if they couldn't be fetched. + */ + protected function fetchInstallationRepositories(GitHubInstallation $installation): ?array + { + $token = $this->getInstallationToken($installation); + + if (! $token) { + return null; + } + + $repositories = []; + $page = 1; + + do { + $response = Http::withToken($token) + ->accept('application/vnd.github+json') + ->get('https://api.github.com/installation/repositories', [ + 'per_page' => 100, + 'page' => $page, + ]); + + if ($response->failed()) { + return null; + } + + $pageRepositories = collect($response->json('repositories', []))->pluck('full_name'); + $repositories = [...$repositories, ...$pageRepositories->all()]; + $page++; + } while ($pageRepositories->count() === 100 && $page <= 10); + + return $repositories; + } + + /** + * Swap an expired GitHub App user token for a new one. Clears the stored tokens if GitHub + * rejects the refresh token, so the user is asked to reconnect. + */ + public function refreshUserToken(User $user): ?string + { + $refreshToken = $user->getGitHubRefreshToken(); + + if (! $refreshToken) { + return null; + } + + try { + $response = Http::asForm() + ->acceptJson() + ->post('https://github.com/login/oauth/access_token', [ + 'client_id' => config('services.github_app.client_id'), + 'client_secret' => config('services.github_app.client_secret'), + 'grant_type' => 'refresh_token', + 'refresh_token' => $refreshToken, + ]); + } catch (\Exception $e) { + Log::warning('[GitHubApp] Exception refreshing user token', [ + 'user_id' => $user->id, + 'error' => $e->getMessage(), + ]); + + return null; + } + + $accessToken = $response->json('access_token'); + + if ($response->failed() || ! $accessToken) { + Log::warning('[GitHubApp] Failed to refresh user token', [ + 'user_id' => $user->id, + 'status' => $response->status(), + 'error' => $response->json('error'), + ]); + + if ($response->json('error') === 'bad_refresh_token') { + $user->update([ + 'github_token' => null, + 'github_refresh_token' => null, + 'github_token_expires_at' => null, + ]); + } + + return null; + } + + $user->update([ + 'github_token' => encrypt($accessToken), + 'github_refresh_token' => encrypt($response->json('refresh_token') ?? $refreshToken), + 'github_token_expires_at' => $response->json('expires_in') ? now()->addSeconds($response->json('expires_in')) : null, + ]); + + return $accessToken; + } +} diff --git a/app/Services/GitHubUserService.php b/app/Services/GitHubUserService.php index 29099bd90..46b3a2b92 100644 --- a/app/Services/GitHubUserService.php +++ b/app/Services/GitHubUserService.php @@ -19,8 +19,40 @@ public static function for(User $user): static return new static($user); } + public function resolveTokenForRepo(string $owner, string $repo): ?string + { + // Priority 1: Installation token (GitHub App) + if ($this->user->isUsingGitHubApp()) { + $appService = app(GitHubAppService::class); + $installation = $appService->findInstallationForRepo($this->user, $owner, $repo); + + if ($installation) { + $token = $appService->getInstallationToken($installation); + + if ($token) { + return $token; + } + } + } + + // Priority 2: User OAuth token + $userToken = $this->user->getGitHubToken(); + + if ($userToken) { + return $userToken; + } + + // Priority 3: Platform token + return config('services.github.token'); + } + public function getRepositories(bool $includePrivate = true): Collection { + // For GitHub App users, aggregate repos from all installations + if ($this->user->isUsingGitHubApp() && $this->user->githubInstallations()->exists()) { + return $this->getRepositoriesFromInstallations($includePrivate); + } + $token = $this->user->getGitHubToken(); if (! $token) { @@ -34,6 +66,60 @@ public function getRepositories(bool $includePrivate = true): Collection }); } + protected function getRepositoriesFromInstallations(bool $includePrivate): Collection + { + $cacheKey = "github_repos_{$this->user->id}"; + + return Cache::remember($cacheKey, now()->addMinutes(5), function () use ($includePrivate) { + $repos = collect(); + $appService = app(GitHubAppService::class); + + foreach ($this->user->githubInstallations()->whereNull('suspended_at')->get() as $installation) { + $token = $appService->getInstallationToken($installation); + + if (! $token) { + continue; + } + + $page = 1; + $perPage = 100; + + do { + $response = Http::withToken($token) + ->get('https://api.github.com/installation/repositories', [ + 'per_page' => $perPage, + 'page' => $page, + ]); + + if ($response->failed()) { + break; + } + + $pageRepos = collect($response->json('repositories') ?? []); + $repos = $repos->concat($pageRepos); + $page++; + } while ($pageRepos->count() === $perPage && $page <= 10); + } + + if (! $includePrivate) { + $repos = $repos->where('private', false); + } + + return $repos->map(function ($repo) { + return [ + 'id' => $repo['id'], + 'name' => $repo['name'], + 'full_name' => $repo['full_name'], + 'private' => $repo['private'], + 'html_url' => $repo['html_url'], + 'description' => $repo['description'], + 'default_branch' => $repo['default_branch'], + 'pushed_at' => $repo['pushed_at'], + ]; + })->unique('id')->values(); + }); + } + public function clearRepositoryCache(): void { Cache::forget("github_repos_{$this->user->id}"); @@ -90,7 +176,7 @@ protected function fetchRepositories(string $token, bool $includePrivate): Colle public function getRepository(string $owner, string $repo): ?array { - $token = $this->user->getGitHubToken(); + $token = $this->resolveTokenForRepo($owner, $repo); if (! $token) { return null; @@ -118,7 +204,7 @@ public function getRepository(string $owner, string $repo): ?array public function getComposerJson(string $owner, string $repo, string $branch = 'main'): ?array { - $token = $this->user->getGitHubToken(); + $token = $this->resolveTokenForRepo($owner, $repo); if (! $token) { return null; @@ -149,7 +235,7 @@ public function getComposerJson(string $owner, string $repo, string $branch = 'm */ public function webhookExists(string $owner, string $repo, string $webhookUrl): bool { - $token = $this->user->getGitHubToken(); + $token = $this->resolveTokenForRepo($owner, $repo); if (! $token) { return false; @@ -178,7 +264,7 @@ public function webhookExists(string $owner, string $repo, string $webhookUrl): */ public function createWebhook(string $owner, string $repo, string $webhookUrl, string $secret): array { - $token = $this->user->getGitHubToken(); + $token = $this->resolveTokenForRepo($owner, $repo); if (! $token) { return [ diff --git a/app/Services/PluginSyncService.php b/app/Services/PluginSyncService.php index ccb836aa2..d0e82a1cf 100644 --- a/app/Services/PluginSyncService.php +++ b/app/Services/PluginSyncService.php @@ -194,11 +194,28 @@ protected function fetchFileFromGitHub(string $owner, string $repo, string $path protected function getGitHubToken(Plugin $plugin): ?string { $user = $plugin->user; + $repo = $plugin->getRepositoryOwnerAndName(); + + // Priority 1: Installation token (GitHub App) + if ($user && $repo && $user->isUsingGitHubApp()) { + $appService = app(GitHubAppService::class); + $installation = $appService->findInstallationForRepo($user, $repo['owner'], $repo['repo']); + + if ($installation) { + $token = $appService->getInstallationToken($installation); + + if ($token) { + return $token; + } + } + } + // Priority 2: User OAuth token if ($user && $user->hasGitHubToken()) { return $user->getGitHubToken(); } + // Priority 3: Platform token return config('services.github.token'); } diff --git a/app/Support/GitHubOAuth.php b/app/Support/GitHubOAuth.php index aea8e9702..df280c66d 100644 --- a/app/Support/GitHubOAuth.php +++ b/app/Support/GitHubOAuth.php @@ -22,6 +22,43 @@ public static function make(): static return new static(config('services.github.token', '')); } + /** + * Revoke a user's authorization of the legacy OAuth App, which invalidates every token it issued them. + */ + public function revokeOAuthGrant(string $accessToken): bool + { + $clientId = config('services.github.client_id'); + $clientSecret = config('services.github.client_secret'); + + if (! $clientId || ! $clientSecret) { + return false; + } + + try { + $response = Http::withBasicAuth($clientId, $clientSecret) + ->accept('application/vnd.github+json') + ->delete("https://api.github.com/applications/{$clientId}/grant", [ + 'access_token' => $accessToken, + ]); + } catch (\Exception $e) { + Log::warning('Failed to revoke legacy GitHub OAuth grant', ['error' => $e->getMessage()]); + + return false; + } + + // 404 means the grant is already gone + if ($response->failed() && ! $response->notFound()) { + Log::warning('Failed to revoke legacy GitHub OAuth grant', [ + 'status' => $response->status(), + 'response' => $response->json(), + ]); + + return false; + } + + return true; + } + /** * Invite a user to a repository with read-only access. */ diff --git a/config/services.php b/config/services.php index 336c7e569..0d74bbf0d 100644 --- a/config/services.php +++ b/config/services.php @@ -65,6 +65,19 @@ 'client_secret' => env('GITHUB_CLIENT_SECRET'), 'redirect' => env('APP_URL').'/auth/github/callback', 'token' => env('GITHUB_TOKEN'), + // The date the legacy OAuth App stops working, shown to people who haven't moved to the GitHub App yet + 'legacy_oauth_cutoff_date' => env('GITHUB_LEGACY_OAUTH_CUTOFF_DATE'), + ], + + 'github_app' => [ + 'app_id' => env('GITHUB_APP_ID'), + 'client_id' => env('GITHUB_APP_CLIENT_ID'), + 'client_secret' => env('GITHUB_APP_CLIENT_SECRET'), + 'private_key' => env('GITHUB_APP_PRIVATE_KEY'), + 'webhook_secret' => env('GITHUB_APP_WEBHOOK_SECRET'), + 'redirect' => env('APP_URL').'/auth/github/callback', + // Public and fixed: the app's name in its github.com/apps/{slug} URL + 'slug' => 'nativephp-plugin-marketplace', ], 'discord' => [ diff --git a/database/factories/GitHubInstallationFactory.php b/database/factories/GitHubInstallationFactory.php new file mode 100644 index 000000000..c9c684c40 --- /dev/null +++ b/database/factories/GitHubInstallationFactory.php @@ -0,0 +1,53 @@ + + */ +class GitHubInstallationFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'installation_id' => fake()->unique()->randomNumber(8), + 'account_login' => fake()->userName(), + 'account_type' => 'User', + 'account_id' => fake()->randomNumber(8), + 'selection_type' => 'all', + 'repository_selection' => null, + ]; + } + + public function forOrganization(): static + { + return $this->state(fn (array $attributes) => [ + 'account_type' => 'Organization', + ]); + } + + public function selectedRepos(array $repos): static + { + return $this->state(fn (array $attributes) => [ + 'selection_type' => 'selected', + 'repository_selection' => $repos, + ]); + } + + public function suspended(): static + { + return $this->state(fn (array $attributes) => [ + 'suspended_at' => now(), + ]); + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 7787f90cd..30b971670 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -39,4 +39,24 @@ public function unverified(): static 'email_verified_at' => null, ]); } + + public function withGitHubApp(): static + { + return $this->state(fn (array $attributes) => [ + 'github_id' => (string) fake()->randomNumber(8), + 'github_username' => fake()->userName(), + 'github_token' => encrypt('ghu_test_token_'.Str::random(20)), + 'github_auth_type' => 'app', + ]); + } + + public function withLegacyGitHub(): static + { + return $this->state(fn (array $attributes) => [ + 'github_id' => (string) fake()->randomNumber(8), + 'github_username' => fake()->userName(), + 'github_token' => encrypt('gho_test_token_'.Str::random(20)), + 'github_auth_type' => 'oauth', + ]); + } } diff --git a/database/migrations/2026_03_23_122642_create_github_installations_table.php b/database/migrations/2026_03_23_122642_create_github_installations_table.php new file mode 100644 index 000000000..02237d4f2 --- /dev/null +++ b/database/migrations/2026_03_23_122642_create_github_installations_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->unsignedBigInteger('installation_id')->unique(); + $table->string('account_login'); + $table->string('account_type')->default('User'); + $table->unsignedBigInteger('account_id')->nullable(); + $table->string('selection_type')->default('all'); + $table->json('repository_selection')->nullable(); + $table->text('access_token')->nullable(); + $table->timestamp('token_expires_at')->nullable(); + $table->timestamp('suspended_at')->nullable(); + $table->timestamps(); + + $table->index('user_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('github_installations'); + } +}; diff --git a/database/migrations/2026_03_23_122647_add_github_auth_type_to_users_table.php b/database/migrations/2026_03_23_122647_add_github_auth_type_to_users_table.php new file mode 100644 index 000000000..45e6b1300 --- /dev/null +++ b/database/migrations/2026_03_23_122647_add_github_auth_type_to_users_table.php @@ -0,0 +1,34 @@ +string('github_auth_type')->nullable()->after('github_token'); + }); + + // Backfill existing users with OAuth tokens + DB::table('users') + ->whereNotNull('github_token') + ->update(['github_auth_type' => 'oauth']); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('github_auth_type'); + }); + } +}; diff --git a/database/migrations/2026_09_24_102741_add_github_app_token_and_migration_fields_to_users_table.php b/database/migrations/2026_09_24_102741_add_github_app_token_and_migration_fields_to_users_table.php new file mode 100644 index 000000000..ba6793398 --- /dev/null +++ b/database/migrations/2026_09_24_102741_add_github_app_token_and_migration_fields_to_users_table.php @@ -0,0 +1,30 @@ +text('github_refresh_token')->nullable()->after('github_auth_type'); + $table->timestamp('github_token_expires_at')->nullable()->after('github_refresh_token'); + $table->timestamp('github_app_migration_notified_at')->nullable()->after('github_token_expires_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn(['github_refresh_token', 'github_token_expires_at', 'github_app_migration_notified_at']); + }); + } +}; diff --git a/resources/views/components/github-migration-banner.blade.php b/resources/views/components/github-migration-banner.blade.php new file mode 100644 index 000000000..d41279bb9 --- /dev/null +++ b/resources/views/components/github-migration-banner.blade.php @@ -0,0 +1,104 @@ +@props(['blocking' => false]) + +@php + $user = auth()->user(); + $needsMigration = $user->needsGitHubAppMigration(); + $missingAccess = $needsMigration ? collect() : $user->pluginsMissingGitHubAppAccess(); + $pluginRepos = $needsMigration + ? $user->pluginRepositoryNames() + : $missingAccess + ->map(fn ($plugin) => $plugin->getRepositoryOwnerAndName()) + ->filter() + ->map(fn (array $repo) => "{$repo['owner']}/{$repo['repo']}") + ->unique() + ->values(); + $appService = app(\App\Services\GitHubAppService::class); + $installUrl = $appService->installationUrl(); + $cutoffDate = $appService->legacyOAuthCutoffDate(); + $deadline = $cutoffDate ? $cutoffDate->format('j F Y') : 'we switch off the old connection'; + $urgent = ! $needsMigration || $blocking || $pluginRepos->isNotEmpty(); +@endphp + +@if($needsMigration || $missingAccess->isNotEmpty()) +
$urgent, + 'border-blue-200 bg-blue-50 dark:border-blue-900/50 dark:bg-blue-900/20' => ! $urgent, + ])> +
+
+ $urgent, 'text-blue-400' => ! $urgent]) viewBox="0 0 20 20" fill="currentColor"> + + +
+
+

$urgent, 'text-blue-800 dark:text-blue-200' => ! $urgent])> + @if(! $needsMigration) + GitHub App Needs Access to Your Plugins + @elseif($urgent) + GitHub Connection Upgrade Required + @else + We've Improved Our GitHub Connection + @endif +

+
$urgent, 'text-blue-700 dark:text-blue-300' => ! $urgent])> + @if($needsMigration) +

+ We're moving away from GitHub OAuth, which gave us broad access to your repositories, + to a GitHub App that gives you fine-grained control over what we can see. +

+ + @if($pluginRepos->isNotEmpty()) +

+ As a plugin author, you need to connect our new GitHub App. If you don't do it before {{ $deadline }}, + we won't be able to keep your plugins up to date and may remove them from the Marketplace. +

+

If you choose "Only select repositories", include each of these:

+ @elseif($blocking) +

You need to connect the GitHub App before you can create a plugin.

+ @else +

+ You don't need to do anything. "Login with GitHub" keeps working, and we only use it to confirm your identity. + You can reconnect now if you'd like to switch straight away. +

+ @endif + @else +

+ We can't reach some of your plugin repositories, so they won't sync new releases. + Install the NativePHP GitHub App on the accounts that own them, or add them to an existing installation. +

+

Grant access to:

+ @endif + + @if($pluginRepos->isNotEmpty()) +
    + @foreach($pluginRepos as $repo) +
  • {{ $repo }}
  • + @endforeach +
+ @endif +
+ @if($needsMigration || $installUrl) + + @endif +
+
+
+@endif diff --git a/resources/views/livewire/customer/integrations.blade.php b/resources/views/livewire/customer/integrations.blade.php index ccb97de9f..0eb2a98b5 100644 --- a/resources/views/livewire/customer/integrations.blade.php +++ b/resources/views/livewire/customer/integrations.blade.php @@ -23,6 +23,9 @@ @endif + {{-- GitHub App Migration Banner --}} + + {{-- Info Section --}} About Integrations @@ -83,7 +86,10 @@
  • Any access to the private nativephp/mobile and nativephp/claude-code repositories will be revoked.
  • Your GitHub repositories will no longer be available when submitting or managing plugins.
  • -
  • You can reconnect at any time. When re-authorizing, be sure to grant access to any organizations whose repositories you need.
  • +
  • You can reconnect at any time. When you do, give the NativePHP GitHub App access to the repositories you need.
  • + @if (auth()->user()->isUsingGitHubApp()) +
  • The NativePHP GitHub App stays installed on your GitHub accounts until you uninstall it from your GitHub settings.
  • + @endif
@@ -107,6 +113,13 @@ @endif + {{-- GitHub App Status (for users on the new GitHub App) --}} + @if(auth()->user()->isUsingGitHubApp()) +
+ +
+ @endif + {{-- Claude Plugins Access --}}
diff --git a/resources/views/livewire/customer/plugins/create.blade.php b/resources/views/livewire/customer/plugins/create.blade.php index 1ef23b01c..b0c96c50c 100644 --- a/resources/views/livewire/customer/plugins/create.blade.php +++ b/resources/views/livewire/customer/plugins/create.blade.php @@ -27,8 +27,11 @@ @endif + {{-- GitHub App Migration Required (blocking) --}} + @if(auth()->user()->needsGitHubAppMigration()) + {{-- GitHub Connection Required --}} - @if (!auth()->user()->github_id) + @elseif (!auth()->user()->github_id) GitHub Connection Required @@ -41,6 +44,23 @@ + {{-- GitHub App Installation Required --}} + @elseif (auth()->user()->needsGitHubAppInstallation()) + + Install the GitHub App + + To create a plugin, install the NativePHP GitHub App on the account that owns your plugin's repository. + You can choose to give it access to only the repositories you want to publish. + + @if ($installUrl = app(\App\Services\GitHubAppService::class)->installationUrl()) + + + + Install GitHub App + + + @endif + @else
{{-- Plugin Type --}} @@ -116,6 +136,12 @@ @endforeach @endif + + @if (auth()->user()->isUsingGitHubApp() && $installUrl = app(\App\Services\GitHubAppService::class)->installationUrl()) + + Don't see your repository? Give the GitHub App access to it, then reload this page. + + @endif @endif
diff --git a/resources/views/livewire/customer/plugins/index.blade.php b/resources/views/livewire/customer/plugins/index.blade.php index 9edc925c1..cee233700 100644 --- a/resources/views/livewire/customer/plugins/index.blade.php +++ b/resources/views/livewire/customer/plugins/index.blade.php @@ -1,4 +1,7 @@
+ {{-- GitHub App Migration Banner --}} + +
Plugins Extend NativePHP Mobile with powerful native features diff --git a/resources/views/livewire/git-hub-app-status.blade.php b/resources/views/livewire/git-hub-app-status.blade.php new file mode 100644 index 000000000..161bf1fbf --- /dev/null +++ b/resources/views/livewire/git-hub-app-status.blade.php @@ -0,0 +1,95 @@ +
+
+
+
+

GitHub App Installations

+

+ Manage which accounts and repositories the NativePHP app can access. +

+
+ @if($installUrl) + + Add Account + + + + + @endif +
+ + @if($installations->isEmpty()) +
+

+ No GitHub App installations found. Install the app on your GitHub account to grant repository access. +

+ @if($installUrl) + + + + + Install GitHub App + + @endif +
+ @else +
+ @foreach($installations as $installation) +
+
+
+ @if($installation->account_type === 'Organization') + + + + @else + + + + @endif +
+
+

{{ $installation->account_login }}

+

+ {{ $installation->account_type }} • + {{ $installation->selection_type === 'all' ? 'All repositories' : 'Selected repositories' }} + @if($installation->isSuspended()) + • Suspended + @endif +

+
+
+ + Manage + +
+ @endforeach +
+ @endif + + {{-- Plugin repo coverage --}} + @if(count($pluginCoverage) > 0) +
+

Plugin Repository Access

+
+ @foreach($pluginCoverage as $item) +
+ @if($item['covered']) + + + + @else + + + + @endif + {{ $item['owner'] }}/{{ $item['repo'] }} + @if(!$item['covered']) + Not accessible - update your installation + @endif +
+ @endforeach +
+
+ @endif +
+
diff --git a/routes/web.php b/routes/web.php index 628528f8d..b0e23fb19 100644 --- a/routes/web.php +++ b/routes/web.php @@ -12,6 +12,7 @@ use App\Http\Controllers\CustomerSubLicenseController; use App\Http\Controllers\DeveloperOnboardingController; use App\Http\Controllers\DiscordIntegrationController; +use App\Http\Controllers\GitHubAppWebhookController; use App\Http\Controllers\GitHubAuthController; use App\Http\Controllers\GitHubIntegrationController; use App\Http\Controllers\LicenseRenewalController; @@ -382,12 +383,16 @@ // GitHub OAuth routes (auth required) Route::middleware(['auth', EnsureFeaturesAreActive::using(ShowAuthButtons::class)])->group(function (): void { Route::get('auth/github', [GitHubIntegrationController::class, 'redirectToGitHub'])->name('github.redirect'); + Route::get('auth/github/setup', [GitHubIntegrationController::class, 'handleSetup'])->name('github.setup'); Route::post('dashboard/github/request-access', [GitHubIntegrationController::class, 'requestRepoAccess'])->name('github.request-access'); Route::post('dashboard/github/request-claude-plugins-access', [GitHubIntegrationController::class, 'requestClaudePluginsAccess'])->name('github.request-claude-plugins-access'); Route::delete('dashboard/github/disconnect', [GitHubIntegrationController::class, 'disconnect'])->name('github.disconnect'); Route::get('dashboard/github/repositories', [GitHubIntegrationController::class, 'repositories'])->name('github.repositories'); }); +// GitHub App webhook +Route::post('webhooks/github-app', GitHubAppWebhookController::class)->name('webhooks.github-app'); + // Discord OAuth routes Route::middleware(['auth', EnsureFeaturesAreActive::using(ShowAuthButtons::class)])->group(function (): void { Route::get('auth/discord', [DiscordIntegrationController::class, 'redirectToDiscord'])->name('discord.redirect'); diff --git a/tests/Concerns/InteractsWithGitHubApp.php b/tests/Concerns/InteractsWithGitHubApp.php new file mode 100644 index 000000000..5b6a95ef2 --- /dev/null +++ b/tests/Concerns/InteractsWithGitHubApp.php @@ -0,0 +1,31 @@ + 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); + openssl_pkey_export($key, $privateKey); + + $keys = [$privateKey, openssl_pkey_get_details($key)['key']]; + } + + config([ + 'services.github_app.app_id' => '12345', + 'services.github_app.client_id' => 'Iv1.testclient', + 'services.github_app.client_secret' => 'test-app-secret', + 'services.github_app.private_key' => $keys[0], + 'services.github_app.slug' => 'nativephp-test', + ]); + + return $keys[1]; + } +} diff --git a/tests/Feature/GitHubAppAuthTest.php b/tests/Feature/GitHubAppAuthTest.php new file mode 100644 index 000000000..f6dd6f716 --- /dev/null +++ b/tests/Feature/GitHubAppAuthTest.php @@ -0,0 +1,161 @@ +id = 12345; + $socialiteUser->nickname = 'testuser'; + $socialiteUser->name = 'Test User'; + $socialiteUser->email = 'test@example.com'; + $socialiteUser->token = 'ghu_test_token'; + + $provider = Mockery::mock(GithubProvider::class); + $provider->shouldReceive('user')->andReturn($socialiteUser); + + Socialite::shouldReceive('driver') + ->with('github-app') + ->andReturn($provider); + + session([ + 'github_auth_intent' => 'login', + 'github_auth_driver' => 'github-app', + ]); + + $response = $this->get('/auth/github/callback'); + + $response->assertRedirect(); + + $this->assertDatabaseHas('users', [ + 'github_id' => '12345', + 'github_username' => 'testuser', + 'github_auth_type' => 'app', + ]); + } + + public function test_login_via_legacy_oauth_stores_oauth_auth_type(): void + { + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->id = 12345; + $socialiteUser->nickname = 'testuser'; + $socialiteUser->name = 'Test User'; + $socialiteUser->email = 'test@example.com'; + $socialiteUser->token = 'gho_test_token'; + + $provider = Mockery::mock(GithubProvider::class); + $provider->shouldReceive('user')->andReturn($socialiteUser); + + Socialite::shouldReceive('driver') + ->with('github') + ->andReturn($provider); + + session([ + 'github_auth_intent' => 'login', + 'github_auth_driver' => 'github', + ]); + + $response = $this->get('/auth/github/callback'); + + $response->assertRedirect(); + + $this->assertDatabaseHas('users', [ + 'github_id' => '12345', + 'github_username' => 'testuser', + 'github_auth_type' => 'oauth', + ]); + } + + public function test_linking_github_app_updates_existing_user_auth_type(): void + { + $user = User::factory()->withLegacyGitHub()->create(); + + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->id = $user->github_id; + $socialiteUser->nickname = 'newusername'; + $socialiteUser->token = 'ghu_new_token'; + + $provider = Mockery::mock(GithubProvider::class); + $provider->shouldReceive('user')->andReturn($socialiteUser); + + Socialite::shouldReceive('driver') + ->with('github-app') + ->andReturn($provider); + + session([ + 'github_auth_intent' => 'link', + 'github_auth_driver' => 'github-app', + ]); + + $this->actingAs($user) + ->get('/auth/github/callback'); + + $user->refresh(); + $this->assertEquals(GitHubAuthType::App, $user->github_auth_type); + $this->assertEquals('newusername', $user->github_username); + } + + public function test_user_model_auth_type_helpers(): void + { + $appUser = User::factory()->withGitHubApp()->create(); + $oauthUser = User::factory()->withLegacyGitHub()->create(); + $noGithubUser = User::factory()->create(); + + $this->assertTrue($appUser->isUsingGitHubApp()); + $this->assertFalse($appUser->isUsingLegacyOAuth()); + $this->assertFalse($appUser->needsGitHubAppMigration()); + + $this->assertFalse($oauthUser->isUsingGitHubApp()); + $this->assertTrue($oauthUser->isUsingLegacyOAuth()); + $this->assertTrue($oauthUser->needsGitHubAppMigration()); + + $this->assertFalse($noGithubUser->isUsingGitHubApp()); + $this->assertFalse($noGithubUser->isUsingLegacyOAuth()); + $this->assertFalse($noGithubUser->needsGitHubAppMigration()); + } + + public function test_github_app_redirect_uses_app_driver_when_configured(): void + { + config(['services.github_app.client_id' => 'test_client_id']); + + $user = User::factory()->create(); + + $provider = Mockery::mock(GithubProvider::class); + $provider->shouldReceive('scopes') + ->with(['read:user', 'user:email']) + ->andReturnSelf(); + $provider->shouldReceive('redirect') + ->andReturn(redirect('https://github.com/login/oauth/authorize')); + + Socialite::shouldReceive('driver') + ->with('github-app') + ->andReturn($provider); + + $response = $this->actingAs($user) + ->get('/auth/github'); + + $response->assertRedirect(); + } +} diff --git a/tests/Feature/GitHubAppInstallationPromptTest.php b/tests/Feature/GitHubAppInstallationPromptTest.php new file mode 100644 index 000000000..ec345ff97 --- /dev/null +++ b/tests/Feature/GitHubAppInstallationPromptTest.php @@ -0,0 +1,303 @@ + 'nativephp-test']); + + Http::fake(['api.github.com/*' => Http::response([], 404)]); + } + + private function fakeGitHubAppLogin(User $user): void + { + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->id = $user->github_id; + $socialiteUser->nickname = $user->github_username; + $socialiteUser->name = $user->name; + $socialiteUser->email = $user->email; + $socialiteUser->token = 'ghu_new_token'; + + $provider = Mockery::mock(GithubProvider::class); + $provider->shouldReceive('user')->andReturn($socialiteUser); + + Socialite::shouldReceive('driver')->with('github-app')->andReturn($provider); + + session([ + 'github_auth_intent' => 'login', + 'github_auth_driver' => 'github-app', + ]); + } + + private function pluginFor(User $user, string $repository): Plugin + { + return Plugin::factory()->create([ + 'user_id' => $user->id, + 'repository_url' => "https://github.com/{$repository}", + ]); + } + + public function test_plugins_missing_access_lists_repos_without_a_covering_installation(): void + { + $user = User::factory()->withGitHubApp()->create(); + $covered = $this->pluginFor($user, 'acme/covered-plugin'); + $uncovered = $this->pluginFor($user, 'other-org/uncovered-plugin'); + + GitHubInstallation::factory()->create([ + 'user_id' => $user->id, + 'account_login' => 'acme', + 'selection_type' => 'all', + ]); + + $missing = $user->pluginsMissingGitHubAppAccess(); + + $this->assertTrue($missing->contains($uncovered)); + $this->assertFalse($missing->contains($covered)); + } + + public function test_plugins_missing_access_respects_selected_repositories(): void + { + $user = User::factory()->withGitHubApp()->create(); + $selected = $this->pluginFor($user, 'acme/selected-plugin'); + $notSelected = $this->pluginFor($user, 'acme/not-selected-plugin'); + + GitHubInstallation::factory()->selectedRepos(['acme/selected-plugin'])->create([ + 'user_id' => $user->id, + 'account_login' => 'acme', + ]); + + $missing = $user->pluginsMissingGitHubAppAccess(); + + $this->assertFalse($missing->contains($selected)); + $this->assertTrue($missing->contains($notSelected)); + } + + public function test_suspended_installations_do_not_count_as_access(): void + { + $user = User::factory()->withGitHubApp()->create(); + $plugin = $this->pluginFor($user, 'acme/some-plugin'); + + GitHubInstallation::factory()->suspended()->create([ + 'user_id' => $user->id, + 'account_login' => 'acme', + ]); + + $this->assertTrue($user->pluginsMissingGitHubAppAccess()->contains($plugin)); + $this->assertTrue($user->needsGitHubAppInstallation()); + } + + public function test_plugins_missing_access_is_empty_for_legacy_oauth_users(): void + { + $user = User::factory()->withLegacyGitHub()->create(); + $this->pluginFor($user, 'acme/some-plugin'); + + $this->assertTrue($user->pluginsMissingGitHubAppAccess()->isEmpty()); + $this->assertFalse($user->needsGitHubAppInstallation()); + } + + public function test_legacy_plugin_author_logging_in_with_github_app_is_sent_to_install_the_app(): void + { + $user = User::factory()->withLegacyGitHub()->create(); + $this->pluginFor($user, 'acme/some-plugin'); + + $this->fakeGitHubAppLogin($user); + + $response = $this->get('/auth/github/callback'); + + $response->assertRedirect(self::INSTALL_URL); + $response->assertSessionHas('github_return_url', route('dashboard')); + $this->assertAuthenticatedAs($user); + $this->assertEquals(GitHubAuthType::App, $user->fresh()->github_auth_type); + } + + public function test_install_redirect_preserves_the_intended_url(): void + { + $user = User::factory()->withLegacyGitHub()->create(); + $this->pluginFor($user, 'acme/some-plugin'); + + $this->fakeGitHubAppLogin($user); + session(['url.intended' => route('customer.plugins.index')]); + + $response = $this->get('/auth/github/callback'); + + $response->assertRedirect(self::INSTALL_URL); + $response->assertSessionHas('github_return_url', route('customer.plugins.index')); + } + + public function test_user_without_plugins_logging_in_with_github_app_goes_to_the_dashboard(): void + { + $user = User::factory()->withLegacyGitHub()->create(); + + $this->fakeGitHubAppLogin($user); + + $this->get('/auth/github/callback')->assertRedirect(route('dashboard')); + } + + public function test_plugin_author_with_covered_repos_logging_in_goes_to_the_dashboard(): void + { + $user = User::factory()->withGitHubApp()->create(); + $this->pluginFor($user, 'acme/some-plugin'); + + GitHubInstallation::factory()->create([ + 'user_id' => $user->id, + 'account_login' => 'acme', + 'selection_type' => 'all', + ]); + + $this->fakeGitHubAppLogin($user); + + $this->get('/auth/github/callback')->assertRedirect(route('dashboard')); + } + + public function test_github_app_user_sees_which_plugin_repos_need_access(): void + { + $user = User::factory()->withGitHubApp()->create(); + $this->pluginFor($user, 'acme/some-plugin'); + + $response = $this->actingAs($user)->get(route('customer.integrations')); + + $response->assertOk(); + $response->assertSee('GitHub App Needs Access to Your Plugins'); + $response->assertSee('acme/some-plugin'); + $response->assertSee(self::INSTALL_URL); + $response->assertDontSee('GitHub Connection Upgrade Required'); + } + + public function test_github_app_user_with_covered_repos_sees_no_access_banner(): void + { + $user = User::factory()->withGitHubApp()->create(); + $this->pluginFor($user, 'acme/some-plugin'); + + GitHubInstallation::factory()->create([ + 'user_id' => $user->id, + 'account_login' => 'acme', + 'selection_type' => 'all', + ]); + + $response = $this->actingAs($user)->get(route('customer.plugins.index')); + + $response->assertOk(); + $response->assertDontSee('GitHub App Needs Access to Your Plugins'); + } + + public function test_legacy_upgrade_banner_names_the_repos_to_grant(): void + { + $user = User::factory()->withLegacyGitHub()->create(); + $this->pluginFor($user, 'acme/paid-plugin'); + + $response = $this->actingAs($user)->get(route('customer.integrations')); + + $response->assertOk(); + $response->assertSee('GitHub Connection Upgrade Required'); + $response->assertSee('acme/paid-plugin'); + } + + public function test_create_page_prompts_github_app_user_without_installation_to_install(): void + { + $user = User::factory()->withGitHubApp()->create(); + + $response = $this->actingAs($user)->get(route('customer.plugins.create')); + + $response->assertOk(); + $response->assertSee('Install the GitHub App'); + $response->assertSee(self::INSTALL_URL); + } + + public function test_create_page_shows_form_once_the_app_is_installed(): void + { + $user = User::factory()->withGitHubApp()->create(); + + GitHubInstallation::factory()->create(['user_id' => $user->id]); + + $response = $this->actingAs($user)->get(route('customer.plugins.create')); + + $response->assertOk(); + $response->assertDontSee('Install the GitHub App'); + $response->assertSee('Select Repository'); + } + + public function test_setup_callback_clears_the_cached_repository_list(): void + { + $user = User::factory()->withGitHubApp()->create(); + $installation = GitHubInstallation::factory()->create(['user_id' => $user->id]); + + Cache::put("github_repos_{$user->id}", collect(), now()->addMinutes(5)); + + $this->actingAs($user) + ->get(route('github.setup', ['installation_id' => $installation->installation_id])) + ->assertRedirect(route('customer.integrations')); + + $this->assertFalse(Cache::has("github_repos_{$user->id}")); + } + + public function test_integrations_page_shows_installations_and_plugin_repo_coverage(): void + { + $user = User::factory()->withGitHubApp()->create(); + $this->pluginFor($user, 'acme/covered-plugin'); + $this->pluginFor($user, 'other-org/uncovered-plugin'); + + $installation = GitHubInstallation::factory()->forOrganization()->create([ + 'user_id' => $user->id, + 'account_login' => 'acme', + 'selection_type' => 'all', + ]); + + Livewire::actingAs($user) + ->test(GitHubAppStatus::class) + ->assertSee('acme') + ->assertSee('All repositories') + ->assertSee("https://github.com/settings/installations/{$installation->installation_id}") + ->assertSee('acme/covered-plugin') + ->assertSee('other-org/uncovered-plugin') + ->assertSeeInOrder(['other-org/uncovered-plugin', 'Not accessible']); + } + + public function test_integrations_page_prompts_to_install_when_there_are_no_installations(): void + { + $user = User::factory()->withGitHubApp()->create(); + + Livewire::actingAs($user) + ->test(GitHubAppStatus::class) + ->assertSee('No GitHub App installations found') + ->assertSee(self::INSTALL_URL); + } + + public function test_disconnect_modal_explains_the_app_stays_installed(): void + { + $user = User::factory()->withGitHubApp()->create(); + + $this->actingAs($user) + ->get(route('customer.integrations')) + ->assertSee('give the NativePHP GitHub App access to the repositories you need') + ->assertSee('stays installed on your GitHub accounts'); + } +} diff --git a/tests/Feature/GitHubAppMigrationNoticeTest.php b/tests/Feature/GitHubAppMigrationNoticeTest.php new file mode 100644 index 000000000..0d887f4a6 --- /dev/null +++ b/tests/Feature/GitHubAppMigrationNoticeTest.php @@ -0,0 +1,145 @@ + Http::response([], 404)]); + } + + public function test_dry_run_does_not_send_anything(): void + { + Notification::fake(); + + $user = User::factory()->withLegacyGitHub()->create(); + + $this->artisan('github:send-app-migration-notice --dry-run') + ->expectsOutputToContain("Would send to: {$user->email}") + ->assertSuccessful(); + + Notification::assertNothingSent(); + $this->assertNull($user->fresh()->github_app_migration_notified_at); + } + + public function test_notice_goes_to_verified_legacy_users_once(): void + { + Notification::fake(); + config(['services.github.legacy_oauth_cutoff_date' => '2026-12-31']); + + $legacy = User::factory()->withLegacyGitHub()->create(); + $unverified = User::factory()->withLegacyGitHub()->unverified()->create(); + $appUser = User::factory()->withGitHubApp()->create(); + $notConnected = User::factory()->create(); + + $this->artisan('github:send-app-migration-notice')->assertSuccessful(); + $this->artisan('github:send-app-migration-notice')->assertSuccessful(); + + Notification::assertSentToTimes($legacy, GitHubAppMigrationRequired::class, 1); + Notification::assertNotSentTo([$unverified, $appUser, $notConnected], GitHubAppMigrationRequired::class); + $this->assertNotNull($legacy->fresh()->github_app_migration_notified_at); + } + + public function test_real_send_refuses_to_run_without_a_cutoff_date(): void + { + Notification::fake(); + + User::factory()->withLegacyGitHub()->create(); + + $this->artisan('github:send-app-migration-notice') + ->expectsOutputToContain('GITHUB_LEGACY_OAUTH_CUTOFF_DATE') + ->assertFailed(); + + Notification::assertNothingSent(); + } + + public function test_preview_sends_a_single_copy_to_the_given_address(): void + { + Notification::fake(); + + $legacy = User::factory()->withLegacyGitHub()->create(); + + $this->artisan('github:send-app-migration-notice --preview=me@example.com') + ->expectsOutputToContain('Sent a preview to me@example.com') + ->assertSuccessful(); + + Notification::assertSentOnDemand( + GitHubAppMigrationRequired::class, + fn ($notification, array $channels, $notifiable) => $notifiable->routes['mail'] === 'me@example.com' + ); + Notification::assertNotSentTo($legacy, GitHubAppMigrationRequired::class); + $this->assertNull($legacy->fresh()->github_app_migration_notified_at); + } + + public function test_email_explains_both_cases_and_gives_the_deadline(): void + { + config(['services.github.legacy_oauth_cutoff_date' => '2026-12-31']); + + $user = User::factory()->withLegacyGitHub()->create(['name' => 'Priya Patel']); + + $html = (string) (new GitHubAppMigrationRequired)->toMail($user)->render(); + + $this->assertStringContainsString('Hi Priya,', $html); + $this->assertStringContainsString('How does this affect you?', $html); + $this->assertStringContainsString('Login with GitHub', $html); + $this->assertStringContainsString('need to do anything', $html); + $this->assertStringContainsString('If you are a plugin author', $html); + $this->assertStringContainsString('before 31 December 2026', $html); + $this->assertStringContainsString('may remove them from the Marketplace', $html); + $this->assertStringContainsString(route('customer.integrations'), $html); + $this->assertStringContainsString('moving away from GitHub OAuth on nativephp.com', $html); + $this->assertStringContainsString('href="https://bifrost.nativephp.com"', $html); + $this->assertStringContainsString('30% off annual plans', $html); + $this->assertLessThan(strpos($html, 'This does not affect'), strpos($html, 'Connect the GitHub App')); + } + + public function test_email_falls_back_to_generic_wording_without_a_cutoff_date(): void + { + $user = User::factory()->withLegacyGitHub()->create(); + + $html = (string) (new GitHubAppMigrationRequired)->toMail($user)->render(); + + $this->assertStringContainsString('before we switch off the old connection', $html); + } + + public function test_admins_can_filter_for_plugin_authors_still_on_the_oauth_app(): void + { + $admin = User::factory()->create(['email' => 'admin@test.com']); + config(['filament.users' => ['admin@test.com']]); + + $legacyAuthor = User::factory()->withLegacyGitHub()->create(); + Plugin::factory()->create(['user_id' => $legacyAuthor->id]); + + $legacyWithoutPlugins = User::factory()->withLegacyGitHub()->create(); + + $appAuthor = User::factory()->withGitHubApp()->create(); + Plugin::factory()->create(['user_id' => $appAuthor->id]); + + Livewire::actingAs($admin) + ->test(ListUsers::class) + ->filterTable('plugin_authors_on_legacy_oauth') + ->assertCanSeeTableRecords([$legacyAuthor]) + ->assertCanNotSeeTableRecords([$legacyWithoutPlugins, $appAuthor]); + } +} diff --git a/tests/Feature/GitHubAppRepositoryEventsTest.php b/tests/Feature/GitHubAppRepositoryEventsTest.php new file mode 100644 index 000000000..2f1208d36 --- /dev/null +++ b/tests/Feature/GitHubAppRepositoryEventsTest.php @@ -0,0 +1,171 @@ + self::WEBHOOK_SECRET]); + } + + private function sendAppWebhook(string $event, array $payload): TestResponse + { + $body = json_encode($payload); + + return $this->call('POST', '/webhooks/github-app', [], [], [], [ + 'HTTP_X_GITHUB_EVENT' => $event, + 'HTTP_X_HUB_SIGNATURE_256' => 'sha256='.hash_hmac('sha256', $body, self::WEBHOOK_SECRET), + 'CONTENT_TYPE' => 'application/json', + ], $body); + } + + private function coveredPlugin(array $attributes = []): Plugin + { + $user = User::factory()->withGitHubApp()->create(); + + GitHubInstallation::factory()->create([ + 'user_id' => $user->id, + 'account_login' => 'acme', + 'selection_type' => 'all', + ]); + + return Plugin::factory()->create([ + 'user_id' => $user->id, + 'repository_url' => 'https://github.com/acme/camera-plugin', + ...$attributes, + ]); + } + + public function test_push_event_from_the_app_syncs_the_matching_plugin(): void + { + Bus::fake([SyncPluginReleases::class]); + $plugin = $this->coveredPlugin(); + + $this->mock(PluginSyncService::class, function (MockInterface $mock) use ($plugin): void { + $mock->shouldReceive('sync')->once()->withArgs(fn (Plugin $synced) => $synced->is($plugin))->andReturn(true); + }); + + $this->sendAppWebhook('push', ['repository' => ['full_name' => 'acme/camera-plugin']]) + ->assertOk() + ->assertJson(['plugins' => 1]); + + Bus::assertNotDispatched(SyncPluginReleases::class); + } + + public function test_release_event_from_the_app_syncs_releases(): void + { + Bus::fake([SyncPluginReleases::class]); + $plugin = $this->coveredPlugin(); + + $this->mock(PluginSyncService::class, function (MockInterface $mock): void { + $mock->shouldReceive('sync')->once()->andReturn(true); + }); + + $this->sendAppWebhook('release', ['repository' => ['full_name' => 'acme/camera-plugin']]) + ->assertOk(); + + Bus::assertDispatched(SyncPluginReleases::class, fn (SyncPluginReleases $job) => $job->plugin->is($plugin)); + } + + public function test_events_for_inactive_plugins_are_ignored(): void + { + $this->coveredPlugin(['is_active' => false]); + + $this->mock(PluginSyncService::class, function (MockInterface $mock): void { + $mock->shouldNotReceive('sync'); + }); + + $this->sendAppWebhook('push', ['repository' => ['full_name' => 'acme/camera-plugin']]) + ->assertOk() + ->assertJson(['plugins' => 0]); + } + + public function test_repository_events_need_a_valid_signature(): void + { + $this->coveredPlugin(); + + $this->postJson('/webhooks/github-app', ['repository' => ['full_name' => 'acme/camera-plugin']], [ + 'X-GitHub-Event' => 'push', + 'X-Hub-Signature-256' => 'sha256=invalid', + ])->assertForbidden(); + } + + public function test_preflight_checks_skip_the_repo_webhook_when_the_app_covers_the_repo(): void + { + Http::fake(['*' => Http::response([], 404)]); + + $plugin = $this->coveredPlugin(['webhook_installed' => false]); + $plugin->update(['status' => PluginStatus::Draft]); + + Livewire::actingAs($plugin->user) + ->test(Show::class, $plugin->routeParams()) + ->call('runPreflightChecks'); + + $this->assertTrue($plugin->fresh()->webhook_installed); + Http::assertNotSent(fn (Request $request) => str_contains($request->url(), '/hooks')); + } + + public function test_retrying_the_webhook_does_not_create_one_when_the_app_covers_the_repo(): void + { + Http::fake(['*' => Http::response([], 404)]); + + $plugin = $this->coveredPlugin(['webhook_installed' => false]); + + Livewire::actingAs($plugin->user) + ->test(Show::class, $plugin->routeParams()) + ->call('retryWebhook'); + + $this->assertTrue($plugin->fresh()->webhook_installed); + Http::assertNotSent(fn (Request $request) => str_contains($request->url(), '/hooks')); + } + + public function test_plugins_the_app_cannot_reach_still_get_a_repo_webhook(): void + { + Http::fake(['*' => Http::response([], 404)]); + + $user = User::factory()->withGitHubApp()->create(); + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'repository_url' => 'https://github.com/acme/camera-plugin', + 'webhook_installed' => false, + ]); + + $this->assertFalse($plugin->isReachableViaGitHubApp()); + + Livewire::actingAs($user) + ->test(Show::class, $plugin->routeParams()) + ->call('retryWebhook'); + + Http::assertSent(fn (Request $request) => $request->method() === 'POST' + && str_ends_with($request->url(), '/repos/acme/camera-plugin/hooks')); + } +} diff --git a/tests/Feature/GitHubAppServiceTest.php b/tests/Feature/GitHubAppServiceTest.php new file mode 100644 index 000000000..9b723873d --- /dev/null +++ b/tests/Feature/GitHubAppServiceTest.php @@ -0,0 +1,149 @@ +assertSame( + 'https://github.com/apps/nativephp-plugin-marketplace/installations/new', + (new GitHubAppService)->installationUrl() + ); + } + + public function test_jwt_is_signed_with_the_app_private_key(): void + { + $publicKey = $this->configureGitHubApp(); + + $jwt = (new GitHubAppService)->generateJwt(); + $claims = JWT::decode($jwt, new Key($publicKey, 'RS256')); + + $this->assertSame('12345', $claims->iss); + $this->assertLessThanOrEqual(time(), $claims->iat); + $this->assertGreaterThan(time(), $claims->exp); + $this->assertLessThanOrEqual(10 * 60, $claims->exp - $claims->iat); + } + + public function test_jwt_accepts_a_private_key_stored_on_one_line(): void + { + $publicKey = $this->configureGitHubApp(); + + config(['services.github_app.private_key' => str_replace("\n", '\n', config('services.github_app.private_key'))]); + + $this->assertStringNotContainsString("\n", config('services.github_app.private_key')); + + $claims = JWT::decode((new GitHubAppService)->generateJwt(), new Key($publicKey, 'RS256')); + + $this->assertSame('12345', $claims->iss); + } + + public function test_jwt_fails_clearly_when_the_private_key_is_missing(): void + { + config(['services.github_app.private_key' => '']); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('GITHUB_APP_PRIVATE_KEY'); + + (new GitHubAppService)->generateJwt(); + } + + public function test_a_missing_private_key_does_not_break_installation_token_lookups(): void + { + config(['services.github_app.private_key' => '']); + + $installation = GitHubInstallation::factory()->create(); + + $this->assertNull((new GitHubAppService)->refreshInstallationToken($installation)); + } + + public function test_find_installation_for_repo_with_all_repos_selected(): void + { + $user = User::factory()->withGitHubApp()->create(); + $installation = GitHubInstallation::factory()->create([ + 'user_id' => $user->id, + 'account_login' => 'testuser', + 'selection_type' => 'all', + ]); + + $service = new GitHubAppService; + $found = $service->findInstallationForRepo($user, 'testuser', 'my-plugin'); + + $this->assertNotNull($found); + $this->assertEquals($installation->id, $found->id); + } + + public function test_find_installation_for_repo_with_selected_repos(): void + { + $user = User::factory()->withGitHubApp()->create(); + GitHubInstallation::factory()->selectedRepos(['testuser/my-plugin'])->create([ + 'user_id' => $user->id, + 'account_login' => 'testuser', + ]); + + $service = new GitHubAppService; + + $found = $service->findInstallationForRepo($user, 'testuser', 'my-plugin'); + $this->assertNotNull($found); + + $notFound = $service->findInstallationForRepo($user, 'testuser', 'other-repo'); + $this->assertNull($notFound); + } + + public function test_find_installation_for_repo_ignores_suspended(): void + { + $user = User::factory()->withGitHubApp()->create(); + GitHubInstallation::factory()->suspended()->create([ + 'user_id' => $user->id, + 'account_login' => 'testuser', + 'selection_type' => 'all', + ]); + + $service = new GitHubAppService; + $found = $service->findInstallationForRepo($user, 'testuser', 'my-plugin'); + + $this->assertNull($found); + } + + public function test_find_installation_for_repo_returns_null_for_wrong_owner(): void + { + $user = User::factory()->withGitHubApp()->create(); + GitHubInstallation::factory()->create([ + 'user_id' => $user->id, + 'account_login' => 'testuser', + 'selection_type' => 'all', + ]); + + $service = new GitHubAppService; + $found = $service->findInstallationForRepo($user, 'otheruser', 'my-plugin'); + + $this->assertNull($found); + } + + public function test_find_installation_for_repo_case_insensitive_owner(): void + { + $user = User::factory()->withGitHubApp()->create(); + GitHubInstallation::factory()->create([ + 'user_id' => $user->id, + 'account_login' => 'TestUser', + 'selection_type' => 'all', + ]); + + $service = new GitHubAppService; + $found = $service->findInstallationForRepo($user, 'testuser', 'my-plugin'); + + $this->assertNotNull($found); + } +} diff --git a/tests/Feature/GitHubAppSetupTest.php b/tests/Feature/GitHubAppSetupTest.php new file mode 100644 index 000000000..bcd8540bb --- /dev/null +++ b/tests/Feature/GitHubAppSetupTest.php @@ -0,0 +1,179 @@ +configureGitHubApp(); + } + + /** + * @param array $userInstallationIds + * @param array $repositories + */ + private function fakeGitHub(array $userInstallationIds, string $selection = 'selected', array $repositories = []): void + { + Http::fake([ + 'api.github.com/user/installations*' => Http::response([ + 'installations' => array_map(fn (int $id) => ['id' => $id], $userInstallationIds), + ]), + 'api.github.com/app/installations/555/access_tokens' => Http::response([ + 'token' => 'ghs_installation_token', + 'expires_at' => now()->addHour()->toIso8601String(), + ]), + 'api.github.com/app/installations/555' => Http::response([ + 'id' => 555, + 'account' => ['login' => 'acme', 'type' => 'Organization', 'id' => 99], + 'repository_selection' => $selection, + 'suspended_at' => null, + ]), + 'api.github.com/installation/repositories*' => Http::response([ + 'repositories' => array_map(fn (string $name) => ['full_name' => $name], $repositories), + ]), + ]); + } + + public function test_setup_records_an_installation_the_user_can_see_on_github(): void + { + $this->fakeGitHub([555], 'selected', ['acme/one', 'acme/two']); + + $user = User::factory()->withGitHubApp()->create(); + + $this->actingAs($user) + ->get(route('github.setup', ['installation_id' => 555])) + ->assertRedirect(route('customer.integrations')) + ->assertSessionHas('success'); + + $installation = $user->githubInstallations()->sole(); + + $this->assertSame(555, (int) $installation->installation_id); + $this->assertSame('acme', $installation->account_login); + $this->assertSame('Organization', $installation->account_type); + $this->assertSame('selected', $installation->selection_type); + $this->assertSame(['acme/one', 'acme/two'], $installation->repository_selection); + } + + public function test_setup_does_not_store_a_repository_list_when_all_repos_are_selected(): void + { + $this->fakeGitHub([555], 'all'); + + $user = User::factory()->withGitHubApp()->create(); + + $this->actingAs($user)->get(route('github.setup', ['installation_id' => 555])); + + $installation = $user->githubInstallations()->sole(); + + $this->assertSame('all', $installation->selection_type); + $this->assertNull($installation->repository_selection); + } + + public function test_setup_rejects_an_installation_the_user_cannot_see_on_github(): void + { + $this->fakeGitHub([111, 222]); + + $user = User::factory()->withGitHubApp()->create(); + + $this->actingAs($user) + ->get(route('github.setup', ['installation_id' => 555])) + ->assertRedirect(route('customer.integrations')) + ->assertSessionHas('error'); + + $this->assertDatabaseCount('github_installations', 0); + } + + public function test_setup_rejects_users_still_on_the_legacy_oauth_app(): void + { + $this->fakeGitHub([555]); + + $user = User::factory()->withLegacyGitHub()->create(); + + $this->actingAs($user) + ->get(route('github.setup', ['installation_id' => 555])) + ->assertSessionHas('error'); + + $this->assertDatabaseCount('github_installations', 0); + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/user/installations')); + } + + public function test_setup_will_not_hand_over_an_installation_linked_to_someone_else(): void + { + $this->fakeGitHub([555]); + + $owner = User::factory()->withGitHubApp()->create(); + GitHubInstallation::factory()->create([ + 'user_id' => $owner->id, + 'installation_id' => 555, + ]); + + $otherUser = User::factory()->withGitHubApp()->create(); + + $this->actingAs($otherUser) + ->get(route('github.setup', ['installation_id' => 555])) + ->assertSessionHas('error'); + + $this->assertDatabaseHas('github_installations', [ + 'installation_id' => 555, + 'user_id' => $owner->id, + ]); + } + + public function test_setup_refreshes_an_installation_the_webhook_already_recorded(): void + { + $this->fakeGitHub([555], 'selected', ['acme/new-repo']); + + $user = User::factory()->withGitHubApp()->create(); + $installation = GitHubInstallation::factory()->selectedRepos([])->create([ + 'user_id' => $user->id, + 'installation_id' => 555, + 'account_login' => 'acme', + ]); + + $this->actingAs($user) + ->get(route('github.setup', ['installation_id' => 555])) + ->assertSessionHas('success'); + + $this->assertSame(['acme/new-repo'], $installation->fresh()->repository_selection); + } + + public function test_sync_command_updates_installations_and_removes_ones_github_no_longer_has(): void + { + $this->fakeGitHub([], 'selected', ['acme/one']); + Http::fake(['api.github.com/app/installations/777' => Http::response([], 404)]); + + $user = User::factory()->withGitHubApp()->create(); + $current = GitHubInstallation::factory()->create([ + 'user_id' => $user->id, + 'installation_id' => 555, + 'selection_type' => 'all', + ]); + $removed = GitHubInstallation::factory()->create([ + 'user_id' => $user->id, + 'installation_id' => 777, + ]); + + $this->artisan('github:sync-installations') + ->expectsOutputToContain('Synced: 1, removed: 1, failed: 0') + ->assertSuccessful(); + + $this->assertSame(['acme/one'], $current->fresh()->repository_selection); + $this->assertModelMissing($removed); + } +} diff --git a/tests/Feature/GitHubAppUserTokenTest.php b/tests/Feature/GitHubAppUserTokenTest.php new file mode 100644 index 000000000..3f5941a92 --- /dev/null +++ b/tests/Feature/GitHubAppUserTokenTest.php @@ -0,0 +1,196 @@ +configureGitHubApp(); + + config([ + 'services.github.client_id' => 'legacy-client', + 'services.github.client_secret' => 'legacy-secret', + ]); + } + + private function fakeSocialiteUser(User $user, string $intent): void + { + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->id = $user->github_id; + $socialiteUser->nickname = $user->github_username; + $socialiteUser->name = $user->name; + $socialiteUser->email = $user->email; + $socialiteUser->token = 'ghu_app_token'; + $socialiteUser->refreshToken = 'ghr_app_refresh'; + $socialiteUser->expiresIn = 28800; + + $provider = Mockery::mock(GithubProvider::class); + $provider->shouldReceive('user')->andReturn($socialiteUser); + + Socialite::shouldReceive('driver')->with('github-app')->andReturn($provider); + + session([ + 'github_auth_intent' => $intent, + 'github_auth_driver' => 'github-app', + ]); + } + + public function test_login_stores_the_refresh_token_and_expiry(): void + { + Http::fake(); + + $user = User::factory()->withGitHubApp()->create(); + $this->fakeSocialiteUser($user, 'login'); + + $this->get('/auth/github/callback'); + + $user->refresh(); + + $this->assertSame('ghr_app_refresh', $user->getGitHubRefreshToken()); + $this->assertTrue($user->github_token_expires_at->between(now()->addHours(7), now()->addHours(9))); + } + + public function test_an_expired_token_is_refreshed_before_use(): void + { + Http::fake([ + 'github.com/login/oauth/access_token' => Http::response([ + 'access_token' => 'ghu_fresh', + 'refresh_token' => 'ghr_fresh', + 'expires_in' => 28800, + ]), + ]); + + $user = User::factory()->withGitHubApp()->create([ + 'github_refresh_token' => encrypt('ghr_old'), + 'github_token_expires_at' => now()->subMinute(), + ]); + + $this->assertSame('ghu_fresh', $user->getGitHubToken()); + + Http::assertSent(fn (Request $request) => $request['grant_type'] === 'refresh_token' + && $request['refresh_token'] === 'ghr_old' + && $request['client_id'] === 'Iv1.testclient'); + + $user->refresh(); + + $this->assertSame('ghr_fresh', $user->getGitHubRefreshToken()); + $this->assertTrue($user->github_token_expires_at->isFuture()); + } + + public function test_a_token_that_has_not_expired_is_used_as_is(): void + { + Http::fake(); + + $user = User::factory()->withGitHubApp()->create([ + 'github_token' => encrypt('ghu_current'), + 'github_refresh_token' => encrypt('ghr_current'), + 'github_token_expires_at' => now()->addHours(4), + ]); + + $this->assertSame('ghu_current', $user->getGitHubToken()); + + Http::assertNothingSent(); + } + + public function test_a_rejected_refresh_token_clears_the_stored_tokens(): void + { + Http::fake([ + 'github.com/login/oauth/access_token' => Http::response(['error' => 'bad_refresh_token']), + ]); + + $user = User::factory()->withGitHubApp()->create([ + 'github_refresh_token' => encrypt('ghr_revoked'), + 'github_token_expires_at' => now()->subMinute(), + ]); + + $this->assertNull($user->getGitHubToken()); + + $user->refresh(); + + $this->assertNull($user->github_token); + $this->assertNull($user->github_refresh_token); + $this->assertNotNull($user->github_id); + } + + public function test_upgrading_by_linking_revokes_the_legacy_oauth_grant(): void + { + Http::fake(); + + $user = User::factory()->withLegacyGitHub()->create(['github_token' => encrypt('gho_legacy')]); + $this->fakeSocialiteUser($user, 'link'); + + $this->actingAs($user)->get('/auth/github/callback'); + + Http::assertSent(fn (Request $request) => $request->method() === 'DELETE' + && $request->url() === 'https://api.github.com/applications/legacy-client/grant' + && $request['access_token'] === 'gho_legacy' + && $request->hasHeader('Authorization', 'Basic '.base64_encode('legacy-client:legacy-secret'))); + + $this->assertEquals(GitHubAuthType::App, $user->fresh()->github_auth_type); + } + + public function test_upgrading_by_logging_in_revokes_the_legacy_oauth_grant(): void + { + Http::fake(); + + $user = User::factory()->withLegacyGitHub()->create(['github_token' => encrypt('gho_legacy')]); + $this->fakeSocialiteUser($user, 'login'); + + $this->get('/auth/github/callback'); + + Http::assertSent(fn (Request $request) => $request->method() === 'DELETE' + && str_ends_with($request->url(), '/applications/legacy-client/grant') + && $request['access_token'] === 'gho_legacy'); + } + + public function test_github_app_users_logging_in_again_do_not_trigger_a_revoke(): void + { + Http::fake(); + + $user = User::factory()->withGitHubApp()->create(); + $this->fakeSocialiteUser($user, 'login'); + + $this->get('/auth/github/callback'); + + Http::assertNotSent(fn (Request $request) => str_contains($request->url(), '/grant')); + } + + public function test_disconnecting_clears_the_refresh_token(): void + { + Http::fake(); + + $user = User::factory()->withGitHubApp()->create([ + 'github_refresh_token' => encrypt('ghr_current'), + 'github_token_expires_at' => now()->addHours(4), + ]); + + $this->actingAs($user)->delete(route('github.disconnect')); + + $user->refresh(); + + $this->assertNull($user->github_refresh_token); + $this->assertNull($user->github_token_expires_at); + } +} diff --git a/tests/Feature/GitHubAppWebhookTest.php b/tests/Feature/GitHubAppWebhookTest.php new file mode 100644 index 000000000..18c0f7612 --- /dev/null +++ b/tests/Feature/GitHubAppWebhookTest.php @@ -0,0 +1,218 @@ + $this->webhookSecret]); + } + + protected function signPayload(string $payload): string + { + return 'sha256='.hash_hmac('sha256', $payload, $this->webhookSecret); + } + + public function test_webhook_rejects_invalid_signature(): void + { + $response = $this->postJson('/webhooks/github-app', ['action' => 'created'], [ + 'X-GitHub-Event' => 'installation', + 'X-Hub-Signature-256' => 'sha256=invalid', + ]); + + $response->assertStatus(403); + } + + public function test_webhook_rejects_missing_signature(): void + { + $response = $this->postJson('/webhooks/github-app', ['action' => 'created'], [ + 'X-GitHub-Event' => 'installation', + ]); + + $response->assertStatus(403); + } + + public function test_installation_created_event_creates_record(): void + { + $user = User::factory()->withGitHubApp()->create(['github_id' => '99999']); + + $payload = [ + 'action' => 'created', + 'installation' => [ + 'id' => 12345, + 'account' => [ + 'login' => 'testuser', + 'type' => 'User', + 'id' => 54321, + ], + 'repository_selection' => 'all', + 'repositories' => [], + ], + 'sender' => [ + 'id' => 99999, + ], + ]; + + $payloadJson = json_encode($payload); + $signature = $this->signPayload($payloadJson); + + $response = $this->call('POST', '/webhooks/github-app', [], [], [], [ + 'HTTP_X_GITHUB_EVENT' => 'installation', + 'HTTP_X_HUB_SIGNATURE_256' => $signature, + 'CONTENT_TYPE' => 'application/json', + ], $payloadJson); + + $response->assertOk(); + $response->assertJson(['status' => 'created']); + + $this->assertDatabaseHas('github_installations', [ + 'user_id' => $user->id, + 'installation_id' => 12345, + 'account_login' => 'testuser', + 'account_type' => 'User', + 'selection_type' => 'all', + ]); + } + + public function test_installation_deleted_event_removes_record(): void + { + $user = User::factory()->withGitHubApp()->create(); + GitHubInstallation::factory()->create([ + 'user_id' => $user->id, + 'installation_id' => 12345, + ]); + + $payload = [ + 'action' => 'deleted', + 'installation' => [ + 'id' => 12345, + 'account' => ['login' => 'testuser', 'type' => 'User', 'id' => 1], + 'repository_selection' => 'all', + ], + 'sender' => ['id' => 99999], + ]; + + $payloadJson = json_encode($payload); + $signature = $this->signPayload($payloadJson); + + $response = $this->call('POST', '/webhooks/github-app', [], [], [], [ + 'HTTP_X_GITHUB_EVENT' => 'installation', + 'HTTP_X_HUB_SIGNATURE_256' => $signature, + 'CONTENT_TYPE' => 'application/json', + ], $payloadJson); + + $response->assertOk(); + $this->assertDatabaseMissing('github_installations', ['installation_id' => 12345]); + } + + public function test_installation_suspend_event_updates_record(): void + { + $user = User::factory()->withGitHubApp()->create(); + $installation = GitHubInstallation::factory()->create([ + 'user_id' => $user->id, + 'installation_id' => 12345, + ]); + + $payload = [ + 'action' => 'suspend', + 'installation' => [ + 'id' => 12345, + 'account' => ['login' => 'testuser', 'type' => 'User', 'id' => 1], + 'repository_selection' => 'all', + ], + 'sender' => ['id' => 99999], + ]; + + $payloadJson = json_encode($payload); + $signature = $this->signPayload($payloadJson); + + $response = $this->call('POST', '/webhooks/github-app', [], [], [], [ + 'HTTP_X_GITHUB_EVENT' => 'installation', + 'HTTP_X_HUB_SIGNATURE_256' => $signature, + 'CONTENT_TYPE' => 'application/json', + ], $payloadJson); + + $response->assertOk(); + $installation->refresh(); + $this->assertNotNull($installation->suspended_at); + } + + public function test_installation_unsuspend_event_clears_suspension(): void + { + $user = User::factory()->withGitHubApp()->create(); + $installation = GitHubInstallation::factory()->suspended()->create([ + 'user_id' => $user->id, + 'installation_id' => 12345, + ]); + + $payload = [ + 'action' => 'unsuspend', + 'installation' => [ + 'id' => 12345, + 'account' => ['login' => 'testuser', 'type' => 'User', 'id' => 1], + 'repository_selection' => 'all', + ], + 'sender' => ['id' => 99999], + ]; + + $payloadJson = json_encode($payload); + $signature = $this->signPayload($payloadJson); + + $response = $this->call('POST', '/webhooks/github-app', [], [], [], [ + 'HTTP_X_GITHUB_EVENT' => 'installation', + 'HTTP_X_HUB_SIGNATURE_256' => $signature, + 'CONTENT_TYPE' => 'application/json', + ], $payloadJson); + + $response->assertOk(); + $installation->refresh(); + $this->assertNull($installation->suspended_at); + } + + public function test_installation_repositories_event_updates_repos(): void + { + $user = User::factory()->withGitHubApp()->create(); + $installation = GitHubInstallation::factory()->selectedRepos(['testuser/repo-a'])->create([ + 'user_id' => $user->id, + 'installation_id' => 12345, + 'account_login' => 'testuser', + ]); + + $payload = [ + 'action' => 'added', + 'installation' => ['id' => 12345], + 'repository_selection' => 'selected', + 'repositories_added' => [ + ['full_name' => 'testuser/repo-b'], + ], + 'repositories_removed' => [], + 'sender' => ['id' => 99999], + ]; + + $payloadJson = json_encode($payload); + $signature = $this->signPayload($payloadJson); + + $response = $this->call('POST', '/webhooks/github-app', [], [], [], [ + 'HTTP_X_GITHUB_EVENT' => 'installation_repositories', + 'HTTP_X_HUB_SIGNATURE_256' => $signature, + 'CONTENT_TYPE' => 'application/json', + ], $payloadJson); + + $response->assertOk(); + $installation->refresh(); + $this->assertContains('testuser/repo-a', $installation->repository_selection); + $this->assertContains('testuser/repo-b', $installation->repository_selection); + } +} diff --git a/tests/Feature/GitHubLegacyOAuthRetirementTest.php b/tests/Feature/GitHubLegacyOAuthRetirementTest.php new file mode 100644 index 000000000..1aec6067d --- /dev/null +++ b/tests/Feature/GitHubLegacyOAuthRetirementTest.php @@ -0,0 +1,118 @@ + now()->addDay()->toDateString()]); + + $user = User::factory()->withLegacyGitHub()->create(['github_token' => encrypt('gho_legacy')]); + + $this->assertSame('gho_legacy', $user->getGitHubToken()); + } + + public function test_legacy_tokens_are_ignored_once_the_cutoff_date_has_passed(): void + { + config([ + 'services.github.legacy_oauth_cutoff_date' => now()->subDay()->toDateString(), + 'services.github.token' => 'ghp_platform', + ]); + + $user = User::factory()->withLegacyGitHub()->create(['github_token' => encrypt('gho_legacy')]); + + $this->assertNull($user->getGitHubToken()); + $this->assertFalse($user->hasGitHubToken()); + $this->assertSame('ghp_platform', GitHubUserService::for($user)->resolveTokenForRepo('acme', 'public-plugin')); + } + + public function test_github_app_tokens_are_unaffected_by_the_cutoff_date(): void + { + config(['services.github.legacy_oauth_cutoff_date' => now()->subDay()->toDateString()]); + + $user = User::factory()->withGitHubApp()->create(['github_token' => encrypt('ghu_app')]); + + $this->assertSame('ghu_app', $user->getGitHubToken()); + } + + public function test_command_refuses_to_run_before_the_cutoff_date(): void + { + config(['services.github.legacy_oauth_cutoff_date' => now()->addWeek()->toDateString()]); + + $user = User::factory()->withLegacyGitHub()->create(); + + $this->artisan('github:retire-legacy-oauth') + ->expectsOutputToContain("hasn't passed yet") + ->assertFailed(); + + $this->assertNotNull($user->fresh()->github_token); + } + + public function test_command_refuses_to_run_without_a_cutoff_date(): void + { + $this->artisan('github:retire-legacy-oauth') + ->expectsOutputToContain('GITHUB_LEGACY_OAUTH_CUTOFF_DATE') + ->assertFailed(); + } + + public function test_command_clears_legacy_tokens_but_keeps_github_identity(): void + { + config(['services.github.legacy_oauth_cutoff_date' => now()->subDay()->toDateString()]); + + $legacy = User::factory()->withLegacyGitHub()->create(); + $appUser = User::factory()->withGitHubApp()->create(['github_token' => encrypt('ghu_app')]); + + $this->artisan('github:retire-legacy-oauth') + ->expectsConfirmation('Clear 1 legacy token(s)? GitHub IDs and usernames are kept, so sign-in and repo invites keep working.', 'yes') + ->expectsOutputToContain('Cleared 1 legacy token(s).') + ->assertSuccessful(); + + $legacy->refresh(); + + $this->assertNull($legacy->github_token); + $this->assertNotNull($legacy->github_id); + $this->assertNotNull($legacy->github_username); + $this->assertEquals(GitHubAuthType::OAuth, $legacy->github_auth_type); + $this->assertSame('ghu_app', $appUser->fresh()->getGitHubToken()); + } + + public function test_command_lists_plugin_authors_who_never_moved_over(): void + { + config(['services.github.legacy_oauth_cutoff_date' => now()->subDay()->toDateString()]); + + $author = User::factory()->withLegacyGitHub()->create(); + Plugin::factory()->create(['user_id' => $author->id, 'name' => 'acme/camera-plugin']); + + $this->artisan('github:retire-legacy-oauth --dry-run') + ->expectsOutputToContain('1 of them plugin authors') + ->expectsTable(['User', 'Email', 'Plugins'], [[$author->id, $author->email, 'acme/camera-plugin']]) + ->expectsOutputToContain('DRY RUN') + ->assertSuccessful(); + + $this->assertNotNull($author->fresh()->github_token); + } + + public function test_declining_the_confirmation_changes_nothing(): void + { + config(['services.github.legacy_oauth_cutoff_date' => now()->subDay()->toDateString()]); + + $legacy = User::factory()->withLegacyGitHub()->create(); + + $this->artisan('github:retire-legacy-oauth') + ->expectsConfirmation('Clear 1 legacy token(s)? GitHub IDs and usernames are kept, so sign-in and repo invites keep working.', 'no') + ->expectsOutputToContain('Nothing changed.') + ->assertSuccessful(); + + $this->assertNotNull($legacy->fresh()->github_token); + } +} diff --git a/tests/Feature/GitHubMigrationBannerTest.php b/tests/Feature/GitHubMigrationBannerTest.php new file mode 100644 index 000000000..47be6caa5 --- /dev/null +++ b/tests/Feature/GitHubMigrationBannerTest.php @@ -0,0 +1,125 @@ + Http::response([], 404)]); + + $user = User::factory()->withLegacyGitHub()->create(); + + $response = $this->actingAs($user)->get('/dashboard/integrations'); + + $response->assertStatus(200); + $response->assertSee("We've Improved Our GitHub Connection", false); + $response->assertSee("You don't need to do anything.", false); + $response->assertSee('Reconnect GitHub'); + $response->assertDontSee('GitHub Connection Upgrade Required'); + } + + public function test_legacy_plugin_author_sees_urgent_banner_with_deadline(): void + { + Http::fake(['api.github.com/*' => Http::response([], 404)]); + config(['services.github.legacy_oauth_cutoff_date' => '2026-12-31']); + + $user = User::factory()->withLegacyGitHub()->create(); + Plugin::factory()->create(['user_id' => $user->id]); + + $response = $this->actingAs($user)->get('/dashboard/integrations'); + + $response->assertStatus(200); + $response->assertSee('GitHub Connection Upgrade Required'); + $response->assertSee("If you don't do it before 31 December 2026", false); + $response->assertSee('may remove them from the Marketplace'); + $response->assertSee('Connect the GitHub App'); + } + + public function test_github_app_user_does_not_see_migration_banner(): void + { + Http::fake(['api.github.com/*' => Http::response([], 404)]); + + $user = User::factory()->withGitHubApp()->create(); + + $response = $this->actingAs($user)->get('/dashboard/integrations'); + + $response->assertStatus(200); + $response->assertDontSee('GitHub Connection Upgrade Required'); + } + + public function test_user_without_github_does_not_see_migration_banner(): void + { + Http::fake(['api.github.com/*' => Http::response([], 404)]); + + $user = User::factory()->create(); + + $response = $this->actingAs($user)->get('/dashboard/integrations'); + + $response->assertStatus(200); + $response->assertDontSee('GitHub Connection Upgrade Required'); + } + + public function test_legacy_oauth_user_sees_banner_on_plugins_index(): void + { + Http::fake(['api.github.com/*' => Http::response([], 404)]); + + $user = User::factory()->withLegacyGitHub()->create(); + + $this->actingAs($user); + + $response = $this->get('/dashboard/developer/plugins'); + + $response->assertStatus(200); + $response->assertSee("We've Improved Our GitHub Connection", false); + } + + public function test_legacy_oauth_user_is_blocked_from_plugin_creation_via_livewire(): void + { + $user = User::factory()->withLegacyGitHub()->create(); + + Livewire::actingAs($user) + ->test(Create::class) + ->set('repository', 'testuser/test-plugin') + ->set('pluginType', 'free') + ->call('createPlugin') + ->assertHasNoErrors(); + + // Verify no plugin was created + $this->assertDatabaseCount('plugins', 0); + } + + public function test_legacy_oauth_user_sees_blocking_banner_on_plugin_create(): void + { + Http::fake(['api.github.com/*' => Http::response([], 404)]); + + $user = User::factory()->withLegacyGitHub()->create(); + + $response = $this->actingAs($user)->get('/dashboard/developer/plugins/create'); + + $response->assertStatus(200); + $response->assertSee('GitHub Connection Upgrade Required'); + $response->assertSee('You need to connect the GitHub App before you can create a plugin.'); + } +} diff --git a/tests/Feature/GitHubTokenResolutionTest.php b/tests/Feature/GitHubTokenResolutionTest.php new file mode 100644 index 000000000..fcd557338 --- /dev/null +++ b/tests/Feature/GitHubTokenResolutionTest.php @@ -0,0 +1,86 @@ +withGitHubApp()->create(); + $installation = GitHubInstallation::factory()->create([ + 'user_id' => $user->id, + 'account_login' => 'testowner', + 'selection_type' => 'all', + ]); + + $appService = Mockery::mock(GitHubAppService::class); + $appService->shouldReceive('findInstallationForRepo') + ->with($user, 'testowner', 'testrepo') + ->andReturn($installation); + $appService->shouldReceive('getInstallationToken') + ->with($installation) + ->andReturn('ghs_installation_token'); + + $this->app->instance(GitHubAppService::class, $appService); + + $service = GitHubUserService::for($user); + $token = $service->resolveTokenForRepo('testowner', 'testrepo'); + + $this->assertEquals('ghs_installation_token', $token); + } + + public function test_falls_back_to_user_oauth_token_when_no_installation(): void + { + $user = User::factory()->withGitHubApp()->create(); + + $appService = Mockery::mock(GitHubAppService::class); + $appService->shouldReceive('findInstallationForRepo') + ->andReturn(null); + + $this->app->instance(GitHubAppService::class, $appService); + + $service = GitHubUserService::for($user); + $token = $service->resolveTokenForRepo('testowner', 'testrepo'); + + // Should get the user's OAuth token + $this->assertNotNull($token); + $this->assertStringStartsWith('ghu_test_token_', $token); + } + + public function test_falls_back_to_platform_token_when_no_user_token(): void + { + $user = User::factory()->create([ + 'github_auth_type' => null, + 'github_token' => null, + ]); + + config(['services.github.token' => 'ghp_platform_token']); + + $service = GitHubUserService::for($user); + $token = $service->resolveTokenForRepo('testowner', 'testrepo'); + + $this->assertEquals('ghp_platform_token', $token); + } + + public function test_legacy_oauth_user_uses_oauth_token_directly(): void + { + $user = User::factory()->withLegacyGitHub()->create(); + + $service = GitHubUserService::for($user); + $token = $service->resolveTokenForRepo('testowner', 'testrepo'); + + // Should get the user's OAuth token directly (no installation lookup) + $this->assertNotNull($token); + $this->assertStringStartsWith('gho_test_token_', $token); + } +}