인터뷰에서 방향만 정하고 남겨 뒀던 후속 5건을 구현하고, 커밋 전 종합 점검에서 발견한 결함 2건을 함께 고쳤다. 기능 도입: - 업로드 이미지 축소 — `upload.image_max_width` / `image_max_height` / `image_quality` 는 저장·검증만 되고 리사이즈 코드가 0건이었다. 비율 유지, 상한 이내면 재인코딩하지 않음(불필요한 화질 손실 방지), 한계 미설정이면 무동작(기존 사이트 이미지 보호). PNG·GIF·WebP 투명도 보존. 실패해도 업로드는 계속되고 원본이 저장된다. - 로그인 2단계 인증 — `security.two_factor_auth` 는 설정 항목만 있고 구현도 화면 컨트롤도 없었다. 코어 IDV 인프라를 재사용해 purpose `login` 을 추가하고, 비밀번호 확인 통과 후 challenge 만 발행한다(토큰 미발급 + logout). `POST /api/auth/login/two-factor` 가 코드를 확인해야 토큰이 나가며, 로그인과 같은 요청 제한을 적용한다. challenge 의 purpose 를 대조해 다른 흐름의 challenge 로 로그인하는 것을 막는다. 코드 발송이 실패하면 실패로 알린다 — 2단계를 건너뛰고 로그인시키면 통제가 조용히 열린다. 결함 수정: - 게시글 수정 시 사용자 경로가 `attachment_ids` 를 Service 로 넘기지 않아, 검증과 권한은 통과하고 200 을 받는데 첨부만 조용히 연결되지 않았다. 관리자 경로는 넘기고 있어 같은 요청이 화면에 따라 다르게 동작했다. - 설정 화면 라디오가 `pointer-events-none` + Label click 으로만 상태를 바꿔, 키보드 방향키(change) 조작 시 화면 표시와 저장될 값이 어긋났다. 동일 패턴 4개 레이아웃 12개 라디오에 change 액션을 부여하고 params 를 click 과 동일하게 고정한다. - 주문완료 화면이 결제수단 확인보다 영수증 조회를 앞세워, PG 를 거치지 않는 주문에서도 매번 404 요청이 나갔다. - 개별 게시판 폼의 숫자 항목에 경계값 바인딩이 없어 환경설정 화면과 안내가 달랐다. 커밋 전 점검에서 발견: - 2단계 인증이 반쪽으로 들어가 있었다. enum 에 case 만 추가하고 코어 purpose 레지스트리와 ko/en 라벨을 빠뜨려, 목적 목록 API 에서 통째로 빠지고 `hasPurpose` 가 false 였으며 라벨 대신 i18n 키 원문이 반환됐다. 관리자가 이 목적의 메시지 템플릿·정책을 만들 수 없는 상태였다. 세 곳을 enum 기준으로 전수 대조하는 회귀 테스트를 함께 둔다. - 업로드 이미지 축소가 적용되지 않은 8번째 서비스(레이아웃 편집기 첨부)가 있었다. 열거형 커버리지 테스트는 내 조사 결과를 green 으로 고정할 뿐 전수를 증명하지 못한다 — 같은 규약을 조건으로 표현한 audit 룰이 첫 실행에서 검출했다. 재발 방지: - audit 룰 `upload-service-image-resize` (신규 업로드 서비스의 축소 누락 검출) - 트러블슈팅 사례 20 (코어 설정이 일부 경로에만 적용되는 증상의 진단 절차) - `sirsoft-ckeditor5` 의 코어 최소 요구 버전을 7.0.6 으로 상향 (신설 클래스 의존) 공개 이슈: gnuboard/g7
147 lines
4.7 KiB
PHP
147 lines
4.7 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit\Support;
|
|
|
|
use App\Services\AttachmentService;
|
|
use App\Support\ImageResizer;
|
|
use Illuminate\Http\UploadedFile;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* 업로드 이미지 리사이즈 테스트
|
|
*
|
|
* 환경설정 > 업로드의 최대 가로/세로 크기와 품질은 저장·검증만 되고 실제로 이미지를 줄이는
|
|
* 코드가 없었습니다. 관리자는 값을 바꾸면 적용된다고 인지하지만 원본이 그대로 저장됐습니다.
|
|
*/
|
|
class ImageResizerTest extends TestCase
|
|
{
|
|
/**
|
|
* 지정 크기의 임시 PNG 를 만듭니다.
|
|
*
|
|
* @param int $width 가로 픽셀
|
|
* @param int $height 세로 픽셀
|
|
* @return string 생성된 파일 경로
|
|
*/
|
|
private function makeImage(int $width, int $height): string
|
|
{
|
|
$path = tempnam(sys_get_temp_dir(), 'g7img').'.png';
|
|
$image = imagecreatetruecolor($width, $height);
|
|
imagefill($image, 0, 0, imagecolorallocate($image, 10, 120, 200));
|
|
imagepng($image, $path);
|
|
imagedestroy($image);
|
|
|
|
return $path;
|
|
}
|
|
|
|
/**
|
|
* 파일의 가로·세로를 반환합니다.
|
|
*
|
|
* @param string $path 이미지 경로
|
|
* @return array{0:int,1:int} [가로, 세로]
|
|
*/
|
|
private function dimensions(string $path): array
|
|
{
|
|
$size = getimagesize($path);
|
|
|
|
return [(int) $size[0], (int) $size[1]];
|
|
}
|
|
|
|
#[Test]
|
|
public function it_shrinks_an_image_that_exceeds_the_configured_bounds(): void
|
|
{
|
|
config(['attachment.image_max_width' => 800, 'attachment.image_max_height' => 600, 'attachment.image_quality' => 80]);
|
|
|
|
$path = $this->makeImage(1600, 1200);
|
|
|
|
$this->assertTrue(app(ImageResizer::class)->resizeInPlace($path, 'image/png'));
|
|
|
|
[$width, $height] = $this->dimensions($path);
|
|
|
|
$this->assertSame(800, $width);
|
|
// 비율 유지 — 1600x1200 을 800 폭에 맞추면 600
|
|
$this->assertSame(600, $height);
|
|
|
|
@unlink($path);
|
|
}
|
|
|
|
#[Test]
|
|
public function it_keeps_aspect_ratio_when_only_one_side_exceeds(): void
|
|
{
|
|
config(['attachment.image_max_width' => 1000, 'attachment.image_max_height' => 1000, 'attachment.image_quality' => 80]);
|
|
|
|
$path = $this->makeImage(2000, 500);
|
|
|
|
$this->assertTrue(app(ImageResizer::class)->resizeInPlace($path, 'image/png'));
|
|
|
|
[$width, $height] = $this->dimensions($path);
|
|
|
|
$this->assertSame(1000, $width);
|
|
$this->assertSame(250, $height);
|
|
|
|
@unlink($path);
|
|
}
|
|
|
|
#[Test]
|
|
public function it_leaves_images_within_bounds_untouched(): void
|
|
{
|
|
config(['attachment.image_max_width' => 1000, 'attachment.image_max_height' => 1000]);
|
|
|
|
$path = $this->makeImage(400, 300);
|
|
$before = md5_file($path);
|
|
|
|
$this->assertFalse(
|
|
app(ImageResizer::class)->resizeInPlace($path, 'image/png'),
|
|
'한계 이내 이미지는 다시 인코딩하지 않아야 합니다 — 불필요한 화질 손실을 만듭니다.'
|
|
);
|
|
$this->assertSame($before, md5_file($path));
|
|
|
|
@unlink($path);
|
|
}
|
|
|
|
#[Test]
|
|
public function it_does_nothing_when_no_limit_is_configured(): void
|
|
{
|
|
config(['attachment.image_max_width' => null, 'attachment.image_max_height' => null]);
|
|
|
|
$path = $this->makeImage(4000, 3000);
|
|
$before = md5_file($path);
|
|
|
|
$this->assertFalse(app(ImageResizer::class)->resizeInPlace($path, 'image/png'));
|
|
$this->assertSame($before, md5_file($path));
|
|
|
|
@unlink($path);
|
|
}
|
|
|
|
#[Test]
|
|
public function it_ignores_non_image_files(): void
|
|
{
|
|
config(['attachment.image_max_width' => 100, 'attachment.image_max_height' => 100]);
|
|
|
|
$path = tempnam(sys_get_temp_dir(), 'g7doc').'.txt';
|
|
file_put_contents($path, str_repeat('a', 500));
|
|
$before = md5_file($path);
|
|
|
|
$this->assertFalse(app(ImageResizer::class)->resizeInPlace($path, 'text/plain'));
|
|
$this->assertSame($before, md5_file($path));
|
|
|
|
@unlink($path);
|
|
}
|
|
|
|
#[Test]
|
|
public function upload_applies_the_configured_bounds(): void
|
|
{
|
|
config(['attachment.image_max_width' => 640, 'attachment.image_max_height' => 640, 'attachment.image_quality' => 75]);
|
|
|
|
$path = $this->makeImage(1280, 960);
|
|
$upload = new UploadedFile($path, 'large.png', 'image/png', null, true);
|
|
|
|
$attachment = app(AttachmentService::class)->upload($upload);
|
|
|
|
$this->assertSame(640, $attachment->meta['width'] ?? null, '저장된 첨부의 가로가 설정 상한을 넘습니다 — 리사이즈가 업로드 경로에 적용되지 않았습니다.');
|
|
$this->assertSame(480, $attachment->meta['height'] ?? null);
|
|
|
|
@unlink($path);
|
|
}
|
|
}
|