diff --git a/ordinis-auth/src/test/java/cloud/nstart/terravault/ordinis/auth/ScopeContextTest.java b/ordinis-auth/src/test/java/cloud/nstart/terravault/ordinis/auth/ScopeContextTest.java new file mode 100644 index 0000000..52eee89 --- /dev/null +++ b/ordinis-auth/src/test/java/cloud/nstart/terravault/ordinis/auth/ScopeContextTest.java @@ -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 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 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 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"))); + } +} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordService.java index 591539c..891b987 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordService.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordService.java @@ -16,6 +16,7 @@ import cloud.nstart.terravault.ordinis.validation.ValidationResult; import org.locationtech.jts.geom.Geometry; import org.locationtech.jts.io.ParseException; import org.locationtech.jts.io.WKTReader; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; @@ -97,7 +98,18 @@ public class DictionaryRecordService { record.setValidTo(validTo); record.setGeometry(parseWkt(req.geometryWkt())); - var saved = repository.save(record); + DictionaryRecord saved; + try { + saved = repository.saveAndFlush(record); + } catch (DataIntegrityViolationException ex) { + if (isExclusionViolation(ex)) { + throw OrdinisException.conflict( + "record_period_overlap", + "Период [" + validFrom + ", " + validTo + ") пересекается с существующей версией business_key='" + + businessKey + "'. Закройте старую версию или скорректируйте даты."); + } + throw ex; + } publishCreated(d, saved); return saved; } @@ -135,7 +147,19 @@ public class DictionaryRecordService { newValidFrom); next.setValidTo(newValidTo); next.setGeometry(parseWkt(req.geometryWkt())); - var saved = repository.save(next); + DictionaryRecord saved; + try { + saved = repository.saveAndFlush(next); + } catch (DataIntegrityViolationException ex) { + if (isExclusionViolation(ex)) { + throw OrdinisException.conflict( + "record_period_overlap", + "Новый период [" + newValidFrom + ", " + newValidTo + + ") пересекается с другой исторической версией business_key='" + + businessKey + "'. Скорректируйте даты."); + } + throw ex; + } publishUpdated(d, saved, previousRecordId, prevData); return saved; @@ -159,6 +183,24 @@ public class DictionaryRecordService { // — internals — + /** + * Распознаёт PG exclusion constraint violation в любом нестоящем месте цепочки причин. + * SQLState 23P01 = exclusion_violation. + */ + private static boolean isExclusionViolation(Throwable ex) { + Throwable t = ex; + while (t != null) { + String msg = t.getMessage(); + if (msg != null && (msg.contains("23P01") + || msg.contains("dictionary_records_no_overlap") + || msg.contains("exclusion constraint"))) { + return true; + } + t = t.getCause(); + } + return false; + } + private void validate(DictionaryDefinition d, CreateRecordRequest req) { ValidationResult vr = validator.validate(d, req.data()); if (!vr.valid()) throw new ValidationException(vr.errors()); @@ -169,7 +211,7 @@ public class DictionaryRecordService { * берётся из {@code data[field]}. Если клиент явно прислал businessKey — он * должен совпадать, иначе 422 (защита от рассинхронизации). */ - private String resolveBusinessKey(DictionaryDefinition d, CreateRecordRequest req) { + String resolveBusinessKey(DictionaryDefinition d, CreateRecordRequest req) { var schema = d.getSchemaJson(); var idSourceNode = schema == null ? null : schema.get("x-id-source"); if (idSourceNode == null || !idSourceNode.isTextual() || idSourceNode.asText().isBlank()) { @@ -203,7 +245,7 @@ public class DictionaryRecordService { * (на момент {@code at}) с тем же значением. Исключает текущую версию (для * update). Бьёт 409 при дубликате. */ - private void enforceUniqueFields( + void enforceUniqueFields( DictionaryDefinition d, com.fasterxml.jackson.databind.JsonNode data, OffsetDateTime at, diff --git a/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/idempotency/IdempotencyServiceTest.java b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/idempotency/IdempotencyServiceTest.java new file mode 100644 index 0000000..88c14ae --- /dev/null +++ b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/idempotency/IdempotencyServiceTest.java @@ -0,0 +1,143 @@ +package cloud.nstart.terravault.ordinis.restapi.idempotency; + +import cloud.nstart.terravault.ordinis.domain.idempotency.IdempotencyKey; +import cloud.nstart.terravault.ordinis.domain.idempotency.IdempotencyKeyRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataIntegrityViolationException; + +import java.time.OffsetDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class IdempotencyServiceTest { + + private IdempotencyKeyRepository repo; + private IdempotencyService service; + + @BeforeEach + void setUp() { + repo = mock(IdempotencyKeyRepository.class); + service = new IdempotencyService(repo, new ObjectMapper()); + } + + // ============= hashRequest ============= + + @Test + void hashIsDeterministicForSameInput() { + byte[] body = "{\"a\":1}".getBytes(); + assertThat(service.hashRequest(body)).isEqualTo(service.hashRequest(body)); + } + + @Test + void hashDiffersForDifferentInput() { + assertThat(service.hashRequest("{\"a\":1}".getBytes())) + .isNotEqualTo(service.hashRequest("{\"a\":2}".getBytes())); + } + + @Test + void hashHandlesNullAsEmpty() { + assertThat(service.hashRequest(null)).isEqualTo(service.hashRequest(new byte[0])); + } + + @Test + void hashIsSha256Hex() { + String hash = service.hashRequest("hello".getBytes()); + assertThat(hash).hasSize(64).matches("[0-9a-f]{64}"); + } + + // ============= lookup ============= + + @Test + void lookupReturnsEmptyWhenKeyMissing() { + when(repo.findById("k1")).thenReturn(Optional.empty()); + assertThat(service.lookup("k1", "hash")).isEmpty(); + } + + @Test + void lookupReturnsEntryWhenHashMatches() { + var key = new IdempotencyKey("k1", "hash-A", OffsetDateTime.now()); + when(repo.findById("k1")).thenReturn(Optional.of(key)); + assertThat(service.lookup("k1", "hash-A")).contains(key); + } + + @Test + void lookupThrows422OnHashMismatch() { + var key = new IdempotencyKey("k1", "hash-A", OffsetDateTime.now()); + when(repo.findById("k1")).thenReturn(Optional.of(key)); + assertThatThrownBy(() -> service.lookup("k1", "hash-B")) + .isInstanceOf(IdempotencyConflictException.class); + } + + // ============= reserve ============= + + @Test + void reserveCallsSaveAndFlush() { + service.reserve("k1", "hash-A"); + verify(repo, times(1)).saveAndFlush(any(IdempotencyKey.class)); + } + + @Test + void reserveOnRaceWithSameHashThrowsInProgress() { + var existing = new IdempotencyKey("k1", "hash-A", OffsetDateTime.now()); + when(repo.saveAndFlush(any(IdempotencyKey.class))) + .thenThrow(new DataIntegrityViolationException("dup")); + when(repo.findById("k1")).thenReturn(Optional.of(existing)); + + assertThatThrownBy(() -> service.reserve("k1", "hash-A")) + .isInstanceOf(IdempotencyInProgressException.class); + } + + @Test + void reserveOnRaceWithDifferentHashThrowsConflict() { + var existing = new IdempotencyKey("k1", "hash-other", OffsetDateTime.now()); + when(repo.saveAndFlush(any(IdempotencyKey.class))) + .thenThrow(new DataIntegrityViolationException("dup")); + when(repo.findById("k1")).thenReturn(Optional.of(existing)); + + assertThatThrownBy(() -> service.reserve("k1", "hash-A")) + .isInstanceOf(IdempotencyConflictException.class); + } + + // ============= saveResponse ============= + + @Test + void saveResponseSkipsMissingKey() { + when(repo.findById("absent")).thenReturn(Optional.empty()); + service.saveResponse("absent", 200, "{}".getBytes()); + verify(repo, times(0)).save(any(IdempotencyKey.class)); + } + + @Test + void saveResponsePersistsParsedBody() { + var key = new IdempotencyKey("k1", "hash", OffsetDateTime.now()); + when(repo.findById("k1")).thenReturn(Optional.of(key)); + + service.saveResponse("k1", 201, "{\"id\":\"abc\"}".getBytes()); + + assertThat(key.getResponseStatus()).isEqualTo(201); + assertThat(key.getResponseBody()).isNotNull(); + assertThat(key.getResponseBody().get("id").asText()).isEqualTo("abc"); + verify(repo).save(key); + } + + @Test + void saveResponseHandlesNonJsonGracefully() { + var key = new IdempotencyKey("k1", "hash", OffsetDateTime.now()); + when(repo.findById("k1")).thenReturn(Optional.of(key)); + + service.saveResponse("k1", 500, "not-json".getBytes()); + + assertThat(key.getResponseStatus()).isEqualTo(500); + assertThat(key.getResponseBody()).isNull(); + verify(repo).save(key); + } +} diff --git a/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordServiceTest.java b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordServiceTest.java new file mode 100644 index 0000000..b9e0522 --- /dev/null +++ b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/DictionaryRecordServiceTest.java @@ -0,0 +1,195 @@ +package cloud.nstart.terravault.ordinis.restapi.service; + +import cloud.nstart.terravault.ordinis.domain.DataScope; +import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition; +import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecord; +import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository; +import cloud.nstart.terravault.ordinis.outbox.OutboxRecorder; +import cloud.nstart.terravault.ordinis.restapi.dto.CreateRecordRequest; +import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException; +import cloud.nstart.terravault.ordinis.validation.RecordValidator; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class DictionaryRecordServiceTest { + + private final ObjectMapper om = new ObjectMapper(); + private DictionaryRecordRepository repo; + private DictionaryRecordService service; + + private static final UUID DICT_ID = UUID.fromString("00000000-0000-0000-0000-000000000001"); + + @BeforeEach + void setUp() { + repo = mock(DictionaryRecordRepository.class); + // resolveBusinessKey/enforceUniqueFields не дёргают остальные деп — передаём null, + // mocking concrete Spring services через Mockito-inline на JDK 25 ломается. + service = new DictionaryRecordService(repo, null, null, null); + } + + // ============= resolveBusinessKey ============= + + @Test + void resolveReturnsExplicitKeyWhenNoIdSource() throws Exception { + var d = definitionWithSchema(""" + { "type":"object", "properties": {} } + """); + var req = new CreateRecordRequest("MY_KEY", om.readTree("{}"), null, null, null); + assertThat(service.resolveBusinessKey(d, req)).isEqualTo("MY_KEY"); + } + + @Test + void resolveThrowsWhenNoIdSourceAndNoExplicitKey() throws Exception { + var d = definitionWithSchema(""" + { "type":"object", "properties": {} } + """); + var req = new CreateRecordRequest(null, om.readTree("{}"), null, null, null); + assertThatThrownBy(() -> service.resolveBusinessKey(d, req)) + .isInstanceOfSatisfying(OrdinisException.class, + ex -> assertThat(ex.getCode()).isEqualTo("business_key_required")); + } + + @Test + void resolveDerivesKeyFromIdSourceField() throws Exception { + var d = definitionWithSchema(""" + { "type":"object", "x-id-source":"code", + "properties": {"code":{"type":"string"}} } + """); + var req = new CreateRecordRequest(null, om.readTree("{\"code\":\"AUTO_KEY\"}"), null, null, null); + assertThat(service.resolveBusinessKey(d, req)).isEqualTo("AUTO_KEY"); + } + + @Test + void resolveAcceptsExplicitKeyWhenItMatchesIdSource() throws Exception { + var d = definitionWithSchema(""" + { "type":"object", "x-id-source":"code", + "properties": {"code":{"type":"string"}} } + """); + var req = new CreateRecordRequest("AUTO_KEY", om.readTree("{\"code\":\"AUTO_KEY\"}"), null, null, null); + assertThat(service.resolveBusinessKey(d, req)).isEqualTo("AUTO_KEY"); + } + + @Test + void resolveThrowsOnMismatchBetweenExplicitAndDerived() throws Exception { + var d = definitionWithSchema(""" + { "type":"object", "x-id-source":"code", + "properties": {"code":{"type":"string"}} } + """); + var req = new CreateRecordRequest("WRONG", om.readTree("{\"code\":\"AUTO_KEY\"}"), null, null, null); + assertThatThrownBy(() -> service.resolveBusinessKey(d, req)) + .isInstanceOfSatisfying(OrdinisException.class, + ex -> assertThat(ex.getCode()).isEqualTo("business_key_mismatch")); + } + + @Test + void resolveThrowsWhenIdSourceFieldMissingInData() throws Exception { + var d = definitionWithSchema(""" + { "type":"object", "x-id-source":"code", + "properties": {"code":{"type":"string"}} } + """); + var req = new CreateRecordRequest(null, om.readTree("{}"), null, null, null); + assertThatThrownBy(() -> service.resolveBusinessKey(d, req)) + .isInstanceOfSatisfying(OrdinisException.class, + ex -> assertThat(ex.getCode()).isEqualTo("id_source_missing")); + } + + // ============= enforceUniqueFields ============= + + @Test + void enforceNoUniqueFieldsIsNoOp() throws Exception { + var d = definitionWithSchema(""" + { "type":"object", + "properties": {"code":{"type":"string"}} } + """); + JsonNode data = om.readTree("{\"code\":\"X\"}"); + service.enforceUniqueFields(d, data, OffsetDateTime.now(), null); + // нет вызовов в repo — just no exception + } + + @Test + void enforceUniqueFieldNoConflict() throws Exception { + var d = definitionWithSchema(""" + { "type":"object", + "properties": {"code":{"type":"string","x-unique":true}} } + """); + when(repo.findActiveByJsonField(eq(DICT_ID), any(), eq("code"), eq("X"))) + .thenReturn(List.of()); + + JsonNode data = om.readTree("{\"code\":\"X\"}"); + service.enforceUniqueFields(d, data, OffsetDateTime.now(), null); + } + + @Test + void enforceUniqueFieldConflictThrows409() throws Exception { + var d = definitionWithSchema(""" + { "type":"object", + "properties": {"code":{"type":"string","x-unique":true}} } + """); + var conflict = new DictionaryRecord( + UUID.randomUUID(), DICT_ID, "OTHER", + om.readTree("{\"code\":\"X\"}"), DataScope.PUBLIC, OffsetDateTime.now()); + when(repo.findActiveByJsonField(eq(DICT_ID), any(), eq("code"), eq("X"))) + .thenReturn(List.of(conflict)); + + JsonNode data = om.readTree("{\"code\":\"X\"}"); + assertThatThrownBy(() -> service.enforceUniqueFields(d, data, OffsetDateTime.now(), null)) + .isInstanceOfSatisfying(OrdinisException.class, + ex -> assertThat(ex.getCode()).isEqualTo("unique_field_violation")); + } + + @Test + void enforceUniqueFieldExcludesOwnRecordOnUpdate() throws Exception { + var d = definitionWithSchema(""" + { "type":"object", + "properties": {"code":{"type":"string","x-unique":true}} } + """); + UUID ownId = UUID.randomUUID(); + var own = new DictionaryRecord( + ownId, DICT_ID, "MY_KEY", + om.readTree("{\"code\":\"X\"}"), DataScope.PUBLIC, OffsetDateTime.now()); + when(repo.findActiveByJsonField(eq(DICT_ID), any(), eq("code"), eq("X"))) + .thenReturn(List.of(own)); + + JsonNode data = om.readTree("{\"code\":\"X\"}"); + // Не должно бросать — собственная запись исключается из проверки + service.enforceUniqueFields(d, data, OffsetDateTime.now(), ownId); + } + + @Test + void enforceSkipsNullValueFields() throws Exception { + var d = definitionWithSchema(""" + { "type":"object", + "properties": { + "code":{"type":"string","x-unique":true}, + "alias":{"type":"string","x-unique":true} + } + } + """); + when(repo.findActiveByJsonField(eq(DICT_ID), any(), anyString(), anyString())) + .thenReturn(List.of()); + + JsonNode data = om.readTree("{\"code\":\"X\"}"); // alias absent + service.enforceUniqueFields(d, data, OffsetDateTime.now(), null); + // Repository вызывается только для code, не для alias — проверка проходит + } + + // ============= helpers ============= + + private DictionaryDefinition definitionWithSchema(String schemaJson) throws Exception { + return new DictionaryDefinition(DICT_ID, "test_dict", DataScope.PUBLIC, om.readTree(schemaJson)); + } +} diff --git a/pom.xml b/pom.xml index 520f3b4..6aa1686 100644 --- a/pom.xml +++ b/pom.xml @@ -124,6 +124,16 @@ + + + + org.springframework.boot + spring-boot-starter-test + test + + +