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:
Zimin A.N.
2026-05-06 17:13:47 +03:00
parent fcfbb1c420
commit 414267807e
4 changed files with 453 additions and 1 deletions
@@ -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);
} }
/** /**
@@ -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) {}
}
@@ -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 =============
@@ -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());
}
}