Compare commits
1
Commits
v0.3.3
..
78e321f665
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78e321f665 |
@@ -1,18 +1,78 @@
|
||||
name: test_and_check_app
|
||||
description: Build the test image and run all application checks
|
||||
name: "Lint and Test APP"
|
||||
description: "Run Pint and the Pest test suite"
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Test and check app
|
||||
shell: bash
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 #v2.37.2
|
||||
with:
|
||||
php-version: "8.5"
|
||||
extensions: mbstring, pdo_sqlite
|
||||
coverage: none
|
||||
tools: composer:v2
|
||||
|
||||
docker buildx build \
|
||||
--file Dockerfile \
|
||||
--target test \
|
||||
--tag "local/kanbangit-test:${GITHUB_SHA}" \
|
||||
--progress plain \
|
||||
--load \
|
||||
.
|
||||
- name: Verify Composer lockfile
|
||||
run: |
|
||||
if [ ! -f composer.lock ]; then
|
||||
echo "::error::composer.lock not found"
|
||||
exit 1
|
||||
fi
|
||||
shell: bash
|
||||
|
||||
- name: Get Composer cache directory
|
||||
id: composer_cache
|
||||
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
|
||||
shell: bash
|
||||
|
||||
- name: Cache Composer packages
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: ${{ steps.composer_cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-php-8.5-${{ hashFiles('composer.lock') }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v11.7.0
|
||||
with:
|
||||
package-json-file: package.json
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "pnpm-lock.yaml"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
shell: bash
|
||||
|
||||
- name: Prepare environment file
|
||||
run: cp .env.example .env
|
||||
shell: bash
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --no-interaction --prefer-dist --no-progress
|
||||
shell: bash
|
||||
|
||||
- name: Generate application key
|
||||
run: php artisan key:generate
|
||||
shell: bash
|
||||
|
||||
- name: Generate Wayfinder routes and actions
|
||||
run: php artisan wayfinder:generate --with-form
|
||||
shell: bash
|
||||
|
||||
- name: Run independent application checks
|
||||
run: |
|
||||
pnpm exec concurrently \
|
||||
--names frontend,backend \
|
||||
--prefix-colors cyan,magenta \
|
||||
--kill-others-on-fail \
|
||||
"composer run ci:frontend" \
|
||||
"composer run ci:backend"
|
||||
shell: bash
|
||||
|
||||
- name: Run Laravel tests
|
||||
run: composer run ci:test
|
||||
shell: bash
|
||||
|
||||
@@ -9,6 +9,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- fix/dependency-cves
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -17,21 +17,18 @@ class StoreProjectTicketLink
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{linked_ticket_id: int, ticket_link_type_id: int, link_direction?: 'source'|'counterpart'} $validated
|
||||
* @param array{linked_ticket_id: int, ticket_link_type_id: int} $validated
|
||||
*/
|
||||
public function handle(Project $project, Ticket $ticket, User $user, array $validated): TicketLink
|
||||
{
|
||||
$linkedTicketId = Arr::get($validated, 'linked_ticket_id');
|
||||
$ticketLinkTypeId = Arr::get($validated, 'ticket_link_type_id');
|
||||
$isSourceTicket = Arr::get($validated, 'link_direction', 'source') === 'source';
|
||||
|
||||
$linkedTicket = Ticket::query()
|
||||
->where('project_id', $project->id)
|
||||
->findOrFail($linkedTicketId);
|
||||
$sourceTicket = $isSourceTicket ? $ticket : $linkedTicket;
|
||||
$targetTicket = $isSourceTicket ? $linkedTicket : $ticket;
|
||||
|
||||
return DB::transaction(function () use ($linkedTicket, $user, $ticketLinkTypeId, $ticket, $sourceTicket, $targetTicket, $isSourceTicket): TicketLink {
|
||||
return DB::transaction(function () use ($linkedTicket, $user, $ticketLinkTypeId, $ticket): TicketLink {
|
||||
Ticket::query()
|
||||
->whereKey([$ticket->id, $linkedTicket->id])
|
||||
->orderBy('id')
|
||||
@@ -53,31 +50,31 @@ class StoreProjectTicketLink
|
||||
|
||||
if ($ticketLink === null) {
|
||||
$ticketLink = TicketLink::query()->create([
|
||||
'source_ticket_id' => $sourceTicket->id,
|
||||
'target_ticket_id' => $targetTicket->id,
|
||||
'source_ticket_id' => $ticket->id,
|
||||
'target_ticket_id' => $linkedTicket->id,
|
||||
'ticket_link_type_id' => $ticketLinkTypeId,
|
||||
]);
|
||||
} else {
|
||||
$ticketLink->update([
|
||||
'source_ticket_id' => $sourceTicket->id,
|
||||
'target_ticket_id' => $targetTicket->id,
|
||||
'source_ticket_id' => $ticket->id,
|
||||
'target_ticket_id' => $linkedTicket->id,
|
||||
'ticket_link_type_id' => $ticketLinkTypeId,
|
||||
]);
|
||||
}
|
||||
|
||||
$ticketLink->load('ticketLinkType');
|
||||
|
||||
$ticketLabel = $ticketLink->ticketLinkType->labelForSource($isSourceTicket);
|
||||
$linkedTicketLabel = $ticketLink->ticketLinkType->labelForSource(! $isSourceTicket);
|
||||
$sourceLabel = $ticketLink->ticketLinkType->labelForSource(true);
|
||||
$targetLabel = $ticketLink->ticketLinkType->labelForSource(false);
|
||||
|
||||
$this->createTicketWorklogEntry->handle(
|
||||
$ticket,
|
||||
$user,
|
||||
TicketWorklogEntryType::LinkUpdated,
|
||||
"Linked {$linkedTicket->ticket_key} as {$ticketLabel}.",
|
||||
"Linked {$linkedTicket->ticket_key} as {$sourceLabel}.",
|
||||
[
|
||||
'linked_ticket_key' => $linkedTicket->ticket_key,
|
||||
'ticket_link_type' => $ticketLabel,
|
||||
'ticket_link_type' => $sourceLabel,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -85,10 +82,10 @@ class StoreProjectTicketLink
|
||||
$linkedTicket,
|
||||
$user,
|
||||
TicketWorklogEntryType::LinkUpdated,
|
||||
"Linked {$ticket->ticket_key} as {$linkedTicketLabel}.",
|
||||
"Linked {$ticket->ticket_key} as {$targetLabel}.",
|
||||
[
|
||||
'linked_ticket_key' => $ticket->ticket_key,
|
||||
'ticket_link_type' => $linkedTicketLabel,
|
||||
'ticket_link_type' => $targetLabel,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -36,12 +36,11 @@ class ProjectTicketLinkController extends ApiController
|
||||
|
||||
$ticketLink = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated());
|
||||
$ticketLink->setAttribute('current_ticket_id', $ticket->id);
|
||||
$label = $ticketLink->ticketLinkType->labelForSource($ticketLink->source_ticket_id === $ticket->id);
|
||||
|
||||
return TicketLinkResource::make($ticketLink->load(['sourceTicket', 'targetTicket', 'ticketLinkType']))
|
||||
->additional([
|
||||
'meta' => [
|
||||
'message' => "{$label} link was saved.",
|
||||
'message' => "{$ticketLink->ticketLinkType->label} link was saved.",
|
||||
],
|
||||
])
|
||||
->response()
|
||||
|
||||
@@ -34,10 +34,9 @@ class ProjectTicketLinkController extends Controller
|
||||
);
|
||||
|
||||
$ticketLink = $this->storeProjectTicketLink->handle($project, $ticket, $user, $request->validated());
|
||||
$label = $ticketLink->ticketLinkType->labelForSource($ticketLink->source_ticket_id === $ticket->id);
|
||||
|
||||
return to_route('projects.tickets.show', [$project, $ticket])
|
||||
->with('status', "{$label} link was saved.");
|
||||
->with('status', "{$ticketLink->ticketLinkType->label} link was saved.");
|
||||
}
|
||||
|
||||
public function destroy(
|
||||
|
||||
@@ -42,7 +42,6 @@ class StoreTicketLinkRequest extends FormRequest
|
||||
fn ($query) => $query->where('project_id', $project->id),
|
||||
),
|
||||
],
|
||||
'link_direction' => ['sometimes', 'required', Rule::in(['source', 'counterpart'])],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Generated
+5
-5
@@ -2007,16 +2007,16 @@
|
||||
},
|
||||
{
|
||||
"name": "league/commonmark",
|
||||
"version": "2.10.3",
|
||||
"version": "2.10.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/commonmark.git",
|
||||
"reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f"
|
||||
"reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6efbd9c472b91db0a3350fcd601c8332c2382e1f",
|
||||
"reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f",
|
||||
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d2d1aa8b35e072966c89bc0c66cf926e56767dc4",
|
||||
"reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -2110,7 +2110,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-09-21T13:07:34+00:00"
|
||||
"time": "2026-08-11T16:06:25+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/config",
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
"dev": "vite",
|
||||
"lint": "eslint . --fix",
|
||||
"lint:check": "eslint .",
|
||||
"types:check": "php artisan wayfinder:generate --with-form --no-interaction && vue-tsc --noEmit"
|
||||
"types:check": "vue-tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
|
||||
Generated
+5
-13
@@ -203,14 +203,6 @@ settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
overrides:
|
||||
axios@>=1.13.0 <1.20.0: ^1.20.0
|
||||
axios@>=1.15.0 <1.20.0: ^1.20.0
|
||||
axios@>=1.15.1 <1.20.0: ^1.20.0
|
||||
axios@>=1.15.2 <1.20.0: ^1.20.0
|
||||
axios@>=1.16.1 <1.20.0: ^1.20.0
|
||||
axios@>=1.17.0 <1.20.0: ^1.20.0
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
@@ -1083,8 +1075,8 @@ packages:
|
||||
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
axios@1.20.0:
|
||||
resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==}
|
||||
axios@1.19.0:
|
||||
resolution: {integrity: sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==}
|
||||
|
||||
balanced-match@1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
@@ -2679,7 +2671,7 @@ snapshots:
|
||||
'@inertiajs/core@2.3.18(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)':
|
||||
dependencies:
|
||||
'@types/lodash-es': 4.17.12
|
||||
axios: 1.20.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
|
||||
axios: 1.19.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
|
||||
laravel-precognition: 1.0.2(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
|
||||
lodash-es: 4.18.1
|
||||
qs: 6.16.0
|
||||
@@ -3295,7 +3287,7 @@ snapshots:
|
||||
dependencies:
|
||||
possible-typed-array-names: 1.1.0
|
||||
|
||||
axios@1.20.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1):
|
||||
axios@1.19.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1):
|
||||
dependencies:
|
||||
follow-redirects: 1.16.0(debug@4.4.3(supports-color@8.1.1))
|
||||
form-data: 4.0.6
|
||||
@@ -4063,7 +4055,7 @@ snapshots:
|
||||
|
||||
laravel-precognition@1.0.2(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1):
|
||||
dependencies:
|
||||
axios: 1.20.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
|
||||
axios: 1.19.0(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
|
||||
lodash-es: 4.18.1
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
@@ -7,10 +7,3 @@ minimumReleaseAgeExclude:
|
||||
allowBuilds:
|
||||
unrs-resolver: true
|
||||
vue-demi: true
|
||||
overrides:
|
||||
axios@>=1.13.0 <1.20.0: ^1.20.0
|
||||
axios@>=1.15.0 <1.20.0: ^1.20.0
|
||||
axios@>=1.15.1 <1.20.0: ^1.20.0
|
||||
axios@>=1.15.2 <1.20.0: ^1.20.0
|
||||
axios@>=1.16.1 <1.20.0: ^1.20.0
|
||||
axios@>=1.17.0 <1.20.0: ^1.20.0
|
||||
|
||||
@@ -35,7 +35,6 @@ const canAddLink = computed(
|
||||
);
|
||||
const form = useForm({
|
||||
ticket_link_type_id: props.ticketLinkTypes[0]?.id ?? '',
|
||||
link_direction: 'source',
|
||||
linked_ticket_id: '',
|
||||
});
|
||||
const protection = useFormProtection(form, {
|
||||
@@ -221,7 +220,7 @@ function submit(): void {
|
||||
/>
|
||||
<fieldset
|
||||
:disabled="form.processing"
|
||||
class="grid min-w-0 gap-4 sm:grid-cols-3"
|
||||
class="grid min-w-0 gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]"
|
||||
>
|
||||
<TicketFormField
|
||||
id="ticket_link_type_id"
|
||||
@@ -231,33 +230,13 @@ function submit(): void {
|
||||
:options="
|
||||
ticketLinkTypes.map((type) => ({
|
||||
value: type.id,
|
||||
label: `${type.label} / ${type.counterpart_label}`,
|
||||
label: type.label,
|
||||
}))
|
||||
"
|
||||
:error="form.errors.ticket_link_type_id"
|
||||
:disabled="form.processing"
|
||||
required
|
||||
/>
|
||||
<TicketFormField
|
||||
id="link_direction"
|
||||
v-model="form.link_direction"
|
||||
kind="select"
|
||||
:label="t('ticketRelationships.direction')"
|
||||
:options="[
|
||||
{
|
||||
value: 'source',
|
||||
label: selectedType?.label ?? '',
|
||||
},
|
||||
{
|
||||
value: 'counterpart',
|
||||
label:
|
||||
selectedType?.counterpart_label ?? '',
|
||||
},
|
||||
]"
|
||||
:error="form.errors.link_direction"
|
||||
:disabled="form.processing"
|
||||
required
|
||||
/>
|
||||
<TicketFormField
|
||||
id="linked_ticket_id"
|
||||
v-model="form.linked_ticket_id"
|
||||
@@ -288,11 +267,7 @@ function submit(): void {
|
||||
<span class="font-mono text-xs">{{
|
||||
ticket.ticket_key
|
||||
}}</span
|
||||
><span>{{
|
||||
form.link_direction === 'source'
|
||||
? selectedType.label
|
||||
: selectedType.counterpart_label
|
||||
}}</span
|
||||
><span>{{ selectedType.label }}</span
|
||||
><ArrowRight class="size-4" aria-hidden="true" /><span
|
||||
class="font-mono text-xs"
|
||||
>{{ selectedTicket.ticket_key }}</span
|
||||
|
||||
@@ -1200,9 +1200,8 @@ export const en = {
|
||||
'Open a child ticket to change its parent. Expand a group to see its descendants.',
|
||||
'ticketRelationships.addLink': 'Add or update a link',
|
||||
'ticketRelationships.selectTicket': 'Select a ticket',
|
||||
'ticketRelationships.direction': 'This ticket',
|
||||
'ticketRelationships.directionHelp':
|
||||
'Choose how this ticket relates to the linked ticket. The reverse label appears on the other ticket. Saving an existing pair updates its type and direction.',
|
||||
'The selected relationship points from this ticket to the linked ticket. Saving an existing pair updates its type and direction.',
|
||||
'ticketRelationships.outgoing': 'Outgoing',
|
||||
'ticketRelationships.incoming': 'Incoming',
|
||||
'ticketRelationships.removeLink': 'Remove link to {key}',
|
||||
|
||||
@@ -293,49 +293,6 @@ class TicketApiTest extends TestCase
|
||||
$this->assertDatabaseCount('ticket_worklog_entries', 0);
|
||||
}
|
||||
|
||||
public function test_counterpart_link_direction_is_reflected_on_both_tickets_in_the_api(): void
|
||||
{
|
||||
[$user, $project] = $this->projectContext();
|
||||
$status = $project->ticketStatuses()->firstOrFail();
|
||||
$blockingTicket = $this->ticket($project, $status);
|
||||
$blockedTicket = $this->ticket($project, $status, 2);
|
||||
$linkType = TicketLinkType::factory()->for($project)->create([
|
||||
'label' => 'Blocks',
|
||||
'counterpart_label' => 'Blocked by',
|
||||
]);
|
||||
|
||||
$this->withApplicationKey($user)
|
||||
->postJson(route('api.projects.tickets.links.store', [$project, $blockedTicket]), [
|
||||
'linked_ticket_id' => $blockingTicket->id,
|
||||
'ticket_link_type_id' => $linkType->id,
|
||||
'link_direction' => 'counterpart',
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.source_ticket_id', $blockingTicket->id)
|
||||
->assertJsonPath('data.target_ticket_id', $blockedTicket->id)
|
||||
->assertJsonPath('data.ticket_link_type.label', 'Blocked by')
|
||||
->assertJsonPath('meta.message', 'Blocked by link was saved.');
|
||||
|
||||
$this->withApplicationKey($user)
|
||||
->getJson(route('api.projects.tickets.show', [$project, $blockingTicket]))
|
||||
->assertOk()
|
||||
->assertJsonPath('data.linkedTickets.0.ticket_link_type.label', 'Blocks');
|
||||
|
||||
$this->withApplicationKey($user)
|
||||
->getJson(route('api.projects.tickets.show', [$project, $blockedTicket]))
|
||||
->assertOk()
|
||||
->assertJsonPath('data.linkedTickets.0.ticket_link_type.label', 'Blocked by');
|
||||
|
||||
$this->withApplicationKey($user)
|
||||
->postJson(route('api.projects.tickets.links.store', [$project, $blockedTicket]), [
|
||||
'linked_ticket_id' => $blockingTicket->id,
|
||||
'ticket_link_type_id' => $linkType->id,
|
||||
'link_direction' => 'invalid',
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('link_direction');
|
||||
}
|
||||
|
||||
public function test_ticket_links_and_branch_endpoints_are_available(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
|
||||
@@ -83,74 +83,6 @@ class ProjectTicketLinkControllerTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_counterpart_label_can_be_selected_from_the_current_ticket(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$project = Project::factory()->create(['key' => 'CORE']);
|
||||
|
||||
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||
|
||||
$priority = $project->ticketPriorities()->firstOrFail();
|
||||
$taskType = $project->ticketTypes()->where('is_parent', false)->firstOrFail();
|
||||
$ticketLinkType = TicketLinkType::factory()->for($project)->create([
|
||||
'label' => 'Blocks',
|
||||
'counterpart_label' => 'Blocked by',
|
||||
]);
|
||||
$blockingTicket = Ticket::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'sequence_number' => 1,
|
||||
'ticket_key' => 'CORE-1',
|
||||
'ticket_priority_id' => $priority->id,
|
||||
'ticket_type_id' => $taskType->id,
|
||||
]);
|
||||
$blockedTicket = Ticket::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'sequence_number' => 2,
|
||||
'ticket_key' => 'CORE-2',
|
||||
'ticket_priority_id' => $priority->id,
|
||||
'ticket_type_id' => $taskType->id,
|
||||
]);
|
||||
|
||||
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('projects.tickets.links.store', [$project, $blockedTicket]), [
|
||||
'linked_ticket_id' => $blockingTicket->id,
|
||||
'ticket_link_type_id' => $ticketLinkType->id,
|
||||
'link_direction' => 'counterpart',
|
||||
])
|
||||
->assertRedirect(route('projects.tickets.show', [$project, $blockedTicket]))
|
||||
->assertSessionHas('status', 'Blocked by link was saved.');
|
||||
|
||||
$this->assertDatabaseHas('ticket_links', [
|
||||
'source_ticket_id' => $blockingTicket->id,
|
||||
'target_ticket_id' => $blockedTicket->id,
|
||||
'ticket_link_type_id' => $ticketLinkType->id,
|
||||
]);
|
||||
$this->assertDatabaseHas('ticket_worklog_entries', [
|
||||
'ticket_id' => $blockedTicket->id,
|
||||
'summary' => 'Linked CORE-1 as Blocked by.',
|
||||
]);
|
||||
$this->assertDatabaseHas('ticket_worklog_entries', [
|
||||
'ticket_id' => $blockingTicket->id,
|
||||
'summary' => 'Linked CORE-2 as Blocks.',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('projects.tickets.links.store', [$project, $blockedTicket]), [
|
||||
'linked_ticket_id' => $blockingTicket->id,
|
||||
'ticket_link_type_id' => $ticketLinkType->id,
|
||||
'link_direction' => 'source',
|
||||
])
|
||||
->assertSessionHas('status', 'Blocks link was saved.');
|
||||
|
||||
$this->assertSame(1, TicketLink::query()->count());
|
||||
$this->assertDatabaseHas('ticket_links', [
|
||||
'source_ticket_id' => $blockedTicket->id,
|
||||
'target_ticket_id' => $blockingTicket->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure ticket links cannot target the current ticket or a ticket from another project.
|
||||
*/
|
||||
@@ -212,15 +144,6 @@ class ProjectTicketLinkControllerTest extends TestCase
|
||||
])
|
||||
->assertRedirect(route('projects.tickets.show', [$project, $ticket]))
|
||||
->assertSessionHasErrors('linked_ticket_id');
|
||||
|
||||
$this->actingAs($user)
|
||||
->from(route('projects.tickets.show', [$project, $ticket]))
|
||||
->post(route('projects.tickets.links.store', [$project, $ticket]), [
|
||||
'linked_ticket_id' => $foreignTicket->id,
|
||||
'ticket_link_type_id' => $ticketLinkType->id,
|
||||
'link_direction' => 'sideways',
|
||||
])
|
||||
->assertSessionHasErrors('link_direction');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -398,11 +398,6 @@ if (serve) {
|
||||
} else {
|
||||
params.delete('noLinks');
|
||||
params.delete('linkRejected');
|
||||
if (data.link_direction === 'counterpart') {
|
||||
params.set('incoming', '1');
|
||||
} else {
|
||||
params.delete('incoming');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
assert.ok(
|
||||
@@ -491,8 +486,6 @@ if (serve) {
|
||||
return result.body.split('id="main-content"')[1];
|
||||
};
|
||||
const html = await render();
|
||||
assert.ok(html.includes('id="link_direction"'));
|
||||
assert.ok(html.includes('Blocked by'));
|
||||
for (const text of [
|
||||
ticket.title,
|
||||
'Keep every existing ticket action available.',
|
||||
|
||||
@@ -70,15 +70,6 @@ try {
|
||||
assert.equal(await collapse.getAttribute('aria-expanded'), 'true');
|
||||
await control('Link type').focus();
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(
|
||||
await control('This ticket').evaluate(
|
||||
(el) =>
|
||||
el === document.activeElement &&
|
||||
el.matches(':focus-visible'),
|
||||
),
|
||||
true,
|
||||
);
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(
|
||||
await control('Linked ticket').evaluate(
|
||||
(el) =>
|
||||
@@ -127,7 +118,6 @@ try {
|
||||
await save().click();
|
||||
assert.deepEqual((await request).postDataJSON(), {
|
||||
ticket_link_type_id: 1,
|
||||
link_direction: 'source',
|
||||
linked_ticket_id: 41,
|
||||
});
|
||||
await section().getByText('Outgoing', { exact: true }).waitFor();
|
||||
@@ -151,25 +141,6 @@ try {
|
||||
document.activeElement?.textContent.includes('Linked tickets'),
|
||||
);
|
||||
|
||||
await visit('?noLinks');
|
||||
await control('This ticket').selectOption('counterpart');
|
||||
await control('Linked ticket').selectOption('41');
|
||||
await section()
|
||||
.locator('form')
|
||||
.last()
|
||||
.getByText('Blocked by', { exact: true })
|
||||
.waitFor();
|
||||
request = page.waitForRequest(
|
||||
(r) => r.method() === 'POST' && r.url().endsWith('/links'),
|
||||
);
|
||||
await save().click();
|
||||
assert.deepEqual((await request).postDataJSON(), {
|
||||
ticket_link_type_id: 1,
|
||||
link_direction: 'counterpart',
|
||||
linked_ticket_id: 41,
|
||||
});
|
||||
await section().getByText('Incoming', { exact: true }).waitFor();
|
||||
|
||||
await visit('?rejectLink');
|
||||
await control('Linked ticket').selectOption('41');
|
||||
await save().click();
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use League\Flysystem\CorruptedPathDetected;
|
||||
use League\Flysystem\WhitespacePathNormalizer;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class FilesystemPathSecurityTest extends TestCase
|
||||
{
|
||||
#[DataProvider('corruptedPaths')]
|
||||
public function test_corrupted_paths_are_rejected(string $path): void
|
||||
{
|
||||
$this->expectException(CorruptedPathDetected::class);
|
||||
|
||||
(new WhitespacePathNormalizer)->normalizePath($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{string}>
|
||||
*/
|
||||
public static function corruptedPaths(): array
|
||||
{
|
||||
return [
|
||||
'control character' => ["avatars/user\x00.svg"],
|
||||
'malformed UTF-8' => ["avatars/user\xFF.svg"],
|
||||
'control character hidden by malformed UTF-8' => ["avatars/user\xFF\x00.svg"],
|
||||
];
|
||||
}
|
||||
|
||||
public function test_valid_unicode_paths_remain_supported(): void
|
||||
{
|
||||
$this->assertSame(
|
||||
'avatars/benützer.svg',
|
||||
(new WhitespacePathNormalizer)->normalizePath('./avatars//benützer.svg'),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user