test: 미추적으로 남아 있던 Studio authz 배선 테스트 2개를 추적에 넣는다

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) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-20 18:33:32 +09:00
co-authored by Claude Opus 5
parent 743fee3907
commit e3254def57
2 changed files with 164 additions and 0 deletions
@@ -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;
/**
* 이 후처리기의 산출물은 프로퍼티가 아니라 <em>바인딩 결과</em>다. 프로퍼티가 environment 에 들어갔는지만 보면 통과하면서도 실제 앱에서는 매핑이 죽는 경우가
* 있다 — application.yml 이 {@code role-permissions: {}} 로 같은 이름을 이미 선언하고 있고, 그 소스가 {@code addLast} 보다
* 우선순위가 높기 때문이다. 그래서 여기서는 {@code Map<String, List<String>>} 로 실제 바인딩해서 확인한다.
*/
class StudioAuthzEnvironmentPostProcessorTest {
private final StudioAuthzEnvironmentPostProcessor epp = new StudioAuthzEnvironmentPostProcessor();
private static final Bindable<Map<String, List<String>>> ROLE_PERMISSIONS =
Bindable.of(
ResolvableType.forClassWithGenerics(
Map.class,
ResolvableType.forClass(String.class),
ResolvableType.forClassWithGenerics(List.class, String.class)));
private static Map<String, List<String>> 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<String, List<String>> 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"));
}
}
@@ -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;
/**
* 후처리기가 심은 매핑이 <em>실행 중인 컨텍스트에서도</em> 살아 있는지 본다.
*
* <p>바인딩만 따로 확인하면 통과하지만 앱에서는 죽는 경우가 있어서다. {@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<MethodInvocation> 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"));
}
}
}