fix(core,admin_basic,board,ecommerce,page,gdpr,verification): 설정 미러·자동 정리·탈퇴 원자성·일괄 선택 범위 정비

공개이슈 ··· 및 그 전수 해소분.

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

No files matched your search

@@ -3,12 +3,14 @@
namespace Tests\Feature\Api\Auth;
use App\Enums\AttachmentSourceType;
use App\Enums\PermissionType;
use App\Models\Attachment;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
@@ -548,7 +550,7 @@ class ProfileControllerTest extends TestCase
// 새 비밀번호로 로그인 가능 확인
$this->user->refresh();
$this->assertTrue(\Illuminate\Support\Facades\Hash::check('newpassword456', $this->user->password));
$this->assertTrue(Hash::check('newpassword456', $this->user->password));
}
/**
@@ -676,6 +678,13 @@ class ProfileControllerTest extends TestCase
$this->assertNotEquals($originalEmail, $this->user->email);
$this->assertNotEquals($originalName, $this->user->name);
// 접미사 형식: 날짜 + 사용자 ID (같은 날 같은 이메일 재탈퇴 충돌 차단, 공개이슈 #112)
$this->assertMatchesRegularExpression(
'/_deleted_\d{8}_'.$this->user->id.'$/u',
$this->user->email,
'이메일 접미사에 사용자 ID 가 없으면 같은 날 재탈퇴에서 unique 충돌이 난다'
);
// 탈퇴 상태로 변경되었는지 확인
$this->assertEquals('withdrawn', $this->user->status);
$this->assertNotNull($this->user->withdrawn_at);
@@ -724,6 +733,9 @@ class ProfileControllerTest extends TestCase
// Attachment 레코드 삭제 확인
$this->assertDatabaseMissing('attachments', ['id' => $attachment->id]);
// 파일 삭제는 DB 커밋이 확정된 뒤에 수행된다 (공개이슈 #112)
Storage::disk('attachments')->assertMissing('attachments/avatars/test-avatar.jpg');
}
/**
@@ -742,7 +754,8 @@ class ProfileControllerTest extends TestCase
'Accept' => 'application/json',
])->deleteJson('/api/me');
$response->assertStatus(500); // Exception 발생
// 관리 anchor 보호는 잘못된 요청이지 서버 오류가 아니다 (공개이슈 #112)
$response->assertStatus(422);
// 슈퍼 관리자는 변경되지 않음
$superAdmin->refresh();
@@ -756,9 +769,9 @@ class ProfileControllerTest extends TestCase
public function test_withdraw_prevents_admin(): void
{
// 관리자 역할 생성 및 할당
$role = \App\Models\Role::factory()->create(['identifier' => 'admin']);
$permission = \App\Models\Permission::factory()->create([
'type' => \App\Enums\PermissionType::Admin,
$role = Role::factory()->create(['identifier' => 'admin']);
$permission = Permission::factory()->create([
'type' => PermissionType::Admin,
]);
$role->permissions()->attach($permission);
@@ -773,7 +786,8 @@ class ProfileControllerTest extends TestCase
'Accept' => 'application/json',
])->deleteJson('/api/me');
$response->assertStatus(500); // ValidationException 발생
// 관리자 탈퇴 차단은 잘못된 요청이지 서버 오류가 아니다 (공개이슈 #112)
$response->assertStatus(422);
// 관리자는 변경되지 않음
$admin->refresh();
@@ -794,6 +808,25 @@ class ProfileControllerTest extends TestCase
$this->user->refresh();
$this->assertStringContainsString('_탈퇴', $this->user->nickname);
// 컬럼 상한(50자) 안에서 접미사가 붙는다 — 넘기면 저장 자체가 실패한다
$this->assertLessThanOrEqual(User::NICKNAME_MAX_LENGTH, mb_strlen($this->user->nickname));
}
/**
* 닉네임이 상한에 가까우면 앞을 잘라 접미사를 붙인다 (공개이슈 #112)
*/
public function test_withdraw_truncates_nickname_at_column_limit(): void
{
$this->user->update(['nickname' => str_repeat('가', User::NICKNAME_MAX_LENGTH)]);
$response = $this->authRequest()->deleteJson('/api/me');
$response->assertStatus(200);
$this->user->refresh();
$this->assertSame(User::NICKNAME_MAX_LENGTH, mb_strlen($this->user->nickname));
$this->assertStringEndsWith('_탈퇴', $this->user->nickname);
}
/**
@@ -0,0 +1,242 @@
<?php
namespace Tests\Feature\Api\Auth;
use App\Enums\AttachmentSourceType;
use App\Enums\PermissionType;
use App\Enums\UserStatus;
use App\Models\Attachment;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use App\Services\UserService;
use Illuminate\Database\Schema\Builder as SchemaBuilder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\ValidationException;
use Tests\TestCase;
/**
* 회원 탈퇴 원자성/접미사 충돌 회귀 테스트 (공개이슈 #112)
*
* 재현 경로: 가입 → 탈퇴 → 같은 이메일 재가입 → 같은 날 재탈퇴.
* 수정 전에는 email unique 위반으로 500 이 났고, 그 시점에 토큰·동의 이력은
* 이미 삭제된 뒤라 "로그인은 되는데 탈퇴는 실패한" 반파괴 상태가 남았다.
*
* @scenario entry_point=self_withdraw_api, email_state=reused_same_day, failure_injection=last_write_fails, field_length=exceeds_limit
*
* @effects withdraw_is_atomic_all_or_nothing, consents_and_tokens_restored_on_failure, avatar_file_kept_on_failure, same_day_rewithdraw_with_same_email_succeeds, long_values_truncated_within_column_limits, withdraw_is_idempotent, failed_withdraw_leaves_no_activity_log
*/
class WithdrawAtomicityTest extends TestCase
{
use RefreshDatabase;
/**
* W1: 같은 날 같은 이메일로 재가입한 회원이 다시 탈퇴해도 실패하지 않는다.
*/
public function test_same_day_rewithdraw_with_same_email_succeeds(): void
{
Carbon::setTestNow(Carbon::parse('2026-08-13 10:00:00'));
$first = User::factory()->create(['email' => 'g7fix112.a@example.com']);
$second = User::factory()->create(['email' => 'placeholder@example.com']);
$service = app(UserService::class);
$this->assertTrue($service->withdrawUser($first));
// 같은 이메일로 재가입한 상황 재현
$second->forceFill(['email' => 'g7fix112.a@example.com'])->save();
$this->assertTrue($service->withdrawUser($second));
$first->refresh();
$second->refresh();
$this->assertSame(UserStatus::Withdrawn->value, $first->status);
$this->assertSame(UserStatus::Withdrawn->value, $second->status);
$this->assertNotSame($first->email, $second->email);
// 접미사에 사용자 ID 가 포함되어 구조적으로 유일하다
$this->assertStringEndsWith('_'.$first->id, $first->email);
$this->assertStringEndsWith('_'.$second->id, $second->email);
}
/**
* W2: 마지막 단계가 실패하면 동의 이력·토큰 삭제도 함께 되돌아간다.
*/
public function test_failed_withdraw_rolls_back_consents_and_tokens(): void
{
$user = User::factory()->create(['email' => 'g7fix112.b@example.com']);
$user->createToken('t');
// 약관 동의 이력도 선행 삭제 대상이다 — 롤백 대상에 포함되는지 함께 본다.
$user->consents()->create([
'consent_type' => 'terms',
'agreed_at' => now(),
]);
$this->assertSame(1, $user->tokens()->count());
$this->assertSame(1, $user->consents()->count());
$service = app(UserService::class);
try {
$service->withdrawUser($this->bindFailingWithdraw($user));
$this->fail('탈퇴가 실패해야 합니다.');
} catch (ValidationException $e) {
// 기대한 실패
}
$user->refresh();
$this->assertSame(1, $user->tokens()->count(), '토큰이 원상 복구되어야 합니다.');
$this->assertSame(1, $user->consents()->count(), '약관 동의 이력이 원상 복구되어야 합니다.');
$this->assertNotSame(UserStatus::Withdrawn->value, $user->status);
$this->assertStringNotContainsString('_deleted_', $user->email);
}
/**
* W3: 탈퇴가 실패하면 아바타 파일도 남아 있어야 한다.
*/
public function test_failed_withdraw_keeps_avatar_file(): void
{
Storage::fake('attachments');
$user = User::factory()->create(['email' => 'g7fix112.c@example.com']);
$attachment = Attachment::factory()->create([
'attachmentable_type' => User::class,
'attachmentable_id' => $user->id,
'collection' => 'avatar',
'disk' => 'attachments',
'path' => 'attachments/avatars/w3.jpg',
'source_type' => AttachmentSourceType::Core,
]);
Storage::disk('attachments')->put('attachments/avatars/w3.jpg', 'fake');
try {
app(UserService::class)->withdrawUser($this->bindFailingWithdraw($user));
$this->fail('탈퇴가 실패해야 합니다.');
} catch (ValidationException $e) {
// 기대한 실패
}
Storage::disk('attachments')->assertExists('attachments/avatars/w3.jpg');
$this->assertDatabaseHas('attachments', ['id' => $attachment->id]);
}
/**
* W4: 이미 탈퇴한 계정에 다시 호출해도 접미사가 겹쳐 붙지 않는다 (멱등).
*/
public function test_withdraw_is_idempotent(): void
{
$user = User::factory()->create(['email' => 'g7fix112.d@example.com', 'nickname' => '닉']);
$service = app(UserService::class);
$service->withdrawUser($user);
$user->refresh();
$emailAfterFirst = $user->email;
$nicknameAfterFirst = $user->nickname;
$this->assertTrue($user->withdraw());
$user->refresh();
$this->assertSame($emailAfterFirst, $user->email);
$this->assertSame($nicknameAfterFirst, $user->nickname);
$this->assertSame(1, substr_count($user->email, '_deleted_'));
}
/**
* W5: 컬럼 길이 상한에 걸리는 값도 절단 후 정상 저장된다.
*/
public function test_long_values_are_truncated_within_column_limits(): void
{
$limit = SchemaBuilder::$defaultStringLength ?? 255;
// 접미사를 붙이면 반드시 상한을 넘도록 컬럼 길이를 꽉 채운다 —
// 여유가 있으면 절단 경로가 실행되지 않아 절단 없이도 통과한다.
$user = User::factory()->create([
'email' => str_repeat('a', $limit - 12).'@example.com',
'name' => str_repeat('이', $limit),
'nickname' => str_repeat('가', User::NICKNAME_MAX_LENGTH),
]);
$this->assertSame($limit, mb_strlen($user->email), '이메일이 컬럼 상한을 꽉 채워야 합니다');
$this->assertSame($limit, mb_strlen($user->name));
$this->assertTrue(app(UserService::class)->withdrawUser($user));
$user->refresh();
// 절단이 실제로 일어났는지 — 원값 길이 그대로면 접미사가 안 붙은 것이다
$this->assertSame($limit, mb_strlen($user->email), '이메일이 상한에 맞춰 절단되어야 합니다');
$this->assertSame($limit, mb_strlen($user->name));
$this->assertSame(User::NICKNAME_MAX_LENGTH, mb_strlen($user->nickname));
$this->assertStringContainsString('_deleted_', $user->email);
$this->assertStringEndsWith('_'.$user->id, $user->email);
$this->assertStringContainsString('_탈퇴_', $user->name);
$this->assertStringEndsWith('_탈퇴', $user->nickname);
}
/**
* W7: 탈퇴가 실패하면 활동 로그도 남지 않는다.
*
* 관리자 계정의 탈퇴 차단은 500 이 아니라 422 로 나가야 한다 — 종전에는 예외가
* 그대로 올라가 서버 오류로 표시됐다.
*
* @effects blocked_withdraw_returns_422_not_500
*/
public function test_failed_withdraw_does_not_log_activity(): void
{
$role = Role::factory()->create(['identifier' => 'admin']);
$permission = Permission::factory()->create([
'type' => PermissionType::Admin,
]);
$role->permissions()->attach($permission);
$admin = User::factory()->create(['email' => 'g7fix112.admin@example.com']);
$admin->roles()->attach($role);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$admin->createToken('t')->plainTextToken,
'Accept' => 'application/json',
])->deleteJson('/api/me');
$response->assertStatus(422);
$this->assertDatabaseMissing('activity_logs', [
'action' => 'profile.withdraw',
'user_id' => $admin->id,
]);
}
/**
* 마지막 쓰기(withdraw 저장)를 실패시키는 사용자 인스턴스를 돌려줍니다.
*
* 접미사가 붙은 이메일을 미리 점유해 두면 unique 위반으로 저장이 실패합니다 —
* 실제 운영에서 발생한 실패 모드와 동일한 경로입니다.
*
* @param User $user 대상 사용자
* @return User 같은 사용자 인스턴스
*/
private function bindFailingWithdraw(User $user): User
{
$suffix = '_deleted_'.now()->format('Ymd').'_'.$user->id;
// 미끼 유저가 같은 접미사 이메일을 선점 → withdraw() 저장이 unique 위반으로 실패
User::factory()->create(['email' => $user->email.$suffix]);
return $user;
}
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
}
+16
View File
@@ -64,6 +64,8 @@ class PasswordPolicyTest extends TestCase
/**
* @test
*
* @effects password_shorter_than_setting_is_rejected
*/
public function 설정한_최소_길이가_회원가입에_적용된다(): void
{
@@ -77,6 +79,8 @@ class PasswordPolicyTest extends TestCase
/**
* @test
*
* @effects password_meeting_setting_is_accepted
*/
public function 설정한_최소_길이를_만족하면_통과한다(): void
{
@@ -89,6 +93,8 @@ class PasswordPolicyTest extends TestCase
/**
* @test
*
* @effects special_char_requirement_is_enforced_when_enabled
*/
public function 특수문자_필수_설정이_적용된다(): void
{
@@ -104,6 +110,8 @@ class PasswordPolicyTest extends TestCase
/**
* @test
*
* @effects login_request_has_no_length_rule
*/
public function 로그인_규칙에는_길이_제한이_없다(): void
{
@@ -117,6 +125,8 @@ class PasswordPolicyTest extends TestCase
/**
* @test
*
* @effects policy_rule_reads_min_length_from_settings, policy_rule_reads_special_requirement_from_settings
*/
public function 정책_규칙이_설정값을_읽는다(): void
{
@@ -131,6 +141,8 @@ class PasswordPolicyTest extends TestCase
/**
* @test
*
* @effects empty_security_settings_fall_back_to_defaults
*/
public function 설정이_비어도_기본값으로_동작한다(): void
{
@@ -144,6 +156,8 @@ class PasswordPolicyTest extends TestCase
/**
* @test
*
* @effects all_password_setting_paths_share_one_rule
*/
public function 비로그인_비밀번호_요청은_모두_정책을_사용한다(): void
{
@@ -180,6 +194,8 @@ class PasswordPolicyTest extends TestCase
/**
* @test
*
* @effects saved_security_settings_are_visible_on_reload
*/
public function 두_보안_설정_키가_저장_후_재조회에_반영된다(): void
{
@@ -0,0 +1,330 @@
<?php
namespace Tests\Feature\Console;
use App\Models\Schedule as ScheduleModel;
use App\Providers\SettingsServiceProvider;
use App\Repositories\JsonConfigRepository;
use Illuminate\Console\Scheduling\Event;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Contracts\Console\Kernel as ConsoleKernelContract;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Config;
use Mockery;
use Tests\TestCase;
/**
* 코어 만료 데이터 정리 스케줄 등록 회귀 테스트 (공개이슈 #110)
*
* 이 축의 테스트가 0건이었던 것이 "커맨드는 있는데 스케줄만 없다" 는 결함군이
* 무가드로 통과된 구조적 원인입니다. 등록 전수와 실행 시각 기준을 함께 고정합니다.
*
* 서머타임 주의: 서머타임이 있는 시간대에서는 전환일에 특정 시각이 건너뛰거나
* 두 번 발생할 수 있습니다. 아래 정리 배치는 모두 멱등이라 이중 실행이 데이터를
* 해치지 않고, 건너뛴 날은 다음 날 배치가 흡수합니다.
*
* @scenario schedule_axis=code_registered, schedule_axis=admin_registered, timezone_setting=configured, timezone_setting=empty
*
* @effects all_gc_commands_are_registered_in_schedule, gc_commands_run_on_one_server_only, sanctum_prune_uses_hours_option, schedule_timezone_follows_site_general_timezone, schedule_timezone_falls_back_to_app_timezone_when_unset, app_timezone_stays_utc, admin_registered_schedule_next_run_uses_same_basis, new_commands_are_in_schedule_security_allowlist, new_commands_are_exposed_in_dev_dashboard
*/
class CoreScheduleRegistrationTest extends TestCase
{
use RefreshDatabase;
/** @var string 부팅 시점에 확정된 예약 시각 기준 */
private string $bootScheduleTimezone = 'UTC';
/**
* B1: 코어 GC 스케줄 전수가 등록되어 있고 주기/onOneServer 가 의도대로다.
*/
public function test_core_gc_schedules_are_registered(): void
{
$expected = [
'sanctum:prune-expired' => '0 4 * * *',
'auth:clear-resets' => '5 4 * * *',
'queue:prune-failed' => '10 4 * * *',
'queue:prune-batches' => '15 4 * * *',
'notification:cleanup' => '20 4 * * *',
'ext-bundles:cleanup' => '25 4 * * *',
'seo:prune-stats' => '30 4 * * *',
'schedules:prune-history' => '35 4 * * *',
'identity:prune-logs' => '40 4 * * *',
'activity-log:prune' => '45 4 * * *',
'notification-log:prune' => '50 4 * * *',
'identity:expire-challenges' => '0 * * * *',
];
foreach ($expected as $command => $expression) {
$event = $this->findScheduledEvent($command);
$this->assertNotNull($event, "{$command} 스케줄이 등록되어야 합니다.");
$this->assertSame($expression, $event->expression, "{$command} 실행 주기");
$this->assertTrue($event->onOneServer, "{$command} 는 다중 서버에서 1대만 실행해야 합니다.");
}
}
/**
* B1: sanctum 정리는 --hours=24 로 실행된다.
*/
public function test_sanctum_prune_uses_expected_hours_option(): void
{
$event = $this->findScheduledEvent('sanctum:prune-expired');
$this->assertNotNull($event);
$this->assertStringContainsString('--hours', (string) $event->command);
$this->assertStringContainsString('24', (string) $event->command);
}
/**
* B1-t: 예약 시각 기준은 사이트 설정 시간대(general.timezone)를 따른다.
*/
public function test_schedule_timezone_follows_site_general_timezone(): void
{
// 테스트가 주입한 값을 그대로 되읽으면 파생 로직을 지워도 통과한다 —
// 사이트 설정에서 app.schedule_timezone 이 실제로 파생되는지를 본다.
Config::set('app', Arr::except(config('app'), ['schedule_timezone']));
$this->applyGeneralSettings(['timezone' => 'Europe/Berlin']);
$this->assertSame(
'Europe/Berlin',
config('app.schedule_timezone'),
'사이트 설정 시간대가 예약 시각 기준으로 파생되지 않습니다'
);
// 사용자 표시용 기본 타임존도 같은 값에서 파생된다 (기존 계약 유지)
$this->assertSame('Europe/Berlin', config('app.default_user_timezone'));
// 저장 타임존은 UTC 불변 — 전 도메인의 저장값 해석 기준이라 건드리지 않는다.
$this->assertSame('UTC', config('app.timezone'));
}
/**
* B1-t: 사이트 설정에 시간대가 없으면 파생 키 자체를 만들지 않는다.
*/
public function test_schedule_timezone_is_not_derived_when_setting_is_empty(): void
{
Config::set('app', Arr::except(config('app'), ['schedule_timezone']));
$this->applyGeneralSettings(['site_name' => '시간대 없는 사이트']);
$this->assertFalse(
Config::has('app.schedule_timezone'),
'시간대 미설정인데 파생 키가 생겨 Laravel 폴백이 차단됩니다'
);
}
/**
* B1-o: 정리 예약 전건이 관리자 화면에서도 등록 가능해야 한다 (artisan 허용목록).
*
* 허용목록에 없으면 운영자가 주기를 바꾸거나 임시로 다시 걸 수 없고, 그 사실이
* 등록 시점의 거부로만 드러난다.
*/
public function test_gc_commands_are_registerable_through_the_admin_allowlist(): void
{
$allowlist = (array) config('schedule_security.artisan.allowlist');
$commands = $this->gcCommandNames();
$this->assertNotEmpty($commands, '정리 예약을 소스에서 도출하지 못했습니다.');
foreach ($commands as $command) {
$this->assertArrayHasKey(
$command,
$allowlist,
"{$command} 가 artisan 허용목록에 없어 관리자가 예약으로 등록할 수 없습니다."
);
}
}
/**
* B1-o: 이 저장소가 소유한 정리 커맨드는 개발 대시보드에서 수동 실행 가능해야 한다.
*
* 배치가 실패한 날 운영자가 손으로 돌릴 통로가 없으면 다음 배치까지 방치된다.
* 프레임워크·벤더 커맨드(`sanctum:prune-expired` 등)는 대시보드 노출 대상이 아니므로
* 소유 커맨드(`app/Console/Commands`)로 한정한다 — 노출 정합 검사와 같은 범위다.
*/
public function test_owned_gc_commands_are_exposed_in_the_dev_dashboard(): void
{
$dashboard = (string) file_get_contents(resource_path('views/dev-dashboard.blade.php'));
$owned = $this->ownedCommandNames();
$checked = 0;
foreach ($this->gcCommandNames() as $command) {
if (! in_array($command, $owned, true)) {
continue;
}
$checked++;
$this->assertStringContainsString(
"runCommand('{$command}')",
$dashboard,
"{$command} 실행 버튼이 개발 대시보드에 없습니다."
);
}
$this->assertGreaterThan(0, $checked, '소유 정리 커맨드를 하나도 판정하지 못했습니다.');
}
/**
* 이 저장소가 소유한(= `app/Console/Commands` 에 정의된) artisan 커맨드 이름 목록.
*
* @return array<int, string> 커맨드 이름 목록
*/
private function ownedCommandNames(): array
{
$root = base_path('app'.DIRECTORY_SEPARATOR.'Console'.DIRECTORY_SEPARATOR.'Commands');
$names = [];
foreach (Artisan::all() as $name => $command) {
$file = (new \ReflectionClass($command))->getFileName();
if ($file !== false && str_starts_with((string) $file, $root)) {
$names[] = $name;
}
}
return $names;
}
/**
* 정리 예약 커맨드 이름을 예약 정의 소스에서 도출합니다.
*
* 손으로 적은 목록은 예약이 늘어도 그대로라 전수를 증명하지 못합니다 —
* 다중 서버 1대 실행(`onOneServer`)으로 선언된 artisan 예약을 그대로 읽습니다.
*
* @return array<int, string> 커맨드 이름 목록
*/
private function gcCommandNames(): array
{
$source = (string) file_get_contents(base_path('routes/console.php'));
$begin = strpos($source, '// gc-schedules:begin');
$end = strpos($source, '// gc-schedules:end');
$this->assertNotFalse($begin, '정리 예약 구간 시작 표식이 없습니다.');
$this->assertNotFalse($end, '정리 예약 구간 종료 표식이 없습니다.');
// 표식 밖의 예약(geoip 갱신·sitemap 생성 등)은 정리 배치가 아니다 —
// `onOneServer()` 만으로 거르면 그런 예약까지 대상이 되어 오탐이 난다.
$region = substr($source, $begin, $end - $begin);
preg_match_all("/Schedule::command\(\s*'([^']+)'/", $region, $matches);
return array_values(array_unique($matches[1] ?? []));
}
/**
* B1-t: 등록된 예약 이벤트 전부가 같은 시각 기준을 공유한다.
*/
public function test_every_scheduled_event_shares_the_same_timezone(): void
{
$events = app(Schedule::class)->events();
$this->assertNotEmpty($events);
// 기대값을 특정 시간대로 박으면 사이트 설정이 다른 환경·CI 에서 실패한다.
// Schedule 인스턴스가 부팅 시점에 해석한 값과 대조한다.
foreach ($events as $event) {
$this->assertSame(
$this->bootScheduleTimezone,
(string) $event->timezone,
'예약 이벤트가 서로 다른 시각 기준을 씁니다: '.$event->command
);
}
}
/**
* 사이트 general 설정을 주고 Provider 의 파생 로직을 실행합니다.
*
* @param array<string, mixed> $general general 카테고리 설정값
*/
private function applyGeneralSettings(array $general): void
{
$repository = Mockery::mock(JsonConfigRepository::class);
$repository->shouldReceive('getCategory')->with('general')->andReturn($general);
$repository->shouldReceive('getCategory')->andReturn([]);
$provider = new SettingsServiceProvider($this->app);
$apply = new \ReflectionMethod($provider, 'applyAppConfig');
$apply->setAccessible(true);
$apply->invoke($provider, $repository);
}
/**
* B1-t: 사이트 설정 시간대가 비어 있으면 종전대로 app.timezone 으로 폴백한다.
*/
public function test_schedule_timezone_falls_back_to_app_timezone(): void
{
$kernel = app(ConsoleKernelContract::class);
$resolve = new \ReflectionMethod($kernel, 'scheduleTimezone');
$resolve->setAccessible(true);
// 사이트 설정에 시간대가 없으면 Provider 가 키 자체를 세팅하지 않는다(= 부재).
Config::set('app', Arr::except(config('app'), ['schedule_timezone']));
$this->assertFalse(Config::has('app.schedule_timezone'));
$this->assertSame(config('app.timezone'), $resolve->invoke($kernel));
Config::set('app.schedule_timezone', 'Asia/Seoul');
$this->assertSame('Asia/Seoul', $resolve->invoke($kernel));
}
/**
* B1-a: 관리자 등록 예약의 다음 실행 시각도 같은 기준으로 계산된다.
*/
public function test_admin_registered_schedule_next_run_uses_site_timezone(): void
{
$schedule = new ScheduleModel([
'expression' => '0 4 * * *',
'is_active' => true,
]);
$schedule->calculateNextRunAt();
$this->assertNotNull($schedule->next_run_at);
// 사이트 설정 시간대(Asia/Seoul, UTC+9)의 04:00 = UTC 19:00
$this->assertSame(
'04:00',
$schedule->next_run_at->copy()->setTimezone('Asia/Seoul')->format('H:i')
);
$this->assertSame('19:00', $schedule->next_run_at->copy()->utc()->format('H:i'));
}
/**
* 테스트 시간대 고정 — 사이트 설정 시간대를 Asia/Seoul 로 두고 앱을 부팅한다.
*/
protected function setUp(): void
{
parent::setUp();
// 예약 이벤트의 시각 기준은 부팅 시점에 확정된다 — 아래 주입은 그 뒤라
// 이미 등록된 이벤트에는 영향이 없다. 대조용으로 부팅 값을 먼저 붙잡는다.
$this->bootScheduleTimezone = (string) (
config('app.schedule_timezone') ?? config('app.timezone', 'UTC')
);
// 아래 계산 시점 해석(관리자 등록 예약)을 고정하기 위한 주입.
Config::set('app.schedule_timezone', 'Asia/Seoul');
}
/**
* 부팅된 앱의 스케줄러에서 커맨드 이름으로 이벤트를 찾습니다.
*
* @param string $command artisan 커맨드 이름
* @return Event|null 등록된 스케줄 이벤트
*/
private function findScheduledEvent(string $command): ?Event
{
foreach (app(Schedule::class)->events() as $event) {
if (str_contains((string) $event->command, "'{$command}'")
|| str_contains((string) $event->command, " {$command} ")
|| str_ends_with((string) $event->command, " {$command}")) {
return $event;
}
}
return null;
}
}
@@ -0,0 +1,247 @@
<?php
namespace Tests\Feature\Console;
use App\Enums\IdentityVerificationStatus;
use App\Extension\HookManager;
use App\Models\ActivityLog;
use App\Models\IdentityVerificationLog;
use App\Models\NotificationLog;
use App\Models\Schedule as ScheduleModel;
use App\Models\ScheduleHistory;
use App\Models\SeoCacheStat;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Tests\TestCase;
/**
* 만료/누적 데이터 정리 커맨드 테스트 (공개이슈 #110 전수 해소분)
*
* 각 커맨드가 보존 기간을 넘긴 행만 지우고 기간 이내 행은 보존하는지 고정합니다.
*
* @scenario row_age=within_retention, row_age=beyond_retention
*
* @effects rows_beyond_retention_are_deleted, rows_within_retention_are_kept, identity_challenges_transition_without_deletion, automated_prune_emits_its_own_hooks
*/
class DataRetentionCommandsTest extends TestCase
{
use RefreshDatabase;
/**
* B2: seo:prune-stats — 보존 기간 초과 통계만 삭제.
*/
public function test_seo_prune_stats_removes_only_expired_rows(): void
{
$old = SeoCacheStat::create(['url' => '/old', 'locale' => 'ko', 'type' => 'hit']);
$old->forceFill(['created_at' => now()->subDays(40)])->saveQuietly();
$recent = SeoCacheStat::create(['url' => '/recent', 'locale' => 'ko', 'type' => 'hit']);
$recent->forceFill(['created_at' => now()->subDays(5)])->saveQuietly();
$this->artisan('seo:prune-stats', ['--days' => 30])->assertSuccessful();
$this->assertDatabaseMissing('seo_cache_stats', ['id' => $old->id]);
$this->assertDatabaseHas('seo_cache_stats', ['id' => $recent->id]);
}
/**
* B3: schedules:prune-history — 보존 기간 초과 이력만 삭제.
*/
public function test_schedule_history_prune_removes_only_expired_rows(): void
{
$schedule = ScheduleModel::create([
'name' => '테스트 스케줄',
'type' => 'artisan',
'command' => 'inspire',
'expression' => '0 4 * * *',
'frequency' => 'daily',
'is_active' => true,
]);
$old = ScheduleHistory::create([
'schedule_id' => $schedule->id,
'started_at' => now()->subDays(120),
'status' => 'success',
]);
$recent = ScheduleHistory::create([
'schedule_id' => $schedule->id,
'started_at' => now()->subDays(10),
'status' => 'success',
]);
$this->artisan('schedules:prune-history', ['--days' => 90])->assertSuccessful();
$this->assertDatabaseMissing('schedule_histories', ['id' => $old->id]);
$this->assertDatabaseHas('schedule_histories', ['id' => $recent->id]);
}
/**
* B4: identity:expire-challenges — 만료 challenge 만 상태 전환(비파괴).
*/
public function test_identity_expire_challenges_transitions_only_past_due(): void
{
$pastDue = $this->createIdentityLog(IdentityVerificationStatus::Requested->value, now()->subMinutes(10));
$future = $this->createIdentityLog(IdentityVerificationStatus::Requested->value, now()->addMinutes(10));
$this->artisan('identity:expire-challenges')->assertSuccessful();
$this->assertSame(
IdentityVerificationStatus::Expired->value,
IdentityVerificationLog::find($pastDue->id)->status->value
);
$this->assertSame(
IdentityVerificationStatus::Requested->value,
IdentityVerificationLog::find($future->id)->status->value
);
// 비파괴 — 행 자체는 남는다.
$this->assertDatabaseHas('identity_verification_logs', ['id' => $pastDue->id]);
}
/**
* B6: identity:prune-logs — 보관 기간 초과 이력만 파기.
*/
public function test_identity_prune_logs_removes_only_expired_rows(): void
{
$old = $this->createIdentityLog(IdentityVerificationStatus::Verified->value, now()->subDays(200));
$old->forceFill(['created_at' => now()->subDays(200)])->saveQuietly();
$recent = $this->createIdentityLog(IdentityVerificationStatus::Verified->value, now()->subDays(10));
$recent->forceFill(['created_at' => now()->subDays(10)])->saveQuietly();
$this->artisan('identity:prune-logs', ['--days' => 180])->assertSuccessful();
$this->assertDatabaseMissing('identity_verification_logs', ['id' => $old->id]);
$this->assertDatabaseHas('identity_verification_logs', ['id' => $recent->id]);
}
/**
* B7: activity-log:prune — 보존 기간 초과 로그만 삭제.
*/
public function test_activity_log_prune_removes_only_expired_rows(): void
{
$old = ActivityLog::create([
'log_type' => 'system',
'action' => 'test.old',
'created_at' => now()->subDays(400),
]);
$recent = ActivityLog::create([
'log_type' => 'system',
'action' => 'test.recent',
'created_at' => now()->subDays(10),
]);
$this->artisan('activity-log:prune', ['--days' => 365])->assertSuccessful();
$this->assertDatabaseMissing('activity_logs', ['id' => $old->id]);
$this->assertDatabaseHas('activity_logs', ['id' => $recent->id]);
}
/**
* B8: notification-log:prune — 보존 기간 초과 발송 이력만 삭제.
*/
public function test_notification_log_prune_removes_only_expired_rows(): void
{
$old = NotificationLog::create([
'channel' => 'mail',
'notification_type' => 'test',
'recipient_identifier' => 'a@example.com',
'status' => 'sent',
]);
$old->forceFill(['created_at' => now()->subDays(120)])->saveQuietly();
$recent = NotificationLog::create([
'channel' => 'mail',
'notification_type' => 'test',
'recipient_identifier' => 'b@example.com',
'status' => 'sent',
]);
$recent->forceFill(['created_at' => now()->subDays(10)])->saveQuietly();
$this->artisan('notification-log:prune', ['--days' => 90])->assertSuccessful();
$this->assertDatabaseMissing('notification_logs', ['id' => $old->id]);
$this->assertDatabaseHas('notification_logs', ['id' => $recent->id]);
}
/**
* 자동 파기는 운영자 일괄 삭제와 별개의 훅을 발행한다.
*
* 운영자 삭제 훅(`*.before_delete_many` 등)에는 본인인증 같은 대화형 가드가 물려
* 있어 무인 예약이 탈 수 없다. 그렇다고 훅 없이 지우면 확장은 가장 큰 삭제 경로를
* 볼 방법이 사라진다 — 전용 훅이 그 자리를 대신한다.
*
* @effects automated_prune_emits_its_own_hooks
*/
public function test_automated_prune_emits_its_own_hooks(): void
{
$fired = [];
foreach ([
'core.activity_log.before_prune',
'core.activity_log.after_prune',
'core.notification_log.before_prune',
'core.notification_log.after_prune',
'core.schedule.before_prune_history',
'core.schedule.after_prune_history',
] as $hook) {
HookManager::addAction($hook, function () use (&$fired, $hook) {
$fired[] = $hook;
});
}
$this->artisan('activity-log:prune', ['--days' => 365])->assertSuccessful();
$this->artisan('notification-log:prune', ['--days' => 90])->assertSuccessful();
$this->artisan('schedules:prune-history', ['--days' => 90])->assertSuccessful();
$this->assertSame([
'core.activity_log.before_prune',
'core.activity_log.after_prune',
'core.notification_log.before_prune',
'core.notification_log.after_prune',
'core.schedule.before_prune_history',
'core.schedule.after_prune_history',
], $fired, '자동 파기 커맨드가 도메인 계층을 건너뛰면 이 훅들이 발행되지 않습니다.');
}
/**
* 보존 기간 하한은 도메인 계층이 소유한다 — `--days=0` 이어도 오늘 데이터는 남는다.
*
* 커맨드가 클램프를 들고 있으면 다른 호출자가 생길 때 그 보호가 따라오지 않는다.
*
* @effects retention_floor_is_owned_by_the_domain_layer
*/
public function test_retention_floor_is_enforced_below_the_command(): void
{
$today = ActivityLog::create([
'log_type' => 'system',
'action' => 'test.today',
'description' => '오늘 기록',
]);
$this->artisan('activity-log:prune', ['--days' => 0])->assertSuccessful();
$this->assertDatabaseHas('activity_logs', ['id' => $today->id]);
}
/**
* 본인인증 로그 생성 헬퍼
*
* @param string $status 상태값
* @param Carbon $expiresAt 만료 시각
* @return IdentityVerificationLog 생성된 로그
*/
private function createIdentityLog(string $status, $expiresAt): IdentityVerificationLog
{
return IdentityVerificationLog::create([
'id' => (string) Str::uuid(),
'provider_id' => 'test-provider',
'purpose' => 'signup',
'channel' => 'sms',
'target_hash' => hash('sha256', Str::random()),
'status' => $status,
'expires_at' => $expiresAt,
]);
}
}
@@ -0,0 +1,297 @@
<?php
namespace Tests\Feature\Services;
use App\Enums\PermissionType;
use App\Enums\UserStatus;
use App\Extension\HookManager;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use App\Services\UserService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Validation\ValidationException;
use Tests\TestCase;
/**
* 관리자 탈퇴 경로 통일 테스트 (공개이슈 #112 전수 해소분)
*
* 관리자가 상태만 '탈퇴'로 바꾸는 경로는 상태 컬럼만 갱신해 익명화와
* before/after_withdraw 훅을 통째로 건너뛰었다. 본인 탈퇴와 결과가 같아야 한다.
*
* @scenario entry_point=admin_update_status, entry_point=admin_bulk_status, target_account=admin
*
* @effects admin_status_change_runs_full_withdrawal, admin_status_change_fires_withdraw_hooks, bulk_status_withdraw_delegates_per_user, bulk_status_reports_failed_count, admin_account_withdraw_blocked_consistently
*/
class AdminWithdrawPathUnificationTest extends TestCase
{
use RefreshDatabase;
/**
* W8: 단건 상태 변경으로 탈퇴시켜도 익명화와 훅이 동일하게 수행된다.
*/
public function test_update_user_with_withdrawn_status_runs_full_withdrawal(): void
{
$user = User::factory()->create([
'email' => 'g7fix112.w8@example.com',
'nickname' => '닉네임',
]);
$user->createToken('t');
$fired = $this->captureWithdrawHooks();
app(UserService::class)->updateUser($user, [
'status' => UserStatus::Withdrawn->value,
]);
$user->refresh();
$this->assertSame(UserStatus::Withdrawn->value, $user->status);
$this->assertNotNull($user->withdrawn_at);
$this->assertStringContainsString('_deleted_', $user->email);
$this->assertStringEndsWith('_탈퇴', $user->nickname);
$this->assertSame(0, $user->tokens()->count());
$this->assertTrue($fired['before'], 'before_withdraw 훅이 발화해야 합니다.');
$this->assertTrue($fired['after'], 'after_withdraw 훅이 발화해야 합니다.');
}
/**
* W8: 같은 요청의 다른 필드 갱신도 함께 반영된다.
*/
public function test_update_user_with_withdrawn_status_still_applies_other_fields(): void
{
$user = User::factory()->create(['email' => 'g7fix112.w8b@example.com']);
app(UserService::class)->updateUser($user, [
'status' => UserStatus::Withdrawn->value,
'admin_memo' => '관리자 처리',
]);
$user->refresh();
$this->assertSame('관리자 처리', $user->admin_memo);
$this->assertSame(UserStatus::Withdrawn->value, $user->status);
}
/**
* 탈퇴가 실패하면 같은 요청의 프로필 갱신도 함께 되돌아간다.
*
* 프로필 갱신과 탈퇴를 따로 커밋하면, 탈퇴가 실패했을 때 이름·메모 변경만 남아
* "전부 성공하거나 전부 취소" 가 이 경로에서만 깨진다. 익명화 결과와 같은 이메일을
* 미리 점유해 마지막 쓰기(withdraw)를 실패시킨다.
*
* @effects admin_status_change_is_atomic_with_profile_update
*/
public function test_failed_withdrawal_rolls_back_the_profile_update(): void
{
$user = User::factory()->create([
'email' => 'g7fix112.w8c@example.com',
'name' => '원래 이름',
]);
// withdraw() 가 만들 이메일을 미리 점유 → unique 충돌로 마지막 쓰기 실패
User::factory()->create([
'email' => 'g7fix112.w8c@example.com_deleted_'.now()->format('Ymd').'_'.$user->id,
]);
try {
app(UserService::class)->updateUser($user, [
'status' => UserStatus::Withdrawn->value,
'name' => '바뀐 이름',
]);
$this->fail('탈퇴 실패 시 예외가 전파되어야 합니다.');
} catch (ValidationException $e) {
// 기대한 실패
}
$user->refresh();
$this->assertSame('원래 이름', $user->name, '탈퇴가 실패했는데 이름 변경이 남았습니다.');
$this->assertNotSame(UserStatus::Withdrawn->value, $user->status);
}
/**
* W9: 일괄 상태 변경도 건별 정식 탈퇴로 처리되고, 차단 대상은 실패 건수로 보고된다.
*/
public function test_bulk_status_withdrawn_delegates_per_user_and_reports_failures(): void
{
$normal = User::factory()->create(['email' => 'g7fix112.w9a@example.com']);
$admin = $this->createAdminUser('g7fix112.w9b@example.com');
$result = app(UserService::class)->bulkUpdateStatus(
[$normal->uuid, $admin->uuid],
UserStatus::Withdrawn->value
);
$this->assertSame(1, $result['updated_count']);
$this->assertSame(1, $result['failed_count']);
$normal->refresh();
$admin->refresh();
$this->assertStringContainsString('_deleted_', $normal->email);
$this->assertSame('g7fix112.w9b@example.com', $admin->email, '관리자는 변경되지 않아야 합니다.');
$this->assertNull($admin->withdrawn_at);
// 건수만으로는 화면이 "왜 안 됐는지" 를 말할 수 없다 — 사유가 응답에 실려야 한다.
$this->assertNotEmpty($result['failed_reasons'] ?? [], '실패 사유가 응답에 없습니다.');
$this->assertSame(
[__('user.withdraw_admin_forbidden')],
$result['failed_reasons'],
'관리자 차단 사유가 그대로 전달되어야 합니다.'
);
}
/**
* W9-b: 전건이 차단되어도 200 으로 나가므로, 응답만으로 "아무 일도 없었음" 이 구분되어야 한다.
*
* 이 구분이 없으면 화면은 성공 안내를 띄우고 운영자는 처리된 줄 안다 (실제 제보 경로).
*/
public function test_bulk_status_withdrawn_reports_zero_updated_when_every_target_is_blocked(): void
{
$admin = $this->createAdminUser('g7fix112.w9c@example.com');
$result = app(UserService::class)->bulkUpdateStatus(
[$admin->uuid],
UserStatus::Withdrawn->value
);
$this->assertSame(0, $result['updated_count']);
$this->assertSame(1, $result['failed_count']);
$this->assertSame([__('user.withdraw_admin_forbidden')], $result['failed_reasons']);
$admin->refresh();
$this->assertSame('g7fix112.w9c@example.com', $admin->email);
$this->assertSame(UserStatus::Active->value, $admin->status);
}
/**
* W9-c: 탈퇴가 아닌 일괄 상태 변경도 같은 응답 형태를 유지한다.
*
* 형태가 갈리면 화면 표현식이 한쪽 경로에서만 undefined 를 만난다.
*/
public function test_bulk_status_response_shape_is_consistent_for_non_withdraw_statuses(): void
{
$user = User::factory()->create(['email' => 'g7fix112.w9d@example.com']);
$result = app(UserService::class)->bulkUpdateStatus(
[$user->uuid],
UserStatus::Inactive->value
);
$this->assertArrayHasKey('updated_count', $result);
$this->assertArrayHasKey('failed_count', $result);
$this->assertArrayHasKey('failed_reasons', $result);
$this->assertSame([], $result['failed_reasons']);
}
/**
* W9-d: 실패 사유에는 화면 문구 조립을 깨는 문자가 들어가지 않는다.
*
* 사유는 `$t:key|reason=...` 파라미터로 전달되는데, 파서가 `|` `&` `=` 를
* 구분자로 읽으므로 그 문자가 섞이면 안내가 조용히 잘린다. 알 수 없는 예외의
* 원문 메시지를 그대로 싣지 않는 것이 이 불변식을 지키는 방법이다.
*/
public function test_bulk_withdraw_reasons_are_safe_for_message_parameters(): void
{
$admin = $this->createAdminUser('g7fix112.w9e@example.com');
$result = app(UserService::class)->bulkUpdateStatus(
[$admin->uuid, 'ffffffff-0000-0000-0000-000000000000'],
UserStatus::Withdrawn->value
);
$this->assertNotEmpty($result['failed_reasons']);
foreach ($result['failed_reasons'] as $reason) {
$this->assertDoesNotMatchRegularExpression(
'/[|&=]/u',
$reason,
"실패 사유에 파라미터 구분자가 들어 있습니다: {$reason}"
);
}
}
/**
* W9: 탈퇴 외 상태의 일괄 변경은 종전 동작을 유지한다.
*/
public function test_bulk_status_other_status_keeps_previous_behaviour(): void
{
$user = User::factory()->create(['email' => 'g7fix112.w9c@example.com']);
$result = app(UserService::class)->bulkUpdateStatus(
[$user->uuid],
UserStatus::Inactive->value
);
$this->assertSame(1, $result['updated_count']);
$user->refresh();
$this->assertSame(UserStatus::Inactive->value, $user->status);
$this->assertSame('g7fix112.w9c@example.com', $user->email);
}
/**
* W10: 관리자를 상태 변경으로 탈퇴시키려는 시도는 일관되게 차단된다.
*/
public function test_update_user_cannot_withdraw_admin_via_status(): void
{
$admin = $this->createAdminUser('g7fix112.w10@example.com');
$this->expectException(ValidationException::class);
try {
app(UserService::class)->updateUser($admin, [
'status' => UserStatus::Withdrawn->value,
'admin_memo' => '차단되어야 함',
]);
} finally {
$admin->refresh();
// 차단 대상이면 다른 필드도 저장되지 않아야 한다.
$this->assertSame('g7fix112.w10@example.com', $admin->email);
$this->assertNotSame(UserStatus::Withdrawn->value, $admin->status);
$this->assertNull($admin->admin_memo);
}
}
/**
* 탈퇴 훅 발화 여부를 수집합니다.
*
* @return \ArrayObject<string, bool> 훅 발화 기록 (참조로 갱신됨)
*/
private function captureWithdrawHooks(): \ArrayObject
{
// 배열은 값 타입이라 반환하면 사본이 된다 — 참조 의미를 갖는 객체로 수집한다.
$fired = new \ArrayObject(['before' => false, 'after' => false]);
HookManager::addAction('core.user.before_withdraw', function () use ($fired) {
$fired['before'] = true;
}, 10);
HookManager::addAction('core.user.after_withdraw', function () use ($fired) {
$fired['after'] = true;
}, 10);
return $fired;
}
/**
* 관리자 역할을 가진 사용자를 생성합니다.
*
* @param string $email 이메일
* @return User 생성된 사용자
*/
private function createAdminUser(string $email): User
{
$role = Role::factory()->create(['identifier' => 'admin_'.uniqid()]);
$permission = Permission::factory()->create(['type' => PermissionType::Admin]);
$role->permissions()->attach($permission);
$user = User::factory()->create(['email' => $email]);
$user->roles()->attach($role);
return $user->fresh();
}
}
@@ -0,0 +1,320 @@
<?php
namespace Tests\Feature\Services;
use App\Contracts\Repositories\AttachmentRepositoryInterface;
use App\Contracts\Repositories\MenuRepositoryInterface;
use App\Contracts\Repositories\PasswordResetTokenRepositoryInterface;
use App\Contracts\Repositories\RoleRepositoryInterface;
use App\Contracts\Repositories\ScheduleRepositoryInterface;
use App\Contracts\Repositories\UserRepositoryInterface;
use App\Enums\AttachmentSourceType;
use App\Enums\PermissionType;
use App\Models\Attachment;
use App\Models\Menu;
use App\Models\PasswordResetToken;
use App\Models\Permission;
use App\Models\Role;
use App\Models\Schedule;
use App\Models\ScheduleHistory;
use App\Models\User;
use App\Services\AttachmentService;
use App\Services\AuthService;
use App\Services\MenuService;
use App\Services\RoleService;
use App\Services\ScheduleService;
use App\Services\UserService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
use Mockery;
use PHPUnit\Framework\AssertionFailedError;
use RuntimeException;
use Tests\TestCase;
/**
* 다단계 쓰기 원자성 회귀 테스트 (공개이슈 #112 동종 전수 해소분)
*
* 표준 패턴: 마지막 쓰기를 담당하는 Repository 를 컨테이너 목킹으로 실패시키고,
* 선행 단계가 원상 복구되는지 단언합니다.
*
* @scenario failure_injection=last_write_fails
*
* @effects withdraw_is_atomic_all_or_nothing, avatar_file_removed_only_after_commit
*/
class MultiStepWriteAtomicityTest extends TestCase
{
use RefreshDatabase;
/**
* deleteUser: 마지막 delete 실패 시 역할·동의·토큰·아바타 파일이 원상 복구된다.
*/
public function test_delete_user_rolls_back_roles_and_tokens(): void
{
Storage::fake('attachments');
$user = User::factory()->create(['email' => 'atomic.delete@example.com']);
$role = Role::factory()->create(['identifier' => 'member_'.uniqid()]);
$user->roles()->attach($role);
$user->createToken('t');
$user->consents()->create([
'consent_type' => 'terms',
'is_agreed' => true,
'agreed_at' => now(),
]);
// 아바타는 커밋 후에만 지워야 한다 — 롤백된 계정의 파일이 사라지면 복구 불가다.
$avatar = Attachment::factory()->create([
'attachmentable_type' => User::class,
'attachmentable_id' => $user->id,
'collection' => 'avatar',
'disk' => 'attachments',
'path' => 'attachments/avatars/atomic-delete.jpg',
'source_type' => AttachmentSourceType::Core,
]);
Storage::disk('attachments')->put('attachments/avatars/atomic-delete.jpg', 'fake');
$this->mockRepositoryFailure(UserRepositoryInterface::class, 'delete');
$this->expectRollbackFailure(fn () => app(UserService::class)->deleteUser($user));
$this->assertDatabaseHas('users', ['id' => $user->id]);
$this->assertSame(1, $user->roles()->count(), '역할 연결이 복구되어야 합니다.');
$this->assertSame(1, $user->tokens()->count(), '토큰이 복구되어야 합니다.');
$this->assertSame(1, $user->consents()->count(), '동의 이력이 복구되어야 합니다.');
Storage::disk('attachments')->assertExists('attachments/avatars/atomic-delete.jpg');
$this->assertDatabaseHas('attachments', ['id' => $avatar->id]);
}
/**
* deleteRole: 마지막 delete 실패 시 사용자·권한 연결이 원상 복구된다.
*/
public function test_delete_role_rolls_back_relations(): void
{
$role = Role::factory()->create(['identifier' => 'member_'.uniqid()]);
$user = User::factory()->create(['email' => 'atomic.role@example.com']);
$user->roles()->attach($role);
$this->mockRepositoryFailure(RoleRepositoryInterface::class, 'delete');
$this->expectRollbackFailure(fn () => app(RoleService::class)->deleteRole($role));
$this->assertDatabaseHas('roles', ['id' => $role->id]);
$this->assertSame(1, $role->users()->count(), '역할 보유자가 복구되어야 합니다.');
}
/**
* register: 중간 실패 시 계정이 남지 않는다 (이메일 점유 유령 계정 차단).
*/
public function test_register_rolls_back_user_on_failure(): void
{
$this->mockRepositoryFailure(RoleRepositoryInterface::class, 'findByIdentifier');
$this->expectRollbackFailure(fn () => app(AuthService::class)->register([
'name' => '테스트',
'email' => 'atomic.register@example.com',
'password' => 'Password!234',
]));
$this->assertDatabaseMissing('users', ['email' => 'atomic.register@example.com']);
}
/**
* resetPassword: 토큰 삭제 실패 시 비밀번호도 원상 복구된다 (토큰 재사용 차단).
*/
public function test_reset_password_rolls_back_password_when_token_delete_fails(): void
{
$user = User::factory()->create([
'email' => 'atomic.reset@example.com',
'password' => Hash::make('OldPassword!1'),
]);
$originalHash = $user->password;
$token = 'reset-token-value';
$hashedToken = Hash::make($token);
app(PasswordResetTokenRepositoryInterface::class)
->updateOrCreateByEmail($user->email, [
'token' => $hashedToken,
'created_at' => now(),
]);
// 트랜잭션의 **마지막** 쓰기는 토큰 삭제다. 1단계(비밀번호 갱신)를 실패시키면
// 트랜잭션이 없어도 통과하므로 회귀를 검출하지 못한다.
$failingRecord = new class extends PasswordResetToken
{
public function delete(): bool
{
throw new RuntimeException('마지막 쓰기 실패');
}
};
$failingRecord->forceFill([
'email' => $user->email,
'token' => $hashedToken,
'created_at' => now(),
])->syncOriginal();
$tokenRepository = Mockery::mock(app(PasswordResetTokenRepositoryInterface::class));
$tokenRepository->shouldReceive('findByEmail')->andReturn($failingRecord);
$this->app->instance(PasswordResetTokenRepositoryInterface::class, $tokenRepository);
$this->expectRollbackFailure(
fn () => app(AuthService::class)->resetPassword($token, $user->email, 'NewPassword!1')
);
$user->refresh();
$this->assertSame($originalHash, $user->password);
$this->assertDatabaseHas('password_reset_tokens', ['email' => $user->email]);
}
/**
* AttachmentService::delete — DB 삭제 실패 시 파일이 남는다 (dangling 없음).
*/
public function test_attachment_delete_keeps_file_when_db_delete_fails(): void
{
Storage::fake('attachments');
$attachment = Attachment::factory()->create([
'disk' => 'attachments',
'path' => 'attachments/atomic.jpg',
'source_type' => AttachmentSourceType::Core,
]);
Storage::disk('attachments')->put('attachments/atomic.jpg', 'fake');
$this->mockRepositoryFailure(AttachmentRepositoryInterface::class, 'forceDelete');
$this->expectRollbackFailure(fn () => app(AttachmentService::class)->delete($attachment->id));
Storage::disk('attachments')->assertExists('attachments/atomic.jpg');
$this->assertDatabaseHas('attachments', ['id' => $attachment->id]);
}
/**
* ScheduleService::delete — 스케줄 삭제 실패 시 실행 이력이 복구된다.
*/
public function test_schedule_delete_rolls_back_histories(): void
{
$schedule = Schedule::create([
'name' => '원자성 테스트',
'type' => 'artisan',
'command' => 'inspire',
'expression' => '0 4 * * *',
'frequency' => 'daily',
'is_active' => true,
]);
ScheduleHistory::create([
'schedule_id' => $schedule->id,
'started_at' => now(),
'status' => 'success',
]);
$this->mockRepositoryFailure(ScheduleRepositoryInterface::class, 'delete');
$this->expectRollbackFailure(fn () => app(ScheduleService::class)->delete($schedule));
$this->assertDatabaseHas('schedules', ['id' => $schedule->id]);
$this->assertSame(1, $schedule->histories()->count(), '실행 이력이 복구되어야 합니다.');
}
/**
* updateUser — 역할 동기화 실패 시 프로필 갱신·토큰 정리가 원상 복구된다.
*/
public function test_update_user_rolls_back_profile_and_tokens(): void
{
$user = User::factory()->create([
'email' => 'atomic.update@example.com',
'name' => '원래 이름',
]);
$user->createToken('t');
// 역할 동기화는 `core.permissions.update` 권한이 있어야 수행된다 — 권한 없이 호출하면
// role_ids 가 통째로 무시되어 마지막 단계 자체가 실행되지 않는다.
$this->actingAs($this->createUserWithPermission('core.permissions.update'));
// 마지막 단계(역할 동기화)를 실패시킨다 — 존재하지 않는 역할 ID 는 외래키 제약에
// 걸린다. 그 전에 프로필 갱신과 토큰 삭제가 이미 수행된 상태다.
$missingRoleId = (int) (Role::max('id') ?? 0) + 1000;
$this->expectRollbackFailure(fn () => app(UserService::class)->updateUser($user, [
'name' => '바뀐 이름',
'status' => 'inactive',
'role_ids' => [$missingRoleId],
]));
$user->refresh();
$this->assertSame('원래 이름', $user->name, '프로필 갱신이 복구되어야 합니다.');
$this->assertSame(1, $user->tokens()->count(), '토큰이 복구되어야 합니다.');
}
/**
* MenuService::deleteMenu — 메뉴 삭제 실패 시 역할 연결이 복구된다.
*/
public function test_menu_delete_rolls_back_role_links(): void
{
$menu = Menu::factory()->create();
$role = Role::factory()->create(['identifier' => 'member_'.uniqid()]);
$menu->roles()->attach($role);
$this->mockRepositoryFailure(MenuRepositoryInterface::class, 'delete');
$this->expectRollbackFailure(fn () => app(MenuService::class)->deleteMenu($menu));
$this->assertDatabaseHas('menus', ['id' => $menu->id]);
$this->assertSame(1, $menu->roles()->count(), '역할 연결이 복구되어야 합니다.');
}
/**
* 지정한 권한을 가진 사용자를 만듭니다 (역할 경유).
*
* @param string $identifier 권한 식별자
* @return User 권한 보유 사용자
*/
private function createUserWithPermission(string $identifier): User
{
$permission = Permission::factory()->create([
'identifier' => $identifier,
'type' => PermissionType::Admin,
]);
$role = Role::factory()->create(['identifier' => 'perm_'.uniqid()]);
$role->permissions()->attach($permission);
$actor = User::factory()->create(['email' => 'atomic.actor.'.uniqid().'@example.com']);
$actor->roles()->attach($role);
return $actor;
}
/**
* 지정한 Repository 메서드만 실패시키고 나머지는 실제 구현에 위임합니다.
*
* @param class-string $interface Repository 인터페이스
* @param string $method 실패시킬 메서드
*/
private function mockRepositoryFailure(string $interface, string $method): void
{
// 실제 인스턴스를 감싸는 프록시 부분 목 — 지정 메서드만 실패시키고
// 나머지 호출은 실제 구현으로 그대로 흘려보낸다.
$mock = Mockery::mock(app($interface));
$mock->shouldReceive($method)->andThrow(new RuntimeException('마지막 쓰기 실패'));
$this->app->instance($interface, $mock);
}
/**
* 롤백을 유발하는 호출이 예외로 끝나는지 확인합니다.
*
* @param callable $operation 실행할 동작
*/
private function expectRollbackFailure(callable $operation): void
{
try {
$operation();
$this->fail('마지막 쓰기 실패로 예외가 발생해야 합니다.');
} catch (\Throwable $e) {
$this->assertNotInstanceOf(AssertionFailedError::class, $e);
}
}
}
@@ -0,0 +1,354 @@
<?php
namespace Tests\Feature\Settings;
use App\Contracts\Extension\PluginInterface;
use App\Contracts\Extension\StorageInterface;
use App\Extension\PluginManager;
use App\Providers\SettingsServiceProvider;
use App\Repositories\JsonConfigRepository;
use App\Services\PluginSettingsService;
use App\Services\SettingsService;
use App\Support\ExtensionSettingsMirror;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Config;
use Mockery;
use Modules\Sirsoft\Page\Services\PageSettingsService;
use Tests\TestCase;
/**
* 설정 config 미러 재채움 회귀 테스트 (공개이슈 #109)
*
* `g7_settings.*` 미러는 종전에 부팅 때 한 번만 채워졌다. FPM 은 요청마다 부팅해
* 문제가 드러나지 않지만, 큐 워커·schedule:work·Reverb 처럼 상주하는 프로세스에서는
* 관리자가 저장해도 그 프로세스가 옛 값을 영원히 읽는다.
*
* 아래 테스트는 **한 프로세스 안에서** 저장 후 미러가 갱신되는지를 본다 —
* 재부팅으로는 결함이 드러나지 않기 때문이다.
*
* @scenario scope=core, scope=plugin, trigger=save, trigger=boot, field_sensitivity=sensitive
*
* @effects core_mirror_refreshed_after_save, core_mirror_refreshed_after_set_setting, plugin_mirror_refreshed_after_save, plugin_mirror_merges_defaults, sensitive_fields_absent_from_mirror_on_boot, sensitive_fields_absent_from_mirror_on_refresh, sensitive_fields_still_readable_via_dedicated_getter, page_module_mirror_exists
*/
class SettingsConfigMirrorTest extends TestCase
{
use RefreshDatabase;
/**
* A1: 코어 설정 저장 후 같은 프로세스의 미러가 신값을 읽는다.
*/
public function test_core_settings_mirror_is_refreshed_after_save(): void
{
Config::set('g7_settings.core.general.site_name', '옛 이름');
app(SettingsService::class)->saveSettings([
'general' => ['site_name' => '새 이름'],
]);
$this->assertSame('새 이름', g7_core_settings('general.site_name'));
}
/**
* A1: setSetting() 경로도 같은 무효화 처리를 받는다.
*/
public function test_set_setting_also_refreshes_core_mirror(): void
{
Config::set('g7_settings.core.general.site_name', '옛 이름');
app(SettingsService::class)->setSetting('general.site_name', '단건 저장 이름');
$this->assertSame('단건 저장 이름', g7_core_settings('general.site_name'));
}
/**
* A1/A3: 부팅 경로도 컨테이너의 미러 인스턴스를 거친다.
*
* 부팅과 저장이 서로 다른 인스턴스를 만들면 한쪽 구현만 고쳐도 초록으로 남는다 —
* 컨테이너 단일 해석이 "두 경로가 같은 코드를 쓴다" 의 실질적 근거다.
*
* @effects boot_and_save_share_one_fill_implementation
*/
public function test_boot_path_fills_the_mirror_through_the_container_instance(): void
{
$spy = new class extends ExtensionSettingsMirror
{
/** @var int refreshCore 호출 횟수 */
public int $coreRefreshes = 0;
public function refreshCore(?JsonConfigRepository $configRepository = null): void
{
$this->coreRefreshes++;
}
};
$this->app->instance(ExtensionSettingsMirror::class, $spy);
$provider = new SettingsServiceProvider($this->app);
$load = new \ReflectionMethod($provider, 'loadCoreSettingsToConfig');
$load->setAccessible(true);
$load->invoke($provider, new JsonConfigRepository);
$this->assertSame(
1,
$spy->coreRefreshes,
'부팅 경로가 컨테이너 미러를 거치지 않고 자체 인스턴스를 만듭니다'
);
}
/**
* A2/A4: 플러그인 미러는 defaults 병합·복호화를 마친 값(전용 게터와 같은 형태)을 담는다.
*
* @effects plugin_mirror_matches_dedicated_getter_shape
*/
public function test_plugin_settings_mirror_uses_the_service_value_not_raw_file(): void
{
$this->bindStubPlugin([
'api_key' => ['sensitive' => true],
'endpoint' => ['sensitive' => false],
], [
'api_key' => 'super-secret-value',
'endpoint' => 'https://example.com',
'from_defaults' => 'default-value',
]);
app(ExtensionSettingsMirror::class)->refreshPlugin('stub-plugin');
$mirror = g7_plugin_settings('stub-plugin');
$this->assertIsArray($mirror);
// raw 파일 읽기였다면 defaults 만 있는 키는 미러에 없다
$this->assertSame('default-value', $mirror['from_defaults'] ?? null);
$this->assertSame('https://example.com', $mirror['endpoint'] ?? null);
// 키 몇 개만 보면 형태가 갈려도 통과한다 — 전용 게터 결과에서 민감 키만 뺀 것과
// 정확히 같아야 한다(형태 동일성).
$viaGetter = (array) app(PluginSettingsService::class)->get('stub-plugin');
$this->assertSame(
Arr::except($viaGetter, ['api_key']),
$mirror,
'미러가 전용 게터와 다른 형태의 값을 담고 있습니다'
);
}
/**
* A6/A7: 민감 필드는 미러에 담기지 않는다 (건별 재채움 / 전체 재채움 양쪽).
*
* 미러는 봇 전용 화면 생성기가 키 제한 없이 조회하는 경로이므로, 민감값이 실리면
* 레이아웃이 그 키를 참조하는 순간 평문이 봇 HTML 로 나간다.
*/
public function test_sensitive_plugin_fields_never_reach_the_mirror(): void
{
$this->bindStubPlugin([
'api_key' => ['sensitive' => true],
'endpoint' => ['sensitive' => false],
], [
'api_key' => 'super-secret-value',
'endpoint' => 'https://example.com',
]);
$mirror = app(ExtensionSettingsMirror::class);
$mirror->refreshPlugin('stub-plugin');
$this->assertArrayNotHasKey('api_key', (array) g7_plugin_settings('stub-plugin'));
$this->assertArrayNotHasKey('api_key', (array) Config::get('g7_settings.plugins.stub-plugin'));
$this->assertSame('https://example.com', g7_plugin_settings('stub-plugin', 'endpoint'));
// 부팅 경로(전체 재채움)에서도 동일 — 한쪽만 막고 다른 쪽에서 새는 회귀 차단
$mirror->refreshAllPlugins();
$this->assertArrayNotHasKey('api_key', (array) g7_plugin_settings('stub-plugin'));
// 전용 게터 경로는 살아 있다 (기능 손실 없음)
$this->assertSame('super-secret-value', app(PluginSettingsService::class)->get('stub-plugin', 'api_key'));
}
/**
* A2/A7: 플러그인 저장 경로가 미러 재채움을 호출하고, 그 결과에 민감값이 없다.
*
* @effects sensitive_fields_absent_from_mirror_after_save
*/
public function test_plugin_save_triggers_mirror_refresh(): void
{
$spy = new class extends ExtensionSettingsMirror
{
/** @var array<int, string> 재채움 요청을 받은 식별자 */
public array $refreshed = [];
public function refreshPlugin(string $identifier): void
{
$this->refreshed[] = $identifier;
}
};
$this->app->instance(ExtensionSettingsMirror::class, $spy);
$this->bindStubPlugin([
'endpoint' => ['sensitive' => false],
'api_key' => ['sensitive' => true],
], [
'endpoint' => 'https://example.com',
'api_key' => 'initial-secret',
]);
app(PluginSettingsService::class)->save('stub-plugin', [
'endpoint' => 'https://changed.example.com',
'api_key' => 'rotated-secret',
]);
$this->assertContains('stub-plugin', $spy->refreshed, '플러그인 저장이 미러를 갱신하지 않았습니다');
// 호출만 세면 재채움이 빈 값을 써도 통과한다 — 실제 미러에 신값이 담기는지 함께 본다.
$this->app->forgetInstance(ExtensionSettingsMirror::class);
app(ExtensionSettingsMirror::class)->refreshPlugin('stub-plugin');
$this->assertSame(
'https://changed.example.com',
g7_plugin_settings('stub-plugin', 'endpoint'),
'재채움이 저장된 신값을 담지 않았습니다'
);
// 실제 저장 경로를 거친 뒤에도 민감값은 미러에 실리지 않는다 —
// 직접 refreshPlugin() 만 호출한 케이스는 저장이 만든 페이로드를 통과시키지 않는다.
$mirror = (array) g7_plugin_settings('stub-plugin');
$this->assertArrayNotHasKey('api_key', $mirror);
$this->assertStringNotContainsString(
'rotated-secret',
(string) json_encode($mirror, JSON_UNESCAPED_UNICODE),
'저장 직후 재채움 경로로 민감값이 미러에 실렸습니다'
);
// 전용 게터로는 회전된 신값이 읽힌다 (기능 손실 없음).
$this->assertSame('rotated-secret', app(PluginSettingsService::class)->get('stub-plugin', 'api_key'));
}
/**
* A8: 백업 복원도 미러를 다시 채운다.
*
* 복원은 설정 전체를 갈아엎는 쓰기다. 캐시만 비우면 같은 프로세스가 복원 전 값을
* 계속 읽어, 운영자가 "복원했는데 아무것도 안 바뀐" 상태를 본다.
*
* @effects core_mirror_refreshed_after_restore
*/
public function test_restore_refreshes_the_core_mirror(): void
{
$service = app(SettingsService::class);
$service->saveSettings(['general' => ['site_name' => '복원 전 이름']]);
$backupPath = $service->backupSettings();
$service->saveSettings(['general' => ['site_name' => '복원 후 바뀐 이름']]);
$this->assertSame('복원 후 바뀐 이름', g7_core_settings('general.site_name'));
$this->assertTrue($service->restoreSettings($backupPath));
$this->assertSame(
'복원 전 이름',
g7_core_settings('general.site_name'),
'복원 후에도 미러가 복원 전 값을 유지합니다'
);
}
/**
* A9: 플러그인 설정 초기화도 미러를 다시 채운다.
*
* @effects plugin_mirror_refreshed_after_reset
*/
public function test_plugin_reset_refreshes_the_mirror(): void
{
$this->bindStubPlugin(
['endpoint' => ['sensitive' => false]],
['endpoint' => 'https://default.example.com']
);
$service = app(PluginSettingsService::class);
$service->save('stub-plugin', ['endpoint' => 'https://saved.example.com']);
$this->assertSame('https://saved.example.com', g7_plugin_settings('stub-plugin', 'endpoint'));
$service->reset('stub-plugin');
$this->assertSame(
'https://default.example.com',
g7_plugin_settings('stub-plugin', 'endpoint'),
'초기화 후에도 미러가 초기화 전 값을 유지합니다'
);
}
/**
* A5: sirsoft-page 미러가 존재하고 defaults.json 값을 담는다.
*/
public function test_page_module_mirror_exists(): void
{
if (! class_exists(PageSettingsService::class)) {
$this->markTestSkipped('sirsoft-page 모듈이 설치되어 있지 않습니다.');
}
Config::set('g7_settings.modules.sirsoft-page', null);
app(ExtensionSettingsMirror::class)->refreshModule('sirsoft-page');
$expected = app(PageSettingsService::class)
->getSetting('attachment.max_count');
$this->assertNotNull($expected, 'sirsoft-page 설정 서비스가 값을 돌려주지 않습니다');
$this->assertSame(
$expected,
g7_module_settings('sirsoft-page', 'attachment.max_count'),
'sirsoft-page 미러가 설정 서비스와 다른 값을 담고 있습니다'
);
}
/**
* 스키마/기본값을 지정한 스텁 플러그인을 활성 플러그인으로 등록합니다.
*
* 어떤 플러그인이 설치돼 있느냐에 결과가 좌우되지 않도록 통제된 스키마를 쓴다 —
* 검증 대상은 미러의 민감 필드 제거·값 형태 로직 자체다.
*
* @param array<string, array<string, mixed>> $schema 설정 스키마
* @param array<string, mixed> $defaults 설정 기본값
*/
private function bindStubPlugin(array $schema, array $defaults): void
{
// 저장 내용을 실제로 보관하는 인메모리 스토리지 — put 한 값이 get 으로 돌아와야
// "저장 → 재채움 → 신값" 사슬을 값 수준에서 검증할 수 있다.
$written = new \ArrayObject;
$storage = Mockery::mock(StorageInterface::class);
$storage->shouldReceive('exists')->andReturnUsing(
fn ($dir, $file) => isset($written[$file])
);
$storage->shouldReceive('get')->andReturnUsing(
fn ($dir, $file) => $written[$file] ?? ''
);
$storage->shouldReceive('put')->andReturnUsing(function ($dir, $file, $content) use ($written) {
$written[$file] = $content;
return true;
});
$storage->shouldReceive('delete')->andReturnUsing(function ($dir, $file) use ($written) {
unset($written[$file]);
return true;
});
$plugin = Mockery::mock(PluginInterface::class);
$plugin->shouldReceive('getStorage')->andReturn($storage);
$plugin->shouldReceive('getIdentifier')->andReturn('stub-plugin');
$plugin->shouldReceive('getSettingsSchema')->andReturn($schema);
$plugin->shouldReceive('getConfigValues')->andReturn($defaults);
$plugin->shouldReceive('hasSettings')->andReturn(true);
$plugin->shouldReceive('getSettingsDefaultsPath')->andReturn(null);
$pluginManager = Mockery::mock(PluginManager::class);
$pluginManager->shouldReceive('getPlugin')->with('stub-plugin')->andReturn($plugin);
$pluginManager->shouldReceive('getPlugin')->andReturn(null);
$pluginManager->shouldReceive('getActivePlugins')->andReturn(['stub-plugin' => $plugin]);
$this->app->instance(PluginManager::class, $pluginManager);
$this->app->forgetInstance(PluginSettingsService::class);
}
}
@@ -0,0 +1,305 @@
<?php
namespace Tests\Feature\Validation;
use App\Enums\ExtensionOwnerType;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\App;
use Tests\TestCase;
/**
* 전역 validation attributes 라벨 정화 회귀 테스트 (공개이슈 #113)
*
* 전역 `validation.attributes` 는 모든 FormRequest 가 공유하는 라벨 사전입니다.
* 여기에 특정 도메인 문구(예: 'SMTP 비밀번호')를 넣으면, 그 필드명을 쓰는 모든
* 화면의 오류 문구가 오염됩니다. 이 테스트는 ① 범용 키가 범용 라벨을 유지하고,
* ② 도메인 전용 키(smtp_*, user_ids 등)가 의도 소비자에서만 쓰이는지 고정합니다.
*/
class GlobalAttributeLabelTest extends TestCase
{
use RefreshDatabase;
/**
* D1: 회원가입 password 타입 오류 문구에 SMTP 라벨이 새지 않는다.
*
* @scenario entry_point=register, attribute_label_scope=generic_password, locale=ko
*
* @effects smtp_label_never_appears_in_password_setting_paths
*/
public function test_register_password_type_error_uses_generic_password_label(): void
{
$response = $this->withHeaders(['Accept-Language' => 'ko'])
->postJson('/api/auth/register', [
'email' => 'g7fix113.d1@example.com',
'password' => 12345678,
'password_confirmation' => 12345678,
'name' => '테스트',
]);
$response->assertStatus(422);
$message = json_encode($response->json('errors.password'), JSON_UNESCAPED_UNICODE);
$this->assertStringNotContainsString('SMTP', (string) $message);
$this->assertStringContainsString('비밀번호', (string) $message);
}
/**
* D2: 비밀번호 확인 엔드포인트(messages() 미정의)의 required 문구 — 제보 문구 경로.
*
* @scenario entry_point=verify_password, attribute_label_scope=generic_password, locale=ko
*
* @effects smtp_label_never_appears_in_password_setting_paths
*/
public function test_verify_password_required_message_uses_generic_password_label(): void
{
$user = User::factory()->create();
$response = $this->withToken($user->createToken('t')->plainTextToken)
->postJson('/api/me/verify-password', []);
$response->assertStatus(422);
$message = (string) json_encode($response->json('errors.password'), JSON_UNESCAPED_UNICODE);
$this->assertStringNotContainsString('SMTP', $message);
$this->assertStringContainsString('비밀번호', $message);
}
/**
* D3: PasswordPolicy 규칙 실패 메시지의 :attribute 치환값.
*
* @scenario entry_point=register, attribute_label_scope=generic_password, input_state=violates_length, locale=ko
*
* @effects policy_message_attribute_uses_generic_password_label
*/
public function test_password_policy_message_uses_generic_password_label(): void
{
$response = $this->withHeaders(['Accept-Language' => 'ko'])
->postJson('/api/auth/register', [
'email' => 'g7fix113.d3@example.com',
'password' => 'a1',
'password_confirmation' => 'a1',
'name' => '테스트',
]);
$response->assertStatus(422);
$message = (string) json_encode($response->json('errors.password'), JSON_UNESCAPED_UNICODE);
$this->assertStringNotContainsString('SMTP', $message);
// 부정 단언만 두면 라벨이 통째로 빠져도 통과한다 — 실제 라벨이 들어갔는지 함께 본다.
$this->assertStringContainsString('비밀번호', $message);
}
/**
* D4: SMTP 연결 테스트는 도메인 라벨('SMTP 호스트'/'SMTP 비밀번호')을 유지한다.
*
* @scenario attribute_label_scope=smtp_password, locale=ko
*
* @effects smtp_screens_keep_their_domain_labels
*/
public function test_test_mail_request_keeps_smtp_labels(): void
{
$admin = $this->createAdmin();
// host 누락 (required, messages() 경로)
$response = $this->withToken($admin->createToken('t')->plainTextToken)
->postJson('/api/admin/settings/test-mail', [
'to_email' => 'a@example.com',
'from_address' => 'b@example.com',
'from_name' => '발신자',
'mailer' => 'smtp',
'port' => 587,
]);
$response->assertStatus(422);
$this->assertStringContainsString(
'SMTP',
(string) json_encode($response->json('errors.host'), JSON_UNESCAPED_UNICODE)
);
// password 배열 전송 (string 규칙, attributes() 경로)
$response = $this->withToken($admin->createToken('t2')->plainTextToken)
->postJson('/api/admin/settings/test-mail', [
'to_email' => 'a@example.com',
'from_address' => 'b@example.com',
'from_name' => '발신자',
'mailer' => 'smtp',
'host' => 'smtp.example.com',
'port' => 587,
'password' => ['x'],
]);
$response->assertStatus(422);
$this->assertStringContainsString(
'SMTP',
(string) json_encode($response->json('errors.password'), JSON_UNESCAPED_UNICODE)
);
}
/**
* D5: 사용자 일괄 상태변경은 '사용자 ID 목록', 전역 ids 라벨은 범용 문구.
*
* @scenario attribute_label_scope=user_scoped_ids, locale=ko
*
* @effects bulk_status_screen_keeps_user_scoped_ids_label, generic_ids_label_stays_generic_on_other_screens
*/
public function test_bulk_user_status_keeps_user_scoped_ids_label(): void
{
$admin = $this->createAdmin();
$response = $this->withToken($admin->createToken('t')->plainTextToken)
->patchJson('/api/admin/users/bulk-status', [
'status' => 'active',
]);
$response->assertStatus(422);
$this->assertStringContainsString(
'사용자 ID 목록',
(string) json_encode($response->json('errors.ids'), JSON_UNESCAPED_UNICODE)
);
// 전역 라벨 자체는 범용으로 원복되어 있어야 한다.
$this->assertSame('ID 목록', __('validation.attributes.ids'));
$this->assertSame('사용자 ID 목록', __('validation.attributes.user_ids'));
// 같은 전역 키를 쓰는 다른 화면은 범용 라벨을 받는다 (HTTP 경로로 확인).
// read-batch 는 ids 에 커스텀 메시지가 없어 전역 attributes 라벨이 그대로 드러난다.
$other = $this->withHeaders(['Accept-Language' => 'ko'])
->withToken($admin->createToken('t2')->plainTextToken)
->postJson('/api/admin/notifications/read-batch', []);
$other->assertStatus(422);
$otherMessage = (string) json_encode($other->json('errors.ids'), JSON_UNESCAPED_UNICODE);
$this->assertStringNotContainsString('사용자 ID 목록', $otherMessage);
$this->assertStringContainsString('ID 목록', $otherMessage);
}
/**
* D6: en / ja 로케일에서도 범용 키가 범용 라벨을 유지한다.
*
* @scenario entry_point=register, attribute_label_scope=generic_password, locale=en
*
* @effects generic_labels_hold_in_every_locale
*/
public function test_generic_labels_are_clean_in_other_locales(): void
{
// en 은 HTTP 경로로도 확인한다 — 사전값만 보면 요청 경로의 회귀를 놓친다.
$response = $this->withHeaders(['Accept-Language' => 'en'])
->postJson('/api/auth/register', [
'email' => 'g7fix113.d6@example.com',
'password' => 12345678,
'password_confirmation' => 12345678,
'name' => 'Matrix',
]);
$response->assertStatus(422);
$enMessage = (string) json_encode($response->json('errors.password'), JSON_UNESCAPED_UNICODE);
$this->assertStringNotContainsString('SMTP', $enMessage);
$this->assertStringContainsString('password', $enMessage);
App::setLocale('en');
$this->assertSame('password', __('validation.attributes.password'));
$this->assertSame('SMTP password', __('validation.attributes.smtp_password'));
$this->assertSame('ID list', __('validation.attributes.ids'));
$this->assertSame('user ID list', __('validation.attributes.user_ids'));
App::setLocale('ko');
// ja 는 언어팩(런타임 설치본)이라 테스트 환경 로케일로 해석되지 않는다 —
// 번들 원본을 직접 읽어 3로케일 동형 적용을 고정한다.
$ja = require base_path('lang-packs/_bundled/g7-core-ja/backend/ja/validation.php');
$this->assertSame('パスワード', $ja['attributes']['password']);
$this->assertSame('SMTPパスワード', $ja['attributes']['smtp_password']);
$this->assertSame('ID一覧', $ja['attributes']['ids']);
$this->assertSame('ユーザーID一覧', $ja['attributes']['user_ids']);
$this->assertSame('言語', $ja['attributes']['language']);
$this->assertSame('通貨', $ja['attributes']['currency']);
}
/**
* D-extra: 범용 키 전수 — 도메인 문구가 다시 새어 들어오는 회귀를 차단한다.
*
* @scenario attribute_label_scope=generic_ids, locale=ko
*
* @effects generic_attribute_keys_carry_no_domain_prefix
*/
public function test_generic_attribute_keys_are_free_of_domain_prefixes(): void
{
$expected = [
'password' => '비밀번호',
'host' => '호스트',
'port' => '포트',
'username' => '사용자명',
'ids' => 'ID 목록',
'language' => '언어',
'currency' => '통화',
'cache_ttl' => '캐시 유지시간',
'channels' => '채널',
];
foreach ($expected as $key => $label) {
$this->assertSame($label, __("validation.attributes.{$key}"), "attributes.{$key}");
}
$domain = [
'smtp_host', 'smtp_port', 'smtp_username', 'smtp_password',
'user_ids', 'default_language', 'default_currency',
'seo_page_cache_ttl', 'notification_channels',
];
foreach ($domain as $key) {
$this->assertNotSame(
"validation.attributes.{$key}",
__("validation.attributes.{$key}"),
"도메인 전용 키 미정의: {$key}"
);
}
}
/**
* 관리자 사용자 생성 헬퍼
*/
private function createAdmin(): User
{
$user = User::factory()->create(['email' => 'g7fix113.admin@example.com']);
$permissionIds = [];
foreach (['core.settings.update', 'core.users.update', 'core.notifications.update'] as $identifier) {
$permission = Permission::firstOrCreate(
['identifier' => $identifier],
[
'name' => json_encode(['ko' => $identifier, 'en' => $identifier]),
'description' => json_encode(['ko' => $identifier, 'en' => $identifier]),
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'type' => 'admin',
]
);
$permissionIds[] = $permission->id;
}
$role = Role::firstOrCreate(
['identifier' => 'admin'],
[
'name' => json_encode(['ko' => '관리자', 'en' => 'Administrator']),
'description' => json_encode(['ko' => '관리자', 'en' => 'Administrator']),
'is_active' => true,
]
);
$role->permissions()->syncWithoutDetaching($permissionIds);
$user->roles()->attach($role->id);
return $user->fresh();
}
}
@@ -0,0 +1,136 @@
/**
* E2E: 일괄 처리 목록의 선택 범위 (selectionScope)
*
* 체크박스 선택은 화면 밖(전역 상태)에 저장되므로, 검색으로 그 행이 목록에서 빠져도
* 선택은 남는다. 그 상태에서 일괄 처리를 누르면 **운영자가 보고 있지도, 체크하지도 않은
* 행이 대상**이 된다. 확인 창은 건수만 말하고("N명의 사용자를 탈퇴 처리합니다") 대상을
* 부르지 않으므로 실행 전에 알아챌 방법이 없고, 서버는 정상 처리하므로 오류도 남지 않는다.
*
* 관측 가능한 결과는 **나가는 요청의 ids** 다 — 화면만 봐서는 구분되지 않으므로 여기서
* 잠근다. 단위 테스트(DataGrid.test.tsx)는 컴포넌트가 정리를 수행하는지를 보고, 이 spec 은
* 그 정리가 실제 화면·실제 전역 상태·실제 요청까지 도달하는지를 본다.
*
* // @scenario surface=admin_user_list, transition=search_filter_change, scope=page
* // @effects hidden_row_selection_dropped, bulk_button_disabled_when_selection_empties, bulk_request_carries_only_visible_checked_rows
*/
import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth';
const ADMIN_PERMISSIONS = ['core.users.read', 'core.users.update', 'core.users.create'];
/** 이 실행에서만 쓰는 접두사 — 검색으로 한 명씩 고립시키기 위해 필요하다. */
const RUN_TAG = `e2esel${Date.now().toString(36)}`;
/**
* 테스트용 회원을 만들고 uuid 를 돌려줍니다.
*
* @param page Playwright 페이지
* @param token 관리자 토큰
* @param slug 이메일 로컬파트 구분자
* @return 생성된 회원 uuid
*/
async function createTestUser(page: any, token: string, slug: string): Promise<string> {
const headers = { Authorization: `Bearer ${token}`, Accept: 'application/json' };
const rolesResponse = await page.request.get('/api/admin/roles/active', { headers });
const roles = (await rolesResponse.json())?.data?.data ?? [];
const roleId = (roles.find((role: any) => role.identifier === 'user') ?? roles[roles.length - 1])?.id;
expect(roleId, '일반 사용자 역할을 찾지 못했습니다').toBeTruthy();
const response = await page.request.post('/api/admin/users', {
headers,
data: {
name: `E2E선택범위 ${slug}`,
email: `${RUN_TAG}${slug}@example.com`,
password: 'Password!234',
password_confirmation: 'Password!234',
status: 'active',
role_ids: [roleId],
},
});
expect(response.status(), `테스트 회원 생성 실패: ${await response.text()}`).toBeLessThan(300);
return (await response.json())?.data?.uuid;
}
/**
* 화면의 검색창으로 목록을 걸러냅니다.
*
* `page.goto` 로 URL 을 갈아끼우면 전체 페이지가 다시 로드되면서 전역 상태가 통째로
* 비워진다 — 그러면 선택이 사라진 이유가 "컴포넌트가 정리해서" 인지 "페이지가 새로
* 뜨면서" 인지 구분되지 않고, 수정이 없어도 통과하는 테스트가 된다. 결함이 실제로
* 관측된 경로는 앱이 떠 있는 채로 목록만 다시 부르는 화면 내 검색이므로 그 경로를 쓴다.
*
* @param page Playwright 페이지
* @param keyword 검색어
* @return void
*/
async function searchUserList(page: any, keyword: string): Promise<void> {
const searchInput = page.locator('#search_input');
await searchInput.waitFor({ state: 'visible', timeout: 20_000 });
await searchInput.fill(keyword);
await searchInput.press('Enter');
await expect(page.locator('#users_data_grid__body tbody tr')).toHaveCount(1, { timeout: 20_000 });
await expect(page.locator('#users_data_grid__body tbody tr').first()).toContainText(keyword, {
timeout: 20_000,
});
}
/**
* 행 체크박스를 켜고 실제로 켜졌는지 확인합니다.
*
* `check()` 는 클릭 직후 상태를 확인하는데, 이 체크박스는 전역 상태를 거쳐 다시 내려오는
* 제어 컴포넌트라 그 시점에 아직 false 다 (실측). 클릭과 확인을 분리한다.
*
* @param page Playwright 페이지
* @return void
*/
async function checkFirstRow(page: any): Promise<void> {
const checkbox = page.locator('#users_data_grid__body tbody tr input[type="checkbox"]').first();
await checkbox.click();
await expect(checkbox).toBeChecked({ timeout: 10_000 });
}
test('@smoke 회원 목록: 검색으로 사라진 행은 선택이 풀리고 일괄 처리 대상에서도 빠진다', async ({ page }) => {
const token = issueToken(...ADMIN_PERMISSIONS);
await authenticatePage(page, token);
const hiddenUserUuid = await createTestUser(page, token, 'hidden');
const visibleUserUuid = await createTestUser(page, token, 'visible');
// 1) hidden 만 보이는 목록에서 그 행을 체크한다.
await page.goto('/admin/users');
await searchUserList(page, `${RUN_TAG}hidden`);
await checkFirstRow(page);
const bulkWithdraw = page.locator('#bulk_withdraw');
await expect(bulkWithdraw).toBeEnabled({ timeout: 10_000 });
// 2) 앱이 떠 있는 채로 검색만 바꾼다 — hidden 은 화면에서 사라지지만 전역 상태는 살아 있다.
await searchUserList(page, `${RUN_TAG}visible`);
// 사라진 행의 선택이 남아 있으면 버튼이 살아 있다. 그 상태가 결함이다.
await expect(bulkWithdraw).toBeDisabled({ timeout: 20_000 });
// 3) 보이는 행만 체크하고 일괄 탈퇴를 실행한다.
await checkFirstRow(page);
await expect(bulkWithdraw).toBeEnabled({ timeout: 10_000 });
const bulkRequest = page.waitForRequest(
(request: any) => request.url().includes('/api/admin/users/bulk-status') && request.method() === 'PATCH'
);
await bulkWithdraw.click();
// 확인 모달의 실행 버튼은 "확인"(공통 문구)이다 — 모달이 뜬 뒤에 누른다.
const confirmButton = page.getByRole('button', { name: /^(확인|Confirm)$/ }).last();
await expect(confirmButton).toBeVisible({ timeout: 10_000 });
await confirmButton.click();
const sentIds = (await (await bulkRequest).postDataJSON())?.ids ?? [];
expect(sentIds, '화면에 보이던 행이 대상에서 빠졌습니다').toContain(visibleUserUuid);
expect(sentIds, '화면에서 사라진 행이 일괄 처리 대상에 실렸습니다').not.toContain(hiddenUserUuid);
});
@@ -0,0 +1,190 @@
/**
* 관리자 회원 수정 폼 — 상태 '탈퇴' 저장 확인 절차 (공개이슈 #112)
*
* 서버에서 관리자 상태 변경 경로가 정식 탈퇴(비가역 익명화)로 통일되었으므로,
* 이 폼의 저장이 경고 없이 회원 정보를 파기하지 않아야 한다.
*
* // @scenario entry_point=admin_form_ui, target_account=normal, confirm_action=cancel, confirm_action=submit
* // @effects admin_form_shows_confirm_dialog_before_destructive_save, admin_form_cancel_leaves_user_untouched, admin_form_non_withdraw_status_saves_without_dialog
*/
import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth';
const ADMIN_PERMISSIONS = ['core.users.read', 'core.users.update', 'core.users.create'];
/** 테스트용 회원을 생성하고 uuid 를 돌려준다. */
async function createTestUser(page: any, token: string, email: string): Promise<string> {
const headers = { Authorization: `Bearer ${token}`, Accept: 'application/json' };
// 역할은 필수 입력이다. 관리자 역할을 붙이면 탈퇴 자체가 차단 대상이 되므로
// 일반 사용자 역할을 고른다 (목록 첫 항목은 admin 이다).
const rolesResponse = await page.request.get('/api/admin/roles/active', { headers });
const roles = (await rolesResponse.json())?.data?.data ?? [];
const roleId = (roles.find((role: any) => role.identifier === 'user') ?? roles[roles.length - 1])?.id;
expect(roleId, '일반 사용자 역할을 찾지 못했습니다').toBeTruthy();
const response = await page.request.post('/api/admin/users', {
headers,
data: {
name: 'E2E 탈퇴확인',
email,
password: 'Password!234',
password_confirmation: 'Password!234',
status: 'active',
role_ids: [roleId],
},
});
expect(response.status(), `테스트 회원 생성 실패: ${await response.text()}`).toBeLessThan(300);
const body = await response.json();
return body?.data?.uuid ?? body?.data?.id;
}
/** 상태 드롭다운에서 라벨을 고르고, 선택이 폼에 반영될 때까지 기다린다. */
async function selectStatus(page: any, label: RegExp): Promise<void> {
const statusSelect = page.locator('[name="status"][aria-haspopup], [name="status"]').first();
await statusSelect.waitFor({ state: 'visible', timeout: 20_000 });
// 폼이 API 응답으로 시드될 때까지 기다린다 — 시드 전에 저장하면 필수 항목이 빠져 422 가 된다.
await expect(page.locator('[name="email"]')).not.toHaveValue('', { timeout: 20_000 });
await statusSelect.click();
await page.getByRole('option', { name: label }).first().click();
await expect(statusSelect).toContainText(label, { timeout: 10_000 });
}
test('@smoke 관리자 회원 수정 폼: 상태 탈퇴 저장은 확인을 거치고, 취소하면 회원 정보가 그대로 남는다', async ({ page }) => {
const token = issueToken(...ADMIN_PERMISSIONS);
await authenticatePage(page, token);
const email = `g7fix112.e2e.${Date.now()}@example.com`;
const uuid = await createTestUser(page, token, email);
await page.goto(`/admin/users/${uuid}/edit`);
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
expect(page.url(), '로그인으로 리다이렉트되었습니다').not.toMatch(/\/admin\/login/);
await selectStatus(page, /탈퇴|Withdraw/i);
// 저장 → 확인 다이얼로그가 먼저 뜨고, 저장 요청은 나가지 않는다.
let saveRequests = 0;
page.on('request', (req) => {
if (req.url().includes(`/api/admin/users/${uuid}`) && req.method() === 'PUT') {
saveRequests += 1;
}
});
await page.locator('#footer_save_button').click();
const dialog = page.getByRole('dialog');
await dialog.waitFor({ state: 'visible', timeout: 10_000 });
// 문구가 비가역성과 파기 대상을 알린다
await expect(dialog).toContainText(/되돌릴 수 없|cannot be undone/i);
await expect(dialog).toContainText(/익명화|anonymiz/i);
expect(saveRequests, '확인 전에 저장 요청이 나갔습니다').toBe(0);
// 취소 → 회원 정보 불변
await dialog.getByRole('button').first().click();
await expect(page.getByRole('dialog')).toHaveCount(0, { timeout: 10_000 });
expect(saveRequests, '취소했는데 저장 요청이 나갔습니다').toBe(0);
const stillOriginal = await page.request.get(`/api/admin/users/${uuid}`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
});
const stillBody = await stillOriginal.json();
expect(stillBody?.data?.email, '취소했는데 이메일이 바뀌었습니다').toBe(email);
// 재시도 후 확인 → 저장되고 익명화가 반영된다
// 다이얼로그 언마운트 직후의 재클릭은 낡은 폼 스냅샷으로 제출될 수 있어 렌더가 가라앉기를 기다린다
await page.waitForTimeout(1500);
await page.locator('#footer_save_button').click();
await expect(page.getByRole('dialog')).toHaveCount(1, { timeout: 15_000 });
const reopened = page.getByRole('dialog');
const savePromise = page.waitForResponse(
(res) => res.url().includes(`/api/admin/users/${uuid}`) && res.request().method() === 'PUT',
{ timeout: 20_000 },
);
await reopened.getByRole('button').last().click();
const saveResponse = await savePromise;
expect(saveResponse.status(), '탈퇴 저장이 실패했습니다').toBe(200);
const after = await page.request.get(`/api/admin/users/${uuid}`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
});
const afterBody = await after.json();
expect(afterBody?.data?.status).toBe('withdrawn');
expect(afterBody?.data?.email, '탈퇴했는데 이메일이 익명화되지 않았습니다').not.toBe(email);
expect(String(afterBody?.data?.email)).toContain('_deleted_');
});
test('관리자 회원 수정 폼: 탈퇴 외 상태 저장은 확인 없이 즉시 저장된다', async ({ page }) => {
const token = issueToken(...ADMIN_PERMISSIONS);
await authenticatePage(page, token);
const email = `g7fix112.e2e.keep.${Date.now()}@example.com`;
const uuid = await createTestUser(page, token, email);
await page.goto(`/admin/users/${uuid}/edit`);
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await selectStatus(page, /비활성|Inactive/i);
const savePromise = page.waitForResponse(
(res) => res.url().includes(`/api/admin/users/${uuid}`) && res.request().method() === 'PUT',
{ timeout: 20_000 },
);
await page.locator('#footer_save_button').click();
const saveResponse = await savePromise;
expect(saveResponse.status()).toBe(200);
// 저장이 확정된 뒤에 다이얼로그 부재를 단언한다 (부재 단언은 렌더 전에도 통과한다)
await expect(page.getByRole('dialog')).toBeHidden();
const after = await page.request.get(`/api/admin/users/${uuid}`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
});
const afterBody = await after.json();
expect(afterBody?.data?.status).toBe('inactive');
expect(afterBody?.data?.email, '탈퇴가 아닌데 이메일이 익명화되었습니다').toBe(email);
});
test('관리자 회원 수정 폼: 회원 정보가 채워지기 전에는 저장할 수 없다', async ({ page }) => {
const token = issueToken(...ADMIN_PERMISSIONS);
await authenticatePage(page, token);
const email = `g7fix112.e2e.seed.${Date.now()}@example.com`;
const uuid = await createTestUser(page, token, email);
const saveRequests: string[] = [];
page.on('request', (req) => {
if (req.method() === 'PUT' && req.url().includes(`/api/admin/users/${uuid}`)) {
saveRequests.push(String(req.postData()).slice(0, 80));
}
});
await page.goto(`/admin/users/${uuid}/edit`);
// 버튼이 보이자마자 누른다 — 폼이 API 응답으로 채워지기 전 시점이다.
const save = page.locator('#footer_save_button');
await save.waitFor({ state: 'visible', timeout: 20_000 });
await save.click({ force: true }).catch(() => undefined);
await page.waitForTimeout(2_000);
// 빈 본문 저장이 나가면 화면에는 값이 보이는데 "이름은 필수입니다" 로 거절된다.
expect(saveRequests, `시드 전에 저장 요청이 나갔습니다: ${saveRequests.join(' | ')}`).toHaveLength(0);
// 시드가 끝나면 정상적으로 저장할 수 있다 (버튼이 영구 비활성으로 굳지 않는다).
await expect(page.locator('[name="email"]')).toHaveValue(email, { timeout: 20_000 });
await expect(save).toBeEnabled({ timeout: 10_000 });
});
@@ -2,9 +2,9 @@
namespace Tests\Unit\Providers;
use App\Providers\SettingsServiceProvider;
use App\Repositories\JsonConfigRepository;
use App\Support\ExtensionSettingsMirror;
use Carbon\Carbon;
use ReflectionClass;
use Tests\TestCase;
/**
@@ -56,13 +56,48 @@ class SettingsServiceProviderTest extends TestCase
/**
* CORE_CATEGORIES에 seo 카테고리가 포함되어 봇 감지 설정이 로딩되는지 테스트합니다.
*
* 목록의 소유자는 ExtensionSettingsMirror 다 — Provider 에 사본을 두고 그 사본을
* 단언하면, 미러의 목록에서 카테고리가 빠져도 이 테스트가 초록으로 남는다.
*/
public function test_seo_category_included_in_core_categories(): void
{
$reflection = new ReflectionClass(SettingsServiceProvider::class);
$categories = $reflection->getConstant('CORE_CATEGORIES');
$this->assertContains(
'seo',
ExtensionSettingsMirror::CORE_CATEGORIES,
'CORE_CATEGORIES에 seo 카테고리가 포함되어야 합니다.'
);
}
$this->assertContains('seo', $categories, 'CORE_CATEGORIES에 seo 카테고리가 포함되어야 합니다.');
/**
* CORE_CATEGORIES 가 실제로 미러에 채워지는 목록인지 테스트합니다.
*
* 이 단언이 없으면 위/아래의 상수 포함 검사는 "상수에 문자열이 있다" 만 증명한다.
* 상수와 채움 동작이 실제로 이어져 있어야 그 검사에 회귀 차단 효력이 생긴다.
*/
public function test_core_categories_constant_is_what_the_mirror_fills(): void
{
$probe = new class extends JsonConfigRepository
{
/**
* 어느 카테고리를 물어도 비어 있지 않은 값을 돌려준다 (채움 대상 판정용).
*
* @param string $category 카테고리명
* @return array<string, mixed> 탐침 값
*/
public function getCategory(string $category): array
{
return ['__probe' => $category];
}
};
app(ExtensionSettingsMirror::class)->refreshCore($probe);
$this->assertSame(
ExtensionSettingsMirror::CORE_CATEGORIES,
array_keys(config('g7_settings.core')),
'g7_settings.core 에 채워지는 카테고리가 CORE_CATEGORIES 와 일치해야 합니다.'
);
}
/**
@@ -85,10 +120,11 @@ class SettingsServiceProviderTest extends TestCase
*/
public function test_identity_category_included_in_core_categories(): void
{
$reflection = new ReflectionClass(SettingsServiceProvider::class);
$categories = $reflection->getConstant('CORE_CATEGORIES');
$this->assertContains('identity', $categories, 'CORE_CATEGORIES 에 identity 카테고리가 포함되어야 합니다.');
$this->assertContains(
'identity',
ExtensionSettingsMirror::CORE_CATEGORIES,
'CORE_CATEGORIES 에 identity 카테고리가 포함되어야 합니다.'
);
}
/**
@@ -0,0 +1,118 @@
<?php
namespace Tests\Unit\Repositories;
use App\Models\ActivityLog;
use App\Repositories\Concerns\DeletesInBatches;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* 배치 삭제 트레이트 테스트
*
* 정리 예약은 도입 직후 첫 실행에서 이미 쌓여 있던 과거 데이터를 전부 지운다.
* 한 문장으로 지우면 그 한 건이 테이블을 오래 잠그므로 배치로 끊어 지운다 —
* 끊어 지우면서도 대상이 남김없이 사라지는지를 고정한다.
*
* @scenario row_age=beyond_retention
*
* @effects large_purge_is_split_into_batches
*/
class DeletesInBatchesTest extends TestCase
{
use RefreshDatabase;
/**
* 배치 크기를 넘는 대상도 남김없이 삭제된다.
*
* 단일 배치만 처리하고 끝내는 구현이면 나머지가 남아 red 가 된다.
*/
public function test_deletes_every_matching_row_across_multiple_batches(): void
{
foreach (range(1, 7) as $i) {
$log = ActivityLog::create([
'log_type' => 'system',
'action' => 'test.batch',
'description' => "오래된 기록 {$i}",
]);
$log->forceFill(['created_at' => now()->subDays(400)])->saveQuietly();
}
$deleted = $this->runner()->purge(
ActivityLog::where('created_at', '<', now()->subDays(365))
);
$this->assertSame(7, $deleted);
$this->assertSame(0, ActivityLog::where('action', 'test.batch')->count());
}
/**
* 조건 밖의 행은 배치가 몇 회전을 돌아도 건드리지 않는다.
*/
public function test_keeps_rows_outside_the_condition(): void
{
foreach (range(1, 5) as $i) {
$old = ActivityLog::create([
'log_type' => 'system',
'action' => 'test.batch.old',
'description' => "오래된 기록 {$i}",
]);
$old->forceFill(['created_at' => now()->subDays(400)])->saveQuietly();
}
$recent = ActivityLog::create([
'log_type' => 'system',
'action' => 'test.batch.recent',
'description' => '최근 기록',
]);
$this->runner()->purge(
ActivityLog::where('created_at', '<', now()->subDays(365))
);
$this->assertDatabaseHas('activity_logs', ['id' => $recent->id]);
$this->assertSame(0, ActivityLog::where('action', 'test.batch.old')->count());
}
/**
* 대상이 없으면 0 을 돌려주고 끝난다 (무한 루프 방지).
*/
public function test_returns_zero_when_nothing_matches(): void
{
$deleted = $this->runner()->purge(
ActivityLog::where('created_at', '<', now()->subDays(365))
);
$this->assertSame(0, $deleted);
}
/**
* 배치 크기를 작게 잡은 실행기를 만듭니다.
*
* 기본 배치(1000)로는 여러 회전을 만들려면 그만큼 행을 쌓아야 해서,
* 회전 자체를 검증하려면 크기를 줄이는 편이 정확하다.
*
* @return object purge(Builder): int 를 제공하는 실행기
*/
private function runner(): object
{
return new class
{
use DeletesInBatches;
/**
* 조건에 맞는 행을 배치로 삭제합니다.
*
* 회전을 강제하려고 배치 크기를 2 로 낮춰 호출합니다.
*
* @param Builder $query 삭제 대상 쿼리
* @return int 삭제된 건수
*/
public function purge(Builder $query): int
{
return $this->deleteInBatches($query, 2);
}
};
}
}
@@ -0,0 +1,143 @@
<?php
namespace Tests\Unit\Services;
use App\Enums\UserStatus;
use App\Models\User;
use Illuminate\Database\Schema\Builder as SchemaBuilder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Tests\TestCase;
/**
* User::withdraw() 익명화 규칙 단위 테스트 (공개이슈 #112)
*
* 서비스 계층의 원자성은 `tests/Feature/Api/Auth/WithdrawAtomicityTest.php` 가 다루고,
* 여기서는 모델이 만드는 **익명화 값 자체**의 규칙을 고정한다 —
* 접미사 형식·유일성·멱등·컬럼 길이 클램프.
*
* @scenario target_account=normal, target_account=already_withdrawn, field_length=exceeds_limit
*
* @effects suffix_includes_user_id_for_structural_uniqueness, long_values_truncated_within_column_limits, withdraw_is_idempotent
*/
class UserServiceWithdrawTest extends TestCase
{
use RefreshDatabase;
/**
* 이메일 접미사는 날짜 + 사용자 ID 로 구성된다.
*/
public function test_email_suffix_contains_date_and_user_id(): void
{
Carbon::setTestNow(Carbon::parse('2026-08-13 09:00:00'));
$user = User::factory()->create(['email' => 'suffix@example.com']);
$this->assertTrue($user->withdraw());
$this->assertSame('suffix@example.com_deleted_20260813_'.$user->id, $user->email);
}
/**
* 같은 날 같은 원본 이메일이어도 사용자마다 결과가 다르다 (구조적 유일).
*/
public function test_same_original_email_yields_distinct_anonymized_values(): void
{
Carbon::setTestNow(Carbon::parse('2026-08-13 09:00:00'));
$first = User::factory()->create(['email' => 'dup@example.com']);
$first->withdraw();
$second = User::factory()->create(['email' => 'dup@example.com']);
$second->withdraw();
$this->assertNotSame($first->email, $second->email);
}
/**
* 이름·닉네임 접미사 규칙.
*/
public function test_name_and_nickname_suffixes(): void
{
Carbon::setTestNow(Carbon::parse('2026-08-13 09:00:00'));
$user = User::factory()->create(['name' => '홍길동', 'nickname' => '길동이']);
$user->withdraw();
$this->assertSame('홍길동_탈퇴_20260813', $user->name);
$this->assertSame('길동이_탈퇴', $user->nickname);
}
/**
* 닉네임이 없으면 접미사를 붙이지 않는다.
*/
public function test_null_nickname_is_left_untouched(): void
{
$user = User::factory()->create(['nickname' => null]);
$user->withdraw();
$this->assertNull($user->nickname);
}
/**
* 이미 탈퇴한 계정은 값이 변하지 않는다 (멱등).
*/
public function test_withdraw_is_idempotent(): void
{
$user = User::factory()->create(['email' => 'idem@example.com', 'nickname' => '닉']);
$user->withdraw();
$snapshot = [$user->email, $user->name, $user->nickname, (string) $user->withdrawn_at];
$this->assertTrue($user->withdraw());
$this->assertSame($snapshot, [$user->email, $user->name, $user->nickname, (string) $user->withdrawn_at]);
}
/**
* 컬럼 상한을 넘기지 않도록 원값 앞부분을 잘라 붙인다.
*/
public function test_values_are_clamped_to_column_limits(): void
{
$limit = SchemaBuilder::$defaultStringLength ?? 255;
$user = User::factory()->create([
'email' => str_repeat('a', $limit - 12).'@example.com',
'name' => str_repeat('나', $limit),
'nickname' => str_repeat('다', User::NICKNAME_MAX_LENGTH),
]);
$user->withdraw();
$this->assertSame($limit, mb_strlen($user->email));
$this->assertSame($limit, mb_strlen($user->name));
$this->assertSame(User::NICKNAME_MAX_LENGTH, mb_strlen($user->nickname));
// 잘렸어도 접미사는 온전히 남는다 (식별 규칙이 깨지면 안 된다)
$this->assertStringEndsWith('_deleted_'.now()->format('Ymd').'_'.$user->id, $user->email);
$this->assertStringEndsWith('_탈퇴', $user->nickname);
}
/**
* 상태·탈퇴 일시가 함께 기록된다.
*/
public function test_status_and_withdrawn_at_are_set(): void
{
$user = User::factory()->create();
$user->withdraw();
$this->assertSame(UserStatus::Withdrawn->value, $user->status);
$this->assertNotNull($user->withdrawn_at);
$this->assertTrue($user->isWithdrawn());
}
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
}
+82
View File
@@ -0,0 +1,82 @@
# audit:allow test-scenario-coverage reason: |
# cross product 자동 전개는 audit 실행 환경의 fallback YAML 파서가 nested axes 를 읽지 못해
# 검출되지 않는다(다른 시나리오와 동일 한계). 축 조합은 PHPUnit Feature 테스트
# (CoreScheduleRegistrationTest · DataRetentionCommandsTest)와 모듈 커맨드 테스트가
# 커버하고 green 이다.
feature: 만료 데이터 자동 정리 예약과 예약 시각 기준 (공개이슈 #110)
description: |
수명이 지난 데이터를 정리하는 예약 작업을 등록하고, 예약의 실행 시각 해석 기준을
사이트 설정 시간대로 통일한다.
배경:
- 만료 토큰 정리 커맨드는 있었지만 어디에도 예약되지 않아 행이 영구 누적됐다.
- 정리 코드는 있는데 호출자가 0건인 대상(임시 주문·SEO 통계·예약 이력·본인인증
challenge)이 있었고, 보존 정책이 아예 없는 대상(본인인증 이력·활동 로그·알림
발송 이력)도 있었다.
- `app.timezone` 이 UTC 이고 예약에 시간대 지정이 없어, '새벽 4시' 로 등록해도
한국 기준 낮에 실행됐다. 관리자가 화면에서 등록한 cron 도 같은 결함을 가졌다.
- 이 축의 회귀 테스트가 0건이라 결함군이 무가드로 통과됐다.
적용 지점:
- `routes/console.php` 의 만료 데이터 정리 구획 (일 1회 새벽 배치 + 시간별 1건).
- 신설 코어 커맨드 6종 + 이커머스 모듈 커맨드 1종.
- 시각 기준: 설정 반영부가 `app.schedule_timezone` 을 세팅해 Schedule 인스턴스
전체에 일괄 적용 (이벤트별 지정 방식은 누락이 생겨 채택하지 않음).
- 관리자 등록 예약: 다음 실행 시각 계산도 같은 기준.
axes:
target:
- sanctum_tokens
- password_reset_tokens
- failed_jobs
- job_batches
- notifications
- extension_bundles
- seo_cache_stats
- schedule_histories
- identity_challenges
- identity_logs
- activity_logs
- notification_logs
- ecommerce_temp_orders
row_age: [within_retention, beyond_retention]
schedule_axis: [code_registered, admin_registered]
timezone_setting: [configured, empty]
exclusions:
- { target: identity_challenges, row_age: beyond_retention, reason: "상태 전환만 수행하며 삭제하지 않음 — 만료 여부는 expires_at 이 판정" }
- { schedule_axis: admin_registered, target: sanctum_tokens, reason: "관리자 등록 축은 시각 기준만 검증 (대상별 동작은 코드 예약 축이 커버)" }
effects:
- all_gc_commands_are_registered_in_schedule
- gc_commands_run_on_one_server_only
- sanctum_prune_uses_hours_option
- rows_beyond_retention_are_deleted
- rows_within_retention_are_kept
- identity_challenges_transition_without_deletion
- schedule_timezone_follows_site_general_timezone
- schedule_timezone_falls_back_to_app_timezone_when_unset
- app_timezone_stays_utc
- admin_registered_schedule_next_run_uses_same_basis
- new_commands_are_in_schedule_security_allowlist
- new_commands_are_exposed_in_dev_dashboard
- automated_prune_emits_its_own_hooks
- retention_floor_is_owned_by_the_domain_layer
- large_purge_is_split_into_batches
test_files:
- tests/Feature/Console/CoreScheduleRegistrationTest.php
- tests/Feature/Console/DataRetentionCommandsTest.php
- tests/Unit/Repositories/DeletesInBatchesTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Console/PruneExpiredTempOrdersCommandTest.php
rules_layer_coverage:
- rule: offset-chunk-mutates-filter
coverage: |
정리는 기본키 배치로 끊어 지운다 — OFFSET 이 개입하지 않으므로 커서가 밀려
미처리 행을 건너뛰는 결함이 성립하지 않는다. 매 회전이 같은 술어를 다시
평가하고, 직전 회전이 지운 만큼 모집단이 줄어든다.
- rule: console-confirm
coverage: 정리 커맨드는 확인 프롬프트가 없는 무인 실행 전용
@@ -0,0 +1,44 @@
feature: 일괄 처리 목록의 선택 범위 (DataGrid selectionScope)
description: |
체크박스 선택은 화면 밖(전역/로컬 상태)에 저장되므로, 검색·필터·페이지 이동으로 행이
목록에서 빠져도 선택은 남는다. 그 상태에서 일괄 처리를 누르면 운영자가 보고 있지도,
체크하지도 않은 행이 대상이 된다. 확인 창은 건수만 말하고 대상을 부르지 않으므로 실행
전에 알아챌 방법이 없고, 서버는 정상 처리하므로 오류도 남지 않는다.
수정: DataGrid 가 `selectionScope` 를 갖고, `"page"` 인 목록은 지금 그려진 행에 없는
선택을 버린다. 선택 자체가 저장 대상인 폼(쿠폰 적용 대상 고르기 등)은 `"free"` 로
종전 동작을 유지한다. 값은 소비 화면이 반드시 명시하며 정적 검사가 미선언을 차단한다.
범위: 저장소의 모든 소비 화면 16곳 (page 12 / free 4).
axes:
scope: [page, free]
transition: [search_filter_change, page_change, initial_mount]
exclusions:
- { scope: free, transition: page_change, reason: "free 는 정의상 어떤 전이에서도 선택을 건드리지 않는다 — 전이별 분해가 의미 없어 대표 1건(search_filter_change)으로 고정" }
- { scope: free, transition: initial_mount, reason: "동일" }
effects:
- hidden_row_selection_dropped
- visible_row_selection_survives
- selection_cleaned_even_when_selectable_off
- free_scope_leaves_selection_untouched
- no_callback_when_nothing_to_drop
- bulk_button_disabled_when_selection_empties
- bulk_request_carries_only_visible_checked_rows
test_files:
- templates/_bundled/sirsoft-admin_basic/src/components/composite/__tests__/DataGrid.test.tsx
- tests/Playwright/specs/admin/list-bulk-selection-scope.spec.ts
rules_layer_coverage:
- rule: layout-datagrid-selection-scope
coverage: |
소비 화면 전수(16곳)가 scope 를 명시했는지는 테스트가 아니라 룰이 증명한다 —
화면을 axis 로 두면 화면이 늘 때마다 테스트를 복제해야 하고, 정작 새로 생긴
화면의 누락은 잡히지 않는다. 룰은 onSelectionChange 가 배선된 DataGrid 전수에서
미선언·비허용값을 차단한다.
설계상 free 고정 화면: 쿠폰 적용 대상 선택기(여러 페이지에 걸쳐 고른다),
주문 상세 클레임 대상 선택기(선택이 곧 입력값).
+36
View File
@@ -25,15 +25,42 @@ axes:
- register
- reset_password
- change_password
- verify_password
- admin_create_user
- admin_update_user
- update_profile
min_length_setting: [default, raised, lowered]
special_char_setting: [required, not_required]
input_state: [satisfies_policy, violates_length, violates_special]
# 오류 문구의 :attribute 치환 라벨 축 (공개이슈 #113)
# 비밀번호 외 범용 키(ids)도 같은 결함군이라 함께 고정한다 —
# 전역 사전 하나를 도메인 문구로 덮으면 그 키를 쓰는 모든 화면이 오염된다.
attribute_label_scope: [generic_password, smtp_password, generic_ids, user_scoped_ids]
locale: [ko, en]
exclusions:
- { special_char_setting: not_required, input_state: violates_special, reason: "특수문자 미요구 설정에서는 위반 상태가 성립하지 않음" }
- { attribute_label_scope: smtp_password, entry_point: register, reason: "SMTP 라벨은 메일 설정 화면 전용 — 비밀번호를 정하는 경로에는 나타나지 않아야 한다" }
- { attribute_label_scope: smtp_password, entry_point: reset_password, reason: "동일" }
- { attribute_label_scope: smtp_password, entry_point: change_password, reason: "동일" }
- { attribute_label_scope: smtp_password, entry_point: admin_create_user, reason: "동일" }
- { attribute_label_scope: smtp_password, entry_point: admin_update_user, reason: "동일" }
- { attribute_label_scope: smtp_password, entry_point: update_profile, reason: "동일" }
- { attribute_label_scope: generic_ids, entry_point: register, reason: "ids 라벨은 목록 대상 일괄 처리 화면 전용 — 비밀번호를 정하는 경로에는 나타나지 않는다" }
- { attribute_label_scope: generic_ids, entry_point: reset_password, reason: "동일" }
- { attribute_label_scope: generic_ids, entry_point: change_password, reason: "동일" }
- { attribute_label_scope: generic_ids, entry_point: verify_password, reason: "동일" }
- { attribute_label_scope: generic_ids, entry_point: admin_create_user, reason: "동일" }
- { attribute_label_scope: generic_ids, entry_point: admin_update_user, reason: "동일" }
- { attribute_label_scope: generic_ids, entry_point: update_profile, reason: "동일" }
- { attribute_label_scope: user_scoped_ids, entry_point: register, reason: "동일" }
- { attribute_label_scope: user_scoped_ids, entry_point: reset_password, reason: "동일" }
- { attribute_label_scope: user_scoped_ids, entry_point: change_password, reason: "동일" }
- { attribute_label_scope: user_scoped_ids, entry_point: verify_password, reason: "동일" }
- { attribute_label_scope: user_scoped_ids, entry_point: admin_create_user, reason: "동일" }
- { attribute_label_scope: user_scoped_ids, entry_point: admin_update_user, reason: "동일" }
- { attribute_label_scope: user_scoped_ids, entry_point: update_profile, reason: "동일" }
- { attribute_label_scope: smtp_password, entry_point: verify_password, reason: "동일" }
effects:
- policy_rule_reads_min_length_from_settings
@@ -46,10 +73,19 @@ effects:
- empty_security_settings_fall_back_to_defaults
- saved_security_settings_are_visible_on_reload
- admin_screen_min_length_bounds_follow_settings_limits
# 라벨 축 (공개이슈 #113)
- policy_message_attribute_uses_generic_password_label
- smtp_label_never_appears_in_password_setting_paths
- smtp_screens_keep_their_domain_labels
- generic_labels_hold_in_every_locale
- bulk_status_screen_keeps_user_scoped_ids_label
- generic_ids_label_stays_generic_on_other_screens
- generic_attribute_keys_carry_no_domain_prefix
test_files:
- tests/Feature/Auth/PasswordPolicyTest.php
- tests/Feature/Settings/SettingsBoundaryContractTest.php
- tests/Feature/Validation/GlobalAttributeLabelTest.php
- templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-security-password-policy.test.ts
rules_layer_coverage:
@@ -0,0 +1,67 @@
# audit:allow test-scenario-coverage reason: |
# cross product 자동 전개는 audit 실행 환경의 fallback YAML 파서가 nested axes 를 읽지 못해
# 검출되지 않는다(다른 시나리오와 동일 한계). 축 조합은 PHPUnit Feature 테스트
# (SettingsConfigMirrorTest · PageSettingsMirrorTest · EcommerceSettingsMirrorTest)가
# 커버하고 green 이다.
feature: 설정 config 미러 재채움 (공개이슈 #109)
description: |
`g7_settings.*` in-memory 미러를 부팅뿐 아니라 저장 시점에도 다시 채워, 상주 프로세스가
옛 설정을 읽지 않게 한다.
배경:
- 미러는 부팅 때 한 번만 채워졌다. FPM 은 요청마다 부팅해 드러나지 않지만,
큐 워커·schedule:work·Reverb 는 프로세스가 상주해 저장 후에도 옛 값을 영원히 읽었다.
- 플러그인 미러는 setting.json 을 raw 로 읽어 defaults 병합·정규화가 빠지고
암호문이 그대로 실렸다 — 전용 게터와 값의 형태가 달랐다.
- sirsoft-page 는 설정 서비스 자체가 없어 미러가 영구 미존재였고, 소비자는 항상
인자 기본값으로 폴백했다 (기존 테스트가 config 직접 주입으로 우회해 미검출).
적용 지점:
- 채움 로직 단일 소유자 신설 — 부팅 Provider 와 저장 경로가 같은 코드를 호출한다.
- 코어: 설정 캐시 무효화 지점 (saveSettings / saveAdvancedSettings / setSetting 공통).
- 플러그인: 저장 성공 후 해당 플러그인만 재채움.
- 모듈: 코어 헬퍼를 각 모듈 서비스의 캐시 초기화 지점에서 호출.
- 민감 항목(`sensitive: true`)은 미러에서 키 자체를 제거 (암호문도 싣지 않음).
axes:
scope: [core, module, plugin]
trigger: [boot, save, cache_clear]
field_sensitivity: [normal, sensitive]
value_origin: [defaults_only, saved_override]
consumer: [mirror_helper, dedicated_getter]
exclusions:
- { scope: core, field_sensitivity: sensitive, reason: "코어 설정 카테고리에는 sensitive 스키마 선언이 없다" }
- { trigger: cache_clear, scope: core, reason: "코어는 캐시 무효화 자체가 저장 경로와 동일 지점" }
- { field_sensitivity: sensitive, consumer: mirror_helper, value_origin: defaults_only, reason: "미러에서 제거되므로 값의 출처와 무관" }
effects:
- core_mirror_refreshed_after_save
- core_mirror_refreshed_after_set_setting
- module_mirror_refreshed_after_save
- module_mirror_refreshed_after_cache_clear
- plugin_mirror_refreshed_after_save
- plugin_mirror_refreshed_after_reset
- core_mirror_refreshed_after_restore
- plugin_mirror_merges_defaults
- plugin_mirror_matches_dedicated_getter_shape
- sensitive_fields_absent_from_mirror_on_boot
- sensitive_fields_absent_from_mirror_on_refresh
- sensitive_fields_absent_from_mirror_after_save
- sensitive_fields_still_readable_via_dedicated_getter
- page_module_mirror_exists
- page_module_consumers_read_configured_values
- boot_and_save_share_one_fill_implementation
test_files:
- tests/Feature/Settings/SettingsConfigMirrorTest.php
- modules/_bundled/sirsoft-page/tests/Feature/PageSettingsMirrorTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Services/EcommerceSettingsMirrorTest.php
rules_layer_coverage:
- rule: service-provider
coverage: 미러 채움은 DI 조기 해석 없이 지연 해석 (register 단계 안전)
- rule: settings_service_testing_path_isolation
coverage: 신설 페이지 설정 서비스도 testing 환경 저장 경로 격리 적용
@@ -0,0 +1,80 @@
# audit:allow test-scenario-coverage reason: |
# cross product 자동 전개는 audit 실행 환경의 fallback YAML 파서가 nested axes 를 읽지 못해
# 검출되지 않는다(다른 시나리오와 동일 한계). 축 조합은 PHPUnit Feature 테스트
# (WithdrawAtomicityTest · AdminWithdrawPathUnificationTest · MultiStepWriteAtomicityTest)와
# Vitest 레이아웃 테스트 · Playwright E2E 가 커버하고 green 이다.
feature: 회원 탈퇴 원자성 및 탈퇴 경로 통일 (공개이슈 #112)
description: |
회원 탈퇴가 전부 성공하거나 전부 취소되게 하고, 탈퇴에 이르는 모든 경로가
같은 처리를 받도록 통일한다.
배경:
- `withdrawUser()` 에 트랜잭션이 없어, 마지막 단계(익명화 저장)가 실패하면
약관 동의 이력·토큰·아바타는 이미 지워진 채 계정만 활성으로 남았다.
- 익명화 접미사가 날짜(`Ymd`)뿐이라, 같은 이메일로 재가입한 회원이 같은 날
다시 탈퇴하면 email unique 위반으로 500 이 났다.
- 접미사를 붙이면 컬럼 길이를 넘길 수 있어(이름·이메일·닉네임) 저장이 실패했다.
- 관리자의 상태 변경(단건·일괄)은 status 컬럼만 바꿔 익명화와 탈퇴 훅을 건너뛰었다.
- 관리자/수퍼관리자 탈퇴 차단이 500 으로 나갔다.
적용 지점:
- 모델: 멱등 가드 + 사용자 ID 를 포함한 유일 접미사 + 컬럼 길이 클램프
(길이 상한은 스키마 기본 길이에서 파생 — 리터럴 금지).
- 서비스: DB 변경은 트랜잭션, 파일 삭제와 after 훅은 커밋 후.
- 관리자 경로: `updateUser` / `bulkUpdateStatus` 의 탈퇴 전환은 `withdrawUser()` 위임.
- 화면: 관리자 회원 수정 폼의 '탈퇴' 저장은 확인 창을 거친다.
axes:
entry_point:
- self_withdraw_api
- admin_update_status
- admin_bulk_status
- admin_form_ui
target_account: [normal, admin, super_admin, already_withdrawn]
email_state: [unique, reused_same_day]
field_length: [within_limit, exceeds_limit]
failure_injection: [none, last_write_fails]
confirm_action: [not_applicable, cancel, submit]
exclusions:
- { entry_point: self_withdraw_api, confirm_action: cancel, reason: "본인 탈퇴 모달은 이번 변경 대상이 아님" }
- { entry_point: admin_bulk_status, confirm_action: cancel, reason: "일괄 탈퇴 확인 모달은 기존 구현 유지" }
- { target_account: already_withdrawn, failure_injection: last_write_fails, reason: "멱등 가드로 쓰기 자체가 발생하지 않음" }
effects:
- withdraw_is_atomic_all_or_nothing
- consents_and_tokens_restored_on_failure
- avatar_file_kept_on_failure
- avatar_file_removed_only_after_commit
- same_day_rewithdraw_with_same_email_succeeds
- suffix_includes_user_id_for_structural_uniqueness
- long_values_truncated_within_column_limits
- withdraw_is_idempotent
- admin_status_change_runs_full_withdrawal
- admin_status_change_fires_withdraw_hooks
- admin_status_change_is_atomic_with_profile_update
- bulk_status_withdraw_delegates_per_user
- bulk_status_reports_failed_count
- admin_account_withdraw_blocked_consistently
- blocked_withdraw_returns_422_not_500
- failed_withdraw_leaves_no_activity_log
- admin_form_shows_confirm_dialog_before_destructive_save
- admin_form_cancel_leaves_user_untouched
- admin_form_non_withdraw_status_saves_without_dialog
- create_mode_hides_withdrawn_option
test_files:
- tests/Feature/Api/Auth/WithdrawAtomicityTest.php
- tests/Feature/Services/AdminWithdrawPathUnificationTest.php
- tests/Feature/Services/MultiStepWriteAtomicityTest.php
- tests/Feature/Api/Auth/ProfileControllerTest.php
- templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-user-form-withdraw-confirm.test.tsx
- tests/Playwright/specs/admin/user-form-withdraw-confirm.spec.ts
rules_layer_coverage:
- rule: service-direct-data-access
coverage: 탈퇴/삭제 경로는 Repository 인터페이스 경유 유지
- rule: i18n-throw-hardcoded-korean
coverage: 차단 문구는 lang/{ko,en}/user.php 의 withdraw_admin_forbidden 키