This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git

commit 2351cb856d8f67b3da3ee53f1e72381122ee7cf6
Author: yangzhang75 <[email protected]>
AuthorDate: Thu Oct 15 01:21:26 2026 +0000

    feat(workflow): serve the pinned copy to public viewers (#8575)
    
    ### What changes were proposed in this PR?
    
    With a version pinned, a workflow has two copies. This routes every read
    that serves a viewer
    without granted access through the frozen one.
    
    - **The seam** — `WorkflowAccessResource.hasGrantedAccess(wid, uid)`:
    granted access, as opposed
    to being able to read a workflow only because it is public. An owner or
    a shared user keeps
    tracking the author's latest, because sharing is not publishing.
    Everyone else is here only
      because the workflow is public, and gets the public copy.
    - **One copy, not four fields** — `WorkflowPublishService.PublicCopy`
    carries the name, the
    description, the content and the default view together, and
    `publicCopyOf` hands them out as a
    group, so no surface can serve the published graph under a title the
    author never published, or
      open a form view over a copy whose content carries no form.
    - **Routed through it** — opening a workflow, the hub's read of a public
    workflow, Clone,
    Duplicate, `/workflow_name`, `/workflow_description`, and the size a
    listing shows.
    - **The revision history is a door into the same room.** Replaying a
    version folds deltas back
    from the author's *current* content, so listing or checking out versions
    would hand a public
    viewer the drafts the pin is holding back. `canReadHistory` keeps the
    history open to anyone
    with granted access, and to everyone while nothing is pinned — the
    public copy is then the
    author's latest and its history is the history of what everyone can
    already see.
    
    **A workflow that follows the author's latest — every workflow today —
    is served exactly what it
    is served now.** `publicCopyOf` returns the working copy when nothing is
    frozen, so with no UI to
    pin from yet (that arrives later in the series), nothing anyone can see
    changes.
    
    ### Any related issues, documentation, discussions?
    
    Closes #7939
    Part of #7828. Design discussion: #7128. Builds on #7853, now merged.
    
    ### How was this PR tested?
    
    22 new cases in `WorkflowPublishSpec` (54 in the suite, 540 in the
    dashboard package):
    
    - the published version reaching a stranger while the author keeps their
    own working copy;
    - opening a pinned workflow serving the whole copy — its own title,
    description and view — and
      the author still opening everything they have;
    - a collaborator with granted read access tracking the author's latest
    as they keep editing;
    - clone and duplicate taking the published copy, metadata included,
    while a private workflow
    still clones its working copy and a copy starts with no publish state of
    its own;
    - the size each case reports;
    - the revision history: a public viewer is refused while a pin is in
    place, a collaborator is
    not, and a public workflow with nothing pinned keeps the history it has
    today;
    - a public copy of a workflow that is not public being refused rather
    than falling through.
    
    The history guard was checked by mutation: restoring `hasReadAccess`
    turns the leak case red,
    and the case was written from a reproduction — a stranger retrieving a
    version of a pinned
    workflow and getting back an unpublished draft.
    
    `scalafmtCheckAll` clean.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 5)
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
    
    Co-authored-by: Claude Opus 5 <[email protected]>
---
 .../user/workflow/WorkflowAccessResource.scala     |   9 +
 .../user/workflow/WorkflowPublishService.scala     |  62 +++-
 .../dashboard/user/workflow/WorkflowResource.scala | 130 +++++---
 .../user/workflow/WorkflowVersionResource.scala    |  17 +-
 .../user/workflow/WorkflowPublishSpec.scala        | 343 ++++++++++++++++++++-
 5 files changed, 502 insertions(+), 59 deletions(-)

diff --git 
a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowAccessResource.scala
 
b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowAccessResource.scala
index 09353d257f..8f975f9041 100644
--- 
a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowAccessResource.scala
+++ 
b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowAccessResource.scala
@@ -92,6 +92,15 @@ object WorkflowAccessResource {
     }
   }
 
+  /**
+    * Whether the user was granted access to the workflow itself, rather than 
merely being able to
+    * read it because it is public. Granted access sees the author's working 
copy; public access is
+    * held at the pinned copy while one is pinned.
+    */
+  def hasGrantedAccess(wid: Integer, uid: Integer): Boolean = {
+    !getPrivilege(wid, uid).eq(PrivilegeEnum.NONE)
+  }
+
   def isPublic(wid: Integer): Boolean = {
     context
       .select(WORKFLOW.IS_PUBLIC)
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
index 48ad3c85f3..9d6a208fcd 100644
--- 
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
@@ -199,15 +199,61 @@ object WorkflowPublishService extends LazyLogging {
     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
+      // Literally "what the public sees is not what you have": whatever 
[[publicCopyOf]] freezes is
+      // what this compares, on values rather than version ids, so an edit and 
its undo cancel out.
+      hasUnpublishedChanges = differs(publicCopyOf(workflow), 
workingCopyOf(workflow))
+    )
+  }
+
+  /**
+    * Every field of the copy, so that a rename the public cannot see is held 
back exactly as an edit
+    * to the graph is. Content is compared as a tree: a restore can rearrange 
whitespace, and calling
+    * that drift alarms nobody.
+    */
+  private def differs(public: PublicCopy, working: PublicCopy): Boolean =
+    public.name != working.name ||
+      public.description != working.description ||
+      public.defaultView != working.defaultView ||
+      !sameContent(public.content, working.content)
+
+  /**
+    * Everything about a workflow that is on public show, carried together so 
that a caller cannot
+    * serve the frozen graph under the author's live title, or open the 
author's chosen view on a
+    * copy that does not contain it.
+    */
+  case class PublicCopy(
+      name: String,
+      description: String,
+      content: String,
+      defaultView: DefaultViewEnum
+  )
+
+  /** What every public surface must serve, as a group so no field is the one 
that gets forgotten. */
+  def publicCopyOf(workflow: Workflow): PublicCopy =
+    if (workflow.getPublishedContent == null) workingCopyOf(workflow)
+    else
+      PublicCopy(
+        workflow.getPublishedName,
+        workflow.getPublishedDescription,
+        workflow.getPublishedContent,
+        workflow.getPublishedDefaultView
       )
+
+  /** The author's own copy, in the same shape. */
+  private def workingCopyOf(workflow: Workflow): PublicCopy =
+    PublicCopy(
+      workflow.getName,
+      workflow.getDescription,
+      workflow.getContent,
+      workflow.getDefaultView
     )
+
+  /** As [[publicCopyOf]], for callers holding only a wid. 404s unless the 
workflow is public. */
+  def publicCopyOf(wid: Integer): PublicCopy = {
+    val workflow = requireWorkflow(wid)
+    if (!workflow.getIsPublic) {
+      throw new NotFoundException(s"Workflow $wid is not public")
+    }
+    publicCopyOf(workflow)
   }
 }
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 bae2fa0047..6fbd19bcc2 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
@@ -96,11 +96,7 @@ object WorkflowResource {
   private def insertWorkflow(workflow: Workflow, user: User): Unit = {
     // A workflow is born with nothing pinned. The endpoint takes a whole 
Workflow, so without this
     // a request body could seed a published copy of its own choosing.
-    workflow.setPublishedVersionId(null)
-    workflow.setPublishedContent(null)
-    workflow.setPublishedName(null)
-    workflow.setPublishedDescription(null)
-    workflow.setPublishedDefaultView(null)
+    clearPublishState(workflow)
     workflowDao.insert(workflow)
     workflowOfUserDao.insert(new WorkflowOfUser(user.getUid, workflow.getWid))
     workflowUserAccessDao.insert(
@@ -144,11 +140,19 @@ object WorkflowResource {
 
   case class WorkflowIDs(wids: List[Integer])
 
+  /** Clears every column that describes a pinned public copy. */
+  private def clearPublishState(workflow: Workflow): Unit = {
+    workflow.setPublishedVersionId(null)
+    workflow.setPublishedContent(null)
+    workflow.setPublishedName(null)
+    workflow.setPublishedDescription(null)
+    workflow.setPublishedDefaultView(null)
+  }
+
   /**
-    * A workflow POJO for the copy-producing paths (clone, duplicate, 
restore-a-version).
-    *
-    * Built with setters rather than the positional constructor, so that 
adding a column cannot
-    * silently shift a null into the wrong field -- as adding the 
published-copy columns would.
+    * A workflow POJO for the copy-producing paths (clone, duplicate, 
restore-a-version). Copies start
+    * unpublished, and setters rather than the positional constructor keep a 
new column from silently
+    * shifting a null into the wrong field.
     */
   def newUnpublishedWorkflow(
       name: String,
@@ -162,9 +166,46 @@ object WorkflowResource {
     workflow.setContent(content)
     workflow.setIsPublic(false)
     workflow.setDefaultView(defaultView)
+    clearPublishState(workflow)
     workflow
   }
 
+  /**
+    * The copy a viewer may see, every field at once: a user granted access to 
the workflow itself,
+    * as its owner or through a share, sees the author's working copy; 
everyone else is here only
+    * because the workflow is public, and gets the public copy. Taken as a 
group so a copy cannot end
+    * up carrying the published graph under a title the author never published.
+    */
+  private def copyVisibleTo(workflow: Workflow, uid: Integer): 
WorkflowPublishService.PublicCopy =
+    if (uid != null && 
WorkflowAccessResource.hasGrantedAccess(workflow.getWid, uid)) {
+      WorkflowPublishService.PublicCopy(
+        workflow.getName,
+        workflow.getDescription,
+        workflow.getContent,
+        workflow.getDefaultView
+      )
+    } else {
+      WorkflowPublishService.publicCopyOf(workflow)
+    }
+
+  /**
+    * One column as the public sees it: the frozen value while a pin is in 
place, the live one
+    * otherwise. Keyed on the pin rather than on the frozen value being 
non-null, so a pinned
+    * workflow can never fall through to what the author is editing.
+    */
+  private def publicField(
+      wid: Integer,
+      frozen: TableField[_, String],
+      live: TableField[_, String]
+  ) =
+    Option(
+      context
+        .select(WORKFLOW.PUBLISHED_CONTENT, frozen, live)
+        .from(WORKFLOW)
+        .where(WORKFLOW.WID.eq(wid))
+        .fetchOne()
+    ).map(row => if (row.value1() != null) row.value2() else 
row.value3()).orNull
+
   /**
     * 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.
@@ -381,16 +422,20 @@ class WorkflowResource extends LazyLogging {
   ): WorkflowWithPrivilege = {
     if (WorkflowAccessResource.hasReadAccess(wid, user.getUid)) {
       val workflow = workflowDao.fetchOneByWid(wid)
+      // A user who only reaches this workflow because it is public is served 
the pinned copy, not
+      // the author's in-progress edits -- all of it, so the graph cannot 
arrive under a title the
+      // author has not published, or in a view the frozen graph does not 
support.
+      val visible = copyVisibleTo(workflow, user.getUid)
       WorkflowWithPrivilege(
-        workflow.getName,
-        workflow.getDescription,
+        visible.name,
+        visible.description,
         workflow.getWid,
-        workflow.getContent,
+        visible.content,
         workflow.getCreationTime,
         workflow.getLastModifiedTime,
         workflow.getIsPublic,
         !WorkflowAccessResource.hasWriteAccess(wid, user.getUid),
-        workflow.getDefaultView
+        visible.defaultView
       )
     } else {
       throw new ForbiddenException("No sufficient access privilege.")
@@ -491,13 +536,16 @@ class WorkflowResource extends LazyLogging {
       context.transaction { txConfig =>
         for (wid <- workflowIDs.wids) {
           val oldWorkflow: Workflow = workflowDao.fetchOneByWid(wid)
+          // Reached only because it is public? Then the copy is of the 
published version, title and
+          // description included.
+          val source = copyVisibleTo(oldWorkflow, user.getUid)
           val newWorkflow = createWorkflow(
             newUnpublishedWorkflow(
-              oldWorkflow.getName + "_copy",
-              oldWorkflow.getDescription,
-              assignNewOperatorIds(oldWorkflow.getContent),
-              // the default view is part of the workflow, so a copy keeps it
-              oldWorkflow.getDefaultView
+              source.name + "_copy",
+              source.description,
+              assignNewOperatorIds(source.content),
+              // the default view is part of the copy being taken, so it comes 
from the same source
+              source.defaultView
             ),
             sessionUser
           )
@@ -526,13 +574,16 @@ class WorkflowResource extends LazyLogging {
       throw new ForbiddenException("No sufficient access privilege.")
     }
     val oldWorkflow: Workflow = workflowDao.fetchOneByWid(wid)
+    // The hub shows the public copy, so Clone copies that -- for the author 
too, who already has
+    // their latest in the editor. For a private workflow this is the author's 
own copy.
+    val source = WorkflowPublishService.publicCopyOf(oldWorkflow)
     val newWorkflow: DashboardWorkflow = createWorkflow(
       newUnpublishedWorkflow(
-        oldWorkflow.getName + "_clone",
-        oldWorkflow.getDescription,
-        assignNewOperatorIds(oldWorkflow.getContent),
-        // a biologist's path is hub -> clone -> use, so the clone must stay 
usable
-        oldWorkflow.getDefaultView
+        source.name + "_clone",
+        source.description,
+        assignNewOperatorIds(source.content),
+        // a biologist's path is hub -> clone -> use, so the clone keeps the 
view of what they saw
+        source.defaultView
       ),
       sessionUser
     )
@@ -894,15 +945,10 @@ class WorkflowResource extends LazyLogging {
   @GET
   @Path("/workflow_name")
   def getWorkflowName(@QueryParam("wid") wid: Integer): String = {
-    context
-      .select(
-        WORKFLOW.NAME
-      )
-      .from(WORKFLOW)
-      .where(WORKFLOW.WID.eq(wid))
-      .fetchOneInto(classOf[String])
+    publicField(wid, WORKFLOW.PUBLISHED_NAME, WORKFLOW.NAME)
   }
 
+  /** The hub's public view of a workflow: the pinned version if one is 
pinned, the latest if not. */
   @GET
   @Path("/publicised/{wid}")
   def retrievePublicWorkflow(
@@ -913,29 +959,27 @@ class WorkflowResource extends LazyLogging {
       .where(WORKFLOW.WID.eq(wid))
       .and(WORKFLOW.IS_PUBLIC.isTrue)
       .fetchOne()
+    // Name and description come from the public copy for the same reason as 
the content: a pin has
+    // to hold everything on show.
+    val publicCopy = 
WorkflowPublishService.publicCopyOf(workflow.into(classOf[Workflow]))
     WorkflowWithPrivilege(
-      workflow.getName,
-      workflow.getDescription,
+      publicCopy.name,
+      publicCopy.description,
       workflow.getWid,
-      workflow.getContent,
+      publicCopy.content,
       workflow.getCreationTime,
       workflow.getLastModifiedTime,
       workflow.getIsPublic,
       readonly = true,
-      defaultView = workflow.getDefaultView
+      // The view freezes with the copy: the form's definition lives inside 
the content.
+      defaultView = publicCopy.defaultView
     )
   }
 
   @GET
   @Path("/workflow_description")
   def getWorkflowDescription(@QueryParam("wid") wid: Integer): String = {
-    context
-      .select(
-        WORKFLOW.DESCRIPTION
-      )
-      .from(WORKFLOW)
-      .where(WORKFLOW.WID.eq(wid))
-      .fetchOneInto(classOf[String])
+    publicField(wid, WORKFLOW.PUBLISHED_DESCRIPTION, WORKFLOW.DESCRIPTION)
   }
 
   //TODO Get size from database
@@ -951,7 +995,9 @@ class WorkflowResource extends LazyLogging {
         .fetch()
         .asScala
         .foreach { wf =>
-          result.put(wf.getWid, wf.getContent.length)
+          // Sized by the copy on show, so the number does not move when the 
author edits privately.
+          val onShow = 
WorkflowPublishService.publicCopyOf(wf.into(classOf[Workflow]))
+          result.put(wf.getWid, onShow.content.length)
         }
     }
     result
diff --git 
a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowVersionResource.scala
 
b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowVersionResource.scala
index 41f0378579..c218a1d4ef 100644
--- 
a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowVersionResource.scala
+++ 
b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowVersionResource.scala
@@ -54,6 +54,19 @@ object WorkflowVersionResource {
       .createDSLContext()
   private def workflowVersionDao = new 
WorkflowVersionDao(context.configuration)
   private def workflowDao = new WorkflowDao(context.configuration)
+
+  /**
+    * Whether this user may read the workflow's revision history.
+    *
+    * Read access is enough while nothing is pinned: the public copy is then 
the author's latest, and
+    * its history is the history of what everyone can already see. A pin 
changes that -- replaying a
+    * version folds deltas back from the author's *current* content, so 
handing the history to a
+    * public viewer would hand them the edits the pin is holding back.
+    */
+  private def canReadHistory(wid: Integer, uid: Integer): Boolean =
+    WorkflowAccessResource.hasGrantedAccess(wid, uid) ||
+      (WorkflowAccessResource.hasReadAccess(wid, uid) &&
+        Option(workflowDao.fetchOneByWid(wid)).forall(_.getPublishedContent == 
null))
   // constant to indicate versions should be aggregated if they are within the 
specified time limit
   private final val AGGREGATE_TIME_LIMIT_MILLSEC =
     UserSystemConfig.workflowVersionCollapseIntervalInMinutes * 60000
@@ -336,7 +349,7 @@ class WorkflowVersionResource {
       @Auth sessionUser: SessionUser
   ): List[VersionEntry] = {
     val user = sessionUser.getUser
-    if (!WorkflowAccessResource.hasReadAccess(wid, user.getUid)) {
+    if (!canReadHistory(wid, user.getUid)) {
       List()
     } else {
       encodeVersionImportance(
@@ -371,7 +384,7 @@ class WorkflowVersionResource {
       @Auth sessionUser: SessionUser
   ): Workflow = {
     val user = sessionUser.getUser
-    if (!WorkflowAccessResource.hasReadAccess(wid, user.getUid)) {
+    if (!canReadHistory(wid, user.getUid)) {
       throw new ForbiddenException("No sufficient access privilege.")
     } else {
       // fetch all versions equal to and subsequent to the specified version
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
index 7a40981fed..73306d84e3 100644
--- 
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
@@ -29,22 +29,23 @@ import org.apache.texera.dao.jooq.generated.tables.daos.{
   WorkflowUserAccessDao
 }
 import org.apache.texera.dao.jooq.generated.tables.pojos.{User, Workflow, 
WorkflowUserAccess}
+import 
org.apache.texera.web.resource.dashboard.user.workflow.WorkflowResource.WorkflowIDs
 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.lang.reflect.Proxy
 import java.time.OffsetDateTime
+import java.util
+import javax.servlet.http.HttpServletRequest
 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.
+  * 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 -- and the
+  * read paths that decide which of the two a caller is served.
   */
 class WorkflowPublishSpec
     extends AnyFlatSpec
@@ -75,6 +76,7 @@ class WorkflowPublishSpec
   private val strangerSession = new SessionUser(stranger)
 
   private val workflowResource = new WorkflowResource()
+  private val versionResource = new WorkflowVersionResource()
 
   private val publishedContent = 
"""{"operators":[],"note":"content_as_published"}"""
   private val editedContent = 
"""{"operators":[],"note":"content_only_a_draft"}"""
@@ -154,7 +156,17 @@ class WorkflowPublishSpec
     try act
     finally configuration.set(previousListeners: _*)
     withClue("nothing was interleaved, so this proves nothing: ") { pending 
shouldBe false }
-  }
+  } /** Clone records the caller's IP, and that is the only thing it wants 
from the request.
+    */
+  private def fakeRequest(): HttpServletRequest =
+    Proxy
+      .newProxyInstance(
+        classOf[HttpServletRequest].getClassLoader,
+        Array[Class[_]](classOf[HttpServletRequest]),
+        (_: Any, method: java.lang.reflect.Method, _: Array[AnyRef]) =>
+          if (method.getName == "getRemoteAddr") "127.0.0.1" else null
+      )
+      .asInstanceOf[HttpServletRequest]
 
   private def statusOf(wid: Integer): WorkflowPublishService.PublishStatus =
     workflowResource.getPublishStatus(wid, ownerSession)
@@ -616,4 +628,321 @@ class WorkflowPublishSpec
     stored.getIsPublic shouldBe true
     stored.getPublishedContent shouldBe publishedContent
   }
+
+  behavior of "name and description"
+
+  it should "freeze the name and description alongside the content" in {
+    // Malicious text in a description is just as public as the graph, so 
editing it must not reach
+    // the public view either -- otherwise a report can be answered by 
rewording rather than fixing.
+    val wid = createWorkflow("freeze_metadata")
+    publishPinned(wid)
+
+    relabel(wid, "renamed_after_publishing", "rewritten after publishing")
+
+    val publicView = workflowResource.retrievePublicWorkflow(wid)
+    publicView.name shouldBe "freeze_metadata"
+    publicView.description shouldBe "a workflow"
+    workflowResource.getWorkflowName(wid) shouldBe "freeze_metadata"
+    workflowResource.getWorkflowDescription(wid) shouldBe "a workflow"
+  }
+
+  it should "publish an edited description by moving the pin forward" in {
+    val wid = createWorkflow("description_publishes_on_repin")
+    publishPinned(wid)
+    relabel(wid, "description_publishes_on_repin", "rewritten after 
publishing")
+
+    workflowResource.pinLatest(wid, ownerSession)
+
+    workflowResource.retrievePublicWorkflow(wid).description shouldBe 
"rewritten after publishing"
+    statusOf(wid).hasUnpublishedChanges shouldBe false
+  }
+
+  behavior of "public read paths"
+
+  it should "show a public viewer the author's latest while nothing is pinned" 
in {
+    // Following follows everything a pin would freeze, not just the graph: 
name and description are
+    // on public show too, so a viewer must see the live ones or the two 
halves would disagree.
+    val wid = createWorkflow("following_serves_latest_to_strangers")
+    workflowResource.makePublic(wid, ownerSession)
+    edit(wid, editedContent)
+    relabel(wid, "renamed_while_following", "described_while_following")
+
+    workflowResource.retrieveWorkflow(wid, strangerSession).content shouldBe 
editedContent
+    workflowResource.getWorkflowName(wid) shouldBe "renamed_while_following"
+    workflowResource.getWorkflowDescription(wid) shouldBe 
"described_while_following"
+
+    val publicView = workflowResource.retrievePublicWorkflow(wid)
+    publicView.content shouldBe editedContent
+    publicView.name shouldBe "renamed_while_following"
+    publicView.description shouldBe "described_while_following"
+  }
+
+  it should "open the public view in the pinned copy's own view" in {
+    // A form's definition lives inside the content, so serving the author's 
live preference over a
+    // frozen graph would put a form on a copy that has none.
+    val wid = createWorkflow("public_view_uses_the_frozen_view")
+    publishPinned(wid)
+
+    getDSLContext
+      .update(WORKFLOW)
+      .set(WORKFLOW.DEFAULT_VIEW, DefaultViewEnum.FORM)
+      .where(WORKFLOW.WID.eq(wid))
+      .execute()
+
+    workflowResource.retrievePublicWorkflow(wid).defaultView shouldBe 
DefaultViewEnum.CANVAS
+    // The author keeps their own preference.
+    workflowDao.fetchOneByWid(wid).getDefaultView shouldBe DefaultViewEnum.FORM
+  }
+
+  it should "follow the author's view while nothing is pinned" in {
+    val wid = createWorkflow("public_view_follows_latest")
+    workflowResource.makePublic(wid, ownerSession)
+
+    getDSLContext
+      .update(WORKFLOW)
+      .set(WORKFLOW.DEFAULT_VIEW, DefaultViewEnum.FORM)
+      .where(WORKFLOW.WID.eq(wid))
+      .execute()
+
+    workflowResource.retrievePublicWorkflow(wid).defaultView shouldBe 
DefaultViewEnum.FORM
+  }
+
+  it should "serve the published version to a user without granted access" in {
+    val wid = createWorkflow("read_serves_published")
+    publishPinned(wid)
+    edit(wid, editedContent)
+
+    // A stranger reaches this workflow only because it is public.
+    workflowResource.retrieveWorkflow(wid, strangerSession).content shouldBe 
publishedContent
+    // The author keeps seeing their own working copy.
+    workflowResource.retrieveWorkflow(wid, ownerSession).content shouldBe 
editedContent
+  }
+
+  it should "open a pinned workflow under the copy's own title and view, not 
the author's" in {
+    // The graph and the label travel together: a public viewer opening this 
workflow must not get
+    // the frozen graph under a title the author has not published, and must 
not be told to open a
+    // form view over a copy whose content carries no form.
+    val wid = createWorkflow("open_serves_the_whole_copy")
+    publishPinned(wid)
+    edit(wid, editedContent)
+    relabel(wid, "renamed_after_pinning", "described_after_pinning")
+    getDSLContext
+      .update(WORKFLOW)
+      .set(WORKFLOW.DEFAULT_VIEW, DefaultViewEnum.FORM)
+      .where(WORKFLOW.WID.eq(wid))
+      .execute()
+
+    val asStranger = workflowResource.retrieveWorkflow(wid, strangerSession)
+    asStranger.content shouldBe publishedContent
+    asStranger.name shouldBe "open_serves_the_whole_copy"
+    asStranger.description shouldBe "a workflow"
+    asStranger.defaultView shouldBe DefaultViewEnum.CANVAS
+
+    // The author opens their own workflow and sees everything they have.
+    val asOwner = workflowResource.retrieveWorkflow(wid, ownerSession)
+    asOwner.content shouldBe editedContent
+    asOwner.name shouldBe "renamed_after_pinning"
+    asOwner.defaultView shouldBe DefaultViewEnum.FORM
+  }
+
+  it should "serve the working copy to a collaborator with granted read 
access" in {
+    val wid = createWorkflow("collaborator_sees_working_copy")
+    publishPinned(wid)
+    edit(wid, editedContent)
+    grantAccess(wid, PrivilegeEnum.READ)
+
+    try {
+      // Sharing is not publishing: a collaborator tracks the author's latest 
content, live.
+      workflowResource.retrieveWorkflow(wid, strangerSession).content shouldBe 
editedContent
+    } finally revokeAccess(wid)
+  }
+
+  it should "keep serving a collaborator the latest content as the author 
keeps editing" in {
+    val wid = createWorkflow("collaborator_tracks_latest")
+    publishPinned(wid)
+    grantAccess(wid, PrivilegeEnum.READ)
+
+    try {
+      val later = """{"operators":[],"note":"later_still"}"""
+      edit(wid, editedContent)
+      workflowResource.retrieveWorkflow(wid, strangerSession).content shouldBe 
editedContent
+      edit(wid, later)
+      workflowResource.retrieveWorkflow(wid, strangerSession).content shouldBe 
later
+      // ...while the public copy stayed put throughout.
+      workflowResource.retrievePublicWorkflow(wid).content shouldBe 
publishedContent
+    } finally revokeAccess(wid)
+  }
+
+  it should "not tell a public viewer that the author has unpublished edits" 
in {
+    val wid = createWorkflow("draft_state_is_private")
+    publishPinned(wid)
+    edit(wid, editedContent)
+
+    a[ForbiddenException] should be thrownBy 
workflowResource.getPublishStatus(wid, strangerSession)
+    statusOf(wid).hasUnpublishedChanges shouldBe true
+  }
+
+  it should "not expose the author's edit history to a public viewer" in {
+    // The other way into the working copy. Replaying a version folds deltas 
back from the author's
+    // *current* content, so a public viewer listing versions and checking one 
out is handed a draft
+    // the pin is holding back -- the one thing the frozen copy exists to 
prevent.
+    val wid = createWorkflow("history_is_not_public")
+    publishPinned(wid)
+    edit(wid, editedContent)
+    edit(wid, """{"operators":[],"note":"newer_still"}""")
+
+    val ownerVersions = versionResource.retrieveVersionsOfWorkflow(wid, 
ownerSession)
+    ownerVersions should not be empty
+    versionResource.retrieveVersionsOfWorkflow(wid, strangerSession) shouldBe 
empty
+    a[ForbiddenException] should be thrownBy
+      versionResource.retrieveWorkflowVersion(wid, ownerVersions.head.vId, 
strangerSession)
+  }
+
+  it should "still expose the history of a public workflow that is not pinned" 
in {
+    // Nothing is frozen, so the public copy is the author's latest and its 
history is the history of
+    // what everyone can already see. Taking that away would be a change this 
feature does not need.
+    val wid = createWorkflow("history_stays_public_while_following")
+    workflowResource.makePublic(wid, ownerSession)
+    edit(wid, editedContent)
+
+    val versions = versionResource.retrieveVersionsOfWorkflow(wid, 
strangerSession)
+    versions should not be empty
+    versionResource
+      .retrieveWorkflowVersion(wid, versions.head.vId, strangerSession)
+      .getContent should not be empty
+  }
+
+  it should "still expose the edit history to a collaborator" in {
+    // Sharing is not publishing: someone granted access tracks the author's 
latest, history included.
+    val wid = createWorkflow("history_visible_to_collaborator")
+    publishPinned(wid)
+    grantAccess(wid, PrivilegeEnum.READ)
+    try {
+      versionResource.retrieveVersionsOfWorkflow(wid, strangerSession) should 
not be empty
+    } finally revokeAccess(wid)
+  }
+
+  it should "refuse to hand out a public copy of a workflow that is not 
public" in {
+    // The guard viewers without granted access rely on: no route to a private 
workflow's content
+    // may fall through to the public copy just because the caller asked for 
it by wid.
+    val wid = createWorkflow("public_copy_requires_public")
+    a[NotFoundException] should be thrownBy 
WorkflowPublishService.publicCopyOf(wid)
+  }
+
+  it should "clone the published version, not the author's latest" in {
+    val wid = createWorkflow("clone_takes_published")
+    publishPinned(wid)
+    edit(wid, editedContent)
+
+    val clonedWid = workflowResource.cloneWorkflow(wid, strangerSession, 
fakeRequest())
+    val cloned = workflowDao.fetchOneByWid(clonedWid)
+
+    cloned.getContent shouldBe publishedContent
+    // A copy has never been reviewed, so it starts private.
+    cloned.getIsPublic shouldBe false
+  }
+
+  it should "clone the published version for the author too" in {
+    // The hub shows the pinned version, so its Clone button copies that even 
for the author, whose
+    // working copy has moved on -- cloning something other than what is on 
the screen would be the
+    // surprise, and their latest is already open in the editor.
+    val wid = createWorkflow("clone_takes_published_for_author")
+    publishPinned(wid)
+    edit(wid, editedContent)
+
+    val clonedWid = workflowResource.cloneWorkflow(wid, ownerSession, 
fakeRequest())
+
+    workflowDao.fetchOneByWid(clonedWid).getContent shouldBe publishedContent
+  }
+
+  it should "clone the published name and description, not the edited ones" in 
{
+    val wid = createWorkflow("clone_takes_published_metadata")
+    publishPinned(wid)
+    relabel(wid, "renamed_after_publishing", "described_after_publishing")
+
+    val cloned =
+      workflowDao.fetchOneByWid(workflowResource.cloneWorkflow(wid, 
ownerSession, fakeRequest()))
+
+    cloned.getName shouldBe "clone_takes_published_metadata_clone"
+    cloned.getDescription shouldBe "a workflow"
+  }
+
+  it should "still clone the working copy of a workflow that is not public" in 
{
+    val wid = createWorkflow("clone_private_takes_working_copy")
+    edit(wid, editedContent)
+
+    val clonedWid = workflowResource.cloneWorkflow(wid, ownerSession, 
fakeRequest())
+
+    workflowDao.fetchOneByWid(clonedWid).getContent shouldBe editedContent
+  }
+
+  it should "duplicate the published version for a user without granted 
access" in {
+    // Title and description too, not just the graph: a copy carrying the 
published canvas under the
+    // author's unpublished title would publish the very rename the pin is 
holding back.
+    val wid = createWorkflow("duplicate_takes_published")
+    publishPinned(wid)
+    edit(wid, editedContent)
+    relabel(wid, "duplicate_unpublished_name", "unpublished description")
+
+    val duplicated =
+      workflowResource.duplicateWorkflow(WorkflowIDs(List(wid)), 
strangerSession)
+
+    duplicated should have size 1
+    val copy = workflowDao.fetchOneByWid(duplicated.head.workflow.getWid)
+    copy.getContent shouldBe publishedContent
+    copy.getName shouldBe "duplicate_takes_published_copy"
+    copy.getDescription should not be "unpublished description"
+  }
+
+  it should "duplicate the owner's own working copy for the owner" in {
+    val wid = createWorkflow("owner_duplicates_working_copy")
+    publishPinned(wid)
+    edit(wid, editedContent)
+
+    val duplicated = 
workflowResource.duplicateWorkflow(WorkflowIDs(List(wid)), ownerSession)
+    workflowDao.fetchOneByWid(duplicated.head.workflow.getWid).getContent 
shouldBe editedContent
+  }
+
+  it should "start a copy of a published workflow with no publish state of its 
own" in {
+    val wid = createWorkflow("copy_starts_clean")
+    publishPinned(wid)
+
+    val copy = workflowDao.fetchOneByWid(
+      workflowResource
+        .duplicateWorkflow(WorkflowIDs(List(wid)), ownerSession)
+        .head
+        .workflow
+        .getWid
+    )
+
+    copy.getIsPublic shouldBe false
+    copy.getPublishedContent shouldBe null
+    copy.getPublishedName shouldBe null
+    copy.getPublishedDescription shouldBe null
+    copy.getPublishedDefaultView shouldBe null
+    copy.getPublishedVersionId shouldBe null
+  }
+
+  it should "size a workflow by the copy the caller can see" in {
+    // Listings show a size next to every card, so it has to describe the copy 
that card opens: the
+    // pinned one while a pin is in place, the author's latest otherwise, and 
a private workflow's
+    // own content.
+    val priv = createWorkflow("private_size_uses_content")
+    edit(priv, editedContent)
+    workflowResource.getSize(util.Arrays.asList(priv)).get(priv) shouldBe 
editedContent.length
+
+    val following = createWorkflow("size_follows_latest")
+    workflowResource.makePublic(following, ownerSession)
+    edit(following, editedContent + "     ")
+    workflowResource
+      .getSize(util.Arrays.asList(following))
+      .get(following) shouldBe editedContent.length + 5
+
+    val pinned = createWorkflow("size_uses_published")
+    publishPinned(pinned)
+    edit(pinned, editedContent + "                    ")
+    workflowResource
+      .getSize(util.Arrays.asList(pinned))
+      .get(pinned) shouldBe publishedContent.length
+  }
 }

Reply via email to