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-7180-0f7a42610e124e3860e74780ed62bc59ec5afe67 in repository https://gitbox.apache.org/repos/asf/texera.git
commit 348fa884442990307b842fd7c0dd6ca005dd320c Author: Meng Wang <[email protected]> AuthorDate: Fri Jul 31 15:23:50 2026 -0700 test(amber): add unit test coverage for AuthResource (#7180) ### What changes were proposed in this PR? Adds a spec for `AuthResource` (`amber/src/main/scala/org/apache/texera/web/resource/auth/AuthResource.scala`), which had no test coverage. Authentication is against the user table (jOOQ) and JWTs are issued via `JwtAuth`, so the spec runs on embedded Postgres via `MockTexeraDB` (the established unit pattern, mirroring `AdminExecutionResourceSpec` / `WorkflowExecutionsResourceSpec`). No production code was changed. 15 tests: - `retrieveUserByUsernameAndPassword` — returns the user for correct credentials; `None` for a wrong password, an unknown user, and a null username/password. - `login` — issues a JWT whose subject (decoded through `JwtAuth.jwtConsumer`) is the username for valid credentials, and rejects bad credentials with `NotAuthorizedException`. - `register` — persists a `RESTRICTED` user with a hashed password (verified with the same `StrongPasswordEncryptor`) and issues a token, and rejects each guard branch (`empty username` / `empty email` / `malformed email` / `empty password` / `duplicate username` / `duplicate email`) with `NotAcceptableException`. - `createAdminUser` — inserts the configured admin with the `ADMIN` role and a hashed password, and is idempotent when an admin already exists. Tokens are verified by decoding through `JwtAuth.jwtConsumer` (subject/claims) rather than asserting raw strings, so there are no time-sensitive assertions. ### Any related issues, documentation, discussions? Closes #7164 ### How was this PR tested? Unit tests, run locally against embedded Postgres (`MockTexeraDB`). All pass, and the failure path was verified by breaking an assertion to confirm the suite goes red: ``` sbt "WorkflowExecutionService/testOnly *AuthResourceSpec" # Tests: succeeded 15, failed 0 sbt "WorkflowExecutionService/scalafmtCheck" # clean sbt "WorkflowExecutionService/Test/scalafix --check" # clean ``` ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Opus 4.8 [1M context]) --- .../web/resource/auth/AuthResourceSpec.scala | 209 +++++++++++++++++++++ 1 file changed, 209 insertions(+) diff --git a/amber/src/test/scala/org/apache/texera/web/resource/auth/AuthResourceSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/auth/AuthResourceSpec.scala new file mode 100644 index 0000000000..c832011baf --- /dev/null +++ b/amber/src/test/scala/org/apache/texera/web/resource/auth/AuthResourceSpec.scala @@ -0,0 +1,209 @@ +/* + * 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.auth + +import org.apache.texera.auth.JwtAuth +import org.apache.texera.common.config.UserSystemConfig +import org.apache.texera.dao.MockTexeraDB +import org.apache.texera.dao.jooq.generated.Tables.USER +import org.apache.texera.dao.jooq.generated.enums.UserRoleEnum +import org.apache.texera.dao.jooq.generated.tables.daos.UserDao +import org.apache.texera.dao.jooq.generated.tables.pojos.User +import org.apache.texera.web.model.http.request.auth.{UserLoginRequest, UserRegistrationRequest} +import org.jasypt.util.password.StrongPasswordEncryptor +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach} + +import java.util.UUID +import javax.ws.rs.{NotAcceptableException, NotAuthorizedException} + +class AuthResourceSpec + extends AnyFlatSpec + with Matchers + with BeforeAndAfterAll + with BeforeAndAfterEach + with MockTexeraDB { + + // Random suffix keeps this suite's rows from colliding with data other suites may + // have left in the shared (singleton) embedded DB; suites run sequentially. + private val runId = UUID.randomUUID().toString.substring(0, 8) + // jasypt verifies a plain password against a stored hash; a fresh instance works fine. + private val encryptor = new StrongPasswordEncryptor() + + private var userDao: UserDao = _ + private var resource: AuthResource = _ + + private def uname(tag: String): String = s"authspec_${tag}_$runId" + + private def uemail(tag: String): String = s"authspec_${tag}[email protected]" + + override protected def beforeAll(): Unit = initializeDBAndReplaceDSLContext() + + override protected def afterAll(): Unit = shutdownDB() + + override protected def beforeEach(): Unit = { + userDao = new UserDao(getDSLContext.configuration()) + resource = new AuthResource() + cleanup() + } + + override protected def afterEach(): Unit = cleanup() + + private def cleanup(): Unit = { + // startsWith escapes SQL LIKE wildcards, so the literal "authspec_" prefix is matched exactly. + getDSLContext.deleteFrom(USER).where(USER.NAME.startsWith("authspec_")).execute() + // createAdminUser() seeds the configured admin — remove it too so the test starts clean. + getDSLContext.deleteFrom(USER).where(USER.NAME.eq(UserSystemConfig.adminUsername)).execute() + } + + private def seedUser( + name: String, + password: String, + role: UserRoleEnum = UserRoleEnum.REGULAR + ): User = { + val user = new User + user.setName(name) + user.setEmail(s"[email protected]") + user.setPassword(encryptor.encryptPassword(password)) + user.setRole(role) + userDao.insert(user) + user + } + + private def subjectOf(token: String): String = + JwtAuth.jwtConsumer.processToClaims(token).getSubject + + // ─── retrieveUserByUsernameAndPassword ────────────────────────────────────── + + "retrieveUserByUsernameAndPassword" should "return the user for correct credentials" in { + seedUser(uname("ok"), "secret") + AuthResource + .retrieveUserByUsernameAndPassword(uname("ok"), "secret") + .map(_.getName) shouldBe Some(uname("ok")) + } + + it should "return None for a wrong password" in { + seedUser(uname("wp"), "secret") + AuthResource.retrieveUserByUsernameAndPassword(uname("wp"), "wrong") shouldBe None + } + + it should "return None for an unknown user" in { + AuthResource.retrieveUserByUsernameAndPassword(uname("nobody"), "secret") shouldBe None + } + + it should "return None when the username or password is null" in { + seedUser(uname("nul"), "secret") + AuthResource.retrieveUserByUsernameAndPassword(null, "secret") shouldBe None + AuthResource.retrieveUserByUsernameAndPassword(uname("nul"), null) shouldBe None + } + + // ─── login ────────────────────────────────────────────────────────────────── + + "login" should "issue a JWT whose subject is the username for valid credentials" in { + seedUser(uname("login"), "pw") + val response = resource.login(UserLoginRequest(uname("login"), "pw")) + response.accessToken should not be empty + subjectOf(response.accessToken) shouldBe uname("login") + } + + it should "reject invalid credentials with NotAuthorizedException" in { + seedUser(uname("bad"), "pw") + assertThrows[NotAuthorizedException](resource.login(UserLoginRequest(uname("bad"), "nope"))) + } + + // ─── register ───────────────────────────────────────────────────────────── + + "register" should "persist a RESTRICTED user with a hashed password and issue a token" in { + val response = resource.register(UserRegistrationRequest(uname("reg"), uemail("reg"), "pw")) + + subjectOf(response.accessToken) shouldBe uname("reg") + val persisted = userDao.fetchByName(uname("reg")) + persisted.size() shouldBe 1 + val stored = persisted.get(0) + stored.getRole shouldBe UserRoleEnum.RESTRICTED + stored.getEmail shouldBe uemail("reg") + // stored hashed, not in plain text, but verifies against the plain password + stored.getPassword should not be "pw" + encryptor.checkPassword("pw", stored.getPassword) shouldBe true + } + + it should "reject an empty username" in { + val ex = intercept[NotAcceptableException]( + resource.register(UserRegistrationRequest(" ", uemail("eu"), "pw")) + ) + ex.getMessage should include("Username") + } + + it should "reject an empty email" in { + val ex = intercept[NotAcceptableException]( + resource.register(UserRegistrationRequest(uname("ee"), " ", "pw")) + ) + ex.getMessage should include("Email cannot be empty") + } + + it should "reject a malformed email" in { + val ex = + intercept[NotAcceptableException]( + resource.register(UserRegistrationRequest(uname("mf"), "not-an-email", "pw")) + ) + ex.getMessage should include("Email format is invalid") + } + + it should "reject an empty password" in { + val ex = intercept[NotAcceptableException]( + resource.register(UserRegistrationRequest(uname("ep"), uemail("ep"), "")) + ) + ex.getMessage should include("Password") + } + + it should "reject a duplicate username" in { + seedUser(uname("dupu"), "pw") + val ex = intercept[NotAcceptableException]( + resource.register(UserRegistrationRequest(uname("dupu"), uemail("dupu_other"), "pw2")) + ) + ex.getMessage should include("Username exists") + } + + it should "reject a duplicate email" in { + val existing = seedUser(uname("dupeowner"), "pw") + val ex = intercept[NotAcceptableException]( + resource.register(UserRegistrationRequest(uname("dupenew"), existing.getEmail, "pw2")) + ) + ex.getMessage should include("Email exists") + } + + // ─── createAdminUser ──────────────────────────────────────────────────────── + + "createAdminUser" should "insert the configured admin with the ADMIN role and a hashed password" in { + AuthResource.createAdminUser() + + val admins = userDao.fetchByName(UserSystemConfig.adminUsername) + admins.size() shouldBe 1 + admins.get(0).getRole shouldBe UserRoleEnum.ADMIN + encryptor.checkPassword(UserSystemConfig.adminPassword, admins.get(0).getPassword) shouldBe true + } + + it should "not create a second admin when one already exists" in { + AuthResource.createAdminUser() + AuthResource.createAdminUser() + userDao.fetchByName(UserSystemConfig.adminUsername).size() shouldBe 1 + } +}
