route:cache 는 새 앱을 부팅해 라우트를 수집하고, 그 부팅의 확장 라우트 프로바이더는 DB 가 아니라 캐시된 활성 확장 목록을 읽는다. 그래서 rebuild 가 invalidate*StatusCache 보다 앞서면 방금 바뀐 상태가 빠진 채 라우트가 박제되고, 라우트 캐시에는 스캔 폴백이 없어 오류도 로그도 없이 404 가 된다. 무효화를 굽기 직전이 아니라 DB 상태 쓰기 직후로 올려, 같은 목록을 읽는 훅 매핑 캐시까지 함께 바로잡았다. update 경로는 Updating 전이 직후에 비우면 그 창의 오토로드 갱신이 확장을 비활성으로 판정하므로, 상태 복원 직후에 비운 뒤 훅 캐시를 다시 굽는다. 플러그인 라우트 프로바이더에는 활성 게이트가 없어 비활성 플러그인의 API 가 계속 응답했다. 화면·메뉴만 사라지고 기능은 살아 있는 상태였다. 모듈과 같은 기준을 적용했다. 실패 사유가 하위 계층에서 버려져 관리자 화면에 :error 자리표시자가 그대로 노출되던 문제도 고쳤다. 반환 경로를 깨지 않도록 배열 키 reason 과 뒤에 붙인 선택적 out 파라미터로 사유를 실어 올리고, 확장이 수명주기 훅에서 사유를 남길 수 있는 통로를 추가했다. 설치 경로의 광역 RuntimeException catch 는 도메인 예외로 좁혀 원본 키와 파라미터를 응답에 싣는다 — 상태코드 422 는 유지해 사용자 계약을 함께 바꾸지 않는다. 언어팩 화면은 프로덕션에서 예외 원문을 싣지 않는 것이 확정된 계약이므로, 자리를 일반 문구로 채우는 대신 치환 자리 자체를 제거했다. 원문은 종전대로 errors 통로를 거쳐 디버그 모드에서 도달한다.
1107 lines
40 KiB
PHP
1107 lines
40 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature\Api\Admin;
|
|
|
|
use App\Enums\ExtensionOwnerType;
|
|
use App\Enums\PermissionType;
|
|
use App\Exceptions\ModuleOperationException;
|
|
use App\Models\Permission;
|
|
use App\Models\Role;
|
|
use App\Models\User;
|
|
use App\Services\ModuleService;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* AdminModuleController 테스트
|
|
*
|
|
* 모듈 관리 API 엔드포인트를 테스트합니다.
|
|
*/
|
|
class ModuleControllerTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private User $admin;
|
|
|
|
private string $token;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->admin = $this->createAdminUser();
|
|
$this->token = $this->admin->createToken('test-token')->plainTextToken;
|
|
}
|
|
|
|
/**
|
|
* 관리자 역할 생성 및 할당
|
|
*
|
|
* 중요: AdminMiddleware가 isAdmin()을 체크하고, isAdmin()은 hasRole('admin')을 확인합니다.
|
|
* 따라서 역할 identifier는 반드시 'admin'이어야 합니다.
|
|
*
|
|
* @param array $permissions 사용자에게 부여할 권한 식별자 목록
|
|
*/
|
|
private function createAdminUser(array $permissions = ['core.modules.read', 'core.modules.install', 'core.modules.activate', 'core.modules.uninstall']): User
|
|
{
|
|
$user = User::factory()->create();
|
|
|
|
// 권한 생성
|
|
$permissionIds = [];
|
|
foreach ($permissions as $permIdentifier) {
|
|
$permission = Permission::firstOrCreate(
|
|
['identifier' => $permIdentifier],
|
|
[
|
|
'name' => json_encode(['ko' => $permIdentifier, 'en' => $permIdentifier]),
|
|
'description' => json_encode(['ko' => $permIdentifier.' 권한', 'en' => $permIdentifier.' Permission']),
|
|
'extension_type' => ExtensionOwnerType::Core,
|
|
'extension_identifier' => 'core',
|
|
'type' => PermissionType::Admin,
|
|
]
|
|
);
|
|
$permissionIds[] = $permission->id;
|
|
}
|
|
|
|
// 고유한 식별자로 역할 생성 (테스트별 격리를 위해)
|
|
$roleIdentifier = 'admin_test_'.uniqid();
|
|
$testRole = Role::create([
|
|
'identifier' => $roleIdentifier,
|
|
'name' => json_encode(['ko' => '테스트 관리자', 'en' => 'Test Administrator']),
|
|
'description' => json_encode(['ko' => '테스트 관리자', 'en' => 'Test Administrator']),
|
|
'is_active' => true,
|
|
]);
|
|
|
|
// admin 역할도 추가 (admin 미들웨어 통과용)
|
|
$adminRole = Role::firstOrCreate(
|
|
['identifier' => 'admin'],
|
|
[
|
|
'name' => json_encode(['ko' => '관리자', 'en' => 'Administrator']),
|
|
'description' => json_encode(['ko' => '시스템 관리자', 'en' => 'System Administrator']),
|
|
'extension_type' => ExtensionOwnerType::Core,
|
|
'extension_identifier' => 'core',
|
|
'is_active' => true,
|
|
]
|
|
);
|
|
|
|
// 테스트용 역할에 권한 할당
|
|
$testRole->permissions()->sync($permissionIds);
|
|
|
|
// 사용자에게 admin 역할과 테스트용 역할 모두 할당
|
|
$user->roles()->attach($adminRole->id, [
|
|
'assigned_at' => now(),
|
|
'assigned_by' => null,
|
|
]);
|
|
$user->roles()->attach($testRole->id, [
|
|
'assigned_at' => now(),
|
|
'assigned_by' => null,
|
|
]);
|
|
|
|
return $user->fresh();
|
|
}
|
|
|
|
/**
|
|
* 인증된 요청 헬퍼 메서드
|
|
*/
|
|
private function authRequest(): static
|
|
{
|
|
return $this->withHeaders([
|
|
'Authorization' => 'Bearer '.$this->token,
|
|
'Accept' => 'application/json',
|
|
]);
|
|
}
|
|
|
|
// ========================================================================
|
|
// 모듈 목록 조회 테스트 (index)
|
|
// ========================================================================
|
|
|
|
/**
|
|
* 인증 없이 모듈 목록 조회 시 401 반환
|
|
*/
|
|
public function test_index_returns_401_without_authentication(): void
|
|
{
|
|
$response = $this->getJson('/api/admin/modules');
|
|
|
|
$response->assertStatus(401);
|
|
}
|
|
|
|
/**
|
|
* 모듈 목록 조회 성공
|
|
*/
|
|
public function test_index_returns_modules_list(): void
|
|
{
|
|
$response = $this->authRequest()->getJson('/api/admin/modules');
|
|
|
|
$response->assertStatus(200)
|
|
->assertJsonStructure([
|
|
'success',
|
|
'data' => [
|
|
'data',
|
|
'pagination' => [
|
|
'total',
|
|
'current_page',
|
|
'per_page',
|
|
],
|
|
],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 페이지네이션 파라미터 테스트
|
|
*/
|
|
public function test_index_supports_pagination(): void
|
|
{
|
|
$response = $this->authRequest()->getJson('/api/admin/modules?page=1&per_page=5');
|
|
|
|
$response->assertStatus(200)
|
|
->assertJsonPath('data.pagination.per_page', 5)
|
|
->assertJsonPath('data.pagination.current_page', 1);
|
|
}
|
|
|
|
/**
|
|
* with[] 파라미터로 custom_menus 조회 테스트
|
|
*/
|
|
public function test_index_returns_custom_menus_with_param(): void
|
|
{
|
|
$response = $this->authRequest()->getJson('/api/admin/modules?with[]=custom_menus');
|
|
|
|
$response->assertStatus(200)
|
|
->assertJsonStructure([
|
|
'success',
|
|
'data' => [
|
|
'data',
|
|
'custom_menus',
|
|
],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* with[] 파라미터 없이 조회 시 custom_menus가 없음
|
|
*/
|
|
public function test_index_does_not_return_custom_menus_without_param(): void
|
|
{
|
|
$response = $this->authRequest()->getJson('/api/admin/modules');
|
|
|
|
$response->assertStatus(200)
|
|
->assertJsonMissing(['custom_menus']);
|
|
}
|
|
|
|
/**
|
|
* 유효하지 않은 with[] 파라미터 시 422 반환
|
|
*/
|
|
public function test_index_returns_422_for_invalid_with_param(): void
|
|
{
|
|
$response = $this->authRequest()->getJson('/api/admin/modules?with[]=invalid_option');
|
|
|
|
$response->assertStatus(422)
|
|
->assertJsonValidationErrors(['with.0']);
|
|
}
|
|
|
|
// ========================================================================
|
|
// core.menus.read 권한으로 모듈 목록 접근 테스트
|
|
// ========================================================================
|
|
|
|
/**
|
|
* core.menus.read 권한만 가진 사용자가 모듈 목록 조회 시 200 반환
|
|
*/
|
|
public function test_index_returns_200_with_menus_read_permission(): void
|
|
{
|
|
$menuUser = $this->createAdminUser(['core.menus.read']);
|
|
$token = $menuUser->createToken('test-token')->plainTextToken;
|
|
|
|
$response = $this->withHeaders([
|
|
'Authorization' => 'Bearer '.$token,
|
|
'Accept' => 'application/json',
|
|
])->getJson('/api/admin/modules');
|
|
|
|
$response->assertStatus(200)
|
|
->assertJsonPath('data.data', [])
|
|
->assertJsonPath('data.pagination.total', 0);
|
|
}
|
|
|
|
/**
|
|
* core.menus.read 권한만 가진 사용자가 custom_menus 조회 시 데이터 반환
|
|
*/
|
|
public function test_index_returns_custom_menus_with_menus_read_permission(): void
|
|
{
|
|
$menuUser = $this->createAdminUser(['core.menus.read']);
|
|
$token = $menuUser->createToken('test-token')->plainTextToken;
|
|
|
|
$response = $this->withHeaders([
|
|
'Authorization' => 'Bearer '.$token,
|
|
'Accept' => 'application/json',
|
|
])->getJson('/api/admin/modules?with[]=custom_menus');
|
|
|
|
$response->assertStatus(200)
|
|
->assertJsonPath('data.data', [])
|
|
->assertJsonStructure([
|
|
'success',
|
|
'data' => [
|
|
'data',
|
|
'pagination',
|
|
'custom_menus',
|
|
],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* core.menus.read도 core.modules.read도 없는 사용자는 403 반환
|
|
*/
|
|
public function test_index_returns_403_without_any_relevant_permission(): void
|
|
{
|
|
$noPermUser = $this->createAdminUser([]);
|
|
$token = $noPermUser->createToken('test-token')->plainTextToken;
|
|
|
|
$response = $this->withHeaders([
|
|
'Authorization' => 'Bearer '.$token,
|
|
'Accept' => 'application/json',
|
|
])->getJson('/api/admin/modules');
|
|
|
|
$response->assertStatus(403);
|
|
}
|
|
|
|
// ========================================================================
|
|
// 모듈 검색 필터 테스트
|
|
// ========================================================================
|
|
|
|
/**
|
|
* 단일 검색어로 필터링 테스트
|
|
*/
|
|
public function test_index_filters_by_search(): void
|
|
{
|
|
$response = $this->authRequest()->getJson('/api/admin/modules?search=sample');
|
|
|
|
$response->assertStatus(200);
|
|
}
|
|
|
|
/**
|
|
* filters 파라미터로 다중 검색 조건 테스트
|
|
*/
|
|
public function test_index_filters_with_multiple_conditions(): void
|
|
{
|
|
$filters = [
|
|
[
|
|
'field' => 'name',
|
|
'operator' => 'like',
|
|
'value' => 'sample',
|
|
],
|
|
];
|
|
|
|
$response = $this->authRequest()->getJson('/api/admin/modules?'.http_build_query(['filters' => $filters]));
|
|
|
|
$response->assertStatus(200);
|
|
}
|
|
|
|
/**
|
|
* status 필터링 테스트
|
|
*
|
|
* status는 별도의 top-level 파라미터로 처리됨
|
|
*/
|
|
public function test_index_filters_by_status(): void
|
|
{
|
|
// status는 filters가 아닌 별도의 파라미터
|
|
$response = $this->authRequest()->getJson('/api/admin/modules?status=active');
|
|
|
|
$response->assertStatus(200);
|
|
}
|
|
|
|
/**
|
|
* 전체 권한 유저의 index 응답에 abilities 포함 확인
|
|
*/
|
|
public function test_index_returns_abilities_for_full_permission_user(): void
|
|
{
|
|
$response = $this->authRequest()->getJson('/api/admin/modules');
|
|
|
|
$response->assertStatus(200)
|
|
->assertJsonPath('data.abilities.can_install', true)
|
|
->assertJsonPath('data.abilities.can_activate', true)
|
|
->assertJsonPath('data.abilities.can_uninstall', true);
|
|
}
|
|
|
|
/**
|
|
* read 전용 유저의 index 응답에 abilities false 확인
|
|
*/
|
|
public function test_index_returns_false_abilities_for_read_only_user(): void
|
|
{
|
|
$readOnlyUser = $this->createAdminUser(['core.modules.read']);
|
|
$readOnlyToken = $readOnlyUser->createToken('test-token')->plainTextToken;
|
|
|
|
$response = $this->withHeaders([
|
|
'Authorization' => 'Bearer '.$readOnlyToken,
|
|
'Accept' => 'application/json',
|
|
])->getJson('/api/admin/modules');
|
|
|
|
$response->assertStatus(200)
|
|
->assertJsonPath('data.abilities.can_install', false)
|
|
->assertJsonPath('data.abilities.can_activate', false)
|
|
->assertJsonPath('data.abilities.can_uninstall', false);
|
|
}
|
|
|
|
/**
|
|
* 부분 권한 유저의 index 응답에 abilities 부분 확인
|
|
*/
|
|
public function test_index_returns_partial_abilities(): void
|
|
{
|
|
$partialUser = $this->createAdminUser(['core.modules.read', 'core.modules.install']);
|
|
$partialToken = $partialUser->createToken('test-token')->plainTextToken;
|
|
|
|
$response = $this->withHeaders([
|
|
'Authorization' => 'Bearer '.$partialToken,
|
|
'Accept' => 'application/json',
|
|
])->getJson('/api/admin/modules');
|
|
|
|
$response->assertStatus(200)
|
|
->assertJsonPath('data.abilities.can_install', true)
|
|
->assertJsonPath('data.abilities.can_activate', false)
|
|
->assertJsonPath('data.abilities.can_uninstall', false);
|
|
}
|
|
|
|
// ========================================================================
|
|
// 파일 업로드 설치 테스트 (installFromFile)
|
|
// ========================================================================
|
|
|
|
/**
|
|
* 인증 없이 파일 업로드 설치 시 401 반환
|
|
*/
|
|
public function test_install_from_file_returns_401_without_authentication(): void
|
|
{
|
|
$response = $this->postJson('/api/admin/modules/install-from-file', []);
|
|
|
|
$response->assertStatus(401);
|
|
}
|
|
|
|
/**
|
|
* 파일 없이 업로드 설치 요청 시 422 반환
|
|
*/
|
|
public function test_install_from_file_returns_422_without_file(): void
|
|
{
|
|
$response = $this->authRequest()->postJson('/api/admin/modules/install-from-file', []);
|
|
|
|
$response->assertStatus(422)
|
|
->assertJsonValidationErrors(['file']);
|
|
}
|
|
|
|
/**
|
|
* 잘못된 파일 형식으로 업로드 시 422 반환
|
|
*/
|
|
public function test_install_from_file_returns_422_for_invalid_file_type(): void
|
|
{
|
|
Storage::fake('local');
|
|
|
|
$file = UploadedFile::fake()->create('document.pdf', 100);
|
|
|
|
$response = $this->authRequest()->postJson('/api/admin/modules/install-from-file', [
|
|
'file' => $file,
|
|
]);
|
|
|
|
$response->assertStatus(422)
|
|
->assertJsonValidationErrors(['file']);
|
|
}
|
|
|
|
/**
|
|
* 파일 크기 초과 시 422 반환
|
|
*/
|
|
public function test_install_from_file_returns_422_for_oversized_file(): void
|
|
{
|
|
Storage::fake('local');
|
|
|
|
// 설정된 최대 크기보다 큰 파일 생성 (기본 50MB)
|
|
$maxSize = config('module.upload_max_size', 50);
|
|
$oversizedKb = ($maxSize * 1024) + 100; // 100KB 초과
|
|
|
|
$file = UploadedFile::fake()->create('module.zip', $oversizedKb);
|
|
|
|
$response = $this->authRequest()->postJson('/api/admin/modules/install-from-file', [
|
|
'file' => $file,
|
|
]);
|
|
|
|
$response->assertStatus(422)
|
|
->assertJsonValidationErrors(['file']);
|
|
}
|
|
|
|
// ========================================================================
|
|
// GitHub 설치 테스트 (installFromGithub)
|
|
// ========================================================================
|
|
|
|
/**
|
|
* 인증 없이 GitHub 설치 시 401 반환
|
|
*/
|
|
public function test_install_from_github_returns_401_without_authentication(): void
|
|
{
|
|
$response = $this->postJson('/api/admin/modules/install-from-github', []);
|
|
|
|
$response->assertStatus(401);
|
|
}
|
|
|
|
/**
|
|
* GitHub URL 없이 설치 요청 시 422 반환
|
|
*/
|
|
public function test_install_from_github_returns_422_without_url(): void
|
|
{
|
|
$response = $this->authRequest()->postJson('/api/admin/modules/install-from-github', []);
|
|
|
|
$response->assertStatus(422)
|
|
->assertJsonValidationErrors(['github_url']);
|
|
}
|
|
|
|
/**
|
|
* 잘못된 GitHub URL 형식으로 요청 시 422 반환
|
|
*/
|
|
public function test_install_from_github_returns_422_for_invalid_url(): void
|
|
{
|
|
$response = $this->authRequest()->postJson('/api/admin/modules/install-from-github', [
|
|
'github_url' => 'not-a-valid-url',
|
|
]);
|
|
|
|
$response->assertStatus(422)
|
|
->assertJsonValidationErrors(['github_url']);
|
|
}
|
|
|
|
/**
|
|
* GitHub이 아닌 URL로 요청 시 422 반환
|
|
*/
|
|
public function test_install_from_github_returns_422_for_non_github_url(): void
|
|
{
|
|
$response = $this->authRequest()->postJson('/api/admin/modules/install-from-github', [
|
|
'github_url' => 'https://gitlab.com/vendor/module',
|
|
]);
|
|
|
|
$response->assertStatus(422)
|
|
->assertJsonValidationErrors(['github_url']);
|
|
}
|
|
|
|
/**
|
|
* 유효한 GitHub URL 형식 테스트
|
|
*/
|
|
public function test_install_from_github_accepts_valid_github_url(): void
|
|
{
|
|
// 실제 설치는 외부 의존성이 필요하므로 URL 형식 검증만 테스트
|
|
// 실제 설치 로직은 mock이나 통합 테스트에서 수행
|
|
$response = $this->authRequest()->postJson('/api/admin/modules/install-from-github', [
|
|
'github_url' => 'https://github.com/sirsoft/sample-module',
|
|
]);
|
|
|
|
// URL 형식 검증 통과 확인: 422가 아니거나 github_url 관련 validation 에러가 없어야 함
|
|
// 실제 설치 과정에서 500 에러가 발생할 수 있음 (저장소 미존재 등)
|
|
if ($response->status() === 422) {
|
|
// 422인 경우 github_url 검증 에러가 아닌지 확인
|
|
$response->assertJsonMissingValidationErrors(['github_url']);
|
|
} else {
|
|
// 422가 아니면 URL 형식은 통과한 것
|
|
$this->assertContains($response->status(), [200, 201, 500]);
|
|
}
|
|
}
|
|
|
|
// ========================================================================
|
|
// 파일 업로드 설치 - Service Mock 테스트
|
|
// ========================================================================
|
|
|
|
/**
|
|
* 유효한 ZIP 파일로 모듈 설치 성공 (Service Mock)
|
|
*/
|
|
public function test_install_from_file_succeeds_with_valid_zip(): void
|
|
{
|
|
$moduleServiceMock = \Mockery::mock(ModuleService::class);
|
|
$moduleServiceMock->shouldReceive('installFromZipFile')
|
|
->once()
|
|
->andReturn([
|
|
'identifier' => 'sirsoft-testmodule',
|
|
'name' => 'Test Module',
|
|
'version' => '1.0.0',
|
|
]);
|
|
$this->app->instance(ModuleService::class, $moduleServiceMock);
|
|
|
|
$file = UploadedFile::fake()->create('module.zip', 100, 'application/zip');
|
|
|
|
$response = $this->authRequest()->postJson('/api/admin/modules/install-from-file', [
|
|
'file' => $file,
|
|
]);
|
|
|
|
$response->assertStatus(201)
|
|
->assertJson(['success' => true])
|
|
->assertJsonPath('data.identifier', 'sirsoft-testmodule');
|
|
}
|
|
|
|
/**
|
|
* ModuleService에서 도메인 예외 발생 시 422 반환 + 키가 해석된 문구
|
|
*
|
|
* 종전에는 raw `\RuntimeException` 이 이미 번역된 문장을 들고 오는 것을 컨트롤러가
|
|
* 메시지 **키** 자리로 넘겨 그대로 내보냈다. 키 해석에 실패한 원문이 나가는 형태라
|
|
* 계약 테스트가 이 지점을 위반으로 등재해 두고 있었다. 이제 도메인 실패는 키와
|
|
* 파라미터를 들고 다니는 예외로 오고, 컨트롤러가 그 키를 해석해 내보낸다.
|
|
*/
|
|
public function test_install_from_file_returns_422_on_domain_exception(): void
|
|
{
|
|
$moduleServiceMock = \Mockery::mock(ModuleService::class);
|
|
$moduleServiceMock->shouldReceive('installFromZipFile')
|
|
->once()
|
|
->andThrow(new ModuleOperationException('modules.errors.module_json_not_found'));
|
|
$this->app->instance(ModuleService::class, $moduleServiceMock);
|
|
|
|
$file = UploadedFile::fake()->create('module.zip', 100, 'application/zip');
|
|
|
|
$response = $this->authRequest()->postJson('/api/admin/modules/install-from-file', [
|
|
'file' => $file,
|
|
]);
|
|
|
|
$response->assertStatus(422);
|
|
$response->assertJsonPath('message', __('modules.errors.module_json_not_found'));
|
|
}
|
|
|
|
/**
|
|
* 도메인 예외가 아닌 raw RuntimeException 은 500 (인프라 장애를 입력 오류로 위장하지 않는다)
|
|
*/
|
|
public function test_install_from_file_returns_500_on_raw_runtime_exception(): void
|
|
{
|
|
$moduleServiceMock = \Mockery::mock(ModuleService::class);
|
|
$moduleServiceMock->shouldReceive('installFromZipFile')
|
|
->once()
|
|
->andThrow(new \RuntimeException('disk full'));
|
|
$this->app->instance(ModuleService::class, $moduleServiceMock);
|
|
|
|
$file = UploadedFile::fake()->create('module.zip', 100, 'application/zip');
|
|
|
|
$response = $this->authRequest()->postJson('/api/admin/modules/install-from-file', [
|
|
'file' => $file,
|
|
]);
|
|
|
|
$response->assertStatus(500);
|
|
}
|
|
|
|
/**
|
|
* ModuleService에서 일반 Exception 발생 시 500 반환
|
|
*/
|
|
public function test_install_from_file_returns_500_on_general_exception(): void
|
|
{
|
|
$moduleServiceMock = \Mockery::mock(ModuleService::class);
|
|
$moduleServiceMock->shouldReceive('installFromZipFile')
|
|
->once()
|
|
->andThrow(new \Exception('예상치 못한 오류'));
|
|
$this->app->instance(ModuleService::class, $moduleServiceMock);
|
|
|
|
$file = UploadedFile::fake()->create('module.zip', 100, 'application/zip');
|
|
|
|
$response = $this->authRequest()->postJson('/api/admin/modules/install-from-file', [
|
|
'file' => $file,
|
|
]);
|
|
|
|
$response->assertStatus(500);
|
|
}
|
|
|
|
// ========================================================================
|
|
// GitHub 설치 - Service Mock 테스트
|
|
// ========================================================================
|
|
|
|
/**
|
|
* 유효한 GitHub URL로 모듈 설치 성공 (Service Mock)
|
|
*/
|
|
public function test_install_from_github_calls_service_with_valid_url(): void
|
|
{
|
|
$moduleServiceMock = \Mockery::mock(ModuleService::class);
|
|
$moduleServiceMock->shouldReceive('installFromGithub')
|
|
->once()
|
|
->with('https://github.com/sirsoft/sample-module')
|
|
->andReturn([
|
|
'identifier' => 'sirsoft-samplemodule',
|
|
'name' => 'Sample Module',
|
|
'version' => '1.0.0',
|
|
]);
|
|
$this->app->instance(ModuleService::class, $moduleServiceMock);
|
|
|
|
$response = $this->authRequest()->postJson('/api/admin/modules/install-from-github', [
|
|
'github_url' => 'https://github.com/sirsoft/sample-module',
|
|
]);
|
|
|
|
$response->assertStatus(201)
|
|
->assertJson(['success' => true])
|
|
->assertJsonPath('data.identifier', 'sirsoft-samplemodule');
|
|
}
|
|
|
|
/**
|
|
* ModuleService에서 RuntimeException 발생 시 422 반환 (GitHub)
|
|
*/
|
|
public function test_install_from_github_returns_422_on_domain_exception(): void
|
|
{
|
|
$moduleServiceMock = \Mockery::mock(ModuleService::class);
|
|
$moduleServiceMock->shouldReceive('installFromGithub')
|
|
->once()
|
|
->andThrow(new ModuleOperationException('modules.errors.github_repo_not_found'));
|
|
$this->app->instance(ModuleService::class, $moduleServiceMock);
|
|
|
|
$response = $this->authRequest()->postJson('/api/admin/modules/install-from-github', [
|
|
'github_url' => 'https://github.com/sirsoft/sample-module',
|
|
]);
|
|
|
|
$response->assertStatus(422);
|
|
$response->assertJsonPath('message', __('modules.errors.github_repo_not_found'));
|
|
}
|
|
|
|
/**
|
|
* ModuleService에서 일반 Exception 발생 시 500 반환 (GitHub)
|
|
*/
|
|
public function test_install_from_github_returns_500_on_general_exception(): void
|
|
{
|
|
$moduleServiceMock = \Mockery::mock(ModuleService::class);
|
|
$moduleServiceMock->shouldReceive('installFromGithub')
|
|
->once()
|
|
->andThrow(new \Exception('예상치 못한 오류'));
|
|
$this->app->instance(ModuleService::class, $moduleServiceMock);
|
|
|
|
$response = $this->authRequest()->postJson('/api/admin/modules/install-from-github', [
|
|
'github_url' => 'https://github.com/sirsoft/sample-module',
|
|
]);
|
|
|
|
$response->assertStatus(500);
|
|
}
|
|
|
|
// ========================================================================
|
|
// 권한 테스트
|
|
// ========================================================================
|
|
|
|
/**
|
|
* create 권한 없는 관리자가 모듈 설치 시도 시 403 반환
|
|
*
|
|
* 이 테스트는 setUp에서 생성된 admin 역할에 core.modules.create 권한이 이미 연결되어 있으므로,
|
|
* admin 역할에서 create 권한을 분리하여 테스트합니다.
|
|
*/
|
|
public function test_install_returns_403_without_create_permission(): void
|
|
{
|
|
// 새 사용자 생성
|
|
$user = User::factory()->create();
|
|
|
|
// admin 역할 가져오기 (setUp에서 이미 생성됨)
|
|
$adminRole = Role::where('identifier', 'admin')->first();
|
|
|
|
// admin 역할에서 create 권한 분리 (테스트용)
|
|
$createPermission = Permission::where('identifier', 'core.modules.create')->first();
|
|
if ($adminRole && $createPermission) {
|
|
$adminRole->permissions()->detach($createPermission->id);
|
|
}
|
|
|
|
// 사용자에게 admin 역할 할당
|
|
$user->roles()->attach($adminRole->id, [
|
|
'assigned_at' => now(),
|
|
'assigned_by' => null,
|
|
]);
|
|
|
|
$token = $user->createToken('test-token')->plainTextToken;
|
|
|
|
Storage::fake('local');
|
|
// 유효한 ZIP 파일 생성 (mimes:zip validation 통과)
|
|
$file = UploadedFile::fake()->create('module.zip', 100, 'application/zip');
|
|
|
|
$response = $this->withHeaders([
|
|
'Authorization' => 'Bearer '.$token,
|
|
'Accept' => 'application/json',
|
|
])->postJson('/api/admin/modules/install-from-file', [
|
|
'file' => $file,
|
|
]);
|
|
|
|
$response->assertStatus(403);
|
|
}
|
|
|
|
// ========================================================================
|
|
// 활성화 의존성 체크 테스트
|
|
// ========================================================================
|
|
|
|
/**
|
|
* 모듈 활성화 시 필요한 의존성이 충족되지 않으면 409 경고 응답
|
|
*/
|
|
public function test_activate_returns_409_when_dependencies_not_met(): void
|
|
{
|
|
// Arrange: ModuleService를 Mock
|
|
$moduleServiceMock = \Mockery::mock(ModuleService::class);
|
|
$moduleServiceMock->shouldReceive('activateModule')
|
|
->with('test-module', false)
|
|
->andReturn([
|
|
'success' => false,
|
|
'warning' => true,
|
|
'missing_modules' => [
|
|
['identifier' => 'sirsoft-core', 'name' => 'Core Module', 'status' => 'inactive'],
|
|
],
|
|
'missing_plugins' => [
|
|
['identifier' => 'sirsoft-payment', 'name' => 'Payment Plugin', 'status' => 'not_installed'],
|
|
],
|
|
'message' => '이 모듈을 활성화하려면 필요한 의존성이 충족되어야 합니다.',
|
|
]);
|
|
$this->app->instance(ModuleService::class, $moduleServiceMock);
|
|
|
|
// Act
|
|
$response = $this->authRequest()->postJson('/api/admin/modules/activate', [
|
|
'module_name' => 'test-module',
|
|
]);
|
|
|
|
// Assert
|
|
$response->assertStatus(409)
|
|
->assertJsonStructure([
|
|
'success',
|
|
'message',
|
|
'errors' => [
|
|
'warning',
|
|
'missing_modules',
|
|
'missing_plugins',
|
|
],
|
|
])
|
|
->assertJsonFragment(['warning' => true]);
|
|
|
|
// missing_modules 확인
|
|
$this->assertNotEmpty($response->json('errors.missing_modules'));
|
|
$this->assertEquals('sirsoft-core', $response->json('errors.missing_modules.0.identifier'));
|
|
|
|
// missing_plugins 확인
|
|
$this->assertNotEmpty($response->json('errors.missing_plugins'));
|
|
$this->assertEquals('sirsoft-payment', $response->json('errors.missing_plugins.0.identifier'));
|
|
}
|
|
|
|
/**
|
|
* 모듈 활성화 시 force=true로 의존성 경고 무시하고 강제 활성화
|
|
*/
|
|
public function test_activate_with_force_bypasses_dependency_check(): void
|
|
{
|
|
// Arrange: ModuleService를 Mock
|
|
$moduleServiceMock = \Mockery::mock(ModuleService::class);
|
|
$moduleServiceMock->shouldReceive('activateModule')
|
|
->with('test-module', true)
|
|
->andReturn([
|
|
'success' => true,
|
|
'module_info' => [
|
|
'identifier' => 'test-module',
|
|
'name' => 'Test Module',
|
|
'status' => 'active',
|
|
],
|
|
]);
|
|
$this->app->instance(ModuleService::class, $moduleServiceMock);
|
|
|
|
// Act
|
|
$response = $this->authRequest()->postJson('/api/admin/modules/activate', [
|
|
'module_name' => 'test-module',
|
|
'force' => true,
|
|
]);
|
|
|
|
// Assert
|
|
$response->assertStatus(200)
|
|
->assertJsonFragment(['success' => true]);
|
|
}
|
|
|
|
/**
|
|
* 모듈 비활성화 시 의존하는 확장이 있으면 409 경고 응답
|
|
*/
|
|
public function test_deactivate_returns_409_when_dependents_exist(): void
|
|
{
|
|
// Arrange: ModuleService를 Mock
|
|
$moduleServiceMock = \Mockery::mock(ModuleService::class);
|
|
$moduleServiceMock->shouldReceive('deactivateModule')
|
|
->with('test-module', false)
|
|
->andReturn([
|
|
'success' => false,
|
|
'warning' => true,
|
|
'dependent_templates' => ['sirsoft-admin_basic'],
|
|
'dependent_modules' => [
|
|
['identifier' => 'sirsoft-ecommerce', 'name' => 'Ecommerce Module'],
|
|
],
|
|
'dependent_plugins' => [],
|
|
'message' => '이 모듈에 의존하는 활성화된 확장이 있습니다.',
|
|
]);
|
|
$this->app->instance(ModuleService::class, $moduleServiceMock);
|
|
|
|
// Act
|
|
$response = $this->authRequest()->postJson('/api/admin/modules/deactivate', [
|
|
'module_name' => 'test-module',
|
|
]);
|
|
|
|
// Assert
|
|
$response->assertStatus(409)
|
|
->assertJsonStructure([
|
|
'success',
|
|
'message',
|
|
'errors' => [
|
|
'warning',
|
|
'dependent_templates',
|
|
'dependent_modules',
|
|
],
|
|
])
|
|
->assertJsonFragment(['warning' => true]);
|
|
}
|
|
|
|
/**
|
|
* 모듈 비활성화 시 force=true로 의존성 경고 무시하고 강제 비활성화
|
|
*/
|
|
public function test_deactivate_with_force_bypasses_dependent_check(): void
|
|
{
|
|
// Arrange: ModuleService를 Mock
|
|
$moduleServiceMock = \Mockery::mock(ModuleService::class);
|
|
$moduleServiceMock->shouldReceive('deactivateModule')
|
|
->with('test-module', true)
|
|
->andReturn([
|
|
'success' => true,
|
|
'module_info' => [
|
|
'identifier' => 'test-module',
|
|
'name' => 'Test Module',
|
|
'status' => 'inactive',
|
|
],
|
|
]);
|
|
$this->app->instance(ModuleService::class, $moduleServiceMock);
|
|
|
|
// Act
|
|
$response = $this->authRequest()->postJson('/api/admin/modules/deactivate', [
|
|
'module_name' => 'test-module',
|
|
'force' => true,
|
|
]);
|
|
|
|
// Assert
|
|
$response->assertStatus(200)
|
|
->assertJsonFragment(['success' => true]);
|
|
}
|
|
|
|
// ========================================================================
|
|
// 에셋 정보 포함 테스트
|
|
// ========================================================================
|
|
|
|
/**
|
|
* 모듈 활성화 응답에 assets 정보가 포함됨
|
|
*/
|
|
public function test_activate_response_includes_assets_when_module_has_assets(): void
|
|
{
|
|
// Arrange: ModuleService를 Mock
|
|
$moduleServiceMock = \Mockery::mock(ModuleService::class);
|
|
$moduleServiceMock->shouldReceive('activateModule')
|
|
->with('test-module', false)
|
|
->andReturn([
|
|
'success' => true,
|
|
'module_info' => [
|
|
'identifier' => 'test-module',
|
|
'name' => 'Test Module',
|
|
'status' => 'active',
|
|
'assets' => [
|
|
'js' => '/api/modules/assets/test-module/dist/js/module.iife.js',
|
|
'css' => '/api/modules/assets/test-module/dist/css/module.css',
|
|
'priority' => 100,
|
|
],
|
|
],
|
|
]);
|
|
$this->app->instance(ModuleService::class, $moduleServiceMock);
|
|
|
|
// Act
|
|
$response = $this->authRequest()->postJson('/api/admin/modules/activate', [
|
|
'module_name' => 'test-module',
|
|
]);
|
|
|
|
// Assert
|
|
// 활성화 응답은 data.module 으로 ModuleResource 를 래핑하고, pending_language_packs 를 동시에 노출
|
|
$response->assertStatus(200)
|
|
->assertJsonPath('data.module.assets.js', '/api/modules/assets/test-module/dist/js/module.iife.js')
|
|
->assertJsonPath('data.module.assets.css', '/api/modules/assets/test-module/dist/css/module.css')
|
|
->assertJsonPath('data.module.assets.priority', 100);
|
|
}
|
|
|
|
/**
|
|
* 모듈 비활성화 응답에 assets 정보가 포함됨
|
|
*/
|
|
public function test_deactivate_response_includes_assets_when_module_has_assets(): void
|
|
{
|
|
// Arrange: ModuleService를 Mock
|
|
$moduleServiceMock = \Mockery::mock(ModuleService::class);
|
|
$moduleServiceMock->shouldReceive('deactivateModule')
|
|
->with('test-module', false)
|
|
->andReturn([
|
|
'success' => true,
|
|
'module_info' => [
|
|
'identifier' => 'test-module',
|
|
'name' => 'Test Module',
|
|
'status' => 'inactive',
|
|
'assets' => [
|
|
'js' => '/api/modules/assets/test-module/dist/js/module.iife.js',
|
|
'css' => '/api/modules/assets/test-module/dist/css/module.css',
|
|
'priority' => 100,
|
|
],
|
|
],
|
|
]);
|
|
$this->app->instance(ModuleService::class, $moduleServiceMock);
|
|
|
|
// Act
|
|
$response = $this->authRequest()->postJson('/api/admin/modules/deactivate', [
|
|
'module_name' => 'test-module',
|
|
]);
|
|
|
|
// Assert
|
|
$response->assertStatus(200)
|
|
->assertJsonPath('data.assets.js', '/api/modules/assets/test-module/dist/js/module.iife.js')
|
|
->assertJsonPath('data.assets.css', '/api/modules/assets/test-module/dist/css/module.css')
|
|
->assertJsonPath('data.assets.priority', 100);
|
|
}
|
|
|
|
// ========================================================================
|
|
// Changelog 조회 테스트
|
|
// ========================================================================
|
|
|
|
/**
|
|
* CHANGELOG.md가 있는 모듈의 changelog를 조회합니다.
|
|
*/
|
|
public function test_changelog_returns_parsed_entries(): void
|
|
{
|
|
// Arrange: 테스트용 CHANGELOG.md 생성
|
|
$modulePath = base_path('modules/test-changelog-mod');
|
|
File::ensureDirectoryExists($modulePath);
|
|
File::put($modulePath.'/CHANGELOG.md', "# Changelog\n\n## [0.1.1] - 2026-02-25\n\n### Added\n- 새 기능\n\n## [0.1.0] - 2026-02-20\n\n### Added\n- 초기 릴리스\n");
|
|
|
|
try {
|
|
$response = $this->authRequest()->getJson('/api/admin/modules/test-changelog-mod/changelog');
|
|
|
|
$response->assertStatus(200)
|
|
->assertJsonPath('data.changelog.0.version', '0.1.1')
|
|
->assertJsonPath('data.changelog.0.categories.0.name', 'Added')
|
|
->assertJsonPath('data.changelog.1.version', '0.1.0');
|
|
} finally {
|
|
File::deleteDirectory($modulePath);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* CHANGELOG.md가 없는 모듈은 빈 배열을 반환합니다.
|
|
*/
|
|
public function test_changelog_returns_empty_for_missing_file(): void
|
|
{
|
|
$response = $this->authRequest()->getJson('/api/admin/modules/nonexistent-module/changelog');
|
|
|
|
$response->assertStatus(200)
|
|
->assertJsonPath('data.changelog', []);
|
|
}
|
|
|
|
/**
|
|
* 버전 범위 필터링이 동작합니다.
|
|
*/
|
|
public function test_changelog_with_version_range(): void
|
|
{
|
|
$modulePath = base_path('modules/test-changelog-range');
|
|
File::ensureDirectoryExists($modulePath);
|
|
File::put($modulePath.'/CHANGELOG.md', "# Changelog\n\n## [0.1.2] - 2026-02-28\n\n### Added\n- 기능 C\n\n## [0.1.1] - 2026-02-25\n\n### Added\n- 기능 B\n\n## [0.1.0] - 2026-02-20\n\n### Added\n- 초기 릴리스\n");
|
|
|
|
try {
|
|
$response = $this->authRequest()->getJson('/api/admin/modules/test-changelog-range/changelog?from_version=0.1.0&to_version=0.1.2');
|
|
|
|
$response->assertStatus(200);
|
|
$changelog = $response->json('data.changelog');
|
|
$this->assertCount(2, $changelog);
|
|
$this->assertSame('0.1.2', $changelog[0]['version']);
|
|
$this->assertSame('0.1.1', $changelog[1]['version']);
|
|
} finally {
|
|
File::deleteDirectory($modulePath);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 인증 없이 changelog 조회 시 401 반환
|
|
*/
|
|
public function test_changelog_returns_401_without_authentication(): void
|
|
{
|
|
$response = $this->getJson('/api/admin/modules/test-module/changelog');
|
|
$response->assertStatus(401);
|
|
}
|
|
|
|
// ========================================================================
|
|
// 라이선스 테스트
|
|
// ========================================================================
|
|
|
|
/**
|
|
* LICENSE 파일이 있는 모듈의 라이선스를 반환합니다.
|
|
*/
|
|
public function test_license_returns_content(): void
|
|
{
|
|
$modulePath = base_path('modules/test-license-mod');
|
|
File::ensureDirectoryExists($modulePath);
|
|
File::put($modulePath.'/LICENSE', 'MIT License - Test');
|
|
|
|
try {
|
|
$response = $this->authRequest()->getJson('/api/admin/modules/test-license-mod/license');
|
|
|
|
$response->assertStatus(200)
|
|
->assertJsonPath('data.content', 'MIT License - Test');
|
|
} finally {
|
|
File::deleteDirectory($modulePath);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* LICENSE 파일이 없는 모듈은 404를 반환합니다.
|
|
*/
|
|
public function test_license_returns_404_for_missing_file(): void
|
|
{
|
|
$response = $this->authRequest()->getJson('/api/admin/modules/nonexistent-module/license');
|
|
|
|
$response->assertStatus(404);
|
|
}
|
|
|
|
/**
|
|
* _bundled 디렉토리의 LICENSE 파일도 반환합니다.
|
|
*/
|
|
public function test_license_falls_back_to_bundled(): void
|
|
{
|
|
// sirsoft-board는 _bundled에 있으므로 활성 디렉토리에 없을 수 있지만 _bundled에서 찾음
|
|
$bundledPath = base_path('modules/_bundled/sirsoft-board/LICENSE');
|
|
if (file_exists($bundledPath)) {
|
|
$response = $this->authRequest()->getJson('/api/admin/modules/sirsoft-board/license');
|
|
$response->assertStatus(200)
|
|
->assertJsonStructure(['data' => ['content']]);
|
|
} else {
|
|
$this->markTestSkipped('sirsoft-board LICENSE not found in _bundled');
|
|
}
|
|
}
|
|
|
|
// ========================================================================
|
|
// 식별자 형식 검증 테스트
|
|
// ========================================================================
|
|
|
|
/**
|
|
* 잘못된 형식의 모듈 식별자로 설치 요청 시 422 반환
|
|
*/
|
|
public function test_install_returns_422_for_invalid_identifier_format(): void
|
|
{
|
|
$response = $this->authRequest()->postJson('/api/admin/modules/install', [
|
|
'module_name' => 'sirsoftboard', // 하이픈 없음
|
|
]);
|
|
|
|
$response->assertStatus(422)
|
|
->assertJsonValidationErrors(['module_name']);
|
|
}
|
|
|
|
/**
|
|
* 숫자로 시작하는 단어가 포함된 모듈 식별자로 설치 요청 시 422 반환
|
|
*/
|
|
public function test_install_returns_422_for_digit_starting_identifier(): void
|
|
{
|
|
$response = $this->authRequest()->postJson('/api/admin/modules/install', [
|
|
'module_name' => 'sirsoft-2shop',
|
|
]);
|
|
|
|
$response->assertStatus(422)
|
|
->assertJsonValidationErrors(['module_name']);
|
|
}
|
|
|
|
/**
|
|
* 대문자가 포함된 모듈 식별자로 설치 요청 시 422 반환
|
|
*/
|
|
public function test_install_returns_422_for_uppercase_identifier(): void
|
|
{
|
|
$response = $this->authRequest()->postJson('/api/admin/modules/install', [
|
|
'module_name' => 'Sirsoft-Board',
|
|
]);
|
|
|
|
$response->assertStatus(422)
|
|
->assertJsonValidationErrors(['module_name']);
|
|
}
|
|
|
|
/**
|
|
* 특수문자가 포함된 모듈 식별자로 설치 요청 시 422 반환
|
|
*/
|
|
public function test_install_returns_422_for_special_char_identifier(): void
|
|
{
|
|
$response = $this->authRequest()->postJson('/api/admin/modules/install', [
|
|
'module_name' => 'sirsoft-my@module',
|
|
]);
|
|
|
|
$response->assertStatus(422)
|
|
->assertJsonValidationErrors(['module_name']);
|
|
}
|
|
}
|