Files
Gnuboard7/modules/_bundled/sirsoft-board/tests/Feature/AttachmentLimitTest.php
T
HeuJung d17ea28da5 fix(core,board,ecommerce,page,kginicis): 정책값 하드코딩·계약 분리 결함 일괄 수정
관리자 환경설정이 값을 저장은 하지만 실제 동작에는 닿지 않던 결함군을 정리했다.
설정 화면(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
2026-07-30 07:52:24 +09:00

465 lines
17 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();
}
/**
* 상한 미설정(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);
}
}