관리자 환경설정이 값을 저장은 하지만 실제 동작에는 닿지 않던 결함군을 정리했다. 설정 화면(min/max)·저장 검증(FormRequest)·런타임 적용(Service) 세 계층이 각자 리터럴을 들고 있어 서로 조용히 갈라지던 것이 공통 원인이다. 경계값 SSoT 도입: - config/core.php `settings_limits` 를 단일 출처로 두고, FormRequest 는 이 값으로 규칙을 만들고 화면은 설정 응답의 `_meta.limits` 로 같은 값을 받아 바인딩한다. - 게시판·이커머스도 각 모듈 config 의 limits 를 동일 구조로 노출한다. 적용되지 않던 정책을 실제로 적용: - 비밀번호 최소 길이·특수문자 필수(신규 지정 7개 경로. 로그인은 제외 — 정책을 올렸을 때 기존 회원이 자기 계정에 접근조차 못 하는 데드락을 막기 위함) - 계정 잠금 시간 0 = 무한대(전용 컬럼 분리. timestamp 상한과 NULL 규약 때문에 기존 컬럼 재사용이 불가능하다) + 관리자 수동 해제 경로 - 업로드 최대 크기·허용 확장자, 게시판 첨부 개수·댓글 깊이, 페이지 첨부 정책, 장바구니 수량·구매 한도, 마일리지 사용 한도, 주문 부가세 파생 결함: - 큐 Job(sync 드라이버)이 진행 중인 요청의 Request 바인딩을 덮어써, 뒤이어 실행되는 훅 리스너가 헤더를 잃고 조용히 건너뛰었다. Job 이 원 바인딩을 복원하도록 고치고, 전송 헤더가 필요한 리스너는 동기 실행으로 선언한다. - 확장 업데이트 실패 시 상태만 되돌리고 상태 캐시를 비우지 않아, 그 확장의 관리자 화면만 캐시 TTL(기본 하루) 동안 404 로 남았다. 실패 경로에도 무효화를 대칭으로 두고, 빈 목록 자체를 캐시하지 않는다. - 글 작성 시 첨부 파일이 Service 로 전달되지 않아 검증·권한은 통과하고 첨부만 사라졌다. - 환경설정 저장 실패 안내가 내부 설정 키를 그대로 노출했다(105개 항목에 라벨 부여). 재발 방지: - audit 룰 3종(레이아웃 경계 리터럴 / FormRequest 경계 불일치 / Service 재클램프) - 테스트 실행 범위 강제 훅(check-test-scope) - 트러블슈팅 사례 2건(큐 Job 요청 오염 / 확장 상태 캐시 고착) 공개 이슈: gnuboard/g7
257 lines
8.8 KiB
PHP
257 lines
8.8 KiB
PHP
<?php
|
|
|
|
namespace Modules\Sirsoft\Page\Tests\Feature;
|
|
|
|
// FeatureTestCase를 수동으로 require (autoload 전에 로드 필요)
|
|
require_once __DIR__.'/../FeatureTestCase.php';
|
|
|
|
use App\Contracts\Extension\StorageInterface;
|
|
use App\Models\User;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Mockery;
|
|
use Modules\Sirsoft\Page\Models\Page;
|
|
use Modules\Sirsoft\Page\Models\PageAttachment;
|
|
use Modules\Sirsoft\Page\Tests\FeatureTestCase;
|
|
|
|
/**
|
|
* 페이지 첨부 정책의 HTTP 왕복 강제 테스트 (E3)
|
|
*
|
|
* `PageAttachmentPolicyTest` 는 규칙 문자열과 Service 불변조건을 정적으로 고정한다.
|
|
* 그러나 그것만으로는 (a) `UploadPageAttachmentRequest` 가 실제 라우트에 결선되어 있는지,
|
|
* (b) Service 예외가 컨트롤러에서 422 로 매핑되는지가 검증되지 않는다.
|
|
* 규칙이 올바르게 생성돼도 라우트에 물려 있지 않으면 업로드는 그대로 통과한다.
|
|
*
|
|
* 이 클래스는 실제 요청을 보내 응답 코드까지 확인한다.
|
|
*
|
|
* 주의: 용량·형식 판정은 반드시 기본값과 다른 설정값을 주입한다 —
|
|
* 수정 전 하드코딩(10240KB / mimes 16종)과 우연히 일치하면 red 가 확보되지 않는다.
|
|
*/
|
|
class PageAttachmentPolicyHttpTest extends FeatureTestCase
|
|
{
|
|
private const ENDPOINT = '/api/modules/sirsoft-page/admin/attachments';
|
|
|
|
protected User $adminUser;
|
|
|
|
/**
|
|
* 테스트 환경 설정
|
|
*/
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->adminUser = $this->createAdminUser([
|
|
'sirsoft-page.pages.read',
|
|
'sirsoft-page.pages.create',
|
|
'sirsoft-page.pages.update',
|
|
'sirsoft-page.pages.delete',
|
|
]);
|
|
|
|
// StorageInterface 를 모킹 (파일 시스템 의존 제거)
|
|
$storageMock = Mockery::mock(StorageInterface::class);
|
|
$storageMock->shouldReceive('put')->andReturn(true);
|
|
$storageMock->shouldReceive('get')->andReturn('file content');
|
|
$storageMock->shouldReceive('exists')->andReturn(true);
|
|
$storageMock->shouldReceive('delete')->andReturn(true);
|
|
$storageMock->shouldReceive('deleteDirectory')->andReturn(true);
|
|
$storageMock->shouldReceive('getDisk')->andReturn('local');
|
|
$storageMock->shouldReceive('url')->andReturn('/storage/test.pdf');
|
|
$storageMock->shouldReceive('response')->andReturn(null);
|
|
$this->app->instance(StorageInterface::class, $storageMock);
|
|
}
|
|
|
|
/**
|
|
* 테스트 정리
|
|
*/
|
|
protected function tearDown(): void
|
|
{
|
|
Page::where('slug', 'like', 'policy-http-%')->forceDelete();
|
|
Mockery::close();
|
|
parent::tearDown();
|
|
}
|
|
|
|
/**
|
|
* 첨부 설정을 주입합니다.
|
|
*
|
|
* @param array $overrides 덮어쓸 설정 값
|
|
*/
|
|
private function writeAttachmentSettings(array $overrides = []): void
|
|
{
|
|
config(['g7_settings.modules.sirsoft-page.attachment' => array_merge([
|
|
'max_count' => 5,
|
|
'max_size_mb' => 10,
|
|
'allowed_types' => ['image/jpeg', 'image/png', 'application/pdf'],
|
|
], $overrides)]);
|
|
}
|
|
|
|
/**
|
|
* 테스트용 페이지를 만듭니다.
|
|
*
|
|
* @param string $slug 페이지 슬러그
|
|
* @return Page 생성된 페이지
|
|
*/
|
|
private function makePage(string $slug): Page
|
|
{
|
|
return Page::factory()->create([
|
|
'slug' => $slug,
|
|
'created_by' => $this->adminUser->id,
|
|
'updated_by' => $this->adminUser->id,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 허용 목록에 없는 형식(docx)은 422 로 거부된다
|
|
*
|
|
* 수정 전에는 `mimes:` 16종 하드코딩이 설정과 무관하게 docx 를 통과시켰다.
|
|
*/
|
|
public function test_disallowed_mime_type_is_rejected_over_http(): void
|
|
{
|
|
$this->writeAttachmentSettings([
|
|
'allowed_types' => ['image/png', 'application/pdf'],
|
|
]);
|
|
|
|
$page = $this->makePage('policy-http-mime');
|
|
|
|
$file = UploadedFile::fake()->create(
|
|
'contract.docx',
|
|
100,
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
|
);
|
|
|
|
$response = $this->actingAs($this->adminUser)
|
|
->postJson(self::ENDPOINT, [
|
|
'file' => $file,
|
|
'page_id' => $page->id,
|
|
]);
|
|
|
|
$response->assertStatus(422)->assertJsonValidationErrors(['file']);
|
|
|
|
$this->assertDatabaseMissing('page_attachments', [
|
|
'page_id' => $page->id,
|
|
'original_filename' => 'contract.docx',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 허용 목록에 있는 형식은 그대로 통과한다 (과잉 차단 방지 대조군)
|
|
*/
|
|
public function test_allowed_mime_type_still_passes_over_http(): void
|
|
{
|
|
$this->writeAttachmentSettings([
|
|
'allowed_types' => ['image/png', 'application/pdf'],
|
|
]);
|
|
|
|
$page = $this->makePage('policy-http-mime-ok');
|
|
|
|
$file = UploadedFile::fake()->create('manual.pdf', 100, 'application/pdf');
|
|
|
|
$this->actingAs($this->adminUser)
|
|
->postJson(self::ENDPOINT, [
|
|
'file' => $file,
|
|
'page_id' => $page->id,
|
|
])
|
|
->assertStatus(201);
|
|
|
|
$this->assertDatabaseHas('page_attachments', [
|
|
'page_id' => $page->id,
|
|
'original_filename' => 'manual.pdf',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 설정한 최대 용량을 넘는 파일은 422 로 거부된다
|
|
*
|
|
* 설정을 5MB 로 낮춘 뒤 6MB 를 올린다 — 기본값(10MB) 상태였다면 통과했을 크기다.
|
|
*/
|
|
public function test_file_over_configured_max_size_is_rejected_over_http(): void
|
|
{
|
|
$this->writeAttachmentSettings(['max_size_mb' => 5]);
|
|
|
|
$page = $this->makePage('policy-http-size');
|
|
|
|
// 6MB — 설정 상한 5MB 초과, 수정 전 하드코딩 10MB 였다면 통과
|
|
$file = UploadedFile::fake()->create('large.pdf', 6 * 1024, 'application/pdf');
|
|
|
|
$response = $this->actingAs($this->adminUser)
|
|
->postJson(self::ENDPOINT, [
|
|
'file' => $file,
|
|
'page_id' => $page->id,
|
|
]);
|
|
|
|
$response->assertStatus(422)->assertJsonValidationErrors(['file']);
|
|
}
|
|
|
|
/**
|
|
* 상한 이내 용량은 통과한다 (경계 대조군)
|
|
*/
|
|
public function test_file_within_configured_max_size_passes_over_http(): void
|
|
{
|
|
$this->writeAttachmentSettings(['max_size_mb' => 5]);
|
|
|
|
$page = $this->makePage('policy-http-size-ok');
|
|
|
|
$file = UploadedFile::fake()->create('small.pdf', 4 * 1024, 'application/pdf');
|
|
|
|
$this->actingAs($this->adminUser)
|
|
->postJson(self::ENDPOINT, [
|
|
'file' => $file,
|
|
'page_id' => $page->id,
|
|
])
|
|
->assertStatus(201);
|
|
}
|
|
|
|
/**
|
|
* 개수 상한을 넘는 6번째 업로드는 422 + 코드로 차단된다
|
|
*
|
|
* Service 예외가 컨트롤러에서 422 로 매핑되는지까지 확인한다
|
|
* (매핑이 없으면 generic 500 으로 떨어진다).
|
|
*/
|
|
public function test_upload_beyond_max_count_returns_422_with_code(): void
|
|
{
|
|
$this->writeAttachmentSettings(['max_count' => 5]);
|
|
|
|
$page = $this->makePage('policy-http-count');
|
|
|
|
for ($i = 0; $i < 5; $i++) {
|
|
$this->actingAs($this->adminUser)
|
|
->postJson(self::ENDPOINT, [
|
|
'file' => UploadedFile::fake()->create("doc-{$i}.pdf", 10, 'application/pdf'),
|
|
'page_id' => $page->id,
|
|
])
|
|
->assertStatus(201);
|
|
}
|
|
|
|
$response = $this->actingAs($this->adminUser)
|
|
->postJson(self::ENDPOINT, [
|
|
'file' => UploadedFile::fake()->create('doc-6.pdf', 10, 'application/pdf'),
|
|
'page_id' => $page->id,
|
|
]);
|
|
|
|
$response->assertStatus(422)
|
|
->assertJsonPath('errors.code', 'attachment_limit_exceeded');
|
|
|
|
// 6번째는 저장되지 않았다 — "차단은 했는데 이미 저장됨" 반쪽 통과 배제
|
|
$this->assertSame(5, PageAttachment::where('page_id', $page->id)->count());
|
|
}
|
|
|
|
/**
|
|
* 상한 미설정(0)이면 개수를 제한하지 않는다 (기존 동작 보존)
|
|
*/
|
|
public function test_zero_max_count_is_unrestricted_over_http(): void
|
|
{
|
|
$this->writeAttachmentSettings(['max_count' => 0]);
|
|
|
|
$page = $this->makePage('policy-http-count-zero');
|
|
|
|
for ($i = 0; $i < 6; $i++) {
|
|
$this->actingAs($this->adminUser)
|
|
->postJson(self::ENDPOINT, [
|
|
'file' => UploadedFile::fake()->create("free-{$i}.pdf", 10, 'application/pdf'),
|
|
'page_id' => $page->id,
|
|
])
|
|
->assertStatus(201);
|
|
}
|
|
|
|
$this->assertSame(6, PageAttachment::where('page_id', $page->id)->count());
|
|
}
|
|
}
|