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-7167-d9c443f38e2f4ac730c840a9fbb0ed8ed19cde63 in repository https://gitbox.apache.org/repos/asf/texera.git
commit d31406eaed568cdd4be5274f73de7683bbfdd586 Author: Meng Wang <[email protected]> AuthorDate: Fri Jul 31 11:48:54 2026 -0700 test(amber): add unit test coverage for AdminUserResource (#7167) ### What changes were proposed in this PR? Adds `AdminUserResourceSpec`, the first spec for `AdminUserResource` (previously ~0% coverage). It runs against embedded Postgres via `MockTexeraDB` and seeds rows through the jOOQ DAOs, with 12 tests covering the admin user-management methods, empty and populated: - `list` — returns the seeded users with name / email / role. - `addUser` — persists a new `INACTIVE` user. - `updateUser` — round-trips an editable-field change; rejects an email that already belongs to another user (`WebApplicationException`). - `getCreatedWorkflow` / `getAccessedWorkflow` — the per-user created / accessible workflow lists. - `getUserQuota` — the per-user quota view. - `deleteCollection` — removes the target execution row. Two side-effecting branches are intentionally left to their owning code paths so this stays a hermetic unit test: `updateUser`'s role-change branch (sends an e-mail via `GmailResource`) is not exercised — only the no-role-change and conflict paths are; and `deleteCollection` is asserted on its DB delete (the Iceberg document cleanup is a best-effort, exception-swallowed step). ### Any related issues, documentation, discussions? Closes #7163. ### How was this PR tested? `sbt "WorkflowExecutionService/testOnly *AdminUserResourceSpec"` — 12 succeeded, 0 failed. `Test/scalafmtCheck` and `Test/scalafix --check` clean. Failure path verified by breaking an assertion (→ 1 failed, non-zero exit) and restoring. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Opus 4.8 [1M context]) --- .../admin/user/AdminUserResourceSpec.scala | 291 +++++++++++++++++++++ 1 file changed, 291 insertions(+) diff --git a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/admin/user/AdminUserResourceSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/admin/user/AdminUserResourceSpec.scala new file mode 100644 index 0000000000..741dbba464 --- /dev/null +++ b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/admin/user/AdminUserResourceSpec.scala @@ -0,0 +1,291 @@ +/* + * 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.resource.dashboard.admin.user + +import org.apache.texera.dao.MockTexeraDB +import org.apache.texera.dao.jooq.generated.Tables._ +import org.apache.texera.dao.jooq.generated.enums.{PrivilegeEnum, UserRoleEnum} +import org.apache.texera.dao.jooq.generated.tables.daos.{ + UserDao, + WorkflowDao, + WorkflowExecutionsDao, + WorkflowOfUserDao, + WorkflowUserAccessDao, + WorkflowVersionDao +} +import org.apache.texera.dao.jooq.generated.tables.pojos.{ + User, + Workflow, + WorkflowExecutions, + WorkflowOfUser, + WorkflowUserAccess, + WorkflowVersion +} +import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach} +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +import java.sql.Timestamp +import java.util.UUID +import javax.ws.rs.WebApplicationException +import scala.jdk.CollectionConverters._ + +class AdminUserResourceSpec + extends AnyFlatSpec + with Matchers + with BeforeAndAfterAll + with BeforeAndAfterEach + with MockTexeraDB { + + private val primaryUid = 90000 + scala.util.Random.nextInt(5000) + private val secondaryUid = primaryUid + 1 + private val testWid = 90000 + scala.util.Random.nextInt(5000) + + private var userDao: UserDao = _ + private var workflowDao: WorkflowDao = _ + private var workflowVersionDao: WorkflowVersionDao = _ + private var workflowExecutionsDao: WorkflowExecutionsDao = _ + private var workflowOfUserDao: WorkflowOfUserDao = _ + private var workflowUserAccessDao: WorkflowUserAccessDao = _ + + private val resource = new AdminUserResource + + override protected def beforeAll(): Unit = { + initializeDBAndReplaceDSLContext() + userDao = new UserDao(getDSLContext.configuration()) + workflowDao = new WorkflowDao(getDSLContext.configuration()) + workflowVersionDao = new WorkflowVersionDao(getDSLContext.configuration()) + workflowExecutionsDao = new WorkflowExecutionsDao(getDSLContext.configuration()) + workflowOfUserDao = new WorkflowOfUserDao(getDSLContext.configuration()) + workflowUserAccessDao = new WorkflowUserAccessDao(getDSLContext.configuration()) + } + + override protected def afterAll(): Unit = closeConnectionPool() + + // Wipe everything this spec seeds, children before parents, after each test. + private def cleanup(): Unit = { + getDSLContext + .deleteFrom(WORKFLOW_EXECUTIONS) + .where(WORKFLOW_EXECUTIONS.UID.in(primaryUid, secondaryUid)) + .execute() + getDSLContext.deleteFrom(WORKFLOW_VERSION).where(WORKFLOW_VERSION.WID.eq(testWid)).execute() + getDSLContext + .deleteFrom(WORKFLOW_USER_ACCESS) + .where(WORKFLOW_USER_ACCESS.WID.eq(testWid)) + .execute() + getDSLContext.deleteFrom(WORKFLOW_OF_USER).where(WORKFLOW_OF_USER.WID.eq(testWid)).execute() + getDSLContext.deleteFrom(WORKFLOW).where(WORKFLOW.WID.eq(testWid)).execute() + getDSLContext.deleteFrom(USER).where(USER.UID.in(primaryUid, secondaryUid)).execute() + // addUser() inserts an INACTIVE user with an auto-generated uid and a "User<millis>" name. + getDSLContext + .deleteFrom(USER) + .where(USER.ROLE.eq(UserRoleEnum.INACTIVE).and(USER.NAME.like("User%"))) + .execute() + } + + private def makeUser(uid: Int, name: String, role: UserRoleEnum = UserRoleEnum.REGULAR): User = { + val user = new User + user.setUid(uid) + user.setName(name) + user.setEmail( + s"admin_user_spec_${uid}_${UUID.randomUUID().toString.substring(0, 8)}@example.com" + ) + user.setPassword("password") + user.setRole(role) + user + } + + private def seedWorkflow(): Workflow = { + val workflow = new Workflow + workflow.setWid(testWid) + workflow.setName("admin_user_spec_wf_" + UUID.randomUUID().toString.substring(0, 8)) + workflow.setContent("{}") + workflow.setDescription("desc") + workflow.setCreationTime(new Timestamp(System.currentTimeMillis())) + workflow.setLastModifiedTime(new Timestamp(System.currentTimeMillis())) + workflowDao.insert(workflow) + workflow + } + + private def seedExecution(uid: Int): WorkflowExecutions = { + seedWorkflow() + val version = new WorkflowVersion + version.setWid(testWid) + version.setContent("{}") + version.setCreationTime(new Timestamp(System.currentTimeMillis())) + workflowVersionDao.insert(version) + + val execution = new WorkflowExecutions + execution.setVid(version.getVid) + execution.setUid(uid) + execution.setStatus(0.toByte) + execution.setResult("") + execution.setLogLocation("") + execution.setStartingTime(new Timestamp(System.currentTimeMillis())) + execution.setBookmarked(false) + execution.setName("admin_user_spec_exec") + execution.setEnvironmentVersion("test-env-1.0") + workflowExecutionsDao.insert(execution) + execution + } + + override protected def afterEach(): Unit = cleanup() + + // ─── list ─────────────────────────────────────────────────────────────── + + "list" should "return the seeded users with their name, email, and role" in { + val a = makeUser(primaryUid, "alice", UserRoleEnum.ADMIN) + val b = makeUser(secondaryUid, "bob", UserRoleEnum.REGULAR) + userDao.insert(a) + userDao.insert(b) + + val listed = resource.list().asScala + val alice = listed.find(_.uid == primaryUid) + val bob = listed.find(_.uid == secondaryUid) + + alice.map(u => (u.name, u.email, u.role)) shouldBe Some( + ("alice", a.getEmail, UserRoleEnum.ADMIN) + ) + bob.map(u => (u.name, u.email, u.role)) shouldBe Some(("bob", b.getEmail, UserRoleEnum.REGULAR)) + } + + it should "not return a user that has not been seeded" in { + resource.list().asScala.exists(_.uid == primaryUid) shouldBe false + } + + // ─── addUser ──────────────────────────────────────────────────────────── + + "addUser" should "persist a new INACTIVE user" in { + val before = userDao.fetchByRole(UserRoleEnum.INACTIVE).size() + + resource.addUser() + + val after = userDao.fetchByRole(UserRoleEnum.INACTIVE) + after.size() shouldBe before + 1 + // The newly added user has a generated non-empty name and no password left blank. + after.asScala.exists(u => u.getName.startsWith("User") && u.getPassword != null) shouldBe true + } + + // ─── updateUser ───────────────────────────────────────────────────────── + + "updateUser" should "update a user's editable fields and round-trip via a re-read" in { + val user = makeUser(primaryUid, "original", UserRoleEnum.REGULAR) + userDao.insert(user) + + val edit = new User + edit.setUid(primaryUid) + edit.setName("renamed") + edit.setEmail(user.getEmail) // unchanged email → no conflict + edit.setRole(UserRoleEnum.REGULAR) // unchanged role → no e-mail side effect + edit.setComment("a new comment") + resource.updateUser(edit) + + val reread = userDao.fetchOneByUid(primaryUid) + reread.getName shouldBe "renamed" + reread.getComment shouldBe "a new comment" + reread.getRole shouldBe UserRoleEnum.REGULAR + } + + it should "reject an update whose email already belongs to another user" in { + val alice = makeUser(primaryUid, "alice", UserRoleEnum.REGULAR) + val bob = makeUser(secondaryUid, "bob", UserRoleEnum.REGULAR) + userDao.insert(alice) + userDao.insert(bob) + + val edit = new User + edit.setUid(secondaryUid) // updating bob… + edit.setName("bob") + edit.setEmail(alice.getEmail) // …to alice's email → conflict + edit.setRole(UserRoleEnum.REGULAR) + + a[WebApplicationException] should be thrownBy resource.updateUser(edit) + } + + // ─── getCreatedWorkflow ─────────────────────────────────────────────────── + + "getCreatedWorkflow" should "return an empty list for a user with no created workflows" in { + userDao.insert(makeUser(primaryUid, "creator")) + resource.getCreatedWorkflow(primaryUid) shouldBe empty + } + + it should "return the workflows the user created" in { + userDao.insert(makeUser(primaryUid, "creator")) + val workflow = seedWorkflow() + val ownership = new WorkflowOfUser + ownership.setUid(primaryUid) + ownership.setWid(testWid) + workflowOfUserDao.insert(ownership) + + val created = resource.getCreatedWorkflow(primaryUid) + created.map(_.workflowId) shouldBe List(Integer.valueOf(testWid)) + created.head.workflowName shouldBe workflow.getName + } + + // ─── getAccessedWorkflow ────────────────────────────────────────────────── + + "getAccessedWorkflow" should "return an empty list for a user with no workflow access" in { + userDao.insert(makeUser(primaryUid, "viewer")) + resource.getAccessedWorkflow(primaryUid).asScala shouldBe empty + } + + it should "return the workflow ids the user can access" in { + userDao.insert(makeUser(primaryUid, "viewer")) + seedWorkflow() + val access = new WorkflowUserAccess + access.setUid(primaryUid) + access.setWid(testWid) + access.setPrivilege(PrivilegeEnum.READ) + workflowUserAccessDao.insert(access) + + resource.getAccessedWorkflow(primaryUid).asScala should contain(Integer.valueOf(testWid)) + } + + // ─── getUserQuota ───────────────────────────────────────────────────────── + + "getUserQuota" should "return an empty array for a user with no executions" in { + userDao.insert(makeUser(primaryUid, "quota_user")) + resource.getUserQuota(primaryUid) shouldBe empty + } + + it should "return one quota entry per execution the user owns" in { + userDao.insert(makeUser(primaryUid, "quota_user")) + val execution = seedExecution(primaryUid) + + val quota = resource.getUserQuota(primaryUid) + quota should have length 1 + quota.head.eid shouldBe execution.getEid + quota.head.workflowId shouldBe Integer.valueOf(testWid) + // No operator_port/operator rows seeded → result and log sizes are zero. + quota.head.resultBytes shouldBe 0L + quota.head.logBytes shouldBe 0L + } + + // ─── deleteCollection ───────────────────────────────────────────────────── + + "deleteCollection" should "delete the target execution row" in { + userDao.insert(makeUser(primaryUid, "collection_user")) + val execution = seedExecution(primaryUid) + workflowExecutionsDao.fetchOneByEid(execution.getEid) should not be null + + resource.deleteCollection(execution.getEid) + + workflowExecutionsDao.fetchOneByEid(execution.getEid) shouldBe null + } +}
