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:
+144
@@ -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")));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user