Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,12 @@
/**
* 브라우저에 RC5 sessionId를 노출하지 않고 단명 pairingId로 감싼다.
*
* pairingId는 이번 연결의 bearer capability다. 프런트는 메모리에만 보관하고,
* 서버는 최초 매핑에 사용한 정규화 입력 전체를 고정한다. 승인 시 입력이 달라졌거나
* 이미 실행 중인 연결이면 RC5를 호출하지 않는다.
* pairingId는 이번 연결의 bearer capability다. 사용자 profileId는 pairing 동안
* 변경할 수 없고, 실행 전까지 같은 사용자의 최신 정규화 profile/sessionContext로
* 다시 바인딩할 수 있다. 주문표를 다시 고르는 것은 사람이 바뀐 것이 아니기 때문이다.
*
* 승인 시에는 마지막으로 바인딩한 입력과 승인 입력이 정확히 같은지 검증하고,
* 한 요청만 EXECUTING 상태로 전환한다.
*
* SIMULATION_ONLY 단일 인스턴스용 구현이다. 실제품/다중 인스턴스에서는 같은 원자적
* 상태 전이를 Redis 또는 DB로 옮기고 실제 Agent claim 검증을 앞단에 추가해야 한다.
Expand Down Expand Up @@ -71,31 +74,55 @@ public CreatedPairing register(String rc5SessionId, String environmentId, String
);
}

/** 최초 매핑 입력을 고정한다. 같은 입력의 재시도만 멱등하게 허용한다. */
/**
* 사용자 identity(profileId)를 pairing에 묶고, 실행 전까지 같은 사용자의
* 최신 정규화 입력으로 갱신한다.
*/
public void bindInput(
String pairingId,
CanonicalProfile profile,
ChickenStoreSessionContext sessionContext
String pairingId,
CanonicalProfile profile,
ChickenStoreSessionContext sessionContext
) {
requireText(pairingId, "pairingId");
Objects.requireNonNull(profile, "profile");
Objects.requireNonNull(sessionContext, "sessionContext");
requireText(profile.profileId(), "profile.profileId");

pairings.compute(pairingId, (ignoredPairingId, current) -> {
Binding binding = requireUsable(current);

pairings.compute(pairingId, (ignoredPairingId, current) -> { // current는 현재 저장된 연결 상태
Binding binding = requireUsable(current); // 존재 및 만료 검사를 통과한 연결
if (binding.status() == Status.EXECUTING) {
throw conflict("PAIRING_ALREADY_EXECUTING", "이미 처리 중인 연결입니다.");
throw conflict(
"PAIRING_ALREADY_EXECUTING",
"이미 처리 중인 연결입니다."
);
}

// 첫 입력
if (binding.profileSnapshot() == null) {
return binding.withInput(profile, sessionContext);
}
if (!binding.profileSnapshot().equals(profile)) {
throw conflict("PAIRING_PROFILE_CHANGED", "연결 이후 프로필 정보가 변경되었습니다.");
}
if (!binding.contextSnapshot().equals(sessionContext)) {
throw conflict("PAIRING_CONTEXT_CHANGED", "연결 이후 주문 조건이 변경되었습니다.");

// pairing에 연결된 사람은 바꿀 수 없다. 주문표와 함께 달라지는 전체
// profile 값이 아니라 사람 단위로 안정적인 profileId로 identity를 비교한다.
if (!Objects.equals(
binding.profileSnapshot().profileId(),
profile.profileId()
)) {
throw conflict(
"PAIRING_PROFILE_CHANGED",
"연결 이후 사용자 프로필이 변경되었습니다."
);
}
return binding;

/*
* 같은 사용자라면 실행 전까지 최신 정규화 profile + sessionContext로
* 갱신한다. 뒤로 가서 다른 주문표를 고른 경우도 이 경로를 탄다.
*
* profile도 같이 갱신하는 이유:
* collectedAt 같은 정규화 메타데이터가 새로 만들어질 수 있기 때문.
*/
return binding.withInput(profile, sessionContext);
});
}

Expand All @@ -116,10 +143,10 @@ public Reservation reserveForExecution(
throw conflict("PAIRING_INPUT_NOT_BOUND", "연결에 사용할 주문표가 아직 지정되지 않았습니다.");
}
if (!binding.profileSnapshot().equals(profile)) {
throw forbidden("PAIRING_PROFILE_MISMATCH", "최초 연결 프로필과 승인 프로필이 다릅니다.");
throw forbidden("PAIRING_PROFILE_MISMATCH", "마지막으로 바인딩한 프로필과 승인 프로필이 다릅니다.");
}
if (!binding.contextSnapshot().equals(sessionContext)) {
throw forbidden("PAIRING_CONTEXT_MISMATCH", "최초 확인 조건과 승인 조건이 다릅니다.");
throw forbidden("PAIRING_CONTEXT_MISMATCH", "마지막으로 바인딩한 주문 조건과 승인 조건이 다릅니다.");
}
if (binding.status() == Status.EXECUTING) {
throw conflict("PAIRING_ALREADY_EXECUTING", "이미 처리 중인 연결입니다.");
Expand Down Expand Up @@ -183,8 +210,8 @@ private record Binding(
String rc5SessionId, // 서버 내부에서만 사용하는 실제 RC5 세션 ID
String environmentId, // RC5 세션이 속한 시뮬레이션 환경 ID
String initialState, // RC5 환경의 시작 상태
CanonicalProfile profileSnapshot, // 최초 bind 시 고정한 사용자 프로필
ChickenStoreSessionContext contextSnapshot, // 최초 bind 시 고정한 주문 조건
CanonicalProfile profileSnapshot, // 실행 전에 마지막으로 바인딩한 사용자 프로필
ChickenStoreSessionContext contextSnapshot, // 실행 전에 마지막으로 바인딩한 주문 조건
Instant expiresAt, // 이 연결을 사용할 수 있는 마지막 시각
Status status // 입력 대기·활성·실행 중 상태
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.kiobridge.kiobridge.common.web.ApiException;
import com.kiobridge.kiobridge.contracts.input.context.ChickenStoreSessionContext;
import com.kiobridge.kiobridge.contracts.input.profile.CanonicalProfile;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import java.security.SecureRandom;
Expand All @@ -15,6 +16,7 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

class PairingRegistryTest {

Expand All @@ -23,6 +25,11 @@ class PairingRegistryTest {
private final CanonicalProfile profile = mock(CanonicalProfile.class);
private final ChickenStoreSessionContext context = mock(ChickenStoreSessionContext.class);

@BeforeEach
void setUp() {
when(profile.profileId()).thenReturn("user-1");
}

@Test
void rc5세션을_노출하지_않는_서로_다른_256비트_pairingId를_발급한다() {
var first = registry.register("SIM-001", "chicken-store", "SERVICE_TYPE");
Expand All @@ -47,28 +54,174 @@ class PairingRegistryTest {
}

@Test
void 다른_프로필이나_주문조건으로_바꿀_수_없다() {
void 다른_사용자_프로필로는_재바인딩할_수_없다() {
String pairingId = newPairing();
registry.bindInput(pairingId, profile, context);

assertThatThrownBy(() -> registry.bindInput(pairingId, mock(CanonicalProfile.class), context))
.isInstanceOfSatisfying(ApiException.class,
e -> assertThat(e.code()).isEqualTo("PAIRING_PROFILE_CHANGED"));
CanonicalProfile firstProfile = mock(CanonicalProfile.class);
CanonicalProfile anotherProfile = mock(CanonicalProfile.class);

assertThatThrownBy(() -> registry.bindInput(pairingId, profile, mock(ChickenStoreSessionContext.class)))
.isInstanceOfSatisfying(ApiException.class,
e -> assertThat(e.code()).isEqualTo("PAIRING_CONTEXT_CHANGED"));
when(firstProfile.profileId()).thenReturn("user-1");
when(anotherProfile.profileId()).thenReturn("user-2");

registry.bindInput(pairingId, firstProfile, context);

assertThatThrownBy(
() -> registry.bindInput(pairingId, anotherProfile, context)
)
.isInstanceOfSatisfying(
ApiException.class,
e -> assertThat(e.code())
.isEqualTo("PAIRING_PROFILE_CHANGED")
);
}

@Test
void 비어_있는_profileId는_바인딩하지_않는다() {
for (String invalidProfileId : new String[]{null, "", " "}) {
String pairingId = newPairing();
CanonicalProfile invalidProfile = mock(CanonicalProfile.class);
when(invalidProfile.profileId()).thenReturn(invalidProfileId);

assertThatThrownBy(
() -> registry.bindInput(pairingId, invalidProfile, context)
)
.isInstanceOfSatisfying(
ApiException.class,
e -> assertThat(e.code())
.isEqualTo("REQUIRED_FIELD_MISSING")
);

registry.bindInput(pairingId, profile, context);
assertThat(registry.reserveForExecution(pairingId, profile, context).rc5SessionId())
.isEqualTo("SIM-001");
}
}

@Test
void 승인_입력도_최초_스냅샷과_같아야_한다() {
void 같은_사용자는_실행_전까지_주문조건을_변경할_수_있다() {
String pairingId = newPairing();

CanonicalProfile firstProfile = mock(CanonicalProfile.class);
CanonicalProfile reboundProfile = mock(CanonicalProfile.class);

when(firstProfile.profileId()).thenReturn("user-1");
when(reboundProfile.profileId()).thenReturn("user-1");

ChickenStoreSessionContext firstContext =
mock(ChickenStoreSessionContext.class);
ChickenStoreSessionContext changedContext =
mock(ChickenStoreSessionContext.class);

registry.bindInput(
pairingId,
firstProfile,
firstContext
);

registry.bindInput(
pairingId,
reboundProfile,
changedContext
);


var reservation = registry.reserveForExecution(
pairingId,
reboundProfile,
changedContext
);

assertThat(reservation.rc5SessionId())
.isEqualTo("SIM-001");
}

@Test
void 승인에는_마지막으로_바인딩한_주문조건을_사용해야_한다() {
String pairingId = newPairing();

CanonicalProfile firstProfile = mock(CanonicalProfile.class);
CanonicalProfile latestProfile = mock(CanonicalProfile.class);

when(firstProfile.profileId()).thenReturn("user-1");
when(latestProfile.profileId()).thenReturn("user-1");

ChickenStoreSessionContext oldContext =
mock(ChickenStoreSessionContext.class);
ChickenStoreSessionContext latestContext =
mock(ChickenStoreSessionContext.class);

registry.bindInput(
pairingId,
firstProfile,
oldContext
);

registry.bindInput(
pairingId,
latestProfile,
latestContext
);

assertThatThrownBy(
() -> registry.reserveForExecution(
pairingId,
latestProfile,
oldContext
)
)
.isInstanceOfSatisfying(
ApiException.class,
e -> assertThat(e.code())
.isEqualTo("PAIRING_CONTEXT_MISMATCH")
);
}

@Test
void 실행이_시작되면_입력을_다시_바인딩할_수_없다() {
String pairingId = newPairing();

when(profile.profileId()).thenReturn("user-1");

ChickenStoreSessionContext changedContext =
mock(ChickenStoreSessionContext.class);

registry.bindInput(pairingId, profile, context);
registry.reserveForExecution(pairingId, profile, context);

assertThatThrownBy(
() -> registry.bindInput(
pairingId,
profile,
changedContext
)
)
.isInstanceOfSatisfying(
ApiException.class,
e -> assertThat(e.code())
.isEqualTo("PAIRING_ALREADY_EXECUTING")
);
}

@Test
void 승인_입력도_마지막으로_바인딩한_스냅샷과_같아야_한다() {
String pairingId = newPairing();
CanonicalProfile firstProfile = mock(CanonicalProfile.class);
CanonicalProfile latestProfile = mock(CanonicalProfile.class);
ChickenStoreSessionContext latestContext = mock(ChickenStoreSessionContext.class);

when(firstProfile.profileId()).thenReturn("user-1");
when(latestProfile.profileId()).thenReturn("user-1");

registry.bindInput(pairingId, firstProfile, context);
registry.bindInput(pairingId, latestProfile, latestContext);

assertThatThrownBy(() -> registry.reserveForExecution(
pairingId, mock(CanonicalProfile.class), context
pairingId, firstProfile, latestContext
)).isInstanceOfSatisfying(ApiException.class,
e -> assertThat(e.code()).isEqualTo("PAIRING_PROFILE_MISMATCH"));

assertThat(registry.reserveForExecution(pairingId, latestProfile, latestContext).rc5SessionId())
.isEqualTo("SIM-001");
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class OrchestratorControllerPairingTest {
ChickenStoreSessionContext context = mock(ChickenStoreSessionContext.class);
Recommendation recommendation = mock(Recommendation.class);
UserDecision decision = mock(UserDecision.class);
when(profile.profileId()).thenReturn("user-1");
String pairingId = registry.register(
"SIM-SECRET-001", "chicken-store", "SERVICE_TYPE"
).pairingId();
Expand Down Expand Up @@ -97,6 +98,7 @@ class OrchestratorControllerPairingTest {
ChickenStoreSessionContext context = mock(ChickenStoreSessionContext.class);
Recommendation recommendation = mock(Recommendation.class);
UserDecision decision = mock(UserDecision.class);
when(profile.profileId()).thenReturn("user-1");
String pairingId = registry.register(
"SIM-SECRET-001", "chicken-store", "SERVICE_TYPE"
).pairingId();
Expand Down Expand Up @@ -131,6 +133,8 @@ class OrchestratorControllerPairingTest {
ChickenStoreSessionContext context = mock(ChickenStoreSessionContext.class);
Recommendation recommendation = mock(Recommendation.class);
UserDecision decision = mock(UserDecision.class);
when(boundProfile.profileId()).thenReturn("user-1");
when(changedProfile.profileId()).thenReturn("user-2");
String pairingId = registry.register(
"SIM-SECRET-001", "chicken-store", "SERVICE_TYPE"
).pairingId();
Expand Down
Loading
Loading