검색 — 이 코어에 추가한 BOOLEAN MODE 정제기가 게시판에는 도달하지 않아 원문 키워드가 그대로 바인딩되고 있었다. + - * " < > 를 넣으면 오류 화면이 떴다. 코어 정제기 경유로 옮겼고, 같은 술어를 count 와 get 으로 두 번 실행하던 것을 페이지네이터 한 번으로 합쳤다. 구체 엔진 직접 호출도 해석기 경유로 바꿔 플러그인 검색엔진이 게시글·신고 검색에 적용된다. 상세 — 같은 글을 3회, 같은 게시판을 5회 읽고 있었다. 조회한 인스턴스를 넘겨 글 1회·게시판 1회로 줄였고, 조회수 증가를 권한 확인 뒤로 옮겨 열람 권한이 없는 요청이 조회수를 올리지 않게 했다. 목록 — 답변 트리의 무제한 조회에 상한과 순환 가드를 두고, O(n²) 병합을 한 패스로 바꿨다. 댓글도 무제한 조회에 상한을 두되 잘렸다는 사실과 전체 건수를 함께 알린다. 조용히 잘리면 "댓글이 그만큼뿐" 으로 보인다. 원댓글 단위 페이지네이션을 더해 답글이 부모와 떨어지지 않게 했다. 관리자 화면 — 총 건수가 잘린 목록에서 페이저가 "1페이지뿐" 으로 접히던 자리를 고쳤다. 저장소가 상한을 붙인 목록인데 화면이 그 값을 1 로 채우고 있어, 기록이 상한을 넘으면 2페이지에 도달할 방법이 없었다. 같은 파일에서 드러난 사전 결함도 함께 처리했다 — 반복 렌더 안의 정적 HTML id, Icon 의 박스 크기 지정, 단색 버튼의 다크 모드 대비.
182 lines
6.1 KiB
PHP
182 lines
6.1 KiB
PHP
<?php
|
|
|
|
namespace Modules\Sirsoft\Board\Tests\Feature;
|
|
|
|
// 테스트 베이스 클래스 수동 require (autoload 전에 로드 필요)
|
|
require_once __DIR__.'/../ModuleTestCase.php';
|
|
|
|
use App\Search\Engines\DatabaseFulltextEngine;
|
|
use Illuminate\Testing\TestResponse;
|
|
use Modules\Sirsoft\Board\Tests\BoardTestCase;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
|
|
/**
|
|
* 게시판 검색어 정제 회귀 테스트 (#407 재발 방지 / #519 C7)
|
|
*
|
|
* MySQL FULLTEXT 의 BOOLEAN MODE 는 `+ - * " ( ) ~ < > @` 를 연산자로 해석한다.
|
|
* 원문 키워드를 그대로 바인딩하면 사용자가 그 문자를 입력하는 것만으로 파싱 오류가 나고
|
|
* 목록 화면 전체가 500 이 된다. `#407` 이 코어에 정제기를 넣었지만 게시판은 자기 raw 쿼리를
|
|
* 들고 있어 그 수정이 닿지 않았다.
|
|
*
|
|
* 여기서 단언하는 것은 "검색이 무엇을 찾는가" 가 아니라 **"어떤 입력에도 화면이 죽지 않는가"** 다.
|
|
*
|
|
* @scenario board-search-keyword-sanitization
|
|
*
|
|
* @effects boolean_mode_operator_does_not_500,
|
|
* html_like_keyword_does_not_500,
|
|
* operator_only_keyword_returns_empty,
|
|
* normal_keyword_still_matches
|
|
*/
|
|
class PostSearchKeywordSanitizationTest extends BoardTestCase
|
|
{
|
|
/**
|
|
* 테스트 게시판 slug
|
|
*
|
|
* @return string 게시판 슬러그
|
|
*/
|
|
protected function getTestBoardSlug(): string
|
|
{
|
|
return 'search-sanitize-test';
|
|
}
|
|
|
|
/**
|
|
* 테스트 사전 준비를 수행합니다.
|
|
*/
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->grantDefaultGuestPermissions();
|
|
$this->resetPermissionMiddlewareCache();
|
|
|
|
$this->createTestPost([
|
|
'title' => '정상 게시글',
|
|
'content' => '검색으로 찾을 수 있는 본문입니다.',
|
|
'status' => 'published',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* BOOLEAN MODE 연산자를 포함한 검색어가 500 을 내지 않는지 확인
|
|
*
|
|
* @effects boolean_mode_operator_does_not_500
|
|
*/
|
|
#[Test]
|
|
public function boolean_mode_operators_do_not_break_search(): void
|
|
{
|
|
foreach (['+', '-', '*', '"', '(', ')', '~', '<', '>', '@', '+검색 -제외', '"닫히지 않은'] as $keyword) {
|
|
$response = $this->searchPosts($keyword);
|
|
|
|
$response->assertStatus(200, "검색어 [{$keyword}] 가 500 을 냈다 — BOOLEAN MODE 정제가 적용되지 않았다");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* HTML 형태 검색어가 500 을 내지 않는지 확인
|
|
*
|
|
* `<script>` 는 `<` `>` 가 BOOLEAN MODE 연산자라 정제 없이는 파싱 오류가 난다.
|
|
*
|
|
* @effects html_like_keyword_does_not_500
|
|
*/
|
|
#[Test]
|
|
public function html_like_keyword_does_not_break_search(): void
|
|
{
|
|
$response = $this->searchPosts('<script>alert(1)</script>');
|
|
|
|
$response->assertStatus(200);
|
|
}
|
|
|
|
/**
|
|
* 연산자만 입력하면 오류 대신 빈 결과가 나오는지 확인
|
|
*
|
|
* 정제 후 남는 토큰이 없으면 "찾을 것이 없다" 이지 "오류" 가 아니다.
|
|
*
|
|
* @effects operator_only_keyword_returns_empty
|
|
*/
|
|
#[Test]
|
|
public function operator_only_keyword_returns_empty_result(): void
|
|
{
|
|
$response = $this->searchPosts('+++');
|
|
|
|
$response->assertStatus(200);
|
|
$this->assertSame([], $response->json('data.data') ?? [], '연산자만 입력하면 매칭이 없어야 한다');
|
|
}
|
|
|
|
/**
|
|
* 정제가 정상 검색어의 토큰을 보존하는지 확인
|
|
*
|
|
* FULLTEXT 인덱스는 커밋된 행만 색인하므로, 트랜잭션 안에서 만든 게시글은 MATCH 로
|
|
* 찾을 수 없다. 그래서 "정제가 정상 검색어를 뭉개지 않는다" 는 명제는 정제기 자체의
|
|
* 출력으로 확인하고, 실제 매칭은 아래 LIKE 경로 테스트가 담당한다.
|
|
*
|
|
* @effects normal_keyword_still_matches
|
|
*/
|
|
#[Test]
|
|
public function sanitizer_preserves_normal_keyword_tokens(): void
|
|
{
|
|
$this->assertSame(
|
|
'"정상"',
|
|
DatabaseFulltextEngine::sanitizeBooleanModeKeyword('정상'),
|
|
'정제가 정상 검색어까지 걸러 내면 안 된다'
|
|
);
|
|
|
|
$this->assertSame(
|
|
'"정상" "게시글"',
|
|
DatabaseFulltextEngine::sanitizeBooleanModeKeyword('정상 게시글'),
|
|
'여러 토큰은 각각 보존되어야 한다'
|
|
);
|
|
|
|
$this->assertSame(
|
|
'"검색" "제외"',
|
|
DatabaseFulltextEngine::sanitizeBooleanModeKeyword('+검색 -제외'),
|
|
'연산자만 제거하고 토큰은 남긴다'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* FULLTEXT 를 타지 않는 검색 경로(작성자 LIKE)가 정상 동작하는지 확인
|
|
*
|
|
* 정제 변경이 목록 검색 전체를 망가뜨리지 않았음을 실제 매칭으로 확인한다.
|
|
*
|
|
* @effects normal_keyword_still_matches
|
|
*/
|
|
#[Test]
|
|
public function like_path_search_still_matches(): void
|
|
{
|
|
$this->createTestPost([
|
|
'title' => '작성자 검색 대상',
|
|
'content' => '본문',
|
|
'author_name' => '홍길동',
|
|
'status' => 'published',
|
|
]);
|
|
|
|
$this->resetPermissionMiddlewareCache();
|
|
|
|
$url = "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts?"
|
|
.http_build_query(['search' => '홍길동', 'search_field' => 'author']);
|
|
|
|
$response = $this->getJson($url);
|
|
$response->assertStatus(200);
|
|
|
|
$titles = collect($response->json('data.data') ?? [])->pluck('title')->all();
|
|
|
|
$this->assertContains('작성자 검색 대상', $titles, 'LIKE 경로 검색이 매칭되어야 한다');
|
|
}
|
|
|
|
/**
|
|
* 게시판 목록에 검색어를 얹어 조회합니다.
|
|
*
|
|
* @param string $keyword 검색어
|
|
* @return TestResponse 응답
|
|
*/
|
|
private function searchPosts(string $keyword)
|
|
{
|
|
$this->resetPermissionMiddlewareCache();
|
|
|
|
$url = "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts?"
|
|
.http_build_query(['search' => $keyword, 'search_field' => 'title_content']);
|
|
|
|
return $this->getJson($url);
|
|
}
|
|
}
|