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

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

- 설정 저장·복원·초기화가 상주 프로세스 미러를 갱신하도록 단일 소유자화
- 만료 데이터 자동 정리 예약 12종 신설, 파기는 기본키 배치로 분할
- 회원 탈퇴를 전 경로 원자화하고 관리자 상태변경 경로를 정식 탈퇴로 통일
- 일괄 처리 목록의 선택을 화면에 보이는 행으로 한정(selectionScope)
- 비밀번호 정책 문구를 도메인 중립 라벨로 정정
- 정적 검사 2종·검증 스킬 설계 정합성 축 신설
This commit is contained in:
HeuJung
2026-08-14 11:06:25 +09:00
parent 49c155509e
commit 4e1ec6e001
136 changed files with 7016 additions and 504 deletions
@@ -9,6 +9,7 @@
### Fixed
- 빌드 산출물이 존재하지 않는 소스맵 파일을 참조해 브라우저 개발자 도구 사용 시 불필요한 404 요청이 발생하던 문제를 수정했습니다.
- 회원 탈퇴·삭제 시 동의 이력 정리가 중간에 실패하면 일부 항목만 처리된 채 남던 문제를 수정했습니다 — 이제 전부 처리되거나 전부 취소됩니다. (#112 @Tuwasduliebst 님께서 제보해주셨습니다.)
## [1.0.2] - 2026-08-10
@@ -295,9 +295,12 @@ class GdprConsentService
{
$activeConsents = $this->statusRepository->getActiveByUserId($userId);
foreach ($activeConsents as $consent) {
$this->updateConsent($userId, null, $consent->consent_key, false, 'withdraw');
}
// 루프 중간에 실패하면 일부 항목만 철회된 채 남는다 — 전부 철회되거나 전부 취소되게 한다.
DB::transaction(function () use ($userId, $activeConsents) {
foreach ($activeConsents as $consent) {
$this->updateConsent($userId, null, $consent->consent_key, false, 'withdraw');
}
});
}
/**
@@ -312,8 +315,11 @@ class GdprConsentService
*/
public function purgeOnUserDelete(int $userId): void
{
$this->statusRepository->deleteByUserId($userId);
$this->historyRepository->anonymizeForUser($userId);
// 삭제만 되고 익명화가 실패하면 감사 이력에 회원 식별자가 그대로 남는다.
DB::transaction(function () use ($userId) {
$this->statusRepository->deleteByUserId($userId);
$this->historyRepository->anonymizeForUser($userId);
});
}
/**
@@ -0,0 +1,94 @@
<?php
namespace Plugins\Sirsoft\Gdpr\Tests\Feature;
use App\Models\User;
use Mockery;
use Plugins\Sirsoft\Gdpr\Repositories\Contracts\GdprUserConsentHistoryRepositoryInterface;
use Plugins\Sirsoft\Gdpr\Repositories\Contracts\GdprUserConsentRepositoryInterface;
use Plugins\Sirsoft\Gdpr\Services\GdprConsentService;
use Plugins\Sirsoft\Gdpr\Tests\PluginTestCase;
use RuntimeException;
/**
* 동의 이력 정리 원자성 테스트 (공개이슈 #112)
*
* 탈퇴 시 항목별 철회는 루프이고, 삭제 시 status 삭제와 history 익명화는 2단계다.
* 중간에 실패하면 일부 항목만 철회되거나, 삭제만 되고 감사 이력에 식별자가 남는다.
*
* @scenario trigger=withdraw, trigger=delete, failure_injection=mid_loop, failure_injection=second_step
*
* @effects partial_revoke_rolled_back, delete_and_anonymize_are_atomic
*/
class GdprConsentAtomicityTest extends PluginTestCase
{
/**
* 탈퇴 철회 루프가 중간에 실패하면 앞서 철회된 항목도 되돌아간다.
*/
public function test_revoke_all_on_withdraw_rolls_back_partial_loop(): void
{
$user = User::factory()->create();
$service = app(GdprConsentService::class);
// 동의 2건을 만든 뒤 철회 루프를 중간에서 실패시킨다
$service->updateConsents($user->id, null, ['marketing' => true, 'analytics' => true], 'signup');
$activeBefore = app(GdprUserConsentRepositoryInterface::class)
->getActiveByUserId($user->id)
->count();
$this->assertGreaterThanOrEqual(2, $activeBefore, '철회 대상 동의가 2건 이상이어야 합니다');
// history 기록을 실패시켜 루프 중간에서 예외를 유발한다
$failing = Mockery::mock(app(GdprUserConsentHistoryRepositoryInterface::class));
$failing->shouldReceive('record')->andThrow(new RuntimeException('이력 기록 실패'));
$this->app->instance(GdprUserConsentHistoryRepositoryInterface::class, $failing);
try {
app(GdprConsentService::class)->revokeAllOnWithdraw($user->id);
$this->fail('철회 루프 실패가 전파되어야 합니다.');
} catch (\Throwable $e) {
$this->assertStringContainsString('이력 기록 실패', $e->getMessage());
}
// 실패 시: 앞 항목만 철회되고 뒤 항목은 그대로인 반쪽 상태
$this->app->forgetInstance(GdprUserConsentHistoryRepositoryInterface::class);
$activeAfter = app(GdprUserConsentRepositoryInterface::class)
->getActiveByUserId($user->id)
->count();
$this->assertSame($activeBefore, $activeAfter, '철회가 일부만 반영된 채 남았습니다');
}
/**
* 삭제 시 history 익명화가 실패하면 status 삭제도 되돌아간다.
*/
public function test_purge_on_user_delete_rolls_back_when_anonymize_fails(): void
{
$user = User::factory()->create();
$service = app(GdprConsentService::class);
$service->updateConsents($user->id, null, ['marketing' => true], 'signup');
$before = app(GdprUserConsentRepositoryInterface::class)->getActiveByUserId($user->id)->count();
$this->assertGreaterThan(0, $before);
$failing = Mockery::mock(app(GdprUserConsentHistoryRepositoryInterface::class));
$failing->shouldReceive('anonymizeForUser')->andThrow(new RuntimeException('익명화 실패'));
$this->app->instance(GdprUserConsentHistoryRepositoryInterface::class, $failing);
try {
app(GdprConsentService::class)->purgeOnUserDelete($user->id);
$this->fail('익명화 실패가 전파되어야 합니다.');
} catch (\Throwable $e) {
$this->assertStringContainsString('익명화 실패', $e->getMessage());
}
// 실패 시: 동의 status 는 지워졌는데 감사 이력에는 회원 식별자가 남는 상태
$this->app->forgetInstance(GdprUserConsentHistoryRepositoryInterface::class);
$this->assertSame(
$before,
app(GdprUserConsentRepositoryInterface::class)->getActiveByUserId($user->id)->count()
);
}
}
@@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Fixed
- 빌드 산출물이 존재하지 않는 소스맵 파일을 참조해 브라우저 개발자 도구 사용 시 불필요한 404 요청이 발생하던 문제를 수정했습니다.
- 회원 탈퇴·삭제 시 본인확인 기록 파기와 이력 익명화 중 한쪽만 처리된 채 남을 수 있던 문제를 수정했습니다 — 이제 두 처리가 함께 완료됩니다. (#112 @Tuwasduliebst 님께서 제보해주셨습니다.)
## [1.0.2] - 2026-08-10
@@ -4,6 +4,7 @@ namespace Plugins\Sirsoft\VerificationKginicis\Listeners;
use App\Contracts\Extension\HookListenerInterface;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Plugins\Sirsoft\VerificationKginicis\Repositories\InicisIdentityLogQueryRepositoryInterface;
use Plugins\Sirsoft\VerificationKginicis\Repositories\InicisIdentityRecordRepositoryInterface;
@@ -69,7 +70,11 @@ class CleanInicisRecordOnUserDelete implements HookListenerInterface
return;
}
$this->recordRepository->deleteByUserId($userId);
$this->logQueryRepository->anonymizeUserId($userId);
// record 파기와 log 익명화는 함께 성공해야 한다 — 한쪽만 끝나면
// 파기된 것으로 보이는데 이력에 식별자가 남거나, 그 반대가 된다.
DB::transaction(function () use ($userId) {
$this->recordRepository->deleteByUserId($userId);
$this->logQueryRepository->anonymizeUserId($userId);
});
}
}
@@ -4,6 +4,7 @@ namespace Plugins\Sirsoft\VerificationKginicis\Listeners;
use App\Contracts\Extension\HookListenerInterface;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Plugins\Sirsoft\VerificationKginicis\Repositories\InicisIdentityLogQueryRepositoryInterface;
use Plugins\Sirsoft\VerificationKginicis\Repositories\InicisIdentityRecordRepositoryInterface;
@@ -60,7 +61,11 @@ class CleanInicisRecordOnUserWithdraw implements HookListenerInterface
$userId = (int) $user->id;
$this->recordRepository->deleteByUserId($userId);
$this->logQueryRepository->anonymizeUserId($userId);
// record 파기와 log 익명화는 함께 성공해야 한다 — 한쪽만 끝나면
// 파기된 것으로 보이는데 이력에 식별자가 남거나, 그 반대가 된다.
DB::transaction(function () use ($userId) {
$this->recordRepository->deleteByUserId($userId);
$this->logQueryRepository->anonymizeUserId($userId);
});
}
}
@@ -0,0 +1,153 @@
<?php
namespace Plugins\Sirsoft\VerificationKginicis\Tests\Feature\Listeners;
use App\Enums\IdentityVerificationStatus;
use App\Extension\HookManager;
use App\Models\IdentityVerificationLog;
use App\Models\User;
use App\Services\IdentityLogService;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Mockery;
use Plugins\Sirsoft\VerificationKginicis\Repositories\InicisIdentityLogQueryRepositoryInterface;
use Plugins\Sirsoft\VerificationKginicis\Repositories\InicisIdentityRecordRepositoryInterface;
use Plugins\Sirsoft\VerificationKginicis\Tests\PluginTestCase;
use RuntimeException;
/**
* 본인확인 정리 원자성 + 연쇄 삭제 테스트 (공개이슈 #112 / #110)
*
* record 파기와 로그 익명화는 함께 성공해야 한다 — 한쪽만 끝나면 파기된 것으로 보이는데
* 이력에 식별자가 남거나, 그 반대가 된다.
*
* 함께, 보관주기 파기(identity:prune-logs)가 challenge 매핑까지 연쇄 삭제하는지 고정한다.
*
* @scenario trigger=withdraw, trigger=delete, trigger=prune_logs, failure_injection=log_anonymize_fails
*
* @effects record_purge_rolled_back, half_done_state_prevented, prune_logs_cascades_to_challenge_mappings
*/
class CleanInicisRecordAtomicityTest extends PluginTestCase
{
/**
* 로그 익명화가 실패하면 record 파기도 되돌아간다.
*/
public function test_record_purge_rolls_back_when_log_anonymize_fails(): void
{
$user = User::factory()->create();
$this->seedRecordForUser($user);
$this->assertDatabaseHas('inicis_identity_records', ['user_id' => $user->id]);
// 두 번째 단계(로그 익명화)만 실패시킨다
$failing = Mockery::mock(app(InicisIdentityLogQueryRepositoryInterface::class));
$failing->shouldReceive('anonymizeUserId')->andThrow(new RuntimeException('익명화 실패'));
$this->app->instance(InicisIdentityLogQueryRepositoryInterface::class, $failing);
try {
HookManager::doAction('core.user.after_withdraw', $user);
$this->fail('로그 익명화 실패가 전파되어야 합니다.');
} catch (\Throwable $e) {
$this->assertStringContainsString('익명화 실패', $e->getMessage());
}
// 선행 단계(record 파기)가 원상 복구되어야 한다
// (실패 시: record 만 파기되고 이력 익명화는 실패한 반쪽 상태)
$this->assertDatabaseHas('inicis_identity_records', ['user_id' => $user->id]);
}
/**
* 삭제 훅에서도 같은 원자성이 보장된다.
*/
public function test_delete_hook_rolls_back_when_log_anonymize_fails(): void
{
$user = User::factory()->create();
$this->seedRecordForUser($user);
$failing = Mockery::mock(app(InicisIdentityLogQueryRepositoryInterface::class));
$failing->shouldReceive('anonymizeUserId')->andThrow(new RuntimeException('익명화 실패'));
$this->app->instance(InicisIdentityLogQueryRepositoryInterface::class, $failing);
try {
HookManager::doAction('core.user.before_delete', $user);
$this->fail('로그 익명화 실패가 전파되어야 합니다.');
} catch (\Throwable $e) {
$this->assertStringContainsString('익명화 실패', $e->getMessage());
}
$this->assertDatabaseHas('inicis_identity_records', ['user_id' => $user->id]);
}
/**
* B6 연쇄: 보관주기 파기가 challenge 매핑까지 함께 지운다.
*/
public function test_prune_logs_cascades_to_challenge_mappings(): void
{
$expiredLog = $this->seedChallengeLog(now()->subDays(200));
$recentLog = $this->seedChallengeLog(now()->subDays(10));
$this->assertDatabaseHas('inicis_challenge_mappings', ['challenge_id' => $expiredLog]);
$this->assertDatabaseHas('inicis_challenge_mappings', ['challenge_id' => $recentLog]);
app(IdentityLogService::class)->purge(180);
$this->assertDatabaseMissing('identity_verification_logs', ['id' => $expiredLog]);
// 실패 시: 이력은 파기됐는데 challenge 매핑이 고아로 남은 상태
$this->assertDatabaseMissing('inicis_challenge_mappings', ['challenge_id' => $expiredLog]);
// 보관기간 이내 이력과 그 매핑은 보존된다
$this->assertDatabaseHas('identity_verification_logs', ['id' => $recentLog]);
$this->assertDatabaseHas('inicis_challenge_mappings', ['challenge_id' => $recentLog]);
}
/**
* 본인확인 record 를 시드합니다.
*
* @param User $user 대상 사용자
*/
private function seedRecordForUser(User $user): void
{
app(InicisIdentityRecordRepositoryInterface::class)->upsertForUser((int) $user->id, [
'name_encrypted' => Crypt::encryptString('홍길동'),
'phone_encrypted' => Crypt::encryptString('01012345678'),
'birthday_encrypted' => Crypt::encryptString('19900101'),
'di_encrypted' => Crypt::encryptString('DI-VAL-'.$user->id),
'di_hash' => hash('sha256', 'DI-VAL-'.$user->id),
'gender' => 'M',
'is_foreigner' => false,
'is_adult' => true,
'provider_dev_cd' => 'SKT',
'verified_at' => now(),
]);
}
/**
* challenge 로그 + mTxId 매핑을 시드합니다.
*
* @param Carbon $createdAt 생성 시각
* @return string 생성된 challenge UUID
*/
private function seedChallengeLog($createdAt): string
{
$log = IdentityVerificationLog::create([
'id' => (string) Str::uuid(),
'provider_id' => 'inicis',
'purpose' => 'signup',
'channel' => 'sms',
'target_hash' => hash('sha256', Str::random()),
'status' => IdentityVerificationStatus::Verified->value,
'expires_at' => $createdAt,
]);
$log->forceFill(['created_at' => $createdAt])->saveQuietly();
DB::table('inicis_challenge_mappings')->insert([
'mtxid' => Str::random(18),
'challenge_id' => $log->id,
'created_at' => $createdAt,
]);
return $log->id;
}
}
@@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Fixed
- 빌드 산출물이 존재하지 않는 소스맵 파일을 참조해 브라우저 개발자 도구 사용 시 불필요한 404 요청이 발생하던 문제를 수정했습니다.
- 회원 탈퇴·삭제 시 본인확인 기록 파기와 이력 익명화 중 한쪽만 처리된 채 남을 수 있던 문제를 수정했습니다 — 이제 두 처리가 함께 완료됩니다. (#112 @Tuwasduliebst 님께서 제보해주셨습니다.)
## [1.0.0] - 2026-08-10
@@ -4,6 +4,7 @@ namespace Plugins\Sirsoft\VerificationNhnkcp\Listeners;
use App\Contracts\Extension\HookListenerInterface;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Plugins\Sirsoft\VerificationNhnkcp\Repositories\KcpIdentityLogQueryRepositoryInterface;
use Plugins\Sirsoft\VerificationNhnkcp\Repositories\KcpIdentityRecordRepositoryInterface;
@@ -61,7 +62,11 @@ class CleanKcpRecordOnUserDelete implements HookListenerInterface
return;
}
$this->recordRepository->deleteByUserId($userId);
$this->logQueryRepository->anonymizeUserId($userId);
// record 파기와 log 익명화는 함께 성공해야 한다 — 한쪽만 끝나면
// 파기된 것으로 보이는데 이력에 식별자가 남거나, 그 반대가 된다.
DB::transaction(function () use ($userId) {
$this->recordRepository->deleteByUserId($userId);
$this->logQueryRepository->anonymizeUserId($userId);
});
}
}
@@ -4,6 +4,7 @@ namespace Plugins\Sirsoft\VerificationNhnkcp\Listeners;
use App\Contracts\Extension\HookListenerInterface;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Plugins\Sirsoft\VerificationNhnkcp\Repositories\KcpIdentityLogQueryRepositoryInterface;
use Plugins\Sirsoft\VerificationNhnkcp\Repositories\KcpIdentityRecordRepositoryInterface;
@@ -59,7 +60,11 @@ class CleanKcpRecordOnUserWithdraw implements HookListenerInterface
return;
}
$this->recordRepository->deleteByUserId($userId);
$this->logQueryRepository->anonymizeUserId($userId);
// record 파기와 log 익명화는 함께 성공해야 한다 — 한쪽만 끝나면
// 파기된 것으로 보이는데 이력에 식별자가 남거나, 그 반대가 된다.
DB::transaction(function () use ($userId) {
$this->recordRepository->deleteByUserId($userId);
$this->logQueryRepository->anonymizeUserId($userId);
});
}
}
@@ -0,0 +1,97 @@
<?php
namespace Plugins\Sirsoft\VerificationNhnkcp\Tests\Feature\Listeners;
use App\Extension\HookManager;
use App\Models\User;
use Mockery;
use Plugins\Sirsoft\VerificationNhnkcp\Repositories\KcpIdentityLogQueryRepositoryInterface;
use Plugins\Sirsoft\VerificationNhnkcp\Repositories\KcpIdentityRecordRepositoryInterface;
use Plugins\Sirsoft\VerificationNhnkcp\Tests\PluginTestCase;
use RuntimeException;
/**
* 본인확인 정리 원자성 테스트 (공개이슈 #112)
*
* record 파기와 로그 익명화는 함께 성공해야 한다 — 한쪽만 끝나면 파기된 것으로 보이는데
* 이력에 식별자가 남거나, 그 반대가 된다.
*
* @scenario trigger=withdraw, trigger=delete, failure_injection=log_anonymize_fails
*
* @effects record_purge_rolled_back, half_done_state_prevented
*/
class CleanKcpRecordAtomicityTest extends PluginTestCase
{
/**
* 탈퇴 훅: 로그 익명화가 실패하면 record 파기도 되돌아간다.
*/
public function test_record_purge_rolls_back_when_log_anonymize_fails_on_withdraw(): void
{
$user = $this->seedUserWithRecord();
$this->failLogAnonymize();
try {
HookManager::doAction('core.user.after_withdraw', $user);
$this->fail('로그 익명화 실패가 전파되어야 합니다.');
} catch (\Throwable $e) {
$this->assertStringContainsString('익명화 실패', $e->getMessage());
}
// 실패 시: record 만 파기되고 이력에는 식별자가 남는 반쪽 상태
$this->assertNotNull(
app(KcpIdentityRecordRepositoryInterface::class)->findByUserId($user->id)
);
}
/**
* 삭제 훅에서도 같은 원자성이 보장된다.
*/
public function test_record_purge_rolls_back_when_log_anonymize_fails_on_delete(): void
{
$user = $this->seedUserWithRecord();
$this->failLogAnonymize();
try {
HookManager::doAction('core.user.before_delete', $user);
$this->fail('로그 익명화 실패가 전파되어야 합니다.');
} catch (\Throwable $e) {
$this->assertStringContainsString('익명화 실패', $e->getMessage());
}
$this->assertNotNull(
app(KcpIdentityRecordRepositoryInterface::class)->findByUserId($user->id)
);
}
/**
* record 를 가진 사용자를 만듭니다.
*
* @return User 생성된 사용자
*/
private function seedUserWithRecord(): User
{
$user = User::factory()->create();
app(KcpIdentityRecordRepositoryInterface::class)->upsertForUser($user->id, [
'di_hash' => hash_hmac('sha256', 'DI-ATOMIC-'.$user->id, (string) config('app.key')),
'verified_at' => now(),
]);
$this->assertNotNull(app(KcpIdentityRecordRepositoryInterface::class)->findByUserId($user->id));
return $user;
}
/**
* 두 번째 단계(로그 익명화)만 실패시킵니다.
*/
private function failLogAnonymize(): void
{
$failing = Mockery::mock(app(KcpIdentityLogQueryRepositoryInterface::class));
$failing->shouldReceive('anonymizeUserId')->andThrow(new RuntimeException('익명화 실패'));
$this->app->instance(KcpIdentityLogQueryRepositoryInterface::class, $failing);
}
}