Files
Gnuboard7/tests/Feature/Validation/GlobalAttributeLabelTest.php
T
HeuJung 4e1ec6e001 fix(core,admin_basic,board,ecommerce,page,gdpr,verification): 설정 미러·자동 정리·탈퇴 원자성·일괄 선택 범위 정비
공개이슈 ··· 및 그 전수 해소분.

- 설정 저장·복원·초기화가 상주 프로세스 미러를 갱신하도록 단일 소유자화
- 만료 데이터 자동 정리 예약 12종 신설, 파기는 기본키 배치로 분할
- 회원 탈퇴를 전 경로 원자화하고 관리자 상태변경 경로를 정식 탈퇴로 통일
- 일괄 처리 목록의 선택을 화면에 보이는 행으로 한정(selectionScope)
- 비밀번호 정책 문구를 도메인 중립 라벨로 정정
- 정적 검사 2종·검증 스킬 설계 정합성 축 신설
2026-08-14 11:06:25 +09:00

306 lines
12 KiB
PHP

<?php
namespace Tests\Feature\Validation;
use App\Enums\ExtensionOwnerType;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\App;
use Tests\TestCase;
/**
* 전역 validation attributes 라벨 정화 회귀 테스트 (공개이슈 #113)
*
* 전역 `validation.attributes` 는 모든 FormRequest 가 공유하는 라벨 사전입니다.
* 여기에 특정 도메인 문구(예: 'SMTP 비밀번호')를 넣으면, 그 필드명을 쓰는 모든
* 화면의 오류 문구가 오염됩니다. 이 테스트는 ① 범용 키가 범용 라벨을 유지하고,
* ② 도메인 전용 키(smtp_*, user_ids 등)가 의도 소비자에서만 쓰이는지 고정합니다.
*/
class GlobalAttributeLabelTest extends TestCase
{
use RefreshDatabase;
/**
* D1: 회원가입 password 타입 오류 문구에 SMTP 라벨이 새지 않는다.
*
* @scenario entry_point=register, attribute_label_scope=generic_password, locale=ko
*
* @effects smtp_label_never_appears_in_password_setting_paths
*/
public function test_register_password_type_error_uses_generic_password_label(): void
{
$response = $this->withHeaders(['Accept-Language' => 'ko'])
->postJson('/api/auth/register', [
'email' => 'g7fix113.d1@example.com',
'password' => 12345678,
'password_confirmation' => 12345678,
'name' => '테스트',
]);
$response->assertStatus(422);
$message = json_encode($response->json('errors.password'), JSON_UNESCAPED_UNICODE);
$this->assertStringNotContainsString('SMTP', (string) $message);
$this->assertStringContainsString('비밀번호', (string) $message);
}
/**
* D2: 비밀번호 확인 엔드포인트(messages() 미정의)의 required 문구 — 제보 문구 경로.
*
* @scenario entry_point=verify_password, attribute_label_scope=generic_password, locale=ko
*
* @effects smtp_label_never_appears_in_password_setting_paths
*/
public function test_verify_password_required_message_uses_generic_password_label(): void
{
$user = User::factory()->create();
$response = $this->withToken($user->createToken('t')->plainTextToken)
->postJson('/api/me/verify-password', []);
$response->assertStatus(422);
$message = (string) json_encode($response->json('errors.password'), JSON_UNESCAPED_UNICODE);
$this->assertStringNotContainsString('SMTP', $message);
$this->assertStringContainsString('비밀번호', $message);
}
/**
* D3: PasswordPolicy 규칙 실패 메시지의 :attribute 치환값.
*
* @scenario entry_point=register, attribute_label_scope=generic_password, input_state=violates_length, locale=ko
*
* @effects policy_message_attribute_uses_generic_password_label
*/
public function test_password_policy_message_uses_generic_password_label(): void
{
$response = $this->withHeaders(['Accept-Language' => 'ko'])
->postJson('/api/auth/register', [
'email' => 'g7fix113.d3@example.com',
'password' => 'a1',
'password_confirmation' => 'a1',
'name' => '테스트',
]);
$response->assertStatus(422);
$message = (string) json_encode($response->json('errors.password'), JSON_UNESCAPED_UNICODE);
$this->assertStringNotContainsString('SMTP', $message);
// 부정 단언만 두면 라벨이 통째로 빠져도 통과한다 — 실제 라벨이 들어갔는지 함께 본다.
$this->assertStringContainsString('비밀번호', $message);
}
/**
* D4: SMTP 연결 테스트는 도메인 라벨('SMTP 호스트'/'SMTP 비밀번호')을 유지한다.
*
* @scenario attribute_label_scope=smtp_password, locale=ko
*
* @effects smtp_screens_keep_their_domain_labels
*/
public function test_test_mail_request_keeps_smtp_labels(): void
{
$admin = $this->createAdmin();
// host 누락 (required, messages() 경로)
$response = $this->withToken($admin->createToken('t')->plainTextToken)
->postJson('/api/admin/settings/test-mail', [
'to_email' => 'a@example.com',
'from_address' => 'b@example.com',
'from_name' => '발신자',
'mailer' => 'smtp',
'port' => 587,
]);
$response->assertStatus(422);
$this->assertStringContainsString(
'SMTP',
(string) json_encode($response->json('errors.host'), JSON_UNESCAPED_UNICODE)
);
// password 배열 전송 (string 규칙, attributes() 경로)
$response = $this->withToken($admin->createToken('t2')->plainTextToken)
->postJson('/api/admin/settings/test-mail', [
'to_email' => 'a@example.com',
'from_address' => 'b@example.com',
'from_name' => '발신자',
'mailer' => 'smtp',
'host' => 'smtp.example.com',
'port' => 587,
'password' => ['x'],
]);
$response->assertStatus(422);
$this->assertStringContainsString(
'SMTP',
(string) json_encode($response->json('errors.password'), JSON_UNESCAPED_UNICODE)
);
}
/**
* D5: 사용자 일괄 상태변경은 '사용자 ID 목록', 전역 ids 라벨은 범용 문구.
*
* @scenario attribute_label_scope=user_scoped_ids, locale=ko
*
* @effects bulk_status_screen_keeps_user_scoped_ids_label, generic_ids_label_stays_generic_on_other_screens
*/
public function test_bulk_user_status_keeps_user_scoped_ids_label(): void
{
$admin = $this->createAdmin();
$response = $this->withToken($admin->createToken('t')->plainTextToken)
->patchJson('/api/admin/users/bulk-status', [
'status' => 'active',
]);
$response->assertStatus(422);
$this->assertStringContainsString(
'사용자 ID 목록',
(string) json_encode($response->json('errors.ids'), JSON_UNESCAPED_UNICODE)
);
// 전역 라벨 자체는 범용으로 원복되어 있어야 한다.
$this->assertSame('ID 목록', __('validation.attributes.ids'));
$this->assertSame('사용자 ID 목록', __('validation.attributes.user_ids'));
// 같은 전역 키를 쓰는 다른 화면은 범용 라벨을 받는다 (HTTP 경로로 확인).
// read-batch 는 ids 에 커스텀 메시지가 없어 전역 attributes 라벨이 그대로 드러난다.
$other = $this->withHeaders(['Accept-Language' => 'ko'])
->withToken($admin->createToken('t2')->plainTextToken)
->postJson('/api/admin/notifications/read-batch', []);
$other->assertStatus(422);
$otherMessage = (string) json_encode($other->json('errors.ids'), JSON_UNESCAPED_UNICODE);
$this->assertStringNotContainsString('사용자 ID 목록', $otherMessage);
$this->assertStringContainsString('ID 목록', $otherMessage);
}
/**
* D6: en / ja 로케일에서도 범용 키가 범용 라벨을 유지한다.
*
* @scenario entry_point=register, attribute_label_scope=generic_password, locale=en
*
* @effects generic_labels_hold_in_every_locale
*/
public function test_generic_labels_are_clean_in_other_locales(): void
{
// en 은 HTTP 경로로도 확인한다 — 사전값만 보면 요청 경로의 회귀를 놓친다.
$response = $this->withHeaders(['Accept-Language' => 'en'])
->postJson('/api/auth/register', [
'email' => 'g7fix113.d6@example.com',
'password' => 12345678,
'password_confirmation' => 12345678,
'name' => 'Matrix',
]);
$response->assertStatus(422);
$enMessage = (string) json_encode($response->json('errors.password'), JSON_UNESCAPED_UNICODE);
$this->assertStringNotContainsString('SMTP', $enMessage);
$this->assertStringContainsString('password', $enMessage);
App::setLocale('en');
$this->assertSame('password', __('validation.attributes.password'));
$this->assertSame('SMTP password', __('validation.attributes.smtp_password'));
$this->assertSame('ID list', __('validation.attributes.ids'));
$this->assertSame('user ID list', __('validation.attributes.user_ids'));
App::setLocale('ko');
// ja 는 언어팩(런타임 설치본)이라 테스트 환경 로케일로 해석되지 않는다 —
// 번들 원본을 직접 읽어 3로케일 동형 적용을 고정한다.
$ja = require base_path('lang-packs/_bundled/g7-core-ja/backend/ja/validation.php');
$this->assertSame('パスワード', $ja['attributes']['password']);
$this->assertSame('SMTPパスワード', $ja['attributes']['smtp_password']);
$this->assertSame('ID一覧', $ja['attributes']['ids']);
$this->assertSame('ユーザーID一覧', $ja['attributes']['user_ids']);
$this->assertSame('言語', $ja['attributes']['language']);
$this->assertSame('通貨', $ja['attributes']['currency']);
}
/**
* D-extra: 범용 키 전수 — 도메인 문구가 다시 새어 들어오는 회귀를 차단한다.
*
* @scenario attribute_label_scope=generic_ids, locale=ko
*
* @effects generic_attribute_keys_carry_no_domain_prefix
*/
public function test_generic_attribute_keys_are_free_of_domain_prefixes(): void
{
$expected = [
'password' => '비밀번호',
'host' => '호스트',
'port' => '포트',
'username' => '사용자명',
'ids' => 'ID 목록',
'language' => '언어',
'currency' => '통화',
'cache_ttl' => '캐시 유지시간',
'channels' => '채널',
];
foreach ($expected as $key => $label) {
$this->assertSame($label, __("validation.attributes.{$key}"), "attributes.{$key}");
}
$domain = [
'smtp_host', 'smtp_port', 'smtp_username', 'smtp_password',
'user_ids', 'default_language', 'default_currency',
'seo_page_cache_ttl', 'notification_channels',
];
foreach ($domain as $key) {
$this->assertNotSame(
"validation.attributes.{$key}",
__("validation.attributes.{$key}"),
"도메인 전용 키 미정의: {$key}"
);
}
}
/**
* 관리자 사용자 생성 헬퍼
*/
private function createAdmin(): User
{
$user = User::factory()->create(['email' => 'g7fix113.admin@example.com']);
$permissionIds = [];
foreach (['core.settings.update', 'core.users.update', 'core.notifications.update'] as $identifier) {
$permission = Permission::firstOrCreate(
['identifier' => $identifier],
[
'name' => json_encode(['ko' => $identifier, 'en' => $identifier]),
'description' => json_encode(['ko' => $identifier, 'en' => $identifier]),
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'type' => 'admin',
]
);
$permissionIds[] = $permission->id;
}
$role = Role::firstOrCreate(
['identifier' => 'admin'],
[
'name' => json_encode(['ko' => '관리자', 'en' => 'Administrator']),
'description' => json_encode(['ko' => '관리자', 'en' => 'Administrator']),
'is_active' => true,
]
);
$role->permissions()->syncWithoutDetaching($permissionIds);
$user->roles()->attach($role->id);
return $user->fresh();
}
}