diff --git a/app/Actions/Tickets/ListAssignedTicketsByVisibleProject.php b/app/Actions/Tickets/ListAssignedTicketsByVisibleProject.php new file mode 100644 index 0000000..f31200a --- /dev/null +++ b/app/Actions/Tickets/ListAssignedTicketsByVisibleProject.php @@ -0,0 +1,57 @@ +, tickets: array>}> + */ + public function handle(User $viewer, User $assignee): array + { + /** @var EloquentCollection $visibleProjects */ + $visibleProjects = $this->listVisibleProjects->handle($viewer); + $visibleProjectIds = $visibleProjects->pluck('id')->all(); + + if ($visibleProjectIds === []) { + return []; + } + + /** @var Collection> $ticketsByProject */ + $ticketsByProject = Ticket::query() + ->where('assignee_user_id', $assignee->id) + ->whereIn('project_id', $visibleProjectIds) + ->with([ + 'assignee', + 'project', + 'ticketPriority', + 'ticketStatus.boardColumn', + 'ticketType', + ]) + ->orderByDesc('sequence_number') + ->get() + ->groupBy('project_id'); + + return $visibleProjects + ->filter(fn (Project $project): bool => $ticketsByProject->has($project->id)) + ->map(fn (Project $project): array => [ + 'project' => ProjectResource::make($project)->resolve(), + 'tickets' => TicketResource::collection($ticketsByProject->get($project->id))->resolve(), + ]) + ->values() + ->all(); + } +} diff --git a/app/Actions/UserApplications/CreateUserApplication.php b/app/Actions/UserApplications/CreateUserApplication.php new file mode 100644 index 0000000..4d8b8ed --- /dev/null +++ b/app/Actions/UserApplications/CreateUserApplication.php @@ -0,0 +1,30 @@ +create([ + 'key_hash' => UserApplication::hashKey($plainTextKey), + 'name' => Arr::get($validated, 'name'), + 'user_id' => $user->id, + ]); + + return [ + 'application' => $application, + 'plain_text_key' => $plainTextKey, + ]; + } +} diff --git a/app/Actions/UserApplications/DeleteUserApplication.php b/app/Actions/UserApplications/DeleteUserApplication.php new file mode 100644 index 0000000..f9bcead --- /dev/null +++ b/app/Actions/UserApplications/DeleteUserApplication.php @@ -0,0 +1,17 @@ +name; + + $application->delete(); + + return $name; + } +} diff --git a/app/Http/Controllers/Api/UserAssignedTicketController.php b/app/Http/Controllers/Api/UserAssignedTicketController.php new file mode 100644 index 0000000..cbd01cb --- /dev/null +++ b/app/Http/Controllers/Api/UserAssignedTicketController.php @@ -0,0 +1,32 @@ +authenticatedUser($request); + + $assignedTickets = $this->listAssignedTicketsByVisibleProject->handle($viewer, $user); + + return response()->json([ + 'data' => $assignedTickets, + 'meta' => [ + 'assignee' => $assignedTickets === [] + ? null + : UserResource::make($user)->resolve(), + ], + ]); + } +} diff --git a/app/Http/Controllers/Settings/UserApplicationController.php b/app/Http/Controllers/Settings/UserApplicationController.php new file mode 100644 index 0000000..52509f0 --- /dev/null +++ b/app/Http/Controllers/Settings/UserApplicationController.php @@ -0,0 +1,57 @@ + $request->session()->get('applicationKey'), + 'applications' => UserApplicationResource::collection($request->user()->userApplications)->resolve(), + 'status' => $request->session()->get('status'), + ]); + } + + public function store(StoreUserApplicationRequest $request): RedirectResponse + { + $result = $this->createUserApplication->handle($request->user(), $request->validated()); + + return to_route('applications.edit') + ->with('applicationKey', $result['plain_text_key']) + ->with('status', "{$result['application']->name} was created. Save this key now; it will not be shown again."); + } + + public function destroy(Request $request, UserApplication $userApplication): RedirectResponse + { + $this->abortUnlessOwner($request, $userApplication); + + $name = $this->deleteUserApplication->handle($userApplication); + + return to_route('applications.edit') + ->with('status', "{$name} was removed."); + } + + private function abortUnlessOwner(Request $request, UserApplication $userApplication): void + { + $user = $request->user(); + + abort_unless($userApplication->user_id === $user->id, 403); + } +} diff --git a/app/Http/Middleware/AuthenticateApiApplicationKey.php b/app/Http/Middleware/AuthenticateApiApplicationKey.php new file mode 100644 index 0000000..8dc1405 --- /dev/null +++ b/app/Http/Middleware/AuthenticateApiApplicationKey.php @@ -0,0 +1,44 @@ +bearerToken(); + + if ($plainTextKey === null || trim($plainTextKey) === '') { + return response()->json(['message' => 'Unauthenticated.'], 401); + } + + $application = UserApplication::query() + ->with('user') + ->where('key_hash', UserApplication::hashKey($plainTextKey)) + ->first(); + + if ($application === null || $application->user === null) { + return response()->json(['message' => 'Unauthenticated.'], 401); + } + + Auth::setUser($application->user); + $request->setUserResolver(fn () => $application->user); + + $application->forceFill([ + 'last_used_at' => now(), + ])->save(); + + return $next($request); + } +} diff --git a/app/Http/Requests/Settings/StoreUserApplicationRequest.php b/app/Http/Requests/Settings/StoreUserApplicationRequest.php new file mode 100644 index 0000000..dc2b8d1 --- /dev/null +++ b/app/Http/Requests/Settings/StoreUserApplicationRequest.php @@ -0,0 +1,37 @@ +user() !== null; + } + + protected function prepareForValidation(): void + { + $this->merge([ + 'name' => Str::of((string) $this->input('name'))->trim()->value(), + ]); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + ]; + } +} diff --git a/app/Http/Resources/UserApplicationResource.php b/app/Http/Resources/UserApplicationResource.php new file mode 100644 index 0000000..11b69e0 --- /dev/null +++ b/app/Http/Resources/UserApplicationResource.php @@ -0,0 +1,26 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'user_id' => $this->user_id, + 'name' => $this->name, + 'last_used_at' => $this->last_used_at?->toISOString(), + 'created_at' => $this->created_at?->toISOString(), + 'updated_at' => $this->updated_at?->toISOString(), + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 22fdb24..b3dc0ba 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -63,6 +63,13 @@ class User extends Authenticatable ->orderBy('base_url'); } + public function userApplications(): HasMany + { + return $this->hasMany(UserApplication::class) + ->orderBy('name') + ->orderBy('id'); + } + public function assignedTickets(): HasMany { return $this->hasMany(Ticket::class, 'assignee_user_id'); diff --git a/app/Models/UserApplication.php b/app/Models/UserApplication.php new file mode 100644 index 0000000..98ea81c --- /dev/null +++ b/app/Models/UserApplication.php @@ -0,0 +1,44 @@ + */ + use HasFactory; + + public static function generatePlainTextKey(): string + { + return 'kg_app_'.Str::random(64); + } + + public static function hashKey(string $plainTextKey): string + { + return hash_hmac('sha256', $plainTextKey, (string) config('app.key')); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'last_used_at' => 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index efb547c..001ee1c 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,5 +1,6 @@ api(append: [ + AuthenticateApiApplicationKey::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/database/factories/UserApplicationFactory.php b/database/factories/UserApplicationFactory.php new file mode 100644 index 0000000..1c36d79 --- /dev/null +++ b/database/factories/UserApplicationFactory.php @@ -0,0 +1,30 @@ + + */ +class UserApplicationFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $plainTextKey = UserApplication::generatePlainTextKey(); + + return [ + 'key_hash' => UserApplication::hashKey($plainTextKey), + 'last_used_at' => null, + 'name' => fake()->words(2, true), + 'user_id' => User::factory(), + ]; + } +} diff --git a/database/migrations/2026_04_20_174556_create_user_applications_table.php b/database/migrations/2026_04_20_174556_create_user_applications_table.php new file mode 100644 index 0000000..e0809a4 --- /dev/null +++ b/database/migrations/2026_04_20_174556_create_user_applications_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('key_hash', 64)->unique(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'name']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_applications'); + } +}; diff --git a/docs/openapi/kanbangit-api.openapi.json b/docs/openapi/kanbangit-api.openapi.json index 1d6dca8..043b565 100644 --- a/docs/openapi/kanbangit-api.openapi.json +++ b/docs/openapi/kanbangit-api.openapi.json @@ -3,14 +3,18 @@ "info": { "title": "KanbanGit Application API", "version": "0.1.0", - "description": "First-party JSON API for KanbanGit UI-equivalent workflows. Authentication is intentionally not described here because API verification will be added globally by the application later." + "description": "First-party JSON API for KanbanGit UI-equivalent workflows. Application endpoints require a bearer application key created in Settings > Applications." }, "servers": [ { "url": "/api" } ], - "security": [], + "security": [ + { + "ApplicationKey": [] + } + ], "tags": [ { "name": "Projects" @@ -29,6 +33,9 @@ }, { "name": "Git Connections" + }, + { + "name": "Users" } ], "paths": { @@ -1041,10 +1048,45 @@ } } } + }, + "/users/{user}/assigned-tickets": { + "get": { + "tags": ["Users"], + "operationId": "getUserAssignedTickets", + "parameters": [ + { + "$ref": "#/components/parameters/UserId" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/AssignedTicketsPayload" + }, + "401": { + "$ref": "#/components/responses/Unauthenticated" + } + } + } } }, "components": { + "securitySchemes": { + "ApplicationKey": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "KanbanGit application key", + "description": "Use a first-party application key created in Settings > Applications." + } + }, "parameters": { + "UserId": { + "name": "user", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + }, "ProjectId": { "name": "project", "in": "path", @@ -1600,6 +1642,16 @@ } } }, + "AssignedTicketsPayload": { + "description": "Tickets assigned to a user grouped by projects visible to the authenticated application key owner. meta.assignee is null when no visible assigned tickets exist.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssignedTicketsPayloadEnvelope" + } + } + } + }, "ValidationError": { "description": "Laravel validation error response.", "content": { @@ -1779,6 +1831,49 @@ } } }, + "AssignedTicketsPayloadEnvelope": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AssignedTicketsProjectGroup" + } + }, + "meta": { + "type": "object", + "properties": { + "assignee": { + "anyOf": [ + { + "$ref": "#/components/schemas/User" + }, + { + "type": "null" + } + ] + } + }, + "required": ["assignee"] + } + }, + "required": ["data", "meta"] + }, + "AssignedTicketsProjectGroup": { + "type": "object", + "properties": { + "project": { + "$ref": "#/components/schemas/Project" + }, + "tickets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Ticket" + } + } + }, + "required": ["project", "tickets"] + }, "BoardColumn": { "type": "object", "additionalProperties": true, diff --git a/resources/js/layouts/settings/Layout.vue b/resources/js/layouts/settings/Layout.vue index 40d4d88..879a8cb 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/index'; +import { edit as editApplications } from '@/routes/applications/index'; import { edit as editGitConnections } from '@/routes/git-connections/index'; import { edit as editProfile } from '@/routes/profile/index'; import { edit as editSecurity } from '@/routes/security/index'; @@ -24,6 +25,10 @@ const sidebarNavItems: NavItem[] = [ title: 'Git connections', href: editGitConnections(), }, + { + title: 'Applications', + href: editApplications(), + }, { title: 'Appearance', href: editAppearance(), diff --git a/resources/js/pages/settings/Applications.vue b/resources/js/pages/settings/Applications.vue new file mode 100644 index 0000000..acb8a08 --- /dev/null +++ b/resources/js/pages/settings/Applications.vue @@ -0,0 +1,198 @@ + + + diff --git a/resources/js/types/models/user-application-model.ts b/resources/js/types/models/user-application-model.ts new file mode 100644 index 0000000..b4d8a96 --- /dev/null +++ b/resources/js/types/models/user-application-model.ts @@ -0,0 +1,8 @@ +export type UserApplicationModel = { + id: number; + user_id: number; + name: string; + last_used_at: string | null; + created_at: string | null; + updated_at: string | null; +}; diff --git a/resources/js/types/page-props.ts b/resources/js/types/page-props.ts index e52c4eb..5a102ac 100644 --- a/resources/js/types/page-props.ts +++ b/resources/js/types/page-props.ts @@ -14,6 +14,7 @@ import type { TicketPriorityModel } from '@/types/models/ticket-priority-model'; import type { TicketStatusModel } from '@/types/models/ticket-status-model'; import type { TicketTreeNodeModel } from '@/types/models/ticket-tree-node-model'; import type { TicketTypeModel } from '@/types/models/ticket-type-model'; +import type { UserApplicationModel } from '@/types/models/user-application-model'; import type { UserGitConnectionModel } from '@/types/models/user-git-connection-model'; import type { UserModel } from '@/types/models/user-model'; @@ -150,3 +151,9 @@ export type SettingsGitConnectionsPageProps = { gitConnections: UserGitConnectionModel[]; status?: string | null; }; + +export type SettingsApplicationsPageProps = { + applicationKey?: string | null; + applications: UserApplicationModel[]; + status?: string | null; +}; diff --git a/routes/api.php b/routes/api.php index 630ab56..7a864a6 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,3 +3,5 @@ require __DIR__.'/api/projects.php'; require __DIR__.'/api/settings.php'; + +require __DIR__.'/api/users.php'; diff --git a/routes/api/users.php b/routes/api/users.php new file mode 100644 index 0000000..52f8708 --- /dev/null +++ b/routes/api/users.php @@ -0,0 +1,11 @@ +name('api.users.') + ->group(function (): void { + Route::get('{user}/assigned-tickets', UserAssignedTicketController::class) + ->name('assigned-tickets'); + }); diff --git a/routes/web/settings.php b/routes/web/settings.php index c03052c..21827fc 100644 --- a/routes/web/settings.php +++ b/routes/web/settings.php @@ -3,6 +3,7 @@ use App\Http\Controllers\Settings\GitConnectionController; use App\Http\Controllers\Settings\ProfileController; use App\Http\Controllers\Settings\SecurityController; +use App\Http\Controllers\Settings\UserApplicationController; use Illuminate\Support\Facades\Route; Route::middleware('auth') @@ -41,5 +42,14 @@ Route::middleware(['auth', 'verified']) Route::delete('{gitConnection}', 'destroy')->name('destroy'); }); + Route::prefix('applications') + ->name('applications.') + ->controller(UserApplicationController::class) + ->group(function (): void { + Route::get('/', 'edit')->name('edit'); + Route::post('/', 'store')->name('store'); + Route::delete('{userApplication}', 'destroy')->name('destroy'); + }); + Route::inertia('appearance', 'settings/Appearance')->name('appearance.edit'); }); diff --git a/tests/Feature/Api/ApiApplicationKeyTest.php b/tests/Feature/Api/ApiApplicationKeyTest.php new file mode 100644 index 0000000..cd85681 --- /dev/null +++ b/tests/Feature/Api/ApiApplicationKeyTest.php @@ -0,0 +1,84 @@ +getJson(route('api.projects.index')) + ->assertUnauthorized() + ->assertJsonPath('message', 'Unauthenticated.'); + } + + public function test_invalid_bearer_token_returns_unauthorized(): void + { + $this->withHeader('Authorization', 'Bearer wrong-key') + ->getJson(route('api.projects.index')) + ->assertUnauthorized() + ->assertJsonPath('message', 'Unauthenticated.'); + } + + public function test_valid_bearer_token_authenticates_as_owner(): void + { + $user = User::factory()->create(); + $project = Project::factory()->create([ + 'name' => 'Visible project', + ]); + + $this->syncProjectPermissions($project, $user, ['view']); + + $this->withApplicationKey($user) + ->getJson(route('api.projects.index')) + ->assertOk() + ->assertJsonPath('data.0.name', 'Visible project') + ->assertJsonCount(1, 'data'); + } + + public function test_application_key_owner_permissions_still_apply(): void + { + $user = User::factory()->create(); + $visibleProject = Project::factory()->create([ + 'name' => 'Visible project', + ]); + Project::factory()->create([ + 'name' => 'Hidden project', + ]); + + $this->syncProjectPermissions($visibleProject, $user, ['view']); + + $this->withApplicationKey($user) + ->getJson(route('api.projects.index')) + ->assertOk() + ->assertJsonPath('data.0.name', 'Visible project') + ->assertJsonCount(1, 'data'); + } + + public function test_application_last_used_at_updates_after_use(): void + { + $user = User::factory()->create(); + $project = Project::factory()->create(); + $plainTextKey = UserApplication::generatePlainTextKey(); + $application = UserApplication::query()->create([ + 'key_hash' => UserApplication::hashKey($plainTextKey), + 'name' => 'Claude Desktop', + 'user_id' => $user->id, + ]); + + $this->syncProjectPermissions($project, $user, ['view']); + + $this->withHeader('Authorization', "Bearer {$plainTextKey}") + ->getJson(route('api.projects.index')) + ->assertOk(); + + $this->assertNotNull($application->fresh()->last_used_at); + } +} diff --git a/tests/Feature/Api/AssignedTicketsApiTest.php b/tests/Feature/Api/AssignedTicketsApiTest.php new file mode 100644 index 0000000..0b7647f --- /dev/null +++ b/tests/Feature/Api/AssignedTicketsApiTest.php @@ -0,0 +1,113 @@ +create(); + $assignee = User::factory()->create([ + 'name' => 'Assigned User', + ]); + $otherAssignee = User::factory()->create(); + + $olderProject = Project::factory()->create([ + 'key' => 'OLD', + 'name' => 'Older project', + ]); + $newerProject = Project::factory()->create([ + 'key' => 'NEW', + 'name' => 'Newer project', + ]); + $hiddenProject = Project::factory()->create([ + 'key' => 'HID', + 'name' => 'Hidden project', + ]); + + $this->syncProjectPermissions($olderProject, $viewer, ['view']); + $this->syncProjectPermissions($newerProject, $viewer, ['view']); + + Ticket::factory()->create([ + 'assignee_user_id' => $assignee->id, + 'project_id' => $olderProject->id, + 'sequence_number' => 1, + 'ticket_key' => 'OLD-1', + 'title' => 'Older visible ticket', + ]); + Ticket::factory()->create([ + 'assignee_user_id' => $assignee->id, + 'project_id' => $newerProject->id, + 'sequence_number' => 2, + 'ticket_key' => 'NEW-2', + 'title' => 'Newer visible ticket', + ]); + Ticket::factory()->create([ + 'assignee_user_id' => $assignee->id, + 'project_id' => $hiddenProject->id, + 'sequence_number' => 1, + 'ticket_key' => 'HID-1', + 'title' => 'Hidden ticket', + ]); + Ticket::factory()->create([ + 'assignee_user_id' => $otherAssignee->id, + 'project_id' => $olderProject->id, + 'sequence_number' => 3, + 'ticket_key' => 'OLD-3', + 'title' => 'Other user ticket', + ]); + + $this->withApplicationKey($viewer) + ->getJson(route('api.users.assigned-tickets', $assignee)) + ->assertOk() + ->assertJsonPath('meta.assignee.name', 'Assigned User') + ->assertJsonPath('data.0.project.name', 'Newer project') + ->assertJsonPath('data.0.tickets.0.ticket_key', 'NEW-2') + ->assertJsonCount(1, 'data.0.tickets') + ->assertJsonPath('data.1.project.name', 'Older project') + ->assertJsonPath('data.1.tickets.0.ticket_key', 'OLD-1') + ->assertJsonCount(1, 'data.1.tickets') + ->assertJsonCount(2, 'data'); + } + + public function test_assignee_metadata_is_hidden_when_no_visible_assigned_tickets_exist(): void + { + $viewer = User::factory()->create(); + $assignee = User::factory()->create([ + 'name' => 'Hidden User', + ]); + + $visibleProject = Project::factory()->create([ + 'key' => 'VIS', + 'name' => 'Visible project', + ]); + $hiddenProject = Project::factory()->create([ + 'key' => 'HID', + 'name' => 'Hidden project', + ]); + + $this->syncProjectPermissions($visibleProject, $viewer, ['view']); + + Ticket::factory()->create([ + 'assignee_user_id' => $assignee->id, + 'project_id' => $hiddenProject->id, + 'sequence_number' => 1, + 'ticket_key' => 'HID-1', + 'title' => 'Hidden ticket', + ]); + + $this->withApplicationKey($viewer) + ->getJson(route('api.users.assigned-tickets', $assignee)) + ->assertOk() + ->assertJsonPath('data', []) + ->assertJsonPath('meta.assignee', null); + } +} diff --git a/tests/Feature/Api/OpenApiDocumentationTest.php b/tests/Feature/Api/OpenApiDocumentationTest.php index e6bd11c..24418f2 100644 --- a/tests/Feature/Api/OpenApiDocumentationTest.php +++ b/tests/Feature/Api/OpenApiDocumentationTest.php @@ -17,7 +17,7 @@ class OpenApiDocumentationTest extends TestCase $this->assertIsArray($document); $this->assertSame(JSON_ERROR_NONE, json_last_error()); $this->assertSame('3.1.0', $document['openapi']); - $this->assertSame([], $document['security']); + $this->assertSame([['ApplicationKey' => []]], $document['security']); $expectedOperations = [ ['get', '/projects'], @@ -67,6 +67,7 @@ class OpenApiDocumentationTest extends TestCase ['post', '/git-connections'], ['patch', '/git-connections/{gitConnection}'], ['delete', '/git-connections/{gitConnection}'], + ['get', '/users/{user}/assigned-tickets'], ]; foreach ($expectedOperations as [$method, $path]) { @@ -74,8 +75,21 @@ class OpenApiDocumentationTest extends TestCase $this->assertArrayHasKey($method, $document['paths'][$path]); } + $this->assertArrayHasKey('ApplicationKey', $document['components']['securitySchemes']); + $this->assertSame('http', $document['components']['securitySchemes']['ApplicationKey']['type']); + $this->assertSame('bearer', $document['components']['securitySchemes']['ApplicationKey']['scheme']); $this->assertArrayHasKey('ValidationError', $document['components']['schemas']); $this->assertArrayHasKey('TicketUpdateRequest', $document['components']['schemas']); $this->assertArrayHasKey('UserGitConnectionRequest', $document['components']['schemas']); + $this->assertArrayHasKey('AssignedTicketsPayloadEnvelope', $document['components']['schemas']); + $this->assertArrayHasKey('AssignedTicketsProjectGroup', $document['components']['schemas']); + + $assigneeSchema = $document['components']['schemas']['AssignedTicketsPayloadEnvelope']['properties']['meta']['properties']['assignee'] ?? null; + + $this->assertIsArray($assigneeSchema); + $this->assertSame([ + ['$ref' => '#/components/schemas/User'], + ['type' => 'null'], + ], $assigneeSchema['anyOf'] ?? null); } } diff --git a/tests/Feature/Api/ProjectApiTest.php b/tests/Feature/Api/ProjectApiTest.php index 9e263e2..c4bbf93 100644 --- a/tests/Feature/Api/ProjectApiTest.php +++ b/tests/Feature/Api/ProjectApiTest.php @@ -24,7 +24,7 @@ class ProjectApiTest extends TestCase $this->syncProjectPermissions($visibleProject, $user, ['view']); - $this->actingAs($user) + $this->withApplicationKey($user) ->getJson(route('api.projects.index')) ->assertOk() ->assertJsonPath('data.0.name', 'Visible project') @@ -36,7 +36,7 @@ class ProjectApiTest extends TestCase $user = User::factory()->create(); $this->syncGlobalPermissions($user, ['create']); - $this->actingAs($user) + $this->withApplicationKey($user) ->postJson(route('api.projects.store'), [ 'key' => 'api', 'name' => 'API Project', @@ -61,20 +61,20 @@ class ProjectApiTest extends TestCase app(InitializeProjectTicketMetadata::class)->handle($project); - $this->actingAs($user) + $this->withApplicationKey($user) ->getJson(route('api.projects.show', $project)) ->assertForbidden(); $this->syncProjectPermissions($project, $user, ['view']); - $this->actingAs($user) + $this->withApplicationKey($user) ->getJson(route('api.projects.show', $project)) ->assertOk() ->assertJsonPath('data.key', 'CORE'); $this->syncGlobalPermissions($admin, ['admin']); - $this->actingAs($admin) + $this->withApplicationKey($admin) ->deleteJson(route('api.projects.destroy', $project), [ 'confirmation_key' => 'CORE', ]) diff --git a/tests/Feature/Api/PublicApiDocumentationTest.php b/tests/Feature/Api/PublicApiDocumentationTest.php index 74c8180..0730d73 100644 --- a/tests/Feature/Api/PublicApiDocumentationTest.php +++ b/tests/Feature/Api/PublicApiDocumentationTest.php @@ -30,7 +30,7 @@ class PublicApiDocumentationTest extends TestCase $document = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); $this->assertSame('3.1.0', $document['openapi']); - $this->assertSame([], $document['security']); + $this->assertSame([['ApplicationKey' => []]], $document['security']); $this->assertArrayHasKey('/projects', $document['paths']); } diff --git a/tests/Feature/Api/SettingsApiTest.php b/tests/Feature/Api/SettingsApiTest.php index 5f88734..8c30924 100644 --- a/tests/Feature/Api/SettingsApiTest.php +++ b/tests/Feature/Api/SettingsApiTest.php @@ -18,12 +18,12 @@ class SettingsApiTest extends TestCase { [$user, $project] = $this->projectContext(); - $this->actingAs($user) + $this->withApplicationKey($user) ->getJson(route('api.projects.settings.board', $project)) ->assertOk() ->assertJsonPath('data.backlogTicketStatuses.0.name', 'Backlog'); - $columnId = $this->actingAs($user) + $columnId = $this->withApplicationKey($user) ->postJson(route('api.projects.settings.board.columns.store', $project), [ 'name' => 'In Review', ]) @@ -31,7 +31,7 @@ class SettingsApiTest extends TestCase ->assertJsonPath('data.name', 'In Review') ->json('data.id'); - $this->actingAs($user) + $this->withApplicationKey($user) ->patchJson(route('api.projects.settings.board.columns.update', [$project, $columnId]), [ 'name' => 'Review', 'position' => 1, @@ -39,7 +39,7 @@ class SettingsApiTest extends TestCase ->assertOk() ->assertJsonPath('data.name', 'Review'); - $statusId = $this->actingAs($user) + $statusId = $this->withApplicationKey($user) ->postJson(route('api.projects.settings.board.statuses.store', $project), [ 'board_column_id' => $columnId, 'name' => 'Ready', @@ -47,7 +47,7 @@ class SettingsApiTest extends TestCase ->assertCreated() ->assertJsonPath('data.name', 'Ready') ->json('data.id'); - $replacementStatusId = $this->actingAs($user) + $replacementStatusId = $this->withApplicationKey($user) ->postJson(route('api.projects.settings.board.statuses.store', $project), [ 'board_column_id' => $columnId, 'name' => 'QA', @@ -57,14 +57,14 @@ class SettingsApiTest extends TestCase $backlogStatus = $project->ticketStatuses()->whereNull('board_column_id')->firstOrFail(); - $this->actingAs($user) + $this->withApplicationKey($user) ->patchJson(route('api.projects.settings.board.statuses.transitions.update', [$project, $backlogStatus]), [ 'allowed_ticket_status_ids' => [$statusId], ]) ->assertOk() ->assertJsonPath('data.allowed_target_statuses.0.id', $statusId); - $this->actingAs($user) + $this->withApplicationKey($user) ->deleteJson(route('api.projects.settings.board.statuses.destroy', [$project, $statusId])) ->assertNoContent(); @@ -72,7 +72,7 @@ class SettingsApiTest extends TestCase 'id' => $replacementStatusId, ]); - $this->actingAs($user) + $this->withApplicationKey($user) ->deleteJson(route('api.projects.settings.board.columns.destroy', [$project, $columnId])) ->assertNoContent(); } @@ -81,12 +81,12 @@ class SettingsApiTest extends TestCase { [$user, $project] = $this->projectContext(); - $this->actingAs($user) + $this->withApplicationKey($user) ->getJson(route('api.projects.settings.metadata', $project)) ->assertOk() ->assertJsonPath('data.ticketTypes.0.name', 'Task'); - $this->actingAs($user) + $this->withApplicationKey($user) ->patchJson(route('api.projects.settings.metadata.update', $project), [ 'show_author' => false, 'show_story_points' => false, @@ -94,7 +94,7 @@ class SettingsApiTest extends TestCase ->assertOk() ->assertJsonPath('data.show_author', false); - $typeId = $this->actingAs($user) + $typeId = $this->withApplicationKey($user) ->postJson(route('api.projects.settings.metadata.types.store', $project), [ 'is_parent' => true, 'name' => 'Epic', @@ -103,7 +103,7 @@ class SettingsApiTest extends TestCase ->assertJsonPath('data.name', 'Epic') ->json('data.id'); - $this->actingAs($user) + $this->withApplicationKey($user) ->patchJson(route('api.projects.settings.metadata.types.update', [$project, $typeId]), [ 'name' => 'Initiative', 'position' => 1, @@ -111,14 +111,14 @@ class SettingsApiTest extends TestCase ->assertOk() ->assertJsonPath('data.name', 'Initiative'); - $priorityId = $this->actingAs($user) + $priorityId = $this->withApplicationKey($user) ->postJson(route('api.projects.settings.metadata.priorities.store', $project), [ 'name' => 'Urgent', ]) ->assertCreated() ->json('data.id'); - $fieldId = $this->actingAs($user) + $fieldId = $this->withApplicationKey($user) ->postJson(route('api.projects.settings.metadata.fields.store', $project), [ 'field_type' => 'number', 'label' => 'Risk', @@ -126,26 +126,26 @@ class SettingsApiTest extends TestCase ->assertCreated() ->json('data.id'); - $linkTypeId = $this->actingAs($user) + $linkTypeId = $this->withApplicationKey($user) ->postJson(route('api.projects.settings.metadata.link-types.store', $project), [ 'name' => 'Relates to', ]) ->assertCreated() ->json('data.id'); - $this->actingAs($user) + $this->withApplicationKey($user) ->deleteJson(route('api.projects.settings.metadata.fields.destroy', [$project, $fieldId])) ->assertNoContent(); - $this->actingAs($user) + $this->withApplicationKey($user) ->deleteJson(route('api.projects.settings.metadata.link-types.destroy', [$project, $linkTypeId])) ->assertNoContent(); - $this->actingAs($user) + $this->withApplicationKey($user) ->deleteJson(route('api.projects.settings.metadata.priorities.destroy', [$project, $priorityId])) ->assertNoContent(); - $this->actingAs($user) + $this->withApplicationKey($user) ->deleteJson(route('api.projects.settings.metadata.types.destroy', [$project, $typeId])) ->assertNoContent(); } @@ -214,7 +214,7 @@ class SettingsApiTest extends TestCase [$user, $project] = $this->projectContext(); - $gitConnectionId = $this->actingAs($user) + $gitConnectionId = $this->withApplicationKey($user) ->postJson(route('api.git-connections.store'), [ 'api_token' => 'secret-token', 'base_url' => 'https://git.example.test/api/v1', @@ -224,12 +224,12 @@ class SettingsApiTest extends TestCase ->assertJsonPath('data.account_login', 'api-user') ->json('data.id'); - $this->actingAs($user) + $this->withApplicationKey($user) ->getJson(route('api.git-connections.index')) ->assertOk() ->assertJsonPath('data.0.id', $gitConnectionId); - $repositoryConnectionId = $this->actingAs($user) + $repositoryConnectionId = $this->withApplicationKey($user) ->postJson(route('api.projects.settings.repositories.store', $project), [ 'provider_repository_id' => '501', 'user_git_connection_id' => $gitConnectionId, @@ -238,17 +238,17 @@ class SettingsApiTest extends TestCase ->assertJsonPath('data.repository_full_name', 'core-team/kanbangit') ->json('data.id'); - $this->actingAs($user) + $this->withApplicationKey($user) ->getJson(route('api.projects.settings.repositories', $project)) ->assertOk() ->assertJsonPath('data.repositoryConnections.0.id', $repositoryConnectionId) ->assertJsonPath('data.availableRepositorySources.0.repositories.0.full_name', 'core-team/kanbangit'); - $this->actingAs($user) + $this->withApplicationKey($user) ->deleteJson(route('api.projects.settings.repositories.destroy', [$project, $repositoryConnectionId])) ->assertNoContent(); - $this->actingAs($user) + $this->withApplicationKey($user) ->deleteJson(route('api.git-connections.destroy', $gitConnectionId)) ->assertNoContent(); } diff --git a/tests/Feature/Api/TicketApiTest.php b/tests/Feature/Api/TicketApiTest.php index e02880a..2847775 100644 --- a/tests/Feature/Api/TicketApiTest.php +++ b/tests/Feature/Api/TicketApiTest.php @@ -28,7 +28,7 @@ class TicketApiTest extends TestCase $status = $project->ticketStatuses()->firstOrFail(); $type = $project->ticketTypes()->where('is_parent', false)->firstOrFail(); - $this->actingAs($user) + $this->withApplicationKey($user) ->getJson(route('api.projects.ticket-options', [ 'project' => $project, 'source' => 'backlog', @@ -37,7 +37,7 @@ class TicketApiTest extends TestCase ->assertJsonPath('data.defaultTicketStatusId', $status->id) ->assertJsonPath('data.ticketTypes.0.id', $type->id); - $createResponse = $this->actingAs($user) + $createResponse = $this->withApplicationKey($user) ->postJson(route('api.projects.tickets.store', $project), [ 'acceptance_criteria' => 'Can sign in', 'description' => 'Build the API-created ticket', @@ -53,12 +53,12 @@ class TicketApiTest extends TestCase $ticket = Ticket::query()->findOrFail($createResponse->json('data.id')); - $this->actingAs($user) + $this->withApplicationKey($user) ->getJson(route('api.projects.tickets.index', $project)) ->assertOk() ->assertJsonPath('data.0.ticket_key', 'CORE-1'); - $this->actingAs($user) + $this->withApplicationKey($user) ->patchJson(route('api.projects.tickets.update', [$project, $ticket]), [ 'acceptance_criteria' => 'Can sign in from the API', 'blocked_comment' => null, @@ -74,7 +74,7 @@ class TicketApiTest extends TestCase ->assertJsonPath('data.title', 'Updated API ticket') ->assertJsonPath('meta.message', 'CORE-1 was updated.'); - $this->actingAs($user) + $this->withApplicationKey($user) ->getJson(route('api.projects.tickets.show', [$project, $ticket])) ->assertOk() ->assertJsonPath('data.ticket.ticket_key', 'CORE-1') @@ -172,13 +172,13 @@ class TicketApiTest extends TestCase $ticket = $this->ticket($project, $backlogStatus); - $this->actingAs($user) + $this->withApplicationKey($user) ->getJson(route('api.projects.tickets.status-targets', [$project, $ticket])) ->assertOk() ->assertJsonPath('data.current_status.id', $backlogStatus->id) ->assertJsonPath('data.target_statuses.0.id', $doingStatus->id); - $this->actingAs($user) + $this->withApplicationKey($user) ->patchJson(route('api.projects.tickets.status.update', [$project, $ticket]), [ 'ticket_status_id' => $doingStatus->id, ]) @@ -228,7 +228,7 @@ class TicketApiTest extends TestCase 'position' => 1, ]); - $this->actingAs($user) + $this->withApplicationKey($user) ->postJson(route('api.projects.tickets.links.store', [$project, $ticket]), [ 'linked_ticket_id' => $linkedTicket->id, 'ticket_link_type_id' => $linkType->id, @@ -252,7 +252,7 @@ class TicketApiTest extends TestCase 'provider' => GitProviderKey::Gitea, ]); - $this->actingAs($user) + $this->withApplicationKey($user) ->getJson(route('api.projects.tickets.branches.validate', [ 'project' => $project, 'ticket' => $ticket, @@ -262,7 +262,7 @@ class TicketApiTest extends TestCase ->assertOk() ->assertJsonPath('data.is_valid', true); - $this->actingAs($user) + $this->withApplicationKey($user) ->postJson(route('api.projects.tickets.branches.store', [$project, $ticket]), [ 'branch_name' => 'core-1-api-ticket', 'repository_connection_id' => $repositoryConnection->id, @@ -290,12 +290,12 @@ class TicketApiTest extends TestCase 'position' => 1, ]); - $this->actingAs($user) + $this->withApplicationKey($user) ->getJson(route('api.projects.backlog', $project)) ->assertOk() ->assertJsonPath('data.tickets.0.id', $ticket->id); - $this->actingAs($user) + $this->withApplicationKey($user) ->getJson(route('api.projects.board', $project)) ->assertOk() ->assertJsonPath('data.boardColumns.0.name', 'Todo'); diff --git a/tests/Feature/Models/UserApplicationTest.php b/tests/Feature/Models/UserApplicationTest.php new file mode 100644 index 0000000..248256e --- /dev/null +++ b/tests/Feature/Models/UserApplicationTest.php @@ -0,0 +1,40 @@ +assertStringStartsWith('kg_app_', $plainTextKey); + $this->assertNotSame($plainTextKey, $keyHash); + $this->assertSame(64, strlen($keyHash)); + $this->assertSame($keyHash, UserApplication::hashKey($plainTextKey)); + + $application = UserApplication::query()->create([ + 'key_hash' => $keyHash, + 'name' => 'Claude Desktop', + 'user_id' => User::factory()->create()->id, + ]); + + $this->assertArrayNotHasKey('key_hash', $application->toArray()); + $this->assertDatabaseHas('user_applications', [ + 'id' => $application->id, + 'key_hash' => $keyHash, + ]); + $this->assertDatabaseMissing('user_applications', [ + 'id' => $application->id, + 'key_hash' => $plainTextKey, + ]); + } +} diff --git a/tests/Feature/Settings/UserApplicationTest.php b/tests/Feature/Settings/UserApplicationTest.php new file mode 100644 index 0000000..d80c6b3 --- /dev/null +++ b/tests/Feature/Settings/UserApplicationTest.php @@ -0,0 +1,103 @@ +create(); + UserApplication::factory()->for($user)->create([ + 'name' => 'Claude Desktop', + ]); + + $this->actingAs($user) + ->get(route('applications.edit')) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('settings/Applications') + ->where('applications.0.name', 'Claude Desktop') + ->where('applicationKey', null), + ); + } + + public function test_users_can_create_applications_and_see_plaintext_key_once(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user) + ->post(route('applications.store'), [ + 'name' => 'Claude Desktop', + ]) + ->assertRedirect(route('applications.edit')) + ->assertSessionHas('applicationKey') + ->assertSessionHas('status', 'Claude Desktop was created. Save this key now; it will not be shown again.'); + + $plainTextKey = (string) $response->baseResponse->getSession()->get('applicationKey'); + + $this->assertStringStartsWith('kg_app_', $plainTextKey); + $this->assertDatabaseHas('user_applications', [ + 'name' => 'Claude Desktop', + 'user_id' => $user->id, + 'key_hash' => UserApplication::hashKey($plainTextKey), + ]); + $this->assertDatabaseMissing('user_applications', [ + 'name' => 'Claude Desktop', + 'key_hash' => $plainTextKey, + ]); + + $this->actingAs($user) + ->get(route('applications.edit')) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->where('applicationKey', $plainTextKey), + ); + + $this->actingAs($user) + ->get(route('applications.edit')) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->where('applicationKey', null), + ); + } + + public function test_users_can_delete_their_own_applications(): void + { + $user = User::factory()->create(); + $application = UserApplication::factory()->for($user)->create([ + 'name' => 'Claude Desktop', + ]); + + $this->actingAs($user) + ->delete(route('applications.destroy', $application)) + ->assertRedirect(route('applications.edit')) + ->assertSessionHas('status', 'Claude Desktop was removed.'); + + $this->assertDatabaseMissing('user_applications', [ + 'id' => $application->id, + ]); + } + + public function test_users_cannot_delete_foreign_applications(): void + { + $owner = User::factory()->create(); + $otherUser = User::factory()->create(); + $application = UserApplication::factory()->for($owner)->create(); + + $this->actingAs($otherUser) + ->delete(route('applications.destroy', $application)) + ->assertForbidden(); + + $this->assertDatabaseHas('user_applications', [ + 'id' => $application->id, + ]); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 8a7bbf6..702aa58 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -7,6 +7,7 @@ use App\Enums\Permission; use App\Models\Project; use App\Models\Team; use App\Models\User; +use App\Models\UserApplication; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; use Laravel\Fortify\Features; @@ -55,6 +56,26 @@ abstract class TestCase extends BaseTestCase ); } + protected function withApplicationKey(User $user, ?string $name = null): static + { + $plainTextKey = $this->createPlainTextApplicationKey($user, $name); + + return $this->withHeader('Authorization', "Bearer {$plainTextKey}"); + } + + protected function createPlainTextApplicationKey(User $user, ?string $name = null): string + { + $plainTextKey = UserApplication::generatePlainTextKey(); + + UserApplication::query()->create([ + 'key_hash' => UserApplication::hashKey($plainTextKey), + 'name' => $name ?? 'Test application', + 'user_id' => $user->id, + ]); + + return $plainTextKey; + } + /** * @param array $permissions * @return array