feat(notifications): Phase A foundation — module + schema 0021

This commit is contained in:
Александр Зимин
2026-05-10 15:41:34 +00:00
parent c675c5e310
commit 3b7b1bffe5
24 changed files with 1802 additions and 0 deletions
@@ -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");
}
}