OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)

This commit is contained in:
wangruiguo
2026-09-03 18:49:20 +08:00
commit d647428529
3501 changed files with 1988906 additions and 0 deletions

View File

@@ -0,0 +1,69 @@
<?php
namespace Unit\app\Domain\Blueprints\Services;
use Leantime\Domain\Blueprints\Services\Blueprints as BlueprintsService;
use Leantime\Domain\Blueprints\Services\BlueprintsExport;
use Leantime\Domain\Blueprints\Services\TemplateRegistry;
use Unit\TestCase;
/**
* Unit tests for the BlueprintsExport service (XML generation).
*
* exportToXml reads the board + items through the Blueprints SERVICE (getBoard / getBoardItems),
* which authorizes VIEW against the board's real project and returns false / [] for a
* missing/foreign/unauthorized board — so these stub the service, not the repository.
*/
class BlueprintsExportTest extends TestCase
{
use \Codeception\Test\Feature\Stub;
public function test_exports_a_canvas_board_to_xml(): void
{
$service = $this->make(BlueprintsService::class, [
'getBoard' => fn () => [['title' => 'My SWOT', 'projectId' => 1]],
'getBoardItems' => fn () => [
[
'box' => 'swot_strengths', 'description' => 'Strong brand', 'author' => 5,
'status' => '', 'relates' => '', 'assumptions' => '', 'data' => '', 'conclusion' => '',
'created' => '2026-01-01 00:00:00', 'modified' => '2026-01-02 00:00:00',
'authorFirstname' => 'Jo', 'authorLastname' => 'Doe',
],
],
'getTranslatedBoxes' => fn () => [
'swot_strengths' => ['title' => 'Strengths'],
'swot_weaknesses' => ['title' => 'Weaknesses'],
],
]);
$xml = (new BlueprintsExport($service, new TemplateRegistry))->exportToXml(7, 'swot');
$this->assertNotNull($xml);
$this->assertStringContainsString('<canvas key="swotcanvas">', $xml);
$this->assertStringContainsString('<title>My SWOT</title>', $xml);
$this->assertStringContainsString('<element key="swot_strengths">', $xml);
$this->assertStringContainsString('<description>Strong brand</description>', $xml);
// An empty box still emits its element wrapper.
$this->assertStringContainsString('<element key="swot_weaknesses">', $xml);
}
public function test_returns_null_for_unknown_canvas_type(): void
{
$export = new BlueprintsExport(
$this->make(BlueprintsService::class),
new TemplateRegistry,
);
$this->assertNull($export->exportToXml(7, 'doesnotexist'));
}
public function test_returns_null_when_board_does_not_exist(): void
{
// getBoard returns false for a missing/foreign/unauthorized board.
$service = $this->make(BlueprintsService::class, ['getBoard' => fn () => false]);
$export = new BlueprintsExport($service, new TemplateRegistry);
$this->assertNull($export->exportToXml(7, 'swot'));
}
}

View File

@@ -0,0 +1,822 @@
<?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');
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace Unit\app\Domain\Blueprints\Services;
use Leantime\Domain\Blueprints\Models\CanvasTemplate;
use Leantime\Domain\Blueprints\Services\TemplateRegistry;
use Unit\TestCase;
/**
* Unit tests for TemplateRegistry, which loads the canvas YAML definitions from
* app/Domain/Blueprints/Templates/definitions into CanvasTemplate objects.
*/
class TemplateRegistryTest extends TestCase
{
private function registry(): TemplateRegistry
{
return new TemplateRegistry;
}
public function test_loads_a_known_definition(): void
{
$template = $this->registry()->get('swot');
$this->assertInstanceOf(CanvasTemplate::class, $template);
$this->assertSame('swot', $template->slug);
$this->assertSame('swotcanvas', $template->getDatabaseType());
// SWOT has four boxes.
$this->assertCount(4, $template->boxes);
$this->assertArrayHasKey('swot_strengths', $template->boxes);
}
public function test_unknown_slug_returns_null(): void
{
$this->assertNull($this->registry()->get('doesnotexist'));
}
public function test_slug_lookup_is_case_insensitive_and_trimmed(): void
{
$this->assertInstanceOf(CanvasTemplate::class, $this->registry()->get(' SWOT '));
}
public function test_get_by_database_type_strips_canvas_suffix(): void
{
$template = $this->registry()->getByDatabaseType('leancanvas');
$this->assertInstanceOf(CanvasTemplate::class, $template);
$this->assertSame('lean', $template->slug);
}
public function test_caches_and_returns_same_instance(): void
{
$registry = $this->registry();
$this->assertSame($registry->get('swot'), $registry->get('swot'));
}
public function test_all_loads_every_definition(): void
{
$slugs = $this->registry()->slugs();
// The 16 consolidated variants all have a YAML definition.
$expected = ['cp', 'dbm', 'ea', 'em', 'insights', 'lbm', 'lean', 'minempathy', 'obm', 'retros', 'risks', 'sb', 'sm', 'sq', 'swot', 'value'];
foreach ($expected as $slug) {
$this->assertContains($slug, $slugs, "Missing definition for '$slug'");
}
}
public function test_obm_carries_min_width_offset(): void
{
// OBM is the one layout that needed an extra +50px min-width offset.
$this->assertSame(50, $this->registry()->get('obm')->minWidthOffset);
}
}