diff --git a/docs/mq-local-repair-contract.md b/docs/mq-local-repair-contract.md
new file mode 100644
index 0000000..63b0f29
--- /dev/null
+++ b/docs/mq-local-repair-contract.md
@@ -0,0 +1,10 @@
+# SH MQ 独立配置契约(2026-10-23)
+
+- `svchall.fc.mq-enabled` 默认 false。配置类与消费者使用相同条件;平台注入 RabbitMQ host 不自动打开能力。
+- `svchall.fc.consumer-app-key` 默认引用最终 `yh.app-auth.app-key`,不再直接使用陈旧 env 回退 100018。
+- 只有显式启用且配置 host 才装配 MQ。启用时校验消费凭据键与签名键一致,再用已有 UserBase `getAppByCode(spring.application.name)` 核验注册行的 id/appKey/appCode 关系。
+- 业务应用主身份是 application_info.id;id 可以不等于凭据 appKey。禁止拿 id 或 appCode 替代 MQ 协议的 *AppKey 字段。
+- binding 继续动态生成 `fc.snapshot.v2.<自身消费凭据键>.*`,没有硬编码 100008。空键、通配符或额外 topic 段拒绝。
+- 仅默认链修正不会移除 broker 已有旧 binding;旧绑定、topic 权限及独立 vhost 清理必须由 zcode 在另行授权的环境验证。
+- 当前 MQ 投影入口的 DB 异常分支直接 basicNack(requeue=false),并吞掉异常;不能只凭工厂 retryTemplate 配置宣称发生了三次 listener 重试。实际死信路由和恢复能力 NOT RUN,本补丁未重构这一策略。
+- 本地 JSON 交接/ACK顺序测试不等于真实数据库提交或 broker 验证。核心 HTTP 签名收据链仍独立运行;本轮不处理 B5、不改 UserBase 模型、不启用对账。
diff --git a/src/main/java/com/yunhai/svchall/config/FcAnswerMqConfiguration.java b/src/main/java/com/yunhai/svchall/config/FcAnswerMqConfiguration.java
index febf796..aa8cf5c 100644
--- a/src/main/java/com/yunhai/svchall/config/FcAnswerMqConfiguration.java
+++ b/src/main/java/com/yunhai/svchall/config/FcAnswerMqConfiguration.java
@@ -12,6 +12,8 @@ import org.springframework.amqp.rabbit.config.SimpleRabbitListenerContainerFacto
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
import org.springframework.amqp.rabbit.core.RabbitAdmin;
import org.springframework.beans.factory.annotation.Value;
+import org.springframework.beans.factory.annotation.Autowired;
+import javax.annotation.PostConstruct;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -21,22 +23,22 @@ import org.springframework.retry.support.RetryTemplate;
/**
* v2 快照投递的消费侧拓扑(answer-delivery-v2 §3.3/§7,TASK_2 B.3/B.5)。
*
- *
整体条件装配:只有显式配了 {@code spring.rabbitmq.host} 才存在。未配 broker 时
+ *
整体条件装配:只有显式设置 {@code svchall.fc.mq-enabled=true} 且配置 {@code spring.rabbitmq.host} 才存在。未配 broker 时
* 不建监听容器(否则后台反复重连把启动与日志拖脏),{@code FcAnswerConsumer} 上的
- * {@code @RabbitListener} 因没有处理器而不生效——可信建件仍走"签名收据确认"那条路,
- * 只是延迟从秒级变成"用户点确认那一刻"。
+ * {@code @RabbitListener} 因没有处理器而不生效。平台统一注入 host 不能自动打开消费能力;
+ * 当前可信建件主链始终是签名收据 + HTTP,不依赖 MQ。
*
* 队列隔离是这条链的硬前提:完整值文档只进按消费 appKey + 租户域绑定的私有队列。
* routing key 形如 {@code fc.snapshot.v2..},本应用只绑自己 appKey
* 那一段({@code .*} 只覆盖自己的多租户域),新消费者不得能绑其他目标的 routing key
* (v2 §5 撤权边界的 broker 侧一半)。部署必须落实的实值来源见交付说明"broker 隔离配置"一节:
* 独立 vhost、账号仅对本 vhost 有 configure/write/read、禁止通配订阅他人前缀;
- * 这三条没有落地就不能打开 {@code RABBITMQ_HOST}——宁可不投,也不在共享 vhost 上发明文。
+ * 这三条没有落地就不能设置 {@code svchall.fc.mq-enabled=true}——宁可不投,也不在共享 vhost 上发明文。
*/
@Slf4j
@Configuration
@org.springframework.amqp.rabbit.annotation.EnableRabbit
-@ConditionalOnExpression("!'${spring.rabbitmq.host:}'.trim().isEmpty()")
+@ConditionalOnExpression("${svchall.fc.mq-enabled:false} && !'${spring.rabbitmq.host:}'.trim().isEmpty()")
public class FcAnswerMqConfiguration {
/** 中心侧 v2 exchange(与 FC {@code AnswerDeliveryModels.EXCHANGE} 同一字面量) */
@@ -49,9 +51,32 @@ public class FcAnswerMqConfiguration {
public static final String DLX = "svc-hall.fc.answer.snapshot.v2.dlx";
public static final String DLQ = QUEUE + ".dlq";
- @Value("${svchall.fc.consumer-app-key:${yh.app-auth.app-key:100018}}")
+ @Value("${svchall.fc.consumer-app-key:${yh.app-auth.app-key:}}")
private String consumerAppKey;
+ @Value("${yh.app-auth.app-key:}")
+ private String signingAppKey;
+
+ @Value("${spring.application.name:svc-hall}")
+ private String applicationCode;
+
+ @Autowired
+ private com.yunhai.platform.openapi.base.OpenApiAppClient appRegistryClient;
+
+ @PostConstruct
+ public void validateIdentity() {
+ String key = trimmedAppKey();
+ if (signingAppKey == null || !key.equals(signingAppKey.trim())) {
+ throw new IllegalStateException("CONSUMER_CREDENTIAL_MISMATCH");
+ }
+ com.fasterxml.jackson.databind.JsonNode app = appRegistryClient.getAppByCode(applicationCode);
+ if (app == null || !app.path("id").isIntegralNumber() || !app.path("id").canConvertToLong()
+ || app.path("id").asLong() <= 0 || !key.equals(app.path("appKey").asText())
+ || !applicationCode.equals(app.path("appCode").asText())) {
+ throw new IllegalStateException("CONSUMER_APPLICATION_MISMATCH");
+ }
+ }
+
@Value("${svchall.fc.prefetch-count:10}")
private int prefetchCount;
@@ -123,6 +148,10 @@ public class FcAnswerMqConfiguration {
}
private String trimmedAppKey() {
- return consumerAppKey == null ? "" : consumerAppKey.trim();
+ String key = consumerAppKey == null ? "" : consumerAppKey.trim();
+ if (!key.matches("[A-Za-z0-9_-]+")) {
+ throw new IllegalStateException("INVALID_CONSUMER_ROUTE_KEY");
+ }
+ return key;
}
}
diff --git a/src/main/java/com/yunhai/svchall/service/impl/FcAnswerConsumer.java b/src/main/java/com/yunhai/svchall/service/impl/FcAnswerConsumer.java
index eb336b9..5947f11 100644
--- a/src/main/java/com/yunhai/svchall/service/impl/FcAnswerConsumer.java
+++ b/src/main/java/com/yunhai/svchall/service/impl/FcAnswerConsumer.java
@@ -26,7 +26,7 @@ import java.util.List;
/**
* v2 快照消费者(answer-delivery-v2 §7,TASK_2 B.5/B.6)。
*
- * 本类整体条件装配:只有显式配了 {@code spring.rabbitmq.host} 才存在(与
+ *
本类整体条件装配:只有显式设置 {@code svchall.fc.mq-enabled=true} 且配置 {@code spring.rabbitmq.host} 才存在(与
* {@link FcAnswerMqConfiguration} 同一条件表达式)。未接 MQ 的环境里 bean 不创建,
* {@code @RabbitListener} 端点自然不被注册——"MQ 可选"必须落到启动期:注解处理器在
* 容器工厂缺失时会直接抛 {@code NoSuchBeanDefinitionException} 打断启动,不会优雅跳过。
@@ -49,7 +49,7 @@ import java.util.List;
@Slf4j
@Component
@org.springframework.boot.autoconfigure.condition.ConditionalOnExpression(
- "!'${spring.rabbitmq.host:}'.trim().isEmpty()")
+ "${svchall.fc.mq-enabled:false} && !'${spring.rabbitmq.host:}'.trim().isEmpty()")
public class FcAnswerConsumer {
private final AnswerProjectionStore projectionStore;
@@ -57,7 +57,7 @@ public class FcAnswerConsumer {
private final FcAnswerInboxRepository inboxRepository;
private final ObjectMapper objectMapper;
- @Value("${svchall.fc.consumer-app-key:${yh.app-auth.app-key:100018}}")
+ @Value("${svchall.fc.consumer-app-key:${yh.app-auth.app-key:}}")
private String consumerAppKey;
@Value("${svchall.fc.report-batch-size:20}")
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 295fabb..86a5af9 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -149,8 +149,10 @@ svchall:
public-base-url: http://localhost:8086/upload/
# ===== 可信建件与快照消费(answer-delivery-v2 §6~§8,TASK_2)=====
fc:
+ # 平台注入 RabbitMQ host 不代表 SH 允许消费;HTTP 收据建件主链不依赖 MQ。
+ mq-enabled: ${SVCHALL_FC_MQ_ENABLED:false}
# 本应用消费身份(默认取签名身份同一把 appKey;信封目标必须等于它,否则整封拒收)
- consumer-app-key: ${YH_APP_AUTH_APP_KEY:100018}
+ consumer-app-key: ${yh.app-auth.app-key:}
# 被嵌填报页基址 = 表单中心前端地址(不是后端 API 地址):iframe 的 src 由服务端拼,
# 浏览器传进来的任何路径都不采信(A.2 末句)。缺键即拒开托管填报页——宁可不给入口,
# 也不给一个"拼了个大概能打开的地址"的入口。本地=FC front dev(8089),生产 env 注入。
diff --git a/src/test/java/com/yunhai/svchall/service/impl/FcAnswerConsumerMqTest.java b/src/test/java/com/yunhai/svchall/service/impl/FcAnswerConsumerMqTest.java
new file mode 100644
index 0000000..170b429
--- /dev/null
+++ b/src/test/java/com/yunhai/svchall/service/impl/FcAnswerConsumerMqTest.java
@@ -0,0 +1,94 @@
+package com.yunhai.svchall.service.impl;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.rabbitmq.client.Channel;
+import com.yunhai.svchall.entity.FcAnswerInbox;
+import com.yunhai.svchall.model.FcApplyModels.SnapshotEnvelope;
+import com.yunhai.svchall.repository.FcAnswerInboxRepository;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InOrder;
+import org.springframework.amqp.core.Message;
+import org.springframework.amqp.core.MessageProperties;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDateTime;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/** Local JSON handoff and manual ACK/NACK ordering; not real broker or transaction commit evidence. */
+class FcAnswerConsumerMqTest {
+ private final ObjectMapper mapper = new ObjectMapper();
+ private AnswerProjectionStore store;
+ private FcSubmissionClient client;
+ private FcAnswerInboxRepository rows;
+ private FcAnswerConsumer consumer;
+ private Channel channel;
+ private final String json = "{\"protocolVersion\":\"fc.answer.snapshot.v2\",\"eventId\":\"evt\","
+ + "\"eventType\":\"updated\",\"sourceAppKey\":\"100025\",\"consumerAppKey\":\"100018\","
+ + "\"consumerSchoolId\":27,\"grantVersion\":4,\"formUuid\":\"form\",\"formVersion\":1,"
+ + "\"answerUuid\":\"answer\",\"answerRevision\":8,\"isDeleted\":false,\"contentHash\":\"hash\","
+ + "\"occurredAt\":\"2026-10-23T10:00:00+08:00\",\"snapshot\":{\"schId\":27,\"appCode\":\"owner\","
+ + "\"code\":\"leave\",\"submitterId\":41,\"subjectId\":42,\"status\":\"processed\","
+ + "\"values\":{\"reason\":\"leave\"},\"answerNo\":\"LEAVE-8\",\"createTime\":\"2026-10-23T10:00:00\","
+ + "\"updateTime\":\"2026-10-23T10:01:00\",\"intent\":{\"intentId\":\"intent\"}}}";
+
+ @BeforeEach void setUp() {
+ store = mock(AnswerProjectionStore.class); client = mock(FcSubmissionClient.class);
+ rows = mock(FcAnswerInboxRepository.class); channel = mock(Channel.class);
+ consumer = new FcAnswerConsumer(store, client, rows, mapper);
+ ReflectionTestUtils.setField(consumer, "consumerAppKey", "100018");
+ }
+
+ @Test void validJsonPreservesEveryAnswerCoordinateBeforeAck() throws Exception {
+ when(store.applySnapshot(any())).thenReturn(new AnswerProjectionStore.Disposition(
+ AnswerProjectionStore.Outcome.REJECTED, "evt", FcAnswerInbox.STATE_REJECTED, 8L, "hash", null, null));
+ consumer.onMessage(message(json), channel, 5L);
+ ArgumentCaptor envelope = ArgumentCaptor.forClass(SnapshotEnvelope.class);
+ InOrder order = inOrder(store, channel); order.verify(store).applySnapshot(envelope.capture());
+ order.verify(channel).basicAck(5L, false);
+ // Compare the complete JSON wire representation; DTO LongNode vs parsed IntNode is not data loss.
+ com.fasterxml.jackson.databind.JsonNode decoded = mapper.readTree(mapper.writeValueAsBytes(envelope.getValue()));
+ assertThat(decoded).isEqualTo(mapper.readTree(json));
+ verifyNoInteractions(client);
+ }
+
+ @Test void invalidJsonIsAckedAsPoisonWithoutPretendingItWasApplied() throws Exception {
+ consumer.onMessage(message("{broken"), channel, 6L);
+ verify(channel).basicAck(6L, false); verify(channel, never()).basicNack(anyLong(), anyBoolean(), anyBoolean());
+ verifyNoInteractions(store, client);
+ }
+
+ @Test void failedProjectionIsNackedWithoutAckOrFalseAppliedReport() throws Exception {
+ when(store.applySnapshot(any())).thenThrow(new IllegalStateException("database unavailable"));
+ consumer.onMessage(message(json), channel, 7L);
+ verify(channel).basicNack(7L, false, false); verify(channel, never()).basicAck(anyLong(), anyBoolean());
+ verifyNoInteractions(client);
+ }
+
+ @Test void reportFailureKeepsTheCommittedOutcomeAndSchedulesOnlyReportRetry() throws Exception {
+ when(store.applySnapshot(any())).thenReturn(new AnswerProjectionStore.Disposition(
+ AnswerProjectionStore.Outcome.APPLIED, "evt", FcAnswerInbox.STATE_APPLIED, 8L, "hash", 42, null));
+ FcAnswerInbox row = new FcAnswerInbox(); row.setState(FcAnswerInbox.STATE_APPLIED);
+ when(rows.findByEventIdAndConsumerAppKey("evt", "100018")).thenReturn(Optional.of(row));
+ consumer.onMessage(message(json), channel, 8L);
+ InOrder order = inOrder(store, channel, client);
+ order.verify(store).applySnapshot(any()); order.verify(channel).basicAck(8L, false);
+ order.verify(client).reportApplied(any());
+ assertThat(row.getState()).isEqualTo(FcAnswerInbox.STATE_APPLIED);
+ assertThat(row.getReportState()).isEqualTo(FcAnswerInbox.REPORT_FAILED);
+ assertThat(row.getReportAttempts()).isEqualTo(1);
+ assertThat(row.getNextReportAt()).isAfter(LocalDateTime.now());
+ verify(channel, never()).basicNack(anyLong(), anyBoolean(), anyBoolean());
+ }
+
+ private Message message(String body) {
+ MessageProperties props = new MessageProperties(); props.setContentType(MessageProperties.CONTENT_TYPE_JSON);
+ return new Message(body.getBytes(StandardCharsets.UTF_8), props);
+ }
+}
diff --git a/src/test/java/com/yunhai/svchall/service/impl/ShMqExplicitEnableTest.java b/src/test/java/com/yunhai/svchall/service/impl/ShMqExplicitEnableTest.java
new file mode 100644
index 0000000..6227a51
--- /dev/null
+++ b/src/test/java/com/yunhai/svchall/service/impl/ShMqExplicitEnableTest.java
@@ -0,0 +1,59 @@
+package com.yunhai.svchall.service.impl;
+
+import com.yunhai.svchall.config.FcAnswerMqConfiguration;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.expression.spel.standard.SpelExpressionParser;
+import org.springframework.mock.env.MockEnvironment;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** B-04 默认关闭的 Spring 条件装配回归,不启动真实 broker。 */
+class ShMqExplicitEnableTest {
+ private final ApplicationContextRunner context = new ApplicationContextRunner()
+ .withUserConfiguration(FcAnswerMqConfiguration.class, FcAnswerConsumer.class);
+
+ @Test
+ void platformHostInjectionAloneDoesNotCreateTopologyOrConsumer() {
+ context.withPropertyValues("spring.rabbitmq.host=platform-broker").run(ctx -> {
+ assertThat(ctx).hasNotFailed();
+ assertThat(ctx).doesNotHaveBean(FcAnswerConsumer.class);
+ assertThat(ctx).doesNotHaveBean(FcAnswerMqConfiguration.class);
+ assertThat(ctx).doesNotHaveBean("fcAnswerContainerFactory");
+ });
+ }
+
+ @Test
+ void explicitFalseStillWinsWithPlatformHost() {
+ context.withPropertyValues("spring.rabbitmq.host=platform-broker", "svchall.fc.mq-enabled=false")
+ .run(ctx -> assertThat(ctx).hasNotFailed().doesNotHaveBean(FcAnswerConsumer.class)
+ .doesNotHaveBean(FcAnswerMqConfiguration.class));
+ }
+
+ @Test
+ void enablingWithoutBrokerDoesNotCreateConsumerOrFactory() {
+ context.withPropertyValues("svchall.fc.mq-enabled=true", "spring.rabbitmq.host=")
+ .run(ctx -> assertThat(ctx).hasNotFailed().doesNotHaveBean(FcAnswerConsumer.class)
+ .doesNotHaveBean("fcAnswerContainerFactory"));
+ }
+
+ @Test
+ void bothProductionConditionsRequireTheSameExplicitTrueAndHost() {
+ String topology = FcAnswerMqConfiguration.class.getAnnotation(ConditionalOnExpression.class).value();
+ String consumer = FcAnswerConsumer.class.getAnnotation(ConditionalOnExpression.class).value();
+ assertThat(consumer).isEqualTo(topology).contains("svchall.fc.mq-enabled:false");
+ MockEnvironment env = new MockEnvironment().withProperty("svchall.fc.mq-enabled", "true")
+ .withProperty("spring.rabbitmq.host", "broker");
+ assertThat(new SpelExpressionParser().parseExpression(env.resolvePlaceholders(topology))
+ .getValue(Boolean.class)).isTrue();
+ }
+
+ @Test
+ void bindingRemainsSnapshotPrefixForOwnConsumerIdentity() {
+ FcAnswerMqConfiguration config = new FcAnswerMqConfiguration();
+ ReflectionTestUtils.setField(config, "consumerAppKey", "100018");
+ assertThat(config.fcSnapshotBinding().getRoutingKey()).isEqualTo("fc.snapshot.v2.100018.*");
+ }
+}
diff --git a/src/test/java/com/yunhai/svchall/service/impl/ShMqRoutingIdentityTest.java b/src/test/java/com/yunhai/svchall/service/impl/ShMqRoutingIdentityTest.java
new file mode 100644
index 0000000..cac7e11
--- /dev/null
+++ b/src/test/java/com/yunhai/svchall/service/impl/ShMqRoutingIdentityTest.java
@@ -0,0 +1,91 @@
+package com.yunhai.svchall.service.impl;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.yunhai.platform.openapi.base.OpenApiAppClient;
+import com.yunhai.svchall.config.FcAnswerMqConfiguration;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.env.YamlPropertySourceLoader;
+import org.springframework.core.env.MapPropertySource;
+import org.springframework.core.env.StandardEnvironment;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.Collections;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+/** Binding object and registry/config contract checks; actual broker routing is NOT RUN. */
+class ShMqRoutingIdentityTest {
+ private final ObjectMapper mapper = new ObjectMapper();
+
+ @Test void yamlUsesTheFinalSigningPropertyInsteadOfTheStaleEnvironmentFallback() throws Exception {
+ StandardEnvironment env = new StandardEnvironment();
+ new YamlPropertySourceLoader().load("application", new ClassPathResource("application.yml"))
+ .forEach(env.getPropertySources()::addLast);
+ env.getPropertySources().addFirst(new MapPropertySource("final-platform-key",
+ Collections.singletonMap("yh.app-auth.app-key", "100008")));
+ assertThat(env.getProperty("svchall.fc.consumer-app-key")).isEqualTo("100008");
+ assertThat(env.getProperty("svchall.fc.mq-enabled")).isEqualTo("false");
+ }
+
+ @Test void matchingCredentialBuildsTheCanonical100018BindingWithoutUsingThePrimaryId() {
+ FcAnswerMqConfiguration config = config("100018", "100018", registered(700018, 100018, "svc-hall"));
+ assertThatCode(config::validateIdentity).doesNotThrowAnyException();
+ assertThat(config.fcSnapshotBinding().getRoutingKey()).isEqualTo("fc.snapshot.v2.100018.*");
+ assertThat(config.fcSnapshotBinding().getExchange()).isEqualTo("fc.answer.delivery.v2");
+ }
+
+ @Test void stale100018CannotSurviveA100008FinalSigningConfiguration() {
+ FcAnswerMqConfiguration config = config("100018", "100008", registered(700008, 100008, "svc-hall"));
+ assertThatThrownBy(config::validateIdentity).hasMessageContaining("CONSUMER_CREDENTIAL_MISMATCH");
+ }
+
+ @Test void configuredCredentialDeterminesBindingWithoutHardcoding100008() {
+ FcAnswerMqConfiguration config = config("100008", "100008", registered(700008, 100008, "svc-hall"));
+ config.validateIdentity();
+ assertThat(config.fcSnapshotBinding().getRoutingKey()).isEqualTo("fc.snapshot.v2.100008.*")
+ .isNotEqualTo("fc.snapshot.v2.100018.*");
+ }
+
+ @Test void aCredentialRegisteredToAnotherBusinessApplicationIsRejected() {
+ assertThatThrownBy(config("100018", "100018", registered(700025, 100025, "form-center"))::validateIdentity)
+ .hasMessageContaining("CONSUMER_APPLICATION_MISMATCH");
+ }
+
+ @Test void businessPrimaryIdIsNeverComparedEqualToOrSubstitutedForTheCredential() {
+ assertThatThrownBy(config("700018", "700018", registered(700018, 100018, "svc-hall"))::validateIdentity)
+ .hasMessageContaining("CONSUMER_APPLICATION_MISMATCH");
+ }
+
+ @Test void emptyRegistryOrMissingPrimaryIdFailsClosedOnlyWhenMqIsExplicitlyEnabled() {
+ assertThatThrownBy(config("100018", "100018", null)::validateIdentity)
+ .hasMessageContaining("CONSUMER_APPLICATION_MISMATCH");
+ ObjectNode incomplete = registered(700018, 100018, "svc-hall"); incomplete.remove("id");
+ assertThatThrownBy(config("100018", "100018", incomplete)::validateIdentity)
+ .hasMessageContaining("CONSUMER_APPLICATION_MISMATCH");
+ }
+
+ @Test void routeWildcardsOrExtraWordsCannotExpandTheConsumersBinding() {
+ for (String key : new String[]{"", "100018.*", "100018.#", "100018.27"}) {
+ FcAnswerMqConfiguration config = config(key, key, null);
+ assertThatThrownBy(config::fcSnapshotBinding).hasMessageContaining("INVALID_CONSUMER_ROUTE_KEY");
+ }
+ }
+
+ private ObjectNode registered(int id, int credential, String code) {
+ return mapper.createObjectNode().put("id", id).put("appKey", credential).put("appCode", code);
+ }
+
+ private FcAnswerMqConfiguration config(String consumer, String signing, ObjectNode application) {
+ FcAnswerMqConfiguration config = new FcAnswerMqConfiguration();
+ ReflectionTestUtils.setField(config, "consumerAppKey", consumer);
+ ReflectionTestUtils.setField(config, "signingAppKey", signing);
+ ReflectionTestUtils.setField(config, "applicationCode", "svc-hall");
+ OpenApiAppClient registry = mock(OpenApiAppClient.class);
+ when(registry.getAppByCode("svc-hall")).thenReturn(application);
+ ReflectionTestUtils.setField(config, "appRegistryClient", registry);
+ return config;
+ }
+}