확장이 훅으로 등록한 카탈로그에서 고른 값은 그 확장이 사라져도 저장값으로 남는다. 그 상태에서 값만 보고 판정하면 이미 제공 불가한 항목이 사용자 화면에 그대로 노출된다. 예외도 경고도 로그도 남지 않아 관리자 화면과 나란히 보지 않으면 드러나지 않는다. - 결제수단에 지정한 PG, 현금영수증 발급사, 검색엔진 드라이버 세 축에 같은 판정을 적용. 공개 응답에서는 제거하고 관리자 응답에는 표시를 남긴다 — 운영자가 고쳐야 할 대상이라 감추면 복구 경로가 사라진다. 서버 검증도 같은 판정을 공유해 화면 우회 제출을 막는다. - 설정 단건 저장이 벌크 저장과 다른 경로를 타던 문제를 코어·이커머스·게시판에서 정리. 저장은 성공하고 파생 값만 갱신되지 않아 어느 화면에서 저장했느냐로 결과가 갈렸다. - 단건 저장의 값 형태 계약 신설. 빈 값으로 되돌릴 수 있게 하고, 타입은 defaults.json 의 기본값을 SSoT 로 삼는다. 해석 불가한 입력은 조용히 캐스팅하지 않고 거절한다. - 드라이버 셀렉트를 서버 카탈로그에 바인딩하고, 저장값이 카탈로그에 없으면 그 값을 드러내는 안내를 8곳 전수에 붙인다. 종전에는 빈 칸으로만 보여 무엇이 저장됐는지 알 수 없었다. 시나리오 매니페스트 8종을 면제 없이 신설했다. `audit:allow` 는 cross product 와 effects 검사를 함께 끄므로, 면제가 걸린 매니페스트에 선언을 추가하면 검증이 회복되지 않는다. 그 아래에서 대응 테스트가 없는 effect 이름 2건이 살아남아 있었고, 함께 정리했다.
379 lines
13 KiB
PHP
379 lines
13 KiB
PHP
<?php
|
||
|
||
namespace Tests\Feature\Api\Admin;
|
||
|
||
use App\Enums\ExtensionOwnerType;
|
||
use App\Models\Permission;
|
||
use App\Models\Role;
|
||
use App\Models\User;
|
||
use App\Repositories\JsonConfigRepository;
|
||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
use Illuminate\Support\Facades\Hash;
|
||
use Illuminate\Testing\TestResponse;
|
||
use PHPUnit\Framework\Attributes\Test;
|
||
use Tests\TestCase;
|
||
|
||
/**
|
||
* 단건 설정 저장(`PUT /api/admin/settings/{key}`)의 값 형태 검증
|
||
*
|
||
* 종전 규칙은 `required|string|max:1000` 이었다. 그래서 두 가지가 깨졌다.
|
||
* - 한 번 입력한 값을 빈 칸으로 되돌릴 수 없다 (`required` 가 빈 문자열을 거부해 422).
|
||
* - 폼 전송의 boolean/정수가 문자열로 저장되어, 값을 읽는 쪽의 타입 비교가 어긋난다.
|
||
*
|
||
* 값의 타입은 `config/settings/defaults.json` 의 기본값이 SSoT 다.
|
||
*/
|
||
class SettingsSingleValueUpdateTest extends TestCase
|
||
{
|
||
use RefreshDatabase;
|
||
|
||
private string $token;
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
|
||
$this->token = $this->createAdminUser()->createToken('test-token')->plainTextToken;
|
||
}
|
||
|
||
/**
|
||
* 관리자 사용자 생성
|
||
*/
|
||
private function createAdminUser(): User
|
||
{
|
||
$user = User::factory()->create(['password' => Hash::make('password123')]);
|
||
|
||
$permissionIds = [];
|
||
foreach (['core.settings.read', 'core.settings.update'] as $identifier) {
|
||
$permissionIds[] = 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',
|
||
]
|
||
)->id;
|
||
}
|
||
|
||
$scopedRole = Role::create([
|
||
'identifier' => 'admin_test_'.uniqid(),
|
||
'name' => json_encode(['ko' => '테스트 관리자', 'en' => 'Test Admin']),
|
||
'description' => json_encode(['ko' => '테스트', 'en' => 'Test']),
|
||
'is_active' => true,
|
||
]);
|
||
$scopedRole->permissions()->sync($permissionIds);
|
||
|
||
$adminRole = Role::firstOrCreate(
|
||
['identifier' => 'admin'],
|
||
[
|
||
'name' => json_encode(['ko' => '관리자', 'en' => 'Administrator']),
|
||
'description' => json_encode(['ko' => '시스템 관리자', 'en' => 'System Administrator']),
|
||
'extension_type' => ExtensionOwnerType::Core,
|
||
'extension_identifier' => 'core',
|
||
'is_active' => true,
|
||
]
|
||
);
|
||
|
||
$user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
|
||
$user->roles()->attach($scopedRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
|
||
|
||
return $user->fresh();
|
||
}
|
||
|
||
/**
|
||
* 단건 저장 요청을 보냅니다.
|
||
*
|
||
* @param string $key 설정 키
|
||
* @param array<string, mixed> $payload 요청 본문
|
||
*/
|
||
private function putSetting(string $key, array $payload): TestResponse
|
||
{
|
||
return $this->withHeaders([
|
||
'Authorization' => 'Bearer '.$this->token,
|
||
'Accept' => 'application/json',
|
||
])->putJson('/api/admin/settings/'.$key, $payload);
|
||
}
|
||
|
||
/**
|
||
* 저장된 값을 저장소에서 직접 읽습니다.
|
||
*
|
||
* @param string $key 설정 키
|
||
* @return mixed 저장값
|
||
*/
|
||
private function stored(string $key): mixed
|
||
{
|
||
return (new JsonConfigRepository)->get($key);
|
||
}
|
||
|
||
// ─── 빈 값 되돌리기 ──────────────────────────────────────
|
||
|
||
/**
|
||
* 문자열 설정을 빈 값으로 되돌릴 수 있습니다. (실패-먼저)
|
||
*
|
||
* @scenario declared_type=string, value_disposition=cleared
|
||
*
|
||
* @effects setting_cleared_to_empty_string
|
||
*/
|
||
#[Test]
|
||
public function string_setting_can_be_cleared_to_empty(): void
|
||
{
|
||
$this->putSetting('seo.meta_keywords', ['value' => 'g7, cms'])->assertOk();
|
||
$this->assertSame('g7, cms', $this->stored('seo.meta_keywords'));
|
||
|
||
$this->putSetting('seo.meta_keywords', ['value' => ''])->assertOk();
|
||
|
||
$this->assertSame('', $this->stored('seo.meta_keywords'), '빈 값으로 되돌릴 수 없습니다.');
|
||
}
|
||
|
||
/**
|
||
* null 도 빈 값으로 받아들이며, 문자열 설정에는 빈 문자열로 남깁니다.
|
||
*
|
||
* `ConvertEmptyStringsToNull` 미들웨어가 `''` 를 null 로 바꾸므로 두 입력은
|
||
* 요청 시점에 이미 같다. 저장 형태는 선언 타입(기본값 `''`)에 맞춘다.
|
||
*
|
||
* @scenario declared_type=string, value_disposition=cleared
|
||
*
|
||
* @effects setting_cleared_to_empty_string
|
||
*/
|
||
#[Test]
|
||
public function null_value_is_accepted(): void
|
||
{
|
||
$this->putSetting('seo.meta_keywords', ['value' => null])->assertOk();
|
||
|
||
$this->assertSame('', $this->stored('seo.meta_keywords'));
|
||
}
|
||
|
||
/**
|
||
* `value` 키 자체가 빠진 요청은 거부합니다.
|
||
*
|
||
* "값을 비운다" 와 "값을 안 보냈다" 는 다르다 — 후자는 잘못된 payload 다.
|
||
*
|
||
* @scenario declared_type=string, value_disposition=rejected
|
||
*
|
||
* @effects missing_value_key_rejected_with_422
|
||
*/
|
||
#[Test]
|
||
public function missing_value_key_is_rejected(): void
|
||
{
|
||
$this->putSetting('seo.meta_keywords', [])
|
||
->assertStatus(422)
|
||
->assertJsonValidationErrors(['value']);
|
||
}
|
||
|
||
/**
|
||
* 문자열 설정은 값이 숫자·불리언처럼 보여도 문자열 그대로 저장합니다.
|
||
*
|
||
* 정규화는 선언 타입이 boolean·정수일 때만 캐스팅해야 한다. 문자열 설정까지 캐스팅하면
|
||
* 앞자리 0 이 사라지거나(`"0123"` → 123) 문구가 boolean 이 되어, 운영자가 넣은 값이
|
||
* 조용히 다른 것이 된다.
|
||
*
|
||
* 앞뒤 공백은 프레임워크의 `TrimStrings` 미들웨어가 요청 시점에 이미 제거하므로
|
||
* 이 경로의 판정 대상이 아니다.
|
||
*
|
||
* @scenario declared_type=string, value_disposition=accepted
|
||
*
|
||
* @effects string_setting_stored_verbatim
|
||
*/
|
||
#[Test]
|
||
public function string_setting_is_stored_verbatim(): void
|
||
{
|
||
foreach (['0123', 'false', '42'] as $value) {
|
||
$this->putSetting('seo.meta_keywords', ['value' => $value])->assertOk();
|
||
|
||
$stored = $this->stored('seo.meta_keywords');
|
||
$this->assertIsString($stored, "'{$value}' 가 문자열이 아닌 타입으로 캐스팅되었습니다.");
|
||
$this->assertSame($value, $stored, '문자열 값이 변형되었습니다.');
|
||
}
|
||
}
|
||
|
||
// ─── 타입 보존 ──────────────────────────────────────
|
||
|
||
/**
|
||
* boolean 설정에 문자열 "true" 를 보내도 boolean 으로 저장됩니다. (실패-먼저)
|
||
*
|
||
* @scenario declared_type=boolean, value_disposition=accepted
|
||
*
|
||
* @effects boolean_setting_stored_as_boolean
|
||
*/
|
||
#[Test]
|
||
public function boolean_setting_stores_boolean_not_string(): void
|
||
{
|
||
$this->putSetting('seo.sitemap_enabled', ['value' => 'false'])->assertOk();
|
||
|
||
$stored = $this->stored('seo.sitemap_enabled');
|
||
$this->assertIsBool($stored, '문자열이 그대로 저장되었습니다.');
|
||
$this->assertFalse($stored);
|
||
|
||
$this->putSetting('seo.sitemap_enabled', ['value' => '1'])->assertOk();
|
||
$this->assertTrue($this->stored('seo.sitemap_enabled'));
|
||
}
|
||
|
||
/**
|
||
* JSON 본문의 진짜 boolean 도 그대로 저장됩니다.
|
||
*
|
||
* @scenario declared_type=boolean, value_disposition=accepted
|
||
*
|
||
* @effects boolean_setting_stored_as_boolean
|
||
*/
|
||
#[Test]
|
||
public function native_boolean_is_preserved(): void
|
||
{
|
||
$this->putSetting('general.maintenance_mode', ['value' => true])->assertOk();
|
||
|
||
$this->assertTrue($this->stored('general.maintenance_mode'));
|
||
}
|
||
|
||
/**
|
||
* boolean 으로 해석할 수 없는 값은 거부합니다.
|
||
*
|
||
* 조용히 false 로 캐스팅하면 오타 입력이 "정상 저장" 으로 통과한다.
|
||
*
|
||
* @scenario declared_type=boolean, value_disposition=rejected
|
||
*
|
||
* @effects uninterpretable_value_rejected_with_422
|
||
*/
|
||
#[Test]
|
||
public function uninterpretable_boolean_is_rejected(): void
|
||
{
|
||
$this->putSetting('seo.sitemap_enabled', ['value' => 'maybe'])
|
||
->assertStatus(422)
|
||
->assertJsonValidationErrors(['value']);
|
||
}
|
||
|
||
/**
|
||
* 정수 설정에 숫자 문자열을 보내면 정수로 저장됩니다. (실패-먼저)
|
||
*
|
||
* @scenario declared_type=integer, value_disposition=accepted
|
||
*
|
||
* @effects integer_setting_stored_as_integer
|
||
*/
|
||
#[Test]
|
||
public function integer_setting_stores_integer_not_string(): void
|
||
{
|
||
$this->putSetting('upload.max_file_size', ['value' => '25'])->assertOk();
|
||
|
||
$stored = $this->stored('upload.max_file_size');
|
||
$this->assertIsInt($stored, '문자열이 그대로 저장되었습니다.');
|
||
$this->assertSame(25, $stored);
|
||
}
|
||
|
||
/**
|
||
* 정수로 해석할 수 없는 값은 거부합니다.
|
||
*
|
||
* @scenario declared_type=integer, value_disposition=rejected
|
||
*
|
||
* @effects uninterpretable_value_rejected_with_422
|
||
*/
|
||
#[Test]
|
||
public function uninterpretable_integer_is_rejected(): void
|
||
{
|
||
$this->putSetting('upload.max_file_size', ['value' => 'twenty'])
|
||
->assertStatus(422)
|
||
->assertJsonValidationErrors(['value']);
|
||
}
|
||
|
||
/**
|
||
* 비-문자열 설정에 빈 문자열을 보내면 null 로 비웁니다.
|
||
*
|
||
* @scenario declared_type=integer, value_disposition=cleared
|
||
*
|
||
* @effects setting_cleared_to_null
|
||
*/
|
||
#[Test]
|
||
public function empty_string_clears_non_string_setting(): void
|
||
{
|
||
$this->putSetting('upload.max_file_size', ['value' => '25'])->assertOk();
|
||
$this->putSetting('upload.max_file_size', ['value' => ''])->assertOk();
|
||
|
||
$this->assertNull($this->stored('upload.max_file_size'));
|
||
}
|
||
|
||
/**
|
||
* 배열 값도 저장할 수 있습니다.
|
||
*
|
||
* @scenario declared_type=array, value_disposition=accepted
|
||
*
|
||
* @effects array_setting_stored_as_array
|
||
*/
|
||
#[Test]
|
||
public function array_value_is_accepted(): void
|
||
{
|
||
$this->putSetting('seo.bot_user_agents', ['value' => ['Googlebot', 'Bingbot']])->assertOk();
|
||
|
||
$this->assertSame(['Googlebot', 'Bingbot'], $this->stored('seo.bot_user_agents'));
|
||
}
|
||
|
||
/**
|
||
* boolean 설정도 빈 값으로 되돌릴 수 있습니다.
|
||
*
|
||
* @scenario declared_type=boolean, value_disposition=cleared
|
||
*
|
||
* @effects setting_cleared_to_null
|
||
*/
|
||
#[Test]
|
||
public function boolean_setting_can_be_cleared(): void
|
||
{
|
||
$this->putSetting('seo.sitemap_enabled', ['value' => 'true'])->assertOk();
|
||
$this->putSetting('seo.sitemap_enabled', ['value' => ''])->assertOk();
|
||
|
||
$this->assertNull($this->stored('seo.sitemap_enabled'), 'boolean 설정을 비울 수 없습니다.');
|
||
}
|
||
|
||
/**
|
||
* 배열 설정도 빈 값으로 되돌릴 수 있습니다.
|
||
*
|
||
* @scenario declared_type=array, value_disposition=cleared
|
||
*
|
||
* @effects setting_cleared_to_null
|
||
*/
|
||
#[Test]
|
||
public function array_setting_can_be_cleared(): void
|
||
{
|
||
$this->putSetting('seo.bot_user_agents', ['value' => ['Googlebot']])->assertOk();
|
||
$this->putSetting('seo.bot_user_agents', ['value' => ''])->assertOk();
|
||
|
||
$this->assertNull($this->stored('seo.bot_user_agents'), '배열 설정을 비울 수 없습니다.');
|
||
}
|
||
|
||
// ─── 길이 상한 ──────────────────────────────────────
|
||
|
||
/**
|
||
* 문자열 상한(1000자)은 그대로 유지됩니다.
|
||
*
|
||
* @scenario declared_type=string, value_disposition=rejected
|
||
*
|
||
* @effects oversized_value_rejected_with_422
|
||
*/
|
||
#[Test]
|
||
public function oversized_string_is_rejected(): void
|
||
{
|
||
$this->putSetting('seo.meta_keywords', ['value' => str_repeat('a', 1001)])
|
||
->assertStatus(422)
|
||
->assertJsonValidationErrors(['value']);
|
||
}
|
||
|
||
/**
|
||
* 배열 값의 JSON 직렬화 상한(5000자)도 거부합니다.
|
||
*
|
||
* 문자열 상한만 있으면 배열 축으로 얼마든지 큰 값이 들어온다 — 설정 JSON 파일이
|
||
* 요청 하나로 무한정 커진다.
|
||
*
|
||
* @scenario declared_type=array, value_disposition=rejected
|
||
*
|
||
* @effects oversized_array_rejected_with_422
|
||
*/
|
||
#[Test]
|
||
public function oversized_array_is_rejected(): void
|
||
{
|
||
// 항목당 12자 남짓 × 500 → JSON 직렬화 6000자 초과
|
||
$agents = array_map(static fn (int $i): string => 'Bot'.str_pad((string) $i, 8, '0', STR_PAD_LEFT), range(1, 500));
|
||
|
||
$this->assertGreaterThan(5000, mb_strlen((string) json_encode($agents)), '픽스처가 상한을 넘지 못합니다.');
|
||
|
||
$this->putSetting('seo.bot_user_agents', ['value' => $agents])
|
||
->assertStatus(422)
|
||
->assertJsonValidationErrors(['value']);
|
||
}
|
||
}
|