Merge pull request from gnuboard:HeuJung/issue641

HeuJung/issue641
This commit is contained in:
정정홍
2026-09-04 10:24:19 +09:00
committed by GitHub
37 changed files with 4026 additions and 118 deletions
+11
View File
@@ -118,6 +118,17 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
# ── .env 키 단위 우선 (선택) ────────────────────────────────────────────────
# 기본값은 관리자 환경설정(admin UI)이 운영 SSoT 이고, 저장된 값이 아래 항목들의
# .env 값을 덮습니다. 이 스위치를 켜면 .env 에 **값이 명시된 항목만** 그 덮어쓰기에서
# 빠져 .env 가 권위를 갖고, 관리자 화면의 해당 필드는 편집 불가로 표시됩니다.
# 켜지 않으면 아무 것도 달라지지 않습니다(기존 설치 무영향).
# 주의: 이 파일은 값을 채워 배포되므로, 켜는 즉시 APP_NAME·LOG_LEVEL·
# G7_UPDATE_GITHUB_URL 같은 이미 채워진 항목이 함께 잠깁니다.
# .env 만 편집한 뒤에는 php artisan config:cache 를 다시 실행해야 반영됩니다.
# 대상 항목과 규약: docs/backend/admin-settings-access.md
# G7_ENV_PRIORITY=true
# 코어 업데이트
G7_UPDATE_GITHUB_URL=https://github.com/gnuboard/g7
G7_UPDATE_GITHUB_TOKEN=
+4
View File
@@ -107,3 +107,7 @@ INSTALLER_COMPLETED=true
# Telescope Configuration
TELESCOPE_ENABLED=false
TELESCOPE_DB_CONNECTION=mysql
# .env 키 단위 우선(G7_ENV_PRIORITY)은 테스트 환경에서 활성화하지 않습니다.
# 이 파일에 활성 라인을 두면 테스트가 개발 머신의 .env 명시 상태에 좌우되어
# 재현 불가능해집니다. 잠금 축은 config 를 직접 주입해 검증합니다.
+18
View File
@@ -630,6 +630,24 @@ TLS 가 앞단에서 종단되고 앱에는 HTTP 로 전달되는 구성(AWS ALB
> 상세: [module-settings.md](docs/extension/module-settings.md) "카탈로그 병합 설정의 공개 응답"
### 설정 주입과 `.env` 우선
관리자 환경설정(`storage/app/settings/*.json`)은 `SettingsServiceProvider` 를 거쳐 `config()` 에 주입되어 `.env` 유래 값을 덮는다. `.env` 를 배포 기준값으로 관리하는 설치(컨테이너·IaC·다중 서버)를 위해 그 소유권을 **키 단위**로 되돌리는 옵트인 스위치(`G7_ENV_PRIORITY`)가 있고, 판정은 `App\Support\EnvPriority` 가 단독으로 소유한다.
| 금지 | 올바른 사용 |
|--------|---------------|
| `SettingsServiceProvider` 에 settings → config 주입을 추가하면서 `EnvPriority::MAP`(또는 `EXEMPT`) 미등재 | 맵을 동반 갱신 — 등재를 잊으면 그 키는 영원히 settings 승으로 남고, 화면상 "그 키가 `.env` 에 없다"와 구분되지 않는다 |
| env 명시 여부를 런타임 `env()` 로 판별 | config 빌드 시점 캡처(`config/env-priority.php`, `disk_explicit` 패턴) — `config:cache` 에서 `env()` 는 null 이라 판정이 영구 미발동한다 |
| 잠긴 키 표시값에 sensitive 값(`.env` 비밀값) 노출 | 잠금 표시만 — 민감 키는 유효값 오버레이 대상에서 제외한다 |
| 제거된 키의 **부재를 값으로 읽는 지점**을 그대로 둠 (게이트·기본값 주입·형제 폴백) | 그 자리는 유효값(런타임 config)으로 보정하거나 잠금일 때만 건너뛴다 — `array_key_exists`/`empty()` 로 판정하면 "저장값이 없는 호출"과 "잠겨서 제거된 호출"이 구분되지 않는다 |
명시 판별은 strict 다 — `KEY=`(빈 값)·`KEY=null`·미설정만 미명시이고, `APP_DEBUG=false`·`REDIS_DB=0` 같은 falsy 명시는 명시로 취급한다(`?:` 를 쓰면 그 값들이 미명시로 오판된다).
잠금은 **그 키의 주입만** 건너뛰는 것이다. 그런데 제거된 키를 읽는 자리가 그 부재를 값으로 해석하면 잠금이 형제 설정까지 무너뜨린다 — 다섯 형태가 있고 전부 오류도 로그도 남기지 않는다: ① 마스터 토글의 OFF 강제(웹소켓) ② 게이트가 되는 키(디버그 모드 → 로그 레벨 강제·프록시 적용, 메일 드라이버 → mailgun/ses 하위 주입) ③ 저장값이 비어도 박히던 기본값(`services.{mailgun.endpoint,ses.region}`) ④ 형제 값으로의 폴백(웹소켓 server endpoint 가 관리자 소유 client 값을 `.env` 자리에 덮어씀) ⑤ 파생 판정(`storage_driver=s3` → `attachment.disk`). 판정에 쓰는 자리는 유효값으로 보정하고, 주입을 건너뛰어야 하는 자리는 `EnvPriority::isLocked()` 를 직접 묻는다.
> 상세: [admin-settings-access.md](docs/backend/admin-settings-access.md) "env 우선 모드(G7_ENV_PRIORITY)"
> 자동 차단: 정적 검사가 주입 메서드의 필터 배선 실존을 확인하고, 맵 패리티·캡처 일치·`env()` 재유입과 표시값·저장 게이트는 계약 테스트가 잠근다. 부재를 값으로 읽는 자리는 의미 판정 영역이라 정적 검사가 덮지 못한다 — 게이트·기본값·폴백·파생 네 축을 각각 고정하는 회귀 테스트가 그 자리를 잠근다
### 목록 조회 컬럼 프루닝과 지연 조인
| 금지 | 올바른 사용 |
+1
View File
@@ -27,6 +27,7 @@
- 확장 문서에 「레이아웃 편집기 스펙」 항목이 추가되었습니다. 그 확장이 레이아웃 편집기에 무엇을 선언했는지(추가 가능한 화면 요소, 스타일 조절 항목, 미리보기용 샘플 데이터, 화면 상태)와 화면 요소·데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담으며, 편집기 스펙을 두지 않은 확장에는 그것이 정상인지 아닌지를 적습니다.
- 확장 문서 검사가 「설명 자리를 비워 둔 상태」도 미작성으로 셉니다. 종전에는 채워 넣으라는 표시만 지우고 내용을 쓰지 않으면 검사를 통과해, 빈 문서가 완비된 것으로 집계되었습니다.
- 확장의 화면에 데이터를 붙였는데 레이아웃 편집기 미리보기에서 그 자리가 비는 경우를 문서가 실측해 알려 줍니다. 이 어긋남은 실제 화면이 정상 동작해 아무 오류도 남지 않으므로 종전에는 편집기를 열어 보기 전까지 드러나지 않았습니다.
- `.env` 를 운영 기준값으로 삼는 설치를 지원합니다. `.env` 에 `G7_ENV_PRIORITY=true` 를 넣으면 `.env` 에 값이 적혀 있는 항목만 관리자 환경설정 저장값의 영향을 받지 않고, 그 항목은 관리자 화면에서 「`.env` 고정」 표시와 함께 편집 불가가 되며 실제로 적용 중인 값이 표시됩니다. 저장 요청에 그 항목이 섞여 있어도 서버가 걸러냅니다. 종전에는 설치 직후부터 관리자 화면 저장값이 항상 이겨 메일·드라이버·디버그 등 20여 개 항목의 `.env` 설정이 조용히 무시되었습니다. 스위치를 넣지 않으면 종전과 동일하게 동작합니다. (#37 @glitter-gim 님께서 건의해주셨습니다.)
### Changed
+30
View File
@@ -597,6 +597,36 @@ TRUSTED_PROXIES=*
방문자가 같은 IP 로 기록됩니다. 값 선택 기준과 도입 시 후속 조치는
[docs/backend/reverse-proxy.md](docs/backend/reverse-proxy.md) 를 참고하세요.
### `.env` 를 운영 기준값으로 삼기 (선택)
기본 설계에서 **관리자 환경설정 화면이 운영 기준**입니다. 설치가 끝나면 메일·드라이버·디버그
같은 항목은 화면에서 관리하며, 그 값이 `.env` 의 같은 항목보다 우선합니다. 설치 마법사가
초기값을 저장하므로 **설치 직후부터** 그 우선순위가 적용됩니다.
컨테이너·구성 관리 도구·다중 서버처럼 `.env` 를 배포 기준값으로 관리하는 환경에서는 이
우선순위를 뒤집을 수 있습니다. `.env` 에 다음 한 줄을 추가하세요.
```dotenv
G7_ENV_PRIORITY=true
```
켜면 **`.env` 에 값이 적혀 있는 항목만** 화면 저장값의 영향을 받지 않습니다. 그 항목들은
관리자 화면에서 편집 불가로 표시되고, 화면에는 실제로 적용 중인 값이 나타납니다. 값이 비어
있거나 줄이 없는 항목은 종전대로 화면에서 관리합니다.
주의할 점:
- `.env.example` 은 `APP_NAME`, `LOG_LEVEL`, `G7_UPDATE_GITHUB_URL` 등에 값을 채워 배포합니다.
그 파일을 복사해 쓰고 있다면 스위치를 켜는 즉시 **그 항목들도 함께 잠깁니다.** 화면에서
관리하고 싶은 항목은 값을 비우거나(`KEY=`) 줄을 지우세요.
- `.env` 에 `BROADCAST_CONNECTION` 이나 `REVERB_*` 를 적으면 웹소켓 사용 여부까지 `.env` 가
결정합니다. 관리자 화면의 웹소켓 토글은 그 설치에서 동작하지 않습니다.
- `.env` 만 고친 경우에는 `php artisan config:cache` 를 다시 실행해야 반영됩니다. 관리자
화면에서 저장하는 경우에는 자동으로 처리됩니다.
- 비밀번호·시크릿·토큰 같은 값은 잠금 표시만 되고 화면에 나타나지 않습니다.
스위치를 넣지 않으면 아무 것도 달라지지 않습니다(기존 설치 무영향).
### `.env` 권한 강화 (선택)
`.env` 는 DB 비밀번호와 `APP_KEY` 등 평문 자격증명을 포함합니다. 인스톨러는 설치 직후 `.env` 의 권한을 임의로 변경하지 않으므로, 운영자가 환경에 맞춰 직접 강화 권한을 적용할 수 있습니다.
@@ -56,6 +56,9 @@ class PlaywrightIssueToken extends Command
}
// ② 명시 옵트인 — 환경변수 없이는 production 호출 실수 차단
// 여기의 `env()` 는 config:cache 의 영향을 받지 않는다 — 이 값은 `.env` 파일이 아니라
// 호출자가 그 자리에서 넘기는 프로세스 환경변수이고, config 로 캡처할 대상도 아니다.
// (`.env` 유래 값을 런타임 `env()` 로 읽는 것은 금지다 — config 로 캡처해야 한다.)
if (env('G7_PLAYWRIGHT_BYPASS') !== '1') {
$this->error('G7_PLAYWRIGHT_BYPASS=1 환경변수가 필요합니다. (예: PowerShell — $env:G7_PLAYWRIGHT_BYPASS=\'1\')');
@@ -104,6 +104,9 @@ class PlaywrightSeedLayout extends Command
}
// ② 명시 옵트인 — 환경변수 없이는 production 호출 실수 차단
// 여기의 `env()` 는 config:cache 의 영향을 받지 않는다 — 이 값은 `.env` 파일이 아니라
// 호출자가 그 자리에서 넘기는 프로세스 환경변수이고, config 로 캡처할 대상도 아니다.
// (`.env` 유래 값을 런타임 `env()` 로 읽는 것은 금지다 — config 로 캡처해야 한다.)
if (env('G7_PLAYWRIGHT_BYPASS') !== '1') {
$this->error('G7_PLAYWRIGHT_BYPASS=1 환경변수가 필요합니다. (예: PowerShell — $env:G7_PLAYWRIGHT_BYPASS=\'1\')');
@@ -15,6 +15,7 @@ use App\Services\DriverConnectionTester;
use App\Services\DriverRegistryService;
use App\Services\OutboundProxyTester;
use App\Services\SettingsService;
use App\Support\EnvPriority;
use App\Support\TrustedProxyDiagnostic;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
@@ -36,6 +37,27 @@ class SettingsController extends AdminBaseController
parent::__construct();
}
/**
* 설정 응답에 동봉할 `_meta` 를 만듭니다.
*
* 조회와 저장 두 응답이 같은 모양이어야 화면이 저장 직후에도 같은 판정을 이어갑니다 —
* 한쪽만 필드가 늘면 저장 후 잠금 표시가 조용히 사라집니다.
*
* - `limits`: 입력 한계값 (core.settings_limits)
* - `env_priority_enabled`: `.env` 우선 모드 활성 여부 (안내 배너 표시 판정)
* - `env_locked`: `.env` 로 잠긴 필드 목록 (프론트엔드 키 기준)
*
* @return array<string, mixed> 응답 메타 배열
*/
private function buildSettingsMeta(): array
{
return [
'limits' => config('core.settings_limits', []),
'env_priority_enabled' => EnvPriority::enabled(),
'env_locked' => $this->settingsService->envLockedMeta(),
];
}
/**
* 모든 시스템 설정을 조회합니다.
*
@@ -46,7 +68,7 @@ class SettingsController extends AdminBaseController
try {
$settings = $this->settingsService->getAllSettings();
$settings['available_drivers'] = $this->driverRegistryService->getAllAvailableDrivers();
$settings['_meta'] = ['limits' => config('core.settings_limits', [])];
$settings['_meta'] = $this->buildSettingsMeta();
return $this->success('settings.fetch_success',
(new SettingsResource($settings))->toArray(request())
@@ -74,7 +96,7 @@ class SettingsController extends AdminBaseController
// 저장 후 전체 설정 반환 (관리자 UI 상태 업데이트용)
$allSettings = $this->settingsService->getAllSettings();
$allSettings['available_drivers'] = $this->driverRegistryService->getAllAvailableDrivers();
$allSettings['_meta'] = ['limits' => config('core.settings_limits', [])];
$allSettings['_meta'] = $this->buildSettingsMeta();
return $this->success('settings.save_success', [
'settings' => $allSettings,
+104 -18
View File
@@ -4,6 +4,7 @@ namespace App\Providers;
use App\Repositories\JsonConfigRepository;
use App\Support\AllowedExtensions;
use App\Support\EnvPriority;
use App\Support\ExtensionSettingsMirror;
use App\Support\OutboundProxy;
use Illuminate\Support\Facades\Config;
@@ -82,6 +83,10 @@ class SettingsServiceProvider extends ServiceProvider
return;
}
// .env 우선 모드에서 `.env` 가 소유권을 가져간 키를 제거한다 — 아래 가드들이
// 제거된 키를 자연히 건너뛰므로 지점마다 조건을 심지 않는다 (스위치 OFF 면 무동작).
$mailSettings = EnvPriority::filterLocked('mail', $mailSettings);
// 메일러 설정
if (! empty($mailSettings['mailer'])) {
Config::set('mail.default', $mailSettings['mailer']);
@@ -108,8 +113,13 @@ class SettingsServiceProvider extends ServiceProvider
Config::set('mail.mailers.smtp.encryption', $mailSettings['encryption'] ?: null);
}
// 드라이버별 설정
$mailer = $mailSettings['mailer'] ?? '';
// 드라이버별 설정 — 마스터 드라이버가 `.env` 로 잠기면 저장값이 이 배열에서 제거되어
// 게이트가 빈 문자열이 된다. 그대로 두면 어느 분기에도 들어가지 않아 mailgun/ses 하위
// 저장값(도메인·자격증명 등, 각자 잠기지 않았다)이 조용히 주입되지 않는다.
// 잠긴 경우에는 유효값(= `.env` 유래 config)으로 게이트를 보정한다.
$mailer = EnvPriority::isLocked('mail.mailer')
? (string) config('mail.default')
: ($mailSettings['mailer'] ?? '');
if ($mailer === 'mailgun') {
if (! empty($mailSettings['mailgun_domain'])) {
@@ -118,9 +128,15 @@ class SettingsServiceProvider extends ServiceProvider
if (! empty($mailSettings['mailgun_secret'])) {
Config::set('services.mailgun.secret', $mailSettings['mailgun_secret']);
}
Config::set('services.mailgun.endpoint',
! empty($mailSettings['mailgun_endpoint']) ? $mailSettings['mailgun_endpoint'] : 'api.mailgun.net'
);
// 저장값이 비어도 기본값을 박는다 — 종전 동작이다. 주입을 건너뛰는 경우는
// `.env` 우선 모드가 이 키를 가져간 때뿐이므로 잠금 여부를 직접 묻는다.
// 키 존재 여부(`array_key_exists`)로 판정하면 "저장값이 없는 호출"과 "잠겨서 제거된 호출"이
// 구분되지 않아, 전자에서도 기본값 주입이 사라진다.
if (! EnvPriority::isLocked('mail.mailgun_endpoint')) {
Config::set('services.mailgun.endpoint',
! empty($mailSettings['mailgun_endpoint']) ? $mailSettings['mailgun_endpoint'] : 'api.mailgun.net'
);
}
}
if ($mailer === 'ses') {
@@ -130,9 +146,12 @@ class SettingsServiceProvider extends ServiceProvider
if (! empty($mailSettings['ses_secret'])) {
Config::set('services.ses.secret', $mailSettings['ses_secret']);
}
Config::set('services.ses.region',
! empty($mailSettings['ses_region']) ? $mailSettings['ses_region'] : 'ap-northeast-2'
);
// mailgun_endpoint 와 같은 사유의 가드 (위 주석 참조).
if (! EnvPriority::isLocked('mail.ses_region')) {
Config::set('services.ses.region',
! empty($mailSettings['ses_region']) ? $mailSettings['ses_region'] : 'ap-northeast-2'
);
}
}
// 발신자 설정
@@ -156,6 +175,9 @@ class SettingsServiceProvider extends ServiceProvider
return;
}
// .env 우선 모드: `.env` 가 소유한 키 제거 (스위치 OFF 면 무동작).
$generalSettings = EnvPriority::filterLocked('general', $generalSettings);
if (! empty($generalSettings['site_name'])) {
// site_name 이 다국어 JSON array 일 수 있으므로 현재/폴백 로케일 string 으로 정규화한다
// (공개#49). raw array 를 config('app.name') 에 넣으면 app.blade.php 의
@@ -240,6 +262,10 @@ class SettingsServiceProvider extends ServiceProvider
*/
private function applyDebugConfig(JsonConfigRepository $configRepository): void
{
// G7_PLAYWRIGHT_BYPASS 는 호출자가 그 프로세스에만 부여하는 프로세스 환경변수다 —
// `.env` 파일에 적히지 않으므로 config:cache 로 박제될 대상이 아니고, env() 가
// 프로세스 환경을 그대로 읽으므로 config:cache 환경에서도 정상 판별된다.
// (`.env` 유래 값의 런타임 env() 판별이 무력해지는 함정과는 다른 축이다.)
if (app()->environment('testing') || env('G7_PLAYWRIGHT_BYPASS') === '1') {
return;
}
@@ -250,14 +276,29 @@ class SettingsServiceProvider extends ServiceProvider
return;
}
$isDebugMode = isset($debugSettings['mode']) && (bool) $debugSettings['mode'];
// .env 우선 모드: `.env` 가 소유한 키 제거 (스위치 OFF 면 무동작).
$debugSettings = EnvPriority::filterLocked('debug', $debugSettings);
$modeLocked = EnvPriority::isLocked('debug.mode');
$logLevelLocked = EnvPriority::isLocked('debug.log_level');
// 디버그 모드가 `.env` 로 잠기면 그 유효값은 config('app.debug')(= APP_DEBUG)다.
// 저장값을 읽으면 잠금으로 키가 사라져 항상 false 가 되고, 아래 두 2차 효과
// (로그 레벨 강제·프록시 게이트)가 운영자 의도와 반대로 동작한다.
$isDebugMode = $modeLocked
? (bool) config('app.debug')
: (isset($debugSettings['mode']) && (bool) $debugSettings['mode']);
if (isset($debugSettings['mode'])) {
Config::set('app.debug', $isDebugMode);
}
// debug 모드가 true이면 log_level을 debug로 강제 설정
if ($isDebugMode) {
// debug 모드가 true이면 log_level을 debug로 강제 설정.
// 단 로그 레벨이 `.env` 로 잠긴 설치에서는 강제 자체를 하지 않는다 — 그 강제는
// 저장값 경로의 편의 규칙이고, 잠긴 키의 권위는 `.env` 에 있다.
if ($logLevelLocked) {
$logLevel = null;
} elseif ($isDebugMode) {
$logLevel = 'debug';
} elseif (! empty($debugSettings['log_level'])) {
$logLevel = $debugSettings['log_level'];
@@ -279,7 +320,10 @@ class SettingsServiceProvider extends ServiceProvider
// 아웃바운드 HTTP 프록시 설정.
// 적용 여부 판정은 OutboundProxy 가 단독으로 소유한다 — 디버그 모드가 꺼져 있으면
// 저장값이 남아 있어도 null 이 되어 주입되지 않는다.
Config::set('g7.outbound_proxy', OutboundProxy::resolve($debugSettings));
// 게이트 값(mode)은 위에서 구한 유효값으로 보정해 넘긴다 — `+` 는 존재하는 키를
// 덮지 않으므로 잠기지 않은 설치에서는 종전과 동일하다. 보정이 없으면 디버그 모드가
// `.env` 로 잠긴 설치에서 프록시가 영구 미적용된다 (오류·로그 없이).
Config::set('g7.outbound_proxy', OutboundProxy::resolve($debugSettings + ['mode' => $isDebugMode]));
}
/**
@@ -301,10 +345,17 @@ class SettingsServiceProvider extends ServiceProvider
return;
}
// .env 우선 모드: `.env` 가 소유한 키 제거 (스위치 OFF 면 무동작).
// 아래 apply*Config 들은 이 배열을 인자로 받으므로 redis/reverb 처럼 한 설정이
// 여러 config 키를 파생시키는 경우도 한 번에 처리된다.
$driverSettings = EnvPriority::filterLocked('drivers', $driverSettings);
// testing 환경에서는 drivers.json의 cache/session/queue 오버라이드 차단 — 테스트 격리 보호
// (storage/app/settings/drivers.json은 shared 파일이므로 dev의 Redis/DB 드라이버가
// testing으로 흘러들어가면 dev 캐시를 오염시킴)
$isTestingEnv = env('APP_ENV') === 'testing';
// env() 직접 호출은 config:cache 환경에서 null 로 고정되어 가드가 무력해지므로
// 해석된 환경(app()->environment)으로 판정한다 — applyDebugConfig 와 동일 규약.
$isTestingEnv = app()->environment('testing');
// 캐시 드라이버 설정
if (! $isTestingEnv && ! empty($driverSettings['cache_driver'])) {
@@ -346,7 +397,14 @@ class SettingsServiceProvider extends ServiceProvider
// 빈 문자열도 미명시로 취급한다 — `ATTACHMENT_DISK=` 가 복사된 .env 에서
// 빈 값을 명시로 읽으면 전환이 영구 미발동한다 (config 정규화의 2차 방어).
// 기존 행은 행 disk 로 서빙되므로 신구 디스크 혼재는 안전하다.
if (($driverSettings['storage_driver'] ?? null) === 's3' && in_array(config('attachment.disk_explicit'), [null, ''], true)) {
// storage_driver 가 `.env` 로 잠긴 설치에서는 저장값이 제거되어 있으므로 유효값으로
// 판정한다. 보정하지 않으면 `FILESYSTEM_DISK=s3` 를 잠근 운영자의 첨부 업로드만
// 로컬 디스크로 되돌아간다 (기존 행은 행 disk 로 서빙되므로 오류 없이 갈라진다).
$storageDriver = EnvPriority::isLocked('drivers.storage_driver')
? config('filesystems.default')
: ($driverSettings['storage_driver'] ?? null);
if ($storageDriver === 's3' && in_array(config('attachment.disk_explicit'), [null, ''], true)) {
Config::set('attachment.disk', 's3');
}
@@ -372,7 +430,8 @@ class SettingsServiceProvider extends ServiceProvider
*/
private function applyPublicAssetDiskConfig(JsonConfigRepository $configRepository): void
{
if (env('APP_ENV') === 'testing') {
// env() 직접 호출은 config:cache 환경에서 null 로 고정되어 가드가 무력해진다.
if (app()->environment('testing')) {
return;
}
@@ -389,6 +448,10 @@ class SettingsServiceProvider extends ServiceProvider
*/
private function injectPublicAssetDiskConfig(JsonConfigRepository $configRepository): void
{
// audit:allow env-priority-filter-wiring 이 지점이 drivers 에서 읽는 키는
// public_asset_disk 하나뿐이고 그 키는 EnvPriority::EXEMPT 다 (env 대응 없음).
// 여기서 다른 drivers 키를 추가로 읽게 되면 그 키는 잠금을 우회하므로,
// 그때는 이 면제를 걷어내고 filterLocked('drivers', …) 를 배선해야 한다.
$driverSettings = $configRepository->getCategory('drivers');
$disk = (string) ($driverSettings['public_asset_disk'] ?? '');
@@ -535,6 +598,14 @@ class SettingsServiceProvider extends ServiceProvider
*/
private function applyWebsocketConfig(array $driverSettings): void
{
// 마스터 토글이 `.env` 로 잠긴 설치에서는 관리자 저장값이 이 배열에서 제거되어
// 있으므로 `empty()` 가 참이 된다 — 그대로 두면 OFF 강제 3종이 오발동해
// `.env`(BROADCAST_CONNECTION=reverb)로 웹소켓을 켠 운영자에게 강제 OFF 가 걸린다.
// 잠긴 경우에는 OFF 강제도 ON 주입도 하지 않고 config 기본값(= `.env` 유래)을 서빙한다.
if (EnvPriority::isLocked('drivers.websocket_enabled')) {
return;
}
if (empty($driverSettings['websocket_enabled'])) {
Config::set('broadcasting.default', 'null');
// 프론트(admin/app.blade.php)가 @if(broadcasting.connections.reverb.key)로 연결을
@@ -562,13 +633,16 @@ class SettingsServiceProvider extends ServiceProvider
$serverHost = $driverSettings['websocket_server_host'] ?? '';
$serverPort = (int) ($driverSettings['websocket_server_port'] ?? 0);
$serverScheme = $driverSettings['websocket_server_scheme'] ?? '';
if (empty($serverHost)) {
// 잠긴 server 키에는 폴백을 태우지 않는다 — 클라이언트 endpoint 는 env 대응이 없는
// 관리자 소유 값이라, 폴백을 그대로 두면 그 값이 `.env` 가 소유한 REVERB_HOST/PORT/SCHEME
// 자리에 덮여 잠금이 무력해진다. 잠긴 키는 빈 값으로 남아 아래 주입에서 건너뛰어진다.
if (empty($serverHost) && ! EnvPriority::isLocked('drivers.websocket_server_host')) {
$serverHost = $clientHost;
}
if ($serverPort <= 0) {
if ($serverPort <= 0 && ! EnvPriority::isLocked('drivers.websocket_server_port')) {
$serverPort = $clientPort;
}
if (empty($serverScheme)) {
if (empty($serverScheme) && ! EnvPriority::isLocked('drivers.websocket_server_scheme')) {
$serverScheme = $clientScheme;
}
$serverUseTLS = $serverScheme === 'https';
@@ -632,6 +706,9 @@ class SettingsServiceProvider extends ServiceProvider
return;
}
// .env 우선 모드: `.env` 가 소유한 키 제거 (스위치 OFF 면 무동작).
$settings = EnvPriority::filterLocked('core_update', $settings);
if (! empty($settings['github_url'])) {
Config::set('app.update.github_url', $settings['github_url']);
}
@@ -658,6 +735,9 @@ class SettingsServiceProvider extends ServiceProvider
return;
}
// .env 우선 모드: `.env` 가 소유한 키 제거 (스위치 OFF 면 무동작).
$settings = EnvPriority::filterLocked('geoip', $settings);
if (isset($settings['feature_enabled'])) {
Config::set('geoip.enabled', (bool) $settings['feature_enabled']);
}
@@ -691,6 +771,9 @@ class SettingsServiceProvider extends ServiceProvider
*/
private function applyIdentityConfig(JsonConfigRepository $configRepository): void
{
// audit:allow env-priority-filter-wiring identity 카테고리는 전 키가 EnvPriority::EXEMPT 다
// (env 대응이 없어 잠글 대상이 하나도 없다) — filterLocked 를 걸어도 no-op 이다.
// 이 카테고리에 env 대응 키가 생기면 EnvPriorityContractTest 의 맵 패리티가 먼저 red 가 된다.
$settings = $configRepository->getCategory('identity');
if (empty($settings)) {
@@ -711,6 +794,9 @@ class SettingsServiceProvider extends ServiceProvider
return;
}
// .env 우선 모드: `.env` 가 소유한 키 제거 (스위치 OFF 면 무동작).
$uploadSettings = EnvPriority::filterLocked('upload', $uploadSettings);
// 관리자 설정은 MB, config/attachment.* 는 KB — 변환은 이 지점 단 한 곳에서만 수행한다.
// (기존에는 존재하지 않는 키 `max_size` 를 읽어 설정이 어디에도 반영되지 않았다)
if (! empty($uploadSettings['max_file_size'])) {
+105
View File
@@ -9,6 +9,7 @@ use App\Extension\HookManager;
use App\Http\Resources\AttachmentResource;
use App\Seo\Contracts\SeoCacheManagerInterface;
use App\Support\ConfigCacheHelper;
use App\Support\EnvPriority;
use App\Support\ExtensionSettingsMirror;
use App\Support\OpcacheStatus;
use App\Support\ProcessOutputEncoding;
@@ -151,9 +152,102 @@ class SettingsService
// 사이트 기본 OG 이미지 첨부 정보 (seo 카테고리에 — site_logo 와 동형)
$settings['seo']['og_image_default'] = $this->getAttachmentListSetting('seo.og_image_default');
return $this->overlayEnvLockedValues($settings);
}
/**
* `.env` 로 잠긴 키의 표시값을 유효값(런타임 config)으로 덮어씁니다.
*
* 잠긴 필드에 사문화된 저장값이 남아 있으면 운영자는 적용되지 않는 값을 읽게 됩니다.
* 화면이 보여줄 진실은 실제로 적용 중인 값이므로, 그 값으로 대체합니다.
*
* 민감 키는 제외합니다 — `.env` 의 비밀값을 관리자 화면 응답에 실어 보내지 않기
* 위해서입니다(잠금 표시만 하고 값은 저장값 그대로 둡니다).
*
* 스위치가 꺼져 있으면 아무 것도 하지 않습니다.
*
* @param array<string, mixed> $settings frontend 키 변환이 끝난 설정 배열
* @return array<string, mixed> 유효값이 덮인 설정 배열
*/
private function overlayEnvLockedValues(array $settings): array
{
if (! EnvPriority::enabled()) {
return $settings;
}
foreach (array_keys(EnvPriority::lockedKeys()) as $storageKey) {
if (EnvPriority::isSensitive($storageKey)) {
continue;
}
[$category, $key] = explode('.', $storageKey, 2);
$value = EnvPriority::effectiveValue($storageKey);
foreach ($this->resolveFrontendKeyTargets($category, $key) as [$targetCategory, $outputKey]) {
if (isset($settings[$targetCategory]) && is_array($settings[$targetCategory])) {
$settings[$targetCategory][$outputKey] = $value;
}
}
}
return $settings;
}
/**
* `.env` 로 잠긴 키 목록을 프론트엔드 키 형태로 반환합니다.
*
* 화면(레이아웃 JSON)이 참조하는 키는 `frontend_key`/`merge_into` 변환을 거친 이름
* (예: `advanced.debug_mode`)이므로, 저장소 키(`debug.mode`)를 그대로 내보내면 화면의
* 잠금 표시가 조용히 미발동합니다.
*
* `getAllSettings()` 가 값을 두 위치(병합 대상 + 원본 카테고리)에 싣는 것과 동일하게
* 잠금 표시도 두 위치를 모두 담습니다.
*
* @return array<string, bool> 프론트엔드 키(`카테고리.키`) => true
*/
public function envLockedMeta(): array
{
$meta = [];
foreach (array_keys(EnvPriority::lockedKeys()) as $storageKey) {
[$category, $key] = explode('.', $storageKey, 2);
foreach ($this->resolveFrontendKeyTargets($category, $key) as [$targetCategory, $outputKey]) {
$meta[$targetCategory.'.'.$outputKey] = true;
}
}
return $meta;
}
/**
* 저장소 키가 응답에서 실리는 (카테고리, 키) 쌍 목록을 반환합니다.
*
* `getAllSettings()` 의 변환 규칙과 같은 규칙을 씁니다 — 갈라지면 잠금 표시가 값과
* 다른 자리를 가리키게 되고, 그 어긋남은 화면에서 "잠금이 안 걸린 것"과 구분되지 않습니다.
*
* @param string $category 저장소 카테고리명
* @param string $key 저장소 키
* @return array<int, array{0: string, 1: string}> (카테고리, 출력 키) 쌍 목록
*/
private function resolveFrontendKeyTargets(string $category, string $key): array
{
$schema = $this->configRepository->getFrontendSchema();
$categorySchema = $schema[$category] ?? [];
$fields = $categorySchema['fields'] ?? [];
$outputKey = $fields[$key]['frontend_key'] ?? $key;
$targetCategory = $categorySchema['frontend_name'] ?? $categorySchema['merge_into'] ?? $category;
$targets = [[$targetCategory, $outputKey]];
if ($targetCategory !== $category) {
$targets[] = [$category, $outputKey];
}
return $targets;
}
/**
* 특정 카테고리의 설정을 조회합니다.
*
@@ -539,6 +633,13 @@ class SettingsService
// frontend_key를 원본 키로 역변환
$tabSettings = $this->reverseFrontendKeys($tabSettings);
// `.env` 가 소유권을 가져간 키는 저장 대상에서 제거한다. 화면의 disabled 는
// 게이트가 아니다 — 저장 API 를 직접 호출하는 경로가 남으므로 실질 차단은 여기다.
// (advanced 탭은 아직 카테고리가 갈리지 않았으므로 saveAdvancedSettings 가 분리 후 적용한다.)
if ($tab !== 'advanced') {
$tabSettings = EnvPriority::rejectLockedForSave($tab, $tabSettings);
}
// advanced 탭은 cache와 debug 두 카테고리로 분리
if ($tab === 'advanced') {
$result = $this->saveAdvancedSettings($tabSettings);
@@ -730,6 +831,10 @@ class SettingsService
// 각 카테고리별 병합 저장
foreach ($categorized as $category => $categorySettings) {
// 고급 탭은 여러 카테고리가 한 폼에 섞여 오므로 잠금 필터를 분리 후에 적용한다
// (탭 이름 'advanced' 는 저장소 카테고리가 아니라 매핑이 걸리지 않는다).
$categorySettings = EnvPriority::rejectLockedForSave($category, $categorySettings);
if (empty($categorySettings)) {
continue;
}
+5 -4
View File
@@ -40,14 +40,15 @@ class ApiEndpointProbe
private ?User $user = null;
/**
* @param string|null $baseUrl 기준 URL (null 이면 .env 의 APP_URL 직접 사용)
* @param string|null $baseUrl 기준 URL (null 이면 config('app.url') 사용)
*/
public function __construct(?string $baseUrl = null)
{
// config('app.url') 은 테스트 환경에서 override 될 수 있으므로(test.example.com 등),
// 실측은 .env 의 APP_URL 을 우선 신뢰한다. 명시 인자가 있으면 그것을 최우선한다.
// 기준 URL 은 config('app.url') 에서 해석한다. 이전에는 `.env` 를 우선 신뢰하려고
// env('APP_URL') 을 먼저 읽었는데, config:cache 환경에서 env() 는 null 로 고정되므로
// 그 우선순위는 실제로 성립한 적이 없었다(그대로 config 폴백으로 떨어졌다).
// 실측 대상을 지정해야 하면 명시 인자를 넘긴다 — 그쪽이 최우선이다.
$resolved = $baseUrl
?: (string) env('APP_URL')
?: (string) config('app.url');
$this->baseUrl = rtrim($resolved, '/');
+359
View File
@@ -0,0 +1,359 @@
<?php
namespace App\Support;
/**
* `.env` 키 단위 우선 규약 (G7_ENV_PRIORITY 옵트인).
*
* G7 은 관리자 환경설정(`storage/app/settings/*.json`)을 운영 SSoT 로 삼고,
* `SettingsServiceProvider` 가 그 값을 `config()` 에 주입해 `.env` 유래 값을 덮는다.
* 공유호스팅 1차 대상 설계에서는 그것이 옳지만, `.env` 를 배포 기준값으로 관리하는
* 설치(컨테이너·IaC·다중 서버)에서는 `.env` 에 적은 값이 조용히 사문화된다.
*
* 이 클래스는 그 소유권을 **키 단위**로 되돌리는 판정을 단독으로 소유한다:
*
* - 스위치(`G7_ENV_PRIORITY`)가 꺼져 있으면 전 경로가 조기 return 이라 현행 동작과 100% 동일하다.
* - 켜진 설치에서는 `.env` 에 값이 **명시된** 키만 잠긴다 — settings 주입을 건너뛰고(`.env` 권위),
* 관리자 화면은 그 필드를 편집 불가로 표시하며, 저장 API 는 그 키를 서버측에서 필터한다.
*
* 명시 여부 판별은 반드시 `config('env-priority.explicit')`(빌드 시점 캡처)를 읽는다.
* `env()` 직접 호출은 `config:cache` 환경에서 null 로 고정되므로 판별이 영구 미발동한다
* (`config/attachment.php` 의 `disk_explicit` 와 동형 함정).
*
* `SettingsServiceProvider::register()` 단계(DI 컨테이너 사용 전)에서 호출되므로 정적 클래스다.
*
* @since 7.0.10
*/
final class EnvPriority
{
/**
* settings 저장소 키 ↔ env 변수 ↔ config 키 매핑 (단일 SSoT).
*
* 소비자 3곳: ① Provider 주입 스킵 ② 설정 API `_meta.env_locked` + 표시값 ③ 계약 테스트.
*
* 각 항목의 형태:
* - `env`: 그 설정을 결정하는 env 변수 목록. **하나라도** 명시되면 잠긴다(any-of) —
* env 두 개가 한 설정을 나누는 `drivers.redis_database` 는 하나만 명시돼도 주입이
* 그것을 덮으므로 any 가 안전측이다.
* - `config`: 그 설정이 파생시키는 config 키 전부(문서·표시값 근거). 주입 스킵은
* 배열 필터 방식이라 이 목록을 순회하지 않는다 — 첫 키가 표시값 조회 대상이다.
* - `display`: 표시값 변환 지시자 (아래 `effectiveValue()` 참조). 없으면 config 값 그대로.
* - `sensitive`: true 면 잠금 표시만 하고 유효값을 화면·응답에 싣지 않는다
* (`.env` 비밀값이 admin UI 로 유출되는 것을 차단).
*
* @var array<string, array{env: array<int, string>, config: array<int, string>, display?: string, sensitive?: bool}>
*/
public const MAP = [
// --- general ---
'general.site_name' => ['env' => ['APP_NAME'], 'config' => ['app.name']],
'general.site_url' => ['env' => ['APP_URL'], 'config' => ['app.url']],
'general.timezone' => ['env' => ['APP_DEFAULT_USER_TIMEZONE'], 'config' => ['app.default_user_timezone', 'app.schedule_timezone']],
'general.language' => ['env' => ['APP_LOCALE'], 'config' => ['app.locale']],
// --- mail ---
'mail.mailer' => ['env' => ['MAIL_MAILER'], 'config' => ['mail.default']],
'mail.host' => ['env' => ['MAIL_HOST'], 'config' => ['mail.mailers.smtp.host']],
'mail.port' => ['env' => ['MAIL_PORT'], 'config' => ['mail.mailers.smtp.port']],
'mail.username' => ['env' => ['MAIL_USERNAME'], 'config' => ['mail.mailers.smtp.username']],
'mail.password' => ['env' => ['MAIL_PASSWORD'], 'config' => ['mail.mailers.smtp.password'], 'sensitive' => true],
'mail.mailgun_domain' => ['env' => ['MAILGUN_DOMAIN'], 'config' => ['services.mailgun.domain']],
'mail.mailgun_secret' => ['env' => ['MAILGUN_SECRET'], 'config' => ['services.mailgun.secret'], 'sensitive' => true],
'mail.mailgun_endpoint' => ['env' => ['MAILGUN_ENDPOINT'], 'config' => ['services.mailgun.endpoint']],
'mail.ses_key' => ['env' => ['AWS_ACCESS_KEY_ID'], 'config' => ['services.ses.key'], 'sensitive' => true],
'mail.ses_secret' => ['env' => ['AWS_SECRET_ACCESS_KEY'], 'config' => ['services.ses.secret'], 'sensitive' => true],
'mail.ses_region' => ['env' => ['AWS_DEFAULT_REGION'], 'config' => ['services.ses.region']],
'mail.from_address' => ['env' => ['MAIL_FROM_ADDRESS'], 'config' => ['mail.from.address']],
'mail.from_name' => ['env' => ['MAIL_FROM_NAME'], 'config' => ['mail.from.name']],
// --- debug ---
'debug.mode' => ['env' => ['APP_DEBUG'], 'config' => ['app.debug']],
'debug.log_level' => ['env' => ['LOG_LEVEL'], 'config' => ['logging.channels.single.level', 'logging.channels.daily.level', 'logging.level']],
// --- drivers ---
'drivers.cache_driver' => ['env' => ['CACHE_STORE'], 'config' => ['cache.default']],
'drivers.session_driver' => ['env' => ['SESSION_DRIVER'], 'config' => ['session.driver']],
'drivers.session_lifetime' => ['env' => ['SESSION_LIFETIME'], 'config' => ['session.lifetime']],
'drivers.queue_driver' => ['env' => ['QUEUE_CONNECTION'], 'config' => ['queue.default']],
'drivers.storage_driver' => ['env' => ['FILESYSTEM_DISK'], 'config' => ['filesystems.default']],
'drivers.redis_host' => ['env' => ['REDIS_HOST'], 'config' => ['database.redis.default.host', 'database.redis.cache.host']],
'drivers.redis_port' => ['env' => ['REDIS_PORT'], 'config' => ['database.redis.default.port', 'database.redis.cache.port']],
'drivers.redis_password' => ['env' => ['REDIS_PASSWORD'], 'config' => ['database.redis.default.password', 'database.redis.cache.password'], 'sensitive' => true],
'drivers.redis_database' => ['env' => ['REDIS_DB', 'REDIS_CACHE_DB'], 'config' => ['database.redis.default.database', 'database.redis.cache.database']],
'drivers.memcached_host' => ['env' => ['MEMCACHED_HOST'], 'config' => ['cache.stores.memcached.servers.0.host']],
'drivers.memcached_port' => ['env' => ['MEMCACHED_PORT'], 'config' => ['cache.stores.memcached.servers.0.port']],
'drivers.s3_bucket' => ['env' => ['AWS_BUCKET'], 'config' => ['filesystems.disks.s3.bucket']],
'drivers.s3_region' => ['env' => ['AWS_DEFAULT_REGION'], 'config' => ['filesystems.disks.s3.region']],
'drivers.s3_access_key' => ['env' => ['AWS_ACCESS_KEY_ID'], 'config' => ['filesystems.disks.s3.key'], 'sensitive' => true],
'drivers.s3_secret_key' => ['env' => ['AWS_SECRET_ACCESS_KEY'], 'config' => ['filesystems.disks.s3.secret'], 'sensitive' => true],
'drivers.s3_url' => ['env' => ['AWS_URL'], 'config' => ['filesystems.disks.s3.url']],
'drivers.s3_endpoint' => ['env' => ['AWS_ENDPOINT'], 'config' => ['filesystems.disks.s3.endpoint']],
'drivers.s3_use_path_style' => ['env' => ['AWS_USE_PATH_STYLE_ENDPOINT'], 'config' => ['filesystems.disks.s3.use_path_style_endpoint']],
'drivers.log_driver' => ['env' => ['LOG_STACK'], 'config' => ['logging.channels.stack.channels'], 'display' => 'log_stack_first'],
'drivers.log_level' => ['env' => ['LOG_LEVEL'], 'config' => ['logging.channels.single.level', 'logging.channels.daily.level']],
'drivers.log_days' => ['env' => ['LOG_DAILY_DAYS'], 'config' => ['logging.channels.daily.days']],
'drivers.search_engine_driver' => ['env' => ['SCOUT_DRIVER'], 'config' => ['scout.driver']],
'drivers.websocket_enabled' => ['env' => ['BROADCAST_CONNECTION'], 'config' => ['broadcasting.default'], 'display' => 'broadcast_is_reverb'],
'drivers.websocket_app_id' => ['env' => ['REVERB_APP_ID'], 'config' => ['broadcasting.connections.reverb.app_id', 'reverb.apps.apps.0.app_id']],
'drivers.websocket_app_key' => ['env' => ['REVERB_APP_KEY'], 'config' => ['broadcasting.connections.reverb.key', 'reverb.apps.apps.0.key']],
'drivers.websocket_app_secret' => ['env' => ['REVERB_APP_SECRET'], 'config' => ['broadcasting.connections.reverb.secret', 'reverb.apps.apps.0.secret'], 'sensitive' => true],
'drivers.websocket_server_host' => ['env' => ['REVERB_HOST'], 'config' => ['broadcasting.connections.reverb.options.host', 'reverb.apps.apps.0.options.host']],
'drivers.websocket_server_port' => ['env' => ['REVERB_PORT'], 'config' => ['broadcasting.connections.reverb.options.port', 'reverb.apps.apps.0.options.port']],
'drivers.websocket_server_scheme' => ['env' => ['REVERB_SCHEME'], 'config' => ['broadcasting.connections.reverb.options.scheme', 'reverb.apps.apps.0.options.scheme']],
'drivers.websocket_verify_ssl' => ['env' => ['REVERB_VERIFY_SSL'], 'config' => ['broadcasting.connections.reverb.client_options.verify']],
// --- core_update ---
'core_update.github_url' => ['env' => ['G7_UPDATE_GITHUB_URL'], 'config' => ['app.update.github_url']],
'core_update.github_token' => ['env' => ['G7_UPDATE_GITHUB_TOKEN'], 'config' => ['app.update.github_token'], 'sensitive' => true],
// --- geoip ---
'geoip.feature_enabled' => ['env' => ['GEOIP_ENABLED'], 'config' => ['geoip.enabled']],
'geoip.license_key' => ['env' => ['GEOIP_LICENSE_KEY'], 'config' => ['geoip.license_key'], 'sensitive' => true],
'geoip.auto_update_enabled' => ['env' => ['GEOIP_AUTO_UPDATE_ENABLED'], 'config' => ['geoip.auto_update_enabled']],
// --- upload ---
'upload.max_file_size' => ['env' => ['ATTACHMENT_MAX_FILE_SIZE'], 'config' => ['attachment.max_file_size'], 'display' => 'kb_to_mb'],
'upload.image_max_width' => ['env' => ['ATTACHMENT_IMAGE_MAX_WIDTH'], 'config' => ['attachment.image_max_width']],
'upload.image_max_height' => ['env' => ['ATTACHMENT_IMAGE_MAX_HEIGHT'], 'config' => ['attachment.image_max_height']],
'upload.image_quality' => ['env' => ['ATTACHMENT_IMAGE_QUALITY'], 'config' => ['attachment.image_quality']],
];
/**
* env 대응이 존재하지 않아 매핑에서 의도적으로 제외한 settings 키와 그 사유.
*
* 계약 테스트가 "맵 누락"과 "의도적 제외"를 구분하는 근거다 — 이 목록이 없으면
* `SettingsServiceProvider` 에 새 주입이 추가될 때 그것이 빠뜨린 것인지 대응이 없는
* 것인지 판정할 수 없고, 결국 아무도 알아채지 못한 채 `.env` 가 다시 사문화된다.
*
* @var array<string, string>
*/
public const EXEMPT = [
'general.site_description' => 'env 대응 없음 (설정 전용 값)',
'general.admin_email' => 'env 대응 없음 (설정 전용 값)',
'general.currency' => 'env 대응 없음 (설정 전용 값)',
'general.maintenance_mode' => 'env 대응 없음 — APP_MAINTENANCE_DRIVER 는 저장소 종류이고 점검 모드 on/off 가 아니다',
'general.site_logo' => 'env 대응 없음 (첨부 id 배열)',
'general.asset_url_mode' => 'env 대응 없음 (설정 전용 값)',
'mail.encryption' => 'config/mail.php 에 대응 키 없음 — Provider 가 쓰는 mail.mailers.smtp.encryption 은 env 유래가 아니다 (Laravel 12 의 env 대응 키는 MAIL_SCHEME → smtp.scheme 로 별개 축)',
'debug.sql_query_log' => 'env 대응 없음 (g7.sql_query_log 는 설정 전용)',
'debug.outbound_proxy' => 'env 대응 없음 (g7.outbound_proxy 는 설정 전용)',
'debug.outbound_proxy_bypass' => 'env 대응 없음 (g7.outbound_proxy 는 설정 전용)',
'drivers.public_asset_disk' => 'env 대응 없음 (core.storage.public_asset_disk 는 설정 전용)',
'drivers.websocket_host' => '브라우저 클라이언트 endpoint (g7.websocket.client.host) — REVERB_HOST 는 서버 endpoint 축이라 대응이 아니다',
'drivers.websocket_port' => '브라우저 클라이언트 endpoint (g7.websocket.client.port)',
'drivers.websocket_scheme' => '브라우저 클라이언트 endpoint (g7.websocket.client.scheme)',
'geoip.last_updated_at' => 'geoip:update 커맨드가 기록하는 런타임 상태값',
'upload.allowed_extensions' => 'env 대응 없음 (AllowedExtensions 정규화를 거치는 설정 전용 값)',
'upload.orphan_cleanup_enabled' => 'env 대응 없음 (설정 전용 값)',
'upload.orphan_retention_days' => 'env 대응 없음 (설정 전용 값)',
'identity.default_provider' => 'config(\'settings.identity\') 로 카테고리를 통째 주입 — 개별 env 대응이 없다',
'identity.purpose_providers' => 'config(\'settings.identity\') 로 카테고리를 통째 주입 — 개별 env 대응이 없다',
'identity.challenge_ttl_minutes' => 'config(\'settings.identity\') 로 카테고리를 통째 주입 — 개별 env 대응이 없다',
'identity.max_attempts' => 'config(\'settings.identity\') 로 카테고리를 통째 주입 — 개별 env 대응이 없다',
];
/**
* 선언 파일(`config/*.php`)에 존재하지 않고 런타임에 생성되는 config 키.
*
* `logging.level` 은 G7 이 도입한 통합 로그 레벨 키로, `SettingsServiceProvider` 가
* 만들고 `BrowserLogWriter` 가 기본값과 함께 읽는다 — Laravel 의 `config/logging.php`
* 에는 선언이 없다. 계약 테스트가 "존재하지 않는 config 키" 로 오탐하지 않도록
* 그 사유를 코드에 남긴다.
*
* @var array<int, string>
*/
public const RUNTIME_CREATED_CONFIG_KEYS = [
'logging.level',
];
/**
* `SettingsServiceProvider` 가 settings → config 주입을 수행하는 카테고리 목록.
*
* 계약 테스트가 "이 카테고리의 defaults 키는 MAP 이나 EXEMPT 중 하나에 반드시 등재"
* 라는 전수 패리티를 강제하는 모집단입니다. 주입 대상이 아닌 카테고리(security·seo·
* cache·pagination·notifications)는 `.env` 를 덮는 일이 없으므로 대상이 아닙니다.
*
* @var array<int, string>
*/
public const INJECTED_CATEGORIES = [
'general',
'mail',
'debug',
'drivers',
'core_update',
'geoip',
'upload',
'identity',
];
/**
* 매핑에 등장하는 env 변수 전체를 중복 없이 반환합니다.
*
* `config/env-priority.php` 가 명시 여부를 캡처할 대상 목록입니다.
*
* @return array<int, string> env 변수명 목록
*/
public static function envVars(): array
{
$vars = [];
foreach (self::MAP as $entry) {
foreach ($entry['env'] as $var) {
$vars[$var] = true;
}
}
return array_keys($vars);
}
/**
* `.env` 우선 모드가 켜져 있는지 반환합니다.
*
* @return bool 켜져 있으면 true
*/
public static function enabled(): bool
{
return config('env-priority.enabled') === true;
}
/**
* 해당 settings 키가 `.env` 로 잠겼는지 판정합니다.
*
* 스위치가 켜져 있고, 그 키를 결정하는 env 변수 중 **하나라도** 명시되어 있으면 잠깁니다.
*
* @param string $categoryDotKey settings 저장소 키 (예: `mail.port`)
* @return bool 잠겼으면 true
*/
public static function isLocked(string $categoryDotKey): bool
{
if (! self::enabled()) {
return false;
}
$entry = self::MAP[$categoryDotKey] ?? null;
if ($entry === null) {
return false;
}
$explicit = config('env-priority.explicit', []);
foreach ($entry['env'] as $var) {
if (($explicit[$var] ?? false) === true) {
return true;
}
}
return false;
}
/**
* 카테고리 설정 배열에서 잠긴 키를 제거합니다.
*
* 주입 지점(`SettingsServiceProvider`)이 이 배열을 받으므로, 기존 `!empty`/`isset`
* 가드가 제거된 키를 자연히 건너뜁니다 — 한 설정이 파생시키는 config 키가 여러 개인
* 경우(redis 2, reverb 2)도 지점마다 조건을 심지 않고 한 번에 처리됩니다.
*
* 스위치가 꺼져 있으면 입력을 그대로 반환합니다 (현행 동작 보존).
*
* @param string $category settings 카테고리명 (예: `mail`)
* @param array<string, mixed> $settings 카테고리 설정 배열
* @return array<string, mixed> 잠긴 키가 제거된 배열
*/
public static function filterLocked(string $category, array $settings): array
{
if (! self::enabled()) {
return $settings;
}
foreach (array_keys($settings) as $key) {
if (self::isLocked($category.'.'.$key)) {
unset($settings[$key]);
}
}
return $settings;
}
/**
* 저장 입력에서 잠긴 키를 제거합니다 (서버측 게이트).
*
* 화면의 `disabled` 는 게이트가 아닙니다 — 저장 API 를 직접 호출하는 경로가 남으므로
* 실질 차단은 이 지점입니다. abilities 필터와 동형으로 조용히 제거합니다(422 아님):
* 운영자가 `.env` 로 소유권을 가져간 키는 "거부"가 아니라 "설정 대상이 아님"입니다.
*
* @param string $category settings 카테고리명 (예: `mail`)
* @param array<string, mixed> $input 저장 입력 (원본 저장소 키 기준)
* @return array<string, mixed> 잠긴 키가 제거된 입력
*/
public static function rejectLockedForSave(string $category, array $input): array
{
return self::filterLocked($category, $input);
}
/**
* 잠긴 settings 키 목록을 반환합니다.
*
* @return array<string, bool> 저장소 키 => true (잠긴 키만)
*/
public static function lockedKeys(): array
{
if (! self::enabled()) {
return [];
}
$locked = [];
foreach (array_keys(self::MAP) as $key) {
if (self::isLocked($key)) {
$locked[$key] = true;
}
}
return $locked;
}
/**
* 해당 settings 키가 민감 정보인지 반환합니다.
*
* 민감 키는 잠금 표시만 하고 유효값을 화면·응답에 싣지 않습니다.
*
* @param string $categoryDotKey settings 저장소 키
* @return bool 민감 정보면 true
*/
public static function isSensitive(string $categoryDotKey): bool
{
return (self::MAP[$categoryDotKey]['sensitive'] ?? false) === true;
}
/**
* 잠긴 키의 유효값(런타임 config 값)을 반환합니다.
*
* 화면이 저장값 대신 이 값을 보여주어야 "진실"과 일치합니다 — 잠긴 필드에 사문화된
* 저장값이 남아 있으면 운영자는 적용되지 않는 값을 읽게 됩니다.
*
* 민감 키는 호출자가 걸러야 합니다 (이 메서드는 값 자체를 판단하지 않습니다).
*
* @param string $categoryDotKey settings 저장소 키
* @return mixed 유효값 (매핑에 없으면 null)
*/
public static function effectiveValue(string $categoryDotKey): mixed
{
$entry = self::MAP[$categoryDotKey] ?? null;
if ($entry === null) {
return null;
}
$value = config($entry['config'][0]);
return match ($entry['display'] ?? null) {
// config/attachment.* 는 KB, 관리자 화면은 MB — Provider 의 변환과 역방향.
'kb_to_mb' => is_numeric($value) ? (int) ((int) $value / 1024) : $value,
// 화면의 웹소켓 마스터 토글은 broadcasting 드라이버 선택으로 표현된다.
'broadcast_is_reverb' => $value === 'reverb',
// stack 채널 목록의 첫 항목이 화면의 단일 로그 드라이버 선택값이다.
'log_stack_first' => is_array($value) ? ($value[0] ?? null) : $value,
default => $value,
};
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
use App\Support\EnvPriority;
/*
|--------------------------------------------------------------------------
| .env 키 단위 우선 (G7_ENV_PRIORITY)
|--------------------------------------------------------------------------
|
| G7 은 관리자 환경설정(storage/app/settings/*.json)을 운영 SSoT 로 삼고 그 값을
| config() 에 주입한다. 이 스위치를 켠 설치에서는 `.env` 에 값이 명시된 키만
| 그 주입에서 제외되어 `.env` 가 권위를 갖는다. 스위치를 켜지 않으면 아무 것도
| 달라지지 않는다 (기본 OFF — 기존 설치 무영향).
|
| explicit 는 "그 env 변수가 명시되었는가" 만 담는 불리언 맵이다. 값 자체는 이미
| 각 config/*.php 가 env() 로 읽어 갖고 있으므로 여기에 다시 담지 않는다.
|
| 판별을 여기서 캡처하는 이유: env() 직접 호출은 config:cache 환경에서 null 로
| 고정되므로 런타임 판별이 영구 미발동한다 (config/attachment.php 의 disk_explicit
| 와 동형 함정). 이 파일은 config:cache 시점에 평가되어 결과가 캐시에 박제된다.
|
| 명시 판정은 strict 다 — `KEY=` (빈 값)과 미설정만 미명시로 본다. `APP_DEBUG=false`
| 나 `REDIS_DB=0` 처럼 falsy 한 값을 명시한 경우는 명시로 취급한다 (?: 를 쓰면
| 그 값들이 미명시로 오판된다).
|
| `.env` 만 편집한 경우에는 config:cache 를 다시 실행해야 반영된다. 설정 저장 경로는
| SettingsService 가 ConfigCacheHelper 로 재빌드하므로 추가 조치가 필요 없다.
|
*/
$explicit = [];
foreach (EnvPriority::envVars() as $var) {
if (! in_array(env($var), [null, ''], true)) {
$explicit[$var] = true;
}
}
return [
'enabled' => (bool) env('G7_ENV_PRIORITY', false),
'explicit' => $explicit,
];
+68 -2
View File
@@ -11,7 +11,8 @@
2. sync 된 키는 config() 와 g7_core_settings() 가 동치 — 둘 중 어느 쪽을 써도 같은 값
3. 예외 1: app.timezone 은 항상 UTC (서버 저장용). 사용자 시간대는 g7_core_settings('general.timezone') 또는 config('app.default_user_timezone')
4. 예외 2: testing 환경에서 drivers.cache/session/queue/session_lifetime 은 sync 차단 (격리 보호) — config() 는 phpunit.xml 격리값, g7_core_settings() 는 dev 공유 파일값
5. 결론: 드라이버 카테고리는 config() 우선. 그 외 admin 관리 키는 둘 다 가능 (취향)
5. 예외 3: `.env` 에 `G7_ENV_PRIORITY=true` 를 명시한 설치는 .env 에 값이 있는 항목만 sync 에서 빠진다 (기본 OFF)
6. 결론: 드라이버 카테고리는 config() 우선. 그 외 admin 관리 키는 둘 다 가능 (취향)
```
---
@@ -50,6 +51,8 @@
위 매핑은 [`app/Providers/SettingsServiceProvider.php`](../../app/Providers/SettingsServiceProvider.php) 가 단일 SSoT.
이 sync 를 키 단위로 되돌리는 옵트인 모드가 있다 — 아래 [env 우선 모드](#env-우선-모드-g7_env_priority) 참조.
---
## 어느 쪽을 쓸 것인가
@@ -138,7 +141,8 @@ plugin_setting('sirsoft-pay_kginicis', 'api_key');
2. sync 코드를 추가하지 않으면 → `g7_core_settings()` 만 사용 가능. `config()` 는 sync 되지 않은 키를 모르기 때문이다.
3. testing 격리가 필요한 키 (드라이버, 외부 서비스 자격증명 등) 는 `! $isTestingEnv` 가드로 sync 를 차단한다. 이 키들은 `config()` 가 testing 격리 SSoT 다.
4. 의미가 다른 키 (`app.timezone` 처럼) 는 sync 하지 않고 별도 키 (`app.default_user_timezone`) 로 분리한다.
5. 고급 탭 화면에 얹을 카테고리는 `config/settings/defaults.json` 의 `frontend_schema.{카테고리}.merge_into` 를 `advanced` 로 선언한다. 저장 시 어느 카테고리 파일에 쓸지는 이 선언에서 도출되므로 별도 등록이 필요 없다. 선언이 없으면 화면·검증·읽기가 모두 정상인데 입력값만 저장되지 않고 버려진다 — 저장 응답은 성공이고 화면에도 값이 보여 실패 신호가 없으므로, 새 카테고리를 추가했으면 저장 후 `storage/app/settings/{카테고리}.json` 이 생성되는지 직접 확인한다.
5. sync 코드를 추가했다면 `App\Support\EnvPriority` 의 `MAP`(env 대응이 있는 경우) 또는 `EXEMPT`(없는 경우)에 그 키를 등재한다. 등재를 잊으면 env 우선 모드를 켠 설치에서 그 키만 계속 settings 승으로 남는데, 오류도 로그도 남지 않아 "그 키가 `.env` 에 없다" 와 구분되지 않는다. 계약 테스트가 미등재를 red 로 만든다.
6. 고급 탭 화면에 얹을 카테고리는 `config/settings/defaults.json` 의 `frontend_schema.{카테고리}.merge_into` 를 `advanced` 로 선언한다. 저장 시 어느 카테고리 파일에 쓸지는 이 선언에서 도출되므로 별도 등록이 필요 없다. 선언이 없으면 화면·검증·읽기가 모두 정상인데 입력값만 저장되지 않고 버려진다 — 저장 응답은 성공이고 화면에도 값이 보여 실패 신호가 없으므로, 새 카테고리를 추가했으면 저장 후 `storage/app/settings/{카테고리}.json` 이 생성되는지 직접 확인한다.
---
@@ -181,6 +185,68 @@ plugin_setting('sirsoft-pay_kginicis', 'api_key');
---
## env 우선 모드 (`G7_ENV_PRIORITY`)
기본 설계에서 admin UI 는 운영 SSoT 다. 공유호스팅처럼 `.env` 를 직접 만지기 어려운 환경을 1차 대상으로 삼았기 때문이다. 그런데 `.env` 를 배포 기준값으로 관리하는 설치(컨테이너·IaC·다중 서버)에서는 그 설계가 반대로 작동한다 — `.env` 에 적은 값이 설치 직후부터 조용히 사문화된다(설치기가 `config/settings/defaults.json` 을 시드하므로 저장값이 항상 존재한다).
`.env` 에 `G7_ENV_PRIORITY=true` 를 명시하면 그 소유권이 **키 단위**로 되돌아간다.
- **잠금 대상**: `.env` 에 값이 명시된 env 변수에 대응하는 설정 키만. 나머지는 종전대로 admin UI 가 이긴다.
- **스위치 미설정**: 아무 것도 달라지지 않는다(기존 설치 무영향).
- **화면**: 잠긴 필드는 편집 불가 + `.env` 고정 배지. 표시값은 저장값이 아니라 **실제로 적용 중인 값**이다.
- **저장 API**: 잠긴 키는 서버측에서 제거된다. 화면의 `disabled` 는 게이트가 아니다.
### 명시 판별 규약
명시 여부는 `config/env-priority.php` 가 **config 빌드 시점에 캡처**한다. `env()` 를 런타임에 부르면 `config:cache` 환경에서 null 로 고정되어 판별이 영구 미발동하기 때문이다(`config/attachment.php` 의 `disk_explicit` 와 같은 함정).
판별은 strict 다.
| `.env` 표기 | 판정 |
|---|---|
| `MAIL_PORT=2599` | 명시 |
| `APP_DEBUG=false` | **명시** (falsy 값도 운영자가 정한 값이다) |
| `REDIS_DB=0` | **명시** |
| `MAIL_PORT=` (빈 값) | 미명시 |
| `AWS_BUCKET=null` | 미명시 |
| 줄 자체가 없음 | 미명시 |
한 설정을 env 두 개가 나누는 키(`drivers.redis_database` ↔ `REDIS_DB`/`REDIS_CACHE_DB`)는 **하나라도** 명시되면 잠긴다 — 주입이 그 하나를 덮어 버리기 때문이다.
### 매핑 SSoT
대상 키와 env 변수의 대응은 `App\Support\EnvPriority::MAP` 이 단독으로 갖는다. env 대응이 없어 대상에서 제외한 키는 그 사유와 함께 `EXEMPT` 에 등재한다 — 이 구분이 없으면 새 주입이 추가됐을 때 "빠뜨린 것"과 "대응이 없는 것"을 판별할 수 없다.
`SettingsServiceProvider` 에 settings → config 주입을 추가하면 **`MAP` 또는 `EXEMPT` 를 동반 갱신**해야 한다.
### 웹소켓 — 소유권이 통째로 넘어간다
`drivers.websocket_enabled`(마스터 토글)가 잠기면 웹소켓 계층 전체의 권위가 `.env`/config 기본값으로 넘어간다. 관리자 화면의 토글은 그 설치에서 무력화되며, OFF 강제(브로드캐스트 드라이버 `null` 화, reverb key 비우기)도 수행하지 않는다. `.env` 에 `BROADCAST_CONNECTION`·`REVERB_*` 를 적었다면 운영자가 그 소유권을 가져간 것으로 본다.
### 잠금은 그 키의 주입만 건너뛴다
제거된 키를 읽는 자리가 그 부재를 **값**으로 해석하면, 잠금이 형제 설정까지 무너뜨린다. 다섯 형태가 있고 전부 오류도 로그도 남기지 않는다.
| 형태 | 보정하지 않으면 |
|---|---|
| 마스터 토글의 OFF 강제 | `.env` 로 웹소켓을 켠 설치에 강제 OFF 가 걸린다 |
| 게이트가 되는 키 | 디버그 모드 잠금 시 로그 레벨 강제·프록시 적용이 죽고, 메일 드라이버 잠금 시 mailgun·ses 하위 저장값이 주입되지 않는다 |
| 저장값이 비어도 박히던 기본값 | `services.mailgun.endpoint`·`services.ses.region` 의 기본값 주입이 저장값 없는 호출에서도 사라진다 |
| 형제 값으로의 폴백 | 웹소켓 server endpoint 가 비면 관리자 소유 client 값으로 폴백하는데, 그 값이 `.env` 가 소유한 `REVERB_HOST`/`PORT`/`SCHEME` 자리에 덮인다 |
| 파생 판정 | `storage_driver=s3` → `attachment.disk` 파생이 멈춰, `FILESYSTEM_DISK=s3` 를 잠근 설치에서 첨부 업로드만 로컬 디스크로 되돌아간다 |
판정에 쓰는 자리는 유효값(런타임 config)으로 보정하고, 주입을 건너뛰어야 하는 자리는 `EnvPriority::isLocked()` 를 직접 묻는다. `array_key_exists`/`empty()` 로 판정하면 "저장값이 없는 호출" 과 "잠겨서 제거된 호출" 이 구분되지 않아, 전자에서도 종전 동작이 사라진다.
### 민감 키
`.env` 의 비밀값(비밀번호·시크릿·토큰·라이선스 키)은 화면·응답에 싣지 않는다. 잠금 표시만 하고 값은 저장값 그대로 남긴다.
### `.env` 만 고쳤을 때
설정 저장 경로는 config 캐시를 자동으로 다시 굽지만, `.env` 파일만 편집한 경우에는 반영되지 않는다. `php artisan config:cache` 를 다시 실행한다.
---
## 관련 문서
- [service-provider.md](service-provider.md) — ServiceProvider 안전성 (DB 접근 가드)
+4 -1
View File
@@ -72,7 +72,10 @@ _단건 응답: `data` 객체의 필드._
| notifications | object | `{"channels":[{"id":"mail","is_active":true,"sort_order":1…` | 알림 탭 설정 그룹. channels 는 알림 채널 목록으로 각 원소가 id(채널 식별자)·is_active(활성 여부)·sort_order(표시 순서)를 가짐 |
| identity | object | `{"default_provider":"g7:core.mail","purpose_providers":{"…` | 본인인증(IDV) 탭 설정 그룹 (기본 provider·목적별 provider 매핑(purpose_providers)·챌린지 유효시간(분)·최대 시도 횟수) |
| available_drivers | object | `{"storage":[{"id":"local","label":{"ko":"로컬","en":"Local"…` | 드라이버 선택지 카탈로그 (DriverRegistryService 산물). 종류별(storage/public_asset/cache/session/queue 등) 선택 가능한 드라이버 목록을 id/다국어 label 형태로 제공. `public_asset` 은 공개 자산 직접 URL 서빙 디스크 선택지 (코어 none/public/s3 + 플러그인 훅 등록분) |
| _meta | object | `{"limits":{"upload_max_file_size_min":1,"upload_max_file_…` | 설정값이 아니라 화면이 쓰는 메타. `limits` 는 각 설정 항목의 min/max 경계값 맵 (`config/core.php` 의 `settings_limits` 가 SSoT, 화면 입력 힌트와 FormRequest 검증이 같은 값을 공유) |
| _meta | object | `{"limits":{"upload_max_file_size_min":1,"upload_max_file_…` | 설정값이 아니라 화면이 쓰는 메타. 조회(GET)와 저장(POST) 응답이 같은 모양으로 동봉한다. 아래 세 필드로 구성 |
| _meta.limits | object | `{"upload_max_file_size_min":1,"upload_max_file_size_max":1024,…}` | 각 설정 항목의 min/max 경계값 맵 (`config/core.php` 의 `settings_limits` 가 SSoT, 화면 입력 힌트와 FormRequest 검증이 같은 값을 공유) |
| _meta.env_priority_enabled | boolean | `false` | `.env` 키 단위 우선 모드(`G7_ENV_PRIORITY`) 활성 여부. 화면은 이 값으로 안내 배너 표시를 판정한다. 기본값 false (스위치 미설정 시 종전 동작) |
| _meta.env_locked | object | `{"general.site_name":true,"advanced.debug_mode":true}` | `.env` 에 값이 명시되어 편집이 잠긴 필드 목록. 키는 **화면이 쓰는 프론트엔드 키**(`frontend_key`/`merge_into` 변환 후 — 예: `debug.mode` → `advanced.debug_mode`)이며 값은 항상 true. 병합 대상 카테고리와 원본 카테고리 양쪽에 실린다(설정값 자체가 두 위치에 실리는 것과 같은 규칙). 스위치가 꺼져 있으면 빈 객체. 잠긴 필드는 저장 요청에 포함되어도 서버가 제거하므로 값이 반영되지 않는다. 잠긴 필드의 값은 저장값이 아니라 실제로 적용 중인 값으로 내려가되, 비밀번호·시크릿·토큰 등 민감 항목은 그 대체를 하지 않는다 |
| abilities | object | `{"can_update":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) |
**응답 예시**
+4
View File
@@ -180,6 +180,8 @@ HookManager::broadcast("core.user.notifications.{$user->uuid}", 'notification.re
브로드캐스팅은 부가 기능이므로 실패해도 메인 작업(사용자 업데이트, 주문 처리, 알림 발송 등)을 중단시키지 않습니다. 개별 리스너에서 try-catch를 추가할 필요가 없습니다.
> **중요**: 웹소켓 OFF 시 `broadcasting.default`가 `'null'`로 강제되는 동작은 `.env`의 `BROADCAST_CONNECTION` 값을 무시하고 적용됩니다. 따라서 운영자가 환경설정에서 OFF한 경우, `.env`에 `REVERB_HOST=localhost` 등이 남아 있어도 broadcast 시도가 발생하지 않습니다. 알림 시스템(mail/database 채널)은 이 설정과 독립적으로 정상 동작합니다.
>
> 예외: `.env` 우선 모드(`G7_ENV_PRIORITY=true`)를 켜고 `.env` 에 `BROADCAST_CONNECTION` 을 명시한 설치에서는 이 강제가 적용되지 않습니다 — 웹소켓 계층 전체의 권위가 `.env` 로 넘어가고 관리자 토글이 그 설치에서 무력화됩니다. 자세한 규약은 [admin-settings-access.md](admin-settings-access.md) "env 우선 모드" 를 참조하세요.
#### 웹소켓 토글은 전 계층 SSoT (송신 · 프론트연결 · 채널인증)
@@ -193,6 +195,8 @@ HookManager::broadcast("core.user.notifications.{$user->uuid}", 'notification.re
따라서 `.env` 에 `REVERB_APP_KEY` 가 살아 있고 Reverb 서버가 운영자 OS 레벨로 기동 중이어도, 토글 OFF 면 프론트가 연결을 시도하지 않고 직접 연결 시도도 채널 인증 단계에서 거부됩니다. `.env` 는 토글 ON 일 때만 자격증명·endpoint 소스로 사용됩니다.
단, `.env` 우선 모드를 켜고 `BROADCAST_CONNECTION` 을 명시한 설치는 예외입니다. 그 설치에서는 마스터 토글이 잠겨 위 세 계층 모두 `.env`/config 기본값을 따르며, 관리자 화면의 토글은 잠금 표시와 함께 편집 불가가 됩니다.
### 채널 타입
| 타입 | 클래스 | 용도 | 인증 필요 |
+3 -1
View File
@@ -348,7 +348,9 @@ public function boot(): void
```
등록 후 `.env`에서 `SCOUT_DRIVER=meilisearch`로 전환하거나, 관리자 환경설정 > 드라이버의
검색엔진 항목에서 선택할 수 있습니다.
검색엔진 항목에서 선택할 수 있습니다. 두 값이 모두 있으면 관리자 환경설정이 이깁니다 —
`.env` 값을 기준으로 삼으려면 `.env` 우선 모드(`G7_ENV_PRIORITY=true`)를 켭니다
([admin-settings-access.md](admin-settings-access.md) "env 우선 모드").
#### 드라이버 폴백 가드
+1
View File
@@ -410,6 +410,7 @@ public static function isSchemaMutatingCommand(): bool
### config 캐시 재생성 주의
- `config:cache` 는 config 소스를 변경하는 라이프사이클에서 `App\Support\ConfigCacheHelper::rebuild()` 로 자동 재생성된다 (아래 "config 캐시 자동 재생성" 참조). `.env` 를 직접 편집한 경우처럼 헬퍼를 거치지 않는 변경은 여전히 `php artisan config:clear && php artisan config:cache` 를 수동 실행한다
- `.env` 우선 모드(`G7_ENV_PRIORITY`)에서는 이 수동 재실행이 기능의 일부다 — `.env` 의 키 명시 여부는 config 빌드 시점에 `config/env-priority.php` 로 캡처되므로, `.env` 만 고치고 재빌드하지 않으면 잠금 상태가 갱신되지 않는다. 런타임 `env()` 로 명시 여부를 판별하면 `config:cache` 환경에서 항상 null 이라 판정이 영구 미발동한다. 상세: [admin-settings-access.md](admin-settings-access.md)
### 확장 Trait 패턴
@@ -13,6 +13,7 @@
- 환경설정 > 고급의 리버스 프록시 진단 항목(라벨·상태·안내 문구)의 일본어 번역을 추가했습니다.
- 레이아웃 편집기 「필터 보이기/숨기기」·「필터 표시 초기화」 동작의 저장 키·필터 ID 입력 항목 이름을 일본어로 추가했습니다.
- 사이드바 하위 메뉴 펼치기/접기 버튼의 화면 낭독기 라벨을 일본어로 추가했습니다.
- 환경설정 화면의 `.env` 우선 모드 안내와 「`.env` 고정」 배지 문구의 일본어 번역을 추가했습니다.
### Changed
@@ -1311,6 +1311,8 @@
"page_title": "環境設定",
"page_description": "システム環境を設定します。",
"readonly_notice": "この設定は読み取り専用です。編集権限が必要な場合は管理者にお問い合わせください。",
"env_locked_badge": ".env で固定",
"env_locked_notice": "このサイトは .env 優先モードで運用されています。.env に値が指定されている項目はここでは変更できず、画面には実際に適用されている値が表示されます。変更するにはサーバーの .env を編集したうえで、設定キャッシュを再作成してください。",
"tabs": {
"general": "一般",
"mail": "メール",
@@ -121,7 +121,7 @@ admin/`)이 이 템플릿의 베이스(`_admin_base`)를 extends 하고 이 템
| 종류 | 개수 | 위치 |
|---|---|---|
| PHPUnit | 0개 | — |
| Vitest | 205개 | `vitest.config.ts` |
| Vitest | 206개 | `vitest.config.ts` |
| Playwright | 9개 | `tests/Playwright` |
| 시나리오 매니페스트 | 2개 | `tests/scenarios` |
@@ -16,6 +16,7 @@
- 개발자와 AI 에이전트를 위한 문서를 추가했습니다. 확장 폴더의 `AGENTS.md`(설계 의도·확장점·수정 시 확인할 것)와 `README.md`(도입·운영 안내), `docs/`(상세 문서)로 구성됩니다.
- 확장 문서에 「레이아웃 편집기 스펙」 항목을 추가했습니다. 이 확장이 레이아웃 편집기에 무엇을 선언했는지와, 화면 요소나 데이터를 추가할 때 편집기 쪽에서 함께 해야 할 일을 담습니다.
- 문서의 제품 표기를 「그누보드7」로 통일했습니다.
- `.env` 우선 모드를 켠 사이트에서 환경설정 화면이 그 사실을 알려 줍니다. 화면 상단에 안내가 표시되고, `.env` 로 고정된 항목은 「`.env` 고정」 표시와 함께 편집 불가로 바뀌며 실제로 적용 중인 값이 나타납니다. 비밀번호·시크릿 같은 항목은 고정 표시만 되고 값은 나타나지 않습니다. 모드를 켜지 않은 사이트는 종전과 동일합니다. (#37 @glitter-gim 님께서 건의해주셨습니다.)
### Changed
@@ -0,0 +1,364 @@
/**
* @file admin-settings-env-locked.test.tsx
* @description 환경설정 `.env` 잠금 UI 렌더링 테스트
*
* 테스트 대상:
* - `_meta.env_locked` 에 포함된 필드 → 잠금 배지 표시 + Input disabled
* - 포함되지 않은 필드 → 배지 없음 + Input 활성
* - `_meta.env_priority_enabled` → 안내 배너 표시/미표시
* - can_update:false 와의 OR 중첩 (권한 잠금 × env 잠금)
*
* 권한 중첩 축은 실브라우저(Chrome MCP 매트릭스 T9)에서 보조 계정을 만들 수 없어
* 이 레이아웃 테스트가 담당한다.
*
* @effects badge_and_disabled_rendered, switch_off_behaves_identically
*/
import React from 'react';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { createLayoutTest, screen } from '@core/template-engine/__tests__/utils/layoutTestUtils';
import { ComponentRegistry } from '@core/template-engine/ComponentRegistry';
const TestDiv: React.FC<{
className?: string;
children?: React.ReactNode;
'data-testid'?: string;
}> = ({ className, children, 'data-testid': testId }) => (
<div className={className} data-testid={testId}>{children}</div>
);
const TestInput: React.FC<{
className?: string;
disabled?: boolean;
type?: string;
name?: string;
'data-testid'?: string;
}> = ({ className, disabled, type, name, 'data-testid': testId }) => (
<input className={className} disabled={disabled} type={type} name={name} data-testid={testId || name} />
);
const TestSpan: React.FC<{
className?: string;
children?: React.ReactNode;
text?: string;
}> = ({ className, children, text }) => <span className={className}>{children || text}</span>;
const TestP: React.FC<{
className?: string;
children?: React.ReactNode;
text?: string;
}> = ({ className, children, text }) => <p className={className}>{children || text}</p>;
const TestIcon: React.FC<{ name?: string; className?: string }> = ({ name, className }) => (
<i className={className} data-icon={name} data-testid={`icon-${name}`} />
);
const TestLabel: React.FC<{
className?: string;
children?: React.ReactNode;
text?: string;
}> = ({ className, children, text }) => <label className={className}>{children || text}</label>;
const TestFragment: React.FC<{ children?: React.ReactNode }> = ({ children }) => <>{children}</>;
const TestBadge: React.FC<{
text?: string;
variant?: string;
size?: string;
className?: string;
'data-testid'?: string;
}> = ({ text, variant, size, className, 'data-testid': testId }) => (
<span className={className} data-variant={variant} data-size={size} data-testid={testId}>
{text}
</span>
);
function setupTestRegistry(): ComponentRegistry {
const registry = ComponentRegistry.getInstance();
(registry as any).registry = {
Div: { component: TestDiv, metadata: { name: 'Div', type: 'basic' } },
Input: { component: TestInput, metadata: { name: 'Input', type: 'basic' } },
Span: { component: TestSpan, metadata: { name: 'Span', type: 'basic' } },
P: { component: TestP, metadata: { name: 'P', type: 'basic' } },
Icon: { component: TestIcon, metadata: { name: 'Icon', type: 'basic' } },
Label: { component: TestLabel, metadata: { name: 'Label', type: 'basic' } },
Badge: { component: TestBadge, metadata: { name: 'Badge', type: 'composite' } },
Fragment: { component: TestFragment, metadata: { name: 'Fragment', type: 'layout' } },
};
return registry;
}
/**
* 설정 API 응답을 만듭니다.
*
* 프로덕션 레이아웃은 `initLocal.form` 을 거쳐 `_local.form._meta` 를 읽지만,
* 레이아웃 테스트 하네스는 initLocal 을 지원하지 않으므로 데이터소스를 직접 참조합니다.
*/
function createSettingsResponse(options: {
canUpdate: boolean;
envPriorityEnabled: boolean;
envLocked: Record<string, boolean>;
}) {
return {
success: true,
data: {
general: { site_name: 'Test Site', site_description: '설명' },
abilities: { can_update: options.canUpdate },
_meta: {
limits: {},
env_priority_enabled: options.envPriorityEnabled,
env_locked: options.envLocked,
},
},
};
}
const ENV_LOCKED = "settings?.data?._meta?.env_locked";
function lockExpr(key: string): string {
return `{{${ENV_LOCKED}?.['${key}'] === true}}`;
}
function disabledExpr(key: string): string {
return `{{(!!settings?.data && !(settings?.data?.abilities?.can_update ?? false)) || ${ENV_LOCKED}?.['${key}'] === true}}`;
}
const settingsLayout = {
version: '1.0.0',
layout_name: 'test_admin_settings_env_locked',
data_sources: [
{
id: 'settings',
type: 'api',
endpoint: '/api/admin/settings',
method: 'GET',
auto_fetch: true,
auth_required: true,
fallback: { data: [] },
},
],
components: [
{
id: 'settings_content',
type: 'basic',
name: 'Div',
children: [
{
id: 'env_priority_banner',
type: 'basic',
name: 'Div',
if: '{{settings?.data?._meta?.env_priority_enabled === true}}',
props: { className: 'alert-info', 'data-testid': 'env-priority-banner' },
children: [
{ type: 'basic', name: 'Icon', props: { name: 'lock' } },
{ type: 'basic', name: 'P', text: '.env 우선 모드' },
],
},
{
id: 'field_site_name',
type: 'basic',
name: 'Div',
children: [
{
type: 'basic',
name: 'Label',
children: [
{ type: 'basic', name: 'Span', text: '사이트 이름' },
{
type: 'composite',
name: 'Badge',
if: lockExpr('general.site_name'),
props: {
text: '.env 고정',
variant: 'secondary',
size: 'sm',
'data-testid': 'badge-site-name',
},
},
],
},
{
type: 'basic',
name: 'Input',
props: {
type: 'text',
name: 'general.site_name',
disabled: disabledExpr('general.site_name'),
'data-testid': 'input-site-name',
},
},
],
},
{
id: 'field_site_description',
type: 'basic',
name: 'Div',
children: [
{
type: 'basic',
name: 'Label',
children: [
{ type: 'basic', name: 'Span', text: '사이트 설명' },
{
type: 'composite',
name: 'Badge',
if: lockExpr('general.site_description'),
props: {
text: '.env 고정',
variant: 'secondary',
size: 'sm',
'data-testid': 'badge-site-description',
},
},
],
},
{
type: 'basic',
name: 'Input',
props: {
type: 'text',
name: 'general.site_description',
disabled: disabledExpr('general.site_description'),
'data-testid': 'input-site-description',
},
},
],
},
],
},
],
};
describe('admin-settings .env 잠금 UI', () => {
let registry: ComponentRegistry;
beforeEach(() => {
registry = setupTestRegistry();
});
afterEach(() => {
(registry as any).registry = {};
});
describe('스위치 ON + 일부 키 잠금', () => {
const response = createSettingsResponse({
canUpdate: true,
envPriorityEnabled: true,
envLocked: { 'general.site_name': true },
});
// @scenario switch=on, key_state=unlocked, surface=ui_lock
// @effects badge_and_disabled_rendered
it('안내 배너가 표시되어야 한다', async () => {
const testUtils = createLayoutTest(settingsLayout);
testUtils.mockApi('settings', { response });
await testUtils.render();
expect(screen.getByTestId('env-priority-banner')).toBeTruthy();
testUtils.cleanup();
});
// @scenario switch=on, key_state=locked_plain, surface=ui_lock
// @effects badge_and_disabled_rendered
it('잠긴 필드에만 배지가 렌더되어야 한다', async () => {
const testUtils = createLayoutTest(settingsLayout);
testUtils.mockApi('settings', { response });
await testUtils.render();
expect(screen.getByTestId('badge-site-name')).toBeTruthy();
expect(screen.queryByTestId('badge-site-description')).toBeNull();
testUtils.cleanup();
});
it('잠긴 필드만 disabled 여야 한다', async () => {
const testUtils = createLayoutTest(settingsLayout);
testUtils.mockApi('settings', { response });
await testUtils.render();
expect((screen.getByTestId('input-site-name') as HTMLInputElement).disabled).toBe(true);
expect((screen.getByTestId('input-site-description') as HTMLInputElement).disabled).toBe(false);
testUtils.cleanup();
});
});
describe('스위치 OFF (현행)', () => {
const response = createSettingsResponse({
canUpdate: true,
envPriorityEnabled: false,
envLocked: {},
});
// @scenario switch=off, key_state=unlocked, surface=ui_lock
// @effects switch_off_behaves_identically
it('배너와 배지가 모두 없어야 한다', async () => {
const testUtils = createLayoutTest(settingsLayout);
testUtils.mockApi('settings', { response });
await testUtils.render();
expect(screen.queryByTestId('env-priority-banner')).toBeNull();
expect(screen.queryByTestId('badge-site-name')).toBeNull();
expect(screen.queryByTestId('badge-site-description')).toBeNull();
testUtils.cleanup();
});
it('모든 필드가 활성이어야 한다', async () => {
const testUtils = createLayoutTest(settingsLayout);
testUtils.mockApi('settings', { response });
await testUtils.render();
expect((screen.getByTestId('input-site-name') as HTMLInputElement).disabled).toBe(false);
expect((screen.getByTestId('input-site-description') as HTMLInputElement).disabled).toBe(false);
testUtils.cleanup();
});
});
describe('권한 잠금 × env 잠금 (OR 중첩)', () => {
it('can_update:false 면 env 잠금과 무관하게 모두 disabled 여야 한다', async () => {
const testUtils = createLayoutTest(settingsLayout);
testUtils.mockApi('settings', {
response: createSettingsResponse({
canUpdate: false,
envPriorityEnabled: true,
envLocked: { 'general.site_name': true },
}),
});
await testUtils.render();
expect((screen.getByTestId('input-site-name') as HTMLInputElement).disabled).toBe(true);
expect((screen.getByTestId('input-site-description') as HTMLInputElement).disabled).toBe(true);
testUtils.cleanup();
});
it('can_update:false 여도 잠금 배지는 env 잠금 필드에만 붙어야 한다', async () => {
const testUtils = createLayoutTest(settingsLayout);
testUtils.mockApi('settings', {
response: createSettingsResponse({
canUpdate: false,
envPriorityEnabled: true,
envLocked: { 'general.site_name': true },
}),
});
await testUtils.render();
expect(screen.getByTestId('badge-site-name')).toBeTruthy();
expect(screen.queryByTestId('badge-site-description')).toBeNull();
testUtils.cleanup();
});
});
});
@@ -1307,6 +1307,8 @@
"page_title": "Settings",
"page_description": "Configure system settings.",
"readonly_notice": "These settings are read-only. Contact your administrator for edit access.",
"env_locked_badge": "Set in .env",
"env_locked_notice": "This site runs with .env priority enabled. Fields whose values are set in .env cannot be changed here, and the values shown are the ones actually in effect. To change them, edit .env on the server and rebuild the configuration cache.",
"tabs": {
"general": "General",
"mail": "Mail",
@@ -1311,6 +1311,8 @@
"page_title": "환경설정",
"page_description": "시스템 환경을 설정합니다.",
"readonly_notice": "이 설정은 읽기 전용입니다. 수정 권한이 필요한 경우 관리자에게 문의하세요.",
"env_locked_badge": ".env 고정",
"env_locked_notice": "이 사이트는 .env 우선 모드로 운영됩니다. .env 에 값이 지정된 항목은 여기서 변경할 수 없으며, 화면에는 실제로 적용 중인 값이 표시됩니다. 변경하려면 서버의 .env 를 수정한 뒤 설정 캐시를 다시 만드세요.",
"tabs": {
"general": "일반",
"mail": "메일",
@@ -10,7 +10,8 @@
"description": "$t:admin.settings.page_description"
},
"computed": {
"isReadOnly": "{{!!_global.settingsAbilities && !(_global.settingsAbilities?.can_update ?? false)}}"
"isReadOnly": "{{!!_global.settingsAbilities && !(_global.settingsAbilities?.can_update ?? false)}}",
"envLocked": "{{_local.form?._meta?.env_locked ?? null}}"
},
"transition_overlay": {
"target": "settings_tab_content",
@@ -427,6 +428,42 @@
}
]
},
{
"id": "env_priority_banner",
"type": "basic",
"name": "Div",
"if": "{{_local.form?._meta?.env_priority_enabled === true}}",
"props": {
"className": "alert-info"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-center gap-3"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "lock",
"className": "text-xl text-blue-600 dark:text-blue-400 flex-shrink-0"
}
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-info-soft"
},
"text": "$t:admin.settings.env_locked_notice"
}
]
}
]
},
{
"id": "sticky_header",
"type": "basic",
@@ -784,6 +784,17 @@
"className": "text-red-500 ml-1"
},
"text": "*"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['advanced.debug_mode'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
@@ -832,7 +843,7 @@
"props": {
"name": "advanced.debug_mode",
"size": "md",
"disabled": "{{_computed.isReadOnly}}"
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['advanced.debug_mode'] === true}}"
}
}
]
@@ -1314,6 +1325,17 @@
"className": "text-heading"
},
"text": "$t:admin.settings.advanced.core_update_github_url"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['advanced.core_update_github_url'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
@@ -1332,7 +1354,7 @@
"type": "url",
"name": "advanced.core_update_github_url",
"placeholder": "https://github.com/owner/repo",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['advanced.core_update_github_url'] === true}}",
"className": "input {{_local.errors?.['advanced.core_update_github_url'] ? 'input-error' : ''}}"
}
},
@@ -1366,6 +1388,17 @@
"className": "text-heading"
},
"text": "$t:admin.settings.advanced.core_update_github_token"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['advanced.core_update_github_token'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
@@ -1385,7 +1418,7 @@
"name": "advanced.core_update_github_token",
"placeholder": "ghp_xxxxxxxxxxxxxxxxxxxx",
"autoComplete": "off",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['advanced.core_update_github_token'] === true}}",
"className": "input {{_local.errors?.['advanced.core_update_github_token'] ? 'input-error' : ''}}"
}
},
@@ -1462,6 +1495,17 @@
"className": "text-label-subtle"
},
"text": "$t:admin.settings.advanced.geoip_enabled_desc"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['advanced.geoip_enabled'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
@@ -1471,7 +1515,7 @@
"props": {
"name": "advanced.geoip_enabled",
"size": "md",
"disabled": "{{_computed.isReadOnly}}"
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['advanced.geoip_enabled'] === true}}"
}
}
]
@@ -1507,6 +1551,17 @@
"className": "text-heading"
},
"text": "$t:admin.settings.advanced.geoip_license_key"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['advanced.geoip_license_key'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
@@ -1526,7 +1581,7 @@
"name": "advanced.geoip_license_key",
"placeholder": "xxxxxxxxxxxxxxxx",
"autoComplete": "off",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['advanced.geoip_license_key'] === true}}",
"className": "{{_local.errors?.['advanced.geoip_license_key'] ? 'input-error' : ''}}"
}
},
@@ -1568,6 +1623,17 @@
"className": "text-label-subtle"
},
"text": "$t:admin.settings.advanced.geoip_auto_update_enabled_desc"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['advanced.geoip_auto_update_enabled'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
@@ -1577,7 +1643,7 @@
"props": {
"name": "advanced.geoip_auto_update_enabled",
"size": "md",
"disabled": "{{_computed.isReadOnly}}"
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['advanced.geoip_auto_update_enabled'] === true}}"
}
}
]
@@ -53,7 +53,24 @@
"props": {
"className": "form-label"
},
"text": "$t:admin.settings.drivers.storage.driver"
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.drivers.storage.driver"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['drivers.storage_driver'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
{
"type": "basic",
@@ -67,7 +84,7 @@
"name": "Select",
"props": {
"name": "drivers.storage_driver",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['drivers.storage_driver'] === true}}",
"className": "w-full",
"error": "{{_local.errors?.['drivers.storage_driver']?.[0] ?? ''}}",
"options": "{{(_local.form?.available_drivers?.storage ?? []).map(d => ({value: d.id, label: $localized(d.label)}))}}"
@@ -139,7 +156,24 @@
"props": {
"className": "form-label"
},
"text": "$t:admin.settings.drivers.storage.s3_bucket"
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.drivers.storage.s3_bucket"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['drivers.s3_bucket'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
{
"type": "basic",
@@ -148,7 +182,7 @@
"type": "text",
"name": "drivers.s3_bucket",
"placeholder": "my-bucket",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['drivers.s3_bucket'] === true}}",
"className": "{{_local.errors?.['drivers.s3_bucket'] ? 'input-error md:w-64' : 'md:w-64'}}"
}
},
@@ -176,7 +210,24 @@
"props": {
"className": "form-label"
},
"text": "$t:admin.settings.drivers.storage.s3_region"
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.drivers.storage.s3_region"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['drivers.s3_region'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
{
"type": "basic",
@@ -185,7 +236,7 @@
"type": "text",
"name": "drivers.s3_region",
"placeholder": "ap-northeast-2",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['drivers.s3_region'] === true}}",
"className": "{{_local.errors?.['drivers.s3_region'] ? 'input-error md:w-64 font-mono' : 'md:w-64 font-mono'}}"
}
},
@@ -221,7 +272,24 @@
"props": {
"className": "form-label"
},
"text": "$t:admin.settings.drivers.storage.s3_access_key"
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.drivers.storage.s3_access_key"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['drivers.s3_access_key'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
{
"type": "basic",
@@ -230,7 +298,7 @@
"type": "text",
"name": "drivers.s3_access_key",
"placeholder": "AKIAIOSFODNN7EXAMPLE",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['drivers.s3_access_key'] === true}}",
"className": "{{_local.errors?.['drivers.s3_access_key'] ? 'input-error md:w-80 font-mono' : 'md:w-80 font-mono'}}"
}
},
@@ -258,7 +326,24 @@
"props": {
"className": "form-label"
},
"text": "$t:admin.settings.drivers.storage.s3_secret_key"
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.drivers.storage.s3_secret_key"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['drivers.s3_secret_key'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
{
"type": "basic",
@@ -267,7 +352,7 @@
"type": "password",
"name": "drivers.s3_secret_key",
"placeholder": "********",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['drivers.s3_secret_key'] === true}}",
"className": "{{_local.errors?.['drivers.s3_secret_key'] ? 'input-error md:w-80 font-mono' : 'md:w-80 font-mono'}}"
}
},
@@ -296,7 +381,24 @@
"props": {
"className": "form-label"
},
"text": "$t:admin.settings.drivers.storage.s3_endpoint"
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.drivers.storage.s3_endpoint"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['drivers.s3_endpoint'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
{
"type": "basic",
@@ -305,7 +407,7 @@
"type": "text",
"name": "drivers.s3_endpoint",
"placeholder": "https://<account-id>.r2.cloudflarestorage.com",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['drivers.s3_endpoint'] === true}}",
"className": "{{_local.errors?.['drivers.s3_endpoint'] ? 'input-error' : ''}}"
}
},
@@ -362,6 +464,17 @@
"className": "text-label-subtle"
},
"text": "$t:admin.settings.drivers.storage.s3_use_path_style_desc"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['drivers.s3_use_path_style'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
@@ -371,7 +484,7 @@
"props": {
"name": "drivers.s3_use_path_style",
"size": "md",
"disabled": "{{_computed.isReadOnly}}"
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['drivers.s3_use_path_style'] === true}}"
}
}
]
@@ -391,7 +504,24 @@
"props": {
"className": "form-label"
},
"text": "$t:admin.settings.drivers.storage.s3_url"
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.drivers.storage.s3_url"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['drivers.s3_url'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
{
"type": "basic",
@@ -400,7 +530,7 @@
"type": "text",
"name": "drivers.s3_url",
"placeholder": "https://my-bucket.s3.ap-northeast-2.amazonaws.com",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['drivers.s3_url'] === true}}",
"className": "{{_local.errors?.['drivers.s3_url'] ? 'input-error' : ''}}"
}
},
@@ -755,7 +885,24 @@
"props": {
"className": "form-label"
},
"text": "$t:admin.settings.drivers.cache.driver"
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.drivers.cache.driver"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['drivers.cache_driver'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
{
"type": "basic",
@@ -769,7 +916,7 @@
"name": "Select",
"props": {
"name": "drivers.cache_driver",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['drivers.cache_driver'] === true}}",
"className": "w-full",
"error": "{{_local.errors?.['drivers.cache_driver']?.[0] ?? ''}}",
"options": "{{(_local.form?.available_drivers?.cache ?? []).map(d => ({value: d.id, label: $localized(d.label)}))}}"
@@ -847,7 +994,24 @@
"props": {
"className": "form-label"
},
"text": "$t:admin.settings.drivers.cache.redis_host"
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.drivers.cache.redis_host"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['drivers.redis_host'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
{
"type": "basic",
@@ -856,7 +1020,7 @@
"type": "text",
"name": "drivers.redis_host",
"placeholder": "127.0.0.1",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['drivers.redis_host'] === true}}",
"className": "{{_local.errors?.['drivers.redis_host'] ? 'input-error' : ''}}"
}
},
@@ -884,7 +1048,24 @@
"props": {
"className": "form-label"
},
"text": "$t:admin.settings.drivers.cache.redis_port"
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.drivers.cache.redis_port"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['drivers.redis_port'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
{
"type": "basic",
@@ -895,7 +1076,7 @@
"placeholder": "6379",
"min": "{{_local?.form?._meta?.limits?.drivers_redis_port_min ?? 1}}",
"max": "{{_local?.form?._meta?.limits?.drivers_redis_port_max ?? 65535}}",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['drivers.redis_port'] === true}}",
"className": "{{_local.errors?.['drivers.redis_port'] ? 'input-error' : ''}}"
}
},
@@ -932,7 +1113,24 @@
"props": {
"className": "form-label"
},
"text": "$t:admin.settings.drivers.cache.redis_password"
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.drivers.cache.redis_password"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['drivers.redis_password'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
{
"type": "basic",
@@ -941,7 +1139,7 @@
"type": "password",
"name": "drivers.redis_password",
"placeholder": "$t:admin.settings.drivers.common.optional",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['drivers.redis_password'] === true}}",
"className": "{{_local.errors?.['drivers.redis_password'] ? 'input-error' : ''}}"
}
},
@@ -969,7 +1167,24 @@
"props": {
"className": "form-label"
},
"text": "$t:admin.settings.drivers.cache.redis_database"
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.drivers.cache.redis_database"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['drivers.redis_database'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
{
"type": "basic",
@@ -980,7 +1195,7 @@
"placeholder": "0",
"min": "{{_local?.form?._meta?.limits?.drivers_redis_database_min ?? 0}}",
"max": "{{_local?.form?._meta?.limits?.drivers_redis_database_max ?? 15}}",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['drivers.redis_database'] === true}}",
"className": "{{_local.errors?.['drivers.redis_database'] ? 'input-error' : ''}}"
}
},
@@ -1184,7 +1399,24 @@
"props": {
"className": "form-label"
},
"text": "$t:admin.settings.drivers.cache.memcached_host"
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.drivers.cache.memcached_host"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['drivers.memcached_host'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
{
"type": "basic",
@@ -1193,7 +1425,7 @@
"type": "text",
"name": "drivers.memcached_host",
"placeholder": "127.0.0.1",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['drivers.memcached_host'] === true}}",
"className": "{{_local.errors?.['drivers.memcached_host'] ? 'input-error' : ''}}"
}
},
@@ -1221,7 +1453,24 @@
"props": {
"className": "form-label"
},
"text": "$t:admin.settings.drivers.cache.memcached_port"
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.drivers.cache.memcached_port"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['drivers.memcached_port'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
{
"type": "basic",
@@ -1232,7 +1481,7 @@
"placeholder": "11211",
"min": "{{_local?.form?._meta?.limits?.drivers_memcached_port_min ?? 1}}",
"max": "{{_local?.form?._meta?.limits?.drivers_memcached_port_max ?? 65535}}",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['drivers.memcached_port'] === true}}",
"className": "{{_local.errors?.['drivers.memcached_port'] ? 'input-error' : ''}}"
}
},
@@ -1454,7 +1703,24 @@
"props": {
"className": "form-label"
},
"text": "$t:admin.settings.drivers.search_engine.driver"
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.drivers.search_engine.driver"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['drivers.search_engine_driver'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
{
"type": "basic",
@@ -1468,7 +1734,7 @@
"name": "Select",
"props": {
"name": "drivers.search_engine_driver",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['drivers.search_engine_driver'] === true}}",
"className": "w-full",
"error": "{{_local.errors?.['drivers.search_engine_driver']?.[0] ?? ''}}",
"options": "{{(_local.form?.available_drivers?.search ?? []).map(d => ({value: d.id, label: $localized(d.label)}))}}"
@@ -1599,7 +1865,24 @@
"props": {
"className": "form-label"
},
"text": "$t:admin.settings.drivers.session.driver"
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.drivers.session.driver"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['drivers.session_driver'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
{
"type": "basic",
@@ -1613,7 +1896,7 @@
"name": "Select",
"props": {
"name": "drivers.session_driver",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['drivers.session_driver'] === true}}",
"className": "w-full",
"error": "{{_local.errors?.['drivers.session_driver']?.[0] ?? ''}}",
"options": "{{(_local.form?.available_drivers?.session ?? []).map(d => ({value: d.id, label: $localized(d.label)}))}}"
@@ -1699,7 +1982,24 @@
"props": {
"className": "form-label"
},
"text": "$t:admin.settings.drivers.session.lifetime"
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.drivers.session.lifetime"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['drivers.session_lifetime'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
{
"type": "basic",
@@ -1717,7 +2017,7 @@
"placeholder": "120",
"min": "{{_local?.form?._meta?.limits?.drivers_session_lifetime_min ?? 1}}",
"max": "{{_local?.form?._meta?.limits?.drivers_session_lifetime_max ?? 43200}}",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['drivers.session_lifetime'] === true}}",
"className": "{{_local.errors?.['drivers.session_lifetime'] ? 'input-error w-32' : 'w-32'}}"
}
},
@@ -1788,7 +2088,24 @@
"props": {
"className": "form-label"
},
"text": "$t:admin.settings.drivers.queue.driver"
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.drivers.queue.driver"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['drivers.queue_driver'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
{
"type": "basic",
@@ -1802,7 +2119,7 @@
"name": "Select",
"props": {
"name": "drivers.queue_driver",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['drivers.queue_driver'] === true}}",
"className": "w-full",
"error": "{{_local.errors?.['drivers.queue_driver']?.[0] ?? ''}}",
"options": "{{(_local.form?.available_drivers?.queue ?? []).map(d => ({value: d.id, label: $localized(d.label)}))}}"
@@ -1972,7 +2289,24 @@
"props": {
"className": "form-label"
},
"text": "$t:admin.settings.drivers.log.driver"
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.drivers.log.driver"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['drivers.log_driver'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
{
"type": "basic",
@@ -1986,7 +2320,7 @@
"name": "Select",
"props": {
"name": "drivers.log_driver",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['drivers.log_driver'] === true}}",
"className": "w-full",
"error": "{{_local.errors?.['drivers.log_driver']?.[0] ?? ''}}",
"options": "{{(_local.form?.available_drivers?.log ?? []).map(d => ({value: d.id, label: $localized(d.label)}))}}"
@@ -2049,7 +2383,24 @@
"props": {
"className": "form-label"
},
"text": "$t:admin.settings.drivers.log.level"
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.drivers.log.level"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['drivers.log_level'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
{
"type": "basic",
@@ -2063,7 +2414,7 @@
"name": "Select",
"props": {
"name": "drivers.log_level",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['drivers.log_level'] === true}}",
"className": "w-full",
"error": "{{_local.errors?.['drivers.log_level']?.[0] ?? ''}}",
"options": [
@@ -2145,7 +2496,24 @@
"props": {
"className": "form-label"
},
"text": "$t:admin.settings.drivers.log.retention_days"
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.drivers.log.retention_days"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['drivers.log_days'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
{
"type": "basic",
@@ -2163,7 +2531,7 @@
"placeholder": "14",
"min": "{{_local?.form?._meta?.limits?.drivers_log_days_min ?? 1}}",
"max": "{{_local?.form?._meta?.limits?.drivers_log_days_max ?? 365}}",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['drivers.log_days'] === true}}",
"className": "{{_local.errors?.['drivers.log_days'] ? 'input-error w-32' : 'w-32'}}"
}
},
@@ -2290,6 +2658,17 @@
"className": "text-label-subtle"
},
"text": "$t:admin.settings.drivers.websocket.enabled_desc"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['drivers.websocket_enabled'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
@@ -2299,7 +2678,7 @@
"props": {
"name": "drivers.websocket_enabled",
"size": "md",
"disabled": "{{_computed.isReadOnly}}"
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['drivers.websocket_enabled'] === true}}"
}
}
]
@@ -2346,6 +2725,17 @@
"className": "text-red-500 ml-1"
},
"text": "*"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['drivers.websocket_app_id'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
@@ -2357,7 +2747,7 @@
"name": "drivers.websocket_app_id",
"placeholder": "my-app-id",
"required": true,
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['drivers.websocket_app_id'] === true}}",
"className": "{{_local.errors?.['drivers.websocket_app_id'] ? 'input-error font-mono' : 'font-mono'}}"
}
},
@@ -2398,6 +2788,17 @@
"className": "text-red-500 ml-1"
},
"text": "*"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['drivers.websocket_app_key'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
@@ -2409,7 +2810,7 @@
"name": "drivers.websocket_app_key",
"placeholder": "my-app-key",
"required": true,
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['drivers.websocket_app_key'] === true}}",
"className": "{{_local.errors?.['drivers.websocket_app_key'] ? 'input-error font-mono' : 'font-mono'}}"
}
},
@@ -2450,6 +2851,17 @@
"className": "text-red-500 ml-1"
},
"text": "*"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['drivers.websocket_app_secret'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
@@ -2461,7 +2873,7 @@
"name": "drivers.websocket_app_secret",
"placeholder": "my-app-secret",
"required": true,
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['drivers.websocket_app_secret'] === true}}",
"className": "{{_local.errors?.['drivers.websocket_app_secret'] ? 'input-error font-mono' : 'font-mono'}}"
}
},
@@ -2624,7 +3036,7 @@
"id": "input_websocket_verify_ssl",
"type": "checkbox",
"name": "drivers.websocket_verify_ssl",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['drivers.websocket_verify_ssl'] === true}}",
"className": "h-4 w-4 text-blue-600 rounded"
}
},
@@ -2639,7 +3051,24 @@
"htmlFor": "input_websocket_verify_ssl",
"className": "text-body font-medium cursor-pointer"
},
"text": "$t:admin.settings.drivers.websocket.verify_ssl"
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.drivers.websocket.verify_ssl"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['drivers.websocket_verify_ssl'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
{
"type": "basic",
@@ -2689,7 +3118,24 @@
"props": {
"className": "form-label"
},
"text": "$t:admin.settings.drivers.websocket.server_host"
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.drivers.websocket.server_host"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['drivers.websocket_server_host'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
{
"type": "basic",
@@ -2698,7 +3144,7 @@
"type": "text",
"name": "drivers.websocket_server_host",
"placeholder": "127.0.0.1",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['drivers.websocket_server_host'] === true}}",
"className": "{{_local.errors?.['drivers.websocket_server_host'] ? 'input-error' : ''}}"
}
},
@@ -2726,7 +3172,24 @@
"props": {
"className": "form-label"
},
"text": "$t:admin.settings.drivers.websocket.server_port"
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.drivers.websocket.server_port"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['drivers.websocket_server_port'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
{
"type": "basic",
@@ -2737,7 +3200,7 @@
"placeholder": "8080",
"min": "{{_local?.form?._meta?.limits?.drivers_websocket_server_port_min ?? 1}}",
"max": "{{_local?.form?._meta?.limits?.drivers_websocket_server_port_max ?? 65535}}",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['drivers.websocket_server_port'] === true}}",
"className": "{{_local.errors?.['drivers.websocket_server_port'] ? 'input-error' : ''}}"
}
},
@@ -2765,14 +3228,31 @@
"props": {
"className": "form-label"
},
"text": "$t:admin.settings.drivers.websocket.server_scheme"
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.drivers.websocket.server_scheme"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['drivers.websocket_server_scheme'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
{
"type": "composite",
"name": "Select",
"props": {
"name": "drivers.websocket_server_scheme",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['drivers.websocket_server_scheme'] === true}}",
"className": "w-full",
"error": "{{_local.errors?.['drivers.websocket_server_scheme']?.[0] ?? ''}}",
"options": [
@@ -70,6 +70,17 @@
"className": "text-red-500 ml-1"
},
"text": "*"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['general.site_name'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
@@ -80,7 +91,7 @@
"type": "text",
"name": "general.site_name",
"placeholder": "그누보드7",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['general.site_name'] === true}}",
"className": "input {{_local.errors?.['general.site_name'] ? 'input-error' : ''}}"
}
},
@@ -119,6 +130,17 @@
"className": "text-red-500 ml-1"
},
"text": "*"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['general.site_url'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
@@ -129,7 +151,7 @@
"type": "url",
"name": "general.site_url",
"placeholder": "https://example.com",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['general.site_url'] === true}}",
"className": "input {{_local.errors?.['general.site_url'] ? 'input-error' : ''}}"
}
},
@@ -386,6 +408,17 @@
"className": "text-red-500 ml-1"
},
"text": "*"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['general.language'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
@@ -395,7 +428,7 @@
"props": {
"name": "general.language",
"className": "w-full",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['general.language'] === true}}",
"placeholder": "$t:admin.settings.general.select_language",
"options": "{{(_global.appConfig?.supportedLocales ?? ['ko', 'en']).map(l => ({ value: l, label: _global.appConfig?.localeNames?.[l] ?? l }))}}",
"error": "{{_local.errors?.['general.language']?.[0] ?? ''}}"
@@ -436,6 +469,17 @@
"className": "text-red-500 ml-1"
},
"text": "*"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['general.timezone'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
@@ -445,7 +489,7 @@
"props": {
"name": "general.timezone",
"className": "w-full",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['general.timezone'] === true}}",
"placeholder": "$t:admin.settings.general.select_timezone",
"options": "{{_global.appConfig?.supportedTimezones ?? []}}",
"searchable": true,
@@ -71,6 +71,17 @@
"className": "text-red-500 ml-1"
},
"text": "*"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['mail.mailer'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
@@ -80,7 +91,7 @@
"props": {
"name": "mail.mailer",
"className": "w-full",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['mail.mailer'] === true}}",
"placeholder": "$t:admin.settings.mail.select_mailer",
"options": "{{(_local.form?.available_drivers?.mail ?? []).map(d => ({value: d.id, label: $localized(d.label)}))}}",
"error": "{{_local.errors?.['mail.mailer']?.[0] ?? ''}}"
@@ -200,6 +211,17 @@
"className": "text-red-500 ml-1"
},
"text": "*"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['mail.host'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
@@ -210,7 +232,7 @@
"type": "text",
"name": "mail.host",
"placeholder": "smtp.example.com",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['mail.host'] === true}}",
"className": "input {{_local.errors?.['mail.host'] ? 'input-error' : ''}}"
}
},
@@ -258,6 +280,17 @@
"className": "text-red-500 ml-1"
},
"text": "*"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['mail.port'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
@@ -268,7 +301,7 @@
"type": "number",
"name": "mail.port",
"placeholder": "587",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['mail.port'] === true}}",
"className": "input {{_local.errors?.['mail.port'] ? 'input-error' : ''}}"
}
},
@@ -294,7 +327,24 @@
"props": {
"className": "form-label"
},
"text": "$t:admin.settings.mail.username"
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.mail.username"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['mail.username'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
{
"type": "basic",
@@ -303,7 +353,7 @@
"type": "text",
"name": "mail.username",
"placeholder": "your@email.com",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['mail.username'] === true}}",
"className": "input"
}
}
@@ -322,7 +372,24 @@
"props": {
"className": "form-label"
},
"text": "$t:admin.settings.mail.password"
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.mail.password"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['mail.password'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
{
"type": "basic",
@@ -331,7 +398,7 @@
"type": "password",
"name": "mail.password",
"placeholder": "********",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['mail.password'] === true}}",
"className": "input"
}
}
@@ -379,6 +446,17 @@
"className": "text-red-500 ml-1"
},
"text": "*"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['mail.mailgun_domain'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
@@ -389,7 +467,7 @@
"type": "text",
"name": "mail.mailgun_domain",
"placeholder": "mg.example.com",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['mail.mailgun_domain'] === true}}",
"className": "input {{_local.errors?.['mail.mailgun_domain'] ? 'input-error' : ''}}"
}
},
@@ -428,6 +506,17 @@
"className": "text-red-500 ml-1"
},
"text": "*"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['mail.mailgun_secret'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
@@ -438,7 +527,7 @@
"type": "password",
"name": "mail.mailgun_secret",
"placeholder": "key-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['mail.mailgun_secret'] === true}}",
"className": "input {{_local.errors?.['mail.mailgun_secret'] ? 'input-error' : ''}}"
}
},
@@ -466,7 +555,24 @@
"props": {
"className": "form-label"
},
"text": "$t:admin.settings.mail.mailgun_endpoint"
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.mail.mailgun_endpoint"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['mail.mailgun_endpoint'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
{
"type": "basic",
@@ -475,7 +581,7 @@
"type": "text",
"name": "mail.mailgun_endpoint",
"placeholder": "api.mailgun.net",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['mail.mailgun_endpoint'] === true}}",
"className": "input"
}
}
@@ -523,6 +629,17 @@
"className": "text-red-500 ml-1"
},
"text": "*"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['mail.ses_key'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
@@ -533,7 +650,7 @@
"type": "text",
"name": "mail.ses_key",
"placeholder": "AKIAIOSFODNN7EXAMPLE",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['mail.ses_key'] === true}}",
"className": "input {{_local.errors?.['mail.ses_key'] ? 'input-error' : ''}}"
}
},
@@ -572,6 +689,17 @@
"className": "text-red-500 ml-1"
},
"text": "*"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['mail.ses_secret'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
@@ -582,7 +710,7 @@
"type": "password",
"name": "mail.ses_secret",
"placeholder": "********",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['mail.ses_secret'] === true}}",
"className": "input {{_local.errors?.['mail.ses_secret'] ? 'input-error' : ''}}"
}
},
@@ -623,6 +751,17 @@
"className": "text-red-500 ml-1"
},
"text": "*"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['mail.ses_region'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
@@ -632,7 +771,7 @@
"props": {
"name": "mail.ses_region",
"className": "w-full",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['mail.ses_region'] === true}}",
"placeholder": "$t:admin.settings.mail.select_ses_region",
"options": [
{
@@ -711,6 +850,17 @@
"className": "text-red-500 ml-1"
},
"text": "*"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['mail.from_address'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
@@ -721,7 +871,7 @@
"type": "email",
"name": "mail.from_address",
"placeholder": "noreply@example.com",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['mail.from_address'] === true}}",
"className": "input {{_local.errors?.['mail.from_address'] ? 'input-error' : ''}}"
}
},
@@ -760,6 +910,17 @@
"className": "text-red-500 ml-1"
},
"text": "*"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['mail.from_name'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
@@ -770,7 +931,7 @@
"type": "text",
"name": "mail.from_name",
"placeholder": "그누보드7",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['mail.from_name'] === true}}",
"className": "input {{_local.errors?.['mail.from_name'] ? 'input-error' : ''}}"
}
},
@@ -67,6 +67,17 @@
"className": "text-red-500 ml-1"
},
"text": "*"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['upload.max_file_size'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
@@ -85,7 +96,7 @@
"name": "upload.max_file_size",
"min": "{{_local?.form?._meta?.limits?.upload_max_file_size_min ?? 1}}",
"max": "{{_local?.form?._meta?.limits?.upload_max_file_size_max ?? 1024}}",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['upload.max_file_size'] === true}}",
"className": "input flex-1 {{_local.errors?.['upload.max_file_size'] ? 'input-error' : ''}}"
}
},
@@ -215,7 +226,24 @@
"props": {
"className": "form-label"
},
"text": "$t:admin.settings.upload.image_max_width"
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.upload.image_max_width"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['upload.image_max_width'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
{
"type": "basic",
@@ -232,7 +260,7 @@
"name": "upload.image_max_width",
"min": "{{_local?.form?._meta?.limits?.upload_image_max_width_min ?? 100}}",
"max": "{{_local?.form?._meta?.limits?.upload_image_max_width_max ?? 10000}}",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['upload.image_max_width'] === true}}",
"className": "input flex-1"
}
},
@@ -259,7 +287,24 @@
"props": {
"className": "form-label"
},
"text": "$t:admin.settings.upload.image_max_height"
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.upload.image_max_height"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['upload.image_max_height'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
{
"type": "basic",
@@ -276,7 +321,7 @@
"name": "upload.image_max_height",
"min": "{{_local?.form?._meta?.limits?.upload_image_max_height_min ?? 100}}",
"max": "{{_local?.form?._meta?.limits?.upload_image_max_height_max ?? 10000}}",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['upload.image_max_height'] === true}}",
"className": "input flex-1"
}
},
@@ -303,7 +348,24 @@
"props": {
"className": "form-label"
},
"text": "$t:admin.settings.upload.image_quality"
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:admin.settings.upload.image_quality"
},
{
"type": "composite",
"name": "Badge",
"if": "{{_computed.envLocked?.['upload.image_quality'] === true}}",
"props": {
"text": "$t:admin.settings.env_locked_badge",
"variant": "secondary",
"size": "sm",
"className": "ml-2"
}
}
]
},
{
"type": "basic",
@@ -320,7 +382,7 @@
"name": "upload.image_quality",
"min": "{{_local?.form?._meta?.limits?.upload_image_quality_min ?? 1}}",
"max": "{{_local?.form?._meta?.limits?.upload_image_quality_max ?? 100}}",
"disabled": "{{_computed.isReadOnly}}",
"disabled": "{{_computed.isReadOnly || _computed.envLocked?.['upload.image_quality'] === true}}",
"className": "input flex-1"
}
},
@@ -0,0 +1,455 @@
<?php
namespace Tests\Feature\Settings;
use App\Support\EnvPriority;
use Tests\TestCase;
/**
* `.env` 키 단위 우선 규약의 계약 테스트.
*
* 이 결함군은 오류를 남기지 않는다 — 매핑이 뒤처지거나 배선이 빠지면 잠금이 조용히
* 미발동하고, 그 상태는 "그 키가 `.env` 에 없다"와 화면상 구분되지 않는다. 그래서
* 판정을 행위가 아니라 **구조**(맵 패리티 · 캡처 일치 · 배선 실존)로 고정한다.
*
* @effects explicit_env_skips_injection, empty_string_env_not_explicit
*/
class EnvPriorityContractTest extends TestCase
{
/**
* 주입 대상 카테고리의 모든 defaults 키는 MAP 이나 EXEMPT 에 등재되어 있습니다.
*
* Provider 에 새 주입이 추가되면 이 단언이 red 가 되어, 그 키가 `.env` 대응을 갖는지
* 판단하도록 강제합니다. 등재를 잊으면 그 키는 영원히 settings 승으로 남습니다.
*
* @effects map_parity_covers_every_injected_default_key
*/
public function test_모든_주입_카테고리_키가_맵이나_면제목록에_등재된다(): void
{
$defaults = $this->loadDefaults();
$unregistered = [];
foreach (EnvPriority::INJECTED_CATEGORIES as $category) {
foreach (array_keys($defaults[$category] ?? []) as $key) {
$storageKey = $category.'.'.$key;
if (! array_key_exists($storageKey, EnvPriority::MAP)
&& ! array_key_exists($storageKey, EnvPriority::EXEMPT)) {
$unregistered[] = $storageKey;
}
}
}
$this->assertSame(
[],
$unregistered,
'EnvPriority::MAP 또는 EXEMPT 에 등재되지 않은 설정 키: '.implode(', ', $unregistered)
);
}
/**
* MAP 의 모든 settings 키는 defaults.json 에 실존합니다.
*
* 반대 방향 — 오타나 사라진 키가 맵에 남아 있으면 그 항목은 영원히 잠기지 않습니다.
*
* @effects map_parity_covers_every_injected_default_key
*/
public function test_맵의_모든_키가_defaults에_실존한다(): void
{
$defaults = $this->loadDefaults();
$missing = [];
foreach (array_keys(EnvPriority::MAP + EnvPriority::EXEMPT) as $storageKey) {
[$category, $key] = explode('.', $storageKey, 2);
if (! array_key_exists($key, $defaults[$category] ?? [])) {
$missing[] = $storageKey;
}
}
$this->assertSame(
[],
$missing,
'defaults.json 에 없는 키가 맵/면제목록에 남아 있습니다: '.implode(', ', $missing)
);
}
/**
* MAP 의 모든 config 키는 실존하는 config 경로입니다.
*
* 오타난 config 키는 표시값이 조용히 null 이 되고, 문서 근거도 함께 어긋납니다.
* 런타임에 생성되는 키(`RUNTIME_CREATED_CONFIG_KEYS`)만 사유와 함께 제외합니다.
*
* @effects locked_plain_shows_effective_value
*/
public function test_맵의_config_키가_실존한다(): void
{
$missing = [];
foreach (EnvPriority::MAP as $storageKey => $entry) {
foreach ($entry['config'] as $configKey) {
if (in_array($configKey, EnvPriority::RUNTIME_CREATED_CONFIG_KEYS, true)) {
continue;
}
if (! config()->has($configKey)) {
$missing[] = $storageKey.' → '.$configKey;
}
}
}
$this->assertSame(
[],
$missing,
'실존하지 않는 config 키: '.implode(', ', $missing)
);
}
/**
* `config/env-priority.php` 의 캡처 대상이 `EnvPriority::envVars()` 와 일치합니다.
*
* 캡처 목록이 뒤처지면 그 env 변수는 명시해도 잠기지 않습니다 — 오류 없이.
*
* @effects explicit_env_skips_injection
*/
public function test_캡처_대상이_맵의_env_변수와_일치한다(): void
{
$source = (string) file_get_contents(config_path('env-priority.php'));
$this->assertStringContainsString(
'EnvPriority::envVars()',
$source,
'config/env-priority.php 는 캡처 대상을 EnvPriority::envVars() 에서 도출해야 합니다 (목록 중복 기재 금지).'
);
// 실제 평가 결과가 맵의 변수만 다루는지도 확인한다 (SSoT 도출이 형식만 남는 것 차단).
$captured = require config_path('env-priority.php');
$this->assertIsArray($captured['explicit']);
$this->assertSame(
[],
array_diff(array_keys($captured['explicit']), EnvPriority::envVars()),
'캡처 결과에 맵 밖의 env 변수가 섞였습니다.'
);
}
/**
* 스위치는 `config('env-priority.enabled')` 만으로 해석됩니다.
*
* @effects switch_off_behaves_identically
*/
public function test_스위치가_config_로만_해석된다(): void
{
$source = (string) file_get_contents(app_path('Support/EnvPriority.php'));
$stripped = $this->stripComments($source);
$this->assertStringNotContainsString(
'env(',
$stripped,
'EnvPriority 안에서 env() 를 읽으면 config:cache 환경에서 null 로 고정되어 판정이 영구 미발동합니다.'
);
config(['env-priority.enabled' => true]);
$this->assertTrue(EnvPriority::enabled());
config(['env-priority.enabled' => false]);
$this->assertFalse(EnvPriority::enabled());
}
/**
* `SettingsServiceProvider` 의 모든 주입 지점에 잠금 필터가 배선되어 있습니다.
*
* 한 곳이라도 빠지면 그 카테고리만 조용히 `.env` 를 계속 덮습니다.
*
* 모집단은 `getCategory('<카테고리>')` 호출부에서 **도출한다** — 메서드 이름을 손으로
* 열거하면 새 주입 메서드가 늘어도 케이스 없이 통과한다(오늘 우연히 일치할 뿐이다).
* EXEMPT 키만 읽어 필터가 no-op 인 지점은 그 메서드 안의 `audit:allow` 사유로 면제한다.
*
* @effects explicit_env_skips_injection
*/
public function test_provider_주입_지점에_잠금_필터가_배선된다(): void
{
$sites = $this->injectionSites();
// 하한 — 도출식이 죽으면 이 단언이 "위반 0" 으로 위장된다.
$this->assertNotEmpty(
$sites,
"getCategory('<카테고리>') 주입 지점을 하나도 도출하지 못했습니다 — 호출 형태가 바뀌었다면 이 도출식도 함께 갱신해야 합니다."
);
$missing = [];
foreach ($sites as $site) {
if (str_contains($site['body'], 'audit:allow env-priority-filter-wiring')) {
continue;
}
if (! str_contains($site['body'], "EnvPriority::filterLocked('{$site['category']}'")) {
$missing[] = "{$site['method']}() → {$site['category']}";
}
}
$this->assertSame(
[],
$missing,
'잠금 필터가 배선되지 않은 주입 지점: '.implode(', ', $missing)
);
}
/**
* `INJECTED_CATEGORIES` 가 소스에서 도출한 주입 카테고리 집합과 일치합니다.
*
* 이 상수는 맵 패리티 검사의 모집단이다. 손으로 적힌 채 뒤처지면 새 카테고리의 키가
* MAP/EXEMPT 등재 검사를 통째로 비껴가고, 그 상태는 "이상 0건" 과 구분되지 않는다.
*
* @effects map_parity_covers_every_injected_default_key
*/
public function test_주입_카테고리_상수가_소스_도출_결과와_일치한다(): void
{
$derived = array_values(array_unique(array_column($this->injectionSites(), 'category')));
sort($derived);
$declared = EnvPriority::INJECTED_CATEGORIES;
sort($declared);
$this->assertNotEmpty($derived, '주입 카테고리를 하나도 도출하지 못했습니다.');
$this->assertSame(
$derived,
$declared,
'EnvPriority::INJECTED_CATEGORIES 가 Provider 의 실제 주입 카테고리와 어긋납니다 — '
.'누락된 카테고리의 키는 MAP/EXEMPT 등재 검사를 받지 않습니다.'
);
}
/**
* `defaults.json` 이 민감으로 표시한 키는 MAP 에서도 민감으로 표시됩니다.
*
* 표시값 오버레이는 MAP 의 `sensitive` 플래그만 보고 건너뛴다. 그런데 민감 여부의
* 본래 출처는 `frontend_schema` 라, 두 곳이 갈라지면 그 키의 `.env` 비밀값이
* 관리자 응답에 조용히 실린다 — 오류도 로그도 남지 않는다.
*
* 반대 방향(MAP 만 민감)은 과하게 가리는 쪽이라 안전측이므로 검사하지 않는다.
*
* @effects locked_sensitive_hides_effective_value
*/
public function test_스키마가_민감으로_표시한_키는_맵에서도_민감이다(): void
{
$schema = json_decode(
(string) file_get_contents(config_path('settings/defaults.json')),
true
)['frontend_schema'] ?? [];
$schemaSensitive = [];
foreach ($schema as $category => $definition) {
foreach (($definition['fields'] ?? []) as $key => $field) {
if (($field['sensitive'] ?? false) === true) {
$schemaSensitive[] = $category.'.'.$key;
}
}
}
// 하한 — 스키마 형태가 바뀌어 0건이 되면 이 검사는 아무것도 재지 않는다.
$this->assertNotEmpty(
$schemaSensitive,
'frontend_schema 에서 민감 키를 하나도 도출하지 못했습니다 — 스키마 형태가 바뀌었다면 이 도출식도 함께 갱신해야 합니다.'
);
$unflagged = [];
foreach ($schemaSensitive as $storageKey) {
if (! array_key_exists($storageKey, EnvPriority::MAP)) {
continue; // 잠금 대상이 아니면 오버레이 자체가 없다
}
if (! EnvPriority::isSensitive($storageKey)) {
$unflagged[] = $storageKey;
}
}
$this->assertSame(
[],
$unflagged,
"defaults.json 이 민감으로 표시했으나 EnvPriority::MAP 에 'sensitive' => true 가 없는 키: "
.implode(', ', $unflagged).' — 이 키들은 잠기면 `.env` 값이 화면에 노출됩니다.'
);
}
/**
* testing 격리 가드가 런타임 `env('APP_ENV')` 로 되돌아가지 않습니다.
*
* config:cache 환경에서 env() 는 null 이라 가드가 통째로 무력해집니다.
*
* @effects switch_off_behaves_identically
*/
public function test_provider_가_런타임_env_로_환경을_판별하지_않는다(): void
{
$source = (string) file_get_contents(app_path('Providers/SettingsServiceProvider.php'));
$stripped = $this->stripComments($source);
$this->assertStringNotContainsString(
"env('APP_ENV')",
$stripped,
"환경 판별은 app()->environment() 로 합니다 — env('APP_ENV') 는 config:cache 에서 null 입니다."
);
$this->assertStringNotContainsString(
"env('G7_ENV_PRIORITY')",
$stripped,
'스위치는 config(env-priority.enabled) 로만 읽습니다.'
);
}
/**
* `.env.example` 은 스위치를 주석 상태로 안내하고, 테스트용 env 는 활성 라인을 두지 않습니다.
*
* @effects env_examples_document_env_priority_switch
*/
public function test_env_예시가_스위치를_주석으로_안내한다(): void
{
$example = (string) file_get_contents(base_path('.env.example'));
$this->assertStringContainsString('G7_ENV_PRIORITY', $example, '.env.example 에 스위치 안내가 필요합니다.');
$this->assertMatchesRegularExpression(
'/^\s*#\s*G7_ENV_PRIORITY=/m',
$example,
'.env.example 의 스위치는 주석 상태여야 합니다 (기본 OFF).'
);
$this->assertDoesNotMatchRegularExpression(
'/^\s*G7_ENV_PRIORITY=/m',
$example,
'.env.example 에 활성 스위치 라인을 두면 신규 설치가 즉시 잠깁니다.'
);
// `.env.testing.example` 은 저장소 추적 파일이라 반드시 존재한다 — 부재를 건너뛰면
// 그 축이 공허 통과한다. 안내 문구가 사라지면 "왜 여기엔 스위치가 없는가" 의 근거가
// 사라져, 다음 사람이 디버깅 편의로 활성 라인을 넣게 된다.
$testingExamplePath = base_path('.env.testing.example');
$this->assertFileExists($testingExamplePath, '.env.testing.example 은 저장소 추적 파일입니다.');
$this->assertStringContainsString(
'G7_ENV_PRIORITY',
(string) file_get_contents($testingExamplePath),
'.env.testing.example 에 스위치를 켜지 않는 사유 안내가 필요합니다.'
);
foreach (['.env.testing.example', '.env.testing'] as $file) {
$path = base_path($file);
if (! file_exists($path)) {
continue;
}
$this->assertDoesNotMatchRegularExpression(
'/^\s*G7_ENV_PRIORITY=/m',
(string) file_get_contents($path),
"{$file} 에 활성 스위치 라인을 두면 테스트가 개발 머신의 .env 상태에 좌우됩니다."
);
}
}
/**
* `defaults.json` 의 defaults 블록을 읽습니다.
*
* @return array<string, array<string, mixed>> 카테고리별 기본값
*/
private function loadDefaults(): array
{
$raw = (string) file_get_contents(config_path('settings/defaults.json'));
return json_decode($raw, true)['defaults'] ?? [];
}
/**
* `SettingsServiceProvider` 의 settings 카테고리 주입 지점을 소스에서 도출합니다.
*
* `getCategory('<카테고리>')` 호출부를 찾고 그것을 감싸는 메서드를 역으로 특정한다.
* 메서드 이름 목록을 손으로 들고 있으면 새 주입이 늘어도 검사가 그것을 보지 못하므로,
* 모집단은 언제나 소스에서 파생한다. audit 룰 `env-priority-filter-wiring` 과 같은 도출식이다.
*
* @return list<array{category: string, method: string, body: string}> 주입 지점 목록
*/
private function injectionSites(): array
{
$source = (string) file_get_contents(app_path('Providers/SettingsServiceProvider.php'));
preg_match_all("/getCategory\(\s*'([a-z_]+)'\s*\)/", $source, $matches, PREG_OFFSET_CAPTURE);
$sites = [];
$seen = [];
foreach ($matches[1] as $index => $capture) {
$category = $capture[0];
$offset = $matches[0][$index][1];
if (! preg_match_all('/function\s+([A-Za-z0-9_]+)\s*\(/', substr($source, 0, $offset), $heads, PREG_OFFSET_CAPTURE)) {
continue;
}
$last = array_key_last($heads[1]);
$method = $heads[1][$last][0];
$body = $this->extractMethodBody($source, $method);
$key = $method.':'.$category;
if ($body === '' || isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$sites[] = ['category' => $category, 'method' => $method, 'body' => $body];
}
return $sites;
}
/**
* PHP 소스에서 주석을 제거합니다 ("하면 안 되는 것" 설명문 오탐 차단).
*
* @param string $source 원본 소스
* @return string 주석이 제거된 소스
*/
private function stripComments(string $source): string
{
return (string) preg_replace(['#/\*.*?\*/#s', '#//[^\n]*#'], '', $source);
}
/**
* 소스에서 지정 메서드의 본문을 잘라냅니다 (중괄호 균형 기준).
*
* @param string $source 원본 소스
* @param string $method 메서드명
* @return string 메서드 본문 (찾지 못하면 빈 문자열)
*/
private function extractMethodBody(string $source, string $method): string
{
$signature = 'function '.$method.'(';
$start = strpos($source, $signature);
if ($start === false) {
return '';
}
$open = strpos($source, '{', $start);
if ($open === false) {
return '';
}
$depth = 0;
$length = strlen($source);
for ($i = $open; $i < $length; $i++) {
if ($source[$i] === '{') {
$depth++;
} elseif ($source[$i] === '}') {
$depth--;
if ($depth === 0) {
return substr($source, $open, $i - $open + 1);
}
}
}
return '';
}
}
@@ -0,0 +1,394 @@
<?php
namespace Tests\Feature\Settings;
use App\Contracts\Repositories\ConfigRepositoryInterface;
use App\Enums\ExtensionOwnerType;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use App\Services\SettingsService;
use App\Support\EnvPriority;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
/**
* `.env` 우선 모드의 설정 API 표면 (응답 메타 · 표시값 · 저장 게이트) 계약.
*
* 화면의 `disabled` 는 게이트가 아니다 — 저장 API 를 직접 호출하면 그대로 통과한다.
* 그래서 잠금은 응답 메타(표시)와 저장 필터(차단) 두 축으로 나뉘고, 둘 다 여기서 고정한다.
*
* @effects meta_env_locked_uses_frontend_keys, locked_plain_shows_effective_value, locked_sensitive_hides_effective_value, locked_key_filtered_on_save_including_advanced, switch_off_behaves_identically
*/
class SettingsEnvLockedMetaTest extends TestCase
{
use RefreshDatabase;
/**
* 스위치를 켜고 지정한 env 변수만 명시 상태로 만듭니다.
*
* `.env.testing` 을 건드리지 않고 config 를 직접 주입합니다 — 테스트가 개발 머신의
* `.env` 상태에 좌우되면 재현이 불가능해집니다.
*
* @param array<int, string> $explicitVars 명시 상태로 만들 env 변수 목록
*/
private function enableWith(array $explicitVars): void
{
config([
'env-priority.enabled' => true,
'env-priority.explicit' => array_fill_keys($explicitVars, true),
]);
}
/**
* 관리자 사용자와 API 토큰을 만듭니다.
*
* @return array{0: User, 1: string} 사용자와 토큰
*/
private function createAdminWithToken(): array
{
$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;
}
$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,
]
);
$scopedRole = Role::create([
'identifier' => 'admin_env_priority_'.uniqid(),
'name' => json_encode(['ko' => '테스트 관리자', 'en' => 'Test Administrator']),
'description' => json_encode(['ko' => '테스트 관리자', 'en' => 'Test Administrator']),
'is_active' => true,
]);
$scopedRole->permissions()->sync($permissionIds);
foreach ([$adminRole->id, $scopedRole->id] as $roleId) {
$user->roles()->attach($roleId, ['assigned_at' => now(), 'assigned_by' => null]);
}
$user = $user->fresh();
return [$user, $user->createToken('env-priority-test')->plainTextToken];
}
// ------------------------------------------------------------ 응답 메타
/**
* 잠금 목록은 화면이 참조하는 프론트엔드 키 형태로 내려갑니다.
*
* 저장소 키(`debug.mode`)를 그대로 내보내면 고급 탭의 잠금 표시가 조용히 미발동합니다.
*
* @effects meta_env_locked_uses_frontend_keys
*
* @scenario switch=on, key_state=locked_plain, surface=get_meta
*/
public function test_env_locked_는_프론트엔드_키로_내려간다(): void
{
$this->enableWith(['APP_DEBUG', 'APP_NAME', 'GEOIP_LICENSE_KEY']);
$meta = app(SettingsService::class)->envLockedMeta();
$this->assertArrayHasKey('advanced.debug_mode', $meta, 'merge_into 변환(debug → advanced)이 반영되어야 합니다.');
$this->assertArrayHasKey('advanced.geoip_license_key', $meta, 'frontend_key 변환이 반영되어야 합니다.');
$this->assertArrayHasKey('general.site_name', $meta, '변환이 없는 키는 그대로 실립니다.');
// getAllSettings() 가 값을 두 위치에 싣는 것과 같은 규칙 — 원본 카테고리도 함께 담긴다.
$this->assertArrayHasKey('debug.debug_mode', $meta);
}
/**
* 스위치가 꺼져 있으면 잠금 목록이 비어 있습니다.
*
* @effects switch_off_behaves_identically
*
* @scenario switch=off, key_state=unlocked, surface=get_meta
*/
public function test_스위치가_꺼지면_잠금_목록이_비어있다(): void
{
config([
'env-priority.enabled' => false,
'env-priority.explicit' => ['APP_NAME' => true, 'APP_DEBUG' => true],
]);
$this->assertSame([], app(SettingsService::class)->envLockedMeta());
}
/**
* 잠긴 일반 키의 표시값은 저장값이 아니라 유효값(적용 중인 값)입니다.
*
* @effects locked_plain_shows_effective_value
*/
/** @scenario switch=on, key_state=locked_plain, surface=get_effective_value */
public function test_잠긴_일반_키는_유효값을_보여준다(): void
{
app(ConfigRepositoryInterface::class)->saveCategory('general', [
'site_name' => '저장된이름',
'site_description' => '설명',
]);
app(ConfigRepositoryInterface::class)->saveCategory('upload', [
'max_file_size' => 10,
]);
config(['app.name' => 'env유래이름', 'attachment.max_file_size' => 51200]);
$this->enableWith(['APP_NAME', 'ATTACHMENT_MAX_FILE_SIZE']);
$settings = app(SettingsService::class)->getAllSettings();
$this->assertSame('env유래이름', $settings['general']['site_name']);
$this->assertSame(50, $settings['upload']['max_file_size'], 'KB(config) → MB(화면) 환산이 적용되어야 합니다.');
$this->assertSame('설명', $settings['general']['site_description'], '미잠금 키는 저장값 그대로입니다.');
}
/**
* 잠긴 민감 키의 `.env` 값은 응답에 실리지 않습니다.
*
* 잠금 표시만 하고 값은 저장값 그대로 둡니다 — `.env` 비밀값의 admin UI 유출 차단.
*
* @effects locked_sensitive_hides_effective_value
*
* @scenario switch=on, key_state=locked_sensitive, surface=get_effective_value
*/
public function test_잠긴_민감_키는_유효값을_노출하지_않는다(): void
{
app(ConfigRepositoryInterface::class)->saveCategory('mail', [
'password' => '저장된비밀',
]);
config(['mail.mailers.smtp.password' => 'ENV_비밀값']);
$this->enableWith(['MAIL_PASSWORD']);
$settings = app(SettingsService::class)->getAllSettings();
$this->assertSame('저장된비밀', $settings['mail']['password']);
$this->assertNotSame('ENV_비밀값', $settings['mail']['password']);
$this->assertArrayHasKey('mail.password', app(SettingsService::class)->envLockedMeta(), '민감 키도 잠금 표시는 됩니다.');
}
// ------------------------------------------------------------ 저장 게이트
/**
* 잠긴 키를 포함한 저장 요청은 그 키만 반영되지 않습니다 (저장 자체는 성공).
*
* @effects locked_key_filtered_on_save_including_advanced
*
* @scenario switch=on, key_state=locked_plain, surface=post_filter
*/
public function test_잠긴_키는_저장에서_필터된다(): void
{
$repository = app(ConfigRepositoryInterface::class);
$repository->saveCategory('general', [
'site_name' => '원래이름',
'site_description' => '원래설명',
]);
$this->enableWith(['APP_NAME']);
$saved = app(SettingsService::class)->saveSettings([
'_tab' => 'general',
'general' => [
'site_name' => '공격자가_보낸_이름',
'site_description' => '바뀐설명',
],
]);
$this->assertTrue($saved);
$general = $repository->getCategory('general');
$this->assertSame('원래이름', $general['site_name'], '잠긴 키는 저장 상태가 불변이어야 합니다.');
$this->assertSame('바뀐설명', $general['site_description'], '미잠금 키는 종전대로 저장됩니다.');
}
/**
* 고급 탭(카테고리 분리 경로)에서도 잠긴 키가 필터됩니다.
*
* 고급 탭은 여러 카테고리가 한 폼에 섞여 오므로 분리 후에 필터하지 않으면 이 경로만
* 조용히 뚫립니다.
*
* @effects locked_key_filtered_on_save_including_advanced
*
* @scenario switch=on, key_state=locked_falsy, surface=post_filter
*/
public function test_고급탭_저장에서도_잠긴_키가_필터된다(): void
{
$repository = app(ConfigRepositoryInterface::class);
$repository->saveCategory('debug', ['mode' => false, 'sql_query_log' => false]);
$repository->saveCategory('core_update', ['github_url' => 'https://github.com/original/repo']);
$this->enableWith(['APP_DEBUG', 'G7_UPDATE_GITHUB_URL']);
$saved = app(SettingsService::class)->saveSettings([
'_tab' => 'advanced',
'advanced' => [
'debug_mode' => true,
'sql_query_log' => true,
'core_update_github_url' => 'https://github.com/attacker/repo',
],
]);
$this->assertTrue($saved);
$this->assertFalse((bool) $repository->getCategory('debug')['mode'], '잠긴 debug.mode 는 불변이어야 합니다.');
$this->assertTrue((bool) $repository->getCategory('debug')['sql_query_log'], '미잠금 키는 저장됩니다.');
$this->assertSame(
'https://github.com/original/repo',
$repository->getCategory('core_update')['github_url'],
'잠긴 core_update.github_url 은 불변이어야 합니다.'
);
}
/**
* 스위치가 꺼져 있으면 저장이 종전과 동일합니다.
*
* @effects switch_off_behaves_identically
*
* @scenario switch=off, key_state=unlocked, surface=post_filter
*/
public function test_스위치가_꺼지면_저장이_종전과_같다(): void
{
$repository = app(ConfigRepositoryInterface::class);
$repository->saveCategory('general', ['site_name' => '원래이름']);
config([
'env-priority.enabled' => false,
'env-priority.explicit' => ['APP_NAME' => true],
]);
app(SettingsService::class)->saveSettings([
'_tab' => 'general',
'general' => ['site_name' => '새이름'],
]);
$this->assertSame('새이름', $repository->getCategory('general')['site_name']);
}
// ------------------------------------------------------------ HTTP 왕복
/**
* 조회·저장 두 응답이 같은 `_meta` 모양을 갖습니다.
*
* 한쪽만 필드가 늘면 저장 직후 화면의 잠금 표시가 조용히 사라집니다.
*
* @effects meta_env_locked_uses_frontend_keys
*
* @scenario switch=on, key_state=unlocked, surface=post_filter
*/
public function test_조회와_저장_응답이_같은_meta_를_동봉한다(): void
{
[, $token] = $this->createAdminWithToken();
$this->enableWith(['APP_NAME']);
$index = $this->withToken($token)->getJson('/api/admin/settings');
$index->assertOk();
$indexMeta = $index->json('data._meta');
$this->assertIsArray($indexMeta);
$this->assertArrayHasKey('limits', $indexMeta);
$this->assertTrue($indexMeta['env_priority_enabled']);
$this->assertArrayHasKey('general.site_name', $indexMeta['env_locked']);
// general 탭은 필수 필드를 함께 보내야 한다 (화면 폼도 탭 전체를 제출한다).
$store = $this->withToken($token)->postJson('/api/admin/settings', [
'_tab' => 'general',
'general' => [
'site_name' => '테스트사이트',
'site_url' => 'https://example.test',
'admin_email' => 'admin@example.test',
'timezone' => 'Asia/Seoul',
'language' => 'ko',
'site_description' => '설명변경',
],
]);
$store->assertOk();
$storeMeta = $store->json('data.settings._meta');
$this->assertIsArray($storeMeta);
$this->assertSame(array_keys($indexMeta), array_keys($storeMeta), '조회·저장 응답의 _meta 키가 같아야 합니다.');
$this->assertArrayHasKey('general.site_name', $storeMeta['env_locked']);
}
/**
* 스위치가 꺼진 응답은 종전 형태를 유지합니다 (잠금 목록만 비어 있음).
*
* @effects switch_off_behaves_identically
*/
/** @scenario switch=off, key_state=unlocked, surface=get_effective_value */
public function test_스위치가_꺼진_응답은_잠금이_비어있다(): void
{
[, $token] = $this->createAdminWithToken();
config([
'env-priority.enabled' => false,
'env-priority.explicit' => ['APP_NAME' => true],
]);
$response = $this->withToken($token)->getJson('/api/admin/settings');
$response->assertOk();
$this->assertFalse($response->json('data._meta.env_priority_enabled'));
$this->assertSame([], $response->json('data._meta.env_locked'));
}
/**
* 매핑에 sensitive 로 표시된 키는 표시값 오버레이 대상에서 제외됩니다.
*
* 오버레이 목록과 sensitive 목록이 갈라지면 그 키만 조용히 값이 새어 나갑니다.
*
* @effects locked_sensitive_hides_effective_value
*/
/** @scenario switch=on, key_state=locked_sensitive, surface=get_effective_value */
public function test_민감_키는_오버레이_대상에서_전부_제외된다(): void
{
$sensitiveKeys = array_keys(array_filter(
EnvPriority::MAP,
fn (array $entry) => ($entry['sensitive'] ?? false) === true
));
$this->assertNotEmpty($sensitiveKeys);
$this->enableWith(EnvPriority::envVars());
$overlaySource = (string) file_get_contents(app_path('Services/SettingsService.php'));
$this->assertStringContainsString(
'EnvPriority::isSensitive($storageKey)',
$overlaySource,
'표시값 오버레이는 sensitive 키를 건너뛰어야 합니다.'
);
// 모든 키를 잠근 상태에서도 민감 키의 저장값이 유지되는지 실측한다.
app(ConfigRepositoryInterface::class)->saveCategory('core_update', [
'github_token' => '저장된토큰',
]);
config(['app.update.github_token' => 'ENV_토큰']);
$settings = app(SettingsService::class)->getAllSettings();
$this->assertSame('저장된토큰', $settings['advanced']['core_update_github_token']);
}
}
@@ -0,0 +1,141 @@
/**
* E2E: 환경설정 — `.env` 키 단위 우선 (G7_ENV_PRIORITY) (#641)
*
* 시나리오 매니페스트: tests/scenarios/settings-env-priority.yaml — 마킹은 각 테스트의
* scenario(k=v 조합)·effects 주석이 담당하며, 헤더 요약 마킹은 파서 형식이 아니다
*
* 잠금 축(스위치 ON)은 서버의 `.env` 를 바꾸고 설정 캐시를 다시 구워야 재현되므로 CI 의
* 일반 실행에서는 다루지 않는다. 그 축은 레이아웃 테스트
* (admin-settings-env-locked.test.tsx)와 Chrome MCP 실측 매트릭스가 커버한다.
* 잠금 축을 켜 둔 서버에서 돌리려면 `G7_E2E_ENV_PRIORITY=1` 을 주면 된다.
*
* 여기서 상시 지키는 것은 **회귀 가드**다 — 스위치를 켜지 않은 설치에서 화면이 종전과
* 똑같이 동작하는지(배지 0 · 배너 0 · 저장 왕복 유지).
*/
import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth';
const ENV_PRIORITY_ON = process.env.G7_E2E_ENV_PRIORITY === '1';
/** 관리자 환경설정 일반 탭 진입 (설정 시드 완료까지 대기) */
async function gotoGeneralTab(page: import('@playwright/test').Page): Promise<void> {
await page.goto('/admin/settings?tab=general');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await expect(page.locator('input[name="general.site_name"]').first()).toBeAttached({ timeout: 20_000 });
await expect
.poll(
() =>
page.evaluate(
() => Object.keys((window as any).G7Core?.state?.getLocal?.()?.form?.general ?? {}).length
),
{ timeout: 20_000 }
)
.toBeGreaterThan(0);
}
/** 설정 응답의 `_meta` 를 읽는다 */
async function readSettingsMeta(page: import('@playwright/test').Page): Promise<any> {
return page.evaluate(() => (window as any).G7Core?.state?.getLocal?.()?.form?._meta ?? null);
}
// @scenario switch=off, key_state=unlocked, surface=get_meta
// @effects switch_off_behaves_identically
test('@smoke #641 - 스위치가 꺼진 설치는 응답 메타에 잠금이 비어 있다', async ({ page }) => {
test.skip(ENV_PRIORITY_ON, '이 블록은 스위치가 꺼진 서버를 전제로 한다');
const token = issueToken('core.settings.read', 'core.settings.update');
await authenticatePage(page, token);
await gotoGeneralTab(page);
const meta = await readSettingsMeta(page);
expect(meta).not.toBeNull();
expect(meta.env_priority_enabled).toBe(false);
expect(Object.keys(meta.env_locked ?? {})).toHaveLength(0);
});
// @scenario switch=off, key_state=unlocked, surface=ui_lock
// @effects switch_off_behaves_identically
test('#641 - 스위치가 꺼진 설치는 배너도 잠금 배지도 렌더하지 않는다', async ({ page }) => {
test.skip(ENV_PRIORITY_ON, '이 블록은 스위치가 꺼진 서버를 전제로 한다');
const token = issueToken('core.settings.read', 'core.settings.update');
await authenticatePage(page, token);
await gotoGeneralTab(page);
await expect(page.locator('#env_priority_banner')).toHaveCount(0);
await expect(page.locator('input[name="general.site_name"]').first()).toBeEnabled();
// site_description 은 Textarea 로 렌더된다 — input 셀렉터로는 잡히지 않는다.
await expect(page.locator('textarea[name="general.site_description"]').first()).toBeEnabled();
});
// @scenario switch=off, key_state=unlocked, surface=post_filter
// @effects switch_off_behaves_identically
test('#641 - 스위치가 꺼진 설치의 저장 왕복이 종전과 같다', async ({ page }) => {
test.skip(ENV_PRIORITY_ON, '이 블록은 스위치가 꺼진 서버를 전제로 한다');
const token = issueToken('core.settings.read', 'core.settings.update');
await authenticatePage(page, token);
await gotoGeneralTab(page);
const description = page.locator('textarea[name="general.site_description"]').first();
await expect(description).toBeAttached();
const original = (await description.inputValue()) ?? '';
const probe = `env-priority-e2e-${Date.now()}`;
const save = async (value: string) => {
await description.fill(value);
const saved = page.waitForResponse(
(response) =>
response.request().method() === 'POST' &&
/\/api\/admin\/settings\/?$/.test(new URL(response.url()).pathname),
{ timeout: 20_000 }
);
await page.getByRole('button', { name: /저장|Save/ }).first().click();
return saved;
};
const response = await save(probe);
expect(response.status()).toBe(200);
const body = await response.json();
expect(body?.data?.settings?._meta?.env_priority_enabled).toBe(false);
expect(body?.data?.settings?.general?.site_description).toBe(probe);
// 원복 — 검수 후 상태를 남기지 않는다
const restore = await save(original);
expect(restore.status()).toBe(200);
});
// @scenario switch=on, key_state=locked_plain, surface=ui_lock
// @effects badge_and_disabled_rendered, meta_env_locked_uses_frontend_keys
test('#641 - 스위치가 켜진 서버는 잠긴 필드를 배지와 함께 잠근다', async ({ page }) => {
test.skip(!ENV_PRIORITY_ON, '서버 `.env` 조작이 필요한 축 — G7_E2E_ENV_PRIORITY=1 일 때만 실행');
const token = issueToken('core.settings.read', 'core.settings.update');
await authenticatePage(page, token);
await gotoGeneralTab(page);
const meta = await readSettingsMeta(page);
expect(meta.env_priority_enabled).toBe(true);
const lockedKeys = Object.keys(meta.env_locked ?? {});
expect(lockedKeys.length).toBeGreaterThan(0);
await expect(page.locator('#env_priority_banner')).toBeVisible();
for (const key of lockedKeys.filter((k) => k.startsWith('general.'))) {
const field = page.locator(`[name="${key}"]`).first();
if ((await field.count()) === 0) {
continue;
}
await expect(field).toBeDisabled();
}
});
@@ -0,0 +1,558 @@
<?php
namespace Tests\Unit\Providers;
use App\Providers\SettingsServiceProvider;
use App\Repositories\JsonConfigRepository;
use Illuminate\Support\Facades\Config;
use PHPUnit\Framework\Attributes\DataProvider;
use ReflectionMethod;
use Tests\TestCase;
/**
* `.env` 우선 모드에서의 settings → config 주입 스킵 검증.
*
* 잠긴 키는 Config::set 이 아예 발생하지 않아야 한다 — 그것이 `.env` 값이 살아남는
* 유일한 조건이다. 스위치가 꺼진 축은 종전 주입이 그대로 일어나는지도 함께 고정한다.
*
* @effects switch_off_behaves_identically, explicit_env_skips_injection, false_env_is_explicit, websocket_enabled_lock_skips_off_branch, debug_mode_lock_corrects_outbound_gate
*/
class SettingsServiceProviderEnvPriorityTest extends TestCase
{
/**
* 스위치를 켜고 지정한 env 변수만 명시 상태로 만듭니다.
*
* @param array<int, string> $explicitVars 명시 상태로 만들 env 변수 목록
*/
private function enableWith(array $explicitVars): void
{
Config::set('env-priority.enabled', true);
Config::set('env-priority.explicit', array_fill_keys($explicitVars, true));
}
/**
* 스위치를 끕니다 (현행 동작).
*/
private function disableSwitch(): void
{
Config::set('env-priority.enabled', false);
Config::set('env-priority.explicit', []);
}
/**
* 앱 환경을 production 으로 바꿉니다.
*
* `applyDebugConfig` 는 testing 환경에서 통째로 조기 return 하므로(테스트 격리 가드),
* 그 본문의 분기를 검증하려면 환경을 바꿔야 합니다. 컨테이너의 `env` 바인딩을 교체하면
* `app()->environment()` 의 해석이 바뀝니다. 앱 인스턴스는 테스트마다 새로 만들어지므로
* 이 변경은 그 테스트 안에만 머뭅니다.
*/
private function forceNonTestingEnvironment(): void
{
$this->app->instance('env', 'production');
}
/**
* Provider 의 private apply* 메서드를 리플렉션으로 호출합니다.
*
* @param string $method 메서드명 (예: applyMailConfig)
* @param string $category 카테고리명
* @param array<string, mixed> $settings 그 카테고리의 설정 데이터
*/
private function callApply(string $method, string $category, array $settings): void
{
$configRepository = $this->createMock(JsonConfigRepository::class);
$configRepository->method('getCategory')
->willReturnCallback(fn (string $requested) => $requested === $category ? $settings : []);
$provider = new SettingsServiceProvider($this->app);
(new ReflectionMethod($provider, $method))->invoke($provider, $configRepository);
}
// ---------------------------------------------------------------- general
/**
* 잠긴 general 키는 주입되지 않고 `.env` 유래 config 값이 살아남습니다.
*
* @scenario switch=on, key_state=unlocked, surface=inject
*/
public function test_locked_general_key_is_not_injected(): void
{
$this->enableWith(['APP_NAME']);
Config::set('app.name', 'env유래이름');
Config::set('app.url', 'https://env.example.com');
$this->callApply('applyAppConfig', 'general', [
'site_name' => '저장값이름',
'site_url' => 'https://saved.example.com',
]);
$this->assertSame('env유래이름', config('app.name'), '잠긴 키는 주입 스킵');
$this->assertSame('https://saved.example.com', config('app.url'), '미잠금 키는 종전대로 주입');
}
/**
* 스위치가 꺼져 있으면 general 주입이 종전과 동일합니다.
*
* @scenario switch=off, key_state=unlocked, surface=inject
*/
public function test_switch_off_injects_general_as_before(): void
{
$this->disableSwitch();
Config::set('app.name', 'env유래이름');
$this->callApply('applyAppConfig', 'general', ['site_name' => '저장값이름']);
$this->assertSame('저장값이름', config('app.name'));
}
/**
* timezone 이 잠기면 그 키가 파생시키는 config 2곳 모두 주입되지 않습니다.
*/
public function test_locked_timezone_skips_both_derived_config_keys(): void
{
$this->enableWith(['APP_DEFAULT_USER_TIMEZONE']);
Config::set('app.default_user_timezone', 'UTC');
Config::set('app.schedule_timezone', 'UTC');
$this->callApply('applyAppConfig', 'general', ['timezone' => 'Asia/Seoul']);
$this->assertSame('UTC', config('app.default_user_timezone'));
$this->assertSame('UTC', config('app.schedule_timezone'));
}
// ------------------------------------------------------------------- mail
/**
* 잠긴 mail 키만 주입에서 빠집니다.
*/
public function test_locked_mail_keys_are_not_injected(): void
{
$this->enableWith(['MAIL_PORT', 'MAIL_PASSWORD']);
Config::set('mail.mailers.smtp.port', 2599);
Config::set('mail.mailers.smtp.password', 'env비밀');
Config::set('mail.mailers.smtp.host', 'env.host');
$this->callApply('applyMailConfig', 'mail', [
'mailer' => 'smtp',
'host' => 'saved.host',
'port' => 587,
'password' => '저장비밀',
]);
$this->assertSame(2599, config('mail.mailers.smtp.port'));
$this->assertSame('env비밀', config('mail.mailers.smtp.password'));
$this->assertSame('saved.host', config('mail.mailers.smtp.host'));
}
/**
* 잠긴 mailgun_endpoint 는 기본값 강제 주입도 하지 않습니다 (F3).
*
* 이 지점은 저장값이 비어도 기본값('api.mailgun.net')을 박는 무조건 주입이었다.
* 잠금으로 키가 제거되면 그 강제까지 멈춰야 `.env` 값이 살아남는다.
*/
public function test_locked_mailgun_endpoint_skips_unconditional_default(): void
{
$this->enableWith(['MAILGUN_ENDPOINT']);
Config::set('services.mailgun.endpoint', 'api.eu.mailgun.net');
$this->callApply('applyMailConfig', 'mail', [
'mailer' => 'mailgun',
'mailgun_domain' => 'mg.example.com',
'mailgun_endpoint' => '',
]);
$this->assertSame('api.eu.mailgun.net', config('services.mailgun.endpoint'));
}
/**
* 스위치가 꺼져 있으면 mailgun_endpoint 의 기본값 강제가 종전대로 유지됩니다.
*/
public function test_switch_off_keeps_mailgun_endpoint_default_forcing(): void
{
$this->disableSwitch();
Config::set('services.mailgun.endpoint', 'api.eu.mailgun.net');
$this->callApply('applyMailConfig', 'mail', [
'mailer' => 'mailgun',
'mailgun_endpoint' => '',
]);
$this->assertSame('api.mailgun.net', config('services.mailgun.endpoint'));
}
/**
* 잠긴 ses_region 도 기본값 강제 주입을 하지 않습니다 (F3).
*/
public function test_locked_ses_region_skips_unconditional_default(): void
{
$this->enableWith(['AWS_DEFAULT_REGION']);
Config::set('services.ses.region', 'us-west-2');
$this->callApply('applyMailConfig', 'mail', [
'mailer' => 'ses',
'ses_region' => '',
]);
$this->assertSame('us-west-2', config('services.ses.region'));
}
// ------------------------------------------------------------------ debug
/**
* debug.mode 잠금 4케이스: app.debug 주입 스킵 + 로그 레벨 2차 효과 보정.
*
* @param bool $modeLocked APP_DEBUG 명시 여부
* @param bool $levelLocked LOG_LEVEL 명시 여부
* @param bool $envDebug `.env` 의 APP_DEBUG 유효값
* @param string $expectedLogLevel 기대 logging.level
*/
#[DataProvider('debugLockCases')]
public function test_debug_lock_matrix(bool $modeLocked, bool $levelLocked, bool $envDebug, string $expectedLogLevel): void
{
$explicit = [];
if ($modeLocked) {
$explicit[] = 'APP_DEBUG';
}
if ($levelLocked) {
$explicit[] = 'LOG_LEVEL';
}
$this->enableWith($explicit);
$this->forceNonTestingEnvironment();
Config::set('app.debug', $envDebug);
Config::set('logging.level', 'env레벨');
$this->callApply('applyDebugConfig', 'debug', [
'mode' => false,
'log_level' => 'warning',
]);
$this->assertSame(
$modeLocked ? $envDebug : false,
config('app.debug'),
'app.debug 는 잠기면 `.env` 유효값이 유지되어야 함'
);
$this->assertSame($expectedLogLevel, config('logging.level'));
}
/**
* debug 잠금 매트릭스 케이스.
*
* @return array<string, array{bool, bool, bool, string}>
*/
public static function debugLockCases(): array
{
return [
// mode 미잠금 · level 미잠금 → 저장값(mode=false) → 저장 log_level 적용
'both unlocked' => [false, false, true, 'warning'],
// mode 잠금(APP_DEBUG=true) · level 미잠금 → 디버그 모드 강제 규칙이 살아나 debug
'mode locked, debug on' => [true, false, true, 'debug'],
// mode 잠금(APP_DEBUG=false) · level 미잠금 → 저장 log_level 적용
'mode locked, debug off' => [true, false, false, 'warning'],
// level 잠금 → logging.* 주입 자체를 하지 않음 (`.env` LOG_LEVEL 권위)
'level locked' => [false, true, true, 'env레벨'],
'both locked' => [true, true, true, 'env레벨'],
];
}
/**
* debug.mode 가 잠기면 프록시 게이트도 `.env` 유효값으로 판정됩니다.
*
* 보정이 없으면 잠금으로 mode 키가 사라져 게이트가 항상 닫히고, 운영자가 저장한
* 프록시가 오류·로그 없이 영구 미적용된다.
*/
public function test_locked_debug_mode_corrects_outbound_proxy_gate(): void
{
$this->enableWith(['APP_DEBUG']);
$this->forceNonTestingEnvironment();
Config::set('app.debug', true);
$this->callApply('applyDebugConfig', 'debug', [
'mode' => false,
'outbound_proxy' => 'http://proxy.example.com:3128',
]);
$proxy = config('g7.outbound_proxy');
$this->assertIsArray($proxy, '디버그 모드가 `.env` 로 켜져 있으면 프록시가 적용되어야 함');
$this->assertSame('http://proxy.example.com:3128', $proxy['http']);
}
/**
* `.env` 의 APP_DEBUG 가 꺼져 있으면 잠금 상태에서도 프록시는 적용되지 않습니다.
*/
public function test_locked_debug_mode_off_keeps_proxy_gate_closed(): void
{
$this->enableWith(['APP_DEBUG']);
$this->forceNonTestingEnvironment();
Config::set('app.debug', false);
$this->callApply('applyDebugConfig', 'debug', [
'mode' => true,
'outbound_proxy' => 'http://proxy.example.com:3128',
]);
$this->assertNull(config('g7.outbound_proxy'));
}
// ---------------------------------------------------------------- drivers
/**
* redis 관련 키가 잠기면 default·cache 두 커넥션 모두 주입되지 않습니다.
*/
public function test_locked_redis_keys_skip_both_connections(): void
{
$this->enableWith(['REDIS_HOST', 'REDIS_DB']);
Config::set('database.redis.default.host', 'env-redis');
Config::set('database.redis.cache.host', 'env-redis');
Config::set('database.redis.default.database', 7);
Config::set('database.redis.cache.database', 8);
Config::set('database.redis.default.port', 6379);
$this->callApply('applyDriverConfig', 'drivers', [
'redis_host' => 'saved-redis',
'redis_port' => 6400,
'redis_database' => 3,
]);
$this->assertSame('env-redis', config('database.redis.default.host'));
$this->assertSame('env-redis', config('database.redis.cache.host'));
$this->assertSame(7, config('database.redis.default.database'));
$this->assertSame(8, config('database.redis.cache.database'));
$this->assertSame(6400, config('database.redis.default.port'), '미잠금 키는 종전대로 주입');
}
/**
* `.env` 로 잠긴 웹소켓 마스터 토글은 OFF 강제 3종을 오발동시키지 않습니다.
*
* 잠금으로 키가 제거되면 `empty()` 가 참이 되어 OFF 강제 분기로 오진입한다.
* 그러면 `.env` 로 웹소켓을 켠 운영자에게 강제 OFF 가 걸린다.
*/
public function test_locked_websocket_toggle_does_not_force_off(): void
{
$this->enableWith(['BROADCAST_CONNECTION']);
Config::set('broadcasting.default', 'reverb');
Config::set('broadcasting.connections.reverb.key', 'env-key');
Config::set('g7.websocket.client.host', 'ws.example.com');
$this->callApply('applyDriverConfig', 'drivers', [
'websocket_enabled' => false,
'websocket_app_key' => 'saved-key',
]);
$this->assertSame('reverb', config('broadcasting.default'));
$this->assertSame('env-key', config('broadcasting.connections.reverb.key'));
$this->assertSame('ws.example.com', config('g7.websocket.client.host'));
}
/**
* 스위치가 꺼져 있으면 웹소켓 OFF 강제 3종이 종전대로 동작합니다.
*/
public function test_switch_off_keeps_websocket_off_forcing(): void
{
$this->disableSwitch();
Config::set('broadcasting.default', 'reverb');
Config::set('broadcasting.connections.reverb.key', 'env-key');
Config::set('g7.websocket.client.host', 'ws.example.com');
$this->callApply('applyDriverConfig', 'drivers', [
'websocket_enabled' => false,
]);
$this->assertSame('null', config('broadcasting.default'));
$this->assertSame('', config('broadcasting.connections.reverb.key'));
$this->assertSame('', config('g7.websocket.client.host'));
}
/**
* 잠긴 log_driver 는 stack 채널 목록을 덮지 않습니다.
*/
public function test_locked_log_driver_skips_stack_channels(): void
{
$this->enableWith(['LOG_STACK']);
Config::set('logging.channels.stack.channels', ['stderr']);
$this->callApply('applyDriverConfig', 'drivers', [
'log_driver' => 'daily',
'log_days' => 30,
]);
$this->assertSame(['stderr'], config('logging.channels.stack.channels'));
$this->assertSame(30, config('logging.channels.daily.days'), '미잠금 키는 종전대로 주입');
}
// ------------------------------------------------------- upload / geoip 등
/**
* 잠긴 upload 키는 KB 환산 주입도 하지 않습니다.
*/
public function test_locked_upload_key_is_not_injected(): void
{
$this->enableWith(['ATTACHMENT_MAX_FILE_SIZE']);
Config::set('attachment.max_file_size', 51200);
Config::set('attachment.image_max_width', 1000);
$this->callApply('applyUploadConfig', 'upload', [
'max_file_size' => 10,
'image_max_width' => 2000,
]);
$this->assertSame(51200, config('attachment.max_file_size'));
$this->assertSame(2000, config('attachment.image_max_width'), '미잠금 키는 종전대로 주입');
}
/**
* 잠긴 core_update 키는 주입되지 않습니다.
*/
public function test_locked_core_update_key_is_not_injected(): void
{
$this->enableWith(['G7_UPDATE_GITHUB_URL']);
Config::set('app.update.github_url', 'https://github.com/env/repo');
$this->callApply('applyCoreUpdateConfig', 'core_update', [
'github_url' => 'https://github.com/saved/repo',
'github_token' => 'saved-token',
]);
$this->assertSame('https://github.com/env/repo', config('app.update.github_url'));
$this->assertSame('saved-token', config('app.update.github_token'));
}
/**
* 잠긴 geoip 불리언 키는 주입되지 않습니다 (falsy 명시 축).
*/
public function test_locked_geoip_boolean_key_is_not_injected(): void
{
$this->enableWith(['GEOIP_ENABLED']);
Config::set('geoip.enabled', true);
Config::set('geoip.auto_update_enabled', true);
$this->callApply('applyGeoIpConfig', 'geoip', [
'feature_enabled' => false,
'auto_update_enabled' => false,
]);
$this->assertTrue(config('geoip.enabled'), '잠긴 키는 `.env` 유효값 유지');
$this->assertFalse(config('geoip.auto_update_enabled'), '미잠금 키는 종전대로 주입');
}
// ------------------------------------------- 잠금이 게이트·폴백을 무너뜨리는 축
/**
* 마스터 메일 드라이버가 잠겨도 하위 저장값(mailgun)은 종전대로 주입됩니다.
*
* 잠금은 그 키의 주입만 건너뛰는 것이지, 그 키를 게이트로 쓰는 형제 키까지
* 무력화하는 것이 아닙니다. 게이트를 유효값으로 보정하지 않으면 `$mailer` 가 빈
* 문자열이 되어 어느 분기에도 들어가지 않고, mailgun 자격증명이 오류 없이 사라집니다.
*
* @scenario switch=on, key_state=locked_plain, surface=inject
*/
public function test_locked_mailer_still_injects_unlocked_driver_children(): void
{
$this->enableWith(['MAIL_MAILER']);
Config::set('mail.default', 'mailgun');
Config::set('services.mailgun.domain', 'env.example.com');
$this->callApply('applyMailConfig', 'mail', [
'mailer' => 'smtp',
'mailgun_domain' => 'saved.example.com',
'mailgun_secret' => 'saved-secret',
'mailgun_endpoint' => 'api.eu.mailgun.net',
]);
$this->assertSame('mailgun', config('mail.default'), '잠긴 마스터 키는 `.env` 유효값 유지');
$this->assertSame('saved.example.com', config('services.mailgun.domain'), '미잠금 하위 키는 주입');
$this->assertSame('saved-secret', config('services.mailgun.secret'));
$this->assertSame('api.eu.mailgun.net', config('services.mailgun.endpoint'));
}
/**
* 잠긴 웹소켓 server endpoint 자리에 클라이언트 값이 폴백으로 덮이지 않습니다.
*
* 클라이언트 endpoint 는 env 대응이 없는 관리자 소유 값입니다. 폴백을 그대로 두면
* 그 값이 `.env` 가 소유한 REVERB_HOST/PORT/SCHEME 자리에 실려 잠금이 무력해집니다.
*
* @scenario switch=on, key_state=locked_plain, surface=inject
*/
public function test_locked_websocket_server_endpoint_is_not_overwritten_by_client_fallback(): void
{
$this->enableWith(['REVERB_HOST', 'REVERB_PORT', 'REVERB_SCHEME']);
Config::set('broadcasting.connections.reverb.options.host', 'ws.env.example.com');
Config::set('broadcasting.connections.reverb.options.port', 8080);
Config::set('broadcasting.connections.reverb.options.scheme', 'https');
Config::set('reverb.apps.apps.0.options.host', 'ws.env.example.com');
$this->callApply('applyDriverConfig', 'drivers', [
'websocket_enabled' => true,
'websocket_host' => 'admin.example.com',
'websocket_port' => 6001,
'websocket_scheme' => 'http',
]);
$this->assertSame('ws.env.example.com', config('broadcasting.connections.reverb.options.host'));
$this->assertSame(8080, config('broadcasting.connections.reverb.options.port'));
$this->assertSame('https', config('broadcasting.connections.reverb.options.scheme'));
$this->assertSame('ws.env.example.com', config('reverb.apps.apps.0.options.host'));
$this->assertSame('admin.example.com', config('g7.websocket.client.host'), '클라이언트 endpoint 는 종전대로 주입');
}
/**
* 미잠금 상태에서는 server endpoint 폴백이 종전대로 동작합니다 (음성 대조).
*/
public function test_websocket_server_endpoint_falls_back_to_client_when_unlocked(): void
{
$this->disableSwitch();
$this->callApply('applyDriverConfig', 'drivers', [
'websocket_enabled' => true,
'websocket_host' => 'admin.example.com',
'websocket_port' => 6001,
'websocket_scheme' => 'https',
]);
$this->assertSame('admin.example.com', config('broadcasting.connections.reverb.options.host'));
$this->assertSame(6001, config('broadcasting.connections.reverb.options.port'));
$this->assertSame('https', config('broadcasting.connections.reverb.options.scheme'));
}
/**
* 잠긴 storage_driver 의 유효값이 s3 면 첨부 디스크 파생이 그대로 발동합니다.
*
* 저장값 부재를 "s3 아님" 으로 읽으면 `FILESYSTEM_DISK=s3` 를 잠근 설치에서
* 첨부 업로드만 로컬 디스크로 되돌아갑니다 (오류 없이 갈라집니다).
*
* @scenario switch=on, key_state=locked_plain, surface=inject
*/
public function test_locked_storage_driver_still_derives_attachment_disk(): void
{
$this->enableWith(['FILESYSTEM_DISK']);
Config::set('filesystems.default', 's3');
Config::set('attachment.disk_explicit', null);
Config::set('attachment.disk', 'attachments');
$this->callApply('applyDriverConfig', 'drivers', [
'storage_driver' => 'local',
]);
$this->assertSame('s3', config('filesystems.default'), '잠긴 키는 `.env` 유효값 유지');
$this->assertSame('s3', config('attachment.disk'), '유효값 기준으로 파생이 발동');
}
/**
* ATTACHMENT_DISK 가 명시된 설치에서는 파생이 발동하지 않습니다 (음성 대조).
*/
public function test_explicit_attachment_disk_still_wins_over_derivation(): void
{
$this->enableWith(['FILESYSTEM_DISK']);
Config::set('filesystems.default', 's3');
Config::set('attachment.disk_explicit', 'attachments');
Config::set('attachment.disk', 'attachments');
$this->callApply('applyDriverConfig', 'drivers', [
'storage_driver' => 'local',
]);
$this->assertSame('attachments', config('attachment.disk'));
}
}
+319
View File
@@ -0,0 +1,319 @@
<?php
namespace Tests\Unit\Support;
use App\Support\EnvPriority;
use Illuminate\Support\Facades\Config;
use Tests\TestCase;
/**
* `.env` 키 단위 우선 판정(EnvPriority) 단위 테스트.
*
* @effects switch_off_behaves_identically, explicit_env_skips_injection, empty_string_env_not_explicit, false_env_is_explicit, locked_plain_shows_effective_value, locked_sensitive_hides_effective_value
*/
class EnvPriorityTest extends TestCase
{
/**
* 스위치를 켜고 지정한 env 변수만 명시 상태로 만듭니다.
*
* @param array<int, string> $explicitVars 명시 상태로 만들 env 변수 목록
*/
private function enableWith(array $explicitVars): void
{
Config::set('env-priority.enabled', true);
Config::set('env-priority.explicit', array_fill_keys($explicitVars, true));
}
/**
* 스위치가 꺼져 있으면 어떤 키도 잠기지 않습니다 (현행 동작 보존).
*
* @scenario switch=off, key_state=unlocked, surface=inject
*/
public function test_switch_off_locks_nothing(): void
{
Config::set('env-priority.enabled', false);
Config::set('env-priority.explicit', ['APP_NAME' => true, 'MAIL_PORT' => true]);
$this->assertFalse(EnvPriority::enabled());
$this->assertFalse(EnvPriority::isLocked('general.site_name'));
$this->assertSame([], EnvPriority::lockedKeys());
$this->assertSame(
['site_name' => 'X'],
EnvPriority::filterLocked('general', ['site_name' => 'X'])
);
}
/**
* 스위치가 켜지고 env 가 명시된 키만 잠깁니다.
*
* @scenario switch=on, key_state=locked_plain, surface=get_meta
*/
public function test_explicit_env_locks_only_that_key(): void
{
$this->enableWith(['MAIL_PORT']);
$this->assertTrue(EnvPriority::isLocked('mail.port'));
$this->assertFalse(EnvPriority::isLocked('mail.host'));
$this->assertSame(['mail.port' => true], EnvPriority::lockedKeys());
}
/**
* 매핑에 없는 키는 잠기지 않습니다 (면제 목록 등재 키 포함).
*
* @scenario switch=on, key_state=unlocked, surface=get_meta
*/
public function test_unmapped_key_is_never_locked(): void
{
$this->enableWith(['MAIL_PORT']);
$this->assertFalse(EnvPriority::isLocked('mail.encryption'));
$this->assertFalse(EnvPriority::isLocked('general.site_description'));
$this->assertFalse(EnvPriority::isLocked('nonexistent.key'));
}
/**
* env 두 개가 한 설정을 나누는 키는 하나만 명시돼도 잠깁니다 (any-of).
*
* @scenario switch=on, key_state=locked_plain, surface=inject
*/
public function test_any_of_env_vars_locks_the_setting(): void
{
$this->enableWith(['REDIS_CACHE_DB']);
$this->assertTrue(EnvPriority::isLocked('drivers.redis_database'));
$this->enableWith(['REDIS_DB']);
$this->assertTrue(EnvPriority::isLocked('drivers.redis_database'));
$this->enableWith(['REDIS_HOST']);
$this->assertFalse(EnvPriority::isLocked('drivers.redis_database'));
}
/**
* filterLocked 는 잠긴 키만 제거하고 나머지는 그대로 둡니다.
*
* @scenario switch=on, key_state=unlocked, surface=inject
*/
public function test_filter_locked_removes_only_locked_keys(): void
{
$this->enableWith(['MAIL_PORT', 'MAIL_PASSWORD']);
$filtered = EnvPriority::filterLocked('mail', [
'mailer' => 'smtp',
'host' => 'smtp.example.com',
'port' => 587,
'password' => 'stored',
]);
$this->assertSame(['mailer' => 'smtp', 'host' => 'smtp.example.com'], $filtered);
}
/**
* 저장 게이트는 잠긴 키를 조용히 제거합니다 (화면 disabled 우회 경로 차단).
*
* @scenario switch=on, key_state=locked_plain, surface=post_filter
*/
public function test_reject_locked_for_save_strips_locked_keys(): void
{
$this->enableWith(['APP_NAME']);
$accepted = EnvPriority::rejectLockedForSave('general', [
'site_name' => '공격자가_보낸_값',
'site_description' => '허용',
]);
$this->assertSame(['site_description' => '허용'], $accepted);
}
/**
* 저장 게이트도 스위치가 꺼져 있으면 입력을 그대로 통과시킵니다.
*
* @scenario switch=off, key_state=unlocked, surface=post_filter
*/
public function test_reject_locked_for_save_is_noop_when_switch_off(): void
{
Config::set('env-priority.enabled', false);
Config::set('env-priority.explicit', ['APP_NAME' => true]);
$input = ['site_name' => 'X', 'site_description' => 'Y'];
$this->assertSame($input, EnvPriority::rejectLockedForSave('general', $input));
}
/**
* 민감 키 규약: password/secret/token/license 류만 sensitive 입니다.
*
* @scenario switch=on, key_state=locked_sensitive, surface=get_meta
*/
public function test_sensitive_contract(): void
{
$this->assertTrue(EnvPriority::isSensitive('mail.password'));
$this->assertTrue(EnvPriority::isSensitive('mail.ses_secret'));
$this->assertTrue(EnvPriority::isSensitive('drivers.redis_password'));
$this->assertTrue(EnvPriority::isSensitive('drivers.s3_secret_key'));
$this->assertTrue(EnvPriority::isSensitive('drivers.websocket_app_secret'));
$this->assertTrue(EnvPriority::isSensitive('core_update.github_token'));
$this->assertTrue(EnvPriority::isSensitive('geoip.license_key'));
$this->assertFalse(EnvPriority::isSensitive('mail.host'));
$this->assertFalse(EnvPriority::isSensitive('drivers.websocket_app_key'));
$this->assertFalse(EnvPriority::isSensitive('general.site_name'));
}
/**
* 매핑에 등장하는 모든 sensitive 키는 실제로 자격증명 계열 이름을 갖습니다.
*
* 반대 방향(자격증명 이름인데 sensitive 표시가 빠진 키) 검출이 목적입니다 —
* 표시가 빠지면 `.env` 의 비밀값이 관리자 화면 응답에 실려 나갑니다.
*/
/** @scenario switch=on, key_state=locked_sensitive, surface=get_meta */
public function test_credential_named_keys_are_all_marked_sensitive(): void
{
$missing = [];
foreach (EnvPriority::MAP as $key => $entry) {
$isCredentialName = (bool) preg_match('/(password|secret|token|license|_access_key|ses_key)/', $key);
if ($isCredentialName && ($entry['sensitive'] ?? false) !== true) {
$missing[] = $key;
}
}
$this->assertSame([], $missing, 'sensitive 표시가 빠진 자격증명 키: '.implode(', ', $missing));
}
/**
* 유효값은 런타임 config 값이며, 표시 변환 지시자가 있으면 화면 단위로 환산됩니다.
*/
/** @scenario switch=on, key_state=locked_plain, surface=get_effective_value */
public function test_effective_value_reads_runtime_config(): void
{
Config::set('app.name', '운영중인이름');
$this->assertSame('운영중인이름', EnvPriority::effectiveValue('general.site_name'));
}
/**
* 첨부 최대 크기는 KB(config) → MB(화면) 로 환산됩니다.
*/
/** @scenario switch=on, key_state=locked_plain, surface=get_effective_value */
public function test_effective_value_converts_kb_to_mb(): void
{
Config::set('attachment.max_file_size', 20480);
$this->assertSame(20, EnvPriority::effectiveValue('upload.max_file_size'));
}
/**
* 웹소켓 마스터 토글은 broadcasting 드라이버가 reverb 인지로 표현됩니다.
*
* @scenario switch=on, key_state=locked_falsy, surface=get_meta
*/
public function test_effective_value_maps_broadcast_driver_to_toggle(): void
{
Config::set('broadcasting.default', 'reverb');
$this->assertTrue(EnvPriority::effectiveValue('drivers.websocket_enabled'));
Config::set('broadcasting.default', 'null');
$this->assertFalse(EnvPriority::effectiveValue('drivers.websocket_enabled'));
}
/**
* 로그 드라이버는 stack 채널 목록의 첫 항목으로 표현됩니다.
*/
/** @scenario switch=on, key_state=locked_plain, surface=get_effective_value */
public function test_effective_value_takes_first_stack_channel(): void
{
Config::set('logging.channels.stack.channels', ['daily', 'stderr']);
$this->assertSame('daily', EnvPriority::effectiveValue('drivers.log_driver'));
}
/**
* 매핑에 없는 키의 유효값은 null 입니다.
*/
/** @scenario switch=on, key_state=unlocked, surface=get_effective_value */
public function test_effective_value_is_null_for_unmapped_key(): void
{
$this->assertNull(EnvPriority::effectiveValue('mail.encryption'));
}
/**
* envVars() 는 매핑의 env 변수를 중복 없이 반환합니다.
*/
public function test_env_vars_are_deduplicated(): void
{
$vars = EnvPriority::envVars();
$this->assertSame(array_values(array_unique($vars)), $vars);
// AWS_ACCESS_KEY_ID 는 mail.ses_key 와 drivers.s3_access_key 두 곳에 등장한다.
$this->assertContains('AWS_ACCESS_KEY_ID', $vars);
$this->assertContains('REDIS_CACHE_DB', $vars);
}
/**
* 명시 판별은 strict 입니다 — 빈 값·`null` 문자열·미설정만 미명시입니다.
*
* 이 규약은 `config/env-priority.php` 의 캡처식이 소유하므로, 그 파일을 실제로
* 평가해 판정합니다. `?:` 로 바꾸면 `APP_DEBUG=false`·`REDIS_DB=0` 같은 falsy
* 명시가 미명시로 오판되는데, 그 회귀를 여기서 잠급니다.
*
* @scenario switch=on, key_state=locked_falsy, surface=inject
*/
public function test_explicit_capture_is_strict_about_presence(): void
{
$cases = [
'MAILGUN_ENDPOINT' => ['value' => 'api.example.com', 'explicit' => true],
'GEOIP_LICENSE_KEY' => ['value' => '', 'explicit' => false],
'GEOIP_AUTO_UPDATE_ENABLED' => ['value' => 'false', 'explicit' => true],
'LOG_DAILY_DAYS' => ['value' => '0', 'explicit' => true],
'AWS_BUCKET' => ['value' => 'null', 'explicit' => false],
];
$originalEnv = [];
$originalServer = [];
foreach ($cases as $var => $case) {
$originalEnv[$var] = $_ENV[$var] ?? null;
$originalServer[$var] = $_SERVER[$var] ?? null;
$_ENV[$var] = $case['value'];
$_SERVER[$var] = $case['value'];
}
// REVERB_APP_ID 는 어떤 케이스에도 없다 — 미설정 축의 대조군.
$unsetVar = 'REVERB_APP_ID';
$originalEnv[$unsetVar] = $_ENV[$unsetVar] ?? null;
$originalServer[$unsetVar] = $_SERVER[$unsetVar] ?? null;
unset($_ENV[$unsetVar], $_SERVER[$unsetVar]);
try {
$captured = require config_path('env-priority.php');
$explicit = $captured['explicit'];
foreach ($cases as $var => $case) {
$this->assertSame(
$case['explicit'],
($explicit[$var] ?? false) === true,
"{$var}={$case['value']} 의 명시 판정이 규약과 다릅니다"
);
}
$this->assertFalse(($explicit[$unsetVar] ?? false) === true, '미설정 env 는 미명시여야 합니다');
} finally {
foreach ($originalEnv as $var => $value) {
if ($value === null) {
unset($_ENV[$var]);
} else {
$_ENV[$var] = $value;
}
}
foreach ($originalServer as $var => $value) {
if ($value === null) {
unset($_SERVER[$var]);
} else {
$_SERVER[$var] = $value;
}
}
}
}
}
@@ -0,0 +1,55 @@
feature: .env 키 단위 우선 (G7_ENV_PRIORITY 옵트인)
description: |
관리자 환경설정(storage/app/settings/*.json)이 `.env` 유래 config 를 덮는 기존 설계를
키 단위로 되돌리는 옵트인 스위치. `.env` 에 값이 명시된 항목만 settings 주입에서 빠지고,
관리자 화면은 그 필드를 편집 불가 + 잠금 배지로 표시하며, 저장 API 는 서버측에서 필터한다.
핵심 동작:
- 명시 판별은 config 빌드 시점 캡처(config/env-priority.php) — env() 런타임 호출은
config:cache 환경에서 null 로 고정되어 판별이 영구 미발동한다
- 판별은 strict: `KEY=`(빈 값)·`KEY=null`·미설정만 미명시. `APP_DEBUG=false`·`REDIS_DB=0`
같은 falsy 명시는 명시로 취급
- env 두 개가 한 설정을 나누는 키(redis_database)는 any-of 로 판정
- 스위치 OFF 면 전 경로 조기 return — 현행 동작 100% 동일
- 잠긴 키의 화면 표시값은 유효값(런타임 config). 단 민감 키는 값을 싣지 않는다
- 웹소켓 마스터 토글 잠금 시 OFF 강제 3종 오진입 금지 (`.env` 권위)
- 디버그 모드 잠금 시 2차 효과(로그 레벨 강제·프록시 게이트)를 유효값으로 보정
axes:
switch: [off, on]
key_state: [unlocked, locked_plain, locked_sensitive, locked_falsy]
surface: [inject, get_meta, get_effective_value, post_filter, ui_lock]
exclusions:
- { switch: off, key_state: locked_plain, reason: "스위치 OFF 는 잠금 판정 자체를 하지 않는다 — 전 key_state 가 unlocked 와 동일 경로" }
- { switch: off, key_state: locked_sensitive, reason: "동일" }
- { switch: off, key_state: locked_falsy, reason: "동일" }
- { key_state: locked_sensitive, surface: inject, reason: "민감 여부는 표시 경로에서만 갈린다 — 주입 스킵은 locked_plain 과 같은 코드 경로" }
- { key_state: locked_sensitive, surface: post_filter, reason: "동일 — 저장 필터는 민감 여부를 보지 않는다" }
- { key_state: locked_sensitive, surface: ui_lock, reason: "동일 — 화면 잠금 표시는 민감 여부와 무관" }
- { key_state: locked_falsy, surface: get_effective_value, reason: "falsy 명시는 판별 축이라 표시값 경로에서는 locked_plain 과 같다 — 유효값 오버레이는 민감 여부만 본다" }
- { key_state: locked_falsy, surface: ui_lock, reason: "falsy 명시는 판별 축이라 화면 표시에서는 locked_plain 과 구분되지 않는다" }
effects:
- switch_off_behaves_identically
- explicit_env_skips_injection
- empty_string_env_not_explicit
- false_env_is_explicit
- map_parity_covers_every_injected_default_key
- env_examples_document_env_priority_switch
- websocket_enabled_lock_skips_off_branch
- debug_mode_lock_corrects_outbound_gate
- locked_key_filtered_on_save_including_advanced
- locked_plain_shows_effective_value
- locked_sensitive_hides_effective_value
- meta_env_locked_uses_frontend_keys
- badge_and_disabled_rendered
test_files:
- tests/Unit/Support/EnvPriorityTest.php
- tests/Unit/Providers/SettingsServiceProviderEnvPriorityTest.php
- tests/Feature/Settings/EnvPriorityContractTest.php
- tests/Feature/Settings/SettingsEnvLockedMetaTest.php
- templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-env-locked.test.tsx
- tests/Playwright/specs/admin/settings-env-priority.spec.ts