From 5f52d050ee0061065dac6fd80660ff5898619cfd Mon Sep 17 00:00:00 2001 From: Twyco Date: Thu, 26 Mar 2026 08:56:52 +0100 Subject: [PATCH] Implement Gitea user connection foundation --- .../Git/Contracts/GitProviderClient.php | 19 ++ app/Actions/Git/Data/GitAuthenticatedUser.php | 15 ++ app/Actions/Git/Data/GitRepository.php | 18 ++ app/Actions/Git/GitProviderKey.php | 15 ++ app/Actions/Git/GitProviderManager.php | 25 ++ app/Actions/Git/Gitea/GiteaProviderClient.php | 101 ++++++++ .../Settings/GitConnectionController.php | 120 +++++++++ .../StoreUserGitConnectionRequest.php | 78 ++++++ .../UpdateUserGitConnectionRequest.php | 83 +++++++ app/Models/User.php | 8 + app/Models/UserGitConnection.php | 43 ++++ .../factories/UserGitConnectionFactory.php | 32 +++ ...4522_create_user_git_connections_table.php | 36 +++ database/seeders/DatabaseSeeder.php | 1 + database/seeders/UserGitConnectionSeeder.php | 32 +++ resources/js/layouts/settings/Layout.vue | 7 +- .../js/pages/settings/GitConnections.vue | 235 ++++++++++++++++++ .../types/models/user-git-connection-model.ts | 13 + resources/js/types/page-props.ts | 6 + routes/settings.php | 10 + tests/Feature/Git/GiteaProviderTest.php | 83 +++++++ .../Feature/Models/UserGitConnectionTest.php | 43 ++++ tests/Feature/Settings/GitConnectionTest.php | 184 ++++++++++++++ 23 files changed, 1206 insertions(+), 1 deletion(-) create mode 100644 app/Actions/Git/Contracts/GitProviderClient.php create mode 100644 app/Actions/Git/Data/GitAuthenticatedUser.php create mode 100644 app/Actions/Git/Data/GitRepository.php create mode 100644 app/Actions/Git/GitProviderKey.php create mode 100644 app/Actions/Git/GitProviderManager.php create mode 100644 app/Actions/Git/Gitea/GiteaProviderClient.php create mode 100644 app/Http/Controllers/Settings/GitConnectionController.php create mode 100644 app/Http/Requests/Settings/StoreUserGitConnectionRequest.php create mode 100644 app/Http/Requests/Settings/UpdateUserGitConnectionRequest.php create mode 100644 app/Models/UserGitConnection.php create mode 100644 database/factories/UserGitConnectionFactory.php create mode 100644 database/migrations/2026_03_26_074522_create_user_git_connections_table.php create mode 100644 database/seeders/UserGitConnectionSeeder.php create mode 100644 resources/js/pages/settings/GitConnections.vue create mode 100644 resources/js/types/models/user-git-connection-model.ts create mode 100644 tests/Feature/Git/GiteaProviderTest.php create mode 100644 tests/Feature/Models/UserGitConnectionTest.php create mode 100644 tests/Feature/Settings/GitConnectionTest.php diff --git a/app/Actions/Git/Contracts/GitProviderClient.php b/app/Actions/Git/Contracts/GitProviderClient.php new file mode 100644 index 0000000..297ff8e --- /dev/null +++ b/app/Actions/Git/Contracts/GitProviderClient.php @@ -0,0 +1,19 @@ + + */ + public function listRepositories(string $baseUrl, string $token): array; +} diff --git a/app/Actions/Git/Data/GitAuthenticatedUser.php b/app/Actions/Git/Data/GitAuthenticatedUser.php new file mode 100644 index 0000000..7bcc8cc --- /dev/null +++ b/app/Actions/Git/Data/GitAuthenticatedUser.php @@ -0,0 +1,15 @@ + 'Gitea', + }; + } +} diff --git a/app/Actions/Git/GitProviderManager.php b/app/Actions/Git/GitProviderManager.php new file mode 100644 index 0000000..e3f8e2e --- /dev/null +++ b/app/Actions/Git/GitProviderManager.php @@ -0,0 +1,25 @@ + $this->giteaProviderClient, + }; + } +} diff --git a/app/Actions/Git/Gitea/GiteaProviderClient.php b/app/Actions/Git/Gitea/GiteaProviderClient.php new file mode 100644 index 0000000..155e21e --- /dev/null +++ b/app/Actions/Git/Gitea/GiteaProviderClient.php @@ -0,0 +1,101 @@ +request($baseUrl, $token) + ->get('/user') + ->throw() + ->json(); + + return new GitAuthenticatedUser( + providerUserId: (string) $payload['id'], + login: $payload['login'], + name: blank($payload['full_name'] ?? null) ? null : $payload['full_name'], + ); + } + + /** + * @return array + */ + public function listRepositories(string $baseUrl, string $token): array + { + $page = 1; + $repositories = []; + + do { + /** @var array $payload + */ + $payload = $this->request($baseUrl, $token) + ->get('/user/repos', [ + 'limit' => 100, + 'page' => $page, + ]) + ->throw() + ->json(); + + foreach ($payload as $repository) { + $repositories[] = new GitRepository( + providerRepositoryId: (string) $repository['id'], + owner: $repository['owner']['login'], + name: $repository['name'], + fullName: $repository['full_name'], + htmlUrl: $repository['html_url'], + isPrivate: $repository['private'], + ); + } + + $page++; + } while (count($payload) === 100); + + return $repositories; + } + + private function request(string $baseUrl, string $token): PendingRequest + { + return Http::acceptJson() + ->baseUrl($this->apiBaseUrl($baseUrl)) + ->connectTimeout(5) + ->timeout(10) + ->withHeader('Authorization', 'token '.$token); + } + + private function apiBaseUrl(string $baseUrl): string + { + $normalizedBaseUrl = Str::of(trim($baseUrl)) + ->rtrim('/') + ->value(); + + if (Str::endsWith(Str::lower($normalizedBaseUrl), '/api/v1')) { + return $normalizedBaseUrl; + } + + return Str::of($normalizedBaseUrl) + ->append('/api/v1') + ->value(); + } +} diff --git a/app/Http/Controllers/Settings/GitConnectionController.php b/app/Http/Controllers/Settings/GitConnectionController.php new file mode 100644 index 0000000..c77362a --- /dev/null +++ b/app/Http/Controllers/Settings/GitConnectionController.php @@ -0,0 +1,120 @@ + $request->user()->gitConnections + ->map(fn (UserGitConnection $gitConnection): array => [ + 'account_login' => $gitConnection->account_login, + 'account_name' => $gitConnection->account_name, + 'base_url' => $gitConnection->base_url, + 'created_at' => $gitConnection->created_at?->toISOString(), + 'id' => $gitConnection->id, + 'provider' => $gitConnection->provider->value, + 'provider_label' => $gitConnection->provider->label(), + 'provider_user_id' => $gitConnection->provider_user_id, + 'updated_at' => $gitConnection->updated_at?->toISOString(), + 'user_id' => $gitConnection->user_id, + ]) + ->values(), + 'status' => $request->session()->get('status'), + ]); + } + + public function store(StoreUserGitConnectionRequest $request): RedirectResponse + { + try { + $gitProvider = $this->gitProviderManager->client($request->provider()); + $authenticatedUser = $gitProvider->getAuthenticatedUser( + $request->baseUrl(), + $request->apiToken(), + ); + } catch (Throwable) { + return back() + ->withErrors([ + 'api_token' => 'KanbanGit could not authenticate against Gitea with this base URL and token.', + ]); + } + + UserGitConnection::query()->create([ + 'account_login' => $authenticatedUser->login, + 'account_name' => $authenticatedUser->name, + 'api_token' => $request->apiToken(), + 'base_url' => $request->baseUrl(), + 'provider' => $request->provider(), + 'provider_user_id' => $authenticatedUser->providerUserId, + 'user_id' => $request->user()->id, + ]); + + return to_route('git-connections.edit') + ->with('status', 'Git connection was added.'); + } + + public function update( + UpdateUserGitConnectionRequest $request, + UserGitConnection $gitConnection, + ): RedirectResponse { + $this->abortUnlessOwner($request, $gitConnection); + + try { + $gitProvider = $this->gitProviderManager->client($request->provider()); + $authenticatedUser = $gitProvider->getAuthenticatedUser( + $request->baseUrl(), + $request->apiToken(), + ); + } catch (Throwable) { + return back() + ->withErrors([ + 'api_token' => 'KanbanGit could not authenticate against Gitea with this base URL and token.', + ]); + } + + $gitConnection->update([ + 'account_login' => $authenticatedUser->login, + 'account_name' => $authenticatedUser->name, + 'api_token' => $request->apiToken(), + 'base_url' => $request->baseUrl(), + 'provider' => $request->provider(), + 'provider_user_id' => $authenticatedUser->providerUserId, + ]); + + return to_route('git-connections.edit') + ->with('status', 'Git connection was updated.'); + } + + public function destroy(Request $request, UserGitConnection $gitConnection): RedirectResponse + { + $this->abortUnlessOwner($request, $gitConnection); + + $label = "{$gitConnection->account_login} @ {$gitConnection->base_url}"; + + $gitConnection->delete(); + + return to_route('git-connections.edit') + ->with('status', "{$label} was removed."); + } + + private function abortUnlessOwner(Request $request, UserGitConnection $gitConnection): void + { + abort_unless($gitConnection->user_id === $request->user()->id, 403); + } +} diff --git a/app/Http/Requests/Settings/StoreUserGitConnectionRequest.php b/app/Http/Requests/Settings/StoreUserGitConnectionRequest.php new file mode 100644 index 0000000..dbddd2b --- /dev/null +++ b/app/Http/Requests/Settings/StoreUserGitConnectionRequest.php @@ -0,0 +1,78 @@ +user() !== null; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'api_token' => ['required', 'string', 'max:2048'], + 'base_url' => [ + 'required', + 'url:http,https', + 'max:255', + Rule::unique('user_git_connections') + ->where(fn ($query) => $query + ->where('user_id', $this->user()->id) + ->where('provider', $this->input('provider'))), + ], + 'provider' => ['required', Rule::in(array_map( + static fn (GitProviderKey $providerKey): string => $providerKey->value, + GitProviderKey::cases(), + ))], + ]; + } + + protected function prepareForValidation(): void + { + $this->merge([ + 'base_url' => $this->normalizedBaseUrl(), + 'provider' => Str::lower(trim((string) $this->input('provider'))), + ]); + } + + public function apiToken(): string + { + return (string) $this->validated('api_token'); + } + + public function baseUrl(): string + { + return (string) $this->validated('base_url'); + } + + public function provider(): GitProviderKey + { + return GitProviderKey::from((string) $this->validated('provider')); + } + + private function normalizedBaseUrl(): string + { + $baseUrl = Str::of((string) $this->input('base_url')) + ->trim() + ->rtrim('/') + ->value(); + + return preg_replace('#/api/v1$#i', '', $baseUrl) ?? $baseUrl; + } +} diff --git a/app/Http/Requests/Settings/UpdateUserGitConnectionRequest.php b/app/Http/Requests/Settings/UpdateUserGitConnectionRequest.php new file mode 100644 index 0000000..e73a97d --- /dev/null +++ b/app/Http/Requests/Settings/UpdateUserGitConnectionRequest.php @@ -0,0 +1,83 @@ +user() !== null; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + /** @var UserGitConnection $gitConnection */ + $gitConnection = $this->route('gitConnection'); + + return [ + 'api_token' => ['required', 'string', 'max:2048'], + 'base_url' => [ + 'required', + 'url:http,https', + 'max:255', + Rule::unique('user_git_connections') + ->ignore($gitConnection->id) + ->where(fn ($query) => $query + ->where('user_id', $this->user()->id) + ->where('provider', $this->input('provider'))), + ], + 'provider' => ['required', Rule::in(array_map( + static fn (GitProviderKey $providerKey): string => $providerKey->value, + GitProviderKey::cases(), + ))], + ]; + } + + protected function prepareForValidation(): void + { + $this->merge([ + 'base_url' => $this->normalizedBaseUrl(), + 'provider' => Str::lower(trim((string) $this->input('provider'))), + ]); + } + + public function apiToken(): string + { + return (string) $this->validated('api_token'); + } + + public function baseUrl(): string + { + return (string) $this->validated('base_url'); + } + + public function provider(): GitProviderKey + { + return GitProviderKey::from((string) $this->validated('provider')); + } + + private function normalizedBaseUrl(): string + { + $baseUrl = Str::of((string) $this->input('base_url')) + ->trim() + ->rtrim('/') + ->value(); + + return preg_replace('#/api/v1$#i', '', $baseUrl) ?? $baseUrl; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 39ed97e..405dffa 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Fortify\TwoFactorAuthenticatable; @@ -39,4 +40,11 @@ class User extends Authenticatable return $this->belongsToMany(Team::class) ->withTimestamps(); } + + public function gitConnections(): HasMany + { + return $this->hasMany(UserGitConnection::class) + ->orderBy('provider') + ->orderBy('base_url'); + } } diff --git a/app/Models/UserGitConnection.php b/app/Models/UserGitConnection.php new file mode 100644 index 0000000..ba5c528 --- /dev/null +++ b/app/Models/UserGitConnection.php @@ -0,0 +1,43 @@ + */ + use HasFactory; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'api_token' => 'encrypted', + 'provider' => GitProviderKey::class, + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/database/factories/UserGitConnectionFactory.php b/database/factories/UserGitConnectionFactory.php new file mode 100644 index 0000000..054aee5 --- /dev/null +++ b/database/factories/UserGitConnectionFactory.php @@ -0,0 +1,32 @@ + + */ +class UserGitConnectionFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'account_login' => fake()->userName(), + 'account_name' => fake()->name(), + 'api_token' => 'gitea-token-'.fake()->uuid(), + 'base_url' => 'https://git.'.fake()->domainName(), + 'provider' => GitProviderKey::Gitea, + 'provider_user_id' => (string) fake()->numberBetween(1, 999_999), + 'user_id' => User::factory(), + ]; + } +} diff --git a/database/migrations/2026_03_26_074522_create_user_git_connections_table.php b/database/migrations/2026_03_26_074522_create_user_git_connections_table.php new file mode 100644 index 0000000..886ada9 --- /dev/null +++ b/database/migrations/2026_03_26_074522_create_user_git_connections_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('provider'); + $table->string('base_url'); + $table->string('provider_user_id'); + $table->string('account_login'); + $table->string('account_name')->nullable(); + $table->text('api_token'); + $table->timestamps(); + + $table->unique(['user_id', 'provider', 'base_url']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_git_connections'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index ff76848..3a5b85e 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -14,6 +14,7 @@ class DatabaseSeeder extends Seeder { $this->call([ UserSeeder::class, + UserGitConnectionSeeder::class, ProjectSeeder::class, BoardColumnSeeder::class, ProjectTicketMetadataSeeder::class, diff --git a/database/seeders/UserGitConnectionSeeder.php b/database/seeders/UserGitConnectionSeeder.php new file mode 100644 index 0000000..c396345 --- /dev/null +++ b/database/seeders/UserGitConnectionSeeder.php @@ -0,0 +1,32 @@ +orderBy('id')->first(); + + if ($firstUser === null) { + return; + } + + UserGitConnection::factory()->for($firstUser)->create([ + 'account_login' => 'admin-user', + 'account_name' => 'Admin User', + 'api_token' => 'seeded-gitea-token', + 'base_url' => 'https://git.example.test', + 'provider' => GitProviderKey::Gitea, + 'provider_user_id' => '1001', + ]); + } +} diff --git a/resources/js/layouts/settings/Layout.vue b/resources/js/layouts/settings/Layout.vue index ff4e1af..4815010 100644 --- a/resources/js/layouts/settings/Layout.vue +++ b/resources/js/layouts/settings/Layout.vue @@ -6,6 +6,7 @@ import { Separator } from '@/components/ui/separator'; import { useCurrentUrl } from '@/composables/useCurrentUrl'; import { toUrl } from '@/lib/utils'; import { edit as editAppearance } from '@/routes/appearance'; +import { edit as editGitConnections } from '@/routes/git-connections'; import { edit as editProfile } from '@/routes/profile'; import { edit as editSecurity } from '@/routes/security'; import type { NavItem } from '@/types'; @@ -19,6 +20,10 @@ const sidebarNavItems: NavItem[] = [ title: 'Security', href: editSecurity(), }, + { + title: 'Git connections', + href: editGitConnections(), + }, { title: 'Appearance', href: editAppearance(), @@ -32,7 +37,7 @@ const { isCurrentOrParentUrl } = useCurrentUrl();
diff --git a/resources/js/pages/settings/GitConnections.vue b/resources/js/pages/settings/GitConnections.vue new file mode 100644 index 0000000..443db19 --- /dev/null +++ b/resources/js/pages/settings/GitConnections.vue @@ -0,0 +1,235 @@ + + + diff --git a/resources/js/types/models/user-git-connection-model.ts b/resources/js/types/models/user-git-connection-model.ts new file mode 100644 index 0000000..635d425 --- /dev/null +++ b/resources/js/types/models/user-git-connection-model.ts @@ -0,0 +1,13 @@ +export type UserGitConnectionModel = { + id: number; + user_id: number; + provider: string; + provider_label: string; + base_url: string; + provider_user_id: string; + account_login: string; + account_name: string | null; + created_at: string | null; + updated_at: string | null; + [key: string]: unknown; +}; diff --git a/resources/js/types/page-props.ts b/resources/js/types/page-props.ts index ec44b30..b4eb225 100644 --- a/resources/js/types/page-props.ts +++ b/resources/js/types/page-props.ts @@ -9,6 +9,7 @@ import type { TicketModel } from '@/types/models/ticket-model'; import type { TicketPriorityModel } from '@/types/models/ticket-priority-model'; import type { TicketTreeNodeModel } from '@/types/models/ticket-tree-node-model'; import type { TicketTypeModel } from '@/types/models/ticket-type-model'; +import type { UserGitConnectionModel } from '@/types/models/user-git-connection-model'; import type { UserModel } from '@/types/models/user-model'; export type AdminUsersPageProps = { @@ -100,3 +101,8 @@ export type ProjectTicketPageProps = { ticketTypes: TicketTypeModel[]; users: UserModel[]; }; + +export type SettingsGitConnectionsPageProps = { + gitConnections: UserGitConnectionModel[]; + status?: string | null; +}; diff --git a/routes/settings.php b/routes/settings.php index 634d7e2..484c10f 100644 --- a/routes/settings.php +++ b/routes/settings.php @@ -1,5 +1,6 @@ group(function () { ->middleware('throttle:6,1') ->name('user-password.update'); + Route::get('settings/git-connections', [GitConnectionController::class, 'edit']) + ->name('git-connections.edit'); + Route::post('settings/git-connections', [GitConnectionController::class, 'store']) + ->name('git-connections.store'); + Route::patch('settings/git-connections/{gitConnection}', [GitConnectionController::class, 'update']) + ->name('git-connections.update'); + Route::delete('settings/git-connections/{gitConnection}', [GitConnectionController::class, 'destroy']) + ->name('git-connections.destroy'); + Route::inertia('settings/appearance', 'settings/Appearance')->name('appearance.edit'); }); diff --git a/tests/Feature/Git/GiteaProviderTest.php b/tests/Feature/Git/GiteaProviderTest.php new file mode 100644 index 0000000..dd4ec4a --- /dev/null +++ b/tests/Feature/Git/GiteaProviderTest.php @@ -0,0 +1,83 @@ + Http::response([ + 'id' => 101, + 'login' => 'nico', + 'full_name' => 'Nico Example', + ]), + ]); + + $authenticatedUser = app(GiteaProviderClient::class)->getAuthenticatedUser( + 'https://git.example.test', + 'super-secret-token', + ); + + $this->assertInstanceOf(GitAuthenticatedUser::class, $authenticatedUser); + $this->assertSame('101', $authenticatedUser->providerUserId); + $this->assertSame('nico', $authenticatedUser->login); + $this->assertSame('Nico Example', $authenticatedUser->name); + + Http::assertSent(function ($request): bool { + return $request->url() === 'https://git.example.test/api/v1/user' + && $request->hasHeader('Authorization', 'token super-secret-token'); + }); + } + + /** + * Ensure the provider lists repositories from the authenticated Gitea account. + */ + public function test_provider_can_list_repositories(): void + { + Http::preventStrayRequests(); + Http::fake([ + 'https://git.example.test/api/v1/user/repos*' => Http::response([ + [ + 'id' => 501, + 'name' => 'kanbangit', + 'full_name' => 'nico/kanbangit', + 'html_url' => 'https://git.example.test/nico/kanbangit', + 'owner' => [ + 'login' => 'nico', + ], + 'private' => false, + ], + ]), + ]); + + $repositories = app(GiteaProviderClient::class)->listRepositories( + 'https://git.example.test/api/v1', + 'repo-token', + ); + + $this->assertCount(1, $repositories); + $this->assertContainsOnlyInstancesOf(GitRepository::class, $repositories); + $this->assertSame('501', $repositories[0]->providerRepositoryId); + $this->assertSame('nico/kanbangit', $repositories[0]->fullName); + $this->assertFalse($repositories[0]->isPrivate); + + Http::assertSent(function ($request): bool { + return str_starts_with($request->url(), 'https://git.example.test/api/v1/user/repos') + && $request->hasHeader('Authorization', 'token repo-token'); + }); + } +} diff --git a/tests/Feature/Models/UserGitConnectionTest.php b/tests/Feature/Models/UserGitConnectionTest.php new file mode 100644 index 0000000..1defbd7 --- /dev/null +++ b/tests/Feature/Models/UserGitConnectionTest.php @@ -0,0 +1,43 @@ +create([ + 'api_token' => 'plain-test-token', + ]); + + $storedToken = DB::table('user_git_connections') + ->where('id', $gitConnection->id) + ->value('api_token'); + + $this->assertNotSame('plain-test-token', $storedToken); + $this->assertSame('plain-test-token', $gitConnection->fresh()->api_token); + $this->assertSame(GitProviderKey::Gitea, $gitConnection->fresh()->provider); + } + + /** + * Ensure the model belongs to a user for per-account credential scoping. + */ + public function test_git_connections_belong_to_a_user(): void + { + $gitConnection = UserGitConnection::factory()->create(); + + $this->assertTrue($gitConnection->user()->exists()); + $this->assertTrue($gitConnection->user->is($gitConnection->fresh()->user)); + } +} diff --git a/tests/Feature/Settings/GitConnectionTest.php b/tests/Feature/Settings/GitConnectionTest.php new file mode 100644 index 0000000..c1c051d --- /dev/null +++ b/tests/Feature/Settings/GitConnectionTest.php @@ -0,0 +1,184 @@ +create(); + UserGitConnection::factory()->for($user)->create([ + 'account_login' => 'nico', + 'base_url' => 'https://git.example.test', + ]); + + $this->actingAs($user) + ->get(route('git-connections.edit')) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('settings/GitConnections') + ->where('gitConnections.0.account_login', 'nico') + ->where('gitConnections.0.base_url', 'https://git.example.test') + ->where('gitConnections.0.provider', GitProviderKey::Gitea->value), + ); + } + + /** + * Ensure a user can add a verified Gitea connection from settings. + */ + public function test_users_can_store_verified_gitea_connections(): void + { + Http::preventStrayRequests(); + Http::fake([ + '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/v1', + '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, + 'base_url' => 'https://git.example.test', + 'provider_user_id' => '101', + 'account_login' => 'nico', + 'account_name' => 'Nico Example', + ]); + } + + /** + * Ensure invalid provider 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([ + 'message' => 'Unauthorized', + ], 401), + ]); + + $user = User::factory()->create(); + + $this->actingAs($user) + ->from(route('git-connections.edit')) + ->post(route('git-connections.store'), [ + 'provider' => 'gitea', + 'base_url' => 'https://git.example.test', + 'api_token' => 'wrong-token', + ]) + ->assertRedirect(route('git-connections.edit')) + ->assertSessionHasErrors('api_token'); + } + + /** + * Ensure a user can update only their own Git connection. + */ + public function test_users_can_update_their_own_git_connections(): void + { + Http::preventStrayRequests(); + Http::fake([ + 'https://git.internal.test/api/v1/user' => Http::response([ + 'id' => 202, + 'login' => 'updated-user', + 'full_name' => 'Updated User', + ]), + ]); + + $user = User::factory()->create(); + $gitConnection = UserGitConnection::factory()->for($user)->create([ + 'account_login' => 'old-user', + 'account_name' => 'Old User', + 'base_url' => 'https://git.example.test', + 'provider' => GitProviderKey::Gitea, + 'provider_user_id' => '100', + ]); + + $this->actingAs($user) + ->patch(route('git-connections.update', $gitConnection), [ + 'provider' => 'gitea', + 'base_url' => 'https://git.internal.test', + 'api_token' => 'fresh-token', + ]) + ->assertRedirect(route('git-connections.edit')) + ->assertSessionHas('status', 'Git connection was updated.'); + + $this->assertDatabaseHas('user_git_connections', [ + 'id' => $gitConnection->id, + 'base_url' => 'https://git.internal.test', + 'provider_user_id' => '202', + 'account_login' => 'updated-user', + 'account_name' => 'Updated User', + ]); + } + + /** + * Ensure users cannot update another user's Git connection. + */ + public function test_users_cannot_update_foreign_git_connections(): void + { + Http::preventStrayRequests(); + Http::fake(); + + $owner = User::factory()->create(); + $otherUser = User::factory()->create(); + $gitConnection = UserGitConnection::factory()->for($owner)->create(); + + $this->actingAs($otherUser) + ->patch(route('git-connections.update', $gitConnection), [ + 'provider' => 'gitea', + 'base_url' => 'https://git.example.test', + 'api_token' => 'fresh-token', + ]) + ->assertForbidden(); + } + + /** + * Ensure users can remove their own Git connections. + */ + public function test_users_can_delete_their_own_git_connections(): void + { + $user = User::factory()->create(); + $gitConnection = UserGitConnection::factory()->for($user)->create([ + 'account_login' => 'nico', + 'base_url' => 'https://git.example.test', + ]); + + $this->actingAs($user) + ->delete(route('git-connections.destroy', $gitConnection)) + ->assertRedirect(route('git-connections.edit')) + ->assertSessionHas( + 'status', + 'nico @ https://git.example.test was removed.', + ); + + $this->assertDatabaseMissing('user_git_connections', [ + 'id' => $gitConnection->id, + ]); + } +}