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-7853-be65bf8564a32eea4196aa0190a04f73884bfe70 in repository https://gitbox.apache.org/repos/asf/texera.git
commit f01f63d95ab7d16a9adc2c4126b1f467c115b102 Author: yangzhang75 <[email protected]> AuthorDate: Sat Oct 10 08:48:36 2026 +0000 feat(workflow): freeze a pinned version as the public copy (#7853) ### What changes were proposed in this PR? A public workflow follows the author's latest content, as publishing has always done: every save reaches the Hub immediately. This adds the other state — the author pins the version they have now, and what the public sees stops moving until they pin again. - **`WorkflowPublishService`** owns the two states and the moves between them: `publish`, `pinLatest`, `unpin`, `unpublish`, `statusOf`. `is_public` stays the on/off switch; `published_content` is the pin, NULL while following. - **A pin freezes everything on public show** — the graph, the title, the description and the view the workflow opens in — because a copy that froze only its graph would still advertise a title nobody published. The default view matters because a form's definition rides inside the content: serving the author's live preference over a frozen graph would open a form on a copy that has none. - **A pin is one statement.** Each column is copied from its own row (`published_content = content` and so on) rather than from a workflow read a moment earlier, so there is no window in which the author's next save lands and the pin freezes the version before it -- which would have left them reading "you have unpublished changes" the instant after they pinned. - **Three endpoints**: | Endpoint | Does | |---|---| | `POST /workflow/pin/{wid}` | freezes the author's current version as the public copy; called again, moves the pin forward | | `DELETE /workflow/pin/{wid}` | drops the pin, back to following the latest | | `GET /workflow/publish-status/{wid}` | published, pinned, and whether a pin is holding edits back | - **Publishing and unpublishing move through the same service**, so unpublishing drops the pin rather than leaving a private workflow carrying one. Re-publishing starts in the following state: coming back should not silently put old public content on show again. - **One writer for the publish columns.** Two paths could still write them by accident, because each read a whole row (or took a whole `Workflow` from the client) and wrote it all back: - a **rename or description edit** wrote every column from a row it had read a moment earlier, so anything landing in between was silently rewritten to what that read had seen: a publish undone, a pin dropped, or — worst — a workflow the author had just unpublished put back on public show under its frozen copy. Each now writes only its own column. - **creating** a workflow clears the publish columns, so a request body cannot seed a published copy of its own choosing. A plain **save** was the third, and #8498 has since narrowed it on main for its own reasons, so nothing is needed here — the tests below still pin the behaviour from this feature's side. Together with `/set-default-view`, which already wrote only its column, every endpoint now writes what it owns and nothing else: outside creation, `WorkflowPublishService` is the only writer of `is_public` and the frozen copy. **Nothing on screen changes.** No read path consults the pinned copy yet and there is no UI: every workflow stays in the following state it is in today. The endpoints answer, and nothing calls them. ### Any related issues, documentation, discussions? Closes #7938 Part of #7828. Design discussion: #7128. Schema: #7851. ### How was this PR tested? 32 cases in `WorkflowPublishSpec`: - the state machine — follow → pin → re-pin → unpin → unpublish → re-publish, and that a re-publish does not resurrect the previous pin; - what a pin freezes — the name, the description and the default view alongside the content, and that each of them moving afterwards is reported as an unpublished change; - the guards — no write access, not published, and a workflow that does not exist; - that a create cannot inject publish columns, and that a save, a collaborator's save and a rename all leave the publish state alone; - that a publish or an unpublish landing while a rename is in flight survives it, and that a save landing while a pin is in flight is the version that gets pinned. These three drive the interleaving off the statement itself rather than off a thread, so the ordering is the same on every run; - the ordinary states that are easy to forget: content that is not valid JSON is reported as drift rather than throwing, a workflow with no description pins like any other, unpinning one that is already following is a no-op rather than an error, and publishing one that is already public leaves its pin alone. `hasUnpublishedChanges` compares the two copies as parsed JSON rather than as strings — the same graph can come back with its keys in another order, and reporting that as an edit is an alarm the author cannot clear. One case covers it. The narrowed write paths were checked by mutation: restoring `is_public` to the save statement turns three cases red, one of them on the CHECK constraint itself, and restoring the read-modify-write to the rename path turns its two interleaving cases red — one showing the publish reverted, the other showing the unpublished workflow back on public show. Restoring the read-then-write to the pin turns the third red, showing the pin frozen one save behind. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (claude-opus-5) Co-authored-by: Claude Opus 5 <[email protected]> --- .../user/workflow/WorkflowPublishService.scala | 213 +++++++ .../dashboard/user/workflow/WorkflowResource.scala | 100 +++- .../user/workflow/WorkflowPublishSpec.scala | 619 +++++++++++++++++++++ 3 files changed, 916 insertions(+), 16 deletions(-) diff --git a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowPublishService.scala b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowPublishService.scala new file mode 100644 index 0000000000..48ad3c85f3 --- /dev/null +++ b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowPublishService.scala @@ -0,0 +1,213 @@ +/* + * 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.workflow + +import com.typesafe.scalalogging.LazyLogging +import org.apache.texera.amber.util.JSONUtils.objectMapper +import org.apache.texera.dao.SqlServer +import org.apache.texera.dao.jooq.generated.Tables.WORKFLOW +import org.apache.texera.dao.jooq.generated.enums.DefaultViewEnum +import org.apache.texera.dao.jooq.generated.tables.daos.WorkflowDao +import org.apache.texera.dao.jooq.generated.tables.pojos.Workflow +import org.jooq.DSLContext + +import javax.ws.rs.{BadRequestException, NotFoundException} +import scala.util.Try + +/** + * Version pinning for public workflows. + * + * A public workflow follows the author's latest, as publishing has always done, until the author + * pins the version they have now: the public then keeps seeing that frozen copy while the author's + * later edits stay in the workflow's own columns until they pin again. + * + * `is_public` stays the on/off switch; `published_content` is the pin, NULL while following. A pin + * freezes everything on public show -- the graph, the title, the description and the view it opens + * in -- because a copy that froze only its graph would still advertise a title nobody published. + * + * Not to be confused with sharing: a user granted access always tracks the author's latest, pin or + * no pin. Only viewers who arrive because the workflow is public are held at the frozen copy. + */ +object WorkflowPublishService extends LazyLogging { + + private def context: DSLContext = SqlServer.getInstance().createDSLContext() + + /** + * What the share dialog asks about: whether the workflow is public, whether a version is pinned, + * and whether that pin is holding edits back -- the last is true when pinning again would publish + * something, and always false while following. + */ + case class PublishStatus( + isPublished: Boolean, + isPinned: Boolean, + hasUnpublishedChanges: Boolean + ) + + /** + * Whether two workflow contents describe the same graph. Compared as parsed trees, because the + * two blobs travel by different routes and the same graph can come back with its whitespace or + * key order rearranged -- reporting that as an edit the public cannot see would be an alarm the + * author cannot clear. + */ + private def sameContent(a: String, b: String): Boolean = + a == b || Try(objectMapper.readTree(a) == objectMapper.readTree(b)).getOrElse(false) + + /** The workflow, or a 404. */ + private def requireWorkflow(wid: Integer): Workflow = + Option(new WorkflowDao(context.configuration).fetchOneByWid(wid)) + .getOrElse(throw new NotFoundException(s"Workflow $wid not found")) + + /** + * Turns publishing on, and touches nothing else. A workflow coming back from private is + * following the author's latest, because unpublishing always drops the pin: coming back should + * not silently put old public content back on show. Called on a workflow that is already public + * it changes nothing, pin included. + */ + def publish(wid: Integer): PublishStatus = { + val updated = context + .update(WORKFLOW) + .set(WORKFLOW.IS_PUBLIC, java.lang.Boolean.TRUE) + .where(WORKFLOW.WID.eq(wid)) + .execute() + if (updated == 0) { + throw new NotFoundException(s"Workflow $wid not found") + } + // Deliberately not "following latest": this turns publishing on and touches nothing else, so a + // workflow that somehow arrives here already pinned stays pinned. + logger.info(s"Workflow $wid published") + statusOf(wid) + } + + /** + * Freezes the author's current copy as the public one. The title, the description and the + * default view freeze with the graph: they are as public as it is, and the database refuses a + * pinned copy that carries only part of itself. The view matters because a form's definition + * rides inside the content -- serving the live preference over a frozen graph would open a form + * on a copy that has none. + * + * Each column is copied from its own row rather than from a workflow read a moment earlier, so + * there is no window in which the author's next save lands and the pin freezes the version + * before it -- which would leave them looking at "you have unpublished changes" the instant + * after they pinned. + * + * Publishing is a condition, not something this writes: asking to be published and pinning are + * two statements, so a `/private` landing between them would otherwise be undone here, putting a + * workflow the author had just taken down back on show under a frozen copy. + * + * @return how many rows it matched -- zero for a workflow that is missing or not published. + */ + private def writePin(wid: Integer): Int = + context + .update(WORKFLOW) + .set(WORKFLOW.PUBLISHED_CONTENT, WORKFLOW.CONTENT) + .set(WORKFLOW.PUBLISHED_NAME, WORKFLOW.NAME) + .set(WORKFLOW.PUBLISHED_DESCRIPTION, WORKFLOW.DESCRIPTION) + .set(WORKFLOW.PUBLISHED_DEFAULT_VIEW, WORKFLOW.DEFAULT_VIEW) + .where(WORKFLOW.WID.eq(wid).and(WORKFLOW.IS_PUBLIC.isTrue)) + .execute() + + /** + * Clears the pinned copy in one statement, optionally unpublishing too: the constraint accepts a + * row only with every frozen column set on a public workflow, or with every one of them NULL, so + * clearing them one at a time -- or clearing them after `is_public` -- would be rejected. + * + * `published_version_id` is named by that constraint as well but is not touched here, for the + * same reason [[writePin]] does not set it: nothing writes it yet, so it is NULL on every row. + * + * Dropping a pin is offered on a published workflow only, so that condition travels in the same + * statement: a `/private` landing between the guard and here cannot be answered with "unpinned, + * following latest" about a workflow that is now down. Unpublishing carries no such condition -- + * taking down what is already down is a no-op, not an error. + * + * @return how many rows it matched, so a missing workflow is distinguishable from a done one. + */ + private def clearPin(wid: Integer, alsoUnpublish: Boolean = false): Int = { + val cleared = context + .update(WORKFLOW) + .set(WORKFLOW.PUBLISHED_CONTENT, null.asInstanceOf[String]) + .set(WORKFLOW.PUBLISHED_NAME, null.asInstanceOf[String]) + .set(WORKFLOW.PUBLISHED_DESCRIPTION, null.asInstanceOf[String]) + .set(WORKFLOW.PUBLISHED_DEFAULT_VIEW, null.asInstanceOf[DefaultViewEnum]) + val statement = + if (alsoUnpublish) cleared.set(WORKFLOW.IS_PUBLIC, java.lang.Boolean.FALSE) else cleared + val scope = + if (alsoUnpublish) WORKFLOW.WID.eq(wid) + else WORKFLOW.WID.eq(wid).and(WORKFLOW.IS_PUBLIC.isTrue) + statement.where(scope).execute() + } + + /** + * Pins the current content as the public copy. Moving a pin forward is the same operation. + * + * Only a published workflow can be pinned, and that is decided by the write itself. Nothing + * matching means one of two things, and the caller is told which. + */ + def pinLatest(wid: Integer): PublishStatus = { + if (writePin(wid) == 0) { + requireWorkflow(wid) // 404s if it is gone + throw new BadRequestException(s"Workflow $wid is not published") + } + logger.info(s"Workflow $wid pinned to its latest content") + statusOf(wid) + } + + /** + * Drops the pin, so the public follows the author's latest again. The workflow stays public. + */ + def unpin(wid: Integer): PublishStatus = { + if (clearPin(wid) == 0) { + requireWorkflow(wid) // 404s if it is gone + throw new BadRequestException(s"Workflow $wid is not published") + } + logger.info(s"Workflow $wid unpinned, following latest") + statusOf(wid) + } + + /** + * Turns publishing off and drops the pin. Publishing again starts in the following state; the + * previous frozen copy is deliberately not remembered, so an unpublish/re-publish cycle cannot + * silently restore old public content. + */ + def unpublish(wid: Integer): Unit = { + if (clearPin(wid, alsoUnpublish = true) == 0) { + throw new NotFoundException(s"Workflow $wid not found") + } + logger.info(s"Workflow $wid unpublished") + } + + /** Whether a version is pinned, and whether it is holding edits back. */ + def statusOf(wid: Integer): PublishStatus = { + val workflow = requireWorkflow(wid) + val pinned = workflow.getPublishedContent != null + PublishStatus( + isPublished = workflow.getIsPublic, + isPinned = pinned, + // Literally "what the public sees is not what you have". Every field the pin freezes counts: + // a rename the public cannot see is held back exactly as an edit to the graph is. Compared on + // values rather than on a version id, so that an edit and its undo report nothing held back. + hasUnpublishedChanges = pinned && ( + !sameContent(workflow.getPublishedContent, workflow.getContent) || + workflow.getPublishedName != workflow.getName || + workflow.getPublishedDescription != workflow.getDescription || + workflow.getPublishedDefaultView != workflow.getDefaultView + ) + ) + } +} diff --git a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowResource.scala b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowResource.scala index 38b2bb9dd2..bae2fa0047 100644 --- a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowResource.scala +++ b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowResource.scala @@ -35,13 +35,14 @@ import org.apache.texera.dao.jooq.generated.tables.daos.{ WorkflowUserAccessDao } import org.apache.texera.dao.jooq.generated.tables.pojos._ +import org.apache.texera.dao.jooq.generated.tables.records.WorkflowRecord import org.apache.texera.service.util.LargeBinaryManager import org.apache.texera.web.resource.dashboard.hub.EntityType import org.apache.texera.web.service.WarehouseReadGuard import org.apache.texera.web.resource.dashboard.hub.HubResource.recordCloneAction import org.apache.texera.web.resource.dashboard.user.workflow.WorkflowResource._ import org.jooq.impl.DSL.max -import org.jooq.{DSLContext, Record10, Result, SelectOnConditionStep} +import org.jooq.{DSLContext, Record10, Result, SelectOnConditionStep, TableField} import java.sql.Timestamp import java.util @@ -164,12 +165,21 @@ object WorkflowResource { workflow } - private def updateWorkflowField( - workflow: Workflow, + /** + * Writes one field of a workflow, and only that field. The endpoints take a whole `Workflow` + * from the client, of which exactly two things are used: which workflow, and the new value. + * + * Reading the row and writing the whole POJO back would carry every other column with it, so + * anything landing between the read and the write was silently rewritten to whatever the read had + * seen: a save reverted, a publish undone, or -- since a pin travels in those columns too -- a + * workflow the author had just unpublished put back on public show under its frozen copy. + */ + private def updateWorkflowField[T]( + wid: Integer, sessionUser: SessionUser, - updateFunction: Workflow => Unit + field: TableField[WorkflowRecord, T], + value: T ): Unit = { - val wid = workflow.getWid val user = sessionUser.getUser if ( @@ -178,9 +188,7 @@ object WorkflowResource { user.getUid ) ) { - val userWorkflow = workflowDao.fetchOneByWid(wid) - updateFunction(userWorkflow) - workflowDao.update(userWorkflow) + context.update(WORKFLOW).set(field, value).where(WORKFLOW.WID.eq(wid)).execute() } else { throw new ForbiddenException("No sufficient access privilege.") } @@ -653,7 +661,7 @@ class WorkflowResource extends LazyLogging { workflow: Workflow, @Auth sessionUser: SessionUser ): Unit = { - updateWorkflowField(workflow, sessionUser, _.setName(workflow.getName)) + updateWorkflowField(workflow.getWid, sessionUser, WORKFLOW.NAME, workflow.getName) } @POST @@ -665,7 +673,12 @@ class WorkflowResource extends LazyLogging { workflow: Workflow, @Auth sessionUser: SessionUser ): Unit = { - updateWorkflowField(workflow, sessionUser, _.setDescription(workflow.getDescription)) + updateWorkflowField( + workflow.getWid, + sessionUser, + WORKFLOW.DESCRIPTION, + workflow.getDescription + ) } @PUT @@ -675,9 +688,7 @@ class WorkflowResource extends LazyLogging { if (!WorkflowAccessResource.hasWriteAccess(wid, user.getUid)) { throw new ForbiddenException(s"You do not have permission to modify workflow $wid") } - val workflow: Workflow = workflowDao.fetchOneByWid(wid) - workflow.setIsPublic(true) - workflowDao.update(workflow) + WorkflowPublishService.publish(wid) } @PUT @@ -687,9 +698,66 @@ class WorkflowResource extends LazyLogging { if (!WorkflowAccessResource.hasWriteAccess(wid, user.getUid)) { throw new ForbiddenException(s"You do not have permission to modify workflow $wid") } - val workflow: Workflow = workflowDao.fetchOneByWid(wid) - workflow.setIsPublic(false) - workflowDao.update(workflow) + WorkflowPublishService.unpublish(wid) + } + + /** + * Pins the author's current version as the public copy, so later edits stop reaching the public. + * Also how a pin moves forward, which is the only way edits become public while one is in place. + */ + @POST + @Produces(Array(MediaType.APPLICATION_JSON)) + @RolesAllowed(Array("REGULAR", "ADMIN")) + @Path("/pin/{wid}") + def pinLatest( + @PathParam("wid") wid: Integer, + @Auth user: SessionUser + ): WorkflowPublishService.PublishStatus = { + requirePublishable(wid, user) + WorkflowPublishService.pinLatest(wid) + } + + /** + * Drops the pin, so the public follows the author's latest again. Guarded on write access, the + * same as pinning: whoever may pin may undo it. + */ + @DELETE + @Produces(Array(MediaType.APPLICATION_JSON)) + @RolesAllowed(Array("REGULAR", "ADMIN")) + @Path("/pin/{wid}") + def unpin( + @PathParam("wid") wid: Integer, + @Auth user: SessionUser + ): WorkflowPublishService.PublishStatus = { + requirePublishable(wid, user) + WorkflowPublishService.unpin(wid) + } + + /** What the share dialog's publish panel reads: published, pinned, and holding edits back. */ + @GET + @Produces(Array(MediaType.APPLICATION_JSON)) + @RolesAllowed(Array("REGULAR", "ADMIN")) + @Path("/publish-status/{wid}") + def getPublishStatus( + @PathParam("wid") wid: Integer, + @Auth user: SessionUser + ): WorkflowPublishService.PublishStatus = { + // Write access rather than read: whether edits are being held back is nobody else's business. + requireWriteAccess(wid, user) + WorkflowPublishService.statusOf(wid) + } + + private def requireWriteAccess(wid: Integer, user: SessionUser): Unit = + if (!WorkflowAccessResource.hasWriteAccess(wid, user.getUid)) { + throw new ForbiddenException(s"You do not have permission to modify workflow $wid") + } + + /** What the pin endpoints need: writable by this user, and published in the first place. */ + private def requirePublishable(wid: Integer, user: SessionUser): Unit = { + requireWriteAccess(wid, user) + if (!WorkflowAccessResource.isPublic(wid)) { + throw new BadRequestException(s"Workflow $wid is not published") + } } /** diff --git a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowPublishSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowPublishSpec.scala new file mode 100644 index 0000000000..7a40981fed --- /dev/null +++ b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowPublishSpec.scala @@ -0,0 +1,619 @@ +/* + * 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.workflow + +import org.apache.texera.auth.SessionUser +import org.apache.texera.dao.MockTexeraDB +import org.apache.texera.dao.jooq.generated.Tables.{WORKFLOW, WORKFLOW_USER_ACCESS} +import org.apache.texera.dao.jooq.generated.enums.{DefaultViewEnum, PrivilegeEnum, UserRoleEnum} +import org.apache.texera.dao.jooq.generated.tables.daos.{ + UserDao, + WorkflowDao, + WorkflowUserAccessDao +} +import org.apache.texera.dao.jooq.generated.tables.pojos.{User, Workflow, WorkflowUserAccess} +import org.scalatest.BeforeAndAfterAll +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers + +import org.jooq.{ExecuteContext, ExecuteListener} +import org.jooq.impl.{DefaultConfiguration, DefaultExecuteListenerProvider} + +import java.time.OffsetDateTime +import javax.ws.rs.{BadRequestException, ForbiddenException, NotFoundException} + +/** + * Covers the publish state a workflow can be in: following the author's latest content, as + * publishing has always done, or holding a pinned copy of the version the author froze. + * + * Only the state itself is covered here: nothing serves the pinned copy to a reader yet, so the + * assertions are about which copy each operation leaves stored. + */ +class WorkflowPublishSpec + extends AnyFlatSpec + with BeforeAndAfterAll + with Matchers + with MockTexeraDB { + + private val exampleCreationTime = OffsetDateTime.parse("2025-01-01T00:00:00Z") + + private def makeUser(uid: Int, name: String): User = { + val user = new User + user.setUid(Integer.valueOf(uid)) + user.setName(name) + user.setEmail(s"[email protected]") + user.setRole(UserRoleEnum.ADMIN) + user.setComment("test") + user.setAccountCreationTime(exampleCreationTime) + user + } + + /** The author. */ + private val owner = makeUser(1, "publish_owner") + + /** A stranger: no access of their own, so nothing about this workflow is theirs to change. */ + private val stranger = makeUser(2, "publish_stranger") + + private val ownerSession = new SessionUser(owner) + private val strangerSession = new SessionUser(stranger) + + private val workflowResource = new WorkflowResource() + + private val publishedContent = """{"operators":[],"note":"content_as_published"}""" + private val editedContent = """{"operators":[],"note":"content_only_a_draft"}""" + + private def workflowDao = new WorkflowDao(getDSLContext.configuration()) + + override protected def beforeAll(): Unit = { + initializeDBAndReplaceDSLContext() + val userDao = new UserDao(getDSLContext.configuration()) + userDao.insert(owner) + userDao.insert(stranger) + } + + override protected def afterAll(): Unit = shutdownDB() + + /** Creates a workflow owned by `owner` holding [[publishedContent]]. */ + private def createWorkflow(name: String): Integer = { + val workflow = new Workflow() + workflow.setName(name) + workflow.setDescription("a workflow") + workflow.setContent(publishedContent) + workflowResource.createWorkflow(workflow, ownerSession).workflow.getWid + } + + /** + * Publishes and pins in one step, which is the state most of these tests are about. Publishing on + * its own leaves the workflow following the author's latest; pinning is what freezes a copy. + */ + private def publishPinned(wid: Integer): WorkflowPublishService.PublishStatus = { + workflowResource.makePublic(wid, ownerSession) + workflowResource.pinLatest(wid, ownerSession) + } + + /** Saves `content` as the author's working copy, the way an autosave would. */ + private def edit(wid: Integer, content: String): Unit = { + val workflow = workflowDao.fetchOneByWid(wid) + workflow.setContent(content) + workflowResource.persistWorkflow(workflow, ownerSession) + } + + /** Renames and re-describes the author's working copy, the way the dashboard does. */ + private def relabel(wid: Integer, name: String, description: String): Unit = { + val workflow = workflowDao.fetchOneByWid(wid) + workflow.setName(name) + workflow.setDescription(description) + workflowResource.persistWorkflow(workflow, ownerSession) + } + + /** + * Runs `interleaved` in the last moment before `act` sends its own write, which is where a second + * request slips in unnoticed. Driven off the statement itself rather than off a thread, so the + * ordering is the same on every run. + */ + /** + * An UPDATE of the workflow table itself, whatever quoting and spacing jOOQ renders it with -- + * and not one of workflow_version or the access tables, since matching those would let a later + * change to one of these paths interleave at the wrong moment and leave the test passing for the + * wrong reason. A rendering this fails to recognise turns the test red rather than skipping it, + * because [[interleaving]] insists that something was interleaved. + */ + private val aWorkflowUpdate = + """(?is)\s*update\s+(?:\S*\.)?["`\[]?workflow["`\]]?\s+set\b.*""".r + + private def interleaving(interleaved: () => Unit)(act: => Unit): Unit = { + var pending = true + val configuration = getDSLContext.configuration().asInstanceOf[DefaultConfiguration] + val previousListeners = configuration.executeListenerProviders() + configuration.set(new DefaultExecuteListenerProvider(new ExecuteListener { + override def executeStart(ctx: ExecuteContext): Unit = { + val sql = Option(ctx.sql()).getOrElse("") + if (pending && aWorkflowUpdate.matches(sql)) { + pending = false + interleaved() + } + } + })) + try act + finally configuration.set(previousListeners: _*) + withClue("nothing was interleaved, so this proves nothing: ") { pending shouldBe false } + } + + private def statusOf(wid: Integer): WorkflowPublishService.PublishStatus = + workflowResource.getPublishStatus(wid, ownerSession) + + /** Grants `stranger` explicit access, which makes them a collaborator rather than an outsider. */ + private def grantAccess(wid: Integer, privilege: PrivilegeEnum): Unit = + new WorkflowUserAccessDao(getDSLContext.configuration()) + .insert(new WorkflowUserAccess(stranger.getUid, wid, privilege)) + + private def revokeAccess(wid: Integer): Unit = + getDSLContext + .deleteFrom(WORKFLOW_USER_ACCESS) + .where(WORKFLOW_USER_ACCESS.WID.eq(wid).and(WORKFLOW_USER_ACCESS.UID.eq(stranger.getUid))) + .execute() + + behavior of "publishing" + + it should "follow the author's latest by default" in { + val wid = createWorkflow("publish_follows_latest") + workflowResource.makePublic(wid, ownerSession) + + val status = statusOf(wid) + status.isPublished shouldBe true + status.isPinned shouldBe false + // Nothing is frozen, so nothing is held back however much the author edits. + status.hasUnpublishedChanges shouldBe false + workflowDao.fetchOneByWid(wid).getPublishedContent shouldBe null + + edit(wid, editedContent) + statusOf(wid).hasUnpublishedChanges shouldBe false + } + + it should "pin the current version as the public copy" in { + val wid = createWorkflow("pins_current_version") + val status = publishPinned(wid) + + status.isPublished shouldBe true + status.isPinned shouldBe true + status.hasUnpublishedChanges shouldBe false + workflowDao.fetchOneByWid(wid).getPublishedContent shouldBe publishedContent + } + + it should "pin a workflow that has no description" in { + // description is nullable and the constraint does not ask for published_description, so a + // workflow saved without one has to pin like any other rather than fail on the way in. + val workflow = new Workflow() + workflow.setName("pins_without_a_description") + workflow.setContent(publishedContent) + val wid = workflowResource.createWorkflow(workflow, ownerSession).workflow.getWid + + val status = publishPinned(wid) + + status.isPinned shouldBe true + status.hasUnpublishedChanges shouldBe false + val stored = workflowDao.fetchOneByWid(wid) + stored.getDescription shouldBe null + stored.getPublishedDescription shouldBe null + + // ...and writing one afterwards is an unpublished change like any other. + relabel(wid, "pins_without_a_description", "described later") + statusOf(wid).hasUnpublishedChanges shouldBe true + } + + it should "follow the author's latest again once the pin is dropped" in { + val wid = createWorkflow("unpin_follows_latest") + publishPinned(wid) + edit(wid, editedContent) + + val status = workflowResource.unpin(wid, ownerSession) + + status.isPublished shouldBe true + status.isPinned shouldBe false + status.hasUnpublishedChanges shouldBe false + // Still public; only the frozen copy is gone. + val stored = workflowDao.fetchOneByWid(wid) + stored.getIsPublic shouldBe true + stored.getPublishedContent shouldBe null + } + + it should "freeze the default view with the copy" in { + // The form's definition rides inside the content, so pinning a canvas version and then switching + // the workflow to the form view would otherwise leave the public opening a form that the frozen + // copy does not contain. + val wid = createWorkflow("view_freezes_with_the_copy") + publishPinned(wid) + + getDSLContext + .update(WORKFLOW) + .set(WORKFLOW.DEFAULT_VIEW, DefaultViewEnum.FORM) + .where(WORKFLOW.WID.eq(wid)) + .execute() + + val stored = workflowDao.fetchOneByWid(wid) + stored.getDefaultView shouldBe DefaultViewEnum.FORM + stored.getPublishedDefaultView shouldBe DefaultViewEnum.CANVAS + } + + it should "clear the frozen default view when the pin is dropped" in { + val wid = createWorkflow("view_clears_with_the_pin") + publishPinned(wid) + workflowDao.fetchOneByWid(wid).getPublishedDefaultView shouldBe DefaultViewEnum.CANVAS + + workflowResource.unpin(wid, ownerSession) + + workflowDao.fetchOneByWid(wid).getPublishedDefaultView shouldBe null + } + + it should "leave the pinned copy untouched when the author edits afterwards" in { + val wid = createWorkflow("edit_stays_private") + publishPinned(wid) + + edit(wid, editedContent) + + val stored = workflowDao.fetchOneByWid(wid) + // The author's own working copy has moved on... + stored.getContent shouldBe editedContent + // ...but the copy that was frozen has not. + stored.getPublishedContent shouldBe publishedContent + statusOf(wid).hasUnpublishedChanges shouldBe true + } + + it should "pin the save that lands while it is pinning, not the version before it" in { + // A pin that read the row and wrote what it had read would freeze the version before a save + // landing in that window -- and the author, who had just pinned, would be told they have + // unpublished changes. Each column is copied from its own row instead, so there is no window. + val wid = createWorkflow("pin_takes_the_row_as_it_stands") + workflowResource.makePublic(wid, ownerSession) + + interleaving(() => edit(wid, editedContent)) { + workflowResource.pinLatest(wid, ownerSession) + } + + workflowDao.fetchOneByWid(wid).getPublishedContent shouldBe editedContent + statusOf(wid).hasUnpublishedChanges shouldBe false + } + + it should "not republish a workflow that is taken down while a pin is in flight" in { + // The "is it published?" check and the pin are two statements. An unpublish landing between + // them used to be undone by the pin's own `is_public = true`, putting a workflow the author had + // just taken down back on the Hub -- and pinned, so the copy they removed is what the public + // reads. + val wid = createWorkflow("pin_cannot_republish") + publishPinned(wid) + + interleaving(() => workflowResource.makePrivate(wid, ownerSession)) { + a[BadRequestException] should be thrownBy workflowResource.pinLatest(wid, ownerSession) + } + + val stored = workflowDao.fetchOneByWid(wid) + stored.getIsPublic shouldBe false + stored.getPublishedContent shouldBe null + } + + it should "move the pin forward to the author's current version" in { + val wid = createWorkflow("repin_updates_public") + publishPinned(wid) + edit(wid, editedContent) + + val status = workflowResource.pinLatest(wid, ownerSession) + + status.isPinned shouldBe true + status.hasUnpublishedChanges shouldBe false + workflowDao.fetchOneByWid(wid).getPublishedContent shouldBe editedContent + } + + it should "count a rename as an unpublished change" in { + // The pin freezes the title too, so the public is still being shown the old one -- the panel has + // to say so, or the author reads "nothing held back" while the hub disagrees with their editor. + val wid = createWorkflow("rename_counts_as_drift") + publishPinned(wid) + statusOf(wid).hasUnpublishedChanges shouldBe false + + relabel(wid, "renamed_after_pinning", "a workflow") + + statusOf(wid).hasUnpublishedChanges shouldBe true + } + + it should "count a description edit as an unpublished change" in { + val wid = createWorkflow("description_counts_as_drift") + publishPinned(wid) + + relabel(wid, "description_counts_as_drift", "rewritten after pinning") + + statusOf(wid).hasUnpublishedChanges shouldBe true + } + + it should "count a change of view as an unpublished change" in { + val wid = createWorkflow("view_counts_as_drift") + publishPinned(wid) + + getDSLContext + .update(WORKFLOW) + .set(WORKFLOW.DEFAULT_VIEW, DefaultViewEnum.FORM) + .where(WORKFLOW.WID.eq(wid)) + .execute() + + statusOf(wid).hasUnpublishedChanges shouldBe true + } + + it should "report no unpublished changes when an edit is undone" in { + val wid = createWorkflow("undo_clears_badge") + publishPinned(wid) + + edit(wid, editedContent) + statusOf(wid).hasUnpublishedChanges shouldBe true + + edit(wid, publishedContent) + statusOf(wid).hasUnpublishedChanges shouldBe false + } + + it should "report no unpublished changes when the same graph comes back rearranged" in { + // The two copies travel by different routes, and the editor is free to hand back the same graph + // with its keys in another order. Reporting that as an edit is an alarm the author cannot clear. + val wid = createWorkflow("reformat_is_not_an_edit") + publishPinned(wid) + + edit(wid, """{ "note":"content_as_published", "operators": [] }""") + + statusOf(wid).hasUnpublishedChanges shouldBe false + } + + it should "not report a take-down as unpinned while an unpin is in flight" in { + // The mirror of the pin case: dropping a pin on a workflow that /private has just taken down + // used to answer "unpinned, following latest" about something no longer on show. + val wid = createWorkflow("unpin_cannot_outlive_a_takedown") + publishPinned(wid) + + interleaving(() => workflowResource.makePrivate(wid, ownerSession)) { + a[BadRequestException] should be thrownBy workflowResource.unpin(wid, ownerSession) + } + + val stored = workflowDao.fetchOneByWid(wid) + stored.getIsPublic shouldBe false + stored.getPublishedContent shouldBe null + } + + it should "do nothing when unpinning a workflow that is following" in { + // The endpoint is reachable whatever the dialog shows, and asking for the state it is already in + // is not an error -- it just has nothing to clear. + val wid = createWorkflow("unpin_while_following") + workflowResource.makePublic(wid, ownerSession) + + val status = workflowResource.unpin(wid, ownerSession) + + status.isPublished shouldBe true + status.isPinned shouldBe false + workflowDao.fetchOneByWid(wid).getIsPublic shouldBe true + } + + it should "leave a pin alone when the workflow is published again" in { + // Publishing is an on/off switch and this one is already on, so it has nothing to turn: the + // frozen copy is not quietly dropped underneath a public that is reading it. + val wid = createWorkflow("republish_keeps_the_pin") + publishPinned(wid) + + workflowResource.makePublic(wid, ownerSession) + + val stored = workflowDao.fetchOneByWid(wid) + stored.getIsPublic shouldBe true + stored.getPublishedContent shouldBe publishedContent + statusOf(wid).isPinned shouldBe true + } + + it should "report drift rather than fail when a copy is not valid JSON" in { + // content is free text as far as the database is concerned, so the comparison has to survive a + // blob it cannot parse. Falling back to "these differ" is the safe direction: the author is told + // the public is behind, rather than the dialog throwing at them. + val wid = createWorkflow("unparsable_content") + publishPinned(wid) + + getDSLContext + .update(WORKFLOW) + .set(WORKFLOW.CONTENT, "not json at all") + .where(WORKFLOW.WID.eq(wid)) + .execute() + + statusOf(wid).hasUnpublishedChanges shouldBe true + } + + it should "drop the pinned copy on unpublish" in { + val wid = createWorkflow("unpublish_clears_pin") + publishPinned(wid) + + workflowResource.makePrivate(wid, ownerSession) + + val stored = workflowDao.fetchOneByWid(wid) + stored.getIsPublic shouldBe false + stored.getPublishedContent shouldBe null + } + + it should "not resurrect the previous pin after unpublish and re-publish" in { + val wid = createWorkflow("unpublish_then_publish") + publishPinned(wid) + edit(wid, editedContent) + workflowResource.makePrivate(wid, ownerSession) + + // Publishing again starts in the following state; the copy that used to be public is gone. + workflowResource.makePublic(wid, ownerSession) + + statusOf(wid).isPinned shouldBe false + workflowDao.fetchOneByWid(wid).getPublishedContent shouldBe null + } + + it should "publish a workflow that is created already public" in { + val workflow = new Workflow() + workflow.setName("created_public") + workflow.setDescription("a workflow") + workflow.setContent(publishedContent) + workflow.setIsPublic(true) + val wid = workflowResource.createWorkflow(workflow, ownerSession).workflow.getWid + + // Asking for a public workflow up front lands in the same following state as any other new + // public workflow, rather than being pinned by surprise. + val stored = workflowDao.fetchOneByWid(wid) + stored.getIsPublic shouldBe true + stored.getPublishedContent shouldBe null + } + + it should "reject publishing by a user without write access" in { + val wid = createWorkflow("publish_requires_write") + a[ForbiddenException] should be thrownBy workflowResource.makePublic(wid, strangerSession) + } + + it should "refuse to pin, unpin or report status without write access" in { + val wid = createWorkflow("pin_requires_write") + publishPinned(wid) + a[ForbiddenException] should be thrownBy workflowResource.pinLatest(wid, strangerSession) + a[ForbiddenException] should be thrownBy workflowResource.unpin(wid, strangerSession) + a[ForbiddenException] should be thrownBy workflowResource.getPublishStatus(wid, strangerSession) + } + + it should "reject pinning and unpinning a workflow that is not published" in { + val wid = createWorkflow("pin_requires_published") + a[BadRequestException] should be thrownBy workflowResource.pinLatest(wid, ownerSession) + a[BadRequestException] should be thrownBy workflowResource.unpin(wid, ownerSession) + } + + it should "answer 404 for every operation on a workflow that does not exist" in { + // Asked of the service rather than the endpoints: a missing workflow has no access row either, + // so the endpoints answer 403 first and never reach these. 404 is the service's own contract. + val missing = Integer.valueOf(987654) + a[NotFoundException] should be thrownBy WorkflowPublishService.publish(missing) + a[NotFoundException] should be thrownBy WorkflowPublishService.pinLatest(missing) + a[NotFoundException] should be thrownBy WorkflowPublishService.unpin(missing) + a[NotFoundException] should be thrownBy WorkflowPublishService.unpublish(missing) + a[NotFoundException] should be thrownBy WorkflowPublishService.statusOf(missing) + } + + behavior of "saving a published workflow" + + it should "not roll back a publish that lands while a save is in flight" in { + // A save used to carry `is_public` along. An editor open since before the workflow was + // published holds a snapshot saying private, and saving it put that back -- taking a pinned + // workflow private underneath its own frozen copy, which the database refuses outright, so the + // author was left with an editor that could no longer save. The save no longer names the column. + val wid = createWorkflow("save_cannot_roll_back_publish") + + // The snapshot an editor opened before any of this was published. + val stale = workflowDao.fetchOneByWid(wid) + stale.getIsPublic shouldBe false + + publishPinned(wid) + + stale.setContent("""{"operators":[],"note":"from_a_stale_client"}""") + workflowResource.persistWorkflow(stale, ownerSession) + + // The save went through, and it moved the working copy only. + val stored = workflowDao.fetchOneByWid(wid) + stored.getContent shouldBe """{"operators":[],"note":"from_a_stale_client"}""" + stored.getIsPublic shouldBe true + stored.getPublishedContent shouldBe publishedContent + } + + it should "not let a save change the publish state" in { + val wid = createWorkflow("save_cannot_publish") + publishPinned(wid) + + // A stale or hostile client sending the whole POJO back with the publish columns rewritten. + val tampered = workflowDao.fetchOneByWid(wid) + tampered.setContent(editedContent) + tampered.setIsPublic(false) + tampered.setPublishedContent(editedContent) + workflowResource.persistWorkflow(tampered, ownerSession) + + val stored = workflowDao.fetchOneByWid(wid) + stored.getIsPublic shouldBe true + stored.getPublishedContent shouldBe publishedContent + } + + it should "not let a collaborator's save change the publish state" in { + val wid = createWorkflow("collaborator_cannot_publish") + publishPinned(wid) + grantAccess(wid, PrivilegeEnum.WRITE) + + try { + val tampered = workflowDao.fetchOneByWid(wid) + tampered.setContent(editedContent) + tampered.setIsPublic(false) + tampered.setPublishedContent(editedContent) + workflowResource.persistWorkflow(tampered, strangerSession) + + val stored = workflowDao.fetchOneByWid(wid) + stored.getIsPublic shouldBe true + stored.getPublishedContent shouldBe publishedContent + } finally revokeAccess(wid) + } + + it should "not let a rename undo a publish that lands first" in { + // A rename used to read the whole row and write it all back, so a publish landing in that window + // was reverted to what the read had seen: the author pressed Public, was told it worked, and the + // workflow was private again. + val wid = createWorkflow("rename_cannot_undo_publish") + + interleaving(() => publishPinned(wid)) { + val body = new Workflow() + body.setWid(wid) + body.setName("renamed_during_a_publish") + workflowResource.updateWorkflowName(body, ownerSession) + } + + val stored = workflowDao.fetchOneByWid(wid) + stored.getName shouldBe "renamed_during_a_publish" + stored.getIsPublic shouldBe true + stored.getPublishedContent shouldBe publishedContent + } + + it should "not let a rename put an unpublished workflow back on show" in { + // The same window, the other way round, and the one that matters: the author takes the workflow + // down, and a rename in flight restores the row as it was -- public, still carrying the frozen + // copy the public had been reading. + val wid = createWorkflow("rename_cannot_republish") + publishPinned(wid) + + interleaving(() => workflowResource.makePrivate(wid, ownerSession)) { + val body = new Workflow() + body.setWid(wid) + body.setName("renamed_during_an_unpublish") + workflowResource.updateWorkflowName(body, ownerSession) + } + + val stored = workflowDao.fetchOneByWid(wid) + stored.getName shouldBe "renamed_during_an_unpublish" + stored.getIsPublic shouldBe false + stored.getPublishedContent shouldBe null + } + + it should "not let a rename change the publish state" in { + val wid = createWorkflow("rename_cannot_publish") + publishPinned(wid) + + val tampered = workflowDao.fetchOneByWid(wid) + tampered.setName("renamed") + tampered.setIsPublic(false) + tampered.setPublishedContent(editedContent) + workflowResource.updateWorkflowName(tampered, ownerSession) + + val stored = workflowDao.fetchOneByWid(wid) + stored.getName shouldBe "renamed" + stored.getIsPublic shouldBe true + stored.getPublishedContent shouldBe publishedContent + } +}
