diff --git a/app/Actions/Git/Contracts/GitProviderClient.php b/app/Actions/Git/Contracts/GitProviderClient.php index 9153465..92fb111 100644 --- a/app/Actions/Git/Contracts/GitProviderClient.php +++ b/app/Actions/Git/Contracts/GitProviderClient.php @@ -12,6 +12,8 @@ interface GitProviderClient { public function key(): GitProviderKey; + public function verifyRepositoryAccess(string $baseUrl, string $token): void; + public function getAuthenticatedUser(string $baseUrl, string $token): GitAuthenticatedUser; public function getRepository( diff --git a/app/Actions/Git/Gitea/GiteaProviderClient.php b/app/Actions/Git/Gitea/GiteaProviderClient.php index 18644ff..1e0fa67 100644 --- a/app/Actions/Git/Gitea/GiteaProviderClient.php +++ b/app/Actions/Git/Gitea/GiteaProviderClient.php @@ -19,6 +19,16 @@ class GiteaProviderClient implements GitProviderClient return GitProviderKey::Gitea; } + public function verifyRepositoryAccess(string $baseUrl, string $token): void + { + $this->request($baseUrl, $token) + ->get('/user/repos', [ + 'limit' => 1, + 'page' => 1, + ]) + ->throw(); + } + public function getAuthenticatedUser(string $baseUrl, string $token): GitAuthenticatedUser { /** @var array{id: int|string, login: string, full_name?: string|null} $payload */ diff --git a/app/Http/Controllers/Settings/GitConnectionController.php b/app/Http/Controllers/Settings/GitConnectionController.php index c77362a..281cbc9 100644 --- a/app/Http/Controllers/Settings/GitConnectionController.php +++ b/app/Http/Controllers/Settings/GitConnectionController.php @@ -2,6 +2,8 @@ namespace App\Http\Controllers\Settings; +use App\Actions\Git\Contracts\GitProviderClient; +use App\Actions\Git\Data\GitAuthenticatedUser; use App\Actions\Git\GitProviderManager; use App\Http\Controllers\Controller; use App\Http\Requests\Settings\StoreUserGitConnectionRequest; @@ -9,6 +11,7 @@ use App\Http\Requests\Settings\UpdateUserGitConnectionRequest; use App\Models\UserGitConnection; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Str; use Inertia\Inertia; use Inertia\Response; use Throwable; @@ -42,26 +45,28 @@ class GitConnectionController extends Controller public function store(StoreUserGitConnectionRequest $request): RedirectResponse { + $gitProvider = $this->gitProviderManager->client($request->provider()); + try { - $gitProvider = $this->gitProviderManager->client($request->provider()); - $authenticatedUser = $gitProvider->getAuthenticatedUser( - $request->baseUrl(), - $request->apiToken(), - ); + $gitProvider->verifyRepositoryAccess($request->baseUrl(), $request->apiToken()); } catch (Throwable) { return back() ->withErrors([ - 'api_token' => 'KanbanGit could not authenticate against Gitea with this base URL and token.', + 'api_token' => 'KanbanGit could not read repositories from Gitea with this base URL and token.', ]); } + $connectionMetadata = $this->resolveConnectionMetadata( + $gitProvider, + $request->baseUrl(), + $request->apiToken(), + ); + UserGitConnection::query()->create([ - 'account_login' => $authenticatedUser->login, - 'account_name' => $authenticatedUser->name, + ...$connectionMetadata, 'api_token' => $request->apiToken(), 'base_url' => $request->baseUrl(), 'provider' => $request->provider(), - 'provider_user_id' => $authenticatedUser->providerUserId, 'user_id' => $request->user()->id, ]); @@ -75,26 +80,28 @@ class GitConnectionController extends Controller ): RedirectResponse { $this->abortUnlessOwner($request, $gitConnection); + $gitProvider = $this->gitProviderManager->client($request->provider()); + try { - $gitProvider = $this->gitProviderManager->client($request->provider()); - $authenticatedUser = $gitProvider->getAuthenticatedUser( - $request->baseUrl(), - $request->apiToken(), - ); + $gitProvider->verifyRepositoryAccess($request->baseUrl(), $request->apiToken()); } catch (Throwable) { return back() ->withErrors([ - 'api_token' => 'KanbanGit could not authenticate against Gitea with this base URL and token.', + 'api_token' => 'KanbanGit could not read repositories from Gitea with this base URL and token.', ]); } + $connectionMetadata = $this->resolveConnectionMetadata( + $gitProvider, + $request->baseUrl(), + $request->apiToken(), + ); + $gitConnection->update([ - 'account_login' => $authenticatedUser->login, - 'account_name' => $authenticatedUser->name, + ...$connectionMetadata, 'api_token' => $request->apiToken(), 'base_url' => $request->baseUrl(), 'provider' => $request->provider(), - 'provider_user_id' => $authenticatedUser->providerUserId, ]); return to_route('git-connections.edit') @@ -117,4 +124,48 @@ class GitConnectionController extends Controller { abort_unless($gitConnection->user_id === $request->user()->id, 403); } + + /** + * @return array{account_login: string, account_name: string|null, provider_user_id: string} + */ + private function resolveConnectionMetadata( + GitProviderClient $gitProvider, + string $baseUrl, + string $apiToken, + ): array { + $authenticatedUser = $this->resolveAuthenticatedUser( + $gitProvider, + $baseUrl, + $apiToken, + ); + + return [ + 'account_login' => $authenticatedUser?->login ?? $this->fallbackAccountLogin($baseUrl), + 'account_name' => $authenticatedUser?->name, + 'provider_user_id' => $authenticatedUser?->providerUserId ?? '', + ]; + } + + private function resolveAuthenticatedUser( + GitProviderClient $gitProvider, + string $baseUrl, + string $apiToken, + ): ?GitAuthenticatedUser { + try { + return $gitProvider->getAuthenticatedUser($baseUrl, $apiToken); + } catch (Throwable) { + return null; + } + } + + private function fallbackAccountLogin(string $baseUrl): string + { + $host = parse_url($baseUrl, PHP_URL_HOST); + + if (is_string($host) && $host !== '') { + return Str::lower($host); + } + + return Str::lower($baseUrl); + } } diff --git a/resources/js/pages/projects/settings/Repositories.vue b/resources/js/pages/projects/settings/Repositories.vue index 1902508..54cd1e6 100644 --- a/resources/js/pages/projects/settings/Repositories.vue +++ b/resources/js/pages/projects/settings/Repositories.vue @@ -202,7 +202,7 @@ const submitUpdate = (): void => { updateForm.submit( ProjectRepositorySettingsController.update([ props.project.id, - updateForm.repository_connection_id, + Number(updateForm.repository_connection_id), ]), { preserveScroll: true, diff --git a/resources/js/pages/settings/GitConnections.vue b/resources/js/pages/settings/GitConnections.vue index c056d75..924b7a8 100644 --- a/resources/js/pages/settings/GitConnections.vue +++ b/resources/js/pages/settings/GitConnections.vue @@ -150,7 +150,10 @@ const breadcrumbItems: BreadcrumbItem[] = [

Remote user ID: - {{ gitConnection.provider_user_id }} + {{ + gitConnection.provider_user_id || + 'Unavailable for repository-only tokens' + }}

diff --git a/tests/Feature/Git/GiteaProviderTest.php b/tests/Feature/Git/GiteaProviderTest.php index a0d5a4b..285b857 100644 --- a/tests/Feature/Git/GiteaProviderTest.php +++ b/tests/Feature/Git/GiteaProviderTest.php @@ -15,6 +15,27 @@ class GiteaProviderTest extends TestCase { use RefreshDatabase; + /** + * Ensure the provider can verify repository read access with minimal payload. + */ + public function test_provider_can_verify_repository_read_access(): void + { + Http::preventStrayRequests(); + Http::fake([ + 'https://git.example.test/api/v1/user/repos*' => Http::response([]), + ]); + + app(GiteaProviderClient::class)->verifyRepositoryAccess( + 'https://git.example.test', + 'repo-token', + ); + + Http::assertSent(function ($request): bool { + return $request->url() === 'https://git.example.test/api/v1/user/repos?limit=1&page=1' + && $request->hasHeader('Authorization', 'token repo-token'); + }); + } + /** * Ensure the provider authenticates against the Gitea user endpoint. */ diff --git a/tests/Feature/Settings/GitConnectionTest.php b/tests/Feature/Settings/GitConnectionTest.php index c1c051d..7ead350 100644 --- a/tests/Feature/Settings/GitConnectionTest.php +++ b/tests/Feature/Settings/GitConnectionTest.php @@ -37,17 +37,16 @@ class GitConnectionTest extends TestCase } /** - * Ensure a user can add a verified Gitea connection from settings. + * Ensure a user can add a verified Gitea connection with repository-only access. */ - public function test_users_can_store_verified_gitea_connections(): void + public function test_users_can_store_verified_gitea_connections_with_repository_only_access(): void { Http::preventStrayRequests(); Http::fake([ + 'https://git.example.test/api/v1/user/repos*' => Http::response([]), 'https://git.example.test/api/v1/user' => Http::response([ - 'id' => 101, - 'login' => 'nico', - 'full_name' => 'Nico Example', - ]), + 'message' => 'Forbidden', + ], 403), ]); $user = User::factory()->create(); @@ -61,6 +60,42 @@ class GitConnectionTest extends TestCase ->assertRedirect(route('git-connections.edit')) ->assertSessionHas('status', 'Git connection was added.'); + $this->assertDatabaseHas('user_git_connections', [ + 'user_id' => $user->id, + 'provider' => GitProviderKey::Gitea->value, + 'base_url' => 'https://git.example.test', + 'provider_user_id' => '', + 'account_login' => 'git.example.test', + 'account_name' => null, + ]); + } + + /** + * Ensure a user connection stores authenticated user metadata when it is available. + */ + public function test_users_can_store_authenticated_user_metadata_when_available(): void + { + Http::preventStrayRequests(); + Http::fake([ + 'https://git.example.test/api/v1/user/repos*' => Http::response([]), + 'https://git.example.test/api/v1/user' => Http::response([ + 'id' => 101, + 'login' => 'nico', + 'full_name' => 'Nico Example', + ]), + ]); + + $user = User::factory()->create(); + + $this->actingAs($user) + ->post(route('git-connections.store'), [ + 'provider' => 'gitea', + 'base_url' => 'https://git.example.test', + 'api_token' => 'super-secret-token', + ]) + ->assertRedirect(route('git-connections.edit')) + ->assertSessionHas('status', 'Git connection was added.'); + $this->assertDatabaseHas('user_git_connections', [ 'user_id' => $user->id, 'provider' => GitProviderKey::Gitea->value, @@ -72,13 +107,13 @@ class GitConnectionTest extends TestCase } /** - * Ensure invalid provider credentials are rejected with a validation error. + * Ensure invalid repository credentials are rejected with a validation error. */ public function test_invalid_provider_credentials_are_rejected(): void { Http::preventStrayRequests(); Http::fake([ - 'https://git.example.test/api/v1/user' => Http::response([ + 'https://git.example.test/api/v1/user/repos*' => Http::response([ 'message' => 'Unauthorized', ], 401), ]); @@ -103,6 +138,7 @@ class GitConnectionTest extends TestCase { Http::preventStrayRequests(); Http::fake([ + 'https://git.internal.test/api/v1/user/repos*' => Http::response([]), 'https://git.internal.test/api/v1/user' => Http::response([ 'id' => 202, 'login' => 'updated-user',