Files
Gnuboard7/tests/Feature/Api/Admin/CheckModifiedLayoutsContractTest.php
HeuJung 1eedbbdda1 fix(core,engine): OFFSET 순회 중 필터 컬럼 변경으로 인한 처리 누락 수정
Laravel chunk/each 는 OFFSET 기반이라, 콜백이 행의 필터 소속을 바꾸면
(필터 컬럼을 채우거나 매칭 행을 삭제하면) 다음 조회가 그만큼 앞으로 밀려
미처리 행을 조용히 건너뛴다. 250건/청크 100 이면 정확히 100건이 남는다.
코어·번들 확장 전수 조사로 확정한 6곳을 chunkById(키셋 순회)로 통일했다.

브라우저 검수(28축)에서 계획에 없던 결함 12건이 드러나 같은 사슬에서
수정했다. 이 중 layout_strategy=keep 무효화와 수정 감지 실패 시 "수정 없음"
오보는 사용자 작업 소실에 직결된다.

재발 방지로 audit 룰을 추가했고, 수정 6곳을 원복해 red 6건 / 복원 후 green
0건으로 판별력을 검증했다.
2026-07-31 21:50:45 +09:00

214 lines
8.6 KiB
PHP

<?php
namespace Tests\Feature\Api\Admin;
use App\Enums\ExtensionOwnerType;
use App\Enums\PermissionType;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use App\Services\ModuleService;
use App\Services\PluginService;
use App\Services\TemplateService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* `check-modified-layouts` 및 확장 업데이트 응답의 계약 회귀 테스트.
*
* 배경(Chrome MCP 정밀 점검에서 실측):
* - 미존재 식별자에 대해 형제 엔드포인트(show/uninstall-info)는 404 를 주는데
* check-modified-layouts 만 200 + "수정된 레이아웃 없음" 을 반환했다. 오타·제거된
* 확장이 "수정 없음" 으로 조용히 통과해, 레이아웃 보존 판단을 그르친다.
* - 업데이트 성공 메시지가 치환 파라미터 없이 반환되어 `:module` / `:version`
* 플레이스홀더가 그대로 사용자에게 노출됐다.
*
* 컨트롤러 소스의 철자가 아니라 **실제 HTTP 응답**(상태코드·메시지 본문)으로 계약을
* 고정한다. 확장 서비스는 컨테이너에서 대역으로 바꿔 파일시스템/네트워크 의존을 제거한다.
*/
class CheckModifiedLayoutsContractTest extends TestCase
{
use RefreshDatabase;
private string $token;
/** 확장 종류별 검증 축: [서비스, 조회 메서드, URL 세그먼트, 업데이트 메서드, 치환 키] */
private const EXTENSIONS = [
['service' => ModuleService::class, 'lookup' => 'getModuleInfo', 'segment' => 'modules', 'update' => 'updateModule', 'name' => 'zz-audit-module'],
['service' => PluginService::class, 'lookup' => 'getPluginInfo', 'segment' => 'plugins', 'update' => 'updatePlugin', 'name' => 'zz-audit-plugin'],
['service' => TemplateService::class, 'lookup' => 'getTemplateInfo', 'segment' => 'templates', 'update' => 'performVersionUpdate', 'name' => 'zz-audit-template'],
];
protected function setUp(): void
{
parent::setUp();
$admin = $this->createAdminUser([
'core.modules.read', 'core.modules.install',
'core.plugins.read', 'core.plugins.install',
'core.templates.read', 'core.templates.install',
]);
$this->token = $admin->createToken('test-token')->plainTextToken;
}
/**
* admin 역할 + 지정 권한을 가진 사용자를 생성합니다.
*
* AdminMiddleware 가 hasRole('admin') 을 확인하므로 admin 역할 부여가 필수입니다.
*
* @param array<int, string> $permissions 부여할 권한 식별자 목록
* @return User 생성된 관리자
*/
private function createAdminUser(array $permissions): User
{
$user = User::factory()->create();
$permissionIds = [];
foreach ($permissions as $identifier) {
$permissionIds[] = Permission::firstOrCreate(
['identifier' => $identifier],
[
'name' => ['ko' => $identifier, 'en' => $identifier],
'description' => ['ko' => $identifier, 'en' => $identifier],
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'type' => PermissionType::Admin,
]
)->id;
}
$testRole = Role::create([
'identifier' => 'admin_test_'.uniqid(),
'name' => ['ko' => '테스트 관리자', 'en' => 'Test Administrator'],
'description' => ['ko' => '테스트 관리자', 'en' => 'Test Administrator'],
'is_active' => true,
]);
$testRole->permissions()->sync($permissionIds);
$adminRole = Role::firstOrCreate(
['identifier' => 'admin'],
[
'name' => ['ko' => '관리자', 'en' => 'Administrator'],
'description' => ['ko' => '시스템 관리자', 'en' => 'System Administrator'],
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'is_active' => true,
]
);
foreach ([$adminRole->id, $testRole->id] as $roleId) {
$user->roles()->attach($roleId, ['assigned_at' => now(), 'assigned_by' => null]);
}
return $user->fresh();
}
/**
* 인증 헤더가 붙은 요청 헬퍼.
*/
private function authRequest(): static
{
return $this->withHeaders([
'Authorization' => 'Bearer '.$this->token,
'Accept' => 'application/json',
]);
}
/**
* 미존재 식별자는 200 "수정 없음" 이 아니라 404 여야 한다 (D4).
*/
public function test_check_modified_layouts_returns_404_for_missing_identifier(): void
{
foreach (self::EXTENSIONS as $ext) {
$this->mock($ext['service'], function ($mock) use ($ext) {
$mock->shouldReceive($ext['lookup'])->andReturn(null);
$mock->shouldNotReceive('checkModifiedLayouts');
});
$response = $this->authRequest()
->getJson("/api/admin/{$ext['segment']}/{$ext['name']}/check-modified-layouts");
$response->assertStatus(404);
$this->assertFalse(
$response->json('success'),
"{$ext['segment']}: 미존재 식별자가 성공 응답으로 통과했습니다."
);
$this->assertStringNotContainsString(
'modified',
(string) $response->json('message'),
"{$ext['segment']}: 미존재인데 수정 감지 결과 메시지가 반환되었습니다."
);
}
}
/**
* 존재하는 식별자는 종전대로 200 + 감지 결과를 반환한다 (비회귀).
*/
public function test_check_modified_layouts_returns_200_for_existing_identifier(): void
{
foreach (self::EXTENSIONS as $ext) {
$this->mock($ext['service'], function ($mock) use ($ext) {
$mock->shouldReceive($ext['lookup'])->andReturn(['identifier' => $ext['name']]);
$mock->shouldReceive('checkModifiedLayouts')->andReturn([
'has_modified_layouts' => true,
'modified_count' => 2,
'modified_layouts' => [
['name' => 'a', 'size_diff' => 10],
['name' => 'b', 'size_diff' => -4],
],
]);
});
$response = $this->authRequest()
->getJson("/api/admin/{$ext['segment']}/{$ext['name']}/check-modified-layouts");
$response->assertStatus(200);
$this->assertSame(2, $response->json('data.modified_count'), $ext['segment']);
}
}
/**
* 업데이트 성공 메시지에 식별자·버전이 실제로 채워져야 한다 (D7).
*/
public function test_update_success_message_has_substituted_placeholders(): void
{
foreach (self::EXTENSIONS as $ext) {
$this->mock($ext['service'], function ($mock) use ($ext) {
// module_info/plugin_info/template_info 를 비워 success() 경로를 통과시킨다.
$mock->shouldReceive($ext['update'])->andReturn([
'success' => true,
'from_version' => '1.0.0',
'to_version' => '1.2.3',
]);
});
$response = $this->authRequest()
->postJson("/api/admin/{$ext['segment']}/{$ext['name']}/update", [
'layout_strategy' => 'keep',
]);
$response->assertStatus(200);
$message = (string) $response->json('message');
$this->assertStringContainsString($ext['name'], $message, "{$ext['segment']}: 식별자가 메시지에 치환되지 않았습니다.");
$this->assertStringContainsString('1.2.3', $message, "{$ext['segment']}: 버전이 메시지에 치환되지 않았습니다.");
$this->assertDoesNotMatchRegularExpression(
'/:(module|plugin|template|version)\b/',
$message,
"{$ext['segment']}: 치환되지 않은 플레이스홀더가 사용자에게 노출됩니다 — {$message}"
);
}
}
/**
* 인증 없이 호출하면 401 (라우트 가드 비회귀).
*/
public function test_check_modified_layouts_requires_authentication(): void
{
foreach (self::EXTENSIONS as $ext) {
$this->getJson("/api/admin/{$ext['segment']}/{$ext['name']}/check-modified-layouts")
->assertStatus(401);
}
}
}