fix(core,admin_basic,board,ecommerce,page,gdpr,verification): 설정 미러·자동 정리·탈퇴 원자성·일괄 선택 범위 정비
공개이슈 ··· 및 그 전수 해소분. - 설정 저장·복원·초기화가 상주 프로세스 미러를 갱신하도록 단일 소유자화 - 만료 데이터 자동 정리 예약 12종 신설, 파기는 기본키 배치로 분할 - 회원 탈퇴를 전 경로 원자화하고 관리자 상태변경 경로를 정식 탈퇴로 통일 - 일괄 처리 목록의 선택을 화면에 보이는 행으로 한정(selectionScope) - 비밀번호 정책 문구를 도메인 중립 라벨로 정정 - 정적 검사 2종·검증 스킬 설계 정합성 축 신설
This commit is contained in:
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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 고정 화면: 쿠폰 적용 대상 선택기(여러 페이지에 걸쳐 고른다),
|
||||
주문 상세 클레임 대상 선택기(선택이 곧 입력값).
|
||||
@@ -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 키
|
||||
Reference in new issue
Block a user