diff --git a/config/alfresco/hibernate-context.xml b/config/alfresco/hibernate-context.xml index e46beb7617..e89b32be15 100644 --- a/config/alfresco/hibernate-context.xml +++ b/config/alfresco/hibernate-context.xml @@ -390,6 +390,9 @@ + + + diff --git a/config/alfresco/messages/patch-service.properties b/config/alfresco/messages/patch-service.properties index 9b2abccbc5..e150722ae5 100644 --- a/config/alfresco/messages/patch-service.properties +++ b/config/alfresco/messages/patch-service.properties @@ -279,3 +279,4 @@ patch.authorityDefaultZonesPatch.result=Unzoned groups and people added to the d patch.fixNameCrcValues.description=Fixes name CRC32 values to match UTF-8 encoding. patch.fixNameCrcValues.result=Fixed {0} name CRC32 values for UTF-8 encoding. See file {1} for details. patch.fixNameCrcValues.fixed=Updated CRC32 value for node ID {0}, name ''{1}'': {2} -> {3}. +patch.fixNameCrcValues.unableToChange=Failed to update the CRC32 value for node ID {0}: \n Node name: {1} \n CRC old: {2} \n CRC new: {3} \n Error: {4} diff --git a/config/alfresco/mimetype/mimetype-map.xml b/config/alfresco/mimetype/mimetype-map.xml index f140f1250e..06d22aa0fc 100644 --- a/config/alfresco/mimetype/mimetype-map.xml +++ b/config/alfresco/mimetype/mimetype-map.xml @@ -62,6 +62,7 @@ htm shtml body + xsd mw diff --git a/source/java/org/alfresco/email/server/impl/subetha/SubethaEmailServer.java b/source/java/org/alfresco/email/server/impl/subetha/SubethaEmailServer.java index 94d4d2f4e4..38a28267bc 100644 --- a/source/java/org/alfresco/email/server/impl/subetha/SubethaEmailServer.java +++ b/source/java/org/alfresco/email/server/impl/subetha/SubethaEmailServer.java @@ -126,11 +126,26 @@ public class SubethaEmailServer extends EmailServer public void data(InputStream data) throws TooMuchDataException, IOException, RejectException { - if (deliveries.size() > 0) + try { - Delivery delivery = deliveries.get(0); - processDelivery(delivery, data); + if (deliveries.size() > 0) + { + Delivery delivery = deliveries.get(0); + processDelivery(delivery, data); + } } + finally + { + // DH: As per comments in ETHREEOH-2252, I am very concerned about the need to do the clear() here. + // If this message is stateful (as it must be, given the API) then the need to clear + // the list of delivery recipients ('deliveries') implies that Subetha is re-using + // the instance. + // Later versions of Subetha appear to define the behaviour better. Un upgrade of + // the library would be a good idea. + deliveries.clear(); + } +// See ALFCOM-3165: Support multiple recipients for inbound Subetha email messages +// // Duplicate messages coming in // http://www.subethamail.org/se/archive_msg.jsp?msgId=20938 // if (deliveries.size() == 1) diff --git a/source/java/org/alfresco/filesys/repo/ContentNetworkFile.java b/source/java/org/alfresco/filesys/repo/ContentNetworkFile.java index de14cd1527..8f87e281c0 100644 --- a/source/java/org/alfresco/filesys/repo/ContentNetworkFile.java +++ b/source/java/org/alfresco/filesys/repo/ContentNetworkFile.java @@ -43,6 +43,7 @@ import org.alfresco.jlan.server.filesys.FileOpenParams; import org.alfresco.jlan.server.filesys.NetworkFile; import org.alfresco.jlan.smb.SeekType; import org.alfresco.model.ContentModel; +import org.alfresco.repo.content.AbstractContentReader; import org.alfresco.repo.content.encoding.ContentCharsetFinder; import org.alfresco.repo.content.filestore.FileContentReader; import org.alfresco.service.cmr.repository.ContentAccessor; @@ -83,6 +84,7 @@ public class ContentNetworkFile extends NodeRefNetworkFile // File content private ContentAccessor content; + private ContentReader preUpdateContentReader; // Indicate if file has been written to or truncated/resized @@ -321,12 +323,17 @@ public class ContentNetworkFile extends NodeRefNetworkFile } content = null; + preUpdateContentReader = null; if (write) { - // Get a writeable channel to the content + // Get a writeable channel to the content, along with the original content content = contentService.getWriter( getNodeRef(), ContentModel.PROP_CONTENT, false); + // Keep the original content for later comparison + + preUpdateContentReader = contentService.getReader( getNodeRef(), ContentModel.PROP_CONTENT); + // Indicate that we have a writable channel to the file writableChannel = true; @@ -397,16 +404,22 @@ public class ContentNetworkFile extends NodeRefNetworkFile channel.close(); channel = null; - // Update node properties + // Update node properties, but only if the binary has changed (ETHREEOH-1861) - ContentData contentData = content.getContentData(); - try + ContentReader postUpdateContentReader = ((ContentWriter) content).getReader(); + boolean contentChanged = !AbstractContentReader.compareContentReaders(preUpdateContentReader, postUpdateContentReader); + + if (contentChanged) { - nodeService.setProperty( getNodeRef(), ContentModel.PROP_CONTENT, contentData); - } - catch (ContentQuotaException qe) - { - throw new DiskFullException(qe.getMessage()); + ContentData contentData = content.getContentData(); + try + { + nodeService.setProperty( getNodeRef(), ContentModel.PROP_CONTENT, contentData); + } + catch (ContentQuotaException qe) + { + throw new DiskFullException(qe.getMessage()); + } } } else diff --git a/source/java/org/alfresco/repo/action/executer/TransformActionExecuter.java b/source/java/org/alfresco/repo/action/executer/TransformActionExecuter.java index 33e99c911f..ee0a20127e 100644 --- a/source/java/org/alfresco/repo/action/executer/TransformActionExecuter.java +++ b/source/java/org/alfresco/repo/action/executer/TransformActionExecuter.java @@ -53,31 +53,36 @@ import org.apache.commons.logging.LogFactory; */ public class TransformActionExecuter extends ActionExecuterAbstractBase { + /** Error messages */ public static final String ERR_OVERWRITE = "Unable to overwrite copy because more than one have been found."; + private static final String CONTENT_READER_NOT_FOUND_MESSAGE = "Can not find Content Reader for document. Operation can't be performed"; + private static final String TRANSFORMING_ERROR_MESSAGE = "Some error occurred during document transforming. Error message: "; + + private static final String TRANSFORMER_NOT_EXISTS_MESSAGE_PATTERN = "Transformer for '%s' source mime type and '%s' target mime type was not found. Operation can't be performed"; /** * The logger */ - private static Log logger = LogFactory.getLog(TransformActionExecuter.class); + private static Log logger = LogFactory.getLog(TransformActionExecuter.class); /** * Action constants */ - public static final String NAME = "transform"; - public static final String PARAM_MIME_TYPE = "mime-type"; - public static final String PARAM_DESTINATION_FOLDER = "destination-folder"; + public static final String NAME = "transform"; + public static final String PARAM_MIME_TYPE = "mime-type"; + public static final String PARAM_DESTINATION_FOLDER = "destination-folder"; public static final String PARAM_ASSOC_TYPE_QNAME = "assoc-type"; public static final String PARAM_ASSOC_QNAME = "assoc-name"; public static final String PARAM_OVERWRITE_COPY = "overwrite-copy"; - + /** * Injected services */ - private DictionaryService dictionaryService; - private NodeService nodeService; - private ContentService contentService; - private CopyService copyService; + private DictionaryService dictionaryService; + private NodeService nodeService; + private ContentService contentService; + private CopyService copyService; private MimetypeService mimetypeService; /** @@ -96,79 +101,90 @@ public class TransformActionExecuter extends ActionExecuterAbstractBase * @param nodeService set the node service */ public void setNodeService(NodeService nodeService) - { - this.nodeService = nodeService; - } - + { + this.nodeService = nodeService; + } + /** * Set the dictionary service * * @param dictionaryService the dictionary service */ - public void setDictionaryService(DictionaryService dictionaryService) - { - this.dictionaryService = dictionaryService; - } - - /** - * Set the content service - * - * @param contentService the content service - */ - public void setContentService(ContentService contentService) - { - this.contentService = contentService; - } - - /** - * Set the copy service - * - * @param copyService the copy service - */ - public void setCopyService(CopyService copyService) - { - this.copyService = copyService; - } - - /** - * Add parameter definitions - */ - @Override - protected void addParameterDefinitions(List paramList) - { - paramList.add(new ParameterDefinitionImpl(PARAM_MIME_TYPE, DataTypeDefinition.TEXT, true, getParamDisplayLabel(PARAM_MIME_TYPE))); - paramList.add(new ParameterDefinitionImpl(PARAM_DESTINATION_FOLDER, DataTypeDefinition.NODE_REF, true, getParamDisplayLabel(PARAM_DESTINATION_FOLDER))); - paramList.add(new ParameterDefinitionImpl(PARAM_ASSOC_TYPE_QNAME, DataTypeDefinition.QNAME, true, getParamDisplayLabel(PARAM_ASSOC_TYPE_QNAME))); - paramList.add(new ParameterDefinitionImpl(PARAM_ASSOC_QNAME, DataTypeDefinition.QNAME, true, getParamDisplayLabel(PARAM_ASSOC_QNAME))); + public void setDictionaryService(DictionaryService dictionaryService) + { + this.dictionaryService = dictionaryService; + } + + /** + * Set the content service + * + * @param contentService the content service + */ + public void setContentService(ContentService contentService) + { + this.contentService = contentService; + } + + /** + * Set the copy service + * + * @param copyService the copy service + */ + public void setCopyService(CopyService copyService) + { + this.copyService = copyService; + } + + /** + * Add parameter definitions + */ + @Override + protected void addParameterDefinitions(List paramList) + { + paramList.add(new ParameterDefinitionImpl(PARAM_MIME_TYPE, DataTypeDefinition.TEXT, true, getParamDisplayLabel(PARAM_MIME_TYPE))); + paramList.add(new ParameterDefinitionImpl(PARAM_DESTINATION_FOLDER, DataTypeDefinition.NODE_REF, true, getParamDisplayLabel(PARAM_DESTINATION_FOLDER))); + paramList.add(new ParameterDefinitionImpl(PARAM_ASSOC_TYPE_QNAME, DataTypeDefinition.QNAME, true, getParamDisplayLabel(PARAM_ASSOC_TYPE_QNAME))); + paramList.add(new ParameterDefinitionImpl(PARAM_ASSOC_QNAME, DataTypeDefinition.QNAME, true, getParamDisplayLabel(PARAM_ASSOC_QNAME))); paramList.add(new ParameterDefinitionImpl(PARAM_OVERWRITE_COPY, DataTypeDefinition.BOOLEAN, false, getParamDisplayLabel(PARAM_OVERWRITE_COPY))); - } + } - /** - * @see org.alfresco.repo.action.executer.ActionExecuterAbstractBase#executeImpl(org.alfresco.service.cmr.repository.NodeRef, org.alfresco.service.cmr.repository.NodeRef) - */ - @Override - protected void executeImpl( - Action ruleAction, - NodeRef actionedUponNodeRef) - { - if (this.nodeService.exists(actionedUponNodeRef) == false) - { + /** + * @see org.alfresco.repo.action.executer.ActionExecuterAbstractBase#executeImpl(org.alfresco.service.cmr.repository.NodeRef, org.alfresco.service.cmr.repository.NodeRef) + */ + @Override + protected void executeImpl( + Action ruleAction, + NodeRef actionedUponNodeRef) + { + if (this.nodeService.exists(actionedUponNodeRef) == false) + { // node doesn't exist - can't do anything return; } - // First check that the node is a sub-type of content - QName typeQName = this.nodeService.getType(actionedUponNodeRef); - if (this.dictionaryService.isSubClass(typeQName, ContentModel.TYPE_CONTENT) == false) - { + // First check that the node is a sub-type of content + QName typeQName = this.nodeService.getType(actionedUponNodeRef); + if (this.dictionaryService.isSubClass(typeQName, ContentModel.TYPE_CONTENT) == false) + { // it is not content, so can't transform return; } - - // Get the mime type - String mimeType = (String)ruleAction.getParameterValue(PARAM_MIME_TYPE); - - // Get the details of the copy destination - NodeRef destinationParent = (NodeRef)ruleAction.getParameterValue(PARAM_DESTINATION_FOLDER); + + // Get the mime type + String mimeType = (String)ruleAction.getParameterValue(PARAM_MIME_TYPE); + // Get the content reader + ContentReader contentReader = this.contentService.getReader(actionedUponNodeRef, ContentModel.PROP_CONTENT); + if (null == contentReader || !contentReader.exists()) + { + throw new RuleServiceException(CONTENT_READER_NOT_FOUND_MESSAGE); + } + + if (null == contentService.getTransformer(contentReader.getMimetype(), mimeType)) + { + throw new RuleServiceException(String.format(TRANSFORMER_NOT_EXISTS_MESSAGE_PATTERN, contentReader.getMimetype(), mimeType)); + } + + // Get the details of the copy destination + NodeRef destinationParent = (NodeRef)ruleAction.getParameterValue(PARAM_DESTINATION_FOLDER); QName destinationAssocTypeQName = (QName)ruleAction.getParameterValue(PARAM_ASSOC_TYPE_QNAME); QName destinationAssocQName = (QName)ruleAction.getParameterValue(PARAM_ASSOC_QNAME); @@ -221,7 +237,7 @@ public class TransformActionExecuter extends ActionExecuterAbstractBase boolean newCopy = false; if (copyNodeRef == null) { - // Copy the content node + // Copy the content node copyNodeRef = this.copyService.copy( actionedUponNodeRef, destinationParent, @@ -229,7 +245,7 @@ public class TransformActionExecuter extends ActionExecuterAbstractBase destinationAssocQName, false); newCopy = true; - } + } if (newCopy == true) { @@ -243,8 +259,6 @@ public class TransformActionExecuter extends ActionExecuterAbstractBase } } - // Get the content reader - ContentReader contentReader = this.contentService.getReader(actionedUponNodeRef, ContentModel.PROP_CONTENT); // Only do the transformation if some content is available if (contentReader != null) { @@ -253,40 +267,41 @@ public class TransformActionExecuter extends ActionExecuterAbstractBase contentWriter.setMimetype(mimeType); // new mimetype contentWriter.setEncoding(contentReader.getEncoding()); // original encoding - // Try and transform the content - failures are caught and allowed to fail silently. + // Try and transform the content - failures are caught and allowed to fail silently. // This is unique to this action, and is essentially a broken pattern. // Clients should rather get the exception and then decide to replay with rules/actions turned off or not. // TODO: Check failure patterns for actions. try { - doTransform(ruleAction, actionedUponNodeRef, contentReader, copyNodeRef, contentWriter); + doTransform(ruleAction, actionedUponNodeRef, contentReader, copyNodeRef, contentWriter); } catch(NoTransformerException e) { - if (logger.isDebugEnabled()) + if (logger.isDebugEnabled()) { logger.debug("No transformer found to execute rule: \n" + " reader: " + contentReader + "\n" + " writer: " + contentWriter + "\n" + " action: " + this); } + throw new RuleServiceException(TRANSFORMING_ERROR_MESSAGE + e.getMessage()); } } - } - + } + /** * Executed in a new transaction so that failures don't cause the entire transaction to rollback. */ - protected void doTransform( Action ruleAction, - NodeRef sourceNodeRef, ContentReader contentReader, - NodeRef destinationNodeRef, ContentWriter contentWriter) + protected void doTransform( Action ruleAction, + NodeRef sourceNodeRef, ContentReader contentReader, + NodeRef destinationNodeRef, ContentWriter contentWriter) - { - // Transformation options - TransformationOptions options = new TransformationOptions( - sourceNodeRef, ContentModel.PROP_NAME, destinationNodeRef, ContentModel.PROP_NAME); + { + // Transformation options + TransformationOptions options = new TransformationOptions( + sourceNodeRef, ContentModel.PROP_NAME, destinationNodeRef, ContentModel.PROP_NAME); - // try to pre-empt the lack of a transformer + // try to pre-empt the lack of a transformer if (this.contentService.isTransformable(contentReader, contentWriter, options) == false) { throw new NoTransformerException(contentReader.getMimetype(), contentWriter.getMimetype()); @@ -294,8 +309,8 @@ public class TransformActionExecuter extends ActionExecuterAbstractBase // transform this.contentService.transform(contentReader, contentWriter, options); - } - + } + /** * Transform name from original extension to new extension * diff --git a/source/java/org/alfresco/repo/admin/patch/AbstractPatch.java b/source/java/org/alfresco/repo/admin/patch/AbstractPatch.java index 30dddc0a5b..cc07b4831f 100644 --- a/source/java/org/alfresco/repo/admin/patch/AbstractPatch.java +++ b/source/java/org/alfresco/repo/admin/patch/AbstractPatch.java @@ -75,6 +75,7 @@ public abstract class AbstractPatch implements Patch private int fixesFromSchema; private int fixesToSchema; private int targetSchema; + private boolean force; private String description; /** a list of patches that this one depends on */ private List dependsOn; @@ -104,6 +105,7 @@ public abstract class AbstractPatch implements Patch this.fixesFromSchema = -1; this.fixesToSchema = -1; this.targetSchema = -1; + this.force = false; this.applied = false; this.applyToTenants = true; // by default, apply to each tenant, if tenant service is enabled this.dependsOn = Collections.emptyList(); @@ -257,6 +259,25 @@ public abstract class AbstractPatch implements Patch this.targetSchema = version; } + /** + * {@inheritDoc} + */ + public boolean isForce() + { + return force; + } + + /** + * Set the flag that forces the patch to be forcefully applied. This allows patches to be overridden to induce execution + * regardless of the upgrade or installation versions, or even if the patch has been executed before. + * + * @param force true to force the patch to be applied + */ + public void setForce(boolean force) + { + this.force = force; + } + public String getDescription() { return description; @@ -513,7 +534,7 @@ public abstract class AbstractPatch implements Patch if (timeRemaining > 60000) { - int reportInterval = getreportingInterval(timeSoFar, timeRemaining); + int reportInterval = getReportingInterval(timeSoFar, timeRemaining); for (int i = previous + 1; i <= percentComplete; i++) { @@ -534,7 +555,7 @@ public abstract class AbstractPatch implements Patch } } - private int getreportingInterval(long soFar, long toGo) + private int getReportingInterval(long soFar, long toGo) { long total = soFar + toGo; if (total < RANGE_10) diff --git a/source/java/org/alfresco/repo/admin/patch/Patch.java b/source/java/org/alfresco/repo/admin/patch/Patch.java index be376f0ec1..29f950c704 100644 --- a/source/java/org/alfresco/repo/admin/patch/Patch.java +++ b/source/java/org/alfresco/repo/admin/patch/Patch.java @@ -62,6 +62,11 @@ public interface Patch */ public int getTargetSchema(); + /** + * @return Returns true if the patch must forcefully run regardless of any other state + */ + public boolean isForce(); + /** * Get patches that this patch depends on * diff --git a/source/java/org/alfresco/repo/admin/patch/PatchServiceImpl.java b/source/java/org/alfresco/repo/admin/patch/PatchServiceImpl.java index 8e7f07d1a8..ed719e4879 100644 --- a/source/java/org/alfresco/repo/admin/patch/PatchServiceImpl.java +++ b/source/java/org/alfresco/repo/admin/patch/PatchServiceImpl.java @@ -247,18 +247,26 @@ public class PatchServiceImpl implements PatchService private AppliedPatch applyPatch(Patch patch) { + boolean forcePatch = patch.isForce(); + if (forcePatch) + { + logger.warn( + "Patch will be forcefully executed: \n" + + " Patch: " + patch); + } // get the patch from the DAO AppliedPatch appliedPatch = patchDaoService.getAppliedPatch(patch.getId()); // We bypass the patch if it was executed successfully - if (appliedPatch != null) + if (appliedPatch != null && !forcePatch) { if (appliedPatch.getSucceeded()) { // It has already been successfully applied if (logger.isDebugEnabled()) { - logger.debug("Patch was already successfully applied: \n" + - " patch: " + appliedPatch); + logger.debug( + "Patch was already successfully applied: \n" + + " Patch: " + appliedPatch); } return appliedPatch; } @@ -268,8 +276,8 @@ public class PatchServiceImpl implements PatchService boolean success = false; // first check whether the patch is relevant to the repo Descriptor repoDescriptor = descriptorService.getInstalledRepositoryDescriptor(); - String preceededByAlternative = preceededByAlternative(patch); - boolean applies = applies(repoDescriptor, patch); + String preceededByAlternative = forcePatch ? null : preceededByAlternative(patch); + boolean applies = forcePatch || applies(repoDescriptor, patch); if (!applies) { // create a dummy report diff --git a/source/java/org/alfresco/repo/admin/patch/impl/FixNameCrcValuesPatch.java b/source/java/org/alfresco/repo/admin/patch/impl/FixNameCrcValuesPatch.java index f797183af4..7385dda552 100644 --- a/source/java/org/alfresco/repo/admin/patch/impl/FixNameCrcValuesPatch.java +++ b/source/java/org/alfresco/repo/admin/patch/impl/FixNameCrcValuesPatch.java @@ -65,6 +65,7 @@ public class FixNameCrcValuesPatch extends AbstractPatch { private static final String MSG_SUCCESS = "patch.fixNameCrcValues.result"; private static final String MSG_REWRITTEN = "patch.fixNameCrcValues.fixed"; + private static final String MSG_UNABLE_TO_CHANGE = "patch.fixNameCrcValues.unableToChange"; private SessionFactory sessionFactory; private NodeDaoService nodeDaoService; @@ -163,6 +164,10 @@ public class FixNameCrcValuesPatch extends AbstractPatch @SuppressWarnings("unused") List childAssocIds = findMismatchedCrcs(); + // Precautionary flush and clear so that we have an empty session + getSession().flush(); + getSession().clear(); + int updated = 0; for (Long childAssocId : childAssocIds) { @@ -188,7 +193,24 @@ public class FixNameCrcValuesPatch extends AbstractPatch assoc.setChildNodeNameCrc(crc); // Persist updated++; - getSession().flush(); + try + { + getSession().flush(); + } + catch (Throwable e) + { + String msg = I18NUtil.getMessage(MSG_UNABLE_TO_CHANGE, childNode.getId(), childName, oldCrc, crc, e.getMessage()); + // We just log this and add details to the message file + if (logger.isDebugEnabled()) + { + logger.debug(msg, e); + } + else + { + logger.warn(msg); + } + writeLine(msg); + } getSession().clear(); // Record writeLine(I18NUtil.getMessage(MSG_REWRITTEN, childNode.getId(), childName, oldCrc, crc)); diff --git a/source/java/org/alfresco/repo/avm/AVMServiceLocalTest.java b/source/java/org/alfresco/repo/avm/AVMServiceLocalTest.java index 0806743592..db821b0f9c 100644 --- a/source/java/org/alfresco/repo/avm/AVMServiceLocalTest.java +++ b/source/java/org/alfresco/repo/avm/AVMServiceLocalTest.java @@ -1389,7 +1389,7 @@ public class AVMServiceLocalTest extends TestCase } } - public void testLayeredFolder_DeleteFile_mimic_ETHREEOH_2297() throws Exception + public void testLayeredFolderDeleteFile1() throws Exception { try { @@ -1480,7 +1480,7 @@ public class AVMServiceLocalTest extends TestCase recursiveList("mainA"); recursiveList("mainB"); - // note: short-cut - removed directly from "staging" area (don't bother with sandbox mainA--layer for now) + // delete file - note: short-cut - removed directly from "staging" area (don't bother with sandbox mainA--layer for now) fService.removeNode("mainA:/a/b", "foo"); fService.createSnapshot("mainA", null, null); @@ -1521,6 +1521,138 @@ public class AVMServiceLocalTest extends TestCase fService.purgeStore("mainB--layer"); } } + + public void testLayeredFolderDeleteFile2() throws Exception + { + try + { + fService.createStore("mainA"); + fService.createStore("mainB"); + + fService.createDirectory("mainA:/", "a"); + fService.createDirectory("mainA:/a", "b"); + + fService.createDirectory("mainB:/", "a"); + + fService.createStore("mainB--layer"); + + fService.createLayeredDirectory("mainB:/a", "mainB--layer:/", "a"); + + // note: short-cut - created directly in "staging" area (don't bother with sandbox mainA--layer for now) + fService.createFile("mainA:/a/b", "foo"); + + PrintStream out = new PrintStream(fService.getFileOutputStream("mainA:/a/b/foo")); + out.println("I am mainA:/a/b/foo"); + out.close(); + + logger.debug("created file: mainA:/a/b/c/foo"); + + recursiveList("mainA"); + recursiveList("mainB"); + recursiveList("mainB--layer"); + + // create equivalent of WCM layered folder between web project staging sandboxes (mainB:/a/b pointing to ,mainA:/a/b) + fService.createLayeredDirectory("mainA:/a/b", "mainB:/a", "b"); + + fService.createSnapshot("mainA", null, null); + fService.createSnapshot("mainB", null, null); + + logger.debug("created layered directory: mainB:/a/b -> mainA:/a/b"); + + recursiveList("mainB"); + recursiveList("mainB--layer"); + + BufferedReader reader = new BufferedReader(new InputStreamReader(fService.getFileInputStream(-1, "mainB--layer:/a/b/foo"))); + String line = reader.readLine(); + reader.close(); + assertEquals("I am mainA:/a/b/foo", line); + + out = new PrintStream(fService.getFileOutputStream("mainB--layer:/a/b/foo")); + out.println("I am mainB--layer:/a/b/foo"); + out.close(); + + fService.createSnapshot("mainB--layer", null, null); + + logger.debug("updated file: mainB--layer:/a/b/foo"); + + recursiveList("mainB"); + recursiveList("mainB--layer"); + + reader = new BufferedReader(new InputStreamReader(fService.getFileInputStream(-1, "mainA:/a/b/foo"))); + line = reader.readLine(); + reader.close(); + assertEquals("I am mainA:/a/b/foo", line); + + reader = new BufferedReader(new InputStreamReader(fService.getFileInputStream(-1, "mainB:/a/b/foo"))); + line = reader.readLine(); + reader.close(); + assertEquals("I am mainA:/a/b/foo", line); + + reader = new BufferedReader(new InputStreamReader(fService.getFileInputStream(-1, "mainB--layer:/a/b/foo"))); + line = reader.readLine(); + reader.close(); + assertEquals("I am mainB--layer:/a/b/foo", line); + + List diffs = fSyncService.compare(-1, "mainB--layer:/a", -1, "mainB:/a", null); + assertEquals(1, diffs.size()); + assertEquals("[mainB--layer:/a/b/foo[-1] > mainB:/a/b/foo[-1]]", diffs.toString()); + + fSyncService.update(diffs, null, false, false, false, false, "one", "one"); + fSyncService.flatten("mainB--layer:/a", "mainB:/a"); + + logger.debug("updated: created file: mainB:/a/b/foo"); + + recursiveList("mainB"); + recursiveList("mainB--layer"); + + reader = new BufferedReader(new InputStreamReader(fService.getFileInputStream(-1, "mainB:/a/b/foo"))); + line = reader.readLine(); + reader.close(); + assertEquals("I am mainB--layer:/a/b/foo", line); + + recursiveList("mainA"); + recursiveList("mainB"); + + // delete folder - note: short-cut - remove directly from "staging" area (don't bother with sandbox mainA--layer for now) + fService.removeNode("mainA:/a", "b"); + + fService.createSnapshot("mainA", null, null); + + logger.debug("removed file: mainA:/a/b/foo"); + + recursiveList("mainA"); + recursiveList("mainB"); + recursiveList("mainB--layer"); + + fService.removeNode("mainB--layer:/a/b", "foo"); + + diffs = fSyncService.compare(-1, "mainB--layer:/a", -1, "mainB:/a", null); + assertEquals(1, diffs.size()); + assertEquals("[mainB--layer:/a/b/foo[-1] > mainB:/a/b/foo[-1]]", diffs.toString()); + + fSyncService.update(diffs, null, false, false, false, false, "one", "one"); + fSyncService.flatten("mainB--layer:/a", "mainB:/a"); + + fService.createSnapshot("mainB", null, null); + + logger.debug("updated: removed file: mainB:/a/b/foo"); + + recursiveList("mainA"); + recursiveList("mainB"); + recursiveList("mainB--layer"); + } + catch (Exception e) + { + e.printStackTrace(System.err); + throw e; + } + finally + { + fService.purgeStore("mainA"); + fService.purgeStore("mainB"); + fService.purgeStore("mainB--layer"); + } + } protected void recursiveContents(String path) { diff --git a/source/java/org/alfresco/repo/avm/AVMStoreImpl.java b/source/java/org/alfresco/repo/avm/AVMStoreImpl.java index 87e980763a..73ed99f285 100644 --- a/source/java/org/alfresco/repo/avm/AVMStoreImpl.java +++ b/source/java/org/alfresco/repo/avm/AVMStoreImpl.java @@ -1746,17 +1746,15 @@ public class AVMStoreImpl implements AVMStore Lookup cPath = new Lookup(lPath, AVMDAOs.Instance().fAVMNodeDAO, AVMDAOs.Instance().fAVMStoreDAO); Pair result = dir.lookupChild(cPath, name, true); - if (result == null) + if (result != null) { - throw new AVMNotFoundException("Path " + parentPath + "/" +name + " not found."); + AVMNode child = result.getFirst(); + if (!fAVMRepository.can(null, child, PermissionService.WRITE, cPath.getDirectlyContained())) + { + throw new AccessDeniedException("Not allowed to update node: " + parentPath + "/" +name ); + } + dir.removeChild(lPath, name); } - AVMNode child = result.getFirst(); - if (!fAVMRepository.can(null, child, PermissionService.WRITE, cPath.getDirectlyContained())) - { - throw new AccessDeniedException("Not allowed to update node: " + parentPath + "/" +name ); - } - - dir.removeChild(lPath, name); dir.link(lPath, name, toLink); } diff --git a/source/java/org/alfresco/repo/content/AbstractContentReader.java b/source/java/org/alfresco/repo/content/AbstractContentReader.java index e492d81764..09ef4cfefc 100644 --- a/source/java/org/alfresco/repo/content/AbstractContentReader.java +++ b/source/java/org/alfresco/repo/content/AbstractContentReader.java @@ -45,6 +45,7 @@ import org.alfresco.service.cmr.repository.ContentAccessor; import org.alfresco.service.cmr.repository.ContentIOException; import org.alfresco.service.cmr.repository.ContentReader; import org.alfresco.service.cmr.repository.ContentStreamListener; +import org.alfresco.util.EqualsHelper; import org.alfresco.util.TempFileProvider; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -453,4 +454,42 @@ public abstract class AbstractContentReader extends AbstractContentAccessor impl e); } } + + /** + * Does a comparison of the binaries associated with two readers. Several shortcuts are assumed to be valid:
+ * - if the readers are the same instance, then the binaries are the same
+ * - if the size field is different, then the binaries are different
+ * Otherwise the binaries are {@link EqualsHelper#binaryStreamEquals(InputStream, InputStream) compared}. + * + * @return Returns true if the underlying binaries are the same + * @throws ContentIOException + */ + public static boolean compareContentReaders(ContentReader left, ContentReader right) throws ContentIOException + { + if (left == right) + { + return true; + } + else if (left == null || right == null) + { + return false; + } + else if (left.getSize() != right.getSize()) + { + return false; + } + InputStream leftIs = left.getContentInputStream(); + InputStream rightIs = right.getContentInputStream(); + try + { + return EqualsHelper.binaryStreamEquals(leftIs, rightIs); + } + catch (IOException e) + { + throw new ContentIOException( + "Failed to compare content reader streams: \n" + + " Left: " + left + "\n" + + " right: " + right); + } + } } diff --git a/source/java/org/alfresco/repo/content/transform/TextMiningContentTransformer.java b/source/java/org/alfresco/repo/content/transform/TextMiningContentTransformer.java index 03af6c1c5b..51ee916632 100644 --- a/source/java/org/alfresco/repo/content/transform/TextMiningContentTransformer.java +++ b/source/java/org/alfresco/repo/content/transform/TextMiningContentTransformer.java @@ -86,6 +86,10 @@ public class TextMiningContentTransformer extends AbstractContentTransformer2 // just assign an empty string text = ""; } + else + { + throw e; + } } finally { diff --git a/source/java/org/alfresco/repo/copy/CrossRepositoryCopyServiceImpl.java b/source/java/org/alfresco/repo/copy/CrossRepositoryCopyServiceImpl.java index 5a61fb115c..4542011b02 100644 --- a/source/java/org/alfresco/repo/copy/CrossRepositoryCopyServiceImpl.java +++ b/source/java/org/alfresco/repo/copy/CrossRepositoryCopyServiceImpl.java @@ -1,5 +1,26 @@ -/** - * +/* + * Copyright (C) 2005-2009 Alfresco Software Limited. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + + * As a special exception to the terms and conditions of version 2.0 of + * the GPL, you may redistribute this Program in connection with Free/Libre + * and Open Source Software ("FLOSS") applications as described in Alfresco's + * FLOSS exception. You should have recieved a copy of the text describing + * the FLOSS exception, and it is also available here: + * http://www.alfresco.com/legal/licensing" */ package org.alfresco.repo.copy; @@ -197,7 +218,7 @@ public class CrossRepositoryCopyServiceImpl implements CrossRepositoryCopyServic childRef = existing; } InputStream in = fAVMService.getFileInputStream(desc); - ContentData cd = fAVMService.getContentDataForRead(desc.getVersionID(), desc.getPath()); + ContentData cd = fAVMService.getContentDataForRead(versionPath.getFirst(), desc.getPath()); ContentWriter writer = fContentService.getWriter(childRef, ContentModel.PROP_CONTENT, true); writer.setEncoding(cd.getEncoding()); writer.setMimetype(cd.getMimetype()); diff --git a/source/java/org/alfresco/repo/importer/ImporterComponent.java b/source/java/org/alfresco/repo/importer/ImporterComponent.java index 9abda6caaa..e27d3bc9ca 100644 --- a/source/java/org/alfresco/repo/importer/ImporterComponent.java +++ b/source/java/org/alfresco/repo/importer/ImporterComponent.java @@ -801,6 +801,12 @@ public class ImporterComponent if (childName != null) { childName = bindPlaceHolder(childName, binding); + // + if (ContentModel.TYPE_PERSON.equals(context.getTypeDefinition().getName())) + { + childName = childName.toLowerCase(); + } + // String[] qnameComponents = QName.splitPrefixedQName(childName); childQName = QName.createQName(qnameComponents[0], QName.createValidLocalName(qnameComponents[1]), namespaceService); } diff --git a/source/java/org/alfresco/repo/node/BaseNodeServiceTest.java b/source/java/org/alfresco/repo/node/BaseNodeServiceTest.java index 57ab85a9f9..6014f3ed4b 100644 --- a/source/java/org/alfresco/repo/node/BaseNodeServiceTest.java +++ b/source/java/org/alfresco/repo/node/BaseNodeServiceTest.java @@ -1861,6 +1861,33 @@ public abstract class BaseNodeServiceTest extends BaseSpringTest RegexQNamePattern.MATCH_ALL); } + public void testDuplicateChildAssocCleanup() throws Exception + { + Map assocRefs = buildNodeGraph(); + NodeRef n1Ref = assocRefs.get(QName.createQName(BaseNodeServiceTest.NAMESPACE,"root_p_n1")).getChildRef(); + ChildAssociationRef n1pn3Ref = assocRefs.get(QName.createQName(BaseNodeServiceTest.NAMESPACE,"n1_p_n3")); + // Recreate the association from n1 to n3 i.e. duplicate it + QName assocQName = QName.createQName(BaseNodeServiceTest.NAMESPACE, "dup"); + ChildAssociationRef dup1 = nodeService.addChild( + n1pn3Ref.getParentRef(), + n1pn3Ref.getChildRef(), + n1pn3Ref.getTypeQName(), + assocQName); + ChildAssociationRef dup2 = nodeService.addChild( + n1pn3Ref.getParentRef(), + n1pn3Ref.getChildRef(), + n1pn3Ref.getTypeQName(), + assocQName); + assertEquals("Duplicate not created", dup1, dup2); + List dupAssocs = nodeService.getChildAssocs(n1pn3Ref.getParentRef(), n1pn3Ref.getTypeQName(), assocQName); + assertEquals("Expected duplicates", 2, dupAssocs.size()); + // Now delete the specific association + nodeService.removeChildAssociation(dup1); + + setComplete(); + endTransaction(); + } + public void testGetChildAssocsByChildType() throws Exception { /* diff --git a/source/java/org/alfresco/repo/node/db/hibernate/HibernateNodeDaoServiceImpl.java b/source/java/org/alfresco/repo/node/db/hibernate/HibernateNodeDaoServiceImpl.java index a509697bd8..13256e4604 100644 --- a/source/java/org/alfresco/repo/node/db/hibernate/HibernateNodeDaoServiceImpl.java +++ b/source/java/org/alfresco/repo/node/db/hibernate/HibernateNodeDaoServiceImpl.java @@ -110,6 +110,7 @@ import org.alfresco.service.cmr.repository.datatype.DefaultTypeConverter; import org.alfresco.service.cmr.repository.datatype.TypeConversionException; import org.alfresco.service.cmr.repository.datatype.TypeConverter; import org.alfresco.service.namespace.QName; +import org.alfresco.service.transaction.TransactionService; import org.alfresco.util.EqualsHelper; import org.alfresco.util.GUID; import org.alfresco.util.Pair; @@ -193,6 +194,7 @@ public class HibernateNodeDaoServiceImpl extends HibernateDaoSupport implements private LocaleDAO localeDAO; private DictionaryService dictionaryService; private boolean enableTimestampPropagation; + private TransactionService transactionService; private RetryingTransactionHelper auditableTransactionHelper; private BehaviourFilter behaviourFilter; /** A cache mapping StoreRef and NodeRef instances to the entity IDs (primary key) */ @@ -306,6 +308,14 @@ public class HibernateNodeDaoServiceImpl extends HibernateDaoSupport implements this.enableTimestampPropagation = enableTimestampPropagation; } + /** + * Executes post-transaction code + */ + public void setTransactionService(TransactionService transactionService) + { + this.transactionService = transactionService; + } + /** * Set the component to start new transactions when setting auditable properties (timestamps) * in the post-transaction phase. @@ -2746,6 +2756,7 @@ public class HibernateNodeDaoServiceImpl extends HibernateDaoSupport implements } } + @SuppressWarnings("unchecked") public Pair getChildAssoc( final Long parentNodeId, final Long childNodeId, @@ -2774,20 +2785,97 @@ public class HibernateNodeDaoServiceImpl extends HibernateDaoSupport implements .setParameter("qnameNamespaceId", assocQNameNamespacePair.getFirst()) .setParameter("qnameLocalName", assocQNameLocalName); DirtySessionMethodInterceptor.setQueryFlushMode(session, query); - return query.uniqueResult(); + return query.list(); } }; - ChildAssoc childAssoc = (ChildAssoc) getHibernateTemplate().execute(callback); - if (childAssoc == null) + @SuppressWarnings("unchecked") + List childAssocs = (List) getHibernateTemplate().execute(callback); + Pair ret = null; + for (ChildAssoc childAssoc : childAssocs) { - return null; - } - else - { - return new Pair(childAssoc.getId(), childAssoc.getChildAssocRef(qnameDAO)); + if (ret == null) + { + ret = new Pair(childAssoc.getId(), childAssoc.getChildAssocRef(qnameDAO)); + } + else + { + // Queue remaining assocs for a cleanup - they are duplicate + new ChildAssocDeleteTransactionListener(childAssoc.getId()); + } } + // Done + return ret; } + /** + * Post-transaction removal of duplicate child associations. + * + * @author Derek Hulley + * @since 2.2SP6 + */ + private class ChildAssocDeleteTransactionListener extends TransactionListenerAdapter + { + private final Long childAssocId; + + private ChildAssocDeleteTransactionListener(Long childAssocId) + { + this.childAssocId = childAssocId; + AlfrescoTransactionSupport.bindListener(this); + } + + @Override + public boolean equals(Object obj) + { + if (obj == this) + { + return true; + } + else if (obj == null || !(obj instanceof ChildAssocDeleteTransactionListener)) + { + return false; + } + ChildAssocDeleteTransactionListener that = (ChildAssocDeleteTransactionListener) obj; + return EqualsHelper.nullSafeEquals(this.childAssocId, that.childAssocId); + } + + @Override + public int hashCode() + { + return childAssocId == null ? 0 : childAssocId.hashCode(); + } + + @Override + public void afterCommit() + { + if (transactionService.isReadOnly()) + { + // Can't write to the repo + return; + } + RetryingTransactionCallback deleteCallback = new RetryingTransactionCallback() + { + public Void execute() throws Throwable + { + deleteChildAssoc(childAssocId); + if (logger.isInfoEnabled()) + { + logger.debug("Cleaned up duplicate child assoc: " + childAssocId); + } + return null; + } + }; + try + { + transactionService.getRetryingTransactionHelper().doInTransaction(deleteCallback); + } + catch (Throwable e) + { + // This is the post-commit phase. Exceptions would be absorbed anyway. + logger.warn("Failed to delete duplicate child association with ID " + childAssocId); + } + } + } + /** * Columns returned are: *
diff --git a/source/java/org/alfresco/repo/usage/ContentUsageImpl.java b/source/java/org/alfresco/repo/usage/ContentUsageImpl.java
index a68394bcfe..1b07d2f25c 100644
--- a/source/java/org/alfresco/repo/usage/ContentUsageImpl.java
+++ b/source/java/org/alfresco/repo/usage/ContentUsageImpl.java
@@ -46,6 +46,7 @@ import org.alfresco.service.cmr.usage.ContentUsageService;
 import org.alfresco.service.cmr.usage.UsageService;
 import org.alfresco.service.namespace.NamespaceService;
 import org.alfresco.service.namespace.QName;
+import org.alfresco.util.ParameterCheck;
 import org.apache.commons.logging.Log;
 import org.apache.commons.logging.LogFactory;
 
@@ -428,6 +429,8 @@ public class ContentUsageImpl implements ContentUsageService,
     
     public long getUserUsage(String userName)
     {
+        ParameterCheck.mandatoryString("userName", userName);
+        
         long currentUsage = -1;
         
         NodeRef personNodeRef = getPerson(userName);
diff --git a/source/java/org/alfresco/wcm/sandbox/SandboxServiceImplTest.java b/source/java/org/alfresco/wcm/sandbox/SandboxServiceImplTest.java
index 164094fdfb..78194a6746 100644
--- a/source/java/org/alfresco/wcm/sandbox/SandboxServiceImplTest.java
+++ b/source/java/org/alfresco/wcm/sandbox/SandboxServiceImplTest.java
@@ -1307,6 +1307,123 @@ public class SandboxServiceImplTest extends AbstractWCMServiceImplTest
         assertNotNull(assetService.getAssetWebApp(stagingSandboxId, webApp, "/myDir1/myFile2"));
     }
     
+    public void testSubmitDeletedItems_mimic_ETHREEOH_2581() throws IOException, InterruptedException
+    {
+        // Create Web Project A
+        
+        WebProjectInfo wpInfoA = wpService.createWebProject(TEST_SANDBOX+"-A", TEST_WEBPROJ_NAME+" A", TEST_WEBPROJ_TITLE, TEST_WEBPROJ_DESCRIPTION);
+        
+        final String wpStoreIdA = wpInfoA.getStoreId();
+        final String webAppA = wpInfoA.getDefaultWebApp();
+        final String stagingSandboxIdA = wpInfoA.getStagingStoreName();
+        
+        SandboxInfo sbInfoA = sbService.getAuthorSandbox(wpStoreIdA);
+        String authorSandboxIdA = sbInfoA.getSandboxId();
+        
+        // no assets
+        String stagingSandboxPathA = sbInfoA.getSandboxRootPath() + "/" + webAppA;
+        assertEquals(0, assetService.listAssets(stagingSandboxIdA, -1, stagingSandboxPathA, false).size());
+        
+        // no changes yet
+        List assets = sbService.listChangedAll(authorSandboxIdA, true);
+        assertEquals(0, assets.size());
+        
+        String authorSandboxPathA = sbInfoA.getSandboxRootPath() + "/" + webAppA;
+        
+        assetService.createFolder(authorSandboxIdA, authorSandboxPathA, "test", null);
+        
+        final String MYFILE = "This is testfile.txt in AAA";
+        ContentWriter writer = assetService.createFile(authorSandboxIdA, authorSandboxPathA+"/test", "testfile.txt", null);
+        writer.setMimetype(MimetypeMap.MIMETYPE_TEXT_PLAIN);
+        writer.setEncoding("UTF-8");
+        writer.putContent(MYFILE);
+        
+        assertEquals(1, assetService.listAssets(authorSandboxIdA, -1, authorSandboxPathA, false).size());
+        assertEquals(1, assetService.listAssets(authorSandboxIdA, -1, authorSandboxPathA+"/test", false).size());
+        
+        assets = sbService.listChangedWebApp(authorSandboxIdA, webAppA, false);
+        assertEquals(1, assets.size());
+        
+        // check staging before
+        assertEquals(0, assetService.listAssets(stagingSandboxIdA, -1, stagingSandboxPathA, false).size());
+        
+        // submit (new assets) !
+        sbService.submitWebApp(authorSandboxIdA, webAppA, "A1", "A1");
+        
+        Thread.sleep(SUBMIT_DELAY);
+        
+        assets = sbService.listChangedWebApp(authorSandboxIdA, webAppA, false);
+        assertEquals(0, assets.size());
+        
+        // check staging after
+        List listing = assetService.listAssets(stagingSandboxIdA, -1, stagingSandboxPathA, false);
+        assertEquals(1, listing.size());
+        
+        listing = assetService.listAssets(stagingSandboxIdA, -1, stagingSandboxPathA+"/test", false);
+        assertEquals(1, listing.size());
+        
+        // Create Web Project B
+        
+        WebProjectInfo wpInfoB = wpService.createWebProject(TEST_SANDBOX+"-B", TEST_WEBPROJ_NAME+" B", TEST_WEBPROJ_TITLE, TEST_WEBPROJ_DESCRIPTION);
+        
+        final String wpStoreIdB = wpInfoB.getStoreId();
+        final String webAppB = wpInfoB.getDefaultWebApp();
+        final String stagingSandboxIdB = wpInfoB.getStagingStoreName();
+        
+        SandboxInfo sbInfoB = sbService.getAuthorSandbox(wpStoreIdB);
+        String authorSandboxIdB = sbInfoB.getSandboxId();
+        
+        // no assets
+        String stagingSandboxPathB = sbInfoB.getSandboxRootPath() + "/" + webAppB;
+        assertEquals(0, assetService.listAssets(stagingSandboxIdB, -1, stagingSandboxPathB, false).size());
+        
+        // no changes yet
+        assets = sbService.listChangedAll(authorSandboxIdB, true);
+        assertEquals(0, assets.size());
+        
+        // drop to AVM to create WCM layered folder
+        avmService.createLayeredDirectory(wpStoreIdA+":"+stagingSandboxPathA+"/test", wpStoreIdB+":"+stagingSandboxPathB, "test");
+        
+        String authorSandboxPathB = sbInfoB.getSandboxRootPath() + "/" + webAppB;
+        
+        assertEquals(1, assetService.listAssets(authorSandboxIdB, -1, authorSandboxPathB, false).size());
+        assertEquals(1, assetService.listAssets(authorSandboxIdB, -1, authorSandboxPathB+"/test", false).size());
+        
+        // modify file
+        final String MYFILE_MODIFIED = "This is testfile.txt modified in BBB";
+        
+        writer = assetService.getContentWriter(assetService.getAssetWebApp(authorSandboxIdB, webAppB+"/test", "/testfile.txt"));
+        writer.setMimetype(MimetypeMap.MIMETYPE_TEXT_PLAIN);
+        writer.setEncoding("UTF-8");
+        writer.putContent(MYFILE_MODIFIED);
+        
+        // submit (modified asset)
+        sbService.submitWebApp(authorSandboxIdB, webAppB, "B1", "B1");
+        
+        Thread.sleep(SUBMIT_DELAY);
+        
+        // Switch back to Web Project A
+        
+        // delete folder
+        assetService.deleteAsset(assetService.getAssetWebApp(authorSandboxIdA, webAppA, "test"));
+        
+        // submit (deleted asset)
+        sbService.submitWebApp(authorSandboxIdA, webAppA, "A2", "A2");
+        
+        Thread.sleep(SUBMIT_DELAY);
+        
+        // Switch back to Web Project B
+        
+        // delete file
+        assetService.deleteAsset(assetService.getAssetWebApp(authorSandboxIdB, webAppB+"/test", "testfile.txt"));
+        
+        // submit (deleted asset)
+        // ETHREEOH_2581
+        sbService.submitWebApp(authorSandboxIdB, webAppB, "B2", "B2");
+        
+        Thread.sleep(SUBMIT_DELAY);
+    }
+    
     // revert/undo (changed) assets in user sandbox
     public void testUndo() throws IOException, InterruptedException
     {