Merge pull request 'Add application API keys' (#55) from codex/application-api-keys into main
Reviewed-on: OpenSource/KanbanGit#55
This commit was merged in pull request #55.
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\User;
|
||||
use App\Models\UserApplication;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ApiApplicationKeyTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_missing_bearer_token_returns_unauthorized(): void
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AssignedTicketsApiTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_assigned_tickets_are_grouped_by_visible_project(): void
|
||||
{
|
||||
$viewer = User::factory()->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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
])
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Models;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\UserApplication;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class UserApplicationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_application_keys_are_hashed_and_hidden(): void
|
||||
{
|
||||
$plainTextKey = UserApplication::generatePlainTextKey();
|
||||
$keyHash = UserApplication::hashKey($plainTextKey);
|
||||
|
||||
$this->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Settings;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\UserApplication;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Tests\TestCase;
|
||||
|
||||
class UserApplicationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_applications_page_is_displayed(): void
|
||||
{
|
||||
$user = User::factory()->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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<int, Permission|string> $permissions
|
||||
* @return array<int, string>
|
||||
|
||||
Reference in New Issue
Block a user