feat: add messaging R2 polling producer
This commit is contained in:
@@ -816,6 +816,23 @@ app-bootstrap 은 합성 루트라 "왜 이 의존성이, 왜 이 scope 로" 결
|
||||
BOM 이 관리.
|
||||
- **`spring-security-test`** — `@WithMockUser`로 actuator 보안 인가(permit-all 프로브 / authenticated
|
||||
loggers / loggers 쓰기 거부)를 검증한다.
|
||||
- **`snakeyaml`** — `config/messaging/*.yaml`의 first polling-producer tuple, closed maturity,
|
||||
wildcard-free compatibility, evidence task/scenario/runbook 선언을
|
||||
`MessagingCapabilityRegistryContractTest`가 읽어 검증한다. 이 레지스트리는 런타임 설정이 아니며
|
||||
모든 card가 `not-implemented`인 동안 R2나 release-ready를 뜻하지 않는다.
|
||||
|
||||
### Messaging qualification scaffold
|
||||
|
||||
`config/messaging/`은 first polling-producer tuple의 machine-readable 계획 truth만 보관한다.
|
||||
현재 card 11개는 모두 `maturity: not-implemented`, `evidenceFingerprint: ""`이고 consumer,
|
||||
CDC, EOS, schema-registry 확장 row는 없다. Task 6의 `verifyMessagingJsonSchemaV1`과
|
||||
`verifyMessagingContracts`는 exact qualification test와 test/build 전용 Draft 2020-12 manifest
|
||||
validator를 실행한다. combined task는 JSON-only task/validator에 명시적으로 의존하므로 CLI 순서와
|
||||
무관하게 shared `contracts-schema/manifest.json`의 최종 소유자가 된다. 나머지 `verifyMessaging*`
|
||||
root task는 후속 owner test와 payload-free evidence validator가 구현되기 전까지 공통 fail-closed
|
||||
guard에서 반드시 non-zero로 종료한다. 파일 존재, SKIP, 오래된 evidence, 다른 source digest 또는
|
||||
다른 profile hash를 PASS로 취급하지 않는다. 이 scaffold는 broker client, scheduler, thread,
|
||||
network 또는 다른 런타임 resource를 생성하지 않는다.
|
||||
|
||||
### ArchUnit "violation-as-data" fixture 의존성 (test 컴파일러 전용)
|
||||
ArchUnit 규칙이 **금지**하는 타입을 fixture 가 일부러 import 해서, 규칙이 실제로 그 위반을 잡는지
|
||||
|
||||
@@ -96,6 +96,8 @@ dependencies {
|
||||
testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0'
|
||||
// test-only: jackson-databind for the deserialization-policy boundary test. See README.
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-json'
|
||||
// test-only: parses checked-in Messaging capability registries for the fail-closed drift gate.
|
||||
testImplementation 'org.yaml:snakeyaml'
|
||||
// sample-on only: ArchUnit analyses the reference impl. sampleOffTest intentionally omits it.
|
||||
sampleFixture project(':sample-portfolio')
|
||||
// test-only: ArchUnit violation fixtures intentionally import forbidden types. See README.
|
||||
|
||||
@@ -9,6 +9,7 @@ ch.qos.logback:logback-core:1.5.21=compileClasspath,productionRuntimeClasspath,r
|
||||
ch.qos.logback:logback-core:1.5.34=sampleFixture
|
||||
com.approvaltests:approvaltests-util:31.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.approvaltests:approvaltests:31.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.ethlo.time:itu:1.14.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-annotations:2.21=sampleFixture
|
||||
com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
@@ -57,6 +58,7 @@ com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,sampleOf
|
||||
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
|
||||
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
|
||||
com.jayway.jsonpath:json-path:2.9.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.networknt:json-schema-validator:3.0.2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
com.nimbusds:nimbus-jose-jwt:10.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
|
||||
com.nimbusds:nimbus-jose-jwt:9.37.4=sampleFixture
|
||||
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
|
||||
@@ -346,7 +348,7 @@ org.springframework.boot:spring-boot-starter-jackson:4.0.0=productionRuntimeClas
|
||||
org.springframework.boot:spring-boot-starter-jdbc:3.5.16=sampleFixture
|
||||
org.springframework.boot:spring-boot-starter-jdbc:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-json:3.5.16=sampleFixture
|
||||
org.springframework.boot:spring-boot-starter-json:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-json:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-logging:3.5.16=sampleFixture
|
||||
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
|
||||
+20
@@ -7,6 +7,8 @@ import com.tngtech.archunit.core.importer.ClassFileImporter;
|
||||
import com.tngtech.archunit.lang.EvaluationResult;
|
||||
import dev.caskeleton.application.architecture.violations.ApplicationDiagnosticFrameworkViolation;
|
||||
import dev.caskeleton.bootstrap.architecture.allowed.application.CleanProjectionQueryPort;
|
||||
import dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.RawExternalResponseFixture;
|
||||
import dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.messaging.GenericTypeLeakingAdapterFixture;
|
||||
import dev.caskeleton.bootstrap.architecture.violations.application.BulkWriteWithoutWriteAccessUseCase;
|
||||
import dev.caskeleton.bootstrap.architecture.violations.application.FixtureRepository;
|
||||
import dev.caskeleton.bootstrap.architecture.violations.application.GenericLeakQueryPort;
|
||||
@@ -85,6 +87,10 @@ class ArchitectureViolationFixtureTest {
|
||||
private static final JavaClasses CLEAN_PROJECTION_QUERY_PORT_ONLY =
|
||||
new ClassFileImporter().importClasses(CleanProjectionQueryPort.class);
|
||||
|
||||
private static final JavaClasses GENERIC_ADAPTER_LEAK_ONLY =
|
||||
new ClassFileImporter()
|
||||
.importClasses(GenericTypeLeakingAdapterFixture.class, RawExternalResponseFixture.class);
|
||||
|
||||
// feature-domain-modeling-guardrails: the @ValueObject rule is an OR of an annotation
|
||||
// branch and a "..domain.vo.." package branch — each is imported in ISOLATION so a
|
||||
// silently broken branch cannot pass vacuously via the other one in the shared pool.
|
||||
@@ -397,6 +403,20 @@ class ArchitectureViolationFixtureTest {
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void outboundAdapterMethodReturnsOnlyDomainCatchesGenericAdapterLeakInIsolation() {
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest
|
||||
.MESSAGING_OUTBOUND_PUBLIC_INSTANCE_METHODS_DO_NOT_LEAK_ADAPTER_TYPES_THROUGH_GENERICS
|
||||
.evaluate(GENERIC_ADAPTER_LEAK_ONLY);
|
||||
|
||||
assertThat(result.hasViolation())
|
||||
.as(
|
||||
"MESSAGING_OUTBOUND_PUBLIC_INSTANCE_METHODS_DO_NOT_LEAK_ADAPTER_TYPES_THROUGH_GENERICS "
|
||||
+ "must recursively catch List<RawExternalResponseFixture> (B7)")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void controllerRequestMappingsFollowAip122CatchesKebabPathFixture() {
|
||||
EvaluationResult result =
|
||||
|
||||
+56
@@ -1272,6 +1272,62 @@ class CleanArchitectureTest {
|
||||
+ "response surfaces.")
|
||||
.allowEmptyShould(true);
|
||||
|
||||
@ArchTest
|
||||
static final ArchRule
|
||||
MESSAGING_OUTBOUND_PUBLIC_INSTANCE_METHODS_DO_NOT_LEAK_ADAPTER_TYPES_THROUGH_GENERICS =
|
||||
methods()
|
||||
.that()
|
||||
.areDeclaredInClassesThat()
|
||||
.resideInAPackage("..adapter.outbound.messaging..")
|
||||
.and()
|
||||
.areDeclaredInClassesThat()
|
||||
.areNotAnnotatedWith("org.springframework.context.annotation.Configuration")
|
||||
.and()
|
||||
.areDeclaredInClassesThat()
|
||||
.areNotAnnotatedWith(
|
||||
"org.springframework.boot.context.properties.ConfigurationProperties")
|
||||
.and()
|
||||
.arePublic()
|
||||
.and()
|
||||
.areNotStatic()
|
||||
.should(
|
||||
notReturnAdapterTypesIncludingGenericArguments(
|
||||
"..adapter.outbound..",
|
||||
"..adapter.inbound.web..",
|
||||
"..adapter.outbound.persistence.."))
|
||||
.as(
|
||||
"B7 messaging hardening: every public non-static messaging method must keep "
|
||||
+ "adapter-local types out of direct and recursive generic return positions; "
|
||||
+ "narrow public static composition bridges are the only allowed adapter-local "
|
||||
+ "return seam")
|
||||
.allowEmptyShould(true);
|
||||
|
||||
private static ArchCondition<JavaMethod> notReturnAdapterTypesIncludingGenericArguments(
|
||||
String... forbiddenPackages) {
|
||||
DescribedPredicate<JavaClass> forbidden =
|
||||
JavaClass.Predicates.resideInAnyPackage(forbiddenPackages);
|
||||
return new ArchCondition<>(
|
||||
"not return adapter-local types directly or through generic arguments") {
|
||||
@Override
|
||||
public void check(JavaMethod method, ConditionEvents events) {
|
||||
for (JavaClass involved : method.getReturnType().getAllInvolvedRawTypes()) {
|
||||
if (forbidden.test(involved)) {
|
||||
events.add(
|
||||
SimpleConditionEvent.violated(
|
||||
method,
|
||||
"Method "
|
||||
+ method.getFullName()
|
||||
+ " leaks "
|
||||
+ involved.getName()
|
||||
+ " through its return type (directly or as a generic argument)"
|
||||
+ " — B7 permits only narrow static composition bridges for "
|
||||
+ "adapter-local types"));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ArchTest
|
||||
static final ArchRule VALID_CASCADE_DEPTH_AT_MOST_THREE =
|
||||
classes()
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.messaging;
|
||||
|
||||
import dev.caskeleton.bootstrap.architecture.violations.adapter.outbound.RawExternalResponseFixture;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Negative B7 fixture proving adapter-local types cannot escape through generic return arguments.
|
||||
*/
|
||||
public class GenericTypeLeakingAdapterFixture {
|
||||
|
||||
public List<RawExternalResponseFixture> leakGeneric() {
|
||||
return List.of(new RawExternalResponseFixture());
|
||||
}
|
||||
}
|
||||
+510
@@ -0,0 +1,510 @@
|
||||
package dev.caskeleton.bootstrap.contract.messaging;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.yaml.snakeyaml.LoaderOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
import org.yaml.snakeyaml.constructor.SafeConstructor;
|
||||
import org.yaml.snakeyaml.error.YAMLException;
|
||||
|
||||
class MessagingCapabilityRegistryContractTest {
|
||||
|
||||
private static final Set<String> FIRST_TUPLE_CARD_IDS =
|
||||
Set.of(
|
||||
"messaging-outbox-publish.v1",
|
||||
"kafka-spring-acknowledged-idempotent.v1",
|
||||
"postgresql-polling-outbox.v2",
|
||||
"postgresql-per-record-jit-claim.v1",
|
||||
"json-schema-envelope.v1",
|
||||
"external-topic-validated.v1",
|
||||
"kafka-sasl-ssl-scram-sha-512.v1",
|
||||
"kafka-compression-none.v1",
|
||||
"per-key-normal-path-sequence-detectable.v1",
|
||||
"same-postgresql-transaction-resource.v1",
|
||||
"authenticated-internal-web-disposition.v1");
|
||||
|
||||
private static final Set<String> LEGAL_MATURITY =
|
||||
Set.of("not-implemented", "implemented-candidate", "release-eligible");
|
||||
|
||||
private static final Set<String> REQUIRED_VERIFICATION_TASKS =
|
||||
Set.of(
|
||||
"verifyMessagingContracts",
|
||||
"verifyMessagingJsonSchemaV1",
|
||||
"verifyMessagingPollingOutboxR2",
|
||||
"verifyMessagingKafkaProducerR2",
|
||||
"verifyMessagingSecurityR2",
|
||||
"verifyMessagingReleaseProfile",
|
||||
"verifyMessagingTargetBindingPreflight",
|
||||
"verifyMessagingTargetBinding",
|
||||
"verifyMessagingDeploymentCutover",
|
||||
"verifyMessagingCleanupTargetBinding",
|
||||
"verifyMessagingFinalR2Profile");
|
||||
|
||||
private static final Set<String> FORBIDDEN_EXTENSION_TOKENS =
|
||||
Set.of(
|
||||
"consumer",
|
||||
"inbox",
|
||||
"cdc",
|
||||
"debezium",
|
||||
"connect",
|
||||
"eos",
|
||||
"exactly-once",
|
||||
"schema-registry",
|
||||
"schema_registry",
|
||||
"avro",
|
||||
"protobuf");
|
||||
|
||||
private static final List<String> COMPATIBILITY_SELECTOR_FIELDS =
|
||||
List.of(
|
||||
"semanticContractId",
|
||||
"dispatchProfile",
|
||||
"claimProfile",
|
||||
"serializationProfile",
|
||||
"producerProfile",
|
||||
"securityProfile",
|
||||
"topicProfile",
|
||||
"compressionProfile",
|
||||
"orderingProfile",
|
||||
"transactionProfile",
|
||||
"operatorControlProfile");
|
||||
|
||||
@Test
|
||||
void yamlLoaderRejectsDuplicateKeys() {
|
||||
String duplicateKeyFixture =
|
||||
"""
|
||||
schemaVersion: 1
|
||||
cards:
|
||||
- cardId: first.v1
|
||||
maturity: not-implemented
|
||||
maturity: release-eligible
|
||||
""";
|
||||
|
||||
assertThatThrownBy(() -> readYaml(duplicateKeyFixture))
|
||||
.isInstanceOf(YAMLException.class)
|
||||
.hasMessageContaining("duplicate");
|
||||
}
|
||||
|
||||
@Test
|
||||
void readinessCardsContainExactlyTheFirstPollingProducerTuple() throws Exception {
|
||||
Map<String, Object> registry = readYaml(requiredConfig("readiness-cards.yaml"));
|
||||
List<Map<String, Object>> cards = rows(registry, "cards");
|
||||
Set<String> cardIds = stringValues(cards, "cardId");
|
||||
|
||||
assertThat(cardIds).containsExactlyInAnyOrderElementsOf(FIRST_TUPLE_CARD_IDS);
|
||||
assertThat(cards).hasSize(FIRST_TUPLE_CARD_IDS.size());
|
||||
assertThat(cardIds).hasSameSizeAs(cards);
|
||||
}
|
||||
|
||||
@Test
|
||||
void onlyTheQualifiedJsonSchemaCardIsAnImplementedCandidate() throws Exception {
|
||||
List<Map<String, Object>> cards =
|
||||
rows(readYaml(requiredConfig("readiness-cards.yaml")), "cards");
|
||||
|
||||
for (Map<String, Object> card : cards) {
|
||||
assertThat(card.get("maturity")).isIn(LEGAL_MATURITY);
|
||||
assertThat(card.get("evidenceFingerprint")).isEqualTo("");
|
||||
if ("json-schema-envelope.v1".equals(card.get("cardId"))) {
|
||||
assertThat(card.get("maturity")).isEqualTo("implemented-candidate");
|
||||
assertThat(card.get("schemaSetHash"))
|
||||
.isEqualTo("sha256:42040504d5c204f9ee0e01bfa17fd9a03182db13f8f99e31b63ec79d5ecc40d0");
|
||||
assertThat(card.get("settingsDigest"))
|
||||
.isEqualTo("sha256:fcd849322d43a8d88c926a43160296339cbc6e945db81e46c513ab45d185b924");
|
||||
} else {
|
||||
assertThat(card.get("maturity")).isEqualTo("not-implemented");
|
||||
assertThat(card.get("schemaSetHash")).isEqualTo("");
|
||||
assertThat(card.get("settingsDigest")).isEqualTo("");
|
||||
}
|
||||
assertNonEmptyStringList(card, "evidenceTasks");
|
||||
assertThat(stringList(card, "evidenceTasks"))
|
||||
.as("evidenceTasks on %s must reference declared root tasks", card.get("cardId"))
|
||||
.allMatch(REQUIRED_VERIFICATION_TASKS::contains);
|
||||
assertNonEmptyStringList(card, "requiredScenarios");
|
||||
assertNonEmptyStringList(card, "runbookIds");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void compatibilityProfilesAreExactUniqueAndWildcardFree() throws Exception {
|
||||
Map<String, Object> registry = readYaml(requiredConfig("profile-compatibility.yaml"));
|
||||
List<Map<String, Object>> profiles = rows(registry, "profiles");
|
||||
|
||||
assertThat(profiles).isNotEmpty();
|
||||
assertUnique(profiles, "profileId");
|
||||
assertThat(flattenStrings(registry))
|
||||
.allSatisfy(
|
||||
value -> {
|
||||
assertThat(value).doesNotContain("*");
|
||||
assertThat(value).doesNotContain("?");
|
||||
});
|
||||
|
||||
for (Map<String, Object> profile : profiles) {
|
||||
List<String> selectedCardIds = stringList(profile, "selectedCardIds");
|
||||
assertListIntegrity(selectedCardIds, "selectedCardIds", profile.get("profileId"));
|
||||
assertThat(selectedCardIds).containsExactlyInAnyOrderElementsOf(FIRST_TUPLE_CARD_IDS);
|
||||
assertThat(selectedCardIds).allMatch(FIRST_TUPLE_CARD_IDS::contains);
|
||||
for (String selectorField : COMPATIBILITY_SELECTOR_FIELDS) {
|
||||
assertThat(profile.get(selectorField))
|
||||
.as("%s must select one of selectedCardIds", selectorField)
|
||||
.isIn(selectedCardIds);
|
||||
}
|
||||
assertNonEmptyStringList(profile, "requiredScenarios");
|
||||
assertListIntegrityIfPresent(profile, "runbookIds");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void releaseAssertionsDeclareUniqueTasksScenariosRunbooksAndFailClosedEvidencePolicy()
|
||||
throws Exception {
|
||||
Map<String, Object> registry = readYaml(requiredConfig("release-profile-assertions.yaml"));
|
||||
List<Map<String, Object>> profiles = rows(registry, "releaseProfiles");
|
||||
|
||||
assertThat(profiles).isNotEmpty();
|
||||
assertUnique(profiles, "releaseProfileId");
|
||||
Set<String> compatibilityProfileIds =
|
||||
stringValues(
|
||||
rows(readYaml(requiredConfig("profile-compatibility.yaml")), "profiles"), "profileId");
|
||||
for (Map<String, Object> profile : profiles) {
|
||||
List<String> selectedCardIds = stringList(profile, "selectedCardIds");
|
||||
assertListIntegrity(selectedCardIds, "selectedCardIds", profile.get("releaseProfileId"));
|
||||
assertThat(selectedCardIds).containsExactlyInAnyOrderElementsOf(FIRST_TUPLE_CARD_IDS);
|
||||
assertThat(profile.get("compatibilityProfileId")).isIn(compatibilityProfileIds);
|
||||
assertThat(profile.get("requiredCardMaturity")).isEqualTo("release-eligible");
|
||||
|
||||
List<String> requiredEvidenceTasks = stringList(profile, "requiredEvidenceTasks");
|
||||
assertListIntegrity(
|
||||
requiredEvidenceTasks, "requiredEvidenceTasks", profile.get("releaseProfileId"));
|
||||
assertThat(requiredEvidenceTasks)
|
||||
.containsExactlyInAnyOrderElementsOf(REQUIRED_VERIFICATION_TASKS);
|
||||
assertNonEmptyStringList(profile, "requiredScenarios");
|
||||
assertNonEmptyStringList(profile, "runbookIds");
|
||||
|
||||
Map<String, Object> evidencePolicy = map(profile, "evidencePolicy");
|
||||
assertThat(evidencePolicy)
|
||||
.containsEntry("rejectMissing", true)
|
||||
.containsEntry("rejectSkipped", true)
|
||||
.containsEntry("rejectStale", true)
|
||||
.containsEntry("rejectWrongSource", true)
|
||||
.containsEntry("rejectMismatchedProfile", true);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void registriesDoNotPredeclareFutureConsumerCdcEosOrSchemaRegistryRows() throws Exception {
|
||||
Map<String, Object> readiness = readYaml(requiredConfig("readiness-cards.yaml"));
|
||||
Map<String, Object> compatibility = readYaml(requiredConfig("profile-compatibility.yaml"));
|
||||
Map<String, Object> releases = readYaml(requiredConfig("release-profile-assertions.yaml"));
|
||||
|
||||
List<String> rowIdentities = new ArrayList<>();
|
||||
rowIdentities.addAll(stringValues(rows(readiness, "cards"), "cardId"));
|
||||
rowIdentities.addAll(stringValues(rows(compatibility, "profiles"), "profileId"));
|
||||
rowIdentities.addAll(stringValues(rows(releases, "releaseProfiles"), "releaseProfileId"));
|
||||
for (Map<String, Object> profile : rows(compatibility, "profiles")) {
|
||||
rowIdentities.addAll(stringList(profile, "selectedCardIds"));
|
||||
}
|
||||
for (Map<String, Object> profile : rows(releases, "releaseProfiles")) {
|
||||
rowIdentities.addAll(stringList(profile, "selectedCardIds"));
|
||||
}
|
||||
|
||||
for (String identity : rowIdentities) {
|
||||
String normalized = identity.toLowerCase(java.util.Locale.ROOT);
|
||||
assertThat(FORBIDDEN_EXTENSION_TOKENS)
|
||||
.noneSatisfy(
|
||||
token ->
|
||||
assertThat(normalized)
|
||||
.as(
|
||||
"machine row '%s' must not predeclare future extension token '%s'",
|
||||
identity, token)
|
||||
.contains(token));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void commonEvidenceSchemaIsPayloadFreeAndKeepsAllFailClosedFields() throws Exception {
|
||||
Path schema = requiredConfig("evidence/build-evidence-manifest-v1.schema.json");
|
||||
Map<String, Object> root =
|
||||
new ObjectMapper().readValue(schema.toFile(), new TypeReference<Map<String, Object>>() {});
|
||||
|
||||
assertThat(root.get("$schema")).isEqualTo("https://json-schema.org/draft/2020-12/schema");
|
||||
assertThat(root).containsEntry("type", "object").containsEntry("additionalProperties", false);
|
||||
assertThat(stringList(root, "required"))
|
||||
.containsExactlyInAnyOrder(
|
||||
"schemaVersion",
|
||||
"sourceDigest",
|
||||
"artifactDigest",
|
||||
"producerTask",
|
||||
"scenarioIds",
|
||||
"counts",
|
||||
"command",
|
||||
"generatedAt",
|
||||
"hashes",
|
||||
"failures",
|
||||
"skips",
|
||||
"unsupportedClaims");
|
||||
|
||||
String schemaText = Files.readString(schema).toLowerCase(java.util.Locale.ROOT);
|
||||
assertThat(schemaText)
|
||||
.doesNotContain("\"payload\"")
|
||||
.contains("\"profile\"")
|
||||
.contains("\"catalog\"")
|
||||
.contains("\"schema\"")
|
||||
.contains("\"settings\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void commonEvidenceSchemaClosesNestedObjectsAndPreservesCoreFieldConstraints() throws Exception {
|
||||
Map<String, Object> root = readJsonSchema();
|
||||
Map<String, Object> properties = map(root, "properties");
|
||||
Map<String, Object> definitions = map(root, "$defs");
|
||||
|
||||
assertThat(map(properties, "schemaVersion"))
|
||||
.containsEntry("type", "integer")
|
||||
.containsEntry("const", 1);
|
||||
assertThat(map(properties, "sourceDigest")).containsEntry("$ref", "#/$defs/sha256");
|
||||
assertThat(map(properties, "artifactDigest")).containsEntry("$ref", "#/$defs/sha256");
|
||||
|
||||
Map<String, Object> producerTask = map(properties, "producerTask");
|
||||
assertThat(producerTask).containsEntry("type", "string");
|
||||
assertThat(stringList(producerTask, "enum"))
|
||||
.containsExactlyInAnyOrderElementsOf(REQUIRED_VERIFICATION_TASKS);
|
||||
|
||||
assertArrayOfReference(
|
||||
map(properties, "scenarioIds"), "#/$defs/identifier", true, Integer.valueOf(1));
|
||||
assertClosedObject(
|
||||
map(properties, "counts"), Set.of("executed", "passed", "failed", "skipped"));
|
||||
Map<String, Object> countProperties = map(map(properties, "counts"), "properties");
|
||||
assertIntegerMinimum(countProperties, "executed", 1);
|
||||
assertIntegerMinimum(countProperties, "passed", 0);
|
||||
assertIntegerMinimum(countProperties, "failed", 0);
|
||||
assertIntegerMinimum(countProperties, "skipped", 0);
|
||||
|
||||
assertThat(map(properties, "command")).containsEntry("type", "string");
|
||||
assertThat(map(properties, "generatedAt"))
|
||||
.containsEntry("type", "string")
|
||||
.containsEntry("format", "date-time");
|
||||
|
||||
Map<String, Object> hashes = map(properties, "hashes");
|
||||
assertClosedObject(hashes, Set.of("profile", "catalog", "schema", "settings"));
|
||||
Map<String, Object> hashProperties = map(hashes, "properties");
|
||||
for (String hashName : List.of("profile", "catalog", "schema", "settings")) {
|
||||
assertThat(map(hashProperties, hashName)).containsEntry("$ref", "#/$defs/sha256");
|
||||
}
|
||||
|
||||
assertArrayOfReference(map(properties, "failures"), "#/$defs/result", false, null);
|
||||
assertArrayOfReference(map(properties, "skips"), "#/$defs/result", false, null);
|
||||
assertArrayOfReference(map(properties, "unsupportedClaims"), "#/$defs/identifier", true, null);
|
||||
|
||||
assertThat(map(definitions, "sha256"))
|
||||
.containsEntry("type", "string")
|
||||
.containsEntry("pattern", "^sha256:[a-f0-9]{64}$");
|
||||
assertThat(map(definitions, "identifier"))
|
||||
.containsEntry("type", "string")
|
||||
.containsEntry("pattern", "^[A-Za-z0-9][A-Za-z0-9._:-]*$");
|
||||
|
||||
Map<String, Object> result = map(definitions, "result");
|
||||
assertClosedObject(result, Set.of("scenarioId", "reason"));
|
||||
Map<String, Object> resultProperties = map(result, "properties");
|
||||
assertThat(map(resultProperties, "scenarioId")).containsEntry("$ref", "#/$defs/identifier");
|
||||
assertThat(map(resultProperties, "reason"))
|
||||
.containsEntry("type", "string")
|
||||
.containsEntry("minLength", 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rootBuildDeclaresEveryFailClosedVerificationTaskThroughTheSharedGuard() throws Exception {
|
||||
String build = Files.readString(repositorySrcRoot().resolve("build.gradle"));
|
||||
|
||||
assertThat(build).contains("messagingFailClosedEvidenceGuard");
|
||||
assertThat(build)
|
||||
.contains(
|
||||
"messagingVerificationSkeletons.each",
|
||||
"tasks.register(taskName)",
|
||||
"messagingFailClosedEvidenceGuard(taskName, evidencePaths)",
|
||||
"qualification producer/tests and common-schema validator are not implemented",
|
||||
"missing evidence",
|
||||
"contains skipped evidence",
|
||||
"is stale or future-dated",
|
||||
"has wrong source digest",
|
||||
"has mismatched profile hash");
|
||||
for (String taskName : REQUIRED_VERIFICATION_TASKS) {
|
||||
assertThat(build).contains("'" + taskName + "'");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rootBuildSchemaValidatesEvidenceAndDeterministicallyLeavesCombinedEvidenceLast()
|
||||
throws Exception {
|
||||
String build = Files.readString(repositorySrcRoot().resolve("build.gradle"));
|
||||
|
||||
assertThat(build)
|
||||
.contains(
|
||||
"MessagingEvidenceManifestSchemaValidator",
|
||||
"validateMessagingJsonSchemaV1EvidenceManifestSchema",
|
||||
"validateMessagingContractsEvidenceManifestSchema",
|
||||
"verifyMessagingJsonSchemaV1.configure",
|
||||
"finalizedBy validateMessagingJsonSchemaV1EvidenceManifestSchema",
|
||||
"dependsOn validateMessagingJsonSchemaV1EvidenceManifestSchema",
|
||||
"verifyMessagingContracts.configure",
|
||||
"finalizedBy validateMessagingContractsEvidenceManifestSchema");
|
||||
}
|
||||
|
||||
private static Path requiredConfig(String relativePath) {
|
||||
Path path = repositorySrcRoot().resolve("config/messaging").resolve(relativePath);
|
||||
assertThat(path).as("required Messaging configuration %s", path).isRegularFile();
|
||||
return path;
|
||||
}
|
||||
|
||||
private static Path repositorySrcRoot() {
|
||||
Path directory = Path.of("").toAbsolutePath();
|
||||
for (int depth = 0; depth < 8 && directory != null; depth++) {
|
||||
if (Files.isRegularFile(directory.resolve("settings.gradle"))
|
||||
&& Files.isRegularFile(directory.resolve("config/architecture/modules.json"))) {
|
||||
return directory;
|
||||
}
|
||||
directory = directory.getParent();
|
||||
}
|
||||
throw new IllegalStateException(
|
||||
"Could not locate repository src root from test working directory");
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Map<String, Object> readYaml(Path path) throws Exception {
|
||||
try (InputStream input = Files.newInputStream(path)) {
|
||||
return readYaml(input);
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, Object> readYaml(String yaml) {
|
||||
return readYaml((Object) yaml);
|
||||
}
|
||||
|
||||
private static Map<String, Object> readYaml(InputStream yaml) {
|
||||
return readYaml((Object) yaml);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Map<String, Object> readYaml(Object yamlInput) {
|
||||
LoaderOptions loaderOptions = new LoaderOptions();
|
||||
loaderOptions.setAllowDuplicateKeys(false);
|
||||
Yaml yaml = new Yaml(new SafeConstructor(loaderOptions));
|
||||
Object value =
|
||||
yamlInput instanceof InputStream input ? yaml.load(input) : yaml.load((String) yamlInput);
|
||||
assertThat(value).isInstanceOf(Map.class);
|
||||
return (Map<String, Object>) value;
|
||||
}
|
||||
|
||||
private static Map<String, Object> readJsonSchema() throws Exception {
|
||||
Path schema = requiredConfig("evidence/build-evidence-manifest-v1.schema.json");
|
||||
return new ObjectMapper()
|
||||
.readValue(schema.toFile(), new TypeReference<Map<String, Object>>() {});
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static List<Map<String, Object>> rows(Map<String, Object> root, String key) {
|
||||
assertThat(root.get(key)).as("top-level '%s' rows", key).isInstanceOf(List.class);
|
||||
return (List<Map<String, Object>>) root.get(key);
|
||||
}
|
||||
|
||||
private static Set<String> stringValues(List<Map<String, Object>> rows, String key) {
|
||||
Set<String> values = new LinkedHashSet<>();
|
||||
for (Map<String, Object> row : rows) {
|
||||
assertThat(row.get(key)).isInstanceOf(String.class);
|
||||
values.add((String) row.get(key));
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private static void assertUnique(List<Map<String, Object>> rows, String key) {
|
||||
Set<String> values = stringValues(rows, key);
|
||||
assertThat(values).hasSameSizeAs(rows);
|
||||
}
|
||||
|
||||
private static void assertNonEmptyStringList(Map<String, Object> row, String key) {
|
||||
assertListIntegrity(stringList(row, key), key, row);
|
||||
}
|
||||
|
||||
private static void assertListIntegrityIfPresent(Map<String, Object> row, String key) {
|
||||
if (row.containsKey(key)) {
|
||||
assertListIntegrity(stringList(row, key), key, row);
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertListIntegrity(List<String> values, String key, Object owner) {
|
||||
assertThat(values)
|
||||
.as("%s on %s", key, owner)
|
||||
.isNotEmpty()
|
||||
.doesNotHaveDuplicates()
|
||||
.allSatisfy(value -> assertThat(value).isNotBlank());
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static List<String> stringList(Map<String, Object> row, String key) {
|
||||
assertThat(row.get(key)).as("required list '%s' on %s", key, row).isInstanceOf(List.class);
|
||||
List<Object> values = (List<Object>) row.get(key);
|
||||
assertThat(values).allMatch(String.class::isInstance);
|
||||
return values.stream().map(String.class::cast).toList();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Map<String, Object> map(Map<String, Object> row, String key) {
|
||||
assertThat(row.get(key)).as("required map '%s' on %s", key, row).isInstanceOf(Map.class);
|
||||
return (Map<String, Object>) row.get(key);
|
||||
}
|
||||
|
||||
private static void assertClosedObject(Map<String, Object> schema, Set<String> required) {
|
||||
assertThat(schema).containsEntry("type", "object").containsEntry("additionalProperties", false);
|
||||
assertThat(stringList(schema, "required")).containsExactlyInAnyOrderElementsOf(required);
|
||||
}
|
||||
|
||||
private static void assertArrayOfReference(
|
||||
Map<String, Object> schema, String reference, boolean unique, Integer minimumItems) {
|
||||
assertThat(schema).containsEntry("type", "array");
|
||||
assertThat(map(schema, "items")).containsEntry("$ref", reference);
|
||||
if (unique) {
|
||||
assertThat(schema).containsEntry("uniqueItems", true);
|
||||
}
|
||||
if (minimumItems != null) {
|
||||
assertThat(schema).containsEntry("minItems", minimumItems);
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertIntegerMinimum(
|
||||
Map<String, Object> properties, String field, int minimum) {
|
||||
assertThat(map(properties, field))
|
||||
.containsEntry("type", "integer")
|
||||
.containsEntry("minimum", minimum);
|
||||
}
|
||||
|
||||
private static List<String> flattenStrings(Object value) {
|
||||
List<String> strings = new ArrayList<>();
|
||||
flattenStrings(value, strings, new HashSet<>());
|
||||
return strings;
|
||||
}
|
||||
|
||||
private static void flattenStrings(
|
||||
Object value, List<String> destination, Set<Object> visitedContainers) {
|
||||
if (value instanceof String string) {
|
||||
destination.add(string);
|
||||
} else if (value instanceof Map<?, ?> map && visitedContainers.add(value)) {
|
||||
map.forEach(
|
||||
(key, nested) -> {
|
||||
destination.add(String.valueOf(key));
|
||||
flattenStrings(nested, destination, visitedContainers);
|
||||
});
|
||||
} else if (value instanceof Iterable<?> iterable && visitedContainers.add(value)) {
|
||||
iterable.forEach(nested -> flattenStrings(nested, destination, visitedContainers));
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -53,7 +53,7 @@ class OutboxAppendTransactionalContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void outboxRowIsAbsentWhenEnclosingTransactionRollsBack() {
|
||||
void sameTransactionAppendRollbackRemovesOutboxRowCharacterization() {
|
||||
Clock clock = Clock.fixed(Instant.now(), ZoneOffset.UTC);
|
||||
|
||||
try (AnnotationConfigApplicationContext ctx =
|
||||
|
||||
+37
@@ -318,6 +318,43 @@ class OutboxRowLifecycleContractTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void equalTimestampRowsAreClaimedTogetherLegacyFifoLimitationCharacterization() {
|
||||
Instant occurredAt = Instant.now();
|
||||
Clock clock = Clock.fixed(occurredAt.plusSeconds(1), ZoneOffset.UTC);
|
||||
|
||||
try (AnnotationConfigApplicationContext ctx =
|
||||
OutboxContainerTestSupport.buildContext(sharedDataSource, e -> {}, clock)) {
|
||||
|
||||
TransactionPort tx = ctx.getBean(TransactionPort.class);
|
||||
OutboxAppendPort append = ctx.getBean(OutboxAppendPort.class);
|
||||
|
||||
String aggregateId = "agg-fifo-tie-" + System.nanoTime();
|
||||
String firstId = "fifo-tie-first-" + System.nanoTime();
|
||||
String secondId = "fifo-tie-second-" + System.nanoTime();
|
||||
|
||||
tx.inWrite(
|
||||
() -> {
|
||||
append.append(newEventAt(firstId, aggregateId, occurredAt));
|
||||
append.append(newEventAt(secondId, aggregateId, occurredAt));
|
||||
return null;
|
||||
});
|
||||
|
||||
OutboxRelayResult cycle =
|
||||
OutboxContainerTestSupport.relayUseCase(ctx)
|
||||
.handle(PublishPendingOutboxEventsCommand.INSTANCE);
|
||||
|
||||
assertThat(cycle.outcomes())
|
||||
.as(
|
||||
"legacy FIFO compares only occurred_at with '<', so equal timestamps do not gate"
|
||||
+ " either row")
|
||||
.filteredOn(
|
||||
outcome -> outcome.eventId().equals(firstId) || outcome.eventId().equals(secondId))
|
||||
.extracting(OutboxRelayResult.EventOutcome::eventId)
|
||||
.containsExactlyInAnyOrder(firstId, secondId);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// FIFO gate blocking scenarios (plan Task E)
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user