feat(notifications): Phase A foundation — module + schema 0021
This commit is contained in:
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