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-7652-95b2e5c10688a9319aef73073bae584615d03a30 in repository https://gitbox.apache.org/repos/asf/texera.git
commit 938342afb31a8b51faeec75e5d98116a694b11e2 Author: Meng Wang <[email protected]> AuthorDate: Fri Aug 14 06:04:06 2026 +0000 test(amber): add specs for GuestAuthFilter and UserResource (#7652) ### What changes were proposed in this PR? Adds specs for two amber classes that had none. No production code was changed. **`GuestAuthFilterSpec`** (+8 tests) — pure, no database. The filter is built through its `Builder` with a small test authorizer, run over a stubbed `ContainerRequestContext`, and the `SecurityContext` it installs is captured and asserted: - `getUserPrincipal` is a `SessionUser` wrapping `GuestAuthFilter.GUEST`; - `isUserInRole` delegates to the authorizer — both an allowed and a denied role; - `isSecure` inherits from the incoming context — secure, insecure, and the **null** incoming context the code guards for; - `getAuthenticationScheme` reflects the scheme `filter()` authenticates with. Plus the `GUEST` preset's fields and `Builder` returning a fresh filter each time. **`UserResourceSpec`** (+7 tests) — jOOQ against embedded Postgres via `MockTexeraDB`: - `isJoiningReasonRequired` — true while unset, false once stored, and a 404 `WebApplicationException` for an unknown uid; - `updateJoiningReason` — persists affiliation/reason and flips the prompt off, trims both values, and defaults a null affiliation to an empty string. Two branches the issue does not mention are covered as well, since they are the guard `updateJoiningReason` opens with: a blank/whitespace reason and a null reason each raise 400, and the blank case is asserted to leave the stored user untouched. ### Any related issues, documentation, discussions? Closes #7650 ### How was this PR tested? Unit tests, run locally (the `UserResource` half against embedded Postgres). All pass, and the failure path was verified by breaking an assertion to confirm the suite goes red: ``` sbt "WorkflowExecutionService/testOnly *GuestAuthFilterSpec *UserResourceSpec" # Tests: succeeded 33, failed 0 sbt "WorkflowExecutionService/Test/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]) --- .../texera/web/auth/GuestAuthFilterSpec.scala | 112 +++++++++++++++++ .../resource/dashboard/user/UserResourceSpec.scala | 138 +++++++++++++++++++++ 2 files changed, 250 insertions(+) diff --git a/amber/src/test/scala/org/apache/texera/web/auth/GuestAuthFilterSpec.scala b/amber/src/test/scala/org/apache/texera/web/auth/GuestAuthFilterSpec.scala new file mode 100644 index 0000000000..5dfacbf9e5 --- /dev/null +++ b/amber/src/test/scala/org/apache/texera/web/auth/GuestAuthFilterSpec.scala @@ -0,0 +1,112 @@ +/* + * 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.auth + +import io.dropwizard.auth.Authorizer +import org.apache.texera.auth.SessionUser +import org.apache.texera.dao.jooq.generated.enums.UserRoleEnum +import org.scalamock.scalatest.MockFactory +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +import javax.ws.rs.container.ContainerRequestContext +import javax.ws.rs.core.SecurityContext + +/** + * Covers the guest identity [[GuestAuthFilter]] installs on an unauthenticated request. + * The filter is pure — it only rewrites the request's SecurityContext — so no database or + * Jersey runtime is involved here. + */ +class GuestAuthFilterSpec extends AnyFlatSpec with Matchers with MockFactory { + + /** Authorizes exactly the "REGULAR" role, so both arms of isUserInRole are observable. */ + private val roleAuthorizer: Authorizer[SessionUser] = + (_: SessionUser, role: String) => role == UserRoleEnum.REGULAR.getLiteral + + /** + * Runs the filter over a request whose incoming SecurityContext is `incoming` and returns the + * SecurityContext the filter installed in its place. + */ + private def installedContext(incoming: SecurityContext): SecurityContext = { + val filter = new GuestAuthFilter.Builder().setAuthorizer(roleAuthorizer).buildAuthFilter() + var installed: SecurityContext = null + + val requestContext = stub[ContainerRequestContext] + (() => requestContext.getSecurityContext).when().returns(incoming) + (requestContext.setSecurityContext _) + .when(*) + .onCall((ctx: SecurityContext) => installed = ctx) + + filter.filter(requestContext) + installed should not be null + installed + } + + private def secureContext(secure: Boolean): SecurityContext = { + val ctx = stub[SecurityContext] + (() => ctx.isSecure).when().returns(secure) + ctx + } + + "GuestAuthFilter.GUEST" should "be a REGULAR user named guest" in { + GuestAuthFilter.GUEST.getName shouldBe "guest" + GuestAuthFilter.GUEST.getRole shouldBe UserRoleEnum.REGULAR + } + + "GuestAuthFilter.Builder" should "build a fresh filter each time" in { + val builder = new GuestAuthFilter.Builder().setAuthorizer(roleAuthorizer) + val first = builder.buildAuthFilter() + val second = builder.buildAuthFilter() + + first should not be theSameInstanceAs(second) + } + + "the installed SecurityContext" should "carry the guest user as its principal" in { + val principal = installedContext(secureContext(secure = false)).getUserPrincipal + + principal shouldBe a[SessionUser] + principal.asInstanceOf[SessionUser].getUser shouldBe GuestAuthFilter.GUEST + } + + it should "delegate isUserInRole to the authorizer" in { + val context = installedContext(secureContext(secure = false)) + + context.isUserInRole(UserRoleEnum.REGULAR.getLiteral) shouldBe true + context.isUserInRole(UserRoleEnum.ADMIN.getLiteral) shouldBe false + } + + it should "inherit isSecure from a secure incoming context" in { + installedContext(secureContext(secure = true)).isSecure shouldBe true + } + + it should "inherit isSecure from an insecure incoming context" in { + installedContext(secureContext(secure = false)).isSecure shouldBe false + } + + it should "report an insecure request when there is no incoming context" in { + // filter() guards with `securityContext != null` before reading isSecure + installedContext(null).isSecure shouldBe false + } + + it should "expose the scheme the filter authenticated with" in { + // GuestAuthFilter.filter passes an empty scheme to authenticate() + installedContext(secureContext(secure = false)).getAuthenticationScheme shouldBe "" + } +} diff --git a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/UserResourceSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/UserResourceSpec.scala new file mode 100644 index 0000000000..90258cc499 --- /dev/null +++ b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/UserResourceSpec.scala @@ -0,0 +1,138 @@ +/* + * 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.user + +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.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach} + +import javax.ws.rs.WebApplicationException +import javax.ws.rs.core.Response + +/** + * Covers the joining-reason endpoints against embedded Postgres: whether a user still has to be + * prompted, and that submitting a reason persists and flips that answer. + */ +class UserResourceSpec + extends AnyFlatSpec + with Matchers + with BeforeAndAfterAll + with BeforeAndAfterEach + with MockTexeraDB { + + private val testUid = 8000 + scala.util.Random.nextInt(1000) + private val unknownUid = testUid + 1 + + private var userDao: UserDao = _ + private var resource: UserResource = _ + + override protected def beforeAll(): Unit = initializeDBAndReplaceDSLContext() + + override protected def afterAll(): Unit = { + cleanup() + shutdownDB() + } + + override protected def beforeEach(): Unit = { + userDao = new UserDao(getDSLContext.configuration()) + resource = new UserResource() + cleanup() + userDao.insert(seedUser()) + } + + override protected def afterEach(): Unit = cleanup() + + private def cleanup(): Unit = + getDSLContext.deleteFrom(USER).where(USER.UID.in(testUid, unknownUid)).execute() + + /** A freshly registered user: joining reason not yet supplied. */ + private def seedUser(): User = { + val user = new User + user.setUid(testUid) + user.setName(s"joining_reason_user_$testUid") + user.setEmail(s"[email protected]") + user.setRole(UserRoleEnum.REGULAR) + user + } + + "isJoiningReasonRequired" should "be true while the user has not supplied one" in { + resource.isJoiningReasonRequired(testUid) shouldBe true + } + + it should "be false once a joining reason is stored" in { + val user = userDao.fetchOneByUid(testUid) + user.setJoiningReason("research") + userDao.update(user) + + resource.isJoiningReasonRequired(testUid) shouldBe false + } + + it should "report 404 for a user that does not exist" in { + val ex = intercept[WebApplicationException](resource.isJoiningReasonRequired(unknownUid)) + ex.getResponse.getStatus shouldBe Response.Status.NOT_FOUND.getStatusCode + } + + "updateJoiningReason" should "persist the affiliation and reason, and stop the prompt" in { + resource.updateJoiningReason(RegistrationUpdateRequest(testUid, "UC Irvine", "research")) + + val stored = userDao.fetchOneByUid(testUid) + stored.getAffiliation shouldBe "UC Irvine" + stored.getJoiningReason shouldBe "research" + resource.isJoiningReasonRequired(testUid) shouldBe false + } + + it should "trim the submitted values" in { + resource.updateJoiningReason( + RegistrationUpdateRequest(testUid, " UC Irvine ", " research ") + ) + + val stored = userDao.fetchOneByUid(testUid) + stored.getAffiliation shouldBe "UC Irvine" + stored.getJoiningReason shouldBe "research" + } + + it should "default a null affiliation to an empty string" in { + resource.updateJoiningReason(RegistrationUpdateRequest(testUid, null, "research")) + + userDao.fetchOneByUid(testUid).getAffiliation shouldBe "" + } + + it should "reject a blank reason with 400 and leave the user untouched" in { + val ex = intercept[WebApplicationException]( + resource.updateJoiningReason(RegistrationUpdateRequest(testUid, "UC Irvine", " ")) + ) + + ex.getResponse.getStatus shouldBe Response.Status.BAD_REQUEST.getStatusCode + userDao.fetchOneByUid(testUid).getJoiningReason shouldBe null + } + + it should "reject a null reason with 400" in { + val ex = intercept[WebApplicationException]( + resource.updateJoiningReason(RegistrationUpdateRequest(testUid, "UC Irvine", null)) + ) + + ex.getResponse.getStatus shouldBe Response.Status.BAD_REQUEST.getStatusCode + } +}
