From 8af259c8a6ec61fa4d04734face158e6c444f812 Mon Sep 17 00:00:00 2001 From: Yang Zhang Date: Sat, 22 Aug 2026 14:54:23 -0700 Subject: [PATCH] feat(workflow): serve the pinned copy to public viewers With a version pinned, a workflow has two copies: the author's working copy and the frozen one on public show. This routes every read that serves a viewer without granted access through the frozen copy, and freezes the name and description with the graph. `WorkflowPublishService.publicCopyOf` returns the three fields as a group, so a surface cannot pick up the published graph under a title the author has not published; `WorkflowAccessResource.hasGrantedAccess` is the seam that decides which copy a caller gets. Granted access -- owner, shared, project member -- keeps tracking the author's latest, because sharing is not publishing. Name and description freeze because they are as public as the graph: if only the graph froze, a report about a title could be answered by editing the title while the pinned copy still advertised it. Routed through it: opening a workflow, the hub's read, Clone, Duplicate, `/workflow_name`, `/workflow_description` and the size a listing shows. A workflow that follows the author's latest -- every workflow today -- is served exactly what it is served now. Co-Authored-By: Claude Opus 5 --- .../workflow/WorkflowAccessResource.scala | 9 + .../workflow/WorkflowPublishService.scala | 62 +++- .../user/workflow/WorkflowResource.scala | 130 ++++--- .../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 09353d257f9..8f975f90413 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 48ad3c85f3e..9d6a208fcd0 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 bae2fa0047e..6fbd19bcc2c 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 41f0378579d..c218a1d4ef8 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 7a40981fed3..73306d84e37 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 + } }