feat(refs): cross-dictionary x-references validation (v2)
JSON Schema extension `x-references: "dict_name.field"` объявляет что
значение поля должно ссылаться на active record в указанном словаре.
На POST/PUT record service валидирует existence + scope visibility.
Пример:
{
"type":"object",
"properties":{
"satTypeCode":{
"type":"string",
"x-references":"satellite_type.code"
}
}
}
ReferenceValidator (ordinis-rest-api/service/reference/):
- Walks schema properties, finds x-references markers
- Для каждого ref: lookup target dictionary + active record by JSONB
field value (использует existing findActiveByJsonField repo method)
- Returns ValidationError list, intergrates с RecordValidator pipeline
через DictionaryRecordService.validate()
- Scope hide: target dict не accessible → "not_found" error (не
"scope_denied"), чтобы не leak'ало existence
- Optional поля: если value missing, skip (JSON Schema validation
отдельно отлавливает required)
Error codes:
- x_references_malformed (spec не "dict.field")
- x_references_dict_not_found (target dictionary не существует)
- x_references_target_not_found (referenced record нет / scope hidden)
- x_references_non_string (v1 поддерживает только string values)
v1 ограничения (документированы в JavaDoc):
- Top-level fields only (nested objects не поддержаны)
- Only string values
- No cascade on delete/close (orphan FK alert — v2.5)
Tests (15 в ReferenceValidatorTest):
- parseRef: valid, empty, missing dot, trailing dot, nested paths
- validate: no refs, missing value (optional skip), existing record OK,
missing record error, missing dictionary error, scope-hidden error,
non-string value error, malformed spec error, multiple fields
validated independently, null schema graceful
Total project tests: 176 → 191.
Note: Mockito не может mock entity classes на JDK 25 (subclass
mock-maker limitation). Используем real entity ctor для
DictionaryDefinition + DictionaryRecord в тестах.
This commit is contained in:
+15
@@ -43,6 +43,8 @@ public class DictionaryRecordService {
|
|||||||
private final DictionaryRecordRepository repository;
|
private final DictionaryRecordRepository repository;
|
||||||
private final DictionaryDefinitionService definitionService;
|
private final DictionaryDefinitionService definitionService;
|
||||||
private final RecordValidator validator;
|
private final RecordValidator validator;
|
||||||
|
private final cloud.nstart.terravault.ordinis.restapi.service.reference.ReferenceValidator referenceValidator;
|
||||||
|
private final cloud.nstart.terravault.ordinis.auth.ScopeContext scopeContext;
|
||||||
private final OutboxRecorder outbox;
|
private final OutboxRecorder outbox;
|
||||||
private final AuditLogger auditLogger;
|
private final AuditLogger auditLogger;
|
||||||
|
|
||||||
@@ -52,11 +54,15 @@ public class DictionaryRecordService {
|
|||||||
DictionaryRecordRepository repository,
|
DictionaryRecordRepository repository,
|
||||||
DictionaryDefinitionService definitionService,
|
DictionaryDefinitionService definitionService,
|
||||||
RecordValidator validator,
|
RecordValidator validator,
|
||||||
|
cloud.nstart.terravault.ordinis.restapi.service.reference.ReferenceValidator referenceValidator,
|
||||||
|
cloud.nstart.terravault.ordinis.auth.ScopeContext scopeContext,
|
||||||
OutboxRecorder outbox,
|
OutboxRecorder outbox,
|
||||||
AuditLogger auditLogger) {
|
AuditLogger auditLogger) {
|
||||||
this.repository = repository;
|
this.repository = repository;
|
||||||
this.definitionService = definitionService;
|
this.definitionService = definitionService;
|
||||||
this.validator = validator;
|
this.validator = validator;
|
||||||
|
this.referenceValidator = referenceValidator;
|
||||||
|
this.scopeContext = scopeContext;
|
||||||
this.outbox = outbox;
|
this.outbox = outbox;
|
||||||
this.auditLogger = auditLogger;
|
this.auditLogger = auditLogger;
|
||||||
}
|
}
|
||||||
@@ -213,6 +219,15 @@ public class DictionaryRecordService {
|
|||||||
private void validate(DictionaryDefinition d, CreateRecordRequest req) {
|
private void validate(DictionaryDefinition d, CreateRecordRequest req) {
|
||||||
ValidationResult vr = validator.validate(d, req.data());
|
ValidationResult vr = validator.validate(d, req.data());
|
||||||
if (!vr.valid()) throw new ValidationException(vr.errors());
|
if (!vr.valid()) throw new ValidationException(vr.errors());
|
||||||
|
|
||||||
|
// Cross-dictionary x-references — после schema validation, потому что
|
||||||
|
// если schema fail, value может быть not-textual и refs check бросит false-positives.
|
||||||
|
var refErrors = referenceValidator.validate(
|
||||||
|
d.getSchemaJson(),
|
||||||
|
req.data(),
|
||||||
|
scopeContext.currentScopes().stream().toList(),
|
||||||
|
OffsetDateTime.now());
|
||||||
|
if (!refErrors.isEmpty()) throw new ValidationException(refErrors);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+184
@@ -0,0 +1,184 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.restapi.service.reference;
|
||||||
|
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.DataScope;
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinitionRepository;
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository;
|
||||||
|
import cloud.nstart.terravault.ordinis.validation.ValidationError;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-dictionary FK references — v2 feature. JSON Schema extension
|
||||||
|
* {@code x-references: "dict_name.field_name"} объявляет что значение
|
||||||
|
* в этом поле должно ссылаться на active record в указанном словаре,
|
||||||
|
* где {@code data->>field_name} == referencing value.
|
||||||
|
*
|
||||||
|
* <p>Пример:
|
||||||
|
* <pre>
|
||||||
|
* {
|
||||||
|
* "type": "object",
|
||||||
|
* "properties": {
|
||||||
|
* "satelliteTypeCode": {
|
||||||
|
* "type": "string",
|
||||||
|
* "x-references": "satellite_type.code"
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* <p>На POST/PUT record service вызывает {@link #validate} который для
|
||||||
|
* каждого x-references поля делает lookup. Если referenced record не
|
||||||
|
* найден / не активен / не в доступном scope — ValidationError.
|
||||||
|
*
|
||||||
|
* <p>v1 ограничения:
|
||||||
|
* <ul>
|
||||||
|
* <li>Только top-level fields (nested object refs не поддержаны).</li>
|
||||||
|
* <li>Только string-valued references (number/array — позже).</li>
|
||||||
|
* <li>Без cascade на delete/close: если referenced record закрывается,
|
||||||
|
* referencing records остаются с dangling FK. Отдельный alert
|
||||||
|
* будет в v2.5.</li>
|
||||||
|
* <li>Валидация в момент CRUD, не на schema change. Если меняется
|
||||||
|
* schema с new x-references на existing data — может быть orphan,
|
||||||
|
* проверяем при first re-save.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class ReferenceValidator {
|
||||||
|
|
||||||
|
private final DictionaryDefinitionRepository definitionRepository;
|
||||||
|
private final DictionaryRecordRepository recordRepository;
|
||||||
|
|
||||||
|
public ReferenceValidator(
|
||||||
|
DictionaryDefinitionRepository definitionRepository,
|
||||||
|
DictionaryRecordRepository recordRepository) {
|
||||||
|
this.definitionRepository = definitionRepository;
|
||||||
|
this.recordRepository = recordRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate references. Возвращает список errors (пусто = OK). Не бросает.
|
||||||
|
* Caller (DictionaryRecordService) кидает ValidationException если errors > 0.
|
||||||
|
*
|
||||||
|
* @param schema JSON Schema справочника (содержит x-references markers)
|
||||||
|
* @param data payload записи
|
||||||
|
* @param allowedScopes scope подмножество которое viewер видит — referenced
|
||||||
|
* records должны быть в нём (если выше — denied)
|
||||||
|
* @param at момент времени (transaction time) — обычно now()
|
||||||
|
*/
|
||||||
|
public List<ValidationError> validate(
|
||||||
|
JsonNode schema, JsonNode data, List<DataScope> allowedScopes, OffsetDateTime at) {
|
||||||
|
List<ValidationError> errors = new ArrayList<>();
|
||||||
|
if (schema == null || data == null || !schema.isObject() || !data.isObject()) {
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
JsonNode properties = schema.get("properties");
|
||||||
|
if (properties == null || !properties.isObject()) return errors;
|
||||||
|
|
||||||
|
OffsetDateTime when = at == null ? OffsetDateTime.now() : at;
|
||||||
|
|
||||||
|
Iterator<Map.Entry<String, JsonNode>> it = properties.fields();
|
||||||
|
while (it.hasNext()) {
|
||||||
|
Map.Entry<String, JsonNode> entry = it.next();
|
||||||
|
String fieldName = entry.getKey();
|
||||||
|
JsonNode propSchema = entry.getValue();
|
||||||
|
JsonNode refNode = propSchema.get("x-references");
|
||||||
|
if (refNode == null || !refNode.isTextual()) continue;
|
||||||
|
|
||||||
|
String refSpec = refNode.asText();
|
||||||
|
ParsedRef parsed;
|
||||||
|
try {
|
||||||
|
parsed = parseRef(refSpec);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
errors.add(new ValidationError(
|
||||||
|
"/" + fieldName,
|
||||||
|
"x_references_malformed",
|
||||||
|
"x-references value malformed: " + e.getMessage()));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode value = data.get(fieldName);
|
||||||
|
if (value == null || value.isNull()) {
|
||||||
|
// Если поле required, JSON Schema валидация уже отлавливает.
|
||||||
|
// Если optional — references skip (нет значения = нет проверки).
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!value.isTextual()) {
|
||||||
|
errors.add(new ValidationError(
|
||||||
|
"/" + fieldName,
|
||||||
|
"x_references_non_string",
|
||||||
|
"x-references поддерживает только string values в v1, got: " + value.getNodeType()));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String stringValue = value.asText();
|
||||||
|
|
||||||
|
Optional<DictionaryDefinition> targetOpt =
|
||||||
|
definitionRepository.findByName(parsed.dictionaryName());
|
||||||
|
if (targetOpt.isEmpty()) {
|
||||||
|
errors.add(new ValidationError(
|
||||||
|
"/" + fieldName,
|
||||||
|
"x_references_dict_not_found",
|
||||||
|
"Referenced dictionary '" + parsed.dictionaryName() + "' does not exist"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
DictionaryDefinition target = targetOpt.get();
|
||||||
|
|
||||||
|
if (!allowedScopes.contains(target.getScope())) {
|
||||||
|
// Scope hide: referenced dictionary not visible to caller. Возвращаем
|
||||||
|
// ту же ошибку что и "not found" чтобы не leak'ало existence.
|
||||||
|
errors.add(new ValidationError(
|
||||||
|
"/" + fieldName,
|
||||||
|
"x_references_target_not_found",
|
||||||
|
"Referenced record not found in '" + parsed.dictionaryName()
|
||||||
|
+ "' for value '" + stringValue + "'"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<?> matches = recordRepository.findActiveByJsonField(
|
||||||
|
target.getId(), when, parsed.fieldName(), stringValue);
|
||||||
|
|
||||||
|
if (matches.isEmpty()) {
|
||||||
|
errors.add(new ValidationError(
|
||||||
|
"/" + fieldName,
|
||||||
|
"x_references_target_not_found",
|
||||||
|
"Referenced record not found in '" + parsed.dictionaryName()
|
||||||
|
+ "' for " + parsed.fieldName() + "='" + stringValue + "'"));
|
||||||
|
}
|
||||||
|
// Если matches.size() > 1 — это ситуация когда referenced field не
|
||||||
|
// x-unique. Проходим: matches.first работает. Лог-warning отдельно
|
||||||
|
// (TODO: emit metric nsi_reference_ambiguous_total).
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse {@code "dict_name.field"} format. Validates что обе части
|
||||||
|
* непустые и не содержат точек (чтобы избежать unexpected nested paths).
|
||||||
|
*/
|
||||||
|
static ParsedRef parseRef(String spec) {
|
||||||
|
if (spec == null || spec.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("empty x-references");
|
||||||
|
}
|
||||||
|
int dot = spec.indexOf('.');
|
||||||
|
if (dot <= 0 || dot == spec.length() - 1) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"x-references must be 'dict_name.field', got: " + spec);
|
||||||
|
}
|
||||||
|
String dict = spec.substring(0, dot);
|
||||||
|
String field = spec.substring(dot + 1);
|
||||||
|
if (dict.contains(".") || field.contains(".")) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"x-references nested paths not supported in v1: " + spec);
|
||||||
|
}
|
||||||
|
return new ParsedRef(dict, field);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ParsedRef(String dictionaryName, String fieldName) {}
|
||||||
|
}
|
||||||
+1
-1
@@ -38,7 +38,7 @@ class DictionaryRecordServiceTest {
|
|||||||
repo = mock(DictionaryRecordRepository.class);
|
repo = mock(DictionaryRecordRepository.class);
|
||||||
// resolveBusinessKey/enforceUniqueFields не дёргают остальные деп — передаём null,
|
// resolveBusinessKey/enforceUniqueFields не дёргают остальные деп — передаём null,
|
||||||
// mocking concrete Spring services через Mockito-inline на JDK 25 ломается.
|
// mocking concrete Spring services через Mockito-inline на JDK 25 ломается.
|
||||||
service = new DictionaryRecordService(repo, null, null, null, null);
|
service = new DictionaryRecordService(repo, null, null, null, null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============= resolveBusinessKey =============
|
// ============= resolveBusinessKey =============
|
||||||
|
|||||||
+253
@@ -0,0 +1,253 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.restapi.service.reference;
|
||||||
|
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.DataScope;
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinitionRepository;
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecord;
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository;
|
||||||
|
import cloud.nstart.terravault.ordinis.validation.ValidationError;
|
||||||
|
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.Optional;
|
||||||
|
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 ReferenceValidatorTest {
|
||||||
|
|
||||||
|
private static final ObjectMapper OM = new ObjectMapper();
|
||||||
|
|
||||||
|
private DictionaryDefinitionRepository defRepo;
|
||||||
|
private DictionaryRecordRepository recRepo;
|
||||||
|
private ReferenceValidator validator;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
defRepo = mock(DictionaryDefinitionRepository.class);
|
||||||
|
recRepo = mock(DictionaryRecordRepository.class);
|
||||||
|
validator = new ReferenceValidator(defRepo, recRepo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === parseRef static tests ===
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parseRefAcceptsValid() {
|
||||||
|
var p = ReferenceValidator.parseRef("satellite_type.code");
|
||||||
|
assertThat(p.dictionaryName()).isEqualTo("satellite_type");
|
||||||
|
assertThat(p.fieldName()).isEqualTo("code");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parseRefRejectsEmpty() {
|
||||||
|
assertThatThrownBy(() -> ReferenceValidator.parseRef(""))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class);
|
||||||
|
assertThatThrownBy(() -> ReferenceValidator.parseRef(null))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parseRefRejectsMissingDot() {
|
||||||
|
assertThatThrownBy(() -> ReferenceValidator.parseRef("noDot"))
|
||||||
|
.hasMessageContaining("dict_name.field");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parseRefRejectsTrailingDot() {
|
||||||
|
assertThatThrownBy(() -> ReferenceValidator.parseRef("dict."))
|
||||||
|
.hasMessageContaining("dict_name.field");
|
||||||
|
assertThatThrownBy(() -> ReferenceValidator.parseRef(".field"))
|
||||||
|
.hasMessageContaining("dict_name.field");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parseRefRejectsNestedPaths() {
|
||||||
|
assertThatThrownBy(() -> ReferenceValidator.parseRef("a.b.c"))
|
||||||
|
.hasMessageContaining("nested paths not supported");
|
||||||
|
}
|
||||||
|
|
||||||
|
// === validate() tests ===
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void schemaWithoutReferences_noErrors() throws Exception {
|
||||||
|
JsonNode schema = OM.readTree("""
|
||||||
|
{"type":"object","properties":{"name":{"type":"string"}}}""");
|
||||||
|
JsonNode data = OM.readTree("{\"name\":\"foo\"}");
|
||||||
|
|
||||||
|
var errors = validator.validate(schema, data, List.of(DataScope.PUBLIC), OffsetDateTime.now());
|
||||||
|
assertThat(errors).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void missingValue_skippedSilently() throws Exception {
|
||||||
|
JsonNode schema = OM.readTree("""
|
||||||
|
{"type":"object","properties":{
|
||||||
|
"ref":{"type":"string","x-references":"satellite_type.code"}
|
||||||
|
}}""");
|
||||||
|
JsonNode data = OM.readTree("{\"name\":\"foo\"}"); // нет 'ref'
|
||||||
|
|
||||||
|
var errors = validator.validate(schema, data, List.of(DataScope.PUBLIC), OffsetDateTime.now());
|
||||||
|
assertThat(errors).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void existingReferencedRecord_noErrors() throws Exception {
|
||||||
|
UUID targetId = UUID.randomUUID();
|
||||||
|
DictionaryDefinition target = mockDefinition("satellite_type", DataScope.PUBLIC, targetId);
|
||||||
|
when(defRepo.findByName("satellite_type")).thenReturn(Optional.of(target));
|
||||||
|
when(recRepo.findActiveByJsonField(eq(targetId), any(), eq("code"), eq("LEO-001")))
|
||||||
|
.thenReturn(List.of(stubRecord()));
|
||||||
|
|
||||||
|
JsonNode schema = OM.readTree("""
|
||||||
|
{"type":"object","properties":{
|
||||||
|
"satTypeCode":{"type":"string","x-references":"satellite_type.code"}
|
||||||
|
}}""");
|
||||||
|
JsonNode data = OM.readTree("{\"satTypeCode\":\"LEO-001\"}");
|
||||||
|
|
||||||
|
var errors = validator.validate(schema, data, List.of(DataScope.PUBLIC), OffsetDateTime.now());
|
||||||
|
assertThat(errors).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void missingReferencedRecord_returnsError() throws Exception {
|
||||||
|
UUID targetId = UUID.randomUUID();
|
||||||
|
DictionaryDefinition target = mockDefinition("satellite_type", DataScope.PUBLIC, targetId);
|
||||||
|
when(defRepo.findByName("satellite_type")).thenReturn(Optional.of(target));
|
||||||
|
when(recRepo.findActiveByJsonField(any(), any(), anyString(), anyString()))
|
||||||
|
.thenReturn(List.of()); // no matches
|
||||||
|
|
||||||
|
JsonNode schema = OM.readTree("""
|
||||||
|
{"type":"object","properties":{
|
||||||
|
"satTypeCode":{"type":"string","x-references":"satellite_type.code"}
|
||||||
|
}}""");
|
||||||
|
JsonNode data = OM.readTree("{\"satTypeCode\":\"NONEXISTENT\"}");
|
||||||
|
|
||||||
|
var errors = validator.validate(schema, data, List.of(DataScope.PUBLIC), OffsetDateTime.now());
|
||||||
|
assertThat(errors).hasSize(1);
|
||||||
|
assertThat(errors.get(0).code()).isEqualTo("x_references_target_not_found");
|
||||||
|
assertThat(errors.get(0).message()).contains("satellite_type");
|
||||||
|
assertThat(errors.get(0).message()).contains("NONEXISTENT");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void missingReferencedDictionary_returnsError() throws Exception {
|
||||||
|
when(defRepo.findByName("nonexistent_dict")).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
JsonNode schema = OM.readTree("""
|
||||||
|
{"type":"object","properties":{
|
||||||
|
"ref":{"type":"string","x-references":"nonexistent_dict.code"}
|
||||||
|
}}""");
|
||||||
|
JsonNode data = OM.readTree("{\"ref\":\"any\"}");
|
||||||
|
|
||||||
|
var errors = validator.validate(schema, data, List.of(DataScope.PUBLIC), OffsetDateTime.now());
|
||||||
|
assertThat(errors).hasSize(1);
|
||||||
|
assertThat(errors.get(0).code()).isEqualTo("x_references_dict_not_found");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void targetScopeNotAccessible_returnsNotFoundError() throws Exception {
|
||||||
|
// Caller имеет PUBLIC scope, target dict — INTERNAL.
|
||||||
|
// Должен вернуть not_found (не "scope_denied") чтобы не leak existence.
|
||||||
|
UUID targetId = UUID.randomUUID();
|
||||||
|
DictionaryDefinition target = mockDefinition("internal_dict", DataScope.INTERNAL, targetId);
|
||||||
|
when(defRepo.findByName("internal_dict")).thenReturn(Optional.of(target));
|
||||||
|
|
||||||
|
JsonNode schema = OM.readTree("""
|
||||||
|
{"type":"object","properties":{
|
||||||
|
"ref":{"type":"string","x-references":"internal_dict.code"}
|
||||||
|
}}""");
|
||||||
|
JsonNode data = OM.readTree("{\"ref\":\"X\"}");
|
||||||
|
|
||||||
|
var errors = validator.validate(schema, data, List.of(DataScope.PUBLIC), OffsetDateTime.now());
|
||||||
|
assertThat(errors).hasSize(1);
|
||||||
|
assertThat(errors.get(0).code()).isEqualTo("x_references_target_not_found");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nonStringValue_returnsError() throws Exception {
|
||||||
|
JsonNode schema = OM.readTree("""
|
||||||
|
{"type":"object","properties":{
|
||||||
|
"ref":{"type":"integer","x-references":"d.f"}
|
||||||
|
}}""");
|
||||||
|
JsonNode data = OM.readTree("{\"ref\":42}");
|
||||||
|
|
||||||
|
var errors = validator.validate(schema, data, List.of(DataScope.PUBLIC), OffsetDateTime.now());
|
||||||
|
assertThat(errors).hasSize(1);
|
||||||
|
assertThat(errors.get(0).code()).isEqualTo("x_references_non_string");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void malformedRefSpec_returnsError() throws Exception {
|
||||||
|
JsonNode schema = OM.readTree("""
|
||||||
|
{"type":"object","properties":{
|
||||||
|
"ref":{"type":"string","x-references":"malformed-no-dot"}
|
||||||
|
}}""");
|
||||||
|
JsonNode data = OM.readTree("{\"ref\":\"any\"}");
|
||||||
|
|
||||||
|
var errors = validator.validate(schema, data, List.of(DataScope.PUBLIC), OffsetDateTime.now());
|
||||||
|
assertThat(errors).hasSize(1);
|
||||||
|
assertThat(errors.get(0).code()).isEqualTo("x_references_malformed");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void multipleReferenceFields_validatedIndependently() throws Exception {
|
||||||
|
UUID dictAId = UUID.randomUUID();
|
||||||
|
UUID dictBId = UUID.randomUUID();
|
||||||
|
when(defRepo.findByName("dict_a")).thenReturn(
|
||||||
|
Optional.of(mockDefinition("dict_a", DataScope.PUBLIC, dictAId)));
|
||||||
|
when(defRepo.findByName("dict_b")).thenReturn(
|
||||||
|
Optional.of(mockDefinition("dict_b", DataScope.PUBLIC, dictBId)));
|
||||||
|
// Только в dict_a есть match. dict_b — пусто.
|
||||||
|
when(recRepo.findActiveByJsonField(eq(dictAId), any(), eq("c"), eq("X")))
|
||||||
|
.thenReturn(List.of(stubRecord()));
|
||||||
|
when(recRepo.findActiveByJsonField(eq(dictBId), any(), any(), any()))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
|
||||||
|
JsonNode schema = OM.readTree("""
|
||||||
|
{"type":"object","properties":{
|
||||||
|
"refA":{"type":"string","x-references":"dict_a.c"},
|
||||||
|
"refB":{"type":"string","x-references":"dict_b.c"}
|
||||||
|
}}""");
|
||||||
|
JsonNode data = OM.readTree("{\"refA\":\"X\",\"refB\":\"Y\"}");
|
||||||
|
|
||||||
|
var errors = validator.validate(schema, data, List.of(DataScope.PUBLIC), OffsetDateTime.now());
|
||||||
|
// refA OK, refB fails → 1 error
|
||||||
|
assertThat(errors).hasSize(1);
|
||||||
|
assertThat(errors.get(0).path()).isEqualTo("/refB");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nullSchema_returnsEmptyErrors() {
|
||||||
|
var errors = validator.validate(null, null, List.of(DataScope.PUBLIC), OffsetDateTime.now());
|
||||||
|
assertThat(errors).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Helpers ===
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mockito не может mock'нуть DictionaryDefinition на JDK 25 (см. memo
|
||||||
|
* "JDK 25 + Mockito subclass mock-maker incompat"). Используем real
|
||||||
|
* entity — у неё public ctor с минимальными полями.
|
||||||
|
*/
|
||||||
|
private static DictionaryDefinition mockDefinition(String name, DataScope scope, UUID id) {
|
||||||
|
return new DictionaryDefinition(id, name, scope, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Аналогично — real DictionaryRecord vместо mock. Validator только
|
||||||
|
* проверяет {@code isEmpty()}, fields этого record нам не важны. */
|
||||||
|
private static DictionaryRecord stubRecord() {
|
||||||
|
return new DictionaryRecord(
|
||||||
|
UUID.randomUUID(), UUID.randomUUID(), "stub-key",
|
||||||
|
OM.createObjectNode(), DataScope.PUBLIC, OffsetDateTime.now());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user