Покрытие: all-success, classification (record_not_active → skipped,
другие OrdinisException + unexpected → errors), дедуп дубликатов внутри
batch'а, blank/null business_key → skipped invalid_key, передача
at/reason в service, mixed results, empty batch, preservation порядка
(audit consistency).
Mockito не может мокать DictionaryRecordService на Java 17+ (Mockito
inline-mock limitation для классов из standard distribution). Workaround:
анонимный subclass с null deps в конструкторе — переопределённый close()
не обращается к ним. routedStub() helper для per-key поведения через
Map<businessKey, Exception>.
Прогон: 60/60 в rest-api модуле (было 50, +10).
Backend POST /api/v1/dictionaries/{name}/records/bulk-close принимает
businessKeys[] (1..500) + reason. BulkRecordService крутит цикл через
inject'ed DictionaryRecordService — каждый close в своей транзакции
(SERIALIZABLE), partial success возможен. Result: closed[] / skipped[]
(already_closed, not_found) / errors[].
Frontend: checkbox column + sticky toolbar когда selection.size > 0.
Header checkbox с indeterminate state для partial select. selectAll
включает все ВИДИМЫЕ ключи (не игнорирует фильтры). При изменении
фильтра — useEffect выкидывает невидимые ключи из selection (WYSIWYG).
Bulk close dialog: count + reason input → confirm → результат панель
"Закрыто N, пропущено M, ошибок K" с детальным списком skipped/errors.
После успеха selection остаётся для skipped/errors — юзер видит что
ещё не сделано.
Лимит 500: защита от случайного огромного селекта или бага UI.
Дедупликация внутри batch'а silent skip — не error.
Fix CI flakiness: тест больше не полагается на @Scheduled тики каждые
200ms. NoSchedulingConfig подменяет TaskScheduler на no-op stub —
@Scheduled методы регистрируются, но никогда не запускаются. Тест
вручную дёргает dispatcher.matchTick() / sendTick() — synchronous,
deterministic, нет race с background threads.
Что изменилось:
- @Disabled убран
- Inject WebhookDispatcher, manual matchTick + sendTick после setup
- Await timeouts: 60s → 5s (manual ticks → нет 200ms scheduler delay)
- Pool=4 / send-interval-ms / match-interval-ms убраны (не нужны)
- Send-timeout-ms 1000 → 2000 (с запасом, но scheduler не работает)
- @AutoConfigureMockMvc сохранён, port=RANDOM_PORT, allowed-hosts тот же
Side-fix: @Autowired на Spring constructor WebhookTestPingService
(добавлен ранее package-private constructor для unit-тестов сделал
выбор конструктора неоднозначным; Spring 6 strict — требует
explicit @Autowired когда есть >1 candidate).
Локально: 2/2 PASSED 1.85s (было 9.9s — 5× быстрее).
CI: 14 e2e всех тестов passed (Smoke, OutboxKafka, Auth, Bitemporal,
Webhook).
Покрытие: SSRF reject (private CIDR + non-http scheme), success 2xx,
failure 5xx, network exception, body trim до 200 chars, HMAC signature
header. Нет реальных HTTP запросов — anonymous subclass переопределяет
protected sendRequest().
Refactor: allowedHostsCsv → @Value parameter в конструкторе вместо
@PostConstruct + поле, добавлен package-private constructor для inject
HttpClient/allowedHosts списка. Mockito не может мокать sealed HttpClient
на Java 17+, поэтому через extension point sendRequest().
Lightweight HttpResponse stub реализует только statusCode()+body() без
Mockito.
Записи ground_station и similar dicts имеют lat/lon в data, но
geometryWkt не задан. Spatial filter (AOI) WHERE geometry IS NOT NULL
AND ST_Intersects(...) возвращал 0 records даже когда координаты
есть.
Три места починены:
1. DictionaryRecordService.resolveGeometry — для CRUD через REST.
Если req.geometryWkt() задан — приоритет (explicit). Иначе
читаем data.lat/lon (или latitude/longitude/lng), валидируем
ranges, строим POINT(lon lat) с SRID 4326. Игнор out-of-range
без throw — не ломаем legacy data.
2. CuodBundleImporter.deriveLatLonGeometry — bundle bypass'ит service,
reuse'аем ту же логику локально (5 строк inline). Применяется при
создании НОВЫХ records (existing skipped по businessKey).
3. Liquibase migration 0015-backfill-latlon-geometry — UPDATE existing
records где geometry IS NULL AND data has lat/lon. Три changeset'а
для разных aliases (lat/lon, latitude/longitude, lat/lng). Idempotent
(только NULL→value), rollback supported (NULL обратно).
После deploy: existing 3 ground_station записей (Отрадное и др.) и
spacecraft с lat/lon получат POINT geometry. AOI bbox-фильтр заработает.
Раньше при page=50 admin не мог найти редкий dlq среди
delivered'ов: client-side filter показал бы 1 совпадение из 50,
а на page=1 могли быть ещё. Делаем server-side через ?status=.
Backend:
- WebhookDeliveryRepository.findBySubscriptionIdAndStatusOrderByCreatedAtDesc
— Spring Data derived query, JPA генерирует SQL.
- GET /admin/webhooks/subscriptions/{id}/deliveries?status=dlq —
опциональный param. Invalid status → 400 OrdinisException.
Allowed: pending, retrying, delivered, dlq.
UI:
- 4 chip'а pending/retrying/delivered/dlq над таблицей deliveries
(одинаковый паттерн со scope-чипами в dictionaries).
- Click toggle + reset page=0. 'Сбросить' появляется когда фильтр
активен.
- Status в URL пока не персистится — для одной session-страницы
не критично.
После создания subscription / rotate secret admin не имел способа
проверить что receiver жив и валидирует HMAC без ожидания реального
business event. Теперь:
- POST /api/v1/admin/webhooks/subscriptions/{id}/test
- Synchronous: receiver получает synthetic event с теми же headers
что у production delivery (X-Ordinis-Signature, X-Ordinis-Event-Type,
X-Ordinis-Scope) + дополнительный X-Ordinis-Test=true чтобы
receiver мог логировать/филтровать как тест.
- HMAC sign'ится через тот же HmacSigner — admin раскопировал secret
правильно если receiver валидирует. Иначе receiver вернёт 4xx
и UI покажет ошибку.
- SSRF guard validate URL host (private CIDR + allowed-hosts override)
— same policy как у dispatcher.
Bypass'ит outbox/dispatcher — нет webhook_deliveries row, нет attempt
count, нет следов в аудите доставок (это test, не business event).
UI:
- Кнопка 'Тест ping' с PaperPlaneTilt иконкой рядом с 'Сменить secret'
на webhook detail page.
- Inline Alert после ответа: success/failure/rejected с HTTP status,
latency, body preview / error message. Dismiss '✕'.
RBAC: RESTRICTED — endpoint отправляет HTTP request на user-controlled
URL (potential abuse vector если бы был INTERNAL).
Tests: rest-api compiles, 76 admin-ui passed.
Раньше для replay'а failed/DLQ delivery нужно было либо ждать
backoff schedule (до 24h), либо лезть в БД и руками сбрасывать
attempt_count + dlq_at. Теперь admin замечает failed delivery в
UI, фиксит receiver (cert renewal, URL update), кликает 'повторить'
— dispatcher подхватит на следующем sendTick.
Backend:
- POST /api/v1/admin/webhooks/deliveries/{id}/retry
- RBAC: RESTRICTED (можно spam'ить receiver = destructive)
- Использует existing WebhookDelivery.resetDlq() — атомарно
status=retrying, attemptCount=0, dlqAt=null, nextAttemptAt=now
UI:
- Колонка 'Действия' в delivery history таблице (на webhook detail page)
- IconButton 'Повторить' показывается только для status=dlq|retrying
(delivered/pending не имеет смысла retry'ить)
- Confirm dialog объясняет что receiver получит payload снова
- onSuccess invalidate ['webhooks','deliveries'] + ['webhooks','dlq']
Tests: 75 admin-ui passed, rest-api compiles clean. Логика
resetDlq() уже покрыта unit tests в WebhookDeliveryTest.
@Scheduled sweep раз в час (configurable). Walks all schemas, для каждого
x-references marker считает orphans через JdbcTemplate native SQL
(WHERE source.data->>field NOT IN target dict active records).
Метрика: Gauge per (source_dict, source_field, target_dict, target_field).
Cardinality bounded by FK count в bundles (~5 для cuod v1.2.1).
Conditional: ordinis.references.scan-enabled=true (default off, включаем
явно в prod values). Read-only, no write impact.
OrphanReferenceQuery (ordinis-domain): native SQL helper, identifier
validation против SQL injection. Field name regex check т.к. JSONB key
path не bind'ится через @Param.
Tests: 4 в OrphanReferenceScannerTest (parseRef edge cases). Реальный
SQL логику покроет integration тест на staging данных.
Total project tests: 191 → 195.
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 в тестах.
Backend:
- DictionaryRecordRepository.countActiveGroupedByDictionary — один GROUP BY
по всем справочникам с фильтром по scope, без N+1.
- DictionaryResponse.recordCount (nullable) + factory from(d, count).
- DictionaryDefinitionController передаёт счётчики в list/get,
фильтрованные по currentScopes() пользователя.
Frontend:
- SearchInput над сеткой карточек, client-side filter по
name/displayName/description (case-insensitive, useDeferredValue).
- Badge "N записей" (i18n plural ru/en) в углу карточки.
- "Показано N из M" рядом с поиском.
- EmptyState когда поиск пустой.
ScopeContext.canAccess(DataScope) — единая точка проверки доступа,
использует DataScope.visibleTo (PUBLIC видит PUBLIC, INTERNAL видит
PUBLIC+INTERNAL, RESTRICTED видит всё).
DictionaryRecordController:
- requireReadAccess на GET /{businessKey} и /{businessKey}/history
→ 404 dictionary_not_found если scope словаря недоступен (намеренно
скрываем существование INTERNAL/RESTRICTED данных, защита от
enumeration)
- requireWriteAccess на POST/PUT/DELETE
→ 403 scope_insufficient если scope недостаточен для записи
DictionaryDefinitionController:
- list() фильтрует список по canAccess (PUBLIC-юзер не видит INTERNAL
словари в каталоге)
- get() → 404 если недоступен
- create()/update() → 403 если новый scope выше доступа юзера
(защита от scope escalation через PUT)
AuthE2ETest.publicUser_cannotSeeInternalRecords: TODO snять, expectation
обновлён на isNotFound() (раньше было isOk + комментарий о gap).
До этого fix: PUBLIC-юзер мог GET /api/v1/dictionaries/{n}/records/{k}
INTERNAL-словарь на writer-side. Read-api (отдельный модуль) фильтровал
корректно. Issue найден через AuthE2ETest в Phase 2c.
Все 6 AuthE2ETest и 24 unit-теста rest-api зелёные.
PostgreSQL не может вывести тип параметра OffsetDateTime когда оба боковых
выражения IS NULL ⇒ 500 на /api/v1/admin/audit. Фикс — controller подставляет
0001-01-01/9999-12-31 если from/to не указаны, JPQL использует обычное
сравнение eventTime BETWEEN :from AND :to. Строковые фильтры (dictionary,
user, action, businessKey) остаются с :p IS NULL OR x = :p — они работают
потому что VARCHAR PG может вывести из правой стороны сравнения.
До этого audit_log entity была определена, но никто туда не писал — реальный
аудит шёл только через Hibernate Envers (без user/IP/trace). Теперь:
- AuditLogger service пишет в той же транзакции что и CRUD (DictionaryRecordService).
Захватывает: user (JWT sub либо preferred_username), scope, IP (X-Forwarded-For),
UA, trace_id (MDC), request_id. Не ломает основной flow при сбое — только
громко логирует.
- AuditLogRepository: гибкий search() с nullable фильтрами (dictionary, user,
action, businessKey, from, to) + Pageable.
- AuditAdminController: GET /admin/audit (paginated) + /admin/audit/export.csv
(cap 10k строк против OOM).
Frontend:
- /audit route: фильтр-панель, таблица с цветными бейджами (CREATE/UPDATE/CLOSE),
expand-row с JSON before/after diff, footer IP/UA/req_id, кнопка экспорта CSV.
Pagination 50/100/200.
- /outbox route: stat-cards pending/DLQ + DLQ-таблица с last_error.
- Nav links + i18n (RU + EN).
CI: path-filtering (наконец-то)
- Backend jobs (maven-test, maven-package, docker-app/read-api/projection-writer/migrations,
resolve-backend-tag) запускаются только при backend-changes (Java модули, pom.xml, CI yml).
- Frontend (docker-admin-ui, resolve-frontend-tag) — только при ordinis-admin-ui/**.
- Trigger downstream передаёт BACKEND_IMAGE_TAG и/или FRONTEND_IMAGE_TAG. Backwards
compat для старого UPSTREAM_IMAGE_TAG сохранён в infra pipeline.
- Frontend-only PR теперь не катит backend rolling update (raньше каждый push
тегал ВСЕ сервисы новым sha и роллил).
Edit dialog UX (bitemporal):
- При Edit validFrom default = now() (а не historical validFrom существующей записи).
Раньше юзер сдвигал validFrom назад → backend не находил активной версии в этот
момент → 'record_not_active'. Теперь по умолчанию 'новая версия с этого момента'.
- nowIsoLocal() в timezone браузера (с offset).
- validTo default пустой = бессрочно.
Backend: понятное сообщение для record_not_active с инструкцией.
Backend (defensive):
- GlobalExceptionHandler ловит HttpMessageNotReadableException → 400 c понятным
сообщением вместо 500 'Internal server error'.
- DateTimeParseException → code='invalid_date_format', сообщение с подсказкой
ISO формата (например 2026-05-08T14:30:00+03:00).
- InvalidFormatException → code='invalid_field_format' с именем сломанного поля.
- Раньше падало 500 если фронт отправлял 'YYYY-MM-DD' вместо OffsetDateTime
(из-за browser cache на старом bundle).
Frontend:
- Submit-валидация validFrom < validTo с inline error на validTo поле,
переключение на таб 'Идентификация' с фокусом на ошибке.
- DateTimeField теперь принимает hint и error props и показывает их под
парой DatePicker+TextInput[time].
- Подсказки i18n под полями: 'Когда запись становится активной. Пусто = с момента
создания.' / 'Когда перестаёт действовать. Пусто = бессрочно.'
- Новая i18n ключ form.error.validToBeforeFrom (RU/EN).
x-id-source (auto-derive businessKey):
- DictionaryRecordService.resolveBusinessKey() читает schemaJson['x-id-source'].
- Если задан — businessKey берётся из data[idSource]. Если клиент явно прислал
и значения не совпадают → 400 business_key_mismatch (защита от рассинхрона).
- Если поле в data пустое → 400 id_source_missing.
- Если x-id-source не задан и businessKey пуст → 400 business_key_required.
x-unique (constraint на дубли значений):
- DictionaryRecordRepository.findActiveByJsonField() — native SQL c data->>field.
- DictionaryRecordService.enforceUniqueFields() walks schema.properties, для
каждого property с x-unique=true ищет активные записи с тем же значением.
Исключает current record на update.
- Конфликт → 409 unique_field_violation с указанием business_key конфликтующей.
Делается на каждом create/update в той же SERIALIZABLE транзакции что и save —
без отдельного DB constraint (для MVP). Partial unique index per-field —
оптимизация на потом, когда будет много справочников с x-unique.
Update bitemporal записи: close old (set valid_to = new valid_from) → insert new.
Hibernate batch'ил эти два save() и до commit'а UPDATE не flush'ился. PostgreSQL
видел что старая запись имеет valid_to=FAR_FUTURE и tstzrange новой записи
пересекался → 23P01 conflicting key value violates exclusion constraint
dictionary_records_no_overlap → 500 для пользователя.
Заменил repository.save(current) на saveAndFlush(current) — UPDATE летит в БД
до INSERT, range'ы становятся [from1, X) и [X, to2) и не пересекаются.
@Scheduled(cron='0 17 * * * *') запускает deleteExpired раз в час (17-я
минута, чтобы не совпало с другими cron). Конфигурируется через
ordinis.idempotency.cleanup-cron. Counter ordinis_idempotency_cleaned_total
для observability.
Новый модуль ordinis-auth:
- OrdinisAuthProperties — public-key, issuer, requireAuthentication, allowQueryScope
- ScopeContext — резолвит current scopes из JWT claim 'scopes' (приоритет),
потом legacy ?as_scope=, потом anonymous=PUBLIC
- SecurityConfig — Spring Security OAuth2 Resource Server + RSA public-key
decoder. Если public-key пуст — JWT validation отключён (permissive
fallback); rollout без auth-сервера не ломает API.
Read controller теперь делегирует scope resolution в ScopeContext вместо
прямого парсинга ?as_scope=. Backward compat: legacy query параметр
работает пока ordinis.auth.allow-query-scope=true (по умолчанию true в
dev/staging, false в prod после интеграции с @nstart/auth).
Vault: public-key читается из secret/ordinis/cuod/jwt-public-key (key=key).
Spring Cloud Vault flatten'ит в property 'key', resolved в
ordinis.auth.public-key=${key:}.
ContentCachingRequestWrapper только cache'ит body ПОСЛЕ того как handler
прочитает getInputStream(). Если filter читает body первым (для hash),
handler потом получает 'Required request body is missing' — буфер пустой.
CachedBodyHttpServletRequest при construction читает body в byte[] и
getInputStream() возвращает свежий ByteArrayInputStream при каждом вызове.
Filter и handler читают одни и те же байты независимо.
OncePerRequestFilter перехватывает POST/PUT/PATCH с заголовком
Idempotency-Key. Алгоритм:
- sha-256(body) → request_hash
- key найден с тем же hash → возврат cached response (status + body)
- key с другим hash → 422 idempotency_conflict
- key зарезервирован но без response → 409 idempotency_in_progress
- новый key → reserve, запустить handler, кэшировать 2xx ответ
Race-safety: reserve через PK saveAndFlush, DataIntegrityViolation
поднимается до 409. retention 30 дней (cleanup job — будущая работа).
Filter автоматически регистрируется через @Component и
scanBasePackages в OrdinisApplication.