Files
Leantime/tests/Unit/app/Domain/Blueprints/Services/BlueprintsServiceTest.php

823 lines
34 KiB
PHP

<?php
namespace Unit\app\Domain\Blueprints\Services;
use Codeception\Test\Feature\Stub;
use Leantime\Core\Auth\Permissions\PermissionService;
use Leantime\Core\Exceptions\AuthorizationException;
use Leantime\Core\Language as LanguageCore;
use Leantime\Domain\Blueprints\Models\CanvasTemplate;
use Leantime\Domain\Blueprints\Repositories\Blueprints as BlueprintsRepository;
use Leantime\Domain\Blueprints\Services\Blueprints as BlueprintsService;
use Leantime\Domain\Blueprints\Services\TemplateRegistry;
use Leantime\Domain\ContentTemplates\Models\ContentTemplate;
use Leantime\Domain\ContentTemplates\Services\ContentTemplateRegistry;
use Leantime\Domain\Users\Repositories\Users as UserRepository;
use Unit\TestCase;
/**
* Unit tests for the Blueprints service: label translation helpers and the
* board-progress calculation (filled boxes / total boxes, max across boards).
*/
class BlueprintsServiceTest extends TestCase
{
use Stub;
/**
* Build the service with a language stub that prefixes keys with "T:" so we
* can assert translation happened, plus optional repo/registry overrides.
*/
private function service(?BlueprintsRepository $repo = null, ?TemplateRegistry $registry = null): BlueprintsService
{
$language = $this->make(LanguageCore::class, ['__' => fn (string $index) => 'T:'.$index]);
return new BlueprintsService(
$repo ?? $this->make(BlueprintsRepository::class),
$registry ?? new TemplateRegistry,
$language,
new ContentTemplateRegistry,
);
}
public function test_translated_boxes_run_titles_through_language(): void
{
$template = new CanvasTemplate([
'slug' => 'swot',
'boxes' => ['swot_strengths' => ['icon' => 'fa-x', 'title' => 'box.swot.strengths']],
]);
$boxes = $this->service()->getTranslatedBoxes($template);
$this->assertSame('T:box.swot.strengths', $boxes['swot_strengths']['title']);
$this->assertSame('fa-x', $boxes['swot_strengths']['icon']);
}
public function test_translates_status_relates_and_data_labels(): void
{
$service = $this->service();
$template = new CanvasTemplate(['slug' => 'x']); // base defaults
$this->assertSame('T:status.draft', $service->getTranslatedStatusLabels($template)['status_draft']['title']);
$this->assertSame('T:relates.none', $service->getTranslatedRelatesLabels($template)['relates_none']['title']);
$this->assertSame('T:label.assumptions', $service->getTranslatedDataLabels($template)[1]['title']);
}
public function test_disclaimer_is_empty_when_unset_and_translated_otherwise(): void
{
$service = $this->service();
$this->assertSame('', $service->getTranslatedDisclaimer(new CanvasTemplate(['slug' => 'x'])));
$this->assertSame(
'T:text.lean.disclaimer',
$service->getTranslatedDisclaimer(new CanvasTemplate(['slug' => 'lean', 'disclaimer' => 'text.lean.disclaimer']))
);
}
public function test_board_progress_is_fraction_of_filled_boxes(): void
{
// SWOT has 4 boxes; board 1 has 2 boxes with items -> 0.5.
$repo = $this->make(BlueprintsRepository::class, [
'getCanvasProgressCount' => fn () => [
['canvasType' => 'swotcanvas', 'canvasId' => 1, 'box' => 'swot_strengths', 'boxItems' => 3],
['canvasType' => 'swotcanvas', 'canvasId' => 1, 'box' => 'swot_threats', 'boxItems' => 1],
['canvasType' => 'swotcanvas', 'canvasId' => 1, 'box' => 'swot_weaknesses', 'boxItems' => 0],
],
]);
$progress = $this->service($repo)->getBoardProgress('1', ['swotcanvas']);
$this->assertEqualsWithDelta(0.5, $progress['swotcanvas'], 0.001);
}
public function test_board_progress_takes_max_across_boards(): void
{
// Board 2 has all 4 SWOT boxes filled -> max progress 1.0.
$repo = $this->make(BlueprintsRepository::class, [
'getCanvasProgressCount' => fn () => [
['canvasType' => 'swotcanvas', 'canvasId' => 1, 'box' => 'swot_strengths', 'boxItems' => 1],
['canvasType' => 'swotcanvas', 'canvasId' => 2, 'box' => 'swot_strengths', 'boxItems' => 1],
['canvasType' => 'swotcanvas', 'canvasId' => 2, 'box' => 'swot_weaknesses', 'boxItems' => 1],
['canvasType' => 'swotcanvas', 'canvasId' => 2, 'box' => 'swot_opportunities', 'boxItems' => 1],
['canvasType' => 'swotcanvas', 'canvasId' => 2, 'box' => 'swot_threats', 'boxItems' => 1],
],
]);
$progress = $this->service($repo)->getBoardProgress('1', ['swotcanvas']);
$this->assertEqualsWithDelta(1.0, $progress['swotcanvas'], 0.001);
}
// ---------------------------------------------------------------------
// Boards overview (absorbed from the former Strategy service).
// ---------------------------------------------------------------------
public function test_build_recent_progress_seeds_metadata_and_removes_used_type(): void
{
$service = $this->service();
$metadata = $service->getBoardMetadata();
$recentlyUpdated = [
['type' => 'valuecanvas', 'title' => 'My Value Board', 'modified' => '2026-05-20 10:00:00', 'id' => 11],
];
$result = $service->buildRecentProgressCanvas($recentlyUpdated, $metadata);
$this->assertArrayHasKey('valuecanvas', $result);
$this->assertSame(1, $result['valuecanvas']['count']);
$this->assertSame('My Value Board', $result['valuecanvas']['lastTitle']);
$this->assertSame('2026-05-20 10:00:00', $result['valuecanvas']['lastUpdate']);
$this->assertSame(11, $result['valuecanvas']['lastCanvasId']);
// Board links point at the consolidated Blueprints routes.
$this->assertSame('blueprints/value', $result['valuecanvas']['module']);
// The consumed type must be removed from the remaining "other" boards map.
$this->assertArrayNotHasKey('valuecanvas', $metadata);
$this->assertArrayHasKey('swotcanvas', $metadata);
}
public function test_build_recent_progress_increments_count_for_repeat_type(): void
{
$service = $this->service();
$metadata = $service->getBoardMetadata();
$recentlyUpdated = [
['type' => 'swotcanvas', 'title' => 'First', 'modified' => '2026-05-21 09:00:00', 'id' => 1],
['type' => 'swotcanvas', 'title' => 'Second', 'modified' => '2026-05-22 09:00:00', 'id' => 2],
['type' => 'swotcanvas', 'title' => 'Third', 'modified' => '2026-05-23 09:00:00', 'id' => 3],
];
$result = $service->buildRecentProgressCanvas($recentlyUpdated, $metadata);
$this->assertSame(3, $result['swotcanvas']['count']);
// The seeded values come from the FIRST occurrence only.
$this->assertSame('First', $result['swotcanvas']['lastTitle']);
$this->assertSame(1, $result['swotcanvas']['lastCanvasId']);
}
public function test_build_recent_progress_with_empty_input_returns_empty(): void
{
$service = $this->service();
$metadata = $service->getBoardMetadata();
$metadataCountBefore = count($metadata);
$result = $service->buildRecentProgressCanvas([], $metadata);
$this->assertSame([], $result);
// Nothing consumed, so the metadata map is untouched.
$this->assertCount($metadataCountBefore, $metadata);
}
public function test_boards_overview_assembles_render_ready_struct(): void
{
$recentlyUpdated = [
['type' => 'leancanvas', 'title' => 'Lean A', 'modified' => '2026-05-25 12:00:00', 'id' => 99],
];
$progress = ['leancanvas' => 0.5];
// getBoardsOverview now self-calls getLastUpdatedCanvas()/getBoardProgress(),
// so partial-mock just those two and exercise the real assembly logic.
$service = $this->make(BlueprintsService::class, [
'getLastUpdatedCanvas' => fn () => $recentlyUpdated,
'getBoardProgress' => fn () => $progress,
]);
$overview = $service->getBoardsOverview(7);
$this->assertArrayHasKey('recentProgressCanvas', $overview);
$this->assertArrayHasKey('otherBoards', $overview);
$this->assertArrayHasKey('recentlyUpdatedCanvas', $overview);
$this->assertArrayHasKey('canvasProgress', $overview);
$this->assertSame($recentlyUpdated, $overview['recentlyUpdatedCanvas']);
$this->assertSame($progress, $overview['canvasProgress']);
// leancanvas was recently used, so it lands in recentProgressCanvas
// and is removed from the remaining "other" boards.
$this->assertArrayHasKey('leancanvas', $overview['recentProgressCanvas']);
$this->assertSame('Lean A', $overview['recentProgressCanvas']['leancanvas']['lastTitle']);
$this->assertArrayNotHasKey('leancanvas', $overview['otherBoards']);
}
public function test_boards_overview_passes_project_id_to_self_calls(): void
{
$capturedLastUpdatedId = null;
$capturedProgressId = null;
$service = $this->make(BlueprintsService::class, [
'getLastUpdatedCanvas' => function ($projectId) use (&$capturedLastUpdatedId) {
$capturedLastUpdatedId = $projectId;
return [];
},
'getBoardProgress' => function ($projectId) use (&$capturedProgressId) {
$capturedProgressId = $projectId;
return [];
},
]);
$service->getBoardsOverview(7);
$this->assertSame(7, $capturedLastUpdatedId);
$this->assertSame('7', $capturedProgressId, 'getBoardProgress receives the project id cast to string');
}
// ---------------------------------------------------------------------
// Secured by-id board/item CRUD chokepoint.
//
// Canvas boards/items live in the shared zp_canvas / zp_canvas_items tables (one id
// sequence across every variant). Every by-id operation must authorize against the
// entity's REAL project (resolved by id + canvas type), never the session project. Reads
// soft-deny (return the neutral "missing" value) so they are not a cross-project existence
// oracle; writes fail CLOSED with an AuthorizationException and never touch the repo.
// ---------------------------------------------------------------------
private function allowingPermissions(): PermissionService
{
return $this->make(PermissionService::class, [
'authorize' => fn () => null,
'currentUserCan' => fn () => true,
]);
}
private function denyingPermissions(): PermissionService
{
return $this->make(PermissionService::class, [
'authorize' => function (): void {
throw new AuthorizationException;
},
'currentUserCan' => fn () => false,
]);
}
private function securedService(BlueprintsRepository $repo, PermissionService $perms): BlueprintsService
{
$service = $this->service($repo);
$service->setPermissionService($perms);
return $service;
}
public function test_get_canvas_item_returns_false_for_missing_or_foreign_item_without_loading_it(): void
{
// Resolver null = missing id OR an id whose board is a different canvas type. Must
// return false WITHOUT loading the item — no cross-project existence oracle.
$loaded = 0;
$repo = $this->make(BlueprintsRepository::class, [
'getCanvasItemProjectId' => fn () => null,
'getSingleCanvasItem' => function () use (&$loaded) {
$loaded++;
return ['id' => 1];
},
]);
$service = $this->securedService($repo, $this->allowingPermissions());
$this->assertFalse($service->getCanvasItem(123, 'swotcanvas'));
$this->assertSame(0, $loaded, 'A missing/foreign item must not be loaded');
}
public function test_get_canvas_item_soft_denies_when_view_not_permitted(): void
{
$loaded = 0;
$repo = $this->make(BlueprintsRepository::class, [
'getCanvasItemProjectId' => fn () => 9,
'getSingleCanvasItem' => function () use (&$loaded) {
$loaded++;
return ['id' => 1];
},
]);
$service = $this->securedService($repo, $this->make(PermissionService::class, ['currentUserCan' => fn () => false]));
$this->assertFalse($service->getCanvasItem(1, 'swotcanvas'));
$this->assertSame(0, $loaded, 'An unauthorized item returns the same neutral result as a missing one');
}
public function test_get_canvas_item_is_type_scoped_and_returns_item_when_authorized(): void
{
$resolvedType = null;
$repo = $this->make(BlueprintsRepository::class, [
'getCanvasItemProjectId' => function ($id, $type) use (&$resolvedType) {
$resolvedType = $type;
return 9;
},
'getSingleCanvasItem' => fn () => ['id' => 7, 'canvasId' => 3],
]);
$service = $this->securedService($repo, $this->make(PermissionService::class, ['currentUserCan' => fn () => true]));
$item = $service->getCanvasItem(7, 'swotcanvas');
$this->assertSame(7, $item['id']);
$this->assertSame('swotcanvas', $resolvedType, 'The resolver must be type-scoped so a foreign canvas type cannot match');
}
public function test_get_board_items_returns_empty_for_foreign_board(): void
{
$loaded = 0;
$repo = $this->make(BlueprintsRepository::class, [
'getCanvasProjectId' => fn () => null,
'getCanvasItemsById' => function () use (&$loaded) {
$loaded++;
return [['id' => 1]];
},
]);
$service = $this->securedService($repo, $this->allowingPermissions());
$this->assertSame([], $service->getBoardItems(999, 'swotcanvas', 'swotcanvasitem'));
$this->assertSame(0, $loaded, 'A foreign/unknown board must not have its items read');
}
public function test_patch_canvas_item_throws_and_never_writes_for_unresolved_item(): void
{
$patched = 0;
$repo = $this->make(BlueprintsRepository::class, [
'getCanvasItemProjectId' => fn () => null,
'patchCanvasItem' => function () use (&$patched) {
$patched++;
return true;
},
]);
// allow-all permissions: the deny must come from the null resolution, not the role.
$service = $this->securedService($repo, $this->allowingPermissions());
try {
$service->patchCanvasItem(5, ['status' => 'x'], 'swotcanvas');
$this->fail('Expected AuthorizationException for an unresolved item');
} catch (AuthorizationException) {
// expected
}
$this->assertSame(0, $patched, 'A missing/foreign item must never be patched');
}
public function test_patch_canvas_item_throws_when_edit_denied(): void
{
$repo = $this->make(BlueprintsRepository::class, [
'getCanvasItemProjectId' => fn () => 9,
'patchCanvasItem' => fn () => true,
]);
$service = $this->securedService($repo, $this->denyingPermissions());
$this->expectException(AuthorizationException::class);
$service->patchCanvasItem(5, ['status' => 'x'], 'swotcanvas');
}
public function test_update_canvas_item_resolves_project_from_item_id_not_payload_canvas_id(): void
{
// Relocation fence: the project is resolved from the EXISTING item's id, not from the
// attacker-supplied canvasId in the payload.
$resolvedItemId = null;
$wrote = 0;
$repo = $this->make(BlueprintsRepository::class, [
'getCanvasItemProjectId' => function ($id) use (&$resolvedItemId) {
$resolvedItemId = $id;
return 9;
},
'editCanvasItem' => function () use (&$wrote) {
$wrote++;
},
]);
$service = $this->securedService($repo, $this->allowingPermissions());
$service->updateCanvasItem(['itemId' => 42, 'canvasId' => 9999, 'description' => 'x'], 'swotcanvas');
$this->assertSame(42, $resolvedItemId, 'Project must be resolved from itemId, not the payload canvasId');
$this->assertSame(1, $wrote);
}
public function test_create_canvas_item_throws_and_never_inserts_for_unknown_board(): void
{
$inserted = 0;
$repo = $this->make(BlueprintsRepository::class, [
'getCanvasProjectId' => fn () => null,
'addCanvasItem' => function () use (&$inserted) {
$inserted++;
return '1';
},
]);
$service = $this->securedService($repo, $this->allowingPermissions());
try {
$service->createCanvasItem(['canvasId' => 9999, 'box' => 'x'], 'swotcanvas');
$this->fail('Expected AuthorizationException for an unknown target board');
} catch (AuthorizationException) {
}
$this->assertSame(0, $inserted, 'An item must never be created into an unknown/foreign board');
}
public function test_delete_canvas_item_throws_and_never_deletes_for_unresolved_item(): void
{
$deleted = 0;
$repo = $this->make(BlueprintsRepository::class, [
'getCanvasItemProjectId' => fn () => null,
'delCanvasItem' => function () use (&$deleted) {
$deleted++;
},
]);
$service = $this->securedService($repo, $this->allowingPermissions());
try {
$service->deleteCanvasItem(5, 'swotcanvas');
$this->fail('Expected AuthorizationException for an unresolved item');
} catch (AuthorizationException) {
}
$this->assertSame(0, $deleted, 'A missing/foreign item must never be deleted');
}
public function test_delete_board_throws_and_never_deletes_for_unresolved_board(): void
{
$deleted = 0;
$repo = $this->make(BlueprintsRepository::class, [
'getCanvasProjectId' => fn () => null,
'deleteCanvas' => function () use (&$deleted) {
$deleted++;
},
]);
$service = $this->securedService($repo, $this->allowingPermissions());
try {
$service->deleteBoard(5, 'swotcanvas');
$this->fail('Expected AuthorizationException for an unresolved board');
} catch (AuthorizationException) {
}
$this->assertSame(0, $deleted, 'A missing/foreign board must never be deleted');
}
public function test_copy_board_throws_when_source_unresolved_and_never_copies(): void
{
$copied = 0;
$repo = $this->make(BlueprintsRepository::class, [
'getCanvasProjectId' => fn () => null,
'copyCanvas' => function () use (&$copied) {
$copied++;
return 1;
},
]);
$service = $this->securedService($repo, $this->allowingPermissions());
try {
$service->copyBoard(5, 7, 1, 'Copy', 'swotcanvas');
$this->fail('Expected AuthorizationException for an unresolved source board');
} catch (AuthorizationException) {
}
$this->assertSame(0, $copied, 'A board must never be copied from an unknown/foreign source');
}
public function test_merge_board_requires_both_boards_to_resolve(): void
{
// Source (1) resolves but target (2) does not -> deny, never merge.
$merged = 0;
$repo = $this->make(BlueprintsRepository::class, [
'getCanvasProjectId' => fn ($id) => $id === 1 ? 9 : null,
'mergeCanvas' => function () use (&$merged) {
$merged++;
return true;
},
]);
$service = $this->securedService($repo, $this->allowingPermissions());
try {
$service->mergeBoard(2, 1, 'swotcanvas');
$this->fail('Expected AuthorizationException when a board does not resolve');
} catch (AuthorizationException) {
}
$this->assertSame(0, $merged, 'Merge must not run unless BOTH boards resolve');
}
public function test_import_authorizes_create_on_target_project_before_doing_anything(): void
{
// import() authorizes CREATE against the passed projectId first — a denial throws
// before the file/template/repo are ever touched (it is reachable via JSON-RPC with an
// arbitrary projectId).
$repo = $this->make(BlueprintsRepository::class, [
'existCanvas' => function (): bool {
$this->fail('import must deny before touching the repository');
return false;
},
]);
$service = $this->securedService($repo, $this->denyingPermissions());
$this->expectException(AuthorizationException::class);
$service->import('/tmp/does-not-matter.xml', 'swot', 55, 1);
}
// ---------------------------------------------------------------------
// import() path-validation regression tests (SSRF / LFI / CWE-918).
// ---------------------------------------------------------------------
public function test_import_rejects_ssrf_url_wrappers(): void
{
// URL wrappers such as http://, ftp:// resolve to false via realpath(),
// but even if/when a stream wrapper could produce a realpath, the
// allow-list check catches it. This test also guards the more
// subtle case of file:///etc/passwd which some PHP builds resolve.
$service = $this->securedService(
$this->make(BlueprintsRepository::class),
$this->allowingPermissions()
);
// SSRF: HTTP URL — realpath() returns false, caught as "file not found".
$this->assertFalse(
$service->import('http://169.254.169.254/latest/meta-data/', 'lean', 55, 1),
'HTTP URL must be rejected'
);
// SSRF: FTP URL.
$this->assertFalse(
$service->import('ftp://evil.com/blueprint.xml', 'lean', 55, 1),
'FTP URL must be rejected'
);
// LFI: file:// wrapper. Some PHP builds resolve file:///etc/passwd
// via realpath() and would read it without the allow-list guard.
$this->assertFalse(
$service->import('file:///etc/passwd', 'lean', 55, 1),
'file:// URL must be rejected'
);
}
public function test_import_rejects_lfi_absolute_path_to_system_file(): void
{
// Create an .xml file in a directory that is NOT in the allowed list.
// base_path('storage') is reliably outside sys_temp_dir, userfiles, and
// Blueprints/imports — unlike /var/tmp which can equal sys_get_temp_dir()
// on some systems.
$service = $this->securedService(
$this->make(BlueprintsRepository::class),
$this->allowingPermissions()
);
$outOfBounds = base_path('storage/leantime_lfi_test_'.uniqid('', true).'.xml');
file_put_contents($outOfBounds, '<canvas key="leancanvas"><title>LFI Test</title></canvas>');
try {
$this->assertFalse(
$service->import($outOfBounds, 'lean', 55, 1),
'Absolute path to an .xml file outside allowed directories must be rejected'
);
} finally {
if (file_exists($outOfBounds)) {
unlink($outOfBounds);
}
}
}
public function test_import_rejects_dot_dot_path_traversal(): void
{
// Create a real .xml file outside the allow-list (in storage/),
// then reach it via a path that starts in sys_get_temp_dir() and
// traverses up to the filesystem root with ../ before descending
// into the project. realpath() must resolve the ../ segments and
// the allow-list must reject the canonicalized path — this proves
// both canonicalization AND allow-list work, not just extension
// validation.
$service = $this->securedService(
$this->make(BlueprintsRepository::class),
$this->allowingPermissions()
);
$outOfBounds = base_path('storage/traversal_target_'.uniqid('', true).'.xml');
file_put_contents($outOfBounds, '<canvas key="leancanvas"><title>Traversal Test</title></canvas>');
// Walk from temp dir up to root (depth + 1 levels), then down
// into the project storage directory.
$upLevels = substr_count(sys_get_temp_dir(), DIRECTORY_SEPARATOR) + 1;
$fromRoot = ltrim($outOfBounds, DIRECTORY_SEPARATOR);
$traversal = sys_get_temp_dir().DIRECTORY_SEPARATOR
.str_repeat('..'.DIRECTORY_SEPARATOR, $upLevels + 1)
.$fromRoot;
try {
$this->assertFalse(
$service->import($traversal, 'lean', 55, 1),
'Path traversal (../) to a valid .xml outside allowed dirs must be rejected'
);
} finally {
if (file_exists($outOfBounds)) {
unlink($outOfBounds);
}
}
}
public function test_import_rejects_sibling_prefix_bypass(): void
{
// str_starts_with without DIRECTORY_SEPARATOR anchoring would
// allow imports-evil/x to match against allowed …/imports.
// Create a sibling of the Blueprints imports directory (under
// the project root, guaranteed writable) to test the anchor.
$service = $this->securedService(
$this->make(BlueprintsRepository::class),
$this->allowingPermissions()
);
$allowedDir = APP_ROOT.'/app/Domain/Blueprints/imports';
if (! is_dir($allowedDir)) {
mkdir($allowedDir, 0700, true);
}
$siblingDir = APP_ROOT.'/app/Domain/Blueprints/imports-sibling-'.uniqid('', true);
if (! is_dir($siblingDir)) {
mkdir($siblingDir, 0700, true);
}
$siblingFile = $siblingDir.'/blueprint.xml';
file_put_contents($siblingFile, '<canvas key="leancanvas"><title>Test</title></canvas>');
try {
$this->assertFalse(
$service->import($siblingFile, 'lean', 55, 1),
'Sibling-prefix path (e.g. /tmp-evil/…) must NOT match allowed /tmp'
);
} finally {
unlink($siblingFile);
rmdir($siblingDir);
}
}
public function test_import_rejects_disallowed_file_extensions(): void
{
// Only .xml is permitted. Other extensions must be
// rejected even when the file sits in an allowed directory.
$service = $this->securedService(
$this->make(BlueprintsRepository::class),
$this->allowingPermissions()
);
// Use tempnam() + rename to get unique filenames — fixed names
// in the shared temp dir can collide with crashed-run leftovers
// or concurrent test processes.
$phpBase = tempnam(sys_get_temp_dir(), 'leantime.');
$phpFile = $phpBase.'.php';
rename($phpBase, $phpFile);
file_put_contents($phpFile, '<?php echo "pwned";');
$txtBase = tempnam(sys_get_temp_dir(), 'leantime.');
$txtFile = $txtBase.'.txt';
rename($txtBase, $txtFile);
file_put_contents($txtFile, 'not xml');
try {
$this->assertFalse(
$service->import($phpFile, 'lean', 55, 1),
'.php extension must be rejected in an allowed directory'
);
$this->assertFalse(
$service->import($txtFile, 'lean', 55, 1),
'.txt extension must be rejected in an allowed directory'
);
} finally {
if (file_exists($phpFile)) {
unlink($phpFile);
}
if (file_exists($txtFile)) {
unlink($txtFile);
}
}
}
public function test_import_accepts_xml_file_in_allowed_temp_dir(): void
{
// A .xml file placed in sys_get_temp_dir() (the normal upload flow)
// must pass path validation and successfully import via the repo.
// The repository is stubbed so the import completes and returns a
// known canvas id, proving that path validation did NOT block it.
$expectedId = 42;
// addCanvas()/addCanvasItem() are declared `false|string` (insertGetId), so the
// stubs must return strings — import() casts the id to int on the way out.
$repo = $this->make(BlueprintsRepository::class, [
'existCanvas' => fn () => false,
'addCanvas' => fn () => (string) $expectedId,
'addCanvasItem' => fn () => '1',
]);
$service = $this->securedService($repo, $this->allowingPermissions());
// import() resolves UserRepository via app()->make(). Unit tests
// disable the database, so bind a stub that never touches it.
$usersStub = $this->make(UserRepository::class, [
'getUserIdByName' => fn () => 1,
]);
app()->instance(UserRepository::class, $usersStub);
// Mirrors what BlueprintsExport::buildXml() actually emits — in particular
// status/relates carry their value in a `key` attribute, which is what
// import() reads. Element text there is silently dropped.
$xml = <<<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<canvas key="leancanvas">
<title>Security Test Canvas</title>
<content>
<element key="problem">
<item>
<author id="1" firstname="A" lastname="B"/>
<description>Test item</description>
<status key="status_draft" />
<relates key="relates_none" />
<assumptions>none</assumptions>
<data>none</data>
<conclusion>none</conclusion>
</item>
</element>
</content>
</canvas>
XML;
$tmpBase = tempnam(sys_get_temp_dir(), 'leantime.');
$tempFile = $tmpBase.'.xml';
rename($tmpBase, $tempFile);
file_put_contents($tempFile, $xml);
try {
$result = $service->import($tempFile, 'lean', 55, 1);
// Path validation passed and repo returned the expected canvas id.
$this->assertSame(
$expectedId,
$result,
'XML file in allowed dir must pass path validation and be imported'
);
} finally {
if (file_exists($tempFile)) {
unlink($tempFile);
}
}
}
public function test_create_board_applies_start_content_against_the_slug_not_the_db_type(): void
{
// Regression test for Phase 4: createBoard() is called with the DATABASE
// type ("swotcanvas") but both the Blueprints TemplateRegistry and the
// ContentTemplateRegistry key by the SLUG ("swot"). The original code
// called TemplateRegistry::get($canvasType), which required a slug and
// silently returned null for the db-type form — making applyStartContent
// a no-op. This test locks in the fix: getByDatabaseType() bridges, and
// the resolved slug flows to the ContentTemplates lookups.
$blueprint = new CanvasTemplate([
'slug' => 'swot',
'startContent' => 'starter-swot',
]);
$registry = new class($blueprint) extends TemplateRegistry
{
public function __construct(private CanvasTemplate $bp) {}
public function get(string $slug): ?CanvasTemplate
{
// Bug reproduction: original code called this with 'swotcanvas'.
// The real registry only knows 'swot' — so it returned null and
// applyStartContent bailed. Test-side we mirror that behavior.
return $slug === 'swot' ? $this->bp : null;
}
public function getByDatabaseType(string $dbType): ?CanvasTemplate
{
// Mirror the shipped str_ends_with/substr strip so this stub and
// the production slug-resolution can't drift (per review CR).
$suffix = 'canvas';
$slug = str_ends_with($dbType, $suffix) && strlen($dbType) > strlen($suffix)
? substr($dbType, 0, -strlen($suffix))
: $dbType;
return $this->get($slug);
}
};
$contentTemplates = new class extends ContentTemplateRegistry
{
/** @var string[] */
public array $seenSlugs = [];
// Override the parent constructor (the stub needs no deps) and record
// the slugs get() is consulted with, so the test asserts on them
// afterward. Avoids a by-reference property — PHP ^8.2 can't promote
// by reference, and a typed-property reference is brittle.
public function __construct() {}
public function get(string $appliesTo, string $key): ?ContentTemplate
{
$this->seenSlugs[] = $appliesTo;
return null; // null lookup exits applyStartContent early, but the assertion is on WHAT slug reached us.
}
};
$repo = $this->make(BlueprintsRepository::class, [
'addCanvas' => fn () => '77',
]);
$language = $this->make(LanguageCore::class, ['__' => fn (string $index) => 'T:'.$index]);
$service = new BlueprintsService($repo, $registry, $language, $contentTemplates);
$service->setPermissionService($this->allowingPermissions());
$service->createBoard(['projectId' => 5, 'title' => 't'], 'swotcanvas');
$this->assertSame(['swot'], $contentTemplates->seenSlugs, 'ContentTemplates must be consulted with the SLUG, not the DB type');
}
}