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
+  }
+}

Reply via email to