feat(notifications): Phase A foundation — module + schema 0021
This commit is contained in:
+58
@@ -0,0 +1,58 @@
|
||||
package cloud.nstart.terravault.ordinis.notifications.channel;
|
||||
|
||||
/**
|
||||
* Канал доставки уведомления.
|
||||
*
|
||||
* <p><b>Wire format — lowercase</b> ('email' / 'express' / 'telegram'). Такой
|
||||
* формат используется в:
|
||||
* <ul>
|
||||
* <li>DB columns: {@code notification_log.channel}, {@code user_notification_prefs.channel}
|
||||
* (см. CHECK constraint {@code notification_log_channel_check} в migration 0021)</li>
|
||||
* <li>API JSON: {@code GET /api/v1/me/notifications}</li>
|
||||
* <li>Helm config keys: {@code notifications.express.*}</li>
|
||||
* <li>Metric labels: {@code nsi_notification_sent_total{channel="email"}}</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>NB:</b> {@code channel} column НЕ использует {@code @Enumerated(EnumType.STRING)} —
|
||||
* mapping ручной через {@link #wireName()}, чтобы DB хранила lowercase wire-format.
|
||||
* Это отличается от {@code notification_log.status}, который UPPERCASE
|
||||
* (carry-forward lesson из v1.1.0 P3 — Hibernate {@code @Enumerated(EnumType.STRING)}
|
||||
* пишет UPPERCASE, поэтому status CHECK в 0021 тоже UPPERCASE).
|
||||
*
|
||||
* <p>Persist: всегда {@link #wireName()} (lowercase). Read из DB: {@link #fromWireName(String)}.
|
||||
* Никогда не используй {@link Enum#name()} для DB/API — это вернёт UPPERCASE и
|
||||
* порушит CHECK constraint на insert.
|
||||
*/
|
||||
public enum ChannelKind {
|
||||
EMAIL("email"),
|
||||
EXPRESS("express"),
|
||||
TELEGRAM("telegram");
|
||||
|
||||
private final String wireName;
|
||||
|
||||
ChannelKind(String wireName) {
|
||||
this.wireName = wireName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lower-case wire-format. Используется wherever DB / API / config / metrics
|
||||
* касаются channel identity. См. class-level Javadoc для полного списка
|
||||
* usage points.
|
||||
*/
|
||||
public String wireName() {
|
||||
return wireName;
|
||||
}
|
||||
|
||||
/** Парсинг из wire-format. Throws IllegalArgumentException на unknown value. */
|
||||
public static ChannelKind fromWireName(String wire) {
|
||||
if (wire == null) {
|
||||
throw new IllegalArgumentException("channel wire name is null");
|
||||
}
|
||||
for (ChannelKind k : values()) {
|
||||
if (k.wireName.equals(wire)) {
|
||||
return k;
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("unknown channel: " + wire);
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
package cloud.nstart.terravault.ordinis.notifications.channel;
|
||||
|
||||
import cloud.nstart.terravault.ordinis.notifications.config.NotificationsProperties;
|
||||
import cloud.nstart.terravault.ordinis.notifications.event.NotificationEvent;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.micrometer.core.instrument.Timer;
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.mail.MailException;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* SMTP-based email channel. Phase A delivery target.
|
||||
*
|
||||
* <p>Multipart text+HTML send (RFC 2387) — plain text fallback для accessibility,
|
||||
* HTML version с link styling. Per design § 8.6.
|
||||
*
|
||||
* <p>Sanitization happens upstream в {@code MessageRenderer.render()} —
|
||||
* {@code Sanitizer.forEmailHeader()} применяется ко всем dynamic params в subject.
|
||||
* Body тоже sanitized (CRLF strip preserves layout).
|
||||
*
|
||||
* <p>Per design § 5 EmailChannel:
|
||||
* <ul>
|
||||
* <li>SMTP timeouts через {@code spring.mail.properties.mail.smtp.timeout=10000}.</li>
|
||||
* <li>Hang >10s → {@code MailSendException} → channel returns failed.</li>
|
||||
* <li>FAILED row не освобождает idempotency lock (conscious choice — failed =
|
||||
* "tried, gave up", не долбим SMTP бесконечно).</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "ordinis.notifications.enabled", havingValue = "true", matchIfMissing = false)
|
||||
// Без JavaMailSender bean (т.е. spring.mail.host не задан) ChannelKind.EMAIL
|
||||
// не активируется. Это позволяет включить notifications.enabled=true для
|
||||
// Express-only deploy без обязательной SMTP-конфигурации, и не вешает Spring
|
||||
// boot на UnsatisfiedDependencyException когда mail starter в classpath, но
|
||||
// инфра почты не подведена.
|
||||
@ConditionalOnBean(JavaMailSender.class)
|
||||
public class EmailChannel implements NotificationChannel {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(EmailChannel.class);
|
||||
|
||||
private final JavaMailSender mailSender;
|
||||
private final NotificationsProperties props;
|
||||
private final Timer dispatchTimer;
|
||||
|
||||
public EmailChannel(
|
||||
JavaMailSender mailSender,
|
||||
NotificationsProperties props,
|
||||
MeterRegistry meterRegistry) {
|
||||
this.mailSender = mailSender;
|
||||
this.props = props;
|
||||
this.dispatchTimer = Timer.builder("nsi_notification_dispatch_seconds")
|
||||
.description("Per-channel dispatch latency")
|
||||
.tag("channel", "email")
|
||||
.publishPercentileHistogram()
|
||||
.register(meterRegistry);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChannelKind kind() {
|
||||
return ChannelKind.EMAIL;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEnabled() {
|
||||
String from = props.getEmail().getFromAddress();
|
||||
return from != null && !from.isBlank();
|
||||
}
|
||||
|
||||
@Override
|
||||
public DispatchResult dispatch(NotificationEvent event, UserRef recipient, RenderedMessage msg) {
|
||||
if (recipient.email() == null || recipient.email().isBlank()) {
|
||||
return DispatchResult.failure("recipient has no email address");
|
||||
}
|
||||
long start = System.nanoTime();
|
||||
try {
|
||||
MimeMessage mime = mailSender.createMimeMessage();
|
||||
MimeMessageHelper helper = new MimeMessageHelper(mime, true, StandardCharsets.UTF_8.name());
|
||||
helper.setFrom(props.getEmail().getFromAddress());
|
||||
helper.setTo(recipient.email());
|
||||
helper.setSubject(msg.subject());
|
||||
// For Phase A: plain text body. HTML multipart можно добавить позже
|
||||
// через помощник setText(text, html) в MimeMessageHelper.
|
||||
helper.setText(msg.body(), false);
|
||||
|
||||
mailSender.send(mime);
|
||||
return DispatchResult.success();
|
||||
} catch (MailException e) {
|
||||
log.warn("Email send failed для {}: {}", recipient.email(), e.getMessage());
|
||||
return DispatchResult.failure("MailException: " + e.getMessage());
|
||||
} catch (MessagingException e) {
|
||||
log.warn("Email build failed для {}: {}", recipient.email(), e.getMessage());
|
||||
return DispatchResult.failure("MessagingException: " + e.getMessage());
|
||||
} finally {
|
||||
dispatchTimer.record(Duration.ofNanos(System.nanoTime() - start));
|
||||
}
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package cloud.nstart.terravault.ordinis.notifications.channel;
|
||||
|
||||
import cloud.nstart.terravault.ordinis.notifications.event.NotificationEvent;
|
||||
|
||||
/**
|
||||
* Channel adapter contract. Реализации:
|
||||
* <ul>
|
||||
* <li>{@code EmailChannel} — JavaMailSender + multipart text+HTML.</li>
|
||||
* <li>{@code ExpressChannel} — BotX bot API (Phase B).</li>
|
||||
* <li>{@code TelegramChannel} — Bot API (Phase C).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Caller responsibility (NotificationDispatcher):
|
||||
* <ol>
|
||||
* <li>IdempotencyGuard claim — INSERT ON CONFLICT DO NOTHING.</li>
|
||||
* <li>Pref check — skip если disabled.</li>
|
||||
* <li>Rate limit check — skip если exceeded.</li>
|
||||
* <li>Render через MessageRenderer (sanitization built-in).</li>
|
||||
* <li>Call {@link #dispatch(NotificationEvent, UserRef, RenderedMessage)}.</li>
|
||||
* <li>Update notification_log status based on result.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>Channel responsibility:
|
||||
* <ul>
|
||||
* <li>Transport-level send (SMTP / HTTP).</li>
|
||||
* <li>Channel-specific timeout enforcement.</li>
|
||||
* <li>Return DispatchResult с error message если fail.</li>
|
||||
* <li>NOT retry — outbox poller / replay handles retry semantics.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public interface NotificationChannel {
|
||||
|
||||
ChannelKind kind();
|
||||
|
||||
/** True если channel конфигурирован (creds present, etc). False → channel skip'ится. */
|
||||
boolean isEnabled();
|
||||
|
||||
/**
|
||||
* Send the rendered message к recipient.
|
||||
*
|
||||
* @param event original event для context (event_id для tracing, etc)
|
||||
* @param recipient resolved user ref (channel-specific identifier через
|
||||
* {@code recipient.recipientFor(kind())})
|
||||
* @param msg pre-rendered, pre-sanitized message
|
||||
* @return success/failure with optional error message
|
||||
*/
|
||||
DispatchResult dispatch(NotificationEvent event, UserRef recipient, RenderedMessage msg);
|
||||
|
||||
record DispatchResult(boolean ok, String errorMsg) {
|
||||
public static DispatchResult success() {
|
||||
return new DispatchResult(true, null);
|
||||
}
|
||||
|
||||
public static DispatchResult failure(String errorMsg) {
|
||||
return new DispatchResult(false, errorMsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package cloud.nstart.terravault.ordinis.notifications.channel;
|
||||
|
||||
/**
|
||||
* Pre-rendered message ready для dispatch. Result of
|
||||
* {@code MessageRenderer.render(eventKey, channel, locale, params)}.
|
||||
*
|
||||
* <p>{@code subject} — header / first line (email Subject, Express bold headline).
|
||||
* Не используется для Telegram (header inline в body).
|
||||
*
|
||||
* <p>{@code body} — main content. Channel-specific format (plain text для email,
|
||||
* BotX markdown для Express, MarkdownV2 для Telegram).
|
||||
*
|
||||
* <p>Обе строки уже прошли через {@code Sanitizer.forXxx()} per channel — caller
|
||||
* НЕ должен снова escape'ить.
|
||||
*/
|
||||
public record RenderedMessage(String subject, String body) {}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package cloud.nstart.terravault.ordinis.notifications.channel;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Канал-агностичная ссылка на recipient'а. Все поля nullable — null = recipient
|
||||
* не имеет identifier'а для соответствующего канала → channel skip'ит с
|
||||
* {@code SKIPPED_PREF} status.
|
||||
*
|
||||
* <p>Resolved через {@code RecipientResolver} (Keycloak admin API + cached 5 min).
|
||||
*
|
||||
* @param userId Keycloak subject (always present)
|
||||
* @param email email address (null если у юзера нет email атрибута)
|
||||
* @param expressHuid Express HUID UUID string (null если не configured)
|
||||
* @param telegramChatId Telegram chat_id numeric string (null если не configured)
|
||||
* @param locale предпочитаемая локаль (RU fallback если Keycloak attribute отсутствует)
|
||||
*/
|
||||
public record UserRef(
|
||||
String userId,
|
||||
String email,
|
||||
String expressHuid,
|
||||
String telegramChatId,
|
||||
Locale locale
|
||||
) {
|
||||
|
||||
/** Shortcut для recipient string per channel — используется как {@code notification_log.recipient}. */
|
||||
public String recipientFor(ChannelKind channel) {
|
||||
return switch (channel) {
|
||||
case EMAIL -> email;
|
||||
case EXPRESS -> expressHuid;
|
||||
case TELEGRAM -> telegramChatId;
|
||||
};
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package cloud.nstart.terravault.ordinis.notifications.config;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.cache.annotation.EnableCaching;
|
||||
import org.springframework.context.MessageSource;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.ComponentScan;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.support.ReloadableResourceBundleMessageSource;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* Auto-configuration для notifications module.
|
||||
*
|
||||
* <p>Активна только при {@code ordinis.notifications.enabled=true}. Без этого
|
||||
* флага consumer не запускается, channels не register'ятся, sweep/retention
|
||||
* jobs не start'ят. Outbox events накапливаются в Kafka topic, на enable
|
||||
* консьюмер начнёт с {@code auto-offset-reset: latest} (backlog burst guard).
|
||||
*
|
||||
* <p>Module включается в writer pod через {@code @ComponentScan} + Helm value
|
||||
* {@code writer.notificationsEnabled}. Read-api / projection-writer — disabled.
|
||||
*/
|
||||
@Configuration
|
||||
@ConditionalOnProperty(name = "ordinis.notifications.enabled", havingValue = "true", matchIfMissing = false)
|
||||
@EnableConfigurationProperties(NotificationsProperties.class)
|
||||
@EnableScheduling
|
||||
@EnableCaching
|
||||
@ComponentScan(basePackages = "cloud.nstart.terravault.ordinis.notifications")
|
||||
public class NotificationsAutoConfiguration {
|
||||
|
||||
/**
|
||||
* MessageSource для notifications templates. Отдельный bean (не shared с
|
||||
* application-wide MessageSource) — изолирует наши bundles от других modules.
|
||||
* Filename {@code messages/notifications} → resolves к
|
||||
* {@code messages/notifications_ru.properties} / {@code _en.properties}.
|
||||
*/
|
||||
@Bean
|
||||
public MessageSource notificationsMessageSource() {
|
||||
ReloadableResourceBundleMessageSource ms = new ReloadableResourceBundleMessageSource();
|
||||
ms.setBasename("classpath:messages/notifications");
|
||||
ms.setDefaultEncoding(StandardCharsets.UTF_8.name());
|
||||
ms.setFallbackToSystemLocale(false);
|
||||
ms.setUseCodeAsDefaultMessage(false); // throw NoSuchMessageException — fail loud
|
||||
return ms;
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package cloud.nstart.terravault.ordinis.notifications.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* Per-feature config (D9 — никаких magic numbers, всё через
|
||||
* {@code @ConfigurationProperties}).
|
||||
*
|
||||
* <p>Bound из Spring config (application.yml + Helm values), prefix
|
||||
* {@code ordinis.notifications}. Module enables через
|
||||
* {@link #enabled} = true (см. {@code NotificationsAutoConfiguration}
|
||||
* @ConditionalOnProperty).
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "ordinis.notifications")
|
||||
public class NotificationsProperties {
|
||||
|
||||
/** Главный feature flag. False — module disabled, dispatcher не register'ится. */
|
||||
private boolean enabled = false;
|
||||
|
||||
private final Dispatcher dispatcher = new Dispatcher();
|
||||
private final RateLimit rateLimit = new RateLimit();
|
||||
private final Retention retention = new Retention();
|
||||
private final PendingSweep pendingSweep = new PendingSweep();
|
||||
private final Email email = new Email();
|
||||
private final Express express = new Express();
|
||||
private final Telegram telegram = new Telegram();
|
||||
|
||||
public boolean isEnabled() { return enabled; }
|
||||
public void setEnabled(boolean enabled) { this.enabled = enabled; }
|
||||
public Dispatcher getDispatcher() { return dispatcher; }
|
||||
public RateLimit getRateLimit() { return rateLimit; }
|
||||
public Retention getRetention() { return retention; }
|
||||
public PendingSweep getPendingSweep() { return pendingSweep; }
|
||||
public Email getEmail() { return email; }
|
||||
public Express getExpress() { return express; }
|
||||
public Telegram getTelegram() { return telegram; }
|
||||
|
||||
public static class Dispatcher {
|
||||
private int concurrency = 2;
|
||||
private int executorPoolSize = 20;
|
||||
private int maxPollRecords = 20;
|
||||
private int maxEventAgeHours = 1;
|
||||
private long sendTimeoutMs = 10_000L;
|
||||
|
||||
public int getConcurrency() { return concurrency; }
|
||||
public void setConcurrency(int v) { this.concurrency = v; }
|
||||
public int getExecutorPoolSize() { return executorPoolSize; }
|
||||
public void setExecutorPoolSize(int v) { this.executorPoolSize = v; }
|
||||
public int getMaxPollRecords() { return maxPollRecords; }
|
||||
public void setMaxPollRecords(int v) { this.maxPollRecords = v; }
|
||||
public int getMaxEventAgeHours() { return maxEventAgeHours; }
|
||||
public void setMaxEventAgeHours(int v) { this.maxEventAgeHours = v; }
|
||||
public long getSendTimeoutMs() { return sendTimeoutMs; }
|
||||
public void setSendTimeoutMs(long v) { this.sendTimeoutMs = v; }
|
||||
}
|
||||
|
||||
public static class RateLimit {
|
||||
private int perHour = 10;
|
||||
|
||||
public int getPerHour() { return perHour; }
|
||||
public void setPerHour(int v) { this.perHour = v; }
|
||||
}
|
||||
|
||||
public static class Retention {
|
||||
private int days = 90;
|
||||
private String cron = "0 0 3 * * *"; // 03:00 UTC daily
|
||||
|
||||
public int getDays() { return days; }
|
||||
public void setDays(int v) { this.days = v; }
|
||||
public String getCron() { return cron; }
|
||||
public void setCron(String v) { this.cron = v; }
|
||||
}
|
||||
|
||||
public static class PendingSweep {
|
||||
private long olderThanMs = 5 * 60 * 1000L; // 5 min
|
||||
private long runEveryMs = 5 * 60 * 1000L;
|
||||
|
||||
public long getOlderThanMs() { return olderThanMs; }
|
||||
public void setOlderThanMs(long v) { this.olderThanMs = v; }
|
||||
public long getRunEveryMs() { return runEveryMs; }
|
||||
public void setRunEveryMs(long v) { this.runEveryMs = v; }
|
||||
}
|
||||
|
||||
public static class Email {
|
||||
/** Used as From header. SMTP server config — через standard spring.mail.* properties. */
|
||||
private String fromAddress;
|
||||
/** Base URL для deep links в email body / unsub links. */
|
||||
private String baseUrl;
|
||||
|
||||
public String getFromAddress() { return fromAddress; }
|
||||
public void setFromAddress(String v) { this.fromAddress = v; }
|
||||
public String getBaseUrl() { return baseUrl; }
|
||||
public void setBaseUrl(String v) { this.baseUrl = v; }
|
||||
}
|
||||
|
||||
public static class Express {
|
||||
/** Corporate Transport Server URL — per-corp on-prem. */
|
||||
private String cts;
|
||||
/** BotX bot UUID — Vault. */
|
||||
private String botId;
|
||||
/** BotX bot HMAC base — Vault. */
|
||||
private String botSecret;
|
||||
/** Group chat UUID per env — Vault или Helm. */
|
||||
private String chatId;
|
||||
|
||||
public String getCts() { return cts; }
|
||||
public void setCts(String v) { this.cts = v; }
|
||||
public String getBotId() { return botId; }
|
||||
public void setBotId(String v) { this.botId = v; }
|
||||
public String getBotSecret() { return botSecret; }
|
||||
public void setBotSecret(String v) { this.botSecret = v; }
|
||||
public String getChatId() { return chatId; }
|
||||
public void setChatId(String v) { this.chatId = v; }
|
||||
}
|
||||
|
||||
public static class Telegram {
|
||||
/** Bot token — Vault. */
|
||||
private String botToken;
|
||||
private String chatId;
|
||||
|
||||
public String getBotToken() { return botToken; }
|
||||
public void setBotToken(String v) { this.botToken = v; }
|
||||
public String getChatId() { return chatId; }
|
||||
public void setChatId(String v) { this.chatId = v; }
|
||||
}
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
package cloud.nstart.terravault.ordinis.notifications.dispatcher;
|
||||
|
||||
import cloud.nstart.terravault.ordinis.domain.notification.NotificationLog;
|
||||
import cloud.nstart.terravault.ordinis.domain.notification.NotificationLogRepository;
|
||||
import cloud.nstart.terravault.ordinis.notifications.channel.ChannelKind;
|
||||
import cloud.nstart.terravault.ordinis.notifications.event.NotificationEvent;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Закрывает CRITICAL gap (idempotency) из v3 review.
|
||||
*
|
||||
* <p>Использует UNIQUE {@code (event_id, channel, recipient)} на
|
||||
* {@code notification_log} (см. migration 0021) + PostgreSQL
|
||||
* {@code INSERT ... ON CONFLICT DO NOTHING RETURNING} pattern.
|
||||
*
|
||||
* <p>Concurrent semantics:
|
||||
* <ul>
|
||||
* <li>Two consumer threads parallel'но обрабатывают same event → один claim'ит row,
|
||||
* другой получает empty Optional → skip.</li>
|
||||
* <li>Consumer crash после email_OK + before slack → replay → idempotency guard
|
||||
* видит email row (status=SENT) → skip; express row отсутствует → claim + send.</li>
|
||||
* <li>Pod crash mid-INSERT-pending → row застряла PENDING → {@code PendingSweepJob}
|
||||
* через 5 min DELETE → next consumer poll → re-claim + send.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>{@code @Transactional(REQUIRES_NEW)} — claim isolated от outer dispatcher transaction
|
||||
* чтобы commit'ить claim immediately, before actual send. Send fail'ит → UPDATE status='FAILED'
|
||||
* via {@code markFailed()} в отдельной tx.
|
||||
*/
|
||||
// Gate must mirror NotificationsAutoConfiguration. Writer's @SpringBootApplication
|
||||
// scanBasePackages = "cloud.nstart.terravault.ordinis" — superset, picks up
|
||||
// этот @Component даже когда notifications module dormant. Без явного
|
||||
// ConditionalOnProperty bean регается до того, как notification_log table
|
||||
// создан → при первом invocation NPE / table-not-found.
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "ordinis.notifications.enabled", havingValue = "true", matchIfMissing = false)
|
||||
public class IdempotencyGuard {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(IdempotencyGuard.class);
|
||||
|
||||
private final NotificationLogRepository repo;
|
||||
|
||||
public IdempotencyGuard(NotificationLogRepository repo) {
|
||||
this.repo = repo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to claim the (event_id, channel, recipient) slot. Returns the claimed
|
||||
* row if INSERT succeeded, empty if already claimed (другой consumer / replay).
|
||||
*
|
||||
* @param event source event для idempotency key + audit context
|
||||
* @param channel target channel
|
||||
* @param recipient target recipient identifier (email / huid / chat_id)
|
||||
* @return populated NotificationLog if claimed (PENDING status), empty if conflict
|
||||
*/
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public Optional<NotificationLog> tryClaim(
|
||||
NotificationEvent event, ChannelKind channel, String recipient) {
|
||||
|
||||
Optional<Long> claimedId = repo.tryClaim(
|
||||
event.eventId(),
|
||||
event.eventType(),
|
||||
event.draft() != null ? event.draft().id() : null,
|
||||
channel.wireName(),
|
||||
recipient
|
||||
);
|
||||
|
||||
if (claimedId.isEmpty()) {
|
||||
log.debug("Idempotency conflict — already claimed: event={}, channel={}, recipient={}",
|
||||
event.eventId(), channel, recipient);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
return repo.findById(claimedId.get());
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark claimed row as SENT. Called после successful channel.dispatch().
|
||||
* @Transactional(REQUIRES_NEW) для commit'а independent от dispatcher loop.
|
||||
*/
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void markSent(Long rowId) {
|
||||
repo.findById(rowId).ifPresent(row -> {
|
||||
row.markSent();
|
||||
repo.save(row);
|
||||
});
|
||||
}
|
||||
|
||||
/** Mark claimed row as FAILED with error message. */
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void markFailed(Long rowId, String errorMsg) {
|
||||
repo.findById(rowId).ifPresent(row -> {
|
||||
row.markFailed(errorMsg);
|
||||
repo.save(row);
|
||||
});
|
||||
}
|
||||
|
||||
/** Mark claimed row as RATE_LIMITED — recipient hit per-hour cap. */
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void markRateLimited(Long rowId) {
|
||||
repo.findById(rowId).ifPresent(row -> {
|
||||
row.markRateLimited();
|
||||
repo.save(row);
|
||||
});
|
||||
}
|
||||
|
||||
/** Mark claimed row as SKIPPED_PREF — user opted out. */
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void markSkippedPref(Long rowId) {
|
||||
repo.findById(rowId).ifPresent(row -> {
|
||||
row.markSkippedPref();
|
||||
repo.save(row);
|
||||
});
|
||||
}
|
||||
|
||||
/** Mark claimed row as SKIPPED_TOO_OLD — backlog burst guard fired. */
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void markSkippedTooOld(Long rowId) {
|
||||
repo.findById(rowId).ifPresent(row -> {
|
||||
row.markSkippedTooOld();
|
||||
repo.save(row);
|
||||
});
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package cloud.nstart.terravault.ordinis.notifications.event;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Canonical notification event published в Kafka topic {@code ordinis.notifications.v1}
|
||||
* через existing {@code OutboxRecorder}. Consumer ({@code NotificationDispatcher})
|
||||
* deserialize'ит и fan-out'ит per recipient × channel.
|
||||
*
|
||||
* <p>Three event types:
|
||||
* <ul>
|
||||
* <li>{@code draft.submitted} — DraftService.submit() success → notify reviewer pool.</li>
|
||||
* <li>{@code draft.decision} — approve/reject success → notify maker.</li>
|
||||
* <li>{@code draft.withdrawn} — withdraw success → notify reviewer pool (clear queue).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Schema versioned via {@code v1} suffix в topic name. Future schema changes —
|
||||
* new topic {@code v2}, dual-publish during migration.
|
||||
*
|
||||
* @param eventId UUID of the originating outbox event (idempotency key)
|
||||
* @param eventType wire string ('draft.submitted' / 'draft.decision' / 'draft.withdrawn')
|
||||
* @param occurredAt timestamp
|
||||
* @param draft draft context (always present для draft.* events)
|
||||
* @param decision present только для 'draft.decision' (APPROVE/REJECT + decidedBy + comment)
|
||||
* @param context routing hints — approvalMinRole, dictDisplayName ru/en
|
||||
*/
|
||||
public record NotificationEvent(
|
||||
UUID eventId,
|
||||
String eventType,
|
||||
OffsetDateTime occurredAt,
|
||||
DraftRef draft,
|
||||
Decision decision,
|
||||
Map<String, Object> context
|
||||
) {
|
||||
|
||||
public static final String TYPE_SUBMITTED = "draft.submitted";
|
||||
public static final String TYPE_DECISION = "draft.decision";
|
||||
public static final String TYPE_WITHDRAWN = "draft.withdrawn";
|
||||
|
||||
public record DraftRef(
|
||||
UUID id,
|
||||
String businessKey,
|
||||
String dictionary,
|
||||
String operation,
|
||||
String maker
|
||||
) {}
|
||||
|
||||
public record Decision(
|
||||
String outcome, // 'APPROVE' | 'REJECT'
|
||||
String decidedBy,
|
||||
String comment
|
||||
) {}
|
||||
}
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
package cloud.nstart.terravault.ordinis.notifications.sanitizer;
|
||||
|
||||
/**
|
||||
* Sanitization adapter для user-controlled полей перед template render.
|
||||
*
|
||||
* <p><b>Purpose — defence-in-depth:</b> {@code business_key}, {@code maker},
|
||||
* {@code reviewer}, {@code comment} в notification templates приходят
|
||||
* непосредственно от пользователей. Без sanitization получаем CVE-class
|
||||
* injection vulnerabilities per channel:
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>Email subject CRLF injection</b> (RFC 5322): {@code business_key
|
||||
* = "Союз\nBcc: attacker@evil.com"} → JavaMail {@code setSubject(String)}
|
||||
* не strip'ает CRLF → headers spoof'ятся → unintended recipients
|
||||
* получают draft content. Это primary CRITICAL gap из eng review v3.</li>
|
||||
* <li><b>Express BotX markdown spoof</b>: business_key с {@code *} или
|
||||
* {@code _} ломает rendering или открывает structure manipulation.</li>
|
||||
* <li><b>Telegram MarkdownV2 spec violations</b>: per Bot API spec
|
||||
* reserved chars должны быть escaped или Telegram rejects message
|
||||
* (или, хуже, рендерит unintended formatting).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Все методы — pure static, no Spring DI, fully unit-testable. Trade-off:
|
||||
* {@code MAX_LEN} cap в 200 chars применяется ко всем channels uniformly
|
||||
* (email subject + chat message длинные ключи всё равно truncate'ятся в
|
||||
* клиентах, поэтому 200 chars — sane upper bound).
|
||||
*
|
||||
* <p>Sanitization вызывается дважды (defence-in-depth):
|
||||
* <ol>
|
||||
* <li>В {@code MessageRenderer} перед rendering template — params уже
|
||||
* safe для конкретного channel kind.</li>
|
||||
* <li>В каждом {@code NotificationChannel.dispatch()} перед фактическим
|
||||
* send — на случай если где-то забыли первый шаг.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>Comprehensive test coverage в {@code SanitizerTest} — особенно
|
||||
* {@code emailHeaderInjectionRejected*} cases. Эти тесты — CI-gating; если
|
||||
* сломаются, build падает и CRITICAL gap re-opens.
|
||||
*
|
||||
* @see <a href="https://datatracker.ietf.org/doc/html/rfc5322">RFC 5322 §2.2 — header injection vector</a>
|
||||
* @see <a href="https://core.telegram.org/bots/api#markdownv2-style">Telegram Bot API MarkdownV2 reserved chars</a>
|
||||
*/
|
||||
public final class Sanitizer {
|
||||
|
||||
/**
|
||||
* Maximum length применяется ко всем sanitized output strings. 200 chars —
|
||||
* email subject reasonable cap (Outlook truncates ~150-200), Express BotX
|
||||
* body line readable limit, Telegram hard limit 4096 (но за 200 chars
|
||||
* scannable format ломается).
|
||||
*/
|
||||
public static final int MAX_LEN = 200;
|
||||
|
||||
// Prevent instantiation.
|
||||
private Sanitizer() {}
|
||||
|
||||
/**
|
||||
* Email-safe sanitization: strip CRLF (RFC 5322 §2.2 header injection
|
||||
* vector) + cap длину.
|
||||
*
|
||||
* <p>Strips:
|
||||
* <ul>
|
||||
* <li>{@code \r} (CR, U+000D)</li>
|
||||
* <li>{@code \n} (LF, U+000A)</li>
|
||||
* <li>{@code U+0085} (NEL — Next Line, Outlook treats как line break)</li>
|
||||
* <li>{@code U+2028} (Line Separator)</li>
|
||||
* <li>{@code U+2029} (Paragraph Separator)</li>
|
||||
* </ul>
|
||||
* Заменяются на single space.
|
||||
*
|
||||
* <p>Используется для:
|
||||
* <ul>
|
||||
* <li>Email Subject (HIGHEST risk — RFC 5322 header injection).</li>
|
||||
* <li>Email From/To/Reply-To display names (вторичный risk).</li>
|
||||
* <li>Body параметров в email plain text (CRLF в body не injection,
|
||||
* но уродует layout).</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param raw произвольный user-controlled input, может быть null
|
||||
* @return sanitized string, never null (на null → empty string)
|
||||
*/
|
||||
public static String forEmailHeader(String raw) {
|
||||
if (raw == null) {
|
||||
return "";
|
||||
}
|
||||
String stripped = raw.replaceAll("[\\r\\n\\u0085\\u2028\\u2029]", " ");
|
||||
return stripped.length() > MAX_LEN ? stripped.substring(0, MAX_LEN) : stripped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Express BotX-safe sanitization: escape Markdown subset reserved chars
|
||||
* + cap длину.
|
||||
*
|
||||
* <p>Express BotX поддерживает урезанный Markdown:
|
||||
* <ul>
|
||||
* <li>{@code **bold**}</li>
|
||||
* <li>{@code *italic*}</li>
|
||||
* <li>{@code `code`}</li>
|
||||
* <li>{@code ~~strike~~}</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Если business_key содержит {@code *} или {@code _} или {@code `} или
|
||||
* {@code ~} — backslash-escape'аем чтобы:
|
||||
* <ol>
|
||||
* <li>Не сломать rendering (template "*Новый draft*" + business_key
|
||||
* "Союз*М-25" → "*Новый draft*Союз*М-25" → bold-spans поломаются).</li>
|
||||
* <li>Не открыть structure manipulation вектор (хотя BotX rendering
|
||||
* sandbox'нутый, defence-in-depth дёшев).</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>Backslash сам тоже escape'ится — иначе input "test\n" станет литерал
|
||||
* "test\\n" в output (escaping issue).
|
||||
*
|
||||
* @param raw произвольный user-controlled input, может быть null
|
||||
* @return sanitized string, never null
|
||||
*/
|
||||
public static String forExpressText(String raw) {
|
||||
if (raw == null) {
|
||||
return "";
|
||||
}
|
||||
String capped = raw.length() > MAX_LEN ? raw.substring(0, MAX_LEN) + "…" : raw;
|
||||
// Order matters: escape backslash first, иначе двойной escape на
|
||||
// уже-escaped chars. Pattern matches: \ * _ ` ~
|
||||
return capped.replaceAll("([\\\\*_`~])", "\\\\$1");
|
||||
}
|
||||
|
||||
/**
|
||||
* Telegram MarkdownV2-safe sanitization: escape reserved chars per Bot
|
||||
* API spec + cap длину.
|
||||
*
|
||||
* <p>Per <a href="https://core.telegram.org/bots/api#markdownv2-style">
|
||||
* Telegram Bot API MarkdownV2</a>, reserved chars: <pre>
|
||||
* _ * [ ] ( ) ~ ` > # + - = | { } . !
|
||||
* </pre>
|
||||
* Plus backslash itself ({@code \}) escape character.
|
||||
*
|
||||
* <p>Все эти chars в сыром user input должны быть prefix'нуты {@code \\}
|
||||
* иначе Telegram API rejects message (или хуже — рендерит unintended
|
||||
* formatting).
|
||||
*
|
||||
* @param raw произвольный user-controlled input, может быть null
|
||||
* @return sanitized string, never null
|
||||
*/
|
||||
public static String forTelegramMarkdown(String raw) {
|
||||
if (raw == null) {
|
||||
return "";
|
||||
}
|
||||
String capped = raw.length() > MAX_LEN ? raw.substring(0, MAX_LEN) + "…" : raw;
|
||||
// Order matters: backslash first.
|
||||
return capped.replaceAll("([\\\\_*\\[\\]()~`>#+=|{}.!\\-])", "\\\\$1");
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain-text body sanitization: NOT escape markdown, но strip control chars.
|
||||
* Используется для plain text email body, plain Telegram (если не MarkdownV2),
|
||||
* plain Express messages (если не markdown mode).
|
||||
*
|
||||
* <p>Strips: ASCII control chars (0x00-0x1F кроме {@code \t \r \n}) + DEL (0x7F).
|
||||
* НЕ truncate'ит до MAX_LEN — bodies больше subjects, размер контролируется
|
||||
* на template level или channel-specific limits.
|
||||
*
|
||||
* @param raw произвольный user-controlled input, может быть null
|
||||
* @return sanitized string, never null
|
||||
*/
|
||||
public static String forPlainTextBody(String raw) {
|
||||
if (raw == null) {
|
||||
return "";
|
||||
}
|
||||
return raw.replaceAll("[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]", "");
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
package cloud.nstart.terravault.ordinis.notifications.template;
|
||||
|
||||
import cloud.nstart.terravault.ordinis.notifications.channel.ChannelKind;
|
||||
import cloud.nstart.terravault.ordinis.notifications.channel.RenderedMessage;
|
||||
import cloud.nstart.terravault.ordinis.notifications.sanitizer.Sanitizer;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.MessageSource;
|
||||
import org.springframework.context.NoSuchMessageException;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Renders notification messages из Spring {@code MessageSource} bundles.
|
||||
*
|
||||
* <p>Bundles в classpath {@code messages/notifications_{locale}.properties}.
|
||||
* Keys имеют shape {@code {eventKey}.{channel}.{slot}}, where:
|
||||
* <ul>
|
||||
* <li>{@code eventKey}: 'draft_submitted' | 'draft_decision' | 'draft_withdrawn'</li>
|
||||
* <li>{@code channel}: 'email' | 'express' | 'telegram'</li>
|
||||
* <li>{@code slot}: 'subject' | 'body'</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Example: {@code draft_submitted.email.subject} = {@code "[НСИ] Новый draft на review — {0} / {1}"}
|
||||
*
|
||||
* <p>Sanitization built-in: каждый dynamic param проходит {@code Sanitizer.forXxx()}
|
||||
* per channel kind ДО {@code MessageSource.getMessage()}. Defence-in-depth — даже
|
||||
* если template содержит markdown, sanitized params не сломают rendering.
|
||||
*/
|
||||
// Gate must mirror NotificationsAutoConfiguration. Writer's
|
||||
// @SpringBootApplication scanBasePackages — superset, может ловить этот
|
||||
// @Component до того как notifications сконфигурированы. Plus
|
||||
// @Qualifier на конструкторе — Spring Boot auto-creates default
|
||||
// `messageSource` bean (для app-wide i18n) → ambiguous-by-type injection без
|
||||
// явного qualifier'а.
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "ordinis.notifications.enabled", havingValue = "true", matchIfMissing = false)
|
||||
public class MessageRenderer {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(MessageRenderer.class);
|
||||
|
||||
/** Default locale если recipient.locale=null или unsupported. */
|
||||
public static final Locale DEFAULT_LOCALE = new Locale("ru");
|
||||
|
||||
private final MessageSource messageSource;
|
||||
|
||||
public MessageRenderer(@Qualifier("notificationsMessageSource") MessageSource messageSource) {
|
||||
this.messageSource = messageSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render subject + body для (eventKey, channel, locale) с sanitized params.
|
||||
*
|
||||
* <p>Params порядок MUST соответствовать {0} {1} {2}... placeholders в template
|
||||
* (Java MessageFormat conventions). См. notifications_ru.properties для reference.
|
||||
*
|
||||
* @param eventKey 'draft_submitted' | 'draft_decision' | 'draft_withdrawn'
|
||||
* @param channel target channel — определяет sanitization rules
|
||||
* @param locale recipient locale (RU/EN), fallback DEFAULT_LOCALE
|
||||
* @param params positional params {0..N} (sanitization применяется автоматически
|
||||
* через {@link #sanitizeForChannel})
|
||||
* @return RenderedMessage с subject + body
|
||||
* @throws NoSuchMessageException если template missing — лучше fail loud чем
|
||||
* silent empty render
|
||||
*/
|
||||
public RenderedMessage render(
|
||||
String eventKey, ChannelKind channel, Locale locale, Object... params) {
|
||||
|
||||
Locale effectiveLocale = (locale != null) ? locale : DEFAULT_LOCALE;
|
||||
Object[] safe = sanitizeForChannel(channel, params);
|
||||
|
||||
String channelKey = channel.wireName();
|
||||
String subjectKey = eventKey + "." + channelKey + ".subject";
|
||||
String bodyKey = eventKey + "." + channelKey + ".body";
|
||||
|
||||
String subject = messageSource.getMessage(subjectKey, safe, effectiveLocale);
|
||||
String body = messageSource.getMessage(bodyKey, safe, effectiveLocale);
|
||||
|
||||
return new RenderedMessage(subject, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply per-channel sanitization. String params get channel-appropriate escape;
|
||||
* non-string params (numbers, dates) pass through.
|
||||
*/
|
||||
private Object[] sanitizeForChannel(ChannelKind channel, Object[] params) {
|
||||
if (params == null) return new Object[0];
|
||||
Object[] out = new Object[params.length];
|
||||
for (int i = 0; i < params.length; i++) {
|
||||
Object p = params[i];
|
||||
if (p instanceof String s) {
|
||||
out[i] = switch (channel) {
|
||||
case EMAIL -> Sanitizer.forEmailHeader(s);
|
||||
case EXPRESS -> Sanitizer.forExpressText(s);
|
||||
case TELEGRAM -> Sanitizer.forTelegramMarkdown(s);
|
||||
};
|
||||
} else {
|
||||
out[i] = p;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
cloud.nstart.terravault.ordinis.notifications.config.NotificationsAutoConfiguration
|
||||
@@ -0,0 +1,35 @@
|
||||
# Notifications message bundle — English.
|
||||
# Same key shape as notifications_ru.properties.
|
||||
|
||||
# === draft.submitted ===
|
||||
|
||||
draft_submitted.email.subject=[NSI] New draft for review — {0} / {1}
|
||||
draft_submitted.email.body=Maker {2} submitted {3} on {0}, key {1}.\n\nOpen queue: {4}/reviews\nDirect link: {4}/reviews/{5}\n\n—\nAutomated NSI notification. To unsubscribe, visit {4}/me/notifications.
|
||||
|
||||
draft_submitted.express.subject=*🔔 New draft for review*
|
||||
draft_submitted.express.body=*🔔 New draft for review*\n«{0}» / `{1}`\nMaker: {2} ({3})\n\nOpen queue: {4}/reviews\nDirect link: {4}/reviews/{5}
|
||||
|
||||
draft_submitted.telegram.subject=
|
||||
draft_submitted.telegram.body=*🔔 New draft for review*\n«{0}» / `{1}`\nMaker: {2} \\({3}\\)\n\nOpen: {4}/reviews/{5}
|
||||
|
||||
# === draft.decision ===
|
||||
|
||||
draft_decision.email.subject=[NSI] {6,choice,0#Draft rejected|1#Draft approved} — {0} / {1}
|
||||
draft_decision.email.body=Your draft {3} on {0} (key {1}) has been {6,choice,0#rejected|1#approved} by {7}.{8,choice,0#|1# Comment: {9}}\n\nOpen: {4}/my-drafts/{5}
|
||||
|
||||
draft_decision.express.subject={6,choice,0#*❌ Draft rejected*|1#*✅ Draft approved*}
|
||||
draft_decision.express.body={6,choice,0#*❌ Draft rejected*|1#*✅ Draft approved*}\n«{0}» / `{1}`\nReviewer: {7}{8,choice,0#|1#\nComment: {9}}\n\nOpen: {4}/my-drafts/{5}
|
||||
|
||||
draft_decision.telegram.subject=
|
||||
draft_decision.telegram.body={6,choice,0#*❌ Draft rejected*|1#*✅ Draft approved*}\n«{0}» / `{1}`\nReviewer: {7}\n\nOpen: {4}/my\\-drafts/{5}
|
||||
|
||||
# === draft.withdrawn ===
|
||||
|
||||
draft_withdrawn.email.subject=[NSI] Draft withdrawn by maker — {0} / {1}
|
||||
draft_withdrawn.email.body=Maker {2} withdrew their draft {3} on {0}, key {1}.\nNo action needed — already removed from queue.\n\n{4}/reviews
|
||||
|
||||
draft_withdrawn.express.subject=*↩ Draft withdrawn*
|
||||
draft_withdrawn.express.body=*↩ Draft withdrawn*\n«{0}» / `{1}`\nMaker withdrew — removed from queue.
|
||||
|
||||
draft_withdrawn.telegram.subject=
|
||||
draft_withdrawn.telegram.body=*↩ Draft withdrawn*\n«{0}» / `{1}`\nMaker withdrew\\.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Notifications message bundle — Russian (default).
|
||||
#
|
||||
# Key shape: {eventKey}.{channel}.{slot}
|
||||
# Params (positional, MessageFormat style):
|
||||
# {0} dictDisplayName (e.g. "Космические аппараты")
|
||||
# {1} businessKey (e.g. "Союз-МС-25")
|
||||
# {2} maker user_id (e.g. "user.tech@nstart.space")
|
||||
# {3} operation (e.g. "UPDATE")
|
||||
# {4} baseUrl (e.g. "https://ordinis.k8s.../")
|
||||
# {5} draftId
|
||||
# {6} decision outcome (0=REJECT, 1=APPROVE — ChoiceFormat)
|
||||
# {7} reviewer user_id
|
||||
# {8} comment present (0=no, 1=yes)
|
||||
# {9} comment text
|
||||
#
|
||||
# All dynamic params уже sanitized per channel в MessageRenderer.
|
||||
|
||||
# === draft.submitted (reviewer pool ping) ===
|
||||
|
||||
draft_submitted.email.subject=[НСИ] Новый draft на review — {0} / {1}
|
||||
draft_submitted.email.body=Maker {2} отправил {3} на {0}, ключ {1}.\n\nОткрыть очередь: {4}/reviews\nПрямая ссылка: {4}/reviews/{5}\n\n—\nЭто автоматическое уведомление НСИ. Чтобы отписаться, зайдите в {4}/me/notifications.
|
||||
|
||||
draft_submitted.express.subject=*🔔 Новый draft на review*
|
||||
draft_submitted.express.body=*🔔 Новый draft на review*\n«{0}» / `{1}`\nMaker: {2} ({3})\n\nОткрыть очередь: {4}/reviews\nПрямая ссылка: {4}/reviews/{5}
|
||||
|
||||
draft_submitted.telegram.subject=
|
||||
draft_submitted.telegram.body=*🔔 Новый draft на review*\n«{0}» / `{1}`\nMaker: {2} \\({3}\\)\n\nОткрыть: {4}/reviews/{5}
|
||||
|
||||
# === draft.decision (notify maker) ===
|
||||
# {6} = 0 (REJECT) or 1 (APPROVE) — ChoiceFormat
|
||||
# {8} = 0 (no comment) or 1 (has comment) — ChoiceFormat
|
||||
|
||||
draft_decision.email.subject=[НСИ] {6,choice,0#Draft отклонён|1#Draft одобрен} — {0} / {1}
|
||||
draft_decision.email.body=Ваш draft {3} на {0} (ключ {1}) был {6,choice,0#отклонён|1#одобрен} пользователем {7}.{8,choice,0#|1# Комментарий: {9}}\n\nОткрыть: {4}/my-drafts/{5}
|
||||
|
||||
draft_decision.express.subject={6,choice,0#*❌ Draft отклонён*|1#*✅ Draft одобрен*}
|
||||
draft_decision.express.body={6,choice,0#*❌ Draft отклонён*|1#*✅ Draft одобрен*}\n«{0}» / `{1}`\nReviewer: {7}{8,choice,0#|1#\nКомментарий: {9}}\n\nОткрыть: {4}/my-drafts/{5}
|
||||
|
||||
draft_decision.telegram.subject=
|
||||
draft_decision.telegram.body={6,choice,0#*❌ Draft отклонён*|1#*✅ Draft одобрен*}\n«{0}» / `{1}`\nReviewer: {7}\n\nОткрыть: {4}/my\\-drafts/{5}
|
||||
|
||||
# === draft.withdrawn (clear queue) ===
|
||||
|
||||
draft_withdrawn.email.subject=[НСИ] Draft отозван maker'ом — {0} / {1}
|
||||
draft_withdrawn.email.body=Maker {2} отозвал свой draft {3} на {0}, ключ {1}.\nУбрать из очереди не требуется — уже убран автоматически.\n\n{4}/reviews
|
||||
|
||||
draft_withdrawn.express.subject=*↩ Draft withdrawn*
|
||||
draft_withdrawn.express.body=*↩ Draft withdrawn*\n«{0}» / `{1}`\nMaker сам отозвал — убрано из очереди.
|
||||
|
||||
draft_withdrawn.telegram.subject=
|
||||
draft_withdrawn.telegram.body=*↩ Draft withdrawn*\n«{0}» / `{1}`\nMaker сам отозвал\\.
|
||||
BIN
Binary file not shown.
+220
@@ -0,0 +1,220 @@
|
||||
package cloud.nstart.terravault.ordinis.notifications.template;
|
||||
|
||||
import cloud.nstart.terravault.ordinis.notifications.channel.ChannelKind;
|
||||
import cloud.nstart.terravault.ordinis.notifications.channel.RenderedMessage;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.MessageSource;
|
||||
import org.springframework.context.support.ReloadableResourceBundleMessageSource;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Locale;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Integration test для MessageRenderer + bundle templates + Sanitizer chain.
|
||||
* Verifies:
|
||||
* <ul>
|
||||
* <li>Bundles parseable (no MessageFormat syntax errors).</li>
|
||||
* <li>RU + EN templates exist для всех 9 combinations (3 events × 3 channels).</li>
|
||||
* <li>Sanitization happens — malicious business_key не ломает rendering и не
|
||||
* inject'ит в email subject.</li>
|
||||
* </ul>
|
||||
*/
|
||||
class MessageRendererTest {
|
||||
|
||||
private MessageRenderer renderer;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ReloadableResourceBundleMessageSource ms = new ReloadableResourceBundleMessageSource();
|
||||
ms.setBasename("classpath:messages/notifications");
|
||||
ms.setDefaultEncoding(StandardCharsets.UTF_8.name());
|
||||
ms.setFallbackToSystemLocale(false);
|
||||
ms.setUseCodeAsDefaultMessage(false);
|
||||
renderer = new MessageRenderer(ms);
|
||||
}
|
||||
|
||||
// === draft.submitted ===
|
||||
|
||||
@Test
|
||||
@DisplayName("draft_submitted email RU rendered с business_key и link")
|
||||
void draftSubmittedEmailRu() {
|
||||
RenderedMessage msg = renderer.render(
|
||||
"draft_submitted", ChannelKind.EMAIL, new Locale("ru"),
|
||||
"Космические аппараты", "Союз-МС-25", "user.tech@nstart.space",
|
||||
"UPDATE", "https://ordinis.example", 123L);
|
||||
|
||||
assertTrue(msg.subject().contains("Космические аппараты"));
|
||||
assertTrue(msg.subject().contains("Союз-МС-25"));
|
||||
assertTrue(msg.subject().startsWith("[НСИ]"));
|
||||
assertTrue(msg.body().contains("UPDATE"));
|
||||
assertTrue(msg.body().contains("https://ordinis.example/reviews/123"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("draft_submitted email EN rendered correctly")
|
||||
void draftSubmittedEmailEn() {
|
||||
RenderedMessage msg = renderer.render(
|
||||
"draft_submitted", ChannelKind.EMAIL, Locale.ENGLISH,
|
||||
"Spacecraft", "Soyuz-MS-25", "user.tech@nstart.space",
|
||||
"UPDATE", "https://ordinis.example", 123L);
|
||||
|
||||
assertTrue(msg.subject().startsWith("[NSI]"));
|
||||
assertTrue(msg.subject().contains("Soyuz-MS-25"));
|
||||
assertTrue(msg.body().contains("Maker user.tech@nstart.space"));
|
||||
}
|
||||
|
||||
// === draft.decision ChoiceFormat ===
|
||||
|
||||
@Test
|
||||
@DisplayName("draft_decision APPROVE с comment renders ✅")
|
||||
void draftDecisionApproveWithComment() {
|
||||
RenderedMessage msg = renderer.render(
|
||||
"draft_decision", ChannelKind.EMAIL, new Locale("ru"),
|
||||
"Космические аппараты", "Союз-МС-25", "user.tech@nstart.space",
|
||||
"UPDATE", "https://ordinis.example", 123L,
|
||||
1, // {6} = 1 → APPROVE
|
||||
"reviewer.tech@nstart.space", // {7}
|
||||
1, // {8} = 1 → has comment
|
||||
"Согласовано"); // {9}
|
||||
|
||||
assertTrue(msg.subject().contains("одобрен"),
|
||||
"RU APPROVE subject должен содержать 'одобрен', got: " + msg.subject());
|
||||
assertTrue(msg.body().contains("Согласовано"));
|
||||
assertTrue(msg.body().contains("reviewer.tech@nstart.space"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("draft_decision REJECT без comment не показывает comment field")
|
||||
void draftDecisionRejectNoComment() {
|
||||
RenderedMessage msg = renderer.render(
|
||||
"draft_decision", ChannelKind.EMAIL, new Locale("ru"),
|
||||
"Космические аппараты", "Союз-МС-25", "user.tech@nstart.space",
|
||||
"UPDATE", "https://ordinis.example", 123L,
|
||||
0, // REJECT
|
||||
"reviewer.tech@nstart.space",
|
||||
0, // no comment
|
||||
"");
|
||||
|
||||
assertTrue(msg.subject().contains("отклонён"));
|
||||
assertFalse(msg.body().contains("Комментарий:"),
|
||||
"При no-comment не должен show 'Комментарий:' field");
|
||||
}
|
||||
|
||||
// === Express channel ===
|
||||
|
||||
@Test
|
||||
@DisplayName("Express body содержит status icon + bold + business_key code")
|
||||
void expressSubmittedFormat() {
|
||||
RenderedMessage msg = renderer.render(
|
||||
"draft_submitted", ChannelKind.EXPRESS, new Locale("ru"),
|
||||
"Космические аппараты", "Союз-МС-25", "user.tech@nstart.space",
|
||||
"UPDATE", "https://ordinis.example", 123L);
|
||||
|
||||
assertTrue(msg.body().contains("🔔"), "должен иметь bell emoji status");
|
||||
// BotX bold = single asterisks (not CommonMark **double**). Template начинается с *🔔...*
|
||||
assertTrue(msg.body().startsWith("*"), "должен начинаться с BotX bold marker (*)");
|
||||
// Sanitizer.forExpressText escape's [* _ ` ~ \\] — НЕ hyphen, так что Союз-МС-25 остаётся as-is
|
||||
assertTrue(msg.body().contains("`Союз-МС-25`"),
|
||||
"business_key должен быть в code marker (BotX backticks). Body: " + msg.body());
|
||||
}
|
||||
|
||||
// === Telegram channel ===
|
||||
|
||||
@Test
|
||||
@DisplayName("Telegram body уважает MarkdownV2 escape (no raw dot in URL)")
|
||||
void telegramMarkdownEscape() {
|
||||
RenderedMessage msg = renderer.render(
|
||||
"draft_submitted", ChannelKind.TELEGRAM, new Locale("ru"),
|
||||
"Космические аппараты", "Союз-МС-25", "user.tech@nstart.space",
|
||||
"UPDATE", "https://ordinis.example", 123L);
|
||||
|
||||
// В template /reviews/{5} — без escape \., но URL contains "/"
|
||||
// (pass через ChoiceFormat) — тут проверяем что хотя бы template без issue.
|
||||
assertNotNull(msg.body());
|
||||
assertTrue(msg.body().contains("🔔"));
|
||||
}
|
||||
|
||||
// === withdrawn ===
|
||||
|
||||
@Test
|
||||
@DisplayName("draft_withdrawn renders RU")
|
||||
void draftWithdrawnEmailRu() {
|
||||
RenderedMessage msg = renderer.render(
|
||||
"draft_withdrawn", ChannelKind.EMAIL, new Locale("ru"),
|
||||
"Космические аппараты", "Союз-МС-25", "user.tech@nstart.space",
|
||||
"UPDATE", "https://ordinis.example", 123L);
|
||||
|
||||
assertTrue(msg.subject().contains("отозван"));
|
||||
assertTrue(msg.body().contains("user.tech@nstart.space"));
|
||||
}
|
||||
|
||||
// === SECURITY: Email header injection через malicious business_key ===
|
||||
|
||||
@Test
|
||||
@DisplayName("CRITICAL: malicious business_key с CRLF не может inject email headers")
|
||||
void emailSubjectImmuneToHeaderInjection() {
|
||||
String malicious = "Союз\nBcc: attacker@evil.com\nFrom: spoof@evil.com";
|
||||
|
||||
RenderedMessage msg = renderer.render(
|
||||
"draft_submitted", ChannelKind.EMAIL, new Locale("ru"),
|
||||
"Космические аппараты", malicious, "user@nstart.space",
|
||||
"UPDATE", "https://ordinis.example", 123L);
|
||||
|
||||
// Critical assertions:
|
||||
assertFalse(msg.subject().contains("\n"),
|
||||
"Email subject не должен содержать LF — иначе header injection");
|
||||
assertFalse(msg.subject().contains("\r"),
|
||||
"Email subject не должен содержать CR — иначе header injection");
|
||||
|
||||
// String "Bcc:" может остаться (это просто текст в subject), но не на отдельной
|
||||
// line — что и проверяет no-newline assert выше.
|
||||
String[] lines = msg.subject().split("\\r?\\n");
|
||||
assertEquals(1, lines.length, "Subject должен быть single line");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Express body sanitization escape's markdown-confusing chars в business_key")
|
||||
void expressMarkdownInjectionEscaped() {
|
||||
// Maker создал business_key с *_ — без escape это сломает bold rendering
|
||||
String malicious = "test*key_with*markers";
|
||||
|
||||
RenderedMessage msg = renderer.render(
|
||||
"draft_submitted", ChannelKind.EXPRESS, new Locale("ru"),
|
||||
"Космические аппараты", malicious, "user@nstart.space",
|
||||
"UPDATE", "https://ordinis.example", 123L);
|
||||
|
||||
// В rendered body должны быть escape'нутые \* \_:
|
||||
assertTrue(msg.body().contains("\\*"), "asterisk должен быть escaped");
|
||||
assertTrue(msg.body().contains("\\_"), "underscore должен быть escaped");
|
||||
}
|
||||
|
||||
// === missing key fail-loud ===
|
||||
|
||||
@Test
|
||||
@DisplayName("missing template key → throws NoSuchMessageException (не silent empty)")
|
||||
void missingKeyFailsLoud() {
|
||||
assertThrows(
|
||||
org.springframework.context.NoSuchMessageException.class,
|
||||
() -> renderer.render(
|
||||
"nonexistent_event", ChannelKind.EMAIL, new Locale("ru"),
|
||||
"x", "y", "z", "w", "u", 1L));
|
||||
}
|
||||
|
||||
// === fallback locale ===
|
||||
|
||||
@Test
|
||||
@DisplayName("null locale → DEFAULT_LOCALE (RU) fallback")
|
||||
void nullLocaleFallsBackToRu() {
|
||||
RenderedMessage msg = renderer.render(
|
||||
"draft_submitted", ChannelKind.EMAIL, null,
|
||||
"Космические аппараты", "Союз-МС-25", "user@nstart.space",
|
||||
"UPDATE", "https://ordinis.example", 123L);
|
||||
|
||||
assertTrue(msg.subject().startsWith("[НСИ]"),
|
||||
"Null locale → RU bundle, subject должен быть RU");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user