test: unit-тесты для ScopeContext, IdempotencyService, DictionaryRecordService + fix exclusion 500 → 409

35 unit-тестов зелёных:

ordinis-auth (11): ScopeContextTest
- Маппинг ролей: ordinis-public/ORDINIS_INTERNAL/x-RESTRICTED/PUBLIC
- Nested claim path realm_access.roles + кастомный путь scopes
- Anonymous + query allowed/disallowed
- JWT всегда побеждает query as_scope (escalation defense)
- Unrecognized roles → PUBLIC fallback

ordinis-rest-api/idempotency (13): IdempotencyServiceTest
- hashRequest: deterministic, sha-256 hex 64 chars, null=empty
- lookup: empty/match/422 на mismatch
- reserve: saveAndFlush, race с тем же hash → 409 InProgress, race с разным hash → 422 Conflict
- saveResponse: skip missing, persist parsed JSON, gracefully игнорит non-JSON

ordinis-rest-api/service (11): DictionaryRecordServiceTest
- resolveBusinessKey: explicit / no-source missing key throws / x-id-source derive /
  match accepted / mismatch throws / missing source field throws
- enforceUniqueFields: no-unique no-op / no-conflict / conflict 409 /
  excludes own record on update / skips null values

Hotfix backend: exclusion constraint 500 → friendly 409
- DictionaryRecordService.create() / update() ловят DataIntegrityViolationException,
  если SQLState 23P01 или message содержит dictionary_records_no_overlap →
  OrdinisException.conflict('record_period_overlap', ...) с понятным сообщением
  про пересекающийся диапазон.
- Saved → saveAndFlush: get DB error eagerly чтобы поймать его в catch.

Test setup:
- spring-boot-starter-test (test scope) в parent pom.
- Mockito mock для DictionaryDefinitionService на JDK 25 ломается, передаём null
  (тестируемые методы не дёргают этот dependency).
This commit is contained in:
Zimin A.N.
2026-05-04 04:07:33 +03:00
parent 07c8a19cf3
commit 416c44bd94
5 changed files with 538 additions and 4 deletions
@@ -0,0 +1,144 @@
package cloud.nstart.terravault.ordinis.auth;
import cloud.nstart.terravault.ordinis.domain.DataScope;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class ScopeContextTest {
private final OrdinisAuthProperties propsWithQuery =
new OrdinisAuthProperties(null, null, null, "realm_access.roles", false, true);
private final OrdinisAuthProperties propsNoQuery =
new OrdinisAuthProperties(null, null, null, "realm_access.roles", false, false);
@AfterEach
void clearContext() {
SecurityContextHolder.clearContext();
}
// ============= role → scope mapping =============
@Test
void mapsOrdinisDashRolesToScopes() {
setJwtWithRealmRoles(List.of("ordinis-public", "ordinis-internal"));
assertThat(new ScopeContext(propsNoQuery).currentScopes())
.containsExactlyInAnyOrder(DataScope.PUBLIC, DataScope.INTERNAL);
}
@Test
void mapsUnderscoreUppercaseRolesToScopes() {
setJwtWithRealmRoles(List.of("ORDINIS_RESTRICTED"));
assertThat(new ScopeContext(propsNoQuery).currentScopes())
.containsExactly(DataScope.RESTRICTED);
}
@Test
void mapsBareScopeNamesToScopes() {
setJwtWithRealmRoles(List.of("PUBLIC", "INTERNAL", "RESTRICTED"));
assertThat(new ScopeContext(propsNoQuery).currentScopes())
.containsExactlyInAnyOrder(DataScope.PUBLIC, DataScope.INTERNAL, DataScope.RESTRICTED);
}
@Test
void mapsArbitrarySuffixedRoles() {
setJwtWithRealmRoles(List.of("any-prefix-PUBLIC", "x_INTERNAL"));
assertThat(new ScopeContext(propsNoQuery).currentScopes())
.containsExactlyInAnyOrder(DataScope.PUBLIC, DataScope.INTERNAL);
}
@Test
void unrecognizedRolesGivePublicFallback() {
setJwtWithRealmRoles(List.of("admin", "viewer"));
assertThat(new ScopeContext(propsNoQuery).currentScopes())
.containsExactly(DataScope.PUBLIC);
}
// ============= JWT vs query =============
@Test
void anonymousNoQueryReturnsPublic() {
setAnonymous();
assertThat(new ScopeContext(propsNoQuery).resolveForRead(null))
.containsExactly(DataScope.PUBLIC);
}
@Test
void anonymousQueryAllowedReturnsRequestedScopes() {
setAnonymous();
assertThat(new ScopeContext(propsWithQuery).resolveForRead("internal,restricted"))
.containsExactlyInAnyOrder(DataScope.INTERNAL, DataScope.RESTRICTED);
}
@Test
void anonymousQueryDisallowedAlwaysReturnsPublic() {
setAnonymous();
assertThat(new ScopeContext(propsNoQuery).resolveForRead("internal,restricted"))
.containsExactly(DataScope.PUBLIC);
}
@Test
void jwtAlwaysWinsOverQueryParam() {
setJwtWithRealmRoles(List.of("ordinis-public"));
Set<DataScope> scopes = new ScopeContext(propsWithQuery).resolveForRead("RESTRICTED");
assertThat(scopes).containsExactly(DataScope.PUBLIC);
}
// ============= claim path resolution =============
@Test
void supportsCustomClaimPath() {
var props = new OrdinisAuthProperties(null, null, null, "scopes", false, false);
setJwtWithFlatScopes(List.of("PUBLIC", "RESTRICTED"));
assertThat(new ScopeContext(props).currentScopes())
.containsExactlyInAnyOrder(DataScope.PUBLIC, DataScope.RESTRICTED);
}
@Test
void emptyClaimReturnsPublic() {
setJwtWithRealmRoles(List.of());
assertThat(new ScopeContext(propsNoQuery).currentScopes())
.containsExactly(DataScope.PUBLIC);
}
// ============= helpers =============
private void setJwtWithRealmRoles(List<String> roles) {
Jwt jwt = Jwt.withTokenValue("dummy")
.header("alg", "RS256")
.issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(3600))
.claim("realm_access", Map.of("roles", roles))
.build();
SecurityContextHolder.getContext()
.setAuthentication(new JwtAuthenticationToken(jwt, AuthorityUtils.NO_AUTHORITIES));
}
private void setJwtWithFlatScopes(List<String> scopes) {
Jwt jwt = Jwt.withTokenValue("dummy")
.header("alg", "RS256")
.issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(3600))
.claim("scopes", scopes)
.build();
SecurityContextHolder.getContext()
.setAuthentication(new JwtAuthenticationToken(jwt, AuthorityUtils.NO_AUTHORITIES));
}
private void setAnonymous() {
SecurityContextHolder.getContext().setAuthentication(
new AnonymousAuthenticationToken("anon", "anon",
AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS")));
}
}