Files
HeuJung 7b56d5f23a feat(core,board,ecommerce,page,ckeditor5,kginicis): 업로드 이미지 축소·로그인 2단계 인증 도입 + 파생 결함 수정
인터뷰에서 방향만 정하고 남겨 뒀던 후속 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
2026-07-30 07:57:06 +09:00

523 lines
20 KiB
PHP

<?php
namespace Modules\Sirsoft\Board\Tests\Feature;
require_once __DIR__.'/../ModuleTestCase.php';
use App\Models\Role;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Mockery;
use Modules\Sirsoft\Board\Exceptions\AttachmentLimitExceededException;
use Modules\Sirsoft\Board\Models\Attachment;
use Modules\Sirsoft\Board\Models\Post;
use Modules\Sirsoft\Board\Services\AttachmentService;
use Modules\Sirsoft\Board\Services\PostService;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
/**
* 첨부파일 개수 상한 서버측 불변조건 테스트 (B3)
*
* 수정 전에는 `files[]` 직접 업로드만 FormRequest 에서 막고, `attachment_ids` · `temp_key` ·
* 단일 업로드 반복 경로에는 개수 검사가 없었습니다. 같은 8개 파일을 `temp_key` 로 보내면
* 201 로 전부 연결되어 사용자 페이지에 그대로 노출됐습니다.
*
* @group board
* @group attachment
*/
class AttachmentLimitTest extends BoardTestCase
{
private User $memberUser;
protected function getTestBoardSlug(): string
{
return 'attachment-limit';
}
protected function getDefaultBoardAttributes(string $slug): array
{
return [
'slug' => $slug,
'name' => ['ko' => '첨부 상한 게시판', 'en' => 'Attachment Limit Board'],
'is_active' => true,
'use_file_upload' => true,
'max_file_count' => 5,
];
}
protected function setUp(): void
{
parent::setUp();
$this->setGuestPermissions(['posts.read', 'posts.write']);
$this->grantUserRolePermissions(['posts.read', 'posts.write', 'attachments.upload']);
$this->memberUser = User::factory()->create(['email' => 'attachment-limit@test.com']);
$userRole = Role::where('identifier', 'user')->first();
if ($userRole) {
$this->memberUser->roles()->attach($userRole->id);
}
}
/**
* 임시 첨부(temp_key) 를 N 개 만듭니다.
*
* @param string $tempKey 임시 업로드 키
* @param int $count 생성 개수
*/
private function seedTempAttachments(string $tempKey, int $count): void
{
for ($i = 0; $i < $count; $i++) {
Attachment::create([
'board_id' => 0,
'post_id' => null,
'temp_key' => $tempKey,
'collection' => 'attachments',
'original_filename' => "temp-{$i}.png",
'stored_filename' => "temp-{$i}.png",
'path' => "{$this->getTestBoardSlug()}/temp/{$tempKey}/temp-{$i}.png",
'mime_type' => 'image/png',
'size' => 100,
'order' => $i,
]);
}
}
/**
* 게시글에 연결된 첨부를 N 개 만듭니다.
*
* @param int $postId 게시글 ID
* @param int $count 생성 개수
*/
private function seedLinkedAttachments(int $postId, int $count): void
{
$board = $this->board;
for ($i = 0; $i < $count; $i++) {
Attachment::create([
'board_id' => $board->id,
'post_id' => $postId,
'collection' => 'attachments',
'original_filename' => "linked-{$i}.png",
'stored_filename' => "linked-{$i}.png",
'path' => "{$this->getTestBoardSlug()}/2026/07/27/linked-{$i}.png",
'mime_type' => 'image/png',
'size' => 100,
'order' => $i,
]);
}
}
/**
* temp_key 경로로 상한을 초과하면 차단된다 (실측 재현된 우회 경로).
*/
public function test_temp_key_link_over_limit_is_blocked(): void
{
$postId = $this->createTestPost();
$tempKey = 'tk_'.str_repeat('a', 20);
$this->seedTempAttachments($tempKey, 8);
$this->expectException(AttachmentLimitExceededException::class);
app(AttachmentService::class)->linkTempAttachmentsWithMove(
$this->getTestBoardSlug(),
$tempKey,
$postId
);
}
/**
* 상한과 정확히 같은 개수는 통과한다 (경계).
*/
public function test_temp_key_link_at_limit_passes(): void
{
$postId = $this->createTestPost();
$tempKey = 'tk_'.str_repeat('b', 20);
$this->seedTempAttachments($tempKey, 5);
$linked = app(AttachmentService::class)->linkTempAttachmentsWithMove(
$this->getTestBoardSlug(),
$tempKey,
$postId
);
$this->assertSame(5, $linked);
}
/**
* 기존 첨부와 합산해 상한을 넘으면 차단된다 (수정 경로).
*/
public function test_existing_plus_new_over_limit_is_blocked(): void
{
$postId = $this->createTestPost();
$this->seedLinkedAttachments($postId, 4);
$tempKey = 'tk_'.str_repeat('c', 20);
$this->seedTempAttachments($tempKey, 2);
$this->expectException(AttachmentLimitExceededException::class);
app(AttachmentService::class)->linkTempAttachmentsWithMove(
$this->getTestBoardSlug(),
$tempKey,
$postId
);
}
/**
* 기존 4 + 신규 1 = 5 는 통과한다 (경계).
*/
public function test_existing_plus_new_at_limit_passes(): void
{
$postId = $this->createTestPost();
$this->seedLinkedAttachments($postId, 4);
$tempKey = 'tk_'.str_repeat('d', 20);
$this->seedTempAttachments($tempKey, 1);
$linked = app(AttachmentService::class)->linkTempAttachmentsWithMove(
$this->getTestBoardSlug(),
$tempKey,
$postId
);
$this->assertSame(1, $linked);
}
/**
* attachment_ids 경로도 합산 판정된다.
*/
public function test_attachment_ids_over_limit_is_blocked(): void
{
$postId = $this->createTestPost();
$this->seedLinkedAttachments($postId, 5);
$this->expectException(AttachmentLimitExceededException::class);
app(AttachmentService::class)->assertAttachmentCountWithin(
$this->getTestBoardSlug(),
$postId,
1
);
}
/**
* 관리자 수정 경로에서 상한 초과가 500 이 아닌 422 로 응답되는지 검증합니다.
*
* FormRequest 선차단은 UX 용이고 최종 불변조건은 Service 예외다. 그런데 관리자 수정
* 컨트롤러의 catch 가 게시글 *조회* try 에 붙어 있어, 예외를 실제로 던지는 저장 try 는
* 이 예외를 잡지 못하고 generic 500 으로 떨어졌다. 조회 mock 이 정상 반환하고 저장 mock 이
* 예외를 던지는 구성으로 그 매핑만 격리해 검증한다.
*/
public function test_admin_update_maps_attachment_limit_to_422(): void
{
$slug = $this->getTestBoardSlug();
$admin = $this->createAdminUser([
"sirsoft-board.{$slug}.admin.posts.write",
"sirsoft-board.{$slug}.admin.manage",
]);
$postId = $this->createTestPost(['user_id' => $admin->id]);
$post = Post::find($postId);
$mock = Mockery::mock(PostService::class);
$mock->shouldReceive('getPost')->andReturn($post);
$mock->shouldReceive('updatePost')
->andThrow(new AttachmentLimitExceededException(5, 6));
$this->app->instance(PostService::class, $mock);
$this->actingAs($admin)
->putJson("/api/modules/sirsoft-board/admin/board/{$slug}/posts/{$postId}", [
'title' => '상한 초과 수정 시도',
'content' => '본문은 최소 길이를 넘겨야 검증을 통과합니다.',
])
->assertStatus(422)
->assertJsonPath('errors.code', 'attachment_limit_exceeded');
}
/**
* 직접 업로드와 `attachment_ids` 를 섞어 보내도 합산 판정된다.
*
* 소스가 나뉘면 각 소스는 상한 이하인데 총합만 초과하는 조합이 만들어진다. 어느 한 소스만
* 세는 구현은 이 조합을 그대로 통과시킨다.
*/
public function test_mixed_files_and_attachment_ids_over_limit_is_blocked(): void
{
$postId = $this->createTestPost(['user_id' => $this->memberUser->id]);
// 기존 첨부 3 개 — 이 중 3 개를 attachment_ids 로 유지하면서 새 파일 3 개를 더 보낸다
$this->seedLinkedAttachments($postId, 3);
$keepIds = Attachment::where('post_id', $postId)->pluck('id')->all();
$response = $this->actingAs($this->memberUser)->putJson(
"/api/modules/sirsoft-board/boards/{$this->getTestBoardSlug()}/posts/{$postId}",
[
'title' => '혼합 소스 상한 초과',
'content' => '본문은 최소 길이를 넘겨야 검증을 통과합니다.',
'attachment_ids' => $keepIds,
'files' => [
UploadedFile::fake()->image('mix-1.png'),
UploadedFile::fake()->image('mix-2.png'),
UploadedFile::fake()->image('mix-3.png'),
],
]
);
$response->assertStatus(422);
$this->assertArrayHasKey('files', $response->json('errors') ?? []);
}
/**
* 작성 시 직접 업로드가 상한과 정확히 같은 개수면 통과하고 실제로 저장된다 (경계).
*
* 합산 판정이 들어간 뒤로 `files[]` 경로는 초과 케이스만 고정돼 있었다. 판정식이
* `>=` 로 어긋나거나 기존 첨부 0 건을 1 건으로 세면 상한 동수가 조용히 막히는데,
* 초과 케이스만 보는 테스트는 그 회귀를 통과시킨다. 저장 건수까지 단언해 "차단은
* 안 했지만 첨부도 안 됐다" 는 절반짜리 통과를 배제한다.
*/
public function test_files_at_limit_on_create_passes_and_persists(): void
{
$response = $this->actingAs($this->memberUser, 'sanctum')->postJson(
"/api/modules/sirsoft-board/boards/{$this->getTestBoardSlug()}/posts",
[
'title' => '상한 동수 첨부',
'content' => '본문은 최소 길이를 넘겨야 검증을 통과합니다.',
'files' => [
UploadedFile::fake()->image('at-1.png'),
UploadedFile::fake()->image('at-2.png'),
UploadedFile::fake()->image('at-3.png'),
UploadedFile::fake()->image('at-4.png'),
UploadedFile::fake()->image('at-5.png'),
],
]
);
$response->assertStatus(201);
$postId = (int) $response->json('data.id');
$this->assertSame(5, Attachment::where('post_id', $postId)->count());
}
/**
* 작성 시 상한을 1 건 넘기면 차단된다 (위 경계의 대조군).
*/
public function test_files_over_limit_on_create_is_blocked(): void
{
$response = $this->actingAs($this->memberUser, 'sanctum')->postJson(
"/api/modules/sirsoft-board/boards/{$this->getTestBoardSlug()}/posts",
[
'title' => '상한 초과 첨부',
'content' => '본문은 최소 길이를 넘겨야 검증을 통과합니다.',
'files' => [
UploadedFile::fake()->image('over-1.png'),
UploadedFile::fake()->image('over-2.png'),
UploadedFile::fake()->image('over-3.png'),
UploadedFile::fake()->image('over-4.png'),
UploadedFile::fake()->image('over-5.png'),
UploadedFile::fake()->image('over-6.png'),
],
]
);
$response->assertStatus(422);
$this->assertArrayHasKey('files', $response->json('errors') ?? []);
}
/**
* 단일 업로드를 반복해도 상한을 넘는 순간 차단된다.
*
* 한 번에 여러 개를 보내는 경로만 막으면, 한 개씩 N 번 부르는 경로로 그대로 우회된다.
*/
public function test_repeated_single_upload_is_blocked_at_the_limit(): void
{
$postId = $this->createTestPost();
$service = app(AttachmentService::class);
for ($i = 0; $i < 5; $i++) {
$service->upload(
$this->getTestBoardSlug(),
UploadedFile::fake()->image("single-{$i}.png"),
$postId
);
}
$this->assertSame(5, Attachment::where('post_id', $postId)->count());
$this->expectException(AttachmentLimitExceededException::class);
$service->upload(
$this->getTestBoardSlug(),
UploadedFile::fake()->image('single-6.png'),
$postId
);
}
/**
* 단일 업로드 엔드포인트가 상한 초과를 422 로 돌려주는지 검증합니다.
*
* 이 엔드포인트는 게시글 작성 Request 들과 마찬가지로 요청 계층에서 선차단하고,
* `AttachmentService::upload()` 가 최종 불변조건으로 남는다(훅·Service 직접 호출 방어).
*
* 두 계층이 같은 응답을 내야 소비자가 분기하지 않는다 — 선차단은 필드 검증 오류가 아니라
* `AttachmentLimitExceededException` 을 던지고, 그 `render()` 가 컨트롤러 catch 와
* 동일한 `errors.code=attachment_limit_exceeded` 를 만든다. 이 테스트가 그 동일성을 고정한다.
*/
public function test_single_upload_endpoint_blocks_over_limit_with_422(): void
{
$slug = $this->getTestBoardSlug();
$postId = $this->createTestPost(['user_id' => $this->memberUser->id]);
$this->seedLinkedAttachments($postId, 5);
$this->actingAs($this->memberUser, 'sanctum')
->postJson("/api/modules/sirsoft-board/boards/{$slug}/attachments", [
'file' => UploadedFile::fake()->image('over-limit.png'),
'post_id' => $postId,
])
->assertStatus(422)
->assertJsonPath('errors.code', 'attachment_limit_exceeded');
}
/**
* 단일 업로드 상한 초과는 Service 에 닿기 전 요청 계층에서 차단된다.
*
* `AttachmentService` 를 통째로 mock 해 `upload()` 가 아예 호출되지 않음을 단언한다 —
* Service 가 최종 관문으로 남아 있어도 요청 계층이 먼저 막아야 선차단이라 부를 수 있다.
*/
public function test_single_upload_over_limit_is_blocked_before_reaching_service(): void
{
$slug = $this->getTestBoardSlug();
$postId = $this->createTestPost(['user_id' => $this->memberUser->id]);
$this->seedLinkedAttachments($postId, 5);
$service = Mockery::mock(AttachmentService::class);
$service->shouldNotReceive('upload');
$this->app->instance(AttachmentService::class, $service);
$this->actingAs($this->memberUser, 'sanctum')
->postJson("/api/modules/sirsoft-board/boards/{$slug}/attachments", [
'file' => UploadedFile::fake()->image('blocked-before-service.png'),
'post_id' => $postId,
])
->assertStatus(422)
->assertJsonPath('errors.code', 'attachment_limit_exceeded');
}
/**
* temp_key 임시 업로드도 상한을 넘는 순간 요청 계층에서 차단된다.
*/
public function test_single_upload_with_temp_key_over_limit_is_blocked(): void
{
$slug = $this->getTestBoardSlug();
$tempKey = 'temp-single-upload-limit';
$this->seedTempAttachments($tempKey, 5);
$this->actingAs($this->memberUser, 'sanctum')
->postJson("/api/modules/sirsoft-board/boards/{$slug}/attachments", [
'file' => UploadedFile::fake()->image('temp-over-limit.png'),
'temp_key' => $tempKey,
])
->assertStatus(422)
->assertJsonPath('errors.code', 'attachment_limit_exceeded');
}
/**
* 상한 이내 단일 업로드는 그대로 통과한다 (경계).
*/
public function test_single_upload_endpoint_allows_within_limit(): void
{
$slug = $this->getTestBoardSlug();
$postId = $this->createTestPost(['user_id' => $this->memberUser->id]);
$this->seedLinkedAttachments($postId, 4);
$this->actingAs($this->memberUser, 'sanctum')
->postJson("/api/modules/sirsoft-board/boards/{$slug}/attachments", [
'file' => UploadedFile::fake()->image('within-limit.png'),
'post_id' => $postId,
])
->assertSuccessful();
}
/**
* 수정 시 `attachment_ids` 로 보낸 첨부가 사용자 경로에서도 실제로 연결된다.
*
* 결함: 관리자 컨트롤러는 `attachment_ids` 를 뽑아 Service 로 넘기는데 사용자 컨트롤러는
* 넘기지 않았다. 그래서 같은 요청이 검증(형식·개수 상한 합산)은 통과하고 200 을 받는데
* 첨부만 조용히 연결되지 않았다 — 화면에는 성공으로 보이고 첨부만 사라진다.
* 작성 경로의 `files[]` 유실과 같은 계열이며, 두 경로가 같은 강도여야 한다.
*/
public function test_update_links_attachment_ids_on_user_path(): void
{
$postId = $this->createTestPost(['user_id' => $this->memberUser->id]);
$tempKey = 'tk_'.str_repeat('u', 20);
$this->seedTempAttachments($tempKey, 2);
$attachmentIds = Attachment::where('temp_key', $tempKey)->pluck('id')->all();
$response = $this->actingAs($this->memberUser, 'sanctum')->putJson(
"/api/modules/sirsoft-board/boards/{$this->getTestBoardSlug()}/posts/{$postId}",
[
'title' => '첨부 연결 수정',
'content' => '본문은 최소 길이를 넘겨야 검증을 통과합니다.',
'attachment_ids' => $attachmentIds,
]
);
$response->assertStatus(200);
$this->assertSame(
2,
Attachment::where('post_id', $postId)->count(),
'attachment_ids 로 보낸 첨부가 게시글에 연결되지 않았습니다 — 응답은 성공인데 첨부만 사라집니다.'
);
}
/**
* 수정 경로의 `attachment_ids` 도 개수 상한을 넘기면 차단된다 (위 경계의 대조군).
*/
public function test_update_attachment_ids_over_limit_is_blocked(): void
{
$postId = $this->createTestPost(['user_id' => $this->memberUser->id]);
$tempKey = 'tk_'.str_repeat('o', 20);
$this->seedTempAttachments($tempKey, 6);
$attachmentIds = Attachment::where('temp_key', $tempKey)->pluck('id')->all();
$response = $this->actingAs($this->memberUser, 'sanctum')->putJson(
"/api/modules/sirsoft-board/boards/{$this->getTestBoardSlug()}/posts/{$postId}",
[
'title' => '상한 초과 첨부 수정',
'content' => '본문은 최소 길이를 넘겨야 검증을 통과합니다.',
'attachment_ids' => $attachmentIds,
]
);
$response->assertStatus(422);
$this->assertSame(0, Attachment::where('post_id', $postId)->count());
}
/**
* 상한 미설정(0) 게시판은 제한하지 않는다 (기존 동작 유지).
*/
public function test_board_without_limit_is_not_restricted(): void
{
$this->board->update(['max_file_count' => 0]);
$postId = $this->createTestPost();
$tempKey = 'tk_'.str_repeat('e', 20);
$this->seedTempAttachments($tempKey, 8);
$linked = app(AttachmentService::class)->linkTempAttachmentsWithMove(
$this->getTestBoardSlug(),
$tempKey,
$postId
);
$this->assertSame(8, $linked);
}
}