From 740a20899cc03f2db851249e2f9ede0ee1ca6737 Mon Sep 17 00:00:00 2001 From: Koda Date: Mon, 20 Apr 2026 23:54:37 +0000 Subject: [PATCH 01/12] Scope project user payloads to visible users --- .../Projects/BuildProjectBacklogPayload.php | 15 ++++++++ .../BuildProjectTicketDetailPayload.php | 15 ++++++++ tests/Feature/Api/SettingsApiTest.php | 38 +++++++++++++++++++ 3 files changed, 68 insertions(+) diff --git a/app/Actions/Projects/BuildProjectBacklogPayload.php b/app/Actions/Projects/BuildProjectBacklogPayload.php index eb71b01..e7b8622 100644 --- a/app/Actions/Projects/BuildProjectBacklogPayload.php +++ b/app/Actions/Projects/BuildProjectBacklogPayload.php @@ -59,9 +59,24 @@ class BuildProjectBacklogPayload )->resolve(), 'users' => UserResource::collection( User::query() + ->whereIn('id', $this->visibleUserIds($project)) ->orderBy('name') ->get(), )->resolve(), ]; } + + /** + * @return array + */ + private function visibleUserIds(Project $project): array + { + return User::query() + ->get() + ->filter(fn (User $candidate): bool => $this->permissionService->allows($candidate, Permission::ProjectRead, $project)) + ->pluck('id') + ->map(fn (mixed $userId): int => (int) $userId) + ->values() + ->all(); + } } diff --git a/app/Actions/Projects/BuildProjectTicketDetailPayload.php b/app/Actions/Projects/BuildProjectTicketDetailPayload.php index 26b4511..56a014b 100644 --- a/app/Actions/Projects/BuildProjectTicketDetailPayload.php +++ b/app/Actions/Projects/BuildProjectTicketDetailPayload.php @@ -116,9 +116,24 @@ class BuildProjectTicketDetailPayload 'ticketTypes' => TicketTypeResource::collection($project->ticketTypes()->get())->resolve(), 'users' => UserResource::collection( User::query() + ->whereIn('id', $this->visibleUserIds($project)) ->orderBy('name') ->get(), )->resolve(), ]; } + + /** + * @return array + */ + private function visibleUserIds(Project $project): array + { + return User::query() + ->get() + ->filter(fn (User $candidate): bool => $this->permissionService->allows($candidate, Permission::ProjectRead, $project)) + ->pluck('id') + ->map(fn (mixed $userId): int => (int) $userId) + ->values() + ->all(); + } } diff --git a/tests/Feature/Api/SettingsApiTest.php b/tests/Feature/Api/SettingsApiTest.php index b5ec766..686338d 100644 --- a/tests/Feature/Api/SettingsApiTest.php +++ b/tests/Feature/Api/SettingsApiTest.php @@ -4,6 +4,7 @@ namespace Tests\Feature\Api; use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata; use App\Models\Project; +use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Http; @@ -149,6 +150,43 @@ class SettingsApiTest extends TestCase ->assertNoContent(); } + public function test_project_payloads_only_return_users_who_can_view_the_project(): void + { + [$user, $project] = $this->projectContext(); + $viewer = User::factory()->create(['name' => 'Visible User']); + User::factory()->create(['name' => 'Hidden User']); + $teamViewer = User::factory()->create(['name' => 'Team Viewer']); + $team = Team::factory()->create(); + + $this->syncProjectPermissions($project, $viewer, ['view']); + $team->users()->attach($teamViewer); + $this->syncProjectPermissions($project, $team, ['view']); + + $ticket = $project->tickets()->firstOrCreate([ + 'sequence_number' => 1, + ], [ + 'ticket_key' => 'CORE-1', + 'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id, + 'ticket_status_id' => $project->ticketStatuses()->firstOrFail()->id, + 'ticket_type_id' => $project->ticketTypes()->where('is_parent', false)->firstOrFail()->id, + 'title' => 'Scoped users ticket', + ]); + + $this->actingAs($user) + ->getJson(route('api.projects.backlog', $project)) + ->assertOk() + ->assertJsonPath('data.users.0.name', 'Team Viewer') + ->assertJsonPath('data.users.1.name', 'Visible User') + ->assertJsonCount(2, 'data.users'); + + $this->actingAs($user) + ->getJson(route('api.projects.tickets.show', [$project, $ticket])) + ->assertOk() + ->assertJsonPath('data.users.0.name', 'Team Viewer') + ->assertJsonPath('data.users.1.name', 'Visible User') + ->assertJsonCount(2, 'data.users'); + } + public function test_project_repositories_and_user_git_connections_are_available(): void { Http::preventStrayRequests(); From f412e4ec19b23bcb053c52e77f44fe87e9ada440 Mon Sep 17 00:00:00 2001 From: Koda Date: Tue, 21 Apr 2026 00:08:12 +0000 Subject: [PATCH 02/12] Refactor visible project users into query scope --- .../Projects/BuildProjectBacklogPayload.php | 16 +--------------- .../Projects/BuildProjectTicketDetailPayload.php | 16 +--------------- app/Models/User.php | 15 +++++++++++++++ 3 files changed, 17 insertions(+), 30 deletions(-) diff --git a/app/Actions/Projects/BuildProjectBacklogPayload.php b/app/Actions/Projects/BuildProjectBacklogPayload.php index e7b8622..bff6315 100644 --- a/app/Actions/Projects/BuildProjectBacklogPayload.php +++ b/app/Actions/Projects/BuildProjectBacklogPayload.php @@ -59,24 +59,10 @@ class BuildProjectBacklogPayload )->resolve(), 'users' => UserResource::collection( User::query() - ->whereIn('id', $this->visibleUserIds($project)) + ->visibleToProject($project) ->orderBy('name') ->get(), )->resolve(), ]; } - - /** - * @return array - */ - private function visibleUserIds(Project $project): array - { - return User::query() - ->get() - ->filter(fn (User $candidate): bool => $this->permissionService->allows($candidate, Permission::ProjectRead, $project)) - ->pluck('id') - ->map(fn (mixed $userId): int => (int) $userId) - ->values() - ->all(); - } } diff --git a/app/Actions/Projects/BuildProjectTicketDetailPayload.php b/app/Actions/Projects/BuildProjectTicketDetailPayload.php index 56a014b..25257cb 100644 --- a/app/Actions/Projects/BuildProjectTicketDetailPayload.php +++ b/app/Actions/Projects/BuildProjectTicketDetailPayload.php @@ -116,24 +116,10 @@ class BuildProjectTicketDetailPayload 'ticketTypes' => TicketTypeResource::collection($project->ticketTypes()->get())->resolve(), 'users' => UserResource::collection( User::query() - ->whereIn('id', $this->visibleUserIds($project)) + ->visibleToProject($project) ->orderBy('name') ->get(), )->resolve(), ]; } - - /** - * @return array - */ - private function visibleUserIds(Project $project): array - { - return User::query() - ->get() - ->filter(fn (User $candidate): bool => $this->permissionService->allows($candidate, Permission::ProjectRead, $project)) - ->pluck('id') - ->map(fn (mixed $userId): int => (int) $userId) - ->values() - ->all(); - } } diff --git a/app/Models/User.php b/app/Models/User.php index ef51f81..b84a873 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -3,12 +3,14 @@ namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; +use App\Actions\Permissions\PermissionService; use Database\Factories\UserFactory; 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\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; @@ -69,4 +71,17 @@ class User extends Authenticatable { return $this->hasMany(Ticket::class, 'author_user_id'); } + + public function scopeVisibleToProject(Builder $query, Project $project): Builder + { + $permissionService = app(PermissionService::class); + + return $query->where(function (Builder $builder) use ($permissionService, $project): void { + foreach (User::query()->select('id')->cursor() as $candidate) { + if ($permissionService->allows($candidate, \App\Enums\Permission::ProjectRead, $project)) { + $builder->orWhereKey($candidate->id); + } + } + }); + } } From c9a3a7f58d2b5651c4bc55eaa75e3ff6332aa5c1 Mon Sep 17 00:00:00 2001 From: Twyco Date: Wed, 22 Apr 2026 10:39:59 +0200 Subject: [PATCH 03/12] fixed scope --- app/Models/User.php | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/app/Models/User.php b/app/Models/User.php index b84a873..22fdb24 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,13 +4,14 @@ namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; use App\Actions\Permissions\PermissionService; +use App\Enums\Permission; use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; @@ -76,12 +77,17 @@ class User extends Authenticatable { $permissionService = app(PermissionService::class); - return $query->where(function (Builder $builder) use ($permissionService, $project): void { - foreach (User::query()->select('id')->cursor() as $candidate) { - if ($permissionService->allows($candidate, \App\Enums\Permission::ProjectRead, $project)) { - $builder->orWhereKey($candidate->id); - } - } - }); + $allowedUserIds = User::query() + ->select('id') + ->cursor() + ->filter(fn (User $candidate) => $permissionService->allows( + $candidate, + Permission::ProjectRead, + $project + )) + ->pluck('id') + ->all(); + + return $query->whereKey($allowedUserIds); } } From 3f4c8f49d9147244c6e1659b4680f4007ccd6cad Mon Sep 17 00:00:00 2001 From: Twyco Date: Wed, 22 Apr 2026 10:57:31 +0200 Subject: [PATCH 04/12] fixed test scope --- tests/Feature/Api/SettingsApiTest.php | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/Feature/Api/SettingsApiTest.php b/tests/Feature/Api/SettingsApiTest.php index 686338d..5f88734 100644 --- a/tests/Feature/Api/SettingsApiTest.php +++ b/tests/Feature/Api/SettingsApiTest.php @@ -175,16 +175,18 @@ class SettingsApiTest extends TestCase $this->actingAs($user) ->getJson(route('api.projects.backlog', $project)) ->assertOk() - ->assertJsonPath('data.users.0.name', 'Team Viewer') - ->assertJsonPath('data.users.1.name', 'Visible User') - ->assertJsonCount(2, 'data.users'); + ->assertJsonPath('data.users.0.name', $user->name) + ->assertJsonPath('data.users.1.name', 'Team Viewer') + ->assertJsonPath('data.users.2.name', 'Visible User') + ->assertJsonCount(3, 'data.users'); $this->actingAs($user) ->getJson(route('api.projects.tickets.show', [$project, $ticket])) ->assertOk() - ->assertJsonPath('data.users.0.name', 'Team Viewer') - ->assertJsonPath('data.users.1.name', 'Visible User') - ->assertJsonCount(2, 'data.users'); + ->assertJsonPath('data.users.0.name', $user->name) + ->assertJsonPath('data.users.1.name', 'Team Viewer') + ->assertJsonPath('data.users.2.name', 'Visible User') + ->assertJsonCount(3, 'data.users'); } public function test_project_repositories_and_user_git_connections_are_available(): void From c79c7bf22e413792bc9907b090d8007bbad9ecd3 Mon Sep 17 00:00:00 2001 From: Koda Date: Thu, 23 Apr 2026 19:46:42 +0000 Subject: [PATCH 05/12] Validate status selector for list tickets tool --- app/Mcp/Tools/ListTicketsByStatus.php | 4 +++- tests/Feature/Mcp/KanbanGitMcpTest.php | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/app/Mcp/Tools/ListTicketsByStatus.php b/app/Mcp/Tools/ListTicketsByStatus.php index 581ca6c..312370e 100644 --- a/app/Mcp/Tools/ListTicketsByStatus.php +++ b/app/Mcp/Tools/ListTicketsByStatus.php @@ -31,7 +31,9 @@ class ListTicketsByStatus extends Tool 'project_id' => ['nullable', 'integer'], 'status_name' => ['nullable', 'string'], 'status_slug' => ['nullable', 'string'], - 'ticket_status_id' => ['nullable', 'integer'], + 'ticket_status_id' => ['nullable', 'integer', 'required_without_all:status_slug,status_name'], + ], [ + 'ticket_status_id.required_without_all' => 'Provide ticket_status_id, status_slug, or status_name.', ]); $user = $this->kanbanGitMcp->user($request); diff --git a/tests/Feature/Mcp/KanbanGitMcpTest.php b/tests/Feature/Mcp/KanbanGitMcpTest.php index 75517d4..bcc56f7 100644 --- a/tests/Feature/Mcp/KanbanGitMcpTest.php +++ b/tests/Feature/Mcp/KanbanGitMcpTest.php @@ -319,6 +319,15 @@ class KanbanGitMcpTest extends TestCase ->etc()); } + public function test_list_tickets_by_status_requires_a_status_selector(): void + { + $user = User::factory()->create(); + + KanbanGitServer::actingAs($user) + ->tool(ListTicketsByStatus::class) + ->assertHasErrors(['Provide ticket_status_id, status_slug, or status_name.']); + } + public function test_ambiguous_status_name_returns_mcp_error(): void { $user = User::factory()->create(); From 3d3b03ee8294da2945a46d59609e26c8dc278093 Mon Sep 17 00:00:00 2001 From: Koda Date: Thu, 23 Apr 2026 20:56:39 +0000 Subject: [PATCH 06/12] Fix PR52 API author visibility and ticket-option user scoping --- .../BuildProjectTicketOptionsPayload.php | 1 + .../Api/ProjectTicketController.php | 21 +++++- tests/Feature/Api/TicketApiTest.php | 71 +++++++++++++++++++ 3 files changed, 91 insertions(+), 2 deletions(-) diff --git a/app/Actions/Projects/BuildProjectTicketOptionsPayload.php b/app/Actions/Projects/BuildProjectTicketOptionsPayload.php index a22aea1..7c6a307 100644 --- a/app/Actions/Projects/BuildProjectTicketOptionsPayload.php +++ b/app/Actions/Projects/BuildProjectTicketOptionsPayload.php @@ -38,6 +38,7 @@ class BuildProjectTicketOptionsPayload 'ticketTypes' => TicketTypeResource::collection($project->ticketTypes()->get())->resolve(), 'users' => UserResource::collection( User::query() + ->visibleToProject($project) ->orderBy('name') ->get(), )->resolve(), diff --git a/app/Http/Controllers/Api/ProjectTicketController.php b/app/Http/Controllers/Api/ProjectTicketController.php index 02409e5..337fda9 100644 --- a/app/Http/Controllers/Api/ProjectTicketController.php +++ b/app/Http/Controllers/Api/ProjectTicketController.php @@ -123,7 +123,8 @@ class ProjectTicketController extends ApiController ? "{$ticket->ticket_key} was updated." : 'No ticket changes were detected.'; - return TicketResource::make($ticket->fresh()->load([ + $canManageProject = $this->permissionService->allows($user, Permission::ProjectAdmin, $project); + $displayTicket = $ticket->fresh([ 'assignee', 'author', 'fieldValues.ticketFieldDefinition', @@ -132,7 +133,23 @@ class ProjectTicketController extends ApiController 'ticketStatus.boardColumn', 'ticketType', 'worklogEntries.actor', - ])) + ]) ?? $ticket->load([ + 'assignee', + 'author', + 'fieldValues.ticketFieldDefinition', + 'fieldValues.userValue', + 'ticketPriority', + 'ticketStatus.boardColumn', + 'ticketType', + 'worklogEntries.actor', + ]); + + if (! $project->show_author && ! $canManageProject) { + $displayTicket->setRelation('author', null); + $displayTicket->author_user_id = null; + } + + return TicketResource::make($displayTicket) ->additional([ 'meta' => [ 'message' => $statusMessage, diff --git a/tests/Feature/Api/TicketApiTest.php b/tests/Feature/Api/TicketApiTest.php index 3a4f044..e02880a 100644 --- a/tests/Feature/Api/TicketApiTest.php +++ b/tests/Feature/Api/TicketApiTest.php @@ -81,6 +81,77 @@ class TicketApiTest extends TestCase ->assertJsonPath('data.ticket.story_points', 5); } + public function test_ticket_update_hides_author_for_non_admins_when_author_visibility_is_disabled(): void + { + $writer = User::factory()->create(); + $author = User::factory()->create([ + 'name' => 'Author User', + ]); + $project = Project::factory()->create([ + 'key' => 'CORE', + 'show_author' => false, + ]); + + app(InitializeProjectTicketMetadata::class)->handle($project); + + $priority = $project->ticketPriorities()->firstOrFail(); + $status = $project->ticketStatuses()->firstOrFail(); + $type = $project->ticketTypes()->where('is_parent', false)->firstOrFail(); + + $ticket = Ticket::factory()->create([ + 'author_user_id' => $author->id, + 'project_id' => $project->id, + 'sequence_number' => 1, + 'ticket_key' => 'CORE-1', + 'ticket_priority_id' => $priority->id, + 'ticket_status_id' => $status->id, + 'ticket_type_id' => $type->id, + 'title' => 'API ticket', + ]); + + $this->syncProjectPermissions($project, $writer, ['write', 'view']); + + $this->actingAs($writer) + ->patchJson(route('api.projects.tickets.update', [$project, $ticket]), [ + 'acceptance_criteria' => $ticket->acceptance_criteria, + 'blocked_comment' => $ticket->blocked_comment, + 'description' => $ticket->description, + 'is_blocked' => $ticket->is_blocked, + 'parent_ticket_id' => $ticket->parent_ticket_id, + 'story_points' => $ticket->story_points, + 'ticket_priority_id' => $priority->id, + 'ticket_type_id' => $type->id, + 'title' => 'Updated API ticket', + ]) + ->assertOk() + ->assertJsonPath('data.author', null) + ->assertJsonPath('data.author_user_id', null); + } + + public function test_ticket_options_only_return_project_visible_users(): void + { + [$user, $project] = $this->projectContext(); + $projectViewer = User::factory()->create(); + $outsider = User::factory()->create(); + + $this->syncProjectPermissions($project, $projectViewer, ['view']); + + $response = $this->actingAs($user) + ->getJson(route('api.projects.ticket-options', [ + 'project' => $project, + 'source' => 'backlog', + ])) + ->assertOk(); + + $userIds = collect($response->json('data.users')) + ->pluck('id') + ->all(); + + $this->assertContains($user->id, $userIds); + $this->assertContains($projectViewer->id, $userIds); + $this->assertNotContains($outsider->id, $userIds); + } + public function test_tickets_can_return_status_targets_and_move_statuses(): void { [$user, $project] = $this->projectContext(); From cebc945fbe07b1171c966183e26008e9bc5b72f3 Mon Sep 17 00:00:00 2001 From: Koda Date: Fri, 24 Apr 2026 09:06:45 +0000 Subject: [PATCH 07/12] Stabilize Scalar docs response assertions --- tests/Feature/Api/PublicApiDocumentationTest.php | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/Feature/Api/PublicApiDocumentationTest.php b/tests/Feature/Api/PublicApiDocumentationTest.php index d06a961..361e632 100644 --- a/tests/Feature/Api/PublicApiDocumentationTest.php +++ b/tests/Feature/Api/PublicApiDocumentationTest.php @@ -8,10 +8,13 @@ class PublicApiDocumentationTest extends TestCase { public function test_scalar_api_docs_are_public(): void { - $this->get('/api/docs') - ->assertOk() - ->assertSee('"hideClientButton":true', false) - ->assertSee('"mcp":{"disabled":true}', false); + $response = $this->get('/api/docs') + ->assertOk(); + + $content = (string) $response->getContent(); + + $this->assertMatchesRegularExpression('/"hideClientButton"\s*:\s*true/', $content); + $this->assertMatchesRegularExpression('/"mcp"\s*:\s*\{\s*"disabled"\s*:\s*true\s*\}/', $content); } public function test_openapi_document_is_publicly_served(): void From 9f87c2eca07256f35d878513d08ed51827ffdc22 Mon Sep 17 00:00:00 2001 From: Koda Date: Fri, 24 Apr 2026 09:07:34 +0000 Subject: [PATCH 08/12] Loosen MCP docs assertion to avoid brittle matching --- tests/Feature/Api/PublicApiDocumentationTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Feature/Api/PublicApiDocumentationTest.php b/tests/Feature/Api/PublicApiDocumentationTest.php index 361e632..74c8180 100644 --- a/tests/Feature/Api/PublicApiDocumentationTest.php +++ b/tests/Feature/Api/PublicApiDocumentationTest.php @@ -14,7 +14,7 @@ class PublicApiDocumentationTest extends TestCase $content = (string) $response->getContent(); $this->assertMatchesRegularExpression('/"hideClientButton"\s*:\s*true/', $content); - $this->assertMatchesRegularExpression('/"mcp"\s*:\s*\{\s*"disabled"\s*:\s*true\s*\}/', $content); + $this->assertMatchesRegularExpression('/"mcp"\s*:\s*\{[^}]*"disabled"\s*:\s*true/', $content); } public function test_openapi_document_is_publicly_served(): void From ae00f1fc441d305a0cf0096557c34143afe34e4a Mon Sep 17 00:00:00 2001 From: Koda Date: Fri, 24 Apr 2026 09:37:53 +0000 Subject: [PATCH 09/12] Hide assignee metadata when no visible assigned tickets exist --- .../Api/UserAssignedTicketController.php | 8 +++-- docs/openapi/kanbangit-api.openapi.json | 11 +++++-- tests/Feature/Api/AssignedTicketsApiTest.php | 33 +++++++++++++++++++ .../Feature/Api/OpenApiDocumentationTest.php | 8 +++++ 4 files changed, 56 insertions(+), 4 deletions(-) diff --git a/app/Http/Controllers/Api/UserAssignedTicketController.php b/app/Http/Controllers/Api/UserAssignedTicketController.php index c750955..cbd01cb 100644 --- a/app/Http/Controllers/Api/UserAssignedTicketController.php +++ b/app/Http/Controllers/Api/UserAssignedTicketController.php @@ -18,10 +18,14 @@ class UserAssignedTicketController extends ApiController { $viewer = $this->authenticatedUser($request); + $assignedTickets = $this->listAssignedTicketsByVisibleProject->handle($viewer, $user); + return response()->json([ - 'data' => $this->listAssignedTicketsByVisibleProject->handle($viewer, $user), + 'data' => $assignedTickets, 'meta' => [ - 'assignee' => UserResource::make($user)->resolve(), + 'assignee' => $assignedTickets === [] + ? null + : UserResource::make($user)->resolve(), ], ]); } diff --git a/docs/openapi/kanbangit-api.openapi.json b/docs/openapi/kanbangit-api.openapi.json index 9a1a607..043b565 100644 --- a/docs/openapi/kanbangit-api.openapi.json +++ b/docs/openapi/kanbangit-api.openapi.json @@ -1643,7 +1643,7 @@ } }, "AssignedTicketsPayload": { - "description": "Tickets assigned to a user grouped by projects visible to the authenticated application key owner.", + "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": { @@ -1844,7 +1844,14 @@ "type": "object", "properties": { "assignee": { - "$ref": "#/components/schemas/User" + "anyOf": [ + { + "$ref": "#/components/schemas/User" + }, + { + "type": "null" + } + ] } }, "required": ["assignee"] diff --git a/tests/Feature/Api/AssignedTicketsApiTest.php b/tests/Feature/Api/AssignedTicketsApiTest.php index 196f39e..0b7647f 100644 --- a/tests/Feature/Api/AssignedTicketsApiTest.php +++ b/tests/Feature/Api/AssignedTicketsApiTest.php @@ -77,4 +77,37 @@ class AssignedTicketsApiTest extends TestCase ->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 db2ae00..24418f2 100644 --- a/tests/Feature/Api/OpenApiDocumentationTest.php +++ b/tests/Feature/Api/OpenApiDocumentationTest.php @@ -83,5 +83,13 @@ class OpenApiDocumentationTest extends TestCase $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); } } From 6f573c7984958ac3a7a075494a260ae535141aa1 Mon Sep 17 00:00:00 2001 From: Twyco Date: Fri, 24 Apr 2026 15:17:35 +0200 Subject: [PATCH 10/12] fixed tests --- tests/Feature/Api/SettingsApiTest.php | 4 ++-- tests/Feature/Api/TicketApiTest.php | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Feature/Api/SettingsApiTest.php b/tests/Feature/Api/SettingsApiTest.php index 8c30924..9747a66 100644 --- a/tests/Feature/Api/SettingsApiTest.php +++ b/tests/Feature/Api/SettingsApiTest.php @@ -172,7 +172,7 @@ class SettingsApiTest extends TestCase 'title' => 'Scoped users ticket', ]); - $this->actingAs($user) + $this->withApplicationKey($user) ->getJson(route('api.projects.backlog', $project)) ->assertOk() ->assertJsonPath('data.users.0.name', $user->name) @@ -180,7 +180,7 @@ class SettingsApiTest extends TestCase ->assertJsonPath('data.users.2.name', 'Visible User') ->assertJsonCount(3, 'data.users'); - $this->actingAs($user) + $this->withApplicationKey($user) ->getJson(route('api.projects.tickets.show', [$project, $ticket])) ->assertOk() ->assertJsonPath('data.users.0.name', $user->name) diff --git a/tests/Feature/Api/TicketApiTest.php b/tests/Feature/Api/TicketApiTest.php index 2847775..224eadd 100644 --- a/tests/Feature/Api/TicketApiTest.php +++ b/tests/Feature/Api/TicketApiTest.php @@ -111,7 +111,7 @@ class TicketApiTest extends TestCase $this->syncProjectPermissions($project, $writer, ['write', 'view']); - $this->actingAs($writer) + $this->withApplicationKey($writer) ->patchJson(route('api.projects.tickets.update', [$project, $ticket]), [ 'acceptance_criteria' => $ticket->acceptance_criteria, 'blocked_comment' => $ticket->blocked_comment, @@ -136,7 +136,7 @@ class TicketApiTest extends TestCase $this->syncProjectPermissions($project, $projectViewer, ['view']); - $response = $this->actingAs($user) + $response = $this->withApplicationKey($user) ->getJson(route('api.projects.ticket-options', [ 'project' => $project, 'source' => 'backlog', From b343494b3a5c52f3ac8ff066541c1f1f3ac1335a Mon Sep 17 00:00:00 2001 From: Koda Date: Fri, 24 Apr 2026 14:39:29 +0000 Subject: [PATCH 11/12] fix: prefill notification message template textarea --- resources/js/pages/projects/settings/Notifications.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/js/pages/projects/settings/Notifications.vue b/resources/js/pages/projects/settings/Notifications.vue index 978e9d2..45fba9c 100644 --- a/resources/js/pages/projects/settings/Notifications.vue +++ b/resources/js/pages/projects/settings/Notifications.vue @@ -543,7 +543,7 @@ const notificationActionTimestamp = ( " name="message_template" rows="4" - :default-value=" + :value=" notificationAction.message_template ?? '' " From 7f5f8110669e71ab32c8ef52c0556594428d7049 Mon Sep 17 00:00:00 2001 From: Koda Date: Fri, 24 Apr 2026 16:42:11 +0000 Subject: [PATCH 12/12] Prevent Discord mention injection in delivery payload --- app/Actions/Notifications/DeliverNotificationAction.php | 3 +++ tests/Feature/Notifications/DeliverNotificationActionTest.php | 3 +++ 2 files changed, 6 insertions(+) diff --git a/app/Actions/Notifications/DeliverNotificationAction.php b/app/Actions/Notifications/DeliverNotificationAction.php index 25783f4..58f642c 100644 --- a/app/Actions/Notifications/DeliverNotificationAction.php +++ b/app/Actions/Notifications/DeliverNotificationAction.php @@ -272,6 +272,9 @@ class DeliverNotificationAction implements ShouldQueue ->timeout(10) ->post($notificationAction->delivery_url, [ 'content' => $message, + 'allowed_mentions' => [ + 'parse' => [], + ], ]); } diff --git a/tests/Feature/Notifications/DeliverNotificationActionTest.php b/tests/Feature/Notifications/DeliverNotificationActionTest.php index 84fe9a0..87a0185 100644 --- a/tests/Feature/Notifications/DeliverNotificationActionTest.php +++ b/tests/Feature/Notifications/DeliverNotificationActionTest.php @@ -89,6 +89,9 @@ class DeliverNotificationActionTest extends TestCase && $request->hasHeader('Content-Type', 'application/json') && $request->data() === [ 'content' => 'CORE-42 moved from Backlog to Doing by Erica Example: https://kanbangit.test/projects/core/tickets/42 ($unknown)', + 'allowed_mentions' => [ + 'parse' => [], + ], ]; });