Files
Gnuboard7/tests/Unit/Installer/PrivilegedDbAccountGuardTest.php
T
HeuJung 062a88ca04 feat(core): DB 최고권한 계정 차단 + OPcache 상태 안내
설치 단계와 코어 부팅의 정책이 엇갈려 있었다. 인스톨러는 root 를 자유롭게
허용하면서, 설치 후 부팅은 같은 값을 보고 확장 로딩 전체를 조용히 스킵했다.
설치는 성공하지만 이후 깨진 화면만 뜨고 원인은 로그 한 줄에만 남았다.

판정을 프레임워크 의존성 0 인 공용 SSoT 로 모아 인스톨러와 코어가 같은 답을
내게 하고, 설치 단계에서 서버 2경로로 차단하며, 설치 후 변경은 전용 에러
페이지로 드러낸다. 가드를 boot 가 아닌 미들웨어에 두어 artisan 이 죽지 않게
해 복구 수단을 남겼다. admin 은 일반 권한 운영 계정 오차단을 막기 위해 차단
목록에서 제외했다.

OPcache 는 상태 조회 경로 자체가 없어 성능 저하를 인지할 방법이 없었다.
설치 단계 경고(비차단)와 관리자 정보 탭 표시를 같은 판정 SSoT 로 추가했다.
확장 로드 여부만으로는 부족해 opcache.enable 지시자까지 확인하며, ini_get 이
차단된 환경은 "확인 불가"로 폴백해 경고도 차단도 하지 않는다.
2026-07-20 14:17:49 +09:00

287 lines
9.8 KiB
PHP

<?php
namespace Tests\Unit\Installer;
use App\Support\PrivilegedDatabaseAccounts;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
/**
* 인스톨러 DB 최고권한 계정 차단 회귀 테스트
*
* 배경: 인스톨러에는 사용자명 검증이 전무했고, `.env.example` fallback 기본값이
* `DB_WRITE_USERNAME=root` 였다. 그 결과 root 로 설치하면 설치는 성공하지만
* 이후 코어 부팅이 확장 로딩 전체를 스킵해 깨진 화면만 남았다.
* 설치 단계에서 미리 차단하는지 보장한다.
*
* 검증 축:
* - Step 3 폼 POST 검증: Write DB / Read DB 양쪽, 최고권한 / 빈 값 / 정상
* - 인스톨러가 코어와 같은 판정 SSoT 를 공유하는지 (목록 이원화 차단)
* - 다국어 키 존재 및 :username 치환 동작 (ko/en)
*/
class PrivilegedDbAccountGuardTest extends TestCase
{
public static function setUpBeforeClass(): void
{
if (! defined('BASE_PATH')) {
define('BASE_PATH', dirname(__DIR__, 3));
}
require_once dirname(__DIR__, 3).'/public/install/includes/config.php';
require_once dirname(__DIR__, 3).'/public/install/includes/functions.php';
require_once dirname(__DIR__, 3).'/public/install/includes/request-handler.php';
}
protected function setUp(): void
{
parent::setUp();
// lang() 은 전역 $translations 를 읽는다 (functions.php:316).
$GLOBALS['translations'] = require dirname(__DIR__, 3).'/public/install/lang/ko.php';
}
/**
* config.php 가 코어의 판정 SSoT 클래스를 로드해야 한다.
* 인스톨러가 자체 목록을 갖게 되면 코어와 판정이 어긋난다.
*
* @effects installer_and_core_share_the_same_judgement_class
*/
public function test_installer_loads_shared_judgement_class(): void
{
$this->assertTrue(
class_exists(PrivilegedDatabaseAccounts::class),
'config.php 가 PrivilegedDatabaseAccounts 를 로드하지 않았습니다.'
);
}
/**
* Step 3 폼 POST 에서 Write DB 최고권한 계정이 거부되어야 한다.
*
* @scenario account_kind=privileged, enforcement_point=installer_step3_write, install_state=not_completed
*
* @effects installer_step3_post_rejects_privileged_username
*/
#[DataProvider('privilegedUsernameProvider')]
public function test_step3_rejects_privileged_write_username(string $username): void
{
$errors = [];
validateDbUsername($username, 'db_write_username', $errors);
$this->assertArrayHasKey(
'db_write_username',
$errors,
sprintf('사용자명 "%s" 이 거부되지 않았습니다.', $username)
);
}
/**
* Read DB 도 동일하게 차단되어야 한다 (Write 만 막으면 Read 로 우회 가능).
*
* @scenario account_kind=privileged, enforcement_point=installer_step3_read, install_state=not_completed
*
* @effects installer_step3_post_rejects_privileged_username
*/
#[DataProvider('privilegedUsernameProvider')]
public function test_step3_rejects_privileged_read_username(string $username): void
{
$errors = [];
validateDbUsername($username, 'db_read_username', $errors);
$this->assertArrayHasKey('db_read_username', $errors);
}
/**
* 빈 사용자명(설정 누락)도 Write DB 에서 거부되어야 한다.
*
* @scenario account_kind=empty, enforcement_point=installer_step3_write, install_state=not_completed
*
* @effects installer_step3_post_rejects_empty_username
*/
#[DataProvider('emptyUsernameProvider')]
public function test_step3_rejects_empty_write_username(string $username): void
{
$errors = [];
validateDbUsername($username, 'db_write_username', $errors);
$this->assertArrayHasKey('db_write_username', $errors);
}
/**
* 빈 사용자명은 Read DB 에서도 거부되어야 한다.
*
* @scenario account_kind=empty, enforcement_point=installer_step3_read, install_state=not_completed
*
* @effects installer_step3_post_rejects_empty_username
*/
#[DataProvider('emptyUsernameProvider')]
public function test_step3_rejects_empty_read_username(string $username): void
{
$errors = [];
validateDbUsername($username, 'db_read_username', $errors);
$this->assertArrayHasKey('db_read_username', $errors);
}
/**
* @return array<string, array{0: string}>
*/
public static function privilegedUsernameProvider(): array
{
return [
'root' => ['root'],
'postgres' => ['postgres'],
'sa' => ['sa'],
'sys' => ['sys'],
'system' => ['system'],
'대문자 ROOT (우회 차단)' => ['ROOT'],
'앞뒤 공백 " root " (우회 차단)' => [' root '],
];
}
/**
* @return array<string, array{0: string}>
*/
public static function emptyUsernameProvider(): array
{
return [
'빈 문자열' => [''],
'공백만' => [' '],
];
}
/**
* 정상 계정은 Write DB 에서 통과해야 한다 — 특히 `admin` 오차단 방지.
*
* @scenario account_kind=normal, enforcement_point=installer_step3_write, install_state=not_completed
*
* @effects installer_step3_post_accepts_normal_username
*/
#[DataProvider('acceptedUsernameProvider')]
public function test_step3_accepts_normal_write_username(string $username): void
{
$errors = [];
validateDbUsername($username, 'db_write_username', $errors);
$this->assertSame(
[],
$errors,
sprintf('정상 사용자명 "%s" 이 잘못 거부됐습니다.', $username)
);
}
/**
* 정상 계정은 Read DB 에서도 통과해야 한다 (과잉 차단 방지).
*
* @scenario account_kind=normal, enforcement_point=installer_step3_read, install_state=not_completed
*
* @effects installer_step3_post_accepts_normal_username
*/
#[DataProvider('acceptedUsernameProvider')]
public function test_step3_accepts_normal_read_username(string $username): void
{
$errors = [];
validateDbUsername($username, 'db_read_username', $errors);
$this->assertSame([], $errors);
}
/**
* @return array<string, array{0: string}>
*/
public static function acceptedUsernameProvider(): array
{
return [
'g7user' => ['g7user'],
'admin (일반 권한 계정 오차단 방지)' => ['admin'],
'rootuser' => ['rootuser'],
'g7_root' => ['g7_root'],
'my_site_db' => ['my_site_db'],
];
}
/**
* 빈 값과 최고권한 계정은 서로 다른 메시지여야 한다 — 운영자가 원인을 구분해야 한다.
*
* @effects empty_and_privileged_produce_distinct_messages
*/
public function test_empty_and_privileged_produce_different_messages(): void
{
$emptyErrors = [];
validateDbUsername('', 'db_write_username', $emptyErrors);
$privilegedErrors = [];
validateDbUsername('root', 'db_write_username', $privilegedErrors);
$this->assertNotSame(
$emptyErrors['db_write_username'],
$privilegedErrors['db_write_username']
);
}
/**
* 차단 메시지는 입력한 계정명을 치환해 보여줘야 한다 (:username).
*
* @effects installer_message_substitutes_username_and_offers_alternative
*/
public function test_privileged_message_substitutes_username(): void
{
$errors = [];
validateDbUsername('postgres', 'db_write_username', $errors);
$message = $errors['db_write_username'];
$this->assertStringContainsString('postgres', $message);
$this->assertStringNotContainsString(':username', $message, '치환되지 않은 플레이스홀더가 남아 있습니다.');
}
/**
* 차단만 하고 해결책이 없으면 사용자가 진행 불가 상태에 갇힌다.
* ko/en 양쪽 문구가 대안(전용 계정 생성)을 제시하는지 확인한다.
*/
#[DataProvider('localeProvider')]
public function test_message_offers_an_alternative(string $locale, string $needle): void
{
$translations = require dirname(__DIR__, 3)."/public/install/lang/{$locale}.php";
$this->assertArrayHasKey('error_db_username_privileged', $translations);
$this->assertStringContainsString($needle, $translations['error_db_username_privileged']);
}
/**
* @return array<string, array{0: string, 1: string}>
*/
public static function localeProvider(): array
{
return [
'ko' => ['ko', '전용 데이터베이스 사용자'],
'en' => ['en', 'dedicated database user'],
];
}
/**
* OPcache 관련 다국어 키가 ko/en 양쪽에 존재해야 한다 (요구사항 카드 렌더용).
*
* @effects installer_opcache_lang_keys_exist_in_ko_and_en
*/
#[DataProvider('opcacheLocaleProvider')]
public function test_opcache_lang_keys_exist(string $locale): void
{
$translations = require dirname(__DIR__, 3)."/public/install/lang/{$locale}.php";
foreach (['opcache', 'opcache_enabled', 'opcache_disabled_short', 'opcache_disabled_warning', 'opcache_unknown'] as $key) {
$this->assertArrayHasKey($key, $translations, sprintf('%s 로케일에 %s 키가 없습니다.', $locale, $key));
}
}
/**
* @return array<string, array{0: string}>
*/
public static function opcacheLocaleProvider(): array
{
return [
'ko' => ['ko'],
'en' => ['en'],
];
}
}