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