fix(seo,engine): 7.0.7 검증 발견 결함 수정 — 표현식 구조분해 회귀·타이틀 접미사 조립·JSON-LD 이스케이프

- SafeExpressionEvaluator 교체(engine-v1.60.0)가 구 평가기가 허용하던 화살표
 파라미터 배열 구조분해(([k, v]) / ([, vid]))를 누락해 해당 문법을 쓰는 액션
 params 가 미평가 원문으로 전송, 장바구니 담기/바로 구매가 전 상품 불능이던
 회귀를 수정 (engine-v1.60.5, production 재빌드 동반). 저장소 레이아웃 54파일
 104곳 사용 형태 전수를 단위/E2E 테스트로 잠금
- TrimStrings 가 타이틀 접미사의 선행 공백을 제거해 봇 렌더 제목이
 "제목| 사이트명" 으로 접착되고 빈 제목 화면은 "| 사이트명" 이 매달리던 기존
 결함 수정 — 조립 규칙을 composeTitleSuffix 단일 지점(SSoT)으로 정규화
- JSON-LD 직렬화에 JSON_HEX_TAG 미적용으로 검색어의 </script> 가 스크립트
 컨텍스트를 조기 종료해 실행 가능한 script 요소가 재생성되던 반사형 XSS(기존
 결함) 차단 — 태그 문자 이스케이프, 구조화 데이터 의미는 보존
This commit is contained in:
HeuJung
2026-08-18 09:53:50 +09:00
parent b3d0da9355
commit 0bb2ab9b49
10 changed files with 301 additions and 38 deletions
+2
View File
@@ -23,6 +23,7 @@
- 만료된 인증 토큰이 사용자 언어(로케일) 판별에서 여전히 유효한 것으로 취급되던 문제를 수정했습니다. 이제 만료된 토큰은 비로그인과 동일하게 처리합니다. (sir.kr 커뮤니티의 Xbuilder 님께서 제보해주셨습니다.)
- 통합 검색이 로그인한 회원을 비회원으로 취급하던 문제를 수정했습니다. 검색 API 가 로그인 상태를 해석하지 않아, 회원 전용 게시판의 읽기 권한이 있어도 검색 결과와 게시판 필터 목록이 비회원 기준으로만 제한되었습니다. 이제 로그인한 회원은 자신이 열람할 수 있는 게시판 범위 그대로 검색됩니다. 비회원 검색은 종전과 동일합니다.
- 셸 예약 작업의 허용 실행 파일 목록에 `bash`·`python` 같은 인터프리터를 등록했을 때, 인터프리터에 인라인 명령(`bash -c ...` 형태)을 실어 임의 명령이 실행되던 문제를 막았습니다. 이제 인터프리터 뒤에는 절대경로 스크립트 파일만 지정할 수 있으며, 인라인 코드 실행·상대경로·경로 조작이 담긴 명령은 저장·실행 양쪽에서 거부됩니다. 스크립트 파일을 주기 실행하는 정상 사용(예: `python /경로/작업.py`)은 그대로 동작합니다. 이 기능은 현재 메뉴에 노출되지 않아 실제 사용 경로가 없습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1653)
- 검색어처럼 방문자가 입력한 값이 검색엔진용 구조화 데이터(JSON-LD)에 실릴 때, 스크립트 태그를 닫는 문자열을 끼워 넣으면 그 뒤의 내용이 방문자 브라우저에서 스크립트로 실행될 수 있던 문제를 수정했습니다. 검색엔진 봇에게 제공되는 화면이지만 특정 주소 파라미터로 일반 방문자에게도 같은 화면을 강제할 수 있어 실제 공격 경로가 됩니다. 이제 태그 문자를 이스케이프해 실행을 차단하며, 검색엔진이 읽는 구조화 데이터의 의미는 그대로 유지됩니다.
### Added
@@ -80,6 +81,7 @@
- 캐시 버전 조회 주소(`?v=`)를 생략하고 공개 라우트·다국어·레이아웃 API 를 직접 호출하는 경우(외부 연동·봇 등), 그 응답이 어떤 갱신 경로로도 무효화되지 않는 별도 캐시에 남던 문제를 수정했습니다. 브라우저를 통한 일반 사용에는 영향이 없습니다.
- 확장·코어 업데이트가 중단되며 남은 임시 폴더와 오래된 백업본, 언어팩 설치 임시 파일이 정리되지 않고 계속 쌓이던 문제를 수정했습니다. 사흘이 지난 임시 산출물과 30일이 지난 백업본(최신 1개는 보존)이 매일 자동 정리됩니다.
- 개발 도구의 브라우저 콘솔 로그 파일이 하나의 파일에 무한히 쌓이던 문제를 수정했습니다. 이제 날짜별 파일로 나뉘어 7일간 보관됩니다.
- 검색엔진 노출용 페이지 제목에서 타이틀 접미사가 제목과 붙어 표시되던 문제를 수정했습니다. 관리자 화면 안내대로 접미사를 공백으로 시작하게 입력해도 저장 시 선행 공백이 제거되어 "제목| 사이트명" 처럼 접착되었고, 페이지 제목이 없는 화면에서는 "| 사이트명" 처럼 구분자가 매달려 표시되었습니다. 이제 제목이 있으면 공백을 복원해 잇고, 제목이 없으면 구분자를 떼고 사이트명만 표시합니다.
## [7.0.6] - 2026-08-10
+44 -4
View File
@@ -253,6 +253,7 @@ class SeoMetaResolver
return $this->resolveLocalizedValue($value);
}
}
return '';
}
@@ -428,6 +429,37 @@ class SeoMetaResolver
return [$contextIdentifier, $rest];
}
/**
* `<title>` 조립용 접미사를 정규화합니다.
*
* 관리자 저장 경로의 TrimStrings 미들웨어가 접미사의 선행 공백을 제거하므로
* ("` | 그누보드7`" 을 입력해도 "`| 그누보드7`" 로 저장된다), blade 의
* `{{ $title }}{{ $titleSuffix }}` 단순 연결에서 제목과 구분자가 붙어 버린다.
* 이 메서드가 조립 규칙의 SSoT 다:
*
* - 제목이 있고 접미사가 공백으로 시작하지 않으면 공백 하나를 복원해 잇는다.
* - 제목이 비어 있으면 매달린 선행 구분자(`|`,`-`,`·`,`:`,`/`,`–`,`—`)를 떼어
* 사이트명 부분만 남긴다 (홈처럼 페이지 제목이 없는 화면의 "`| 사이트명`" 방지).
*
* @param string $title 최종 페이지 제목 (filter 훅 적용 후)
* @param string $suffix 코어 설정의 타이틀 접미사
* @return string 조립에 사용할 접미사 (제목이 없으면 접미사가 곧 전체 제목)
*/
public static function composeTitleSuffix(string $title, string $suffix): string
{
if (trim($suffix) === '') {
return '';
}
if ($title === '') {
$stripped = (string) preg_replace('/^[\s|·:\/\x{2013}\x{2014}-]+/u', '', $suffix);
return trim($stripped);
}
return preg_match('/^\s/u', $suffix) === 1 ? $suffix : ' '.$suffix;
}
/**
* 레이아웃 meta의 title을 해석합니다 (fallback용).
*
@@ -691,7 +723,15 @@ class SeoMetaResolver
$resolved = array_merge(['@context' => 'https://schema.org'], $structuredData);
return json_encode($resolved, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
// JSON-LD 는 `<script type="application/ld+json">` 안에 임베드된다. 사용자 제어 값
// (검색어·상품명 등)에 포함된 `</script>` 가 스크립트 컨텍스트를 조기 종료하면 그
// 뒤의 페이로드가 실행 가능한 script 요소로 재생성된다(반사형 XSS — 봇 렌더는
// _escaped_fragment_ 로 일반 UA 도 강제됨). JSON_HEX_TAG 로 `<`·`>` 를 <·>
// 로 이스케이프해 브레이크아웃을 차단한다. JSON_HEX_AMP 는 방어 심화.
return json_encode(
$resolved,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT | JSON_HEX_TAG | JSON_HEX_AMP,
);
}
/**
@@ -790,9 +830,9 @@ class SeoMetaResolver
* 구조화 데이터 하위 객체가 실질적으로 비어있는지 확인합니다.
*
* @type 키가 있는 객체에서, @type 외 필드 중 하나라도 빈 문자열이면
* 해당 객체를 JSON-LD에서 제거합니다.
* 예: aggregateRating의 ratingValue=""이면, bestRating="5"가 있더라도 제거됩니다.
* Google 구조화 데이터 검증에서 필수 필드가 빈 값이면 에러로 처리되기 때문입니다.
* 해당 객체를 JSON-LD에서 제거합니다.
* 예: aggregateRating의 ratingValue=""이면, bestRating="5"가 있더라도 제거됩니다.
* Google 구조화 데이터 검증에서 필수 필드가 빈 값이면 에러로 처리되기 때문입니다.
*
* @param array $resolved 평가된 구조화 데이터 객체
* @return bool 비어있으면 true
+3 -1
View File
@@ -491,7 +491,9 @@ class SeoRenderer implements SeoRendererInterface
$viewData = [
'locale' => $locale,
'title' => $meta['title'],
'titleSuffix' => $meta['titleSuffix'],
// filter 훅이 title 을 바꿨을 수 있으므로 최종 title 기준으로 접미사를 정규화한다
// (TrimStrings 로 선행 공백이 제거된 접미사 복원 + 빈 제목의 매달린 구분자 제거)
'titleSuffix' => SeoMetaResolver::composeTitleSuffix((string) ($meta['title'] ?? ''), (string) ($meta['titleSuffix'] ?? '')),
'description' => $meta['description'],
'keywords' => $meta['keywords'],
'canonicalUrl' => $canonicalUrl,
File diff suppressed because one or more lines are too long
@@ -5,6 +5,16 @@
>
> 형식: [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)
## [engine-v1.60.5] - 2026-08-18
### Fixed
#### 화살표 파라미터 배열 구조분해 미지원 회귀 (장바구니/바로구매 불능)
- engine-v1.60.0 의 SafeExpressionEvaluator 교체가 구 평가기(`new Function`)가 허용하던 **화살표 함수 파라미터의 배열 구조분해**(`([k, v]) =>`, `([, vid]) =>`)를 수용하지 않아, 해당 문법을 쓰는 표현식이 전부 파싱 실패했다. 액션 params 는 미평가 원문 문자열 그대로 서버에 전송되어 422 가 되며(스토어프론트 장바구니 담기·바로 구매가 전 상품에서 불능), 저장소 레이아웃 54개 파일 104곳이 이 문법을 사용 중이었다.
- `tryParseParamList` 에 배열 패턴(식별자 + 홀/elision, 파라미터 기본값 조합 포함)을 추가하고 `bindParams` 가 JS iterator 시맨틱(비-iterable 은 TypeError)으로 분해 바인딩하도록 했다. 중첩 패턴·rest·객체 패턴은 배포 레이아웃 사용 0건이라 지원하지 않는다(발견 시 arrow 아님으로 안전 되돌림).
- 회귀 잠금: 사용 형태 전수(단일/쌍/홀/기본값/실전 `_purchase_card.json` 본문) 단위 테스트 추가.
## [engine-v1.60.4] - 2026-08-14
### Security
@@ -70,7 +70,7 @@ const DANGEROUS_GLOBALS = new Set([
* 잡히지 않는다. 따라서 리플렉션·프로토타입·디스크립터 계열을 전부 제거하고 순수 데이터 계열만 노출한다.
* (KVE-2026-1915)
*
* @since engine-v1.60.1
* @since engine-v1.60.5
*/
/**
* 대상 객체가 공유 전역(화이트리스트 전역/생성자)이면 거부합니다.
@@ -245,9 +245,18 @@ type Node =
| { type: 'Empty' }
| { type: 'Try'; block: Node; handlerParam: string | null; handler: Node | null; finalizer: Node | null };
/** 함수/화살표 파라미터 (기본값 지원) */
/**
* 함수/화살표 파라미터 (기본값 지원).
*
* `name` 이 null 이면 배열 구조분해 패턴 — `elements` 가 요소 이름 목록이며
* null 요소는 홀(elision)이다 (`([, vid]) =>` → elements: [null, 'vid']).
* 구 평가기(new Function)가 허용하던 형태로, 레이아웃 전반에서 사용된다.
*
* @since engine-v1.60.5
*/
interface Param {
name: string;
name: string | null;
elements?: (string | null)[];
default: Node | null;
}
@@ -758,8 +767,8 @@ class Parser {
}
/**
* 파라미터 목록을 파싱한다: `ident (= 기본값)?` 를 `,` 로 구분. 열림 `(` 는 호출부가
* 이미 소비한 상태이며, 닫힘 `)` 는 소비하지 않는다(호출부가 검사).
* 파라미터 목록을 파싱한다: `(ident | [배열패턴]) (= 기본값)?` 를 `,` 로 구분.
* 열림 `(` 는 호출부가 이미 소비한 상태이며, 닫힘 `)` 는 소비하지 않는다(호출부가 검사).
*
* @return 파싱된 파라미터 배열, 또는 파라미터 형태가 아니면 null(arrow 아님)
*/
@@ -768,11 +777,18 @@ class Parser {
if (this.isPunct(')')) return params; // 빈 목록
for (;;) {
const p = this.peek();
if (!p || p.type !== 'ident' || FORBIDDEN_KEYWORDS.has(String(p.value))) {
let name: string | null = null;
let elements: (string | null)[] | undefined;
if (p && p.type === 'punct' && p.value === '[') {
const pattern = this.tryParseArrayPattern();
if (!pattern) return null;
elements = pattern;
} else if (p && p.type === 'ident' && !FORBIDDEN_KEYWORDS.has(String(p.value))) {
name = String(p.value);
this.pos += 1;
} else {
return null;
}
const name = String(p.value);
this.pos += 1;
let def: Node | null = null;
if (this.isPunct('=')) {
this.pos += 1; // =
@@ -780,7 +796,7 @@ class Parser {
// 콤마를 소비하지 않으므로 다음 파라미터 구분자 ',' 는 그대로 남는다.
def = this.parseExpression();
}
params.push({ name, default: def });
params.push(elements ? { name, elements, default: def } : { name, default: def });
if (this.isPunct(',')) {
this.pos += 1;
continue;
@@ -790,6 +806,48 @@ class Parser {
return params;
}
/**
* 파라미터 위치의 배열 구조분해 패턴 `[a, , b]` 를 파싱한다.
* 요소는 식별자 또는 홀(elision)만 허용 — 중첩 패턴·rest·요소별 기본값은
* 레이아웃 표현식에서 쓰이지 않으므로 지원하지 않는다(발견 시 arrow 아님으로 되돌림).
*
* @return 요소 이름 배열 (홀은 null), 패턴 형태가 아니면 null
* @since engine-v1.60.5
*/
private tryParseArrayPattern(): (string | null)[] | null {
const save = this.pos;
this.pos += 1; // [
const elements: (string | null)[] = [];
for (;;) {
if (this.isPunct(']')) {
this.pos += 1;
return elements;
}
if (this.isPunct(',')) {
elements.push(null); // 홀 (elision)
this.pos += 1;
continue;
}
const t = this.peek();
if (!t || t.type !== 'ident' || FORBIDDEN_KEYWORDS.has(String(t.value))) {
this.pos = save;
return null;
}
elements.push(String(t.value));
this.pos += 1;
if (this.isPunct(',')) {
this.pos += 1;
continue;
}
if (this.isPunct(']')) {
this.pos += 1;
return elements;
}
this.pos = save;
return null;
}
}
private parseArrowBody(): { body: Node; isBlock: boolean } {
// 블록 본문: (…) => { statements } (return 으로 값 반환)
if (this.isPunct('{')) {
@@ -1387,7 +1445,7 @@ function resolveIdentifier(name: string, scope: Scope): unknown {
*
* @param key 해석된 computed 키(임의 타입)
* @return 정규화된 안전한 프로퍼티 키
* @since engine-v1.60.1
* @since engine-v1.60.5
*/
function normalizeKey(key: unknown): string | symbol {
const normalized = typeof key === 'symbol' ? key : String(key);
@@ -1502,7 +1560,19 @@ function bindParams(params: Param[], args: unknown[], local: Scope): void {
if (v === undefined && p.default) {
v = evalNode(p.default, local);
}
local.vars[p.name] = v;
if (p.elements) {
// 배열 구조분해 패턴 — JS iterator 시맨틱과 동일하게 null/undefined 는 예외
if (v == null) {
throw new TypeError(`Cannot destructure ${String(v)}: value is not iterable`);
}
const arr = Array.isArray(v) ? v : Array.from(v as Iterable<unknown>);
for (let e = 0; e < p.elements.length; e++) {
const name = p.elements[e];
if (name !== null) local.vars[name] = arr[e];
}
} else if (p.name !== null) {
local.vars[p.name] = v;
}
}
}
@@ -147,6 +147,69 @@ describe('SafeExpressionEvaluator', () => {
});
});
describe('arrow param 배열 구조분해 (engine-v1.60.5 회귀 — 장바구니/바로구매 불능)', () => {
// 구 평가기(new Function)가 허용하던 형태 전수: 저장소 레이아웃 54파일 104곳 사용
// (`([code])` 35 · `([field, messages])` 34 · `([k, v])` 등 — _purchase_card.json 이 대표)
it('단일 요소 ([k])', () => {
expect(evalx('Object.entries({ a: 1, b: 2 }).map(([k]) => k)')).toEqual(['a', 'b']);
});
it('두 요소 ([k, v])', () => {
expect(evalx('Object.entries({ a: 1, b: 2 }).map(([k, v]) => k + v)')).toEqual(['a1', 'b2']);
});
it('선행 홀 ([, v])', () => {
expect(evalx('Object.entries({ a: 1, b: null }).filter(([, v]) => v != null).length')).toBe(1);
});
it('배열 인자 직접 분해', () => {
expect(evalx('[[1, 2], [3, 4]].map(([a, b]) => a * b)')).toEqual([2, 12]);
});
it('파라미터 기본값 조합 (([a, b] = []) =>)', () => {
expect(evalx('[undefined].map(([a, b] = [7, 8]) => (a ?? 0) + (b ?? 0))')).toEqual([15]);
});
it('null/undefined 인자 분해는 JS 와 동일하게 예외', () => {
expect(() => evalx('[null].map(([a]) => a)')).toThrow();
});
it('실전 회귀: _purchase_card.json 장바구니 담기 본문 표현식', () => {
const ctx = {
product: { data: { id: 98, has_options: true, options: [{ id: 1 }, { id: 2 }] } },
_local: {
selectedOptionItems: [
{
optionId: 11,
quantity: 2,
additionalOptionSelections: { 5: 50, 6: null },
additionalOptionCustomTexts: { 5: '각인 문구' },
},
],
noOptionQuantity: 1,
},
};
const expr =
'product.data?.has_options && (product.data?.options?.length ?? 0) > 1 ? ' +
'{ product_id: product.data?.id, items: (_local.selectedOptionItems ?? []).map(item => ({ ' +
'product_option_id: item.optionId, quantity: item.quantity, ' +
'additional_option_selections: Object.entries(item.additionalOptionSelections ?? {})' +
'.filter(([, vid]) => vid != null)' +
'.map(([gid, vid]) => ({ additional_option_id: Number(gid), value_id: Number(vid), custom_text: item.additionalOptionCustomTexts?.[Number(gid)] })) ' +
'})) } : { product_id: product.data?.id, items: [{ quantity: _local.noOptionQuantity ?? 1 }] }';
expect(evalx(expr, ctx)).toEqual({
product_id: 98,
items: [
{
product_option_id: 11,
quantity: 2,
additional_option_selections: [{ additional_option_id: 5, value_id: 50, custom_text: '각인 문구' }],
},
],
});
});
});
describe('spread (array / object / call)', () => {
it('배열 스프레드', () => {
expect(evalx('[...a, ...b, 3]', { a: [1], b: [2] })).toEqual([1, 2, 3]);
@@ -71,6 +71,25 @@ test.describe('레이아웃 표현식 평가 샌드박스', () => {
expect(result.date).toBe(true);
});
// @scenario case=destructuring_param
// @effects same_expression_same_value_across_paths
test('화살표 파라미터 배열 구조분해(([k, v])·홀)가 배포 번들에서 동작한다 (engine-v1.60.5 회귀)', async ({ page }) => {
const result = await page.evaluate(() => {
const g7 = (window as any).G7Core;
const ctx = { sel: { 5: 50, 6: null } };
return {
// _purchase_card.json 장바구니/바로구매 본문 형태 — 홀 + 쌍 분해
hole: g7.evaluateCondition('{{Object.entries(sel ?? {}).filter(([, vid]) => vid != null).length === 1}}', ctx),
pair: g7.evaluateCondition('{{Object.entries(sel ?? {}).filter(([, v]) => v != null).map(([gid, vid]) => Number(gid) + Number(vid)).includes(55)}}', ctx),
single: g7.evaluateCondition('{{Object.entries(sel ?? {}).map(([k]) => k).includes("5")}}', ctx),
};
});
expect(result.hole).toBe(true);
expect(result.pair).toBe(true);
expect(result.single).toBe(true);
});
// @scenario case=constructor_escape
// @effects sandbox_escape_blocked, dangerous_payload_does_not_set_global
test('constructor 체인 샌드박스 탈출이 코드를 실행하지 못한다', async ({ page }) => {
+58 -1
View File
@@ -374,6 +374,35 @@ class SeoMetaResolverTest extends TestCase
$this->assertSame('나이키 에어맥스', $jsonLd['description']);
}
/**
* JSON-LD 는 `<script type="application/ld+json">` 안에 임베드되므로, 사용자
* 제어 값(검색어 등)의 `</script>` 가 스크립트 컨텍스트를 조기 종료해 실행 가능한
* script 요소를 재생성하지 못하도록 `<`·`>` 를 유니코드 이스케이프해야 한다.
* (봇 렌더는 _escaped_fragment_ 로 일반 UA 도 강제되므로 반사형 XSS 벡터가 된다.)
*/
public function test_structured_data_escapes_script_breakout_in_json_ld(): void
{
$seoConfig = [
'structured_data' => [
'@type' => 'SearchResultsPage',
'name' => 'G7 - {{query.q}}',
],
];
$context = ['query' => ['q' => '</script><script>alert(1)</script>']];
$result = $this->resolver->resolve($seoConfig, $context, null, null, []);
// 원문 태그가 그대로 실리면 스크립트 컨텍스트가 조기 종료돼 실행 가능한 script 요소가
// 재생성된다(반사형 XSS). JSON_HEX_TAG 가 태그 문자를 유니코드 이스케이프하므로 산출물에는
// 리터럴 태그 열림/닫힘 문자가 없어야 한다.
$this->assertDoesNotMatchRegularExpression('#</?script#', $result['jsonLd']);
$this->assertStringContainsString(chr(0x5C).'u003C', $result['jsonLd']);
// 이스케이프에도 불구하고 JSON 파싱 시 원래 값이 복원된다(구조화 데이터 의미 보존).
$decoded = json_decode($result['jsonLd'], true);
$this->assertSame('G7 - </script><script>alert(1)</script>', $decoded['name']);
}
/**
* 구조화 데이터가 없으면 jsonLd는 null입니다.
*/
@@ -1562,7 +1591,7 @@ class SeoMetaResolverTest extends TestCase
'page_type' => 'product',
'vars' => [
// 표현식이 다국어 객체를 직접 반환 — substituteVars 까지 array 전달
'product_name' => "{{product.data.name}}",
'product_name' => '{{product.data.name}}',
'commerce_name' => '$module_settings:basic_info.shop_name',
],
];
@@ -1638,4 +1667,32 @@ class SeoMetaResolverTest extends TestCase
$this->assertSame('운동화', $result['og']['description']);
$this->assertSame('https://e.co/ko.jpg', $result['og']['image']);
}
/**
* 접미사 조립: 저장 시 TrimStrings 가 선행 공백을 제거하므로("| 그누보드7"),
* 제목이 있으면 공백을 복원해 잇고, 제목이 비면 매달린 구분자를 떼어낸다.
* (blade 는 title.titleSuffix 단순 연결 — 조립 규칙은 이 메서드가 SSoT)
*/
public function test_compose_title_suffix_restores_space_between_title_and_trimmed_suffix(): void
{
$this->assertSame(' | 그누보드7', SeoMetaResolver::composeTitleSuffix('가죽 크로스백', '| 그누보드7'));
}
public function test_compose_title_suffix_keeps_explicit_leading_space_as_is(): void
{
$this->assertSame(' | 그누보드7', SeoMetaResolver::composeTitleSuffix('가죽 크로스백', ' | 그누보드7'));
}
public function test_compose_title_suffix_strips_dangling_separator_when_title_is_empty(): void
{
$this->assertSame('그누보드7', SeoMetaResolver::composeTitleSuffix('', '| 그누보드7'));
$this->assertSame('그누보드7', SeoMetaResolver::composeTitleSuffix('', ' - 그누보드7'));
}
public function test_compose_title_suffix_returns_empty_when_suffix_is_blank(): void
{
$this->assertSame('', SeoMetaResolver::composeTitleSuffix('가죽 크로스백', ''));
$this->assertSame('', SeoMetaResolver::composeTitleSuffix('', ' '));
$this->assertSame('', SeoMetaResolver::composeTitleSuffix('', '| '));
}
}
@@ -10,7 +10,7 @@ description: |
미실행(전역 canary `window.__g7jsi` 미설정) 양면을 잠근다.
axes:
case: [safe_expression, arrow_template_literal, constructor_escape, nonstring_key_escape, object_facade_safe]
case: [safe_expression, arrow_template_literal, destructuring_param, constructor_escape, nonstring_key_escape, object_facade_safe]
effects:
- same_expression_same_value_across_paths