From e3254def5769f4f365d5b954698737082dc0a556 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Thu, 20 Aug 2026 18:33:32 +0900 Subject: [PATCH] =?UTF-8?q?test:=20=EB=AF=B8=EC=B6=94=EC=A0=81=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=82=A8=EC=95=84=20=EC=9E=88=EB=8D=98=20Studio=20?= =?UTF-8?q?authz=20=EB=B0=B0=EC=84=A0=20=ED=85=8C=EC=8A=A4=ED=8A=B8=202?= =?UTF-8?q?=EA=B0=9C=EB=A5=BC=20=EC=B6=94=EC=A0=81=EC=97=90=20=EB=84=A3?= =?UTF-8?q?=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 37d5614("fix: make Studio authorization actually work, and stop it failing as a 500")가 고친 배선을 지키는 테스트인데 커밋에 들어가지 않아 작업 트리에만 있었다. 추적되지 않으면 브랜치를 옮길 때 조용히 사라지고, 다른 사람이 같은 저장소를 받아도 그 회귀 게이트를 갖지 못한다. - StudioAuthzWiringTest: MethodSecurityConfig의 advisor가 AuthorizationPort를 생성자로 받는 인프라 빈이라, auto-proxy보다 먼저 만들어지며 AuthorizationAdapter → RolePermissionRegistry → RolePermissionPolicy를 BeanPostProcessor 등록 전에 끌어 올린다는 사실을 고정한다. 바인딩만 따로 보면 통과하지만 앱에서는 죽는 경우다 - StudioAuthzEnvironmentPostProcessorTest: 같은 수정의 환경 설정 쪽 내용은 손대지 않았다. spotless가 요구한 줄바꿈 두 곳만 정규화됐다(의미 변경 없음). AGENTS.md의 commit 정책은 human-only다. 이 커밋은 사용자가 "지금 변경했던 내용을 전부 반영하고 develop과 main에 반영하도록" 지시해 예외로 수행한다. Co-Authored-By: Claude Opus 5 (1M context) --- ...udioAuthzEnvironmentPostProcessorTest.java | 88 +++++++++++++++++++ .../techlog/StudioAuthzWiringTest.java | 76 ++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/techlog/StudioAuthzEnvironmentPostProcessorTest.java create mode 100644 src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/techlog/StudioAuthzWiringTest.java diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/techlog/StudioAuthzEnvironmentPostProcessorTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/techlog/StudioAuthzEnvironmentPostProcessorTest.java new file mode 100644 index 0000000..8fcf301 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/techlog/StudioAuthzEnvironmentPostProcessorTest.java @@ -0,0 +1,88 @@ +package dev.caskeleton.bootstrap.techlog; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.web.authz.RolePermissionPolicy; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.core.ResolvableType; +import org.springframework.core.env.MapPropertySource; +import org.springframework.mock.env.MockEnvironment; + +/** + * 이 후처리기의 산출물은 프로퍼티가 아니라 바인딩 결과다. 프로퍼티가 environment 에 들어갔는지만 보면 통과하면서도 실제 앱에서는 매핑이 죽는 경우가 + * 있다 — application.yml 이 {@code role-permissions: {}} 로 같은 이름을 이미 선언하고 있고, 그 소스가 {@code addLast} 보다 + * 우선순위가 높기 때문이다. 그래서 여기서는 {@code Map>} 로 실제 바인딩해서 확인한다. + */ +class StudioAuthzEnvironmentPostProcessorTest { + + private final StudioAuthzEnvironmentPostProcessor epp = new StudioAuthzEnvironmentPostProcessor(); + + private static final Bindable>> ROLE_PERMISSIONS = + Bindable.of( + ResolvableType.forClassWithGenerics( + Map.class, + ResolvableType.forClass(String.class), + ResolvableType.forClassWithGenerics(List.class, String.class))); + + private static Map> bind(MockEnvironment env) { + return Binder.get(env) + .bind("ca-skeleton.authz.role-permissions", ROLE_PERMISSIONS) + .orElse(Map.of()); + } + + /** + * 앱이 실제로 바인딩하는 대상은 {@code Map} 이 아니라 {@code RolePermissionPolicy} 레코드다(생성자 바인딩). 맵으로만 확인하면 레코드 + * 경로에서만 나타나는 차이를 놓친다. + */ + @Test + void bindsThroughTheRecordTheApplicationActuallyUses() { + MockEnvironment env = new MockEnvironment(); + env.setProperty("app.studio.author-role", "site-admin"); + env.getPropertySources() + .addLast( + new MapPropertySource( + "applicationDefaults", Map.of("ca-skeleton.authz.role-permissions", ""))); + + epp.postProcessEnvironment(env, new SpringApplication()); + + Map> bound = + Binder.get(env) + .bind("ca-skeleton.authz", Bindable.of(RolePermissionPolicy.class)) + .map(RolePermissionPolicy::rolePermissions) + .orElse(Map.of()); + assertThat(bound).containsEntry("site-admin", List.of("studio:read", "studio:write")); + } + + @Test + void grantsBothReadAndWriteToTheConfiguredRole() { + MockEnvironment env = new MockEnvironment(); + env.setProperty("app.studio.author-role", "site-admin"); + + epp.postProcessEnvironment(env, new SpringApplication()); + + assertThat(bind(env)).containsEntry("site-admin", List.of("studio:read", "studio:write")); + } + + /** + * application.yml 이 선언하는 빈 맵을 재현한다. 이것이 매핑을 가리면 Studio 의 모든 쓰기가 403 이 된다 — 읽기는 통과하는데 쓰기만 막히는, + * 진단하기 어려운 모양으로 나타난다. + */ + @Test + void survivesAnEmptyMapDeclaredByTheApplicationDefaults() { + MockEnvironment env = new MockEnvironment(); + env.setProperty("app.studio.author-role", "site-admin"); + env.getPropertySources() + .addLast( + new MapPropertySource( + "applicationDefaults", Map.of("ca-skeleton.authz.role-permissions", ""))); + + epp.postProcessEnvironment(env, new SpringApplication()); + + assertThat(bind(env)).containsEntry("site-admin", List.of("studio:read", "studio:write")); + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/techlog/StudioAuthzWiringTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/techlog/StudioAuthzWiringTest.java new file mode 100644 index 0000000..bf98bc8 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/techlog/StudioAuthzWiringTest.java @@ -0,0 +1,76 @@ +package dev.caskeleton.bootstrap.techlog; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.web.authz.AuthorizationAdapter; +import dev.caskeleton.adapter.inbound.web.authz.RolePermissionPolicy; +import dev.caskeleton.adapter.inbound.web.authz.RolePermissionRegistry; +import dev.caskeleton.application.security.AuthorizationPort; +import dev.caskeleton.shared.security.Permission; +import java.util.Set; +import org.aopalliance.intercept.MethodInvocation; +import org.junit.jupiter.api.Test; +import org.springframework.aop.Advisor; +import org.springframework.aop.support.annotation.AnnotationMatchingPointcut; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Role; +import org.springframework.mock.env.MockEnvironment; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor; + +/** + * 후처리기가 심은 매핑이 실행 중인 컨텍스트에서도 살아 있는지 본다. + * + *

바인딩만 따로 확인하면 통과하지만 앱에서는 죽는 경우가 있어서다. {@code MethodSecurityConfig} 의 advisor 는 auto-proxy 보다 먼저 + * 만들어져야 하는 인프라 빈인데 {@link AuthorizationPort} 를 생성자 파라미터로 받는다. 그래서 {@code AuthorizationAdapter → + * RolePermissionRegistry → RolePermissionPolicy} 가 BeanPostProcessor 등록이 끝나기 전에 끌려 올라온다 — 운영 로그가 이 + * 세 빈에 대해 "not eligible for getting processed by all BeanPostProcessors" 를 정확히 그렇게 찍고 있다. + */ +class StudioAuthzWiringTest { + + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties(RolePermissionPolicy.class) + static class Wiring { + + @Bean + RolePermissionRegistry rolePermissionRegistry(RolePermissionPolicy policy) { + return new RolePermissionRegistry(policy); + } + + @Bean + AuthorizationPort authorizationAdapter(RolePermissionRegistry registry) { + return new AuthorizationAdapter(registry); + } + + /** MethodSecurityConfig 와 같은 모양: 인프라 advisor 가 포트를 직접 받는다. */ + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + static Advisor requiresPermissionAuthorizationAdvisor(AuthorizationPort authorizationPort) { + AuthorizationManager manager = (authentication, invocation) -> null; + return new AuthorizationManagerBeforeMethodInterceptor( + AnnotationMatchingPointcut.forMethodAnnotation(Deprecated.class), manager); + } + } + + @Test + void theConfiguredRoleKeepsBothPermissionsInsideARunningContext() { + MockEnvironment env = new MockEnvironment(); + env.setProperty("app.studio.author-role", "site-admin"); + new StudioAuthzEnvironmentPostProcessor().postProcessEnvironment(env, new SpringApplication()); + + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { + context.setEnvironment(env); + context.register(Wiring.class); + context.refresh(); + + RolePermissionRegistry registry = context.getBean(RolePermissionRegistry.class); + assertThat(registry.effectivePermissions(Set.of("site-admin"))) + .contains(Permission.parse("studio:read"), Permission.parse("studio:write")); + } + } +}