helper = $this->app->make(IdentityPolicySyncHelper::class); } public function test_sync_policy_creates_new_policy_on_first_run(): void { $policy = $this->helper->syncPolicy([ 'key' => 'test.p1', 'scope' => 'route', 'target' => 'api.test.one', 'purpose' => 'sensitive_action', 'grace_minutes' => 0, 'enabled' => true, 'source_type' => 'core', 'source_identifier' => 'core', ]); $this->assertSame('test.p1', $policy->key); $this->assertSame('route', $policy->scope->value); $this->assertTrue($policy->enabled); } public function test_sync_policy_updates_fields_not_in_user_overrides(): void { $this->helper->syncPolicy([ 'key' => 'test.p2', 'scope' => 'route', 'target' => 'api.test.two', 'purpose' => 'sensitive_action', 'grace_minutes' => 0, 'enabled' => true, 'source_type' => 'core', 'source_identifier' => 'core', ]); // 운영자가 enabled 를 수정했다고 가정 — user_overrides 에 기록 $policy = IdentityPolicy::where('key', 'test.p2')->first(); $policy->user_overrides = ['enabled']; $policy->enabled = false; $policy->save(); // 재시딩 — enabled 는 user_overrides 에 있으므로 보존, target 은 갱신 $this->helper->syncPolicy([ 'key' => 'test.p2', 'scope' => 'route', 'target' => 'api.test.two_changed', 'purpose' => 'sensitive_action', 'grace_minutes' => 0, 'enabled' => true, // 여기서 true 로 재설정해도 'source_type' => 'core', 'source_identifier' => 'core', ]); $policy->refresh(); $this->assertSame('api.test.two_changed', $policy->target, 'target 은 갱신되어야 함'); $this->assertFalse($policy->enabled, 'enabled 는 user_overrides 에 있어 보존되어야 함'); } /** * conditions 운영자 편집값 보존 (B안 리팩토링). * * 회원가입 단계 등 정책 조건은 운영자가 화면에서 수정 후 user_overrides 에 'conditions' 가 추가되며, * 모듈 재sync 시 declared default 가 운영자 값을 덮어쓰지 않아야 한다. */ public function test_sync_policy_preserves_conditions_in_user_overrides(): void { $this->helper->syncPolicy([ 'key' => 'test.signup.before_submit', 'scope' => 'route', 'target' => 'api.auth.register', 'purpose' => 'signup', 'conditions' => ['signup_stage' => 'before_submit'], 'source_type' => 'module', 'source_identifier' => 'sirsoft-board', ]); $policy = IdentityPolicy::where('key', 'test.signup.before_submit')->first(); $policy->user_overrides = ['conditions']; $policy->conditions = ['signup_stage' => 'after_create']; $policy->save(); $this->helper->syncPolicy([ 'key' => 'test.signup.before_submit', 'scope' => 'route', 'target' => 'api.auth.register', 'purpose' => 'signup', 'conditions' => ['signup_stage' => 'before_submit'], 'source_type' => 'module', 'source_identifier' => 'sirsoft-board', ]); $policy->refresh(); $this->assertSame( ['signup_stage' => 'after_create'], $policy->conditions, 'conditions 는 user_overrides 에 등록되어 있어 declared default 로 덮어써지면 안 됨', ); } /** * trackable 5필드(enabled / grace_minutes / provider_id / fail_mode / conditions) 각각에 대해 * user_overrides 등록 시 syncPolicy 가 운영자 값을 보존하는지 매트릭스 검증. * * @dataProvider trackableFieldProvider */ public function test_sync_preserves_each_trackable_field_in_user_overrides(string $field, mixed $declared, mixed $override): void { $key = "test.matrix.{$field}"; $base = [ 'key' => $key, 'scope' => 'route', 'target' => "api.test.{$field}", 'purpose' => 'sensitive_action', 'grace_minutes' => 0, 'enabled' => true, 'provider_id' => null, 'fail_mode' => 'block', 'conditions' => null, 'source_type' => 'module', 'source_identifier' => 'sirsoft-board', ]; $base[$field] = $declared; $this->helper->syncPolicy($base); // 운영자 변경 시뮬레이션 $policy = IdentityPolicy::where('key', $key)->first(); $policy->user_overrides = [$field]; $policy->{$field} = $override; $policy->save(); // 재시딩 — declared 가 운영자 값을 덮어쓰면 안 됨 $this->helper->syncPolicy($base); $policy->refresh(); $actual = $field === 'conditions' ? $policy->conditions : $policy->{$field}; // enum cast 된 분류 컬럼은 enum 객체이므로 ->value 비교 if ($actual instanceof \BackedEnum) { $actual = $actual->value; } $this->assertSame( $override, $actual, "trackable 필드 '{$field}' 가 user_overrides 에 등록되어 있어 declared default 로 덮어써지면 안 됨", ); } public static function trackableFieldProvider(): array { return [ 'enabled' => ['enabled', true, false], 'grace_minutes' => ['grace_minutes', 0, 60], 'provider_id' => ['provider_id', null, 'g7:core.mail'], 'fail_mode' => ['fail_mode', 'block', 'log_only'], 'conditions' => ['conditions', null, ['signup_stage' => 'after_create']], ]; } public function test_cleanup_stale_removes_policies_not_in_current_keys(): void { $this->helper->syncPolicy([ 'key' => 'test.stale.a', 'scope' => 'route', 'target' => 'a', 'purpose' => 'sensitive_action', 'source_type' => 'module', 'source_identifier' => 'some-module', ]); $this->helper->syncPolicy([ 'key' => 'test.stale.b', 'scope' => 'route', 'target' => 'b', 'purpose' => 'sensitive_action', 'source_type' => 'module', 'source_identifier' => 'some-module', ]); // b 만 현재 선언 — a 는 stale 로 삭제되어야 함 $removed = $this->helper->cleanupStalePolicies('module', 'some-module', ['test.stale.b']); $this->assertSame(1, $removed); $this->assertNull(IdentityPolicy::where('key', 'test.stale.a')->first()); $this->assertNotNull(IdentityPolicy::where('key', 'test.stale.b')->first()); } public function test_cleanup_stale_preserves_other_sources(): void { $this->helper->syncPolicy([ 'key' => 'test.admin.one', 'scope' => 'route', 'target' => 'a', 'purpose' => 'sensitive_action', 'source_type' => 'admin', 'source_identifier' => 'admin', ]); // core source cleanup 은 admin source 정책에 영향 없어야 함 $this->helper->cleanupStalePolicies('core', 'core', []); $this->assertNotNull(IdentityPolicy::where('key', 'test.admin.one')->first()); } }