This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-7268-351ce201e91fa124987502c9235d16a8d07ab61c in repository https://gitbox.apache.org/repos/asf/texera.git
commit 6a5885c1bce0005a41525ba42f6995de4db14331 Author: Xinyuan Lin <[email protected]> AuthorDate: Mon Aug 3 13:22:14 2026 -0700 test(amber): cover WorkflowEmailNotifier and two GmailResource guards (#7268) ### What changes were proposed in this PR? `WorkflowEmailNotifier` was at **0%**. It looks untestable, but the blocker was never SMTP — it was the constructor: ``` new WorkflowEmailNotifier(wid, ...) └─ line 39: WorkflowResource.getWorkflowName(wid) └─ SqlServer.getInstance() // instance.get on an Option -> throws with no pool ``` So even the pure `shouldSendEmail` was unreachable. `WorkflowResource`'s DAOs are `private def` rather than vals, so the object loads fine and `MockTexeraDB` is enough; `getWorkflowName` reads only the `workflow` table, making **one seeded row** the entire fixture. **12 tests** on the notifier: `createDashboardUrl`'s port branches (−1 / 80 / 443 collapse, an explicit 8080 kept), the subject and content carrying the DB-resolved workflow name, `formatTimestamp` against a fixed `Instant`, `isValidEmail` both ways, `shouldSendEmail` across terminal and in-flight states, and the constructor propagating `NotFoundException` for an unseeded wid. **2 additions** to `GmailResourceSpec`, neither needing infrastructure: the 403 format guard on `notifyUnauthorizedUser` (first statement, ahead of any DAO touch) and the empty-receiver fallback to the session user's email, observable as a 502 rather than a 400. **Assertion strength was measured, not claimed** — 17 production mutations, every one killing at least one test, all reverted: | Mutation | Caught by | |---|---| | drop any one of the `-1` / `80` / `443` port branches | the matching port test (3 mutations) | | `path` loses the workflow id | all 4 URL tests + content | | keep the DB lookup but fake the resulting name | subject + content — proves the assertions read the DB *value*, not just that a call happened | | wrap the lookup in `Try(...).getOrElse` | the `NotFoundException` test | | `ZoneOffset.UTC` → `ZoneId.systemDefault()` | `formatTimestamp` | | drop the `'(UTC)'` suffix | `formatTimestamp` + content | | add/remove a terminal state | the matching `shouldSendEmail` test | | pin `isValidEmail` true / false | the opposite-direction test | | remove either Gmail guard | the corresponding new test | Deliberately **not** tested, with reasons in the file: `sendStatusEmail` (no injectable mail seam, so both branches are observationally identical to an empty body); `TERMINATED`/`UNKNOWN` in `shouldSendEmail` (adding `TERMINATED` is a plausible improvement, so pinning today's answer would cement a limitation); and `isValidEmail("")`, because Hibernate's validator returns **true** for empty input — an "empty is invalid" assertion would be asserting a falsehood, and note `GmailResource`'s own regex-based validator disagrees with it. The new Gmail test **fails fast** if `UserSystemConfig.gmail` is ever non-empty. The suite stays network-free only because it defaults to blank; `Transport.send` is configured with no connect or read timeout, so a misconfigured environment would hang unbounded rather than fail, and on a runner with egress would deliver a real email. **Bug found, reported not cemented:** `sendStatusEmail` wraps `GmailResource.sendEmail` in a `try/catch`, but `sendEmail` returns `Either` and already wraps everything in its own `Try` — so the catch can never fire, and the returned `Left` is discarded without even a log line. Every delivery failure is currently silent. ### Any related issues, documentation, discussions? Closes #7266 ### How was this PR tested? 14 new tests, run with the adjacent email specs to confirm no interference — 19 tests, Java 17: ``` sbt "WorkflowExecutionService/testOnly org.apache.texera.web.service.WorkflowEmailNotifierSpec org.apache.texera.web.resource.GmailResourceSpec org.apache.texera.web.service.EmailNotificationServiceSpec" ``` ``` [info] Suites: completed 3, aborted 0 [info] Tests: succeeded 19, failed 0, canceled 0, ignored 0, pending 0 [info] All tests passed. ``` `Test/scalafmtCheck` and `Test/scalafix --check` both `[success]`. No production file is touched. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Opus 5) --------- Signed-off-by: Xinyuan Lin <[email protected]> Co-authored-by: Copilot Autofix powered by AI <[email protected]> --- .../texera/web/resource/GmailResourceSpec.scala | 63 +++++- .../web/service/WorkflowEmailNotifierSpec.scala | 219 +++++++++++++++++++++ 2 files changed, 281 insertions(+), 1 deletion(-) diff --git a/amber/src/test/scala/org/apache/texera/web/resource/GmailResourceSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/GmailResourceSpec.scala index 868b0e34cd..ec694f694f 100644 --- a/amber/src/test/scala/org/apache/texera/web/resource/GmailResourceSpec.scala +++ b/amber/src/test/scala/org/apache/texera/web/resource/GmailResourceSpec.scala @@ -20,11 +20,12 @@ package org.apache.texera.web.resource import org.apache.texera.auth.SessionUser +import org.apache.texera.common.config.UserSystemConfig import org.apache.texera.dao.jooq.generated.enums.UserRoleEnum import org.apache.texera.dao.jooq.generated.tables.pojos.User import org.scalatest.flatspec.AnyFlatSpec -import javax.ws.rs.{BadRequestException, WebApplicationException} +import javax.ws.rs.{BadRequestException, ForbiddenException, WebApplicationException} class GmailResourceSpec extends AnyFlatSpec { @@ -71,4 +72,64 @@ class GmailResourceSpec extends AnyFlatSpec { ) assert(ex.getResponse.getStatus == 502) } + + /** + * Every test here that reaches `sendEmail` relies on `UserSystemConfig.gmail` being empty, which + * is the shipped default: it makes `new InternetAddress(senderGmail)` throw inside sendEmail's own + * Try, so nothing is dialled. If it is ever overridden (USER_SYS_GOOGLE_SMTP_GMAIL) the code + * reaches `Transport.send` against smtp.gmail.com:465 with no connect/read timeout configured — + * an unbounded hang on a runner without egress, and a real email where there is one. Fail loudly + * rather than skip, so the misconfiguration is visible instead of silently deleting coverage. + */ + private def requireNoRealGmailSender(): Unit = + if (UserSystemConfig.gmail.nonEmpty) { + fail( + s"UserSystemConfig.gmail is set to '${UserSystemConfig.gmail}'; this suite would open a real " + + "SMTP connection. Unset USER_SYS_GOOGLE_SMTP_GMAIL and re-run." + ) + } + + requireNoRealGmailSender() + it should "fall back to the session user's email when the request carries an empty receiver" in { + requireNoRealGmailSender() + // An empty receiver reaching `sendEmail` unchanged fails the format regex and surfaces as a + // 400; a 502 (the AddressException path described above) means something valid-looking got + // through, i.e. the fallback fired. Strictly this pins "whatever reached sendEmail passed + // isValidEmail" rather than the substitution itself — widening that regex to accept "" would + // keep this green with the fallback deleted — but as production stands it is a genuine + // change-detector, and sendEmailRequest returns Unit so there is no seam for a direct assertion. + val resource = new GmailResource() + val msg = EmailMessage( + receiver = "", + subject = "subj", + content = "body" + ) + val ex = intercept[WebApplicationException] { + resource.sendEmailRequest(msg, newSessionUser()) + } + assert( + !ex.isInstanceOf[BadRequestException], + s"empty receiver was not replaced by the session user's email: ${ex.getMessage}" + ) + assert(ex.getResponse.getStatus == 502) + } + + "notifyUnauthorizedUser" should "reject a malformed receiver with HTTP 403 before any DB access" in { + // The format guard is the first statement, ahead of the admin lookup, so this needs no + // database: `new GmailResource()` touches none either, since the companion's context/userDao + // are defs and senderGmail is lazy. Remove the guard and `userDao.fetchByRole` throws something + // else — NoSuchElementException on a virgin JVM, or a jOOQ DataAccessException once another + // MockTexeraDB suite has initialised and closed the shared SqlServer — and either way + // `intercept[ForbiddenException]` rejects it. + val resource = new GmailResource() + val msg = EmailMessage( + receiver = "not-a-valid-email", + subject = "subj", + content = "body" + ) + val ex = intercept[ForbiddenException] { + resource.notifyUnauthorizedUser(msg) + } + assert(ex.getResponse.getStatus == 403) + } } diff --git a/amber/src/test/scala/org/apache/texera/web/service/WorkflowEmailNotifierSpec.scala b/amber/src/test/scala/org/apache/texera/web/service/WorkflowEmailNotifierSpec.scala new file mode 100644 index 0000000000..af21c7f687 --- /dev/null +++ b/amber/src/test/scala/org/apache/texera/web/service/WorkflowEmailNotifierSpec.scala @@ -0,0 +1,219 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.texera.web.service + +import org.apache.texera.amber.engine.architecture.rpc.controlreturns.WorkflowAggregatedState +import org.apache.texera.amber.engine.architecture.rpc.controlreturns.WorkflowAggregatedState._ +import org.apache.texera.dao.MockTexeraDB +import org.apache.texera.dao.jooq.generated.Tables.WORKFLOW +import org.apache.texera.dao.jooq.generated.tables.daos.WorkflowDao +import org.apache.texera.dao.jooq.generated.tables.pojos.Workflow +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.{BeforeAndAfterAll, PrivateMethodTester} + +import java.net.URI +import java.sql.Timestamp +import java.time.Instant +import java.util.{Locale, TimeZone, UUID} +import javax.ws.rs.NotFoundException + +/** + * Covers the parts of [[WorkflowEmailNotifier]] that decide *whether* to notify and *what* the + * notification says. Concretely, these tests fail if: + * - the constructor stops resolving the workflow name from the `workflow` table (the name is + * seeded here with a random suffix, so a hard-coded or echoed value cannot satisfy the + * subject/content assertions), or stops surfacing a missing workflow as `NotFoundException`; + * - `createDashboardUrl` starts emitting `:80`/`:443`/no-port links, or drops the workflow id + * from the deep link, so the mail points somewhere the user cannot open; + * - `formatTimestamp` stops pinning UTC or changes the human-readable pattern (the JVM default + * zone is deliberately set to non-UTC around the assertion; the locale is pinned to US so the + * expected month/day text is stable across environments); + * - the terminal-state set stops covering a state that ends or suspends an execution, which + * would silently drop the only email a user gets for that run. + * + * SMTP is never exercised: only the pure message-building helpers and the state predicate are + * invoked, so no test here reaches `GmailResource.sendEmail`. + */ +class WorkflowEmailNotifierSpec + extends AnyFlatSpec + with BeforeAndAfterAll + with MockTexeraDB + with PrivateMethodTester { + + private val testWid: Int = 7000 + scala.util.Random.nextInt(1000) + private val unseededWid: Int = testWid + 1 + // Random suffix: the subject/content assertions can only pass if the notifier really read this + // value back out of the database. + private val testWorkflowName: String = "notifier_wf_" + UUID.randomUUID().toString.substring(0, 8) + private val userEmail = "[email protected]" + + private val createDashboardUrl = PrivateMethod[String](Symbol("createDashboardUrl")) + private val createEmailSubject = PrivateMethod[String](Symbol("createEmailSubject")) + private val createEmailContent = PrivateMethod[String](Symbol("createEmailContent")) + private val formatTimestamp = PrivateMethod[String](Symbol("formatTimestamp")) + private val isValidEmail = PrivateMethod[Boolean](Symbol("isValidEmail")) + + override protected def beforeAll(): Unit = { + initializeDBAndReplaceDSLContext() + deleteFixtureRows() + + val workflow = new Workflow + workflow.setWid(testWid) + workflow.setName(testWorkflowName) + workflow.setContent("{}") + workflow.setDescription("seeded by WorkflowEmailNotifierSpec") + workflow.setCreationTime(new Timestamp(System.currentTimeMillis())) + workflow.setLastModifiedTime(new Timestamp(System.currentTimeMillis())) + new WorkflowDao(getDSLContext.configuration()).insert(workflow) + } + + override protected def afterAll(): Unit = { + deleteFixtureRows() + shutdownDB() + } + + private def deleteFixtureRows(): Unit = + getDSLContext.deleteFrom(WORKFLOW).where(WORKFLOW.WID.in(testWid, unseededWid)).execute() + + /** Builds a notifier for the seeded workflow; the constructor hits the database every time. */ + private def notifierFor(sessionUri: String): WorkflowEmailNotifier = + new WorkflowEmailNotifier(testWid.toLong, userEmail, new URI(sessionUri)) + + // ─── createDashboardUrl ──────────────────────────────────────────────────── + + "createDashboardUrl" should "omit the port when the session URI carries none" in { + val url = notifierFor("http://texera.example.com/dashboard/user/workspace/9") invokePrivate + createDashboardUrl() + assert(url == s"http://texera.example.com/user/workspace/$testWid") + } + + it should "omit an explicit port 80" in { + val url = notifierFor("http://texera.example.com:80/dashboard") invokePrivate + createDashboardUrl() + assert(url == s"http://texera.example.com/user/workspace/$testWid") + } + + // Note the assertion keeps `http://` even for the https-conventional :443. createDashboardUrl + // hardcodes the scheme and never reads sessionUri.getScheme; that is today's behaviour, not an + // endorsement of it. A fix that preserved the scheme would surface here. + it should "omit an explicit port 443" in { + val url = notifierFor("http://texera.example.com:443/dashboard") invokePrivate + createDashboardUrl() + assert(url == s"http://texera.example.com/user/workspace/$testWid") + } + + it should "keep a non-default port" in { + val url = notifierFor("http://localhost:8080/dashboard") invokePrivate createDashboardUrl() + assert(url == s"http://localhost:8080/user/workspace/$testWid") + } + + // ─── createEmailSubject / createEmailContent ─────────────────────────────── + + "createEmailSubject" should "carry the workflow name read from the database" in { + val subject = notifierFor("http://texera.example.com") invokePrivate + createEmailSubject(COMPLETED) + assert(subject == s"[Texera] Workflow $testWorkflowName ($testWid) Status: ${COMPLETED.name}") + } + + "createEmailContent" should "list the database-resolved name, the id, the state and the link" in { + val content = notifierFor("http://texera.example.com:8080/dashboard") invokePrivate + createEmailContent(FAILED) + + // stripMargin + trim: a broken margin would leave the "|" prefixes and leading blank line. + assert(content.startsWith("Hello,")) + assert(content.contains(s"- Workflow ID: $testWid")) + assert(content.contains(s"- Workflow Name: $testWorkflowName")) + assert(content.contains(s"- State: ${FAILED.name}")) + assert( + content.contains(s"visiting: http://texera.example.com:8080/user/workspace/$testWid"), + s"dashboard link missing from content:\n$content" + ) + assert( + content.linesIterator.exists(line => + line.startsWith("- Timestamp: ") && line.endsWith("(UTC)") + ), + s"no UTC timestamp line in content:\n$content" + ) + } + + // ─── formatTimestamp ─────────────────────────────────────────────────────── + + "formatTimestamp" should "render a fixed instant in UTC, independent of the JVM defaults" in { + val previousLocale = Locale.getDefault + val previousZone = TimeZone.getDefault + // Non-UTC default zone: had the formatter used ZoneId.systemDefault() the clock time below + // would read 06:07 instead of 14:07. + Locale.setDefault(Locale.US) + TimeZone.setDefault(TimeZone.getTimeZone("America/Los_Angeles")) + try { + val rendered = notifierFor("http://texera.example.com") invokePrivate + formatTimestamp(Instant.parse("2024-03-05T14:07:09Z")) + assert(rendered == "March 5, 2024, 2:07:09 PM (UTC)") + } finally { + TimeZone.setDefault(previousZone) + Locale.setDefault(previousLocale) + } + } + + // ─── isValidEmail ────────────────────────────────────────────────────────── + + "isValidEmail" should "accept a well-formed address" in { + assert(notifierFor("http://texera.example.com") invokePrivate isValidEmail("[email protected]")) + } + + it should "reject an address with no '@' and one with an empty domain" in { + val notifier = notifierFor("http://texera.example.com") + assert(!(notifier invokePrivate isValidEmail("user-at-example.com"))) + assert(!(notifier invokePrivate isValidEmail("user@"))) + } + + // ─── shouldSendEmail ─────────────────────────────────────────────────────── + + "shouldSendEmail" should "accept the states that end or suspend an execution" in { + val notifier = notifierFor("http://texera.example.com") + Seq(COMPLETED, PAUSED, FAILED, KILLED).foreach { state => + assert(notifier.shouldSendEmail(state), s"expected $state to trigger a notification") + } + } + + it should "decline the states an execution merely passes through" in { + val notifier = notifierFor("http://texera.example.com") + // TERMINATED / UNKNOWN are intentionally left out: whether they should notify is a product + // question, and pinning today's answer would block a harmless change. + val inFlight: Seq[WorkflowAggregatedState] = + Seq(UNINITIALIZED, READY, RUNNING, PAUSING, RESUMING) + inFlight.foreach { state => + assert(!notifier.shouldSendEmail(state), s"expected $state not to trigger a notification") + } + } + + // ─── constructor ─────────────────────────────────────────────────────────── + + "the constructor" should "propagate NotFoundException when the workflow row is missing" in { + val ex = intercept[NotFoundException] { + new WorkflowEmailNotifier( + unseededWid.toLong, + userEmail, + new URI("http://texera.example.com") + ) + } + assert(ex.getMessage.contains(unseededWid.toString)) + } +}
