Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/detekt.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ complexity:
NestedBlockDepth:
active: false
NestedScopeFunctions:
active: false
active: true

exceptions:
TooGenericExceptionCaught:
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import com.moneat.utils.suspendRunCatching

private val logger = KotlinLogging.logger {}

/** Escapes HTML special characters for safe inclusion in email templates. */
private fun String.escapeHtml(): String =
replace("&", "&")
.replace("<", "&lt;")
Expand All @@ -60,6 +61,7 @@ private const val BADGE_NEGATIVE =
private const val BADGE_NEUTRAL = "font-weight:500;background-color:#f5f5f5;" +
"border:1px solid #e5e5e5;color:#737373;"

/** Sends transactional and notification email via Jakarta Mail and HTML templates. */
class EmailService {
private val config = ApplicationConfig("application.conf")
private val fromEmail = config.property("email.from").getString()
Expand Down Expand Up @@ -89,6 +91,7 @@ class EmailService {
Session.getInstance(
props,
object : Authenticator() {
/** Credentials used for authenticated SMTP submission. */
override fun getPasswordAuthentication(): PasswordAuthentication {
return PasswordAuthentication(smtpUsername, smtpPassword)
}
Expand Down Expand Up @@ -187,6 +190,10 @@ class EmailService {
sendEmail(toEmail, subject, htmlBody, textBody, "org_invitation")
}

/**
* Sends a multipart alternative (plain text + HTML) message when SMTP is configured;
* otherwise logs a preview and records a failed send for metrics.
*/
fun sendEmail(
to: String,
subject: String,
Expand Down Expand Up @@ -214,29 +221,23 @@ class EmailService {

var success = false
try {
val textPart =
MimeBodyPart().apply {
setText(textBody, "UTF-8")
}
val htmlPart =
MimeBodyPart().apply {
setContent(htmlBody, "text/html; charset=UTF-8")
}
val message =
MimeMessage(mailSession).apply {
setFrom(InternetAddress(fromEmail, "Moneat"))
setRecipients(Message.RecipientType.TO, InternetAddress.parse(to))
setSubject(subject)

// Create multipart message with both HTML and text
val multipart = MimeMultipart("alternative")

// Add text part
val textPart =
MimeBodyPart().apply {
setText(textBody, "UTF-8")
}
multipart.addBodyPart(textPart)

// Add HTML part
val htmlPart =
MimeBodyPart().apply {
setContent(htmlBody, "text/html; charset=UTF-8")
}
multipart.addBodyPart(htmlPart)

setContent(multipart)
}

Expand Down Expand Up @@ -292,6 +293,7 @@ class EmailService {
}
}

/** Records send outcome in [EmailsSent] for the recipient's organization when resolvable. */
private fun trackEmailSent(
recipient: String,
emailType: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ import kotlin.time.Clock
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import com.moneat.utils.suspendRunCatching

/** Manages synthetic HTTP checks, variables, execution, and ClickHouse result storage. */
class SyntheticsService(
private val emailService: EmailService = EmailService(),
private val slackService: SlackService = SlackService(),
Expand Down Expand Up @@ -134,21 +136,19 @@ class SyntheticsService(
}
}

/** Applies partial updates to a synthetic test; validates retry fields when either count or interval is present. */
fun updateTest(
testId: UUID,
organizationId: Int,
request: UpdateSyntheticTestRequest
): SyntheticTestResponse? {
request.retryCount?.let { rc ->
request.retryIntervalMs?.let { ri ->
validateRetryParams(rc, ri)
} ?: validateRetryParams(rc, RETRY_INTERVAL_MS_DEFAULT)
}
request.retryIntervalMs?.let { ri ->
validateRetryParams(
request.retryCount ?: RETRY_COUNT_DEFAULT,
ri
)
val rc = request.retryCount
val ri = request.retryIntervalMs
if (rc != null) {
validateRetryParams(rc, ri ?: RETRY_INTERVAL_MS_DEFAULT)
}
if (ri != null) {
validateRetryParams(rc ?: RETRY_COUNT_DEFAULT, ri)
}
val updated = transaction {
SyntheticTests
Expand Down Expand Up @@ -522,6 +522,7 @@ class SyntheticsService(
}
}

/** Ensures retry count and interval are non-negative before create/update. */
private fun validateRetryParams(retryCount: Int, retryIntervalMs: Int) {
require(retryCount >= 0) {
"retryCount must be non-negative, got $retryCount"
Expand Down
72 changes: 34 additions & 38 deletions backend/src/test/kotlin/com/moneat/services/EventServiceTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ class EventServiceTest {
private val validPublicKey = "test-public-key-valid"
private val inactivePublicKey = "test-public-key-inactive"

/** Constructs [EventService] with default and scenario-specific [EventRepository] mocks. */
@BeforeTest
fun setup() {
eventService = EventService(eventRepository = eventRepository)
Expand Down Expand Up @@ -123,6 +124,7 @@ class EventServiceTest {

// ──── EVENT FINGERPRINTING TESTS (P0) ────

/** Asserts identical primary exception metadata yields the same deduplication fingerprint. */
@Test
fun `same error with identical exception generates same fingerprint for deduplication`() {
val event1 =
Expand Down Expand Up @@ -150,46 +152,40 @@ class EventServiceTest {
)

val fingerprint1 =
event1.let {
it.exception?.let { exc ->
val firstException = exc.values.firstOrNull()
listOf(
firstException?.type,
firstException
?.stacktrace
?.frames
?.lastOrNull()
?.function,
firstException
?.stacktrace
?.frames
?.lastOrNull()
?.filename
)
.filterNotNull()
} ?: emptyList()
}
event1.exception?.let { exc ->
val firstException = exc.values.firstOrNull()
listOf(
firstException?.type,
firstException
?.stacktrace
?.frames
?.lastOrNull()
?.function,
firstException
?.stacktrace
?.frames
?.lastOrNull()
?.filename
).filterNotNull()
} ?: emptyList()

val fingerprint2 =
event2.let {
it.exception?.let { exc ->
val firstException = exc.values.firstOrNull()
listOf(
firstException?.type,
firstException
?.stacktrace
?.frames
?.lastOrNull()
?.function,
firstException
?.stacktrace
?.frames
?.lastOrNull()
?.filename
)
.filterNotNull()
} ?: emptyList()
}
event2.exception?.let { exc ->
val firstException = exc.values.firstOrNull()
listOf(
firstException?.type,
firstException
?.stacktrace
?.frames
?.lastOrNull()
?.function,
firstException
?.stacktrace
?.frames
?.lastOrNull()
?.filename
).filterNotNull()
} ?: emptyList()

assertEquals(fingerprint1, fingerprint2, "Same errors should generate identical fingerprints for deduplication")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ object RouteTestSupport {
.withIssuer(ISSUER)
.withAudience(AUDIENCE)
.withClaim("userId", userId)
.apply { orgId?.let { withClaim("orgId", it) } }
.apply { if (orgId != null) withClaim("orgId", orgId) }
.withClaim("email", email)
.sign(Algorithm.HMAC256(secret))
}
Expand Down
2 changes: 1 addition & 1 deletion ee/backend/detekt.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ complexity:
NestedBlockDepth:
active: false
NestedScopeFunctions:
active: false
active: true

exceptions:
TooGenericExceptionCaught:
Expand Down
Loading