diff --git a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/DatasetSearchQueryBuilder.scala b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/DatasetSearchQueryBuilder.scala
index 0cda3eecdc8..3f97486b2fa 100644
--- a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/DatasetSearchQueryBuilder.scala
+++ b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/DatasetSearchQueryBuilder.scala
@@ -103,7 +103,8 @@ object DatasetSearchQueryBuilder extends SearchQueryBuilder with LazyLogging {
override protected def constructWhereClause(
uid: Integer,
- params: DashboardResource.SearchQueryParams
+ params: DashboardResource.SearchQueryParams,
+ includePublic: Boolean
): Condition = {
val splitKeywords = params.keywords.asScala
.flatMap(_.split("[+\\-()<>~*@\"]"))
diff --git a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/ProjectSearchQueryBuilder.scala b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/ProjectSearchQueryBuilder.scala
index 5e247f87172..2a51cba74c4 100644
--- a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/ProjectSearchQueryBuilder.scala
+++ b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/ProjectSearchQueryBuilder.scala
@@ -58,7 +58,8 @@ object ProjectSearchQueryBuilder extends SearchQueryBuilder {
override protected def constructWhereClause(
uid: Integer,
- params: DashboardResource.SearchQueryParams
+ params: DashboardResource.SearchQueryParams,
+ includePublic: Boolean
): Condition = {
val splitKeywords = params.keywords.asScala
.flatMap(_.split("[+\\-()<>~*@\"]"))
diff --git a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/SearchQueryBuilder.scala b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/SearchQueryBuilder.scala
index e8a651cb821..985f293115f 100644
--- a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/SearchQueryBuilder.scala
+++ b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/SearchQueryBuilder.scala
@@ -59,7 +59,17 @@ trait SearchQueryBuilder {
includePublic: Boolean = false
): TableLike[_]
- protected def constructWhereClause(uid: Integer, params: SearchQueryParams): Condition
+ /**
+ * @param includePublic whether public resources the user has not been granted access to are in
+ * scope. Builders whose content differs between the public and the private
+ * view (workflows, whose public copy is a pinned version) need this to know
+ * which copy a filter may match against.
+ */
+ protected def constructWhereClause(
+ uid: Integer,
+ params: SearchQueryParams,
+ includePublic: Boolean = false
+ ): Condition
protected def getGroupByFields: Seq[GroupField] = Seq.empty
@@ -79,7 +89,7 @@ trait SearchQueryBuilder {
val query: SelectGroupByStep[Record] = context
.selectDistinct(mappedResourceSchema.allFields: _*)
.from(constructFromClause(uid, params, includePublic))
- .where(constructWhereClause(uid, params))
+ .where(constructWhereClause(uid, params, includePublic))
val groupByFields = getGroupByFields
if (groupByFields.nonEmpty) {
query.groupBy(groupByFields: _*)
diff --git a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/UnifiedResourceSchema.scala b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/UnifiedResourceSchema.scala
index aa3681ae1fb..673805ed714 100644
--- a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/UnifiedResourceSchema.scala
+++ b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/UnifiedResourceSchema.scala
@@ -77,7 +77,12 @@ object UnifiedResourceSchema {
isDatasetDownloadable: Field[java.lang.Boolean] = DSL.cast(null, classOf[java.lang.Boolean]),
datasetUserAccess: Field[PrivilegeEnum] = DSL.castNull(classOf[PrivilegeEnum]),
datasetCoverImage: Field[String] = DSL.cast(null, classOf[String]),
- workflowCoverImage: Field[String] = DSL.cast(null, classOf[String])
+ workflowCoverImage: Field[String] = DSL.cast(null, classOf[String]),
+ workflowHasUnpublishedChanges: Field[java.lang.Boolean] =
+ DSL.cast(null, classOf[java.lang.Boolean]),
+ workflowPublishedName: Field[String] = DSL.cast(null, classOf[String]),
+ workflowPublishedDescription: Field[String] = DSL.cast(null, classOf[String]),
+ viewerHasGrantedAccess: Field[java.lang.Boolean] = DSL.cast(null, classOf[java.lang.Boolean])
): UnifiedResourceSchema = {
new UnifiedResourceSchema(
Seq(
@@ -104,7 +109,13 @@ object UnifiedResourceSchema {
isDatasetDownloadable -> isDatasetDownloadable.as("is_dataset_downloadable"),
datasetUserAccess -> datasetUserAccess.as("user_dataset_access"),
datasetCoverImage -> datasetCoverImage.as("cover_image"),
- workflowCoverImage -> workflowCoverImage.as("workflow_cover_image")
+ workflowCoverImage -> workflowCoverImage.as("workflow_cover_image"),
+ workflowHasUnpublishedChanges -> workflowHasUnpublishedChanges
+ .as("workflow_has_unpublished_changes"),
+ workflowPublishedName -> workflowPublishedName.as("workflow_published_name"),
+ workflowPublishedDescription -> workflowPublishedDescription
+ .as("workflow_published_description"),
+ viewerHasGrantedAccess -> viewerHasGrantedAccess.as("viewer_has_granted_access")
)
)
}
diff --git a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/WorkflowSearchQueryBuilder.scala b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/WorkflowSearchQueryBuilder.scala
index b44ccaf30cc..601c471fbde 100644
--- a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/WorkflowSearchQueryBuilder.scala
+++ b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/WorkflowSearchQueryBuilder.scala
@@ -26,7 +26,7 @@ import org.apache.texera.web.resource.dashboard.FulltextSearchQueryUtils._
import org.apache.texera.web.resource.dashboard.user.workflow.WorkflowResource.DashboardWorkflow
import org.jooq.impl.DSL
import org.jooq.impl.DSL.groupConcatDistinct
-import org.jooq.{Condition, GroupField, Record, TableLike}
+import org.jooq.{Condition, Field, GroupField, Record, TableLike}
import scala.jdk.CollectionConverters.CollectionHasAsScala
import org.apache.texera.dao.jooq.generated.enums.PrivilegeEnum
@@ -54,7 +54,30 @@ object WorkflowSearchQueryBuilder extends SearchQueryBuilder {
ownerId = WORKFLOW_OF_USER.UID,
userName = USER.NAME,
projectsOfWorkflow = groupConcatDistinct(WORKFLOW_OF_PROJECT.PID),
- workflowCoverImage = DSL.max(WORKFLOW_COVER_IMAGE.IMAGE).as("workflow_cover_image")
+ workflowCoverImage = DSL.max(WORKFLOW_COVER_IMAGE.IMAGE).as("workflow_cover_image"),
+ // The isNotNull guard because isDistinctFrom would read a NULL pin as different content, and
+ // every unpinned public workflow would report drift. Aggregated to stay out of the GROUP BY,
+ // which would otherwise group the search by two TEXT columns.
+ workflowHasUnpublishedChanges = DSL
+ .boolOr(
+ WORKFLOW.PUBLISHED_CONTENT.isNotNull
+ .and(
+ WORKFLOW.PUBLISHED_CONTENT
+ .isDistinctFrom(WORKFLOW.CONTENT)
+ .or(WORKFLOW.PUBLISHED_NAME.isDistinctFrom(WORKFLOW.NAME))
+ .or(WORKFLOW.PUBLISHED_DESCRIPTION.isDistinctFrom(WORKFLOW.DESCRIPTION))
+ )
+ )
+ .as("workflow_has_unpublished_changes"),
+ // What a viewer without granted access is shown instead of the author's live metadata. NULL
+ // while following, which the reader treats as "show the live values".
+ workflowPublishedName = DSL.max(WORKFLOW.PUBLISHED_NAME).as("workflow_published_name"),
+ workflowPublishedDescription =
+ DSL.max(WORKFLOW.PUBLISHED_DESCRIPTION).as("workflow_published_description"),
+ // Both access joins are already restricted to the caller, so this needs no user id of its own.
+ viewerHasGrantedAccess = DSL
+ .boolOr(WORKFLOW_USER_ACCESS.UID.isNotNull.or(PROJECT_USER_ACCESS.UID.isNotNull))
+ .as("viewer_has_granted_access")
)
}
@@ -95,9 +118,57 @@ object WorkflowSearchQueryBuilder extends SearchQueryBuilder {
baseQuery.where(condition)
}
+ /** Rows the user was granted access to, as opposed to rows they see only because it is public. */
+ private def grantedAccessCondition(uid: Integer): Condition =
+ if (uid == null) DSL.falseCondition()
+ else WORKFLOW_USER_ACCESS.UID.eq(uid).or(PROJECT_USER_ACCESS.UID.eq(uid))
+
+ /**
+ * The three columns carrying one copy. They travel together because a pin freezes all three: a
+ * filter over content alone would match a pinned workflow on a title nobody public has seen.
+ */
+ private case class WorkflowCopy(
+ name: Field[String],
+ description: Field[String],
+ content: Field[String]
+ )
+
+ private val workingCopy =
+ WorkflowCopy(WORKFLOW.NAME, WORKFLOW.DESCRIPTION, WORKFLOW.CONTENT)
+ private val pinnedCopy =
+ WorkflowCopy(
+ WORKFLOW.PUBLISHED_NAME,
+ WORKFLOW.PUBLISHED_DESCRIPTION,
+ WORKFLOW.PUBLISHED_CONTENT
+ )
+
+ /**
+ * Applies a filter to whichever copy the user is allowed to see: one search can return both their
+ * own workflows and public ones, and a pinned public one must not turn up on keywords that exist
+ * only behind the pin. A disjunction of guarded filters over bare columns rather than a CASE, so
+ * each side stays eligible for its own PGroonga index.
+ */
+ private def onVisibleCopy(
+ uid: Integer,
+ includePublic: Boolean
+ )(build: WorkflowCopy => Condition): Condition = {
+ val onWorkingCopy = build(workingCopy).and(grantedAccessCondition(uid))
+ val onPublicCopy = WORKFLOW.IS_PUBLIC
+ .eq(true)
+ .and(
+ // Following leaves the pinned columns NULL, and the public copy is then the working one.
+ build(pinnedCopy)
+ .or(WORKFLOW.PUBLISHED_CONTENT.isNull.and(build(workingCopy)))
+ )
+ if (uid == null) onPublicCopy
+ else if (includePublic) onWorkingCopy.or(onPublicCopy)
+ else onWorkingCopy
+ }
+
override protected def constructWhereClause(
uid: Integer,
- params: DashboardResource.SearchQueryParams
+ params: DashboardResource.SearchQueryParams,
+ includePublic: Boolean
): Condition = {
val splitKeywords = params.keywords.asScala
.flatMap(_.split("[+\\-()<>~*@\"]"))
@@ -121,15 +192,25 @@ object WorkflowSearchQueryBuilder extends SearchQueryBuilder {
// Apply owner filter
.and(getContainsFilter(params.owners, USER.EMAIL))
// Apply operators filter
- .and(getOperatorsFilter(params.operators, WORKFLOW.CONTENT))
+ .and(
+ if (params.operators.isEmpty) DSL.noCondition()
+ else
+ onVisibleCopy(uid, includePublic)(copy =>
+ getOperatorsFilter(params.operators, copy.content)
+ )
+ )
// Apply projectId filter
.and(getContainsFilter(params.projectIds, WORKFLOW_OF_PROJECT.PID))
// Apply fulltext search filter
.and(
- getFullTextSearchFilter(
- splitKeywords,
- List(WORKFLOW.NAME, WORKFLOW.DESCRIPTION, WORKFLOW.CONTENT)
- )
+ if (splitKeywords.isEmpty) DSL.noCondition()
+ else
+ onVisibleCopy(uid, includePublic)(copy =>
+ getFullTextSearchFilter(
+ splitKeywords,
+ List(copy.name, copy.description, copy.content)
+ )
+ )
)
}
@@ -151,13 +232,27 @@ object WorkflowSearchQueryBuilder extends SearchQueryBuilder {
record: Record
): DashboardResource.DashboardClickableFileEntry = {
val pidField = groupConcatDistinct(WORKFLOW_OF_PROJECT.PID)
+ val workflow = record.into(WORKFLOW).into(classOf[Workflow])
+
+ // A viewer here only because the workflow is public sees the pinned name and description, the
+ // same copy the detail page serves -- otherwise a listing would advertise a title that opening it
+ // does not show. Both are NULL while following, which leaves the live values in place.
+ // Unknown counts as not granted: the reverse is the leak.
+ val granted = Option(record.get("viewer_has_granted_access", classOf[java.lang.Boolean]))
+ .exists(_.booleanValue())
+ if (!granted) {
+ Option(record.get("workflow_published_name", classOf[String])).foreach(workflow.setName)
+ Option(record.get("workflow_published_description", classOf[String]))
+ .foreach(workflow.setDescription)
+ }
+
val dw = DashboardWorkflow(
record.into(WORKFLOW_OF_USER).getUid == uid,
Option(record.get(WORKFLOW_USER_ACCESS.PRIVILEGE, classOf[PrivilegeEnum]))
.map(_.toString)
.getOrElse(PrivilegeEnum.NONE.toString),
record.into(USER).getName,
- record.into(WORKFLOW).into(classOf[Workflow]),
+ workflow,
if (record.get(pidField) == null) {
List[Integer]()
} else {
@@ -169,7 +264,10 @@ object WorkflowSearchQueryBuilder extends SearchQueryBuilder {
.toList
},
record.into(USER).getUid,
- Option(record.get("workflow_cover_image", classOf[String]))
+ Option(record.get("workflow_cover_image", classOf[String])),
+ // Null for the resource types that do not define the column at all.
+ Option(record.get("workflow_has_unpublished_changes", classOf[java.lang.Boolean]))
+ .exists(_.booleanValue())
)
DashboardClickableFileEntry(SearchQueryBuilder.WORKFLOW_RESOURCE_TYPE, workflow = Some(dw))
}
diff --git a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/hub/HubResource.scala b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/hub/HubResource.scala
index 33ba16c6e14..28d03fc9954 100644
--- a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/hub/HubResource.scala
+++ b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/hub/HubResource.scala
@@ -35,6 +35,7 @@ import org.apache.texera.web.resource.dashboard.hub.ActionType.{Clone, Like, Unl
import org.apache.texera.web.resource.dashboard.hub.EntityTables._
import org.apache.texera.web.resource.dashboard.hub.HubResource._
import org.apache.texera.web.resource.dashboard.user.dataset.DatasetResource.DashboardDataset
+import org.apache.texera.web.resource.dashboard.user.workflow.WorkflowPublishService
import org.apache.texera.web.resource.dashboard.user.workflow.WorkflowResource.{
DashboardWorkflow,
baseWorkflowSelect,
@@ -279,7 +280,23 @@ object HubResource {
)
.fetch()
- mapWorkflowEntries(records, uid)
+ // The hub is the public shelf, so everything on it is listed as the public sees it: while a pin
+ // is in place, under the name and description frozen with it rather than the author's live ones
+ // -- for the author too, who is looking at the shelf and not at their own dashboard.
+ val entries = mapWorkflowEntries(records, uid)
+ val pinned = WorkflowPublishService.pinnedListingsOf(entries.map(_.workflow.getWid))
+ entries.map { entry =>
+ pinned.get(entry.workflow.getWid) match {
+ case None => entry
+ case Some(listing) =>
+ entry.workflow.setName(listing.name)
+ entry.workflow.setDescription(listing.description)
+ // Carried like the search listing carries it: a card advertising the pinned copy has to
+ // open that copy, and without this flag the author's own card would open their editor and
+ // show them something else.
+ entry.copy(hasUnpublishedChanges = listing.hasUnpublishedChanges)
+ }
+ }
}
def fetchDashboardDatasetsByDids(dids: Seq[Integer], uid: Integer): List[DashboardDataset] = {
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 4fd22f4260c..5bfe2df8d05 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
@@ -103,6 +103,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
new file mode 100644
index 00000000000..790ac0a1e03
--- /dev/null
+++ b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowPublishService.scala
@@ -0,0 +1,281 @@
+/*
+ * 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, WORKFLOW_VERSION}
+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 scala.jdk.CollectionConverters.CollectionHasAsScala
+
+import java.sql.Timestamp
+import scala.util.Try
+import javax.ws.rs.NotFoundException
+
+/**
+ * Version pinning for public workflows.
+ *
+ * A public workflow follows the author's latest content, 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 `workflow.content` until they pin again.
+ *
+ * `is_public` stays the on/off switch; `published_content` is the pin, NULL while following.
+ *
+ * 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 --
+ * which the public read paths take up next.
+ */
+object WorkflowPublishService extends LazyLogging {
+
+ private def context: DSLContext = SqlServer.getInstance().createDSLContext()
+
+ /**
+ * @param pinnedVersionTime the date naming the version in both the dialog and the revision
+ * panel.
+ * @param hasUnpublishedChanges true when a pin is holding edits back, i.e. pinning again would
+ * publish them. Always false while following.
+ */
+ case class PublishStatus(
+ isPublished: Boolean,
+ isPinned: Boolean,
+ pinnedVersionTime: Option[Timestamp],
+ 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"))
+
+ /**
+ * Makes the workflow public, following the author's latest. A pin from a previous publication is
+ * not restored: coming back should not silently put old public content on show again.
+ */
+ 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")
+ }
+ logger.info(s"Workflow $wid published, following latest")
+ statusOf(wid)
+ }
+
+ /** Writes the pin. Name and description freeze with the graph, or a pin would leak the working title. */
+ private def writePin(
+ ctx: DSLContext,
+ workflow: Workflow,
+ versionId: Integer,
+ content: String
+ ): Unit =
+ ctx
+ .update(WORKFLOW)
+ .set(WORKFLOW.IS_PUBLIC, java.lang.Boolean.TRUE)
+ .set(WORKFLOW.PUBLISHED_VERSION_ID, versionId)
+ .set(WORKFLOW.PUBLISHED_CONTENT, content)
+ .set(WORKFLOW.PUBLISHED_NAME, workflow.getName)
+ .set(WORKFLOW.PUBLISHED_DESCRIPTION, workflow.getDescription)
+ .where(WORKFLOW.WID.eq(workflow.getWid))
+ .execute()
+
+ /**
+ * Clears the pinned copy in one statement, optionally unpublishing too: the CHECK constraint
+ * holds only while the copy and `is_public` move together.
+ *
+ * @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_VERSION_ID, null.asInstanceOf[Integer])
+ .set(WORKFLOW.PUBLISHED_CONTENT, null.asInstanceOf[String])
+ .set(WORKFLOW.PUBLISHED_NAME, null.asInstanceOf[String])
+ .set(WORKFLOW.PUBLISHED_DESCRIPTION, null.asInstanceOf[String])
+ val statement =
+ if (alsoUnpublish) cleared.set(WORKFLOW.IS_PUBLIC, java.lang.Boolean.FALSE) else cleared
+ statement.where(WORKFLOW.WID.eq(wid)).execute()
+ }
+
+ /** Pins the current content as the public copy. Moving a pin forward is the same operation. */
+ def pinLatest(wid: Integer): PublishStatus = {
+ context.transaction { txConfig =>
+ val ctx = org.jooq.impl.DSL.using(txConfig)
+ // Locked, not merely read: two pins racing would both read before either wrote, and both
+ // insert an anchor.
+ val workflow = ctx
+ .selectFrom(WORKFLOW)
+ .where(WORKFLOW.WID.eq(wid))
+ .forUpdate()
+ .fetchOneInto(classOf[Workflow])
+ if (workflow == null) {
+ throw new NotFoundException(s"Workflow $wid not found")
+ }
+
+ // An anchor in the revision history for the copy being pinned: its delta is the identity patch,
+ // so replaying this row returns what was published however many edits pile up later. An
+ // existing version row cannot stand in, since one replays to the content as it was *before*
+ // the change it records. Pinning again unchanged reuses the anchor rather than adding a twin.
+ val anchorVid = Option(workflow.getPublishedVersionId)
+ .filter(_ =>
+ Option(workflow.getPublishedContent).exists(sameContent(_, workflow.getContent))
+ )
+ .getOrElse(WorkflowVersionResource.insertNewVersion(wid, ctx = ctx).getVid)
+
+ writePin(ctx, workflow, anchorVid, workflow.getContent)
+ }
+ 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) {
+ throw new NotFoundException(s"Workflow $wid not found")
+ }
+ 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 = {
+ clearPin(wid, alsoUnpublish = true)
+ logger.info(s"Workflow $wid unpublished")
+ }
+
+ /** Read from the version row, so the dialog and the revision panel print one date, not two clocks'. */
+ private def pinnedVersionTimeOf(versionId: Integer): Option[Timestamp] =
+ Option(
+ context
+ .select(WORKFLOW_VERSION.CREATION_TIME)
+ .from(WORKFLOW_VERSION)
+ .where(WORKFLOW_VERSION.VID.eq(versionId))
+ .fetchOneInto(classOf[Timestamp])
+ )
+
+ /** The date a public viewer should see: that of the version on show, not of an edit they cannot. */
+ def publicModifiedTime(workflow: Workflow): Timestamp =
+ Option(workflow.getPublishedVersionId)
+ .filter(_ => workflow.getPublishedContent != null)
+ .flatMap(pinnedVersionTimeOf)
+ .getOrElse(workflow.getLastModifiedTime)
+
+ /** Whether a version is pinned, and whether it is holding edits back. */
+ def statusOf(wid: Integer): PublishStatus = statusOf(requireWorkflow(wid))
+
+ def statusOf(workflow: Workflow): PublishStatus =
+ PublishStatus(
+ isPublished = workflow.getIsPublic,
+ isPinned = workflow.getPublishedContent != null,
+ pinnedVersionTime = Option(workflow.getPublishedVersionId).flatMap(pinnedVersionTimeOf),
+ // 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))
+ )
+
+ /** 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 ||
+ !sameContent(public.content, working.content)
+
+ /** Everything about a workflow that is on public show. */
+ case class PublicCopy(name: String, description: String, content: String)
+
+ /** 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
+ )
+
+ /** The author's own copy, in the same shape. */
+ private def workingCopyOf(workflow: Workflow): PublicCopy =
+ PublicCopy(workflow.getName, workflow.getDescription, workflow.getContent)
+
+ /**
+ * What a listing needs about one pinned workflow: the frozen name and description it must show
+ * instead of the author's live ones, and whether those live ones have moved on.
+ */
+ case class PinnedListing(name: String, description: String, hasUnpublishedChanges: Boolean)
+
+ /**
+ * The pinned listings among `wids`, keyed by wid. A workflow that follows the author's latest is
+ * simply absent, which leaves its live values in place and its drift flag false.
+ *
+ * Drift is decided in SQL here rather than by [[differs]], because a listing asks about many
+ * workflows at once and none of their contents are worth shipping back to compare in memory.
+ */
+ def pinnedListingsOf(wids: Seq[Integer]): Map[Integer, PinnedListing] =
+ if (wids.isEmpty) Map()
+ else {
+ val drifted = WORKFLOW.PUBLISHED_CONTENT
+ .isDistinctFrom(WORKFLOW.CONTENT)
+ .or(WORKFLOW.PUBLISHED_NAME.isDistinctFrom(WORKFLOW.NAME))
+ .or(WORKFLOW.PUBLISHED_DESCRIPTION.isDistinctFrom(WORKFLOW.DESCRIPTION))
+ context
+ .select(WORKFLOW.WID, WORKFLOW.PUBLISHED_NAME, WORKFLOW.PUBLISHED_DESCRIPTION, drifted)
+ .from(WORKFLOW)
+ .where(WORKFLOW.WID.in(wids: _*).and(WORKFLOW.PUBLISHED_CONTENT.isNotNull))
+ .fetch()
+ .asScala
+ .map(row =>
+ row.get(WORKFLOW.WID) -> PinnedListing(
+ row.get(WORKFLOW.PUBLISHED_NAME),
+ row.get(WORKFLOW.PUBLISHED_DESCRIPTION),
+ row.get(drifted)
+ )
+ )
+ .toMap
+ }
+
+ /** 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 2d438e8dc75..6af458383db 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
@@ -43,7 +43,7 @@ import org.apache.texera.web.resource.dashboard.hub.HubResource.recordCloneActio
import org.apache.texera.web.resource.dashboard.user.workflow.WorkflowAccessResource.hasReadAccess
import org.apache.texera.web.resource.dashboard.user.workflow.WorkflowResource._
import org.jooq.impl.DSL.{groupConcatDistinct, noCondition, max}
-import org.jooq.{Condition, DSLContext, Record10, Result, SelectOnConditionStep}
+import org.jooq.{Condition, DSLContext, Record10, Result, SelectOnConditionStep, TableField}
import java.sql.Timestamp
import java.util
@@ -93,6 +93,9 @@ object WorkflowResource {
}
private def insertWorkflow(workflow: Workflow, user: User): Unit = {
+ // A workflow is born with nothing pinned, whether or not the caller asked for a public one:
+ // clearing the columns here is what stops a request body from seeding a public copy of its own.
+ clearPublishState(workflow)
workflowDao.insert(workflow)
workflowOfUserDao.insert(new WorkflowOfUser(user.getUid, workflow.getWid))
workflowUserAccessDao.insert(
@@ -104,6 +107,22 @@ object WorkflowResource {
)
}
+ /**
+ * Writes only the columns a save is allowed to change. Going through the DAO would write the
+ * publish columns from the request body, or restore them from a stale read -- rolling back a pin
+ * that landed in between, or leaving a private workflow carrying one, which the constraint
+ * refuses outright.
+ */
+ private def updateEditableFields(workflow: Workflow): Unit = {
+ context
+ .update(WORKFLOW)
+ .set(WORKFLOW.NAME, workflow.getName)
+ .set(WORKFLOW.DESCRIPTION, workflow.getDescription)
+ .set(WORKFLOW.CONTENT, workflow.getContent)
+ .where(WORKFLOW.WID.eq(workflow.getWid))
+ .execute()
+ }
+
private def workflowOfUserExists(wid: Integer, uid: Integer): Boolean = {
workflowOfUserDao.existsById(
context
@@ -127,7 +146,10 @@ object WorkflowResource {
workflow: Workflow,
projectIDs: List[Integer],
ownerId: Integer,
- coverImage: Option[String]
+ coverImage: Option[String],
+ // Behind the author's working copy? Listings use it to open the published preview instead of
+ // the editor -- for the author too, since that is what the entry is showing.
+ hasUnpublishedChanges: Boolean = false
)
case class WorkflowWithPrivilege(
@@ -143,6 +165,68 @@ object WorkflowResource {
case class WorkflowIDs(wids: List[Integer], pid: Option[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)
+ }
+
+ /**
+ * 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, description: String, content: String): Workflow = {
+ val workflow = new Workflow()
+ workflow.setName(name)
+ workflow.setDescription(description)
+ workflow.setContent(content)
+ workflow.setIsPublic(false)
+ clearPublishState(workflow)
+ workflow
+ }
+
+ /**
+ * The copy a viewer may see, all three fields at once: granted access (owner, shared, project
+ * member) sees the author's working copy; everyone else is here 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 has not 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
+ )
+ } else {
+ WorkflowPublishService.publicCopyOf(workflow)
+ }
+
+ /** The content half of [[copyVisibleTo]], for the paths that carry their own name and description. */
+ def contentVisibleTo(workflow: Workflow, uid: Integer): String =
+ copyVisibleTo(workflow, uid).content
+
+ /**
+ * 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
+
private def updateWorkflowField(
workflow: Workflow,
sessionUser: SessionUser,
@@ -157,9 +241,11 @@ object WorkflowResource {
user.getUid
)
) {
+ // Same reason as updateEditableFields: a read-modify-write through the DAO would rewrite the
+ // publish columns from a stale read.
val userWorkflow = workflowDao.fetchOneByWid(wid)
updateFunction(userWorkflow)
- workflowDao.update(userWorkflow)
+ updateEditableFields(userWorkflow)
} else {
throw new ForbiddenException("No sufficient access privilege.")
}
@@ -413,7 +499,9 @@ class WorkflowResource extends LazyLogging {
workflow.getName,
workflow.getDescription,
workflow.getWid,
- workflow.getContent,
+ // A user who only reaches this workflow because it is public gets the pinned version, not
+ // the author's in-progress edits.
+ contentVisibleTo(workflow, user.getUid),
workflow.getCreationTime,
workflow.getLastModifiedTime,
workflow.getIsPublic,
@@ -445,7 +533,7 @@ class WorkflowResource extends LazyLogging {
if (workflowOfUserExists(workflow.getWid, user.getUid)) {
WorkflowVersionResource.insertVersion(workflow, insertingNewWorkflow = false)
- workflowDao.update(workflow)
+ updateEditableFields(workflow)
} else {
if (!WorkflowAccessResource.hasReadAccess(workflow.getWid, user.getUid)) {
// Check if this workflow exists in the database
@@ -462,7 +550,7 @@ class WorkflowResource extends LazyLogging {
} else if (WorkflowAccessResource.hasWriteAccess(workflow.getWid, user.getUid)) {
WorkflowVersionResource.insertVersion(workflow, insertingNewWorkflow = false)
// not owner but has write access
- workflowDao.update(workflow)
+ updateEditableFields(workflow)
} else {
// not owner and no write access -> rejected
throw new ForbiddenException("No sufficient access privilege.")
@@ -503,15 +591,14 @@ 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(
- new Workflow(
- null,
- oldWorkflow.getName + "_copy",
- oldWorkflow.getDescription,
- assignNewOperatorIds(oldWorkflow.getContent),
- null,
- null,
- false
+ newUnpublishedWorkflow(
+ source.name + "_copy",
+ source.description,
+ assignNewOperatorIds(source.content)
),
sessionUser
)
@@ -553,15 +640,14 @@ 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(
- new Workflow(
- null,
- oldWorkflow.getName + "_clone",
- oldWorkflow.getDescription,
- assignNewOperatorIds(oldWorkflow.getContent),
- null,
- null,
- false
+ newUnpublishedWorkflow(
+ source.name + "_clone",
+ source.description,
+ assignNewOperatorIds(source.content)
),
sessionUser
)
@@ -713,9 +799,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
@@ -725,9 +809,65 @@ 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.
+ if (!WorkflowAccessResource.hasWriteAccess(wid, user.getUid)) {
+ throw new ForbiddenException(s"You do not have permission to modify workflow $wid")
+ }
+ WorkflowPublishService.statusOf(wid)
+ }
+
+ /** The pin endpoints share one guard: writable by this user, and public in the first place. */
+ private def requirePublishable(wid: Integer, user: SessionUser): Unit = {
+ if (!WorkflowAccessResource.hasWriteAccess(wid, user.getUid)) {
+ throw new ForbiddenException(s"You do not have permission to modify workflow $wid")
+ }
+ if (!WorkflowAccessResource.isPublic(wid)) {
+ throw new BadRequestException(s"Workflow $wid is not published")
+ }
}
/** Returns the workflow's cover image; 404 if none set. */
@@ -825,15 +965,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(
@@ -844,13 +979,18 @@ 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 stored = workflow.into(classOf[Workflow])
+ val publicCopy = WorkflowPublishService.publicCopyOf(stored)
WorkflowWithPrivilege(
- workflow.getName,
- workflow.getDescription,
+ publicCopy.name,
+ publicCopy.description,
workflow.getWid,
- workflow.getContent,
+ publicCopy.content,
workflow.getCreationTime,
- workflow.getLastModifiedTime,
+ // Dated by the version on show, not by the author's most recent private edit.
+ WorkflowPublishService.publicModifiedTime(stored),
workflow.getIsPublic,
readonly = true
)
@@ -859,13 +999,7 @@ class WorkflowResource extends LazyLogging {
@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
@@ -881,7 +1015,11 @@ 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 content =
+ if (wf.getIsPublic && wf.getPublishedContent != null) wf.getPublishedContent
+ else wf.getContent
+ result.put(wf.getWid, 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 e0664b7c1d4..c2af5b7a906 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
@@ -30,7 +30,8 @@ import org.apache.texera.dao.jooq.generated.tables.daos.{WorkflowDao, WorkflowVe
import org.apache.texera.dao.jooq.generated.tables.pojos.{Workflow, WorkflowVersion}
import org.apache.texera.web.resource.dashboard.user.workflow.WorkflowResource.{
DashboardWorkflow,
- assignNewOperatorIds
+ assignNewOperatorIds,
+ newUnpublishedWorkflow
}
import org.apache.texera.web.resource.dashboard.user.workflow.WorkflowVersionResource._
import org.jooq.DSLContext
@@ -53,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 history to a public
+ * viewer would hand them exactly 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
@@ -127,11 +141,15 @@ object WorkflowVersionResource {
*
* @param wid
*/
- def insertNewVersion(wid: Integer, content: String = "[]"): WorkflowVersion = {
+ def insertNewVersion(
+ wid: Integer,
+ content: String = "[]",
+ ctx: DSLContext = context
+ ): WorkflowVersion = {
val workflowVersion = new WorkflowVersion()
workflowVersion.setContent(content)
workflowVersion.setWid(wid)
- workflowVersionDao.insert(workflowVersion)
+ new WorkflowVersionDao(ctx.configuration).insert(workflowVersion)
workflowVersion
}
@@ -195,39 +213,45 @@ object WorkflowVersionResource {
* @return
*/
private def encodeVersionImportance(
- currentVersions: List[WorkflowVersion]
+ currentVersions: List[WorkflowVersion],
+ publicVersionId: Option[Integer]
): List[VersionEntry] = {
var impEncodedVersions: List[VersionEntry] = List()
+ // A pin's anchor is not a version the author made: it carries the identity patch and appears the
+ // moment they pin. It is always shown, so they can find and restore what the public has, but it
+ // must not start the aggregation window -- the save it was pinned from is seconds older, and
+ // would otherwise be folded into it and disappear from the panel.
+ def isAnchor(version: WorkflowVersion): Boolean = publicVersionId.contains(version.getVid)
+
val lastVersion = currentVersions.head
- var lastVersionTime = lastVersion.getCreationTime
+ var lastVersionTime: Option[Timestamp] =
+ if (isAnchor(lastVersion)) None else Some(lastVersion.getCreationTime)
impEncodedVersions = impEncodedVersions :+ VersionEntry(
lastVersion.getVid,
lastVersion.getCreationTime,
lastVersion.getContent,
- true
- ) // the first (latest)
- // version is important even if it is positional
+ true,
+ isAnchor(lastVersion)
+ ) // the first (latest) version is important even if it is positional
var versionImportance: Boolean = true
for (version <- currentVersions.tail) {
- if (
- isWithinTimeLimit(
- lastVersionTime,
- version.getCreationTime
- )
- ) {
+ if (isAnchor(version)) {
+ versionImportance = true
+ } else if (lastVersionTime.exists(isWithinTimeLimit(_, version.getCreationTime))) {
versionImportance = false
} // try reducing unnecessary check of positional versions
// because parsing the Json string is expensive
else {
- lastVersionTime = version.getCreationTime
+ lastVersionTime = Some(version.getCreationTime)
versionImportance = isVersionImportant(version.getContent)
}
impEncodedVersions = impEncodedVersions :+ VersionEntry(
version.getVid,
version.getCreationTime,
version.getContent,
- versionImportance
+ versionImportance,
+ isAnchor(version)
)
}
impEncodedVersions
@@ -326,7 +350,10 @@ object WorkflowVersionResource {
vId: Integer,
creationTime: Timestamp,
content: String,
- importance: Boolean
+ importance: Boolean,
+ // True for the one version the Hub is serving right now, so the author can tell at a glance
+ // where the public copy sits relative to what they are editing.
+ isCurrentlyPublic: Boolean = false
)
}
@@ -349,7 +376,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(
@@ -360,7 +387,8 @@ class WorkflowVersionResource {
.orderBy(WORKFLOW_VERSION.CREATION_TIME.desc())
.fetchInto(classOf[WorkflowVersion])
.asScala
- .toList
+ .toList,
+ Option(workflowDao.fetchOneByWid(wid)).flatMap(w => Option(w.getPublishedVersionId))
)
}
}
@@ -384,7 +412,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
@@ -428,14 +456,10 @@ class WorkflowVersionResource {
val newWorkflow: DashboardWorkflow =
try {
workflowResource.createWorkflow(
- new Workflow(
- null,
+ newUnpublishedWorkflow(
newWorkflowName,
workflowVersion.getDescription,
- assignNewOperatorIds(workflowVersion.getContent),
- null,
- null,
- false
+ assignNewOperatorIds(workflowVersion.getContent)
),
sessionUser
)
diff --git a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/UnifiedResourceSchemaSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/UnifiedResourceSchemaSpec.scala
index 9d5f1e0b0bd..3b8f66be6e5 100644
--- a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/UnifiedResourceSchemaSpec.scala
+++ b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/UnifiedResourceSchemaSpec.scala
@@ -79,10 +79,11 @@ class UnifiedResourceSchemaSpec extends AnyFlatSpec with Matchers {
// Sentinels for the three slots that have no convenient distinct table
// column of the right type; every other slot uses a real generated column so
- // that all 24 originals render differently from one another.
+ // that all 28 originals render differently from one another.
private val sentinelResourceType: Field[String] = JDSL.inline("s-resource-type")
private val sentinelProjects: Field[String] = JDSL.inline("s-projects")
private val sentinelStoragePath: Field[String] = JDSL.inline("s-storage-path")
+ private val sentinelGranted: Field[java.lang.Boolean] = JDSL.inline(java.lang.Boolean.TRUE)
private val sentinelSchema: UnifiedResourceSchema = UnifiedResourceSchema(
resourceType = sentinelResourceType,
@@ -108,7 +109,11 @@ class UnifiedResourceSchemaSpec extends AnyFlatSpec with Matchers {
isDatasetDownloadable = DATASET.IS_DOWNLOADABLE,
datasetUserAccess = DATASET_USER_ACCESS.PRIVILEGE,
datasetCoverImage = DATASET.COVER_IMAGE,
- workflowCoverImage = WORKFLOW_COVER_IMAGE.IMAGE
+ workflowCoverImage = WORKFLOW_COVER_IMAGE.IMAGE,
+ workflowHasUnpublishedChanges = WORKFLOW.IS_PUBLIC,
+ workflowPublishedName = WORKFLOW.PUBLISHED_NAME,
+ workflowPublishedDescription = WORKFLOW.PUBLISHED_DESCRIPTION,
+ viewerHasGrantedAccess = sentinelGranted
)
// Expected projection, in order: alias -> the original it must be built from.
@@ -136,13 +141,17 @@ class UnifiedResourceSchemaSpec extends AnyFlatSpec with Matchers {
"is_dataset_downloadable" -> DATASET.IS_DOWNLOADABLE,
"user_dataset_access" -> DATASET_USER_ACCESS.PRIVILEGE,
"cover_image" -> DATASET.COVER_IMAGE,
- "workflow_cover_image" -> WORKFLOW_COVER_IMAGE.IMAGE
+ "workflow_cover_image" -> WORKFLOW_COVER_IMAGE.IMAGE,
+ "workflow_has_unpublished_changes" -> WORKFLOW.IS_PUBLIC,
+ "workflow_published_name" -> WORKFLOW.PUBLISHED_NAME,
+ "workflow_published_description" -> WORKFLOW.PUBLISHED_DESCRIPTION,
+ "viewer_has_granted_access" -> sentinelGranted
)
// -- apply(): the projection ------------------------------------------------
- "apply" should "expose all 24 slots as aliases, in the order the UNION ALL depends on" in {
- sentinelSchema.allFields should have size 24
+ "apply" should "expose all 28 slots as aliases, in the order the UNION ALL depends on" in {
+ sentinelSchema.allFields should have size 28
sentinelSchema.allFields.map(_.getName) shouldBe expectedProjection.map(_._1)
}
@@ -162,7 +171,7 @@ class UnifiedResourceSchemaSpec extends AnyFlatSpec with Matchers {
// about datasets still union with one that does: the column count and
// types have to line up.
val defaults = UnifiedResourceSchema()
- defaults.allFields should have size 24
+ defaults.allFields should have size 28
val rendered = ctx.renderInlined(JDSL.select(defaults.allFields: _*))
rendered should include("'' as \"resourceType\"")
rendered should include("cast(null as timestamp) as \"resourceCreationTime\"")
@@ -197,12 +206,12 @@ class UnifiedResourceSchemaSpec extends AnyFlatSpec with Matchers {
}
it should "collapse the all-defaults projection down to one alias per distinct default" in {
- // 24 slots, but only six structurally distinct default expressions, so the
+ // 28 slots, but only six structurally distinct default expressions, so the
// de-dup collapses the map to six entries. Worth pinning because it is
// surprising, and because it is what makes the keep-first rule observable at
- // all: allFields stays at 24 while the translation map does not.
+ // all: allFields stays at 28 while the translation map does not.
val defaults = UnifiedResourceSchema()
- defaults.allFields should have size 24
+ defaults.allFields should have size 28
translatedAliases(defaults) shouldBe Seq(
"resourceType", // DSL.inline("")
"resourceCreationTime", // cast(null as timestamp)
@@ -213,7 +222,7 @@ class UnifiedResourceSchemaSpec extends AnyFlatSpec with Matchers {
)
}
- it should "keep every distinct original when the caller supplies 24 distinct Fields" in {
+ it should "keep every distinct original when the caller supplies 28 distinct Fields" in {
// Nothing to collapse here, which is the control case for the two tests
// above: the shrinkage they observe comes from duplicate originals only.
translatedAliases(sentinelSchema) shouldBe expectedProjection.map(_._1)
@@ -221,7 +230,7 @@ class UnifiedResourceSchemaSpec extends AnyFlatSpec with Matchers {
it should "drop exactly the duplicated slots of the production workflow projection" in {
val workflowSchema = WorkflowSearchQueryBuilder.mappedResourceSchema
- workflowSchema.allFields should have size 24
+ workflowSchema.allFields should have size 28
val aliases = translatedAliases(workflowSchema)
// `uid` duplicates ownerId (WORKFLOW_OF_USER.UID); the rest are slots the
// builder left at their default, and the defaults collide by type.
@@ -241,7 +250,7 @@ class UnifiedResourceSchemaSpec extends AnyFlatSpec with Matchers {
"jOOQ Field equality" should "be structural, which is what makes the de-dup collapse anything" in {
// If jOOQ ever switched to identity equality, translatedFieldSet would keep
- // all 24 slots and translateRecord would start reading duplicated columns —
+ // all 28 slots and translateRecord would start reading duplicated columns —
// the tests above would flip, and this one says why.
JDSL.cast(null, classOf[Integer]) shouldBe JDSL.cast(null, classOf[Integer])
JDSL.inline("") shouldBe JDSL.inline("")
diff --git a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/WorkflowSearchQueryBuilderSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/WorkflowSearchQueryBuilderSpec.scala
index 59dd8d9e19f..ae40cc28735 100644
--- a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/WorkflowSearchQueryBuilderSpec.scala
+++ b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/WorkflowSearchQueryBuilderSpec.scala
@@ -68,6 +68,13 @@ class WorkflowSearchQueryBuilderSpec extends AnyFlatSpec with Matchers {
// QueryParts structurally. The first test pins that assumption.
private val pidField = JDSL.groupConcatDistinct(WORKFLOW_OF_PROJECT.PID)
private val coverField = JDSL.max(WORKFLOW_COVER_IMAGE.IMAGE).as("workflow_cover_image")
+ private val driftField =
+ JDSL.field(JDSL.name("workflow_has_unpublished_changes"), classOf[java.lang.Boolean])
+ private val publishedNameField = JDSL.field(JDSL.name("workflow_published_name"), classOf[String])
+ private val publishedDescriptionField =
+ JDSL.field(JDSL.name("workflow_published_description"), classOf[String])
+ private val grantedField =
+ JDSL.field(JDSL.name("viewer_has_granted_access"), classOf[java.lang.Boolean])
private val ownerUid: Integer = Integer.valueOf(42)
private val viewerUid: Integer = Integer.valueOf(43)
@@ -85,7 +92,13 @@ class WorkflowSearchQueryBuilderSpec extends AnyFlatSpec with Matchers {
uidValue: Integer = ownerUid,
privilege: PrivilegeEnum = PrivilegeEnum.WRITE,
projects: String = "3,1,2",
- cover: String = "cover-b64"
+ cover: String = "cover-b64",
+ hasUnpublishedChanges: java.lang.Boolean = null,
+ // The rows these tests describe belong to a viewer who was granted access, which is what
+ // leaves the author's own name and description in place.
+ grantedAccess: java.lang.Boolean = java.lang.Boolean.TRUE,
+ publishedName: String = null,
+ publishedDescription: String = null
): Record = {
val record = ctx.newRecord(
WORKFLOW.WID,
@@ -95,7 +108,11 @@ class WorkflowSearchQueryBuilderSpec extends AnyFlatSpec with Matchers {
WORKFLOW_USER_ACCESS.PRIVILEGE,
USER.NAME,
pidField,
- coverField
+ coverField,
+ driftField,
+ publishedNameField,
+ publishedDescriptionField,
+ grantedField
)
record.set(WORKFLOW.WID, wid)
record.set(WORKFLOW.NAME, "wf-name")
@@ -105,12 +122,61 @@ class WorkflowSearchQueryBuilderSpec extends AnyFlatSpec with Matchers {
record.set(USER.NAME, "owner-name")
record.set(pidField, projects)
record.set(coverField, cover)
+ record.set(driftField, hasUnpublishedChanges)
+ record.set(publishedNameField, publishedName)
+ record.set(publishedDescriptionField, publishedDescription)
+ record.set(grantedField, grantedAccess)
record
}
private def workflowOf(record: Record, uid: Integer): DashboardWorkflow =
WorkflowSearchQueryBuilder.toEntryImpl(uid, record).workflow.get
+ // -- what a viewer without granted access is shown --------------------------
+
+ "toEntryImpl" should "serve the published name and description to a viewer without granted access" in {
+ // Publishing pins what the public sees, and a listing is where the public meets a workflow, so
+ // the listing has to serve the pinned copy too rather than the author's live metadata.
+ val record = translatedRecord(
+ grantedAccess = java.lang.Boolean.FALSE,
+ publishedName = "as-published",
+ publishedDescription = "described-as-published"
+ )
+
+ val workflow = workflowOf(record, viewerUid).workflow
+
+ workflow.getName shouldBe "as-published"
+ workflow.getDescription shouldBe "described-as-published"
+ }
+
+ it should "leave the author's live name and description for a viewer who was granted access" in {
+ val record = translatedRecord(
+ grantedAccess = java.lang.Boolean.TRUE,
+ publishedName = "as-published",
+ publishedDescription = "described-as-published"
+ )
+
+ val workflow = workflowOf(record, ownerUid).workflow
+
+ workflow.getName shouldBe "wf-name"
+ workflow.getDescription shouldBe "wf-description"
+ }
+
+ it should "treat an unknown access answer as public, which is the safe direction" in {
+ // Showing the published copy to someone who turns out to have access is harmless; the reverse
+ // would hand the author's live metadata to the public.
+ val record = translatedRecord(grantedAccess = null, publishedName = "as-published")
+
+ workflowOf(record, viewerUid).workflow.getName shouldBe "as-published"
+ }
+
+ it should "keep the author's name when a public workflow has nothing pinned" in {
+ // Nothing to substitute, so the row is left as it is rather than blanked.
+ val record = translatedRecord(grantedAccess = java.lang.Boolean.FALSE, publishedName = null)
+
+ workflowOf(record, viewerUid).workflow.getName shouldBe "wf-name"
+ }
+
// -- the aggregate-field lookup --------------------------------------------
"toEntryImpl" should "resolve the project aggregate through structural Field equality" in {
diff --git a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/workflow/PublishedCopySchemaSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/workflow/PublishedCopySchemaSpec.scala
new file mode 100644
index 00000000000..08a87869c56
--- /dev/null
+++ b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/workflow/PublishedCopySchemaSpec.scala
@@ -0,0 +1,93 @@
+/*
+ * 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.dao.MockTexeraDB
+import org.apache.texera.dao.jooq.generated.tables.daos.WorkflowDao
+import org.apache.texera.dao.jooq.generated.tables.pojos.Workflow
+import org.jooq.exception.DataAccessException
+import org.scalatest.BeforeAndAfterAll
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+
+/**
+ * The columns a pinned public copy lives in, and the constraint that keeps them honest.
+ *
+ * Nothing writes them yet: this covers what the migration alone guarantees -- that a workflow
+ * public today keeps behaving as it does, and that a private workflow can never carry a pin.
+ */
+class PublishedCopySchemaSpec
+ extends AnyFlatSpec
+ with Matchers
+ with BeforeAndAfterAll
+ with MockTexeraDB {
+
+ private var workflowDao: WorkflowDao = _
+
+ override protected def beforeAll(): Unit = {
+ initializeDBAndReplaceDSLContext()
+ workflowDao = new WorkflowDao(getDSLContext.configuration())
+ }
+
+ /** A workflow as it exists before anything pins it: content only, nothing frozen. */
+ private def insertWorkflow(name: String, isPublic: Boolean): Workflow = {
+ val workflow = new Workflow()
+ workflow.setName(name)
+ workflow.setDescription("a workflow")
+ workflow.setContent("""{"operators":[]}""")
+ workflow.setIsPublic(isPublic)
+ workflowDao.insert(workflow)
+ workflowDao.fetchOneByWid(workflow.getWid)
+ }
+
+ behavior of "the published-copy columns"
+
+ it should "leave every workflow following the author's latest" in {
+ // The migration adds columns and no backfill, so a workflow that was public before it ran shows
+ // exactly what it showed: nothing is frozen, which is the state the rest of the feature calls
+ // "following".
+ val stored = insertWorkflow("migration_changes_nothing", isPublic = true)
+
+ stored.getPublishedContent shouldBe null
+ stored.getPublishedName shouldBe null
+ stored.getPublishedDescription shouldBe null
+ stored.getPublishedVersionId shouldBe null
+ }
+
+ it should "let a public workflow carry a frozen copy" in {
+ val stored = insertWorkflow("public_may_be_pinned", isPublic = true)
+ stored.setPublishedContent("""{"operators":[]}""")
+ stored.setPublishedName("frozen name")
+ stored.setPublishedDescription("frozen description")
+
+ workflowDao.update(stored)
+
+ workflowDao.fetchOneByWid(stored.getWid).getPublishedName shouldBe "frozen name"
+ }
+
+ it should "refuse a private workflow that carries a frozen copy" in {
+ // A pin only means something while the workflow is public. The database rejects the other case,
+ // so no code path can leave one behind -- unpublishing has to clear the copy.
+ val stored = insertWorkflow("private_cannot_be_pinned", isPublic = false)
+ stored.setPublishedContent("""{"operators":[]}""")
+
+ a[DataAccessException] should be thrownBy workflowDao.update(stored)
+ }
+}
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 00000000000..7c0de49a3b7
--- /dev/null
+++ b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowPublishSpec.scala
@@ -0,0 +1,1024 @@
+/*
+ * 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_USER_ACCESS
+import org.apache.texera.dao.jooq.generated.enums.{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.apache.texera.web.resource.dashboard.DashboardResource.SearchQueryParams
+import org.apache.texera.web.resource.dashboard.hub.HubResource
+import org.apache.texera.web.resource.dashboard.user.workflow.WorkflowResource.WorkflowIDs
+import org.apache.texera.web.resource.dashboard.{DashboardResource, FulltextSearchQueryUtils}
+import org.scalatest.BeforeAndAfterAll
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+
+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 -- and the
+ * read paths that decide which of the two a caller is served, listings and search among them, and
+ * the anchor a pin leaves in the revision history.
+ */
+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"$name@example.com")
+ 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 versionResource = new WorkflowVersionResource()
+
+ 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()
+ FulltextSearchQueryUtils.usePgroonga = false
+ 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)
+ }
+
+ /** 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 keywords(values: String*): util.ArrayList[String] = {
+ val list = new util.ArrayList[String]()
+ values.foreach(list.add)
+ list
+ }
+
+ private def keywordIds(values: Integer*): util.ArrayList[Integer] = {
+ val list = new util.ArrayList[Integer]()
+ values.foreach(list.add)
+ list
+ }
+
+ /** The wids a search returns, which is all any of these tests asks of one. */
+ private def searchWids(user: SessionUser, params: SearchQueryParams): List[Integer] =
+ DashboardResource
+ .searchAllResources(user, params, includePublic = true)
+ .results
+ .flatMap(_.workflow.map(_.workflow.getWid))
+
+ /** One workflow's listing row, as the given user would see it in a dashboard or on the Hub. */
+ private def listingOf(user: SessionUser, wid: Integer) =
+ DashboardResource
+ .searchAllResources(
+ user,
+ SearchQueryParams(workflowIDs = keywordIds(wid)),
+ includePublic = true
+ )
+ .results
+ .flatMap(_.workflow)
+ .head
+
+ /** Nobody: the hub as an unauthenticated visitor reads it. */
+ private def anonymous: SessionUser = new SessionUser(new User())
+
+ 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 "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 "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 "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 "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 "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 "ignore publish columns supplied by the client on create" in {
+ val workflow = new Workflow()
+ workflow.setName("create_cannot_inject")
+ workflow.setDescription("a workflow")
+ workflow.setContent(publishedContent)
+ workflow.setIsPublic(false)
+ // A client cannot hand us a public copy of its own choosing, in any of its parts.
+ workflow.setPublishedContent("""{"operators":[],"note":"injected"}""")
+ workflow.setPublishedName("injected_name")
+ workflow.setPublishedDescription("injected_description")
+ workflow.setPublishedVersionId(1)
+
+ val wid = workflowResource.createWorkflow(workflow, ownerSession).workflow.getWid
+
+ val stored = workflowDao.fetchOneByWid(wid)
+ stored.getPublishedContent shouldBe null
+ stored.getPublishedName shouldBe null
+ stored.getPublishedDescription shouldBe null
+ stored.getPublishedVersionId 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 "reject pinning and unpinning by a user 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 "reject publishing or pinning a workflow that does not exist" in {
+ 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.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 read every column and write them all back. A pin landing in that window was
+ // silently reverted to whatever the save had read. The save statement no longer names the
+ // publish columns at all, so the sequence is harmless.
+ val wid = createWorkflow("save_cannot_roll_back_publish")
+ publishPinned(wid)
+
+ // A save built from a snapshot taken *before* a pin that happens in between.
+ val stale = workflowDao.fetchOneByWid(wid)
+ edit(wid, editedContent)
+ workflowResource.pinLatest(wid, ownerSession)
+
+ stale.setContent("""{"operators":[],"note":"from_a_stale_client"}""")
+ workflowResource.persistWorkflow(stale, ownerSession)
+
+ // The pin stands; only the working copy moved.
+ workflowDao.fetchOneByWid(wid).getPublishedContent shouldBe editedContent
+ }
+
+ 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 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
+ }
+
+ 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 "count a description edit as an unpublished change" in {
+ val wid = createWorkflow("description_counts")
+ publishPinned(wid)
+ statusOf(wid).hasUnpublishedChanges shouldBe false
+
+ relabel(wid, "description_counts", "rewritten after publishing")
+
+ statusOf(wid).hasUnpublishedChanges shouldBe true
+ workflowResource.pinLatest(wid, ownerSession)
+ workflowResource.retrievePublicWorkflow(wid).description shouldBe "rewritten after publishing"
+ }
+
+ 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 "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 "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 "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), None), 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), None), 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), None), ownerSession)
+ .head
+ .workflow
+ .getWid
+ )
+
+ copy.getIsPublic shouldBe false
+ copy.getPublishedContent shouldBe null
+ copy.getPublishedName shouldBe null
+ copy.getPublishedDescription 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
+ }
+
+ behavior of "hub listings"
+
+ it should "list a pinned workflow on the hub under the name and description it froze" in {
+ // The hub is the public shelf: everything on it is listed as the public sees it, the author
+ // included. A pin that this listing did not honour would put the author's live title on that
+ // shelf, and would leave it disagreeing with the hub's own search about what a workflow is
+ // called.
+ val wid = createWorkflow("hub_listing_shows_public_copy")
+ publishPinned(wid)
+ relabel(wid, "renamed_after_pinning", "described_after_pinning")
+
+ for (viewer <- Seq(stranger.getUid, owner.getUid)) {
+ val listed = HubResource.fetchDashboardWorkflowsByWids(Seq(wid), viewer).head.workflow
+ listed.getName shouldBe "hub_listing_shows_public_copy"
+ listed.getDescription shouldBe "a workflow"
+ }
+ }
+
+ it should "list a following workflow on the hub under the author's latest name and description" in {
+ val wid = createWorkflow("hub_listing_follows_latest")
+ workflowResource.makePublic(wid, ownerSession)
+ relabel(wid, "renamed_while_following", "described_while_following")
+
+ val listed = HubResource.fetchDashboardWorkflowsByWids(Seq(wid), owner.getUid).head.workflow
+ listed.getName shouldBe "renamed_while_following"
+ listed.getDescription shouldBe "described_while_following"
+ }
+
+ it should "tell the hub listing when the copy on show is behind the author's working copy" in {
+ // The card advertises the pinned copy, so clicking it has to open that copy. Without this flag
+ // the author's own card would take them to their editor and show them something else -- the
+ // same signal the search listing carries, on the query the hub builds for itself.
+ val wid = createWorkflow("hub_listing_reports_drift")
+ publishPinned(wid)
+
+ def listedDrift(): Boolean =
+ HubResource.fetchDashboardWorkflowsByWids(Seq(wid), owner.getUid).head.hasUnpublishedChanges
+
+ listedDrift() shouldBe false
+ edit(wid, editedContent)
+ listedDrift() shouldBe true
+ workflowResource.pinLatest(wid, ownerSession)
+ listedDrift() shouldBe false
+ }
+
+ it should "clone the author's latest name and description while nothing is pinned" in {
+ val wid = createWorkflow("clone_follows_latest_metadata")
+ workflowResource.makePublic(wid, ownerSession)
+ relabel(wid, "renamed_before_clone", "described_before_clone")
+
+ val cloned =
+ workflowDao.fetchOneByWid(workflowResource.cloneWorkflow(wid, strangerSession, fakeRequest()))
+
+ cloned.getName shouldBe "renamed_before_clone_clone"
+ cloned.getDescription shouldBe "described_before_clone"
+ }
+
+ behavior of "search"
+
+ it should "not match a public workflow on anything that exists only in unpublished edits" in {
+ // Both halves of what public search indexes -- the words in the graph, and the operators in it --
+ // have to stop at the pinned copy, or searching would surface drafts nobody can open.
+ val wid = createWorkflow("search_ignores_drafts")
+ publishPinned(wid)
+ edit(
+ wid,
+ """{"operators":[{"operatorType":"SecretDraftOperator"}],"note":"supersecretdraftword"}"""
+ )
+
+ val byKeyword =
+ searchWids(anonymous, SearchQueryParams(keywords = keywords("supersecretdraftword")))
+ val byOperator =
+ searchWids(anonymous, SearchQueryParams(operators = keywords("SecretDraftOperator")))
+
+ byKeyword should not contain wid
+ byOperator should not contain wid
+ }
+
+ it should "match a public workflow on keywords in its published copy" in {
+ val wid = createWorkflow("search_finds_published")
+ publishPinned(wid)
+ edit(wid, editedContent)
+
+ searchWids(
+ anonymous,
+ SearchQueryParams(keywords = keywords("content_as_published"))
+ ) should contain(wid)
+ }
+
+ it should "match an unpinned public workflow on the author's latest" in {
+ // Following means the public copy is the working copy, so search must reach it through the same
+ // public path that a pinned workflow reaches its frozen copy through.
+ val wid = createWorkflow("search_finds_unpinned_latest")
+ workflowResource.makePublic(wid, ownerSession)
+ edit(wid, """{"operators":[],"note":"unpinnedsearchword"}""")
+
+ searchWids(
+ anonymous,
+ SearchQueryParams(keywords = keywords("unpinnedsearchword"))
+ ) should contain(wid)
+ }
+
+ it should "still match the author's own workflow on their unpublished edits" in {
+ val wid = createWorkflow("search_finds_own_draft")
+ publishPinned(wid)
+ edit(wid, """{"operators":[],"note":"myowndraftword"}""")
+
+ searchWids(
+ ownerSession,
+ SearchQueryParams(keywords = keywords("myowndraftword"))
+ ) should contain(wid)
+ }
+
+ it should "match a pinned workflow on the name and description the public can see" in {
+ // The graph was already matched against the pinned copy; the title and description are on public
+ // show just as much, so matching them against the author's live values gets it wrong in both
+ // directions at once -- findable by a title nobody has seen, unfindable by the one on screen.
+ val wid = createWorkflow("aapublicnamesearch")
+ relabel(wid, "aapublicnamesearch", "aapublicdescriptionsearch")
+ publishPinned(wid)
+ relabel(wid, "zzsecretnamesearch", "zzsecretdescriptionsearch")
+
+ def anonymousHits(word: String): List[Integer] =
+ searchWids(anonymous, SearchQueryParams(keywords = keywords(word)))
+
+ anonymousHits("aapublicnamesearch") should contain(wid)
+ anonymousHits("aapublicdescriptionsearch") should contain(wid)
+ anonymousHits("zzsecretnamesearch") should not contain wid
+ anonymousHits("zzsecretdescriptionsearch") should not contain wid
+ }
+
+ it should "match an unpinned public workflow on its live name and description" in {
+ val wid = createWorkflow("bbfollowingnamesearch")
+ workflowResource.makePublic(wid, ownerSession)
+ relabel(wid, "bbrenamedwhilefollowing", "a workflow")
+
+ searchWids(
+ anonymous,
+ SearchQueryParams(keywords = keywords("bbrenamedwhilefollowing"))
+ ) should contain(wid)
+ }
+
+ it should "still match the author's own workflow on a name only they can see" in {
+ val wid = createWorkflow("ccownnamesearch")
+ publishPinned(wid)
+ relabel(wid, "ccprivaterenamesearch", "a workflow")
+
+ searchWids(
+ ownerSession,
+ SearchQueryParams(keywords = keywords("ccprivaterenamesearch"))
+ ) should contain(wid)
+ }
+
+ it should "show a public viewer the published name and description in a listing" in {
+ // The listing is where people find and click a workflow, so a title that keeps following the
+ // author defeats the freeze exactly as an unfrozen graph would: a report about a title could be
+ // answered by quietly editing the title.
+ val wid = createWorkflow("listing_name_before")
+ publishPinned(wid)
+ relabel(wid, "listing_name_after", "described after publishing")
+
+ def listedAs(session: SessionUser): (String, String) = {
+ val entry = listingOf(session, wid).workflow
+ (entry.getName, entry.getDescription)
+ }
+
+ // The author is not a public viewer of their own workflow.
+ listedAs(ownerSession) shouldBe ("listing_name_after", "described after publishing")
+ // A stranger reaches it only because it is public.
+ listedAs(strangerSession) shouldBe ("listing_name_before", "a workflow")
+ // ...and the listing now agrees with what opening it shows.
+ workflowResource.retrievePublicWorkflow(wid).name shouldBe "listing_name_before"
+ }
+
+ it should "show a public viewer the author's live name while nothing is pinned" in {
+ val wid = createWorkflow("listing_unpinned_before")
+ workflowResource.makePublic(wid, ownerSession)
+ relabel(wid, "listing_unpinned_after", "a workflow")
+
+ listingOf(strangerSession, wid).workflow.getName shouldBe "listing_unpinned_after"
+ }
+
+ it should "show a collaborator the author's live name in a listing" in {
+ val wid = createWorkflow("listing_for_collaborator")
+ publishPinned(wid)
+ grantAccess(wid, PrivilegeEnum.READ)
+
+ try {
+ relabel(wid, "renamed_after_sharing", "a workflow")
+ listingOf(strangerSession, wid).workflow.getName shouldBe "renamed_after_sharing"
+ } finally revokeAccess(wid)
+ }
+
+ it should "leave a private workflow's own listing untouched" in {
+ val wid = createWorkflow("listing_private")
+ relabel(wid, "private_renamed", "a workflow")
+
+ listingOf(ownerSession, wid).workflow.getName shouldBe "private_renamed"
+ }
+
+ it should "tell listings when the copy on show is behind the author's working copy" in {
+ // What sends the author to the published preview instead of their editor when they click their
+ // own workflow in the hub: the entry is advertising the pinned version, not what they are editing.
+ val wid = createWorkflow("listing_reports_drift")
+ publishPinned(wid)
+
+ def drifted(): Boolean = listingOf(ownerSession, wid).hasUnpublishedChanges
+
+ drifted() shouldBe false
+ edit(wid, editedContent)
+ drifted() shouldBe true
+ workflowResource.pinLatest(wid, ownerSession)
+ drifted() shouldBe false
+ }
+
+ it should "never report drift for a workflow with nothing frozen" in {
+ // Drift is "what the public sees is not what you have", so it can only be true of a workflow that
+ // has a frozen copy at all. Private and public-but-following both have none.
+ def driftOf(wid: Integer): Boolean = listingOf(ownerSession, wid).hasUnpublishedChanges
+
+ val priv = createWorkflow("listing_ignores_private")
+ edit(priv, editedContent)
+ driftOf(priv) shouldBe false
+
+ val following = createWorkflow("listing_ignores_unpinned")
+ workflowResource.makePublic(following, ownerSession)
+ edit(following, editedContent)
+ driftOf(following) shouldBe false
+ }
+
+ behavior of "the revision history"
+
+ it should "leave an anchor in the revision history the author can identify" in {
+ val wid = createWorkflow("publish_leaves_anchor")
+ publishPinned(wid)
+
+ val marked = versionResource
+ .retrieveVersionsOfWorkflow(wid, ownerSession)
+ .filter(_.isCurrentlyPublic)
+ marked should have size 1
+ marked.head.vId shouldBe workflowDao.fetchOneByWid(wid).getPublishedVersionId
+ // Both collapsing rules would otherwise hide it, and a hidden anchor is useless to the author.
+ marked.head.importance shouldBe true
+ // The revision panel prints this row's creation time and the share dialog prints the pinned
+ // version's date. They are one row, so they have to be one value: the same version dated a
+ // second apart in two panels reads as two versions.
+ marked.head.creationTime shouldBe statusOf(wid).pinnedVersionTime.get
+ }
+
+ it should "keep the save a pin was taken from visible in the revision panel" in {
+ // The anchor lands seconds after the save it freezes, and the panel folds versions that land
+ // close together into the newest of them. Letting the anchor be that newest one would hide the
+ // author's own save behind a row they never made.
+ val wid = createWorkflow("pin_does_not_swallow_the_save")
+ edit(wid, editedContent)
+ publishPinned(wid)
+
+ val shown = versionResource
+ .retrieveVersionsOfWorkflow(wid, ownerSession)
+ .filter(_.importance)
+ // The anchor, and the save it was taken from.
+ shown.count(_.isCurrentlyPublic) shouldBe 1
+ shown.size should be >= 2
+ }
+
+ it should "move the mark when the pin moves, leaving the old anchor unmarked" in {
+ val wid = createWorkflow("only_the_live_one_is_marked")
+ publishPinned(wid)
+ val first = workflowDao.fetchOneByWid(wid).getPublishedVersionId
+ edit(wid, editedContent)
+ workflowResource.pinLatest(wid, ownerSession)
+ val second = workflowDao.fetchOneByWid(wid).getPublishedVersionId
+
+ second should not be first
+ versionResource
+ .retrieveVersionsOfWorkflow(wid, ownerSession)
+ .filter(_.isCurrentlyPublic)
+ .map(_.vId) shouldBe List(second)
+ }
+
+ it should "replay the anchor to exactly what was published, however much is edited after" in {
+ val wid = createWorkflow("anchor_replays_to_published")
+ publishPinned(wid)
+ val anchor = workflowDao.fetchOneByWid(wid).getPublishedVersionId
+
+ edit(wid, editedContent)
+ edit(wid, """{"operators":[],"note":"later_still"}""")
+
+ versionResource
+ .retrieveWorkflowVersion(wid, anchor, ownerSession)
+ .getContent shouldBe publishedContent
+ }
+
+ it should "not record a second anchor when pinning again without having edited" in {
+ // Otherwise a repeated click would litter the author's revision panel with rows that all replay
+ // to the same graph.
+ val wid = createWorkflow("republish_without_edits")
+ val before = versionResource.retrieveVersionsOfWorkflow(wid, ownerSession).size
+ publishPinned(wid)
+ val anchor = workflowDao.fetchOneByWid(wid).getPublishedVersionId
+ workflowResource.pinLatest(wid, ownerSession)
+ workflowResource.pinLatest(wid, ownerSession)
+
+ workflowDao.fetchOneByWid(wid).getPublishedVersionId shouldBe anchor
+ versionResource.retrieveVersionsOfWorkflow(wid, ownerSession) should have size (before + 1)
+ }
+
+ it should "keep the version the pin was taken from after the pin is dropped" in {
+ val wid = createWorkflow("unpin_keeps_history")
+ publishPinned(wid)
+
+ workflowResource.unpin(wid, ownerSession)
+
+ versionResource.retrieveVersionsOfWorkflow(wid, ownerSession) should not be empty
+ workflowDao.fetchOneByWid(wid).getPublishedVersionId shouldBe null
+ }
+
+ it should "not expose the author's edit history to a public viewer" in {
+ // Replaying a version folds deltas back from the author's *current* content, so listing or
+ // checking out versions would hand a public viewer exactly the drafts publishing keeps private.
+ val wid = createWorkflow("history_is_not_public")
+ publishPinned(wid)
+ edit(wid, editedContent)
+
+ 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 {
+ 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 "date the public view by the version on show" in {
+ // A public viewer is told when what they are looking at was published, not when the author last
+ // touched a copy they cannot see.
+ val wid = createWorkflow("public_view_is_dated_by_the_pin")
+ publishPinned(wid)
+ val pinnedAt = statusOf(wid).pinnedVersionTime.get
+
+ edit(wid, editedContent)
+
+ workflowResource.retrievePublicWorkflow(wid).lastModifiedTime shouldBe pinnedAt
+ }
+}
diff --git a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowVersionResourceSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowVersionResourceSpec.scala
index 15a3bb66546..44deef02a1e 100644
--- a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowVersionResourceSpec.scala
+++ b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowVersionResourceSpec.scala
@@ -518,10 +518,40 @@ class WorkflowVersionResourceSpec
version(3, 0L, positional), // latest — important regardless of content
version(2, TimeUnit.SECONDS.toMillis(1), meaningful), // within the aggregate window
version(1, TimeUnit.DAYS.toMillis(30), meaningful) // outside it, judged on content
- )
+ ),
+ Option.empty[Integer]
)
encoded.map(_.vId) shouldBe List(3, 2, 1)
encoded.map(_.importance) shouldBe List(true, false, true)
+ encoded.map(_.isCurrentlyPublic) shouldBe List(false, false, false)
+ }
+
+ it should "keep the version on public show visible even when the rules would collapse it" in {
+ val base = 1_700_000_000_000L
+ def version(vid: Int, offsetMillis: Long, content: String): WorkflowVersion = {
+ val v = new WorkflowVersion
+ v.setVid(vid)
+ v.setWid(testWorkflowWid)
+ v.setContent(content)
+ v.setCreationTime(new Timestamp(base - offsetMillis))
+ v
+ }
+
+ val positional = """[{"op":"replace","path":"/operatorPositions/op-1/x","value":5}]"""
+
+ // Version 2 lands inside the aggregation window and carries a positional-only
+ // patch, so both collapsing rules would hide it. Being the published one wins.
+ val encoded = WorkflowVersionResource invokePrivate encodeVersionImportance(
+ List(
+ version(3, 0L, positional),
+ version(2, TimeUnit.SECONDS.toMillis(1), positional),
+ version(1, TimeUnit.SECONDS.toMillis(2), positional)
+ ),
+ Option(Integer.valueOf(2))
+ )
+
+ encoded.map(_.importance) shouldBe List(true, true, false)
+ encoded.map(_.isCurrentlyPublic) shouldBe List(false, true, false)
}
}
diff --git a/sql/changelog.xml b/sql/changelog.xml
index 3debbad196e..2f4a389bf6c 100644
--- a/sql/changelog.xml
+++ b/sql/changelog.xml
@@ -114,6 +114,11 @@
+
+
+
+
+