diff --git a/src/lib/PnP.Framework.Modernization.Test/Transform/Functions/BuiltInImageLookupTests.cs b/src/lib/PnP.Framework.Modernization.Test/Transform/Functions/BuiltInImageLookupTests.cs new file mode 100644 index 000000000..776bb6b5f --- /dev/null +++ b/src/lib/PnP.Framework.Modernization.Test/Transform/Functions/BuiltInImageLookupTests.cs @@ -0,0 +1,284 @@ +using Microsoft.SharePoint.Client; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using PnP.Framework.Modernization.Cache; +using PnP.Framework.Modernization.Functions; +using PnP.Framework.Modernization.Transform; +using PnP.Framework.Utilities.UnitTests.Model; +using PnP.Framework.Utilities.UnitTests.Web; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Xml.Linq; + +namespace PnP.Framework.Modernization.Test.Transform.Functions +{ + [TestClass] + public class BuiltInImageLookupTests + { + private const string SiteUrl = "https://contoso.sharepoint.com/sites/modernization"; + private const string WebServerRelativeUrl = "/sites/modernization"; + private const string ImageServerRelativeUrl = "/sites/modernization/SiteAssets/green.svg"; + private static readonly Guid ImageListId = Guid.Parse("f266cd97-f155-4360-b802-47bb9f0d9b55"); + private static readonly Guid ImageUniqueId = Guid.Parse("a9b5b53e-2014-47d9-b26c-fc8d52047d1e"); + + [TestMethod] + public void ImageLookup_WhenBothDimensionsAreMissing_ReturnsNumericDefaults() + { + var result = InvokeImageLookup(false, null, false, null); + + AssertDimensions(result, "-1", "-1"); + } + + [TestMethod] + public void ImageLookup_WhenOnlyWidthIsMissing_ReturnsWidthDefault() + { + var result = InvokeImageLookup(false, null, true, "360"); + + AssertDimensions(result, "-1", "360"); + } + + [TestMethod] + public void ImageLookup_WhenOnlyHeightIsMissing_ReturnsHeightDefault() + { + var result = InvokeImageLookup(true, "960", false, null); + + AssertDimensions(result, "960", "-1"); + } + + [TestMethod] + public void ImageLookup_WhenDimensionsAreEmptyOrZero_ReturnsNumericDefaults() + { + var result = InvokeImageLookup(true, string.Empty, true, "0"); + + AssertDimensions(result, "-1", "-1"); + } + + [TestMethod] + public void ImageLookup_WhenDimensionsAreNull_ReturnsNumericDefaults() + { + var result = InvokeImageLookup(true, null, true, null); + + AssertDimensions(result, "-1", "-1"); + } + + [TestMethod] + public void ImageLookup_WhenDimensionsAreValid_ReturnsPropertyValues() + { + var result = InvokeImageLookup(true, "960", true, 360); + + AssertDimensions(result, "960", "360"); + } + + [TestMethod] + public void ImageLookup_WhenLookupIsSkipped_PreservesExistingDefaults() + { + var provider = CreateProvider(false, null, false, null); + using (var context = CreateContext(provider)) + { + var result = CreateBuiltIn(context).ImageLookup(null); + + Assert.IsNotNull(result); + Assert.AreEqual("-1", result["ImageWidth"]); + Assert.AreEqual("-1", result["ImageHeight"]); + Assert.AreEqual(string.Empty, result["ImageListId"]); + Assert.AreEqual(string.Empty, result["ImageUniqueId"]); + } + } + + [TestMethod] + public void ImageLookup_WhenFileIsNotFound_ReturnsNumericDefaults() + { + using (var context = CreateContext(CreateProvider(false, null, false, null))) + { + var builtIn = CreateBuiltIn(context); + context.Load(context.Web, web => web.ServerRelativeUrl); + context.ExecuteQuery(); + context.WebRequestExecutorFactory = new MockWebRequestExecutorFactory(new StaticResponseProvider( + "[{\"SchemaVersion\":\"15.0.0.0\",\"LibraryVersion\":\"16.0.0.0\",\"ErrorInfo\":{\"ErrorMessage\":\"File Not Found.\",\"ErrorValue\":null,\"ErrorCode\":-2147024894,\"ErrorTypeName\":\"System.IO.FileNotFoundException\"},\"TraceCorrelationId\":\"d991e178-2b53-4ff8-b77f-6bb73516962a\"}]")); + + var result = builtIn.ImageLookup(ImageServerRelativeUrl); + + Assert.IsNotNull(result); + Assert.AreEqual(string.Empty, result["ImageListId"]); + Assert.AreEqual(string.Empty, result["ImageUniqueId"]); + Assert.AreEqual("-1", result["ImageWidth"]); + Assert.AreEqual("-1", result["ImageHeight"]); + AssertMappingWithLookupResult("SharePointPnP.Modernization.WikiImagePart", result); + AssertMappingWithLookupResult("Microsoft.SharePoint.WebPartPages.ImageWebPart, Microsoft.SharePoint, Version=16.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c", result); + } + } + + [TestMethod] + public void WikiImageMapping_WithDefaultDimensions_ProducesNumericJsonProperties() + { + AssertMappingWithDefaultDimensions("SharePointPnP.Modernization.WikiImagePart"); + } + + [TestMethod] + public void ClassicImageWebPartMapping_WithDefaultDimensions_ProducesNumericJsonProperties() + { + AssertMappingWithDefaultDimensions("Microsoft.SharePoint.WebPartPages.ImageWebPart, Microsoft.SharePoint, Version=16.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c"); + } + + private static void AssertMappingWithDefaultDimensions(string webPartType) + { + var lookupResult = InvokeImageLookup(false, null, false, null); + AssertMappingWithLookupResult(webPartType, lookupResult); + } + + private static void AssertMappingWithLookupResult(string webPartType, Dictionary lookupResult) + { + var mapping = LoadImageJsonControlData(webPartType); + var replacements = new Dictionary + { + ["{Caption}"] = string.Empty, + ["{ServerRelativeFileName}"] = ImageServerRelativeUrl, + ["{serverRelativeFileName}"] = ImageServerRelativeUrl, + ["{Anchor}"] = string.Empty, + ["{AlternativeText}"] = "Green image", + ["{FileName}"] = "green.svg", + ["{SiteId}"] = "c6815669-f27c-4ff6-b937-0825b2cdfa93", + ["{webId}"] = "9d03e0aa-cb94-4463-b885-15a55d03ed52", + ["{WebId}"] = "9d03e0aa-cb94-4463-b885-15a55d03ed52", + ["{ImageListId}"] = lookupResult["ImageListId"], + ["{ImageUniqueId}"] = lookupResult["ImageUniqueId"], + ["{ImageWidth}"] = lookupResult["ImageWidth"], + ["{ImageHeight}"] = lookupResult["ImageHeight"] + }; + + foreach (var replacement in replacements) + { + mapping = mapping.Replace(replacement.Key, replacement.Value); + } + + using (var document = JsonDocument.Parse(mapping)) + { + var properties = document.RootElement.GetProperty("properties"); + Assert.AreEqual(JsonValueKind.Number, properties.GetProperty("imgWidth").ValueKind); + Assert.AreEqual(JsonValueKind.Number, properties.GetProperty("imgHeight").ValueKind); + Assert.AreEqual(-1, properties.GetProperty("imgWidth").GetInt32()); + Assert.AreEqual(-1, properties.GetProperty("imgHeight").GetInt32()); + } + } + + private static Dictionary InvokeImageLookup(bool includeWidth, object width, bool includeHeight, object height) + { + using (var context = CreateContext(CreateProvider(includeWidth, width, includeHeight, height))) + { + return CreateBuiltIn(context).ImageLookup(ImageServerRelativeUrl); + } + } + + private static ClientContext CreateContext(IMockResponseProvider provider) + { + CacheManager.Instance.SetSharePointVersion(new Uri(SiteUrl), SPVersion.SPO); + var context = new ClientContext(SiteUrl); + context.WebRequestExecutorFactory = new MockWebRequestExecutorFactory(provider); + return context; + } + + private static BuiltIn CreateBuiltIn(ClientContext context) + { + return new BuiltIn(new PageTransformationInformation(null) + { + SkipTelemetry = true + }, context); + } + + private static MockEntryResponseProvider CreateProvider(bool includeWidth, object width, bool includeHeight, object height) + { + var properties = new Dictionary + { + ["_ObjectType_"] = "SP.PropertyValues" + }; + if (includeWidth) + { + properties["vti_lastwidth"] = width; + } + if (includeHeight) + { + properties["vti_lastheight"] = height; + } + + var provider = new MockEntryResponseProvider(); + provider.ResponseEntries.Add(new MockResponseEntry + { + Url = SiteUrl, + PropertyName = "Site", + ReturnValue = new Dictionary + { + ["_ObjectType_"] = "SP.Site", + ["ServerRelativeUrl"] = WebServerRelativeUrl, + ["Url"] = SiteUrl + } + }); + provider.ResponseEntries.Add(new MockResponseEntry + { + Url = SiteUrl, + PropertyName = "Web", + ReturnValue = new Dictionary + { + ["_ObjectType_"] = "SP.Web", + ["ServerRelativeUrl"] = WebServerRelativeUrl, + ["Url"] = SiteUrl + } + }); + provider.ResponseEntries.Add(new MockResponseEntry + { + Url = SiteUrl, + Method = "GetFileByServerRelativeUrl", + ReturnValue = new Dictionary + { + ["_ObjectType_"] = "SP.File", + ["ListId"] = $"/Guid({ImageListId})/", + ["UniqueId"] = $"/Guid({ImageUniqueId})/", + ["Properties"] = properties + } + }); + return provider; + } + + private static string LoadImageJsonControlData(string webPartType) + { + var assembly = typeof(BuiltIn).Assembly; + var resourceName = assembly.GetManifestResourceNames() + .Single(name => name.EndsWith("Modernization.webpartmapping.xml", StringComparison.Ordinal)); + using (var stream = assembly.GetManifestResourceStream(resourceName)) + { + var document = XDocument.Load(stream); + var schemaNamespace = document.Root.Name.Namespace; + return document.Descendants(schemaNamespace + "WebPart") + .Single(element => (string)element.Attribute("Type") == webPartType) + .Descendants(schemaNamespace + "ClientSideWebPart") + .Single() + .Attribute("JsonControlData") + .Value; + } + } + + private static void AssertDimensions(Dictionary result, string expectedWidth, string expectedHeight) + { + Assert.IsNotNull(result); + Assert.AreEqual(ImageListId.ToString(), result["ImageListId"]); + Assert.AreEqual(ImageUniqueId.ToString(), result["ImageUniqueId"]); + Assert.AreEqual(expectedWidth, result["ImageWidth"]); + Assert.AreEqual(expectedHeight, result["ImageHeight"]); + } + + private sealed class StaticResponseProvider : IMockResponseProvider + { + private readonly string response; + + public StaticResponseProvider(string response) + { + this.response = response; + } + + public string GetResponse(string url, string verb, string body) + { + return response; + } + } + } +} diff --git a/src/lib/PnP.Framework.Modernization.Test/Transform/Publishing/PublishingPageInPlaceTransformationTests.cs b/src/lib/PnP.Framework.Modernization.Test/Transform/Publishing/PublishingPageInPlaceTransformationTests.cs new file mode 100644 index 000000000..e38af419b --- /dev/null +++ b/src/lib/PnP.Framework.Modernization.Test/Transform/Publishing/PublishingPageInPlaceTransformationTests.cs @@ -0,0 +1,441 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Microsoft.SharePoint.Client; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using PnP.Framework.Modernization.Cache; +using PnP.Framework.Modernization.Publishing; +using PnP.Framework.Modernization.Telemetry; +using PnP.Framework.Modernization.Transform; +using PnP.Framework.Utilities.UnitTests.Model; +using PnP.Framework.Utilities.UnitTests.Web; + +namespace PnP.Framework.Modernization.Tests.Transform.Publishing +{ + [TestClass] + public class PublishingPageInPlaceTransformationTests + { + private static readonly Guid SourceSiteId = Guid.Parse("c33f44c0-e1ce-4d87-a961-98669e76bca6"); + private static readonly Guid SourceWebId = Guid.Parse("a467c0f4-dcf4-49f3-8a08-e33de32d3202"); + + public TestContext TestContext { get; set; } + + [TestMethod] + public void ModelCreateInPlaceUsesSourceContextAsTarget() + { + using (var sourceContext = CreateContext("https://contoso.sharepoint.com/sites/enterprise-wiki")) + { + var transformator = PublishingPageTransformator.CreateInPlace(sourceContext, new PageTransformation(), null); + + Assert.AreSame(sourceContext, transformator.sourceClientContext); + Assert.AreSame(sourceContext, transformator.targetClientContext); + } + } + + [TestMethod] + public void FileMappingCreateInPlaceUsesSourceContextAsTarget() + { + Directory.CreateDirectory(TestContext.TestResultsDirectory); + var mappingPath = Path.Combine(TestContext.TestResultsDirectory, $"webpartmapping-{Guid.NewGuid():N}.xml"); + System.IO.File.WriteAllText(mappingPath, BasePageTransformator.LoadDefaultWebPartMappingFile()); + + try + { + using (var sourceContext = CreateContext("https://contoso.sharepoint.com/sites/enterprise-wiki")) + { + var transformator = PublishingPageTransformator.CreateInPlace(sourceContext, mappingPath, null); + + Assert.AreSame(sourceContext, transformator.sourceClientContext); + Assert.AreSame(sourceContext, transformator.targetClientContext); + } + } + finally + { + System.IO.File.Delete(mappingPath); + } + } + + [TestMethod] + public void ModelConstructorStillRejectsANullTargetContext() + { + using (var sourceContext = CreateContext("https://contoso.sharepoint.com/sites/enterprise-wiki")) + { + Assert.ThrowsException(() => CreateTransformator(sourceContext, null)); + } + } + + [TestMethod] + public void FileMappingConstructorStillRejectsANullTargetContext() + { + Directory.CreateDirectory(TestContext.TestResultsDirectory); + var mappingPath = Path.Combine(TestContext.TestResultsDirectory, $"webpartmapping-{Guid.NewGuid():N}.xml"); + System.IO.File.WriteAllText(mappingPath, BasePageTransformator.LoadDefaultWebPartMappingFile()); + + try + { + using (var sourceContext = CreateContext("https://contoso.sharepoint.com/sites/enterprise-wiki")) + { + Assert.ThrowsException( + () => new PublishingPageTransformator(sourceContext, null, mappingPath, null)); + } + } + finally + { + System.IO.File.Delete(mappingPath); + } + } + + [TestMethod] + public void SameWebTargetIsAcceptedWithoutAdditionalOptions() + { + var result = ValidateSameWeb(hasWritableSitePages: true); + + Assert.AreEqual(PublishingPageTransformationTarget.SameWeb, result); + } + + [TestMethod] + public void SameSiteCollectionDifferentWebFailsClosed() + { + var result = PublishingPageTransformationValidator.ValidateTarget( + SourceSiteId, + SourceSiteId, + SourceWebId, + Guid.Parse("7896708f-f5ea-4f22-aeb3-6223bf8897b0"), + true); + + Assert.AreEqual(PublishingPageTransformationTarget.SameSiteCollectionDifferentWeb, result); + } + + [TestMethod] + public void ConstructorRetainsAnExplicitTargetContext() + { + using (var sourceContext = CreateContext("https://contoso.sharepoint.com/sites/enterprise-wiki")) + using (var targetContext = CreateContext("https://contoso.sharepoint.com/sites/modern-target")) + { + var transformator = CreateTransformator(sourceContext, targetContext); + + Assert.AreSame(sourceContext, transformator.sourceClientContext); + Assert.AreSame(targetContext, transformator.targetClientContext); + } + } + + [TestMethod] + public void ConstructorStillRequiresSourceContext() + { + Assert.ThrowsException(() => CreateTransformator(null, null)); + } + + [TestMethod] + public void SameWebSupportsGeneralPublishingPagesAndRequiresWritableSitePages() + { + Assert.AreEqual( + PublishingPageTransformationTarget.SameWebRequiresWritableSitePages, + ValidateSameWeb(false)); + Assert.AreEqual( + PublishingPageTransformationTarget.SameWeb, + ValidateSameWeb(true)); + } + + [TestMethod] + public void CrossSiteCollectionKeepsExistingTargetPolicyAndPermissionSemantics() + { + var result = PublishingPageTransformationValidator.ValidateTarget( + SourceSiteId, + Guid.Parse("2e33ad34-f43a-4d75-af55-e0deba981252"), + SourceWebId, + Guid.Empty, + false); + + Assert.AreEqual(PublishingPageTransformationTarget.CrossSiteCollection, result); + Assert.IsTrue(PublishingPageTransformationValidator.CanOverwriteTarget(result, true)); + Assert.IsTrue(PublishingPageTransformationValidator.UsesCrossSitePermissionSemantics(result)); + } + + [TestMethod] + public void SameWebNeverOverwritesAndUsesInPlacePermissionSemantics() + { + var result = ValidateSameWeb(true); + + Assert.IsFalse(PublishingPageTransformationValidator.CanOverwriteTarget(result, true)); + Assert.IsFalse(PublishingPageTransformationValidator.UsesCrossSitePermissionSemantics(result)); + } + + [TestMethod] + public void SameWebUrlTransformationIsANoOp() + { + const string webUrl = "https://contoso.sharepoint.com/sites/enterprise-wiki"; + const string sourceUrl = "/sites/enterprise-wiki/Pages/source.aspx"; + + using (var sourceContext = CreateContext(webUrl)) + using (var targetContext = CreateContext(webUrl)) + { + var information = new PublishingPageTransformationInformation(null); + var transformator = new UrlTransformator(information, sourceContext, targetContext); + + Assert.AreEqual(sourceUrl, transformator.Transform(sourceUrl)); + } + } + + [TestMethod] + public void SameWebAssetTransferIsANoOp() + { + const string webUrl = "https://contoso.sharepoint.com/sites/enterprise-wiki"; + const string sourceAssetUrl = "/sites/enterprise-wiki/PublishingImages/image.png"; + + using (var sourceContext = CreateContext(webUrl)) + using (var targetContext = CreateContext(webUrl)) + { + var transfer = new AssetTransfer(sourceContext, targetContext); + + Assert.AreEqual(sourceAssetUrl, transfer.TransferAsset(sourceAssetUrl, "target.aspx")); + } + } + + [TestMethod] + public void TransformSeamKnownCollisionNeverInvokesAddAndKeepsSourceUnchanged() + { + using (var sourceContext = CreateContext("https://contoso.sharepoint.com/sites/enterprise-wiki")) + using (var targetContext = CreateContext("https://contoso.sharepoint.com/sites/enterprise-wiki")) + { + var information = CreateInPlaceInformation(sourceContext, overwrite: true); + var sourceBefore = SnapshotSource(information.SourcePage); + var sourceHadPendingRequest = sourceContext.HasPendingRequest; + var addCalls = 0; + var transformator = CreateTransformator(sourceContext, targetContext); + + Assert.ThrowsException(() => transformator.CreateTargetPageAfterCollisionProbe( + PublishingPageTransformationTarget.SameWeb, + information, + () => null, + _ => + { + addCalls++; + return null; + }, + out _)); + + Assert.AreEqual(0, addCalls, "AddClientSidePage seam must not run for a known in-place collision."); + AssertSourceUnchanged(sourceBefore, information.SourcePage); + Assert.AreEqual(sourceHadPendingRequest, sourceContext.HasPendingRequest, "The source context request state changed."); + } + } + + [TestMethod] + public void TransformSeamRejectedInPlaceTargetsNeverProbeOrAddAndKeepSourceUnchanged() + { + using (var sourceContext = CreateContext("https://contoso.sharepoint.com/sites/enterprise-wiki")) + using (var targetContext = CreateContext("https://contoso.sharepoint.com/sites/enterprise-wiki")) + { + var information = CreateInPlaceInformation(sourceContext, overwrite: true); + var sourceBefore = SnapshotSource(information.SourcePage); + var sourceHadPendingRequest = sourceContext.HasPendingRequest; + var transformator = CreateTransformator(sourceContext, targetContext); + + foreach (var rejectedTarget in new[] + { + PublishingPageTransformationTarget.SameSiteCollectionDifferentWeb, + PublishingPageTransformationTarget.SameWebRequiresWritableSitePages, + }) + { + var probeCalls = 0; + var addCalls = 0; + + Assert.ThrowsException(() => transformator.CreateTargetPageAfterCollisionProbe( + rejectedTarget, + information, + () => + { + probeCalls++; + return null; + }, + _ => + { + addCalls++; + return null; + }, + out _)); + + Assert.AreEqual(0, probeCalls, $"Target probe ran for rejected target '{rejectedTarget}'."); + Assert.AreEqual(0, addCalls, $"AddClientSidePage seam ran for rejected target '{rejectedTarget}'."); + AssertSourceUnchanged(sourceBefore, information.SourcePage); + Assert.AreEqual(sourceHadPendingRequest, sourceContext.HasPendingRequest, "The source context request state changed."); + } + } + } + + [TestMethod] + public void TransformSeamInconclusiveProbeNeverInvokesAddAndKeepsSourceUnchanged() + { + using (var sourceContext = CreateContext("https://contoso.sharepoint.com/sites/enterprise-wiki")) + using (var targetContext = CreateContext("https://contoso.sharepoint.com/sites/enterprise-wiki")) + { + var information = CreateInPlaceInformation(sourceContext, overwrite: true); + var sourceBefore = SnapshotSource(information.SourcePage); + var sourceHadPendingRequest = sourceContext.HasPendingRequest; + var addCalls = 0; + var transformator = CreateTransformator(sourceContext, targetContext); + + var exception = Assert.ThrowsException(() => transformator.CreateTargetPageAfterCollisionProbe( + PublishingPageTransformationTarget.SameWeb, + information, + () => throw new InvalidOperationException("target probe inconclusive"), + _ => + { + addCalls++; + return null; + }, + out _)); + + Assert.AreEqual("target probe inconclusive", exception.Message); + Assert.AreEqual(0, addCalls, "AddClientSidePage seam must not run after an inconclusive in-place probe."); + AssertSourceUnchanged(sourceBefore, information.SourcePage); + Assert.AreEqual(sourceHadPendingRequest, sourceContext.HasPendingRequest, "The source context request state changed."); + } + } + + [TestMethod] + public void TransformSeamConfirmedMissingTargetInvokesAddOnce() + { + using (var sourceContext = CreateContext("https://contoso.sharepoint.com/sites/enterprise-wiki")) + using (var targetContext = CreateContext("https://contoso.sharepoint.com/sites/enterprise-wiki")) + { + var information = CreateInPlaceInformation(sourceContext, overwrite: false); + var addCalls = 0; + var addedPageName = string.Empty; + var transformator = CreateTransformator(sourceContext, targetContext); + + transformator.CreateTargetPageAfterCollisionProbe( + PublishingPageTransformationTarget.SameWeb, + information, + () => throw new PageDoesNotExistInWebException($"{information.TargetPageName} - {LogStrings.TransformPageDoesNotExistInWeb}"), + pageName => + { + addCalls++; + addedPageName = pageName; + return null; + }, + out _); + + Assert.AreEqual(1, addCalls); + Assert.AreEqual("converted/source.aspx", addedPageName); + } + } + + [TestMethod] + public void TargetPageNameWithoutExtensionIsCanonicalized() + { + // Saving appends .aspx, so an uncanonicalized name would probe and read back a different + // file than the one written, letting an existing target page be overwritten unnoticed. + Assert.AreEqual("existing.aspx", BasePageTransformator.EnsureAspxExtension("existing")); + } + + [TestMethod] + public void TargetPageNameKeepsAnExistingExtensionRegardlessOfCasing() + { + Assert.AreEqual("existing.aspx", BasePageTransformator.EnsureAspxExtension("existing.aspx")); + Assert.AreEqual("existing.ASPX", BasePageTransformator.EnsureAspxExtension("existing.ASPX")); + } + + [TestMethod] + public void TargetPageNameCanonicalizationIsIdempotent() + { + var canonicalized = BasePageTransformator.EnsureAspxExtension("existing"); + + Assert.AreEqual(canonicalized, BasePageTransformator.EnsureAspxExtension(canonicalized)); + } + + [TestMethod] + public void TargetPageNameCanonicalizationKeepsEmbeddedDots() + { + Assert.AreEqual("report.v2.aspx", BasePageTransformator.EnsureAspxExtension("report.v2")); + } + + [TestMethod] + public void TargetPageNameCanonicalizationLeavesAnAbsentNameToTheSourcePageDefault() + { + Assert.IsNull(BasePageTransformator.EnsureAspxExtension(null)); + Assert.AreEqual(string.Empty, BasePageTransformator.EnsureAspxExtension(string.Empty)); + } + + private static PublishingPageTransformationTarget ValidateSameWeb( + bool hasWritableSitePages) + { + return PublishingPageTransformationValidator.ValidateTarget( + SourceSiteId, + SourceSiteId, + SourceWebId, + SourceWebId, + hasWritableSitePages); + } + + private static PublishingPageTransformator CreateTransformator(ClientContext sourceContext, ClientContext targetContext) + { + return new PublishingPageTransformator(sourceContext, targetContext, new PageTransformation(), null); + } + + private static PublishingPageTransformationInformation CreateInPlaceInformation(ClientContext sourceContext, bool overwrite) + { + var sourcePage = sourceContext.Web.Lists.GetByTitle("Pages").GetItemById(7); + sourcePage["CCD_SourceIdentity"] = "Pages/7/v3"; + sourcePage["CCD_SourceDigest"] = "sha256:unchanged"; + + return new PublishingPageTransformationInformation(sourcePage, overwrite) + { + Folder = "converted/", + TargetPageName = "source.aspx", + }; + } + + private static Dictionary SnapshotSource(ListItem sourcePage) + { + return sourcePage.FieldValues.ToDictionary( + pair => pair.Key, + pair => pair.Value?.ToString()); + } + + private static void AssertSourceUnchanged(Dictionary expected, ListItem actual) + { + var actualSnapshot = SnapshotSource(actual); + CollectionAssert.AreEquivalent(expected.Keys.ToArray(), actualSnapshot.Keys.ToArray()); + + foreach (var pair in expected) + { + Assert.AreEqual(pair.Value, actualSnapshot[pair.Key], $"Source field '{pair.Key}' changed."); + } + } + + private static ClientContext CreateContext(string webUrl) + { + var responseProvider = new MockEntryResponseProvider(); + responseProvider.ResponseEntries.Add(new MockResponseEntry + { + Url = webUrl, + PropertyName = "Web", + ReturnValue = new + { + Url = webUrl, + ServerRelativeUrl = "/sites/enterprise-wiki", + }, + }); + responseProvider.ResponseEntries.Add(new MockResponseEntry + { + Url = webUrl, + PropertyName = "Site", + ReturnValue = new + { + Url = webUrl, + ServerRelativeUrl = "/sites/enterprise-wiki", + }, + }); + + CacheManager.Instance.SetSharePointVersion(new Uri(webUrl), SPVersion.SPO); + CacheManager.Instance.SetExactSharePointVersion(new Uri(webUrl), "16.0.0.26000"); + + return new ClientContext(webUrl) + { + WebRequestExecutorFactory = new MockWebRequestExecutorFactory(responseProvider), + }; + } + } +} diff --git a/src/lib/PnP.Framework/Modernization/Functions/BuiltIn.cs b/src/lib/PnP.Framework/Modernization/Functions/BuiltIn.cs index d6b0e0957..4a1795e4d 100644 --- a/src/lib/PnP.Framework/Modernization/Functions/BuiltIn.cs +++ b/src/lib/PnP.Framework/Modernization/Functions/BuiltIn.cs @@ -647,7 +647,7 @@ public string ListDetectUsedView(Guid listId, string xmlDefinition) /// /// Server relative path of the image /// A set of image properties - [FunctionDocumentation(Description = "Does lookup a file based on the given server relative path and return needed properties of the file. Returns null if file was not found.", + [FunctionDocumentation(Description = "Looks up image properties by server relative path. Returns empty file identities and numeric defaults when the image file was not found.", Example = "ImageLookup({ServerRelativeFileName})")] [InputDocumentation(Name = "{ServerRelativeFileName}", Description = "Server relative file name of the image")] [OutputDocumentation(Name = "{ImageListId}", Description = "Id of the list holding the file")] @@ -692,23 +692,27 @@ public Dictionary ImageLookup(string serverRelativeImagePath) results.Add("ImageListId", pageHeaderImage.ListId.ToString()); results.Add("ImageUniqueId", pageHeaderImage.UniqueId.ToString()); - if (pageHeaderImage.Properties.FieldValues.ContainsKey("vti_lastheight")) + // Pages and image files created entirely through programmatic flows can bypass the + // post-save processing that populates vti_lastheight and vti_lastwidth. Treat those + // dimensions as unknown so transformation can continue for these customer edge cases. + results.Add("ImageHeight", "-1"); + results.Add("ImageWidth", "-1"); + + if (pageHeaderImage.Properties.FieldValues.TryGetValue("vti_lastheight", out var heightValue)) { - var height = pageHeaderImage.Properties.FieldValues["vti_lastheight"].ToString(); - if (string.IsNullOrEmpty(height) || height == "0") + var height = heightValue?.ToString(); + if (!string.IsNullOrEmpty(height) && height != "0") { - height = "-1"; + results["ImageHeight"] = height; } - results.Add("ImageHeight", height); } - if (pageHeaderImage.Properties.FieldValues.ContainsKey("vti_lastwidth")) + if (pageHeaderImage.Properties.FieldValues.TryGetValue("vti_lastwidth", out var widthValue)) { - var width = pageHeaderImage.Properties.FieldValues["vti_lastwidth"].ToString(); - if (string.IsNullOrEmpty(width) || width == "0") + var width = widthValue?.ToString(); + if (!string.IsNullOrEmpty(width) && width != "0") { - width = "-1"; + results["ImageWidth"] = width; } - results.Add("ImageWidth", width); } @@ -718,8 +722,14 @@ public Dictionary ImageLookup(string serverRelativeImagePath) { if (ex.ServerErrorTypeName == "System.IO.FileNotFoundException") { - // Provided image was not found, should not happen - return null; + // Keep the original image URL when its file is missing, but still resolve + // the required numeric tokens in the modern Image web part mapping. + LogWarning($"Image file was not found; retaining its source URL: {serverRelativeImagePath}", LogStrings.Heading_BuiltInFunctions); + results["ImageListId"] = ""; + results["ImageUniqueId"] = ""; + results["ImageHeight"] = "-1"; + results["ImageWidth"] = "-1"; + return results; } else { diff --git a/src/lib/PnP.Framework/Modernization/Publishing/PublishingPageTransformationValidator.cs b/src/lib/PnP.Framework/Modernization/Publishing/PublishingPageTransformationValidator.cs new file mode 100644 index 000000000..d2ec57bcb --- /dev/null +++ b/src/lib/PnP.Framework/Modernization/Publishing/PublishingPageTransformationValidator.cs @@ -0,0 +1,53 @@ +using System; + +namespace PnP.Framework.Modernization.Publishing +{ + internal enum PublishingPageTransformationTarget + { + CrossSiteCollection, + SameWeb, + SameSiteCollectionDifferentWeb, + SameWebRequiresWritableSitePages, + } + + /// + /// Pure validation rules for choosing the publishing page transformation target mode. + /// + internal static class PublishingPageTransformationValidator + { + internal static PublishingPageTransformationTarget ValidateTarget( + Guid sourceSiteId, + Guid targetSiteId, + Guid sourceWebId, + Guid targetWebId, + bool hasWritableSitePages) + { + if (sourceSiteId != targetSiteId) + { + return PublishingPageTransformationTarget.CrossSiteCollection; + } + + if (sourceWebId != targetWebId) + { + return PublishingPageTransformationTarget.SameSiteCollectionDifferentWeb; + } + + if (!hasWritableSitePages) + { + return PublishingPageTransformationTarget.SameWebRequiresWritableSitePages; + } + + return PublishingPageTransformationTarget.SameWeb; + } + + internal static bool CanOverwriteTarget(PublishingPageTransformationTarget target, bool overwriteRequested) + { + return target == PublishingPageTransformationTarget.CrossSiteCollection && overwriteRequested; + } + + internal static bool UsesCrossSitePermissionSemantics(PublishingPageTransformationTarget target) + { + return target == PublishingPageTransformationTarget.CrossSiteCollection; + } + } +} diff --git a/src/lib/PnP.Framework/Modernization/Publishing/PublishingPageTransformator.cs b/src/lib/PnP.Framework/Modernization/Publishing/PublishingPageTransformator.cs index 3a7c1ba61..f04e5a0e7 100644 --- a/src/lib/PnP.Framework/Modernization/Publishing/PublishingPageTransformator.cs +++ b/src/lib/PnP.Framework/Modernization/Publishing/PublishingPageTransformator.cs @@ -40,7 +40,7 @@ public PublishingPageTransformator(ClientContext sourceClientContext, ClientCont #endif this.sourceClientContext = sourceClientContext ?? throw new ArgumentException("sourceClientContext must be provided."); - this.targetClientContext = targetClientContext ?? throw new ArgumentException("targetClientContext must be provided."); ; + this.targetClientContext = targetClientContext ?? throw new ArgumentException("targetClientContext must be provided."); this.version = GetVersion(); this.pageTelemetry = new PageTelemetry(version); @@ -79,7 +79,7 @@ public PublishingPageTransformator(ClientContext sourceClientContext, ClientCont #endif this.sourceClientContext = sourceClientContext ?? throw new ArgumentException("sourceClientContext must be provided."); - this.targetClientContext = targetClientContext ?? throw new ArgumentException("targetClientContext must be provided."); ; + this.targetClientContext = targetClientContext ?? throw new ArgumentException("targetClientContext must be provided."); this.version = GetVersion(); this.pageTelemetry = new PageTelemetry(version); @@ -96,6 +96,51 @@ public PublishingPageTransformator(ClientContext sourceClientContext, ClientCont this.publishingPageTransformation = this.pageLayoutManager.MergePageLayoutMappingFiles(this.publishingPageTransformation, customPublishingPageTransformationModel); } } + + /// + /// Creates a transformator that writes the modern page into the Web that holds the publishing page. + /// + /// Client context of the Web holding the publishing page + /// A transformator configured for in-place transformation + public static PublishingPageTransformator CreateInPlace(ClientContext sourceClientContext) + { + return new PublishingPageTransformator(sourceClientContext, sourceClientContext); + } + + /// + /// Creates a transformator that writes the modern page into the Web that holds the publishing page. + /// + /// Client context of the Web holding the publishing page + /// Page layout mapping file + /// A transformator configured for in-place transformation + public static PublishingPageTransformator CreateInPlace(ClientContext sourceClientContext, string publishingPageTransformationFile) + { + return new PublishingPageTransformator(sourceClientContext, sourceClientContext, publishingPageTransformationFile); + } + + /// + /// Creates a transformator that writes the modern page into the Web that holds the publishing page. + /// + /// Client context of the Web holding the publishing page + /// Web part mapping file + /// Page layout mapping file + /// A transformator configured for in-place transformation + public static PublishingPageTransformator CreateInPlace(ClientContext sourceClientContext, string pageTransformationFile, string publishingPageTransformationFile) + { + return new PublishingPageTransformator(sourceClientContext, sourceClientContext, pageTransformationFile, publishingPageTransformationFile); + } + + /// + /// Creates a transformator that writes the modern page into the Web that holds the publishing page. + /// + /// Client context of the Web holding the publishing page + /// Web part mapping model + /// Page layout mapping model + /// A transformator configured for in-place transformation + public static PublishingPageTransformator CreateInPlace(ClientContext sourceClientContext, PageTransformation pageTransformationModel, PublishingPageTransformation customPublishingPageTransformationModel) + { + return new PublishingPageTransformator(sourceClientContext, sourceClientContext, pageTransformationModel, customPublishingPageTransformationModel); + } #endregion /// @@ -175,17 +220,35 @@ public string Transform(PublishingPageTransformationInformation publishingPageTr PopulateGlobalProperties(sourceClientContext, targetClientContext); - if (sourceClientContext.Site.Id.Equals(targetClientContext.Site.Id)) + var sameSiteCollection = sourceClientContext.Site.Id.Equals(targetClientContext.Site.Id); + var sourceWebId = sameSiteCollection ? sourceClientContext.Web.EnsureProperty(p => p.Id) : Guid.Empty; + var hasWritableSitePages = false; + + if (sameSiteCollection && sourceWebId.Equals(targetClientContext.Web.Id)) + { + hasWritableSitePages = HasWritableSitePagesLibrary(sourceClientContext); + } + + var transformationTarget = PublishingPageTransformationValidator.ValidateTarget( + sourceClientContext.Site.Id, + targetClientContext.Site.Id, + sourceWebId, + targetClientContext.Web.Id, + hasWritableSitePages); + + EnsureTransformationTargetAllowed(transformationTarget); + + if (transformationTarget == PublishingPageTransformationTarget.SameWeb) { - // Oops, seems source and target point to the same site collection...that's a no go for publishing portal page transformation! - LogError(LogStrings.Error_SameSiteTransferNoAllowedForPublishingPages, LogStrings.Heading_SharePointConnection); - throw new ArgumentNullException(LogStrings.Error_SameSiteTransferNoAllowedForPublishingPages); + // Use the source context for all writes so existing in-place permission, URL and asset semantics apply. + targetClientContext = sourceClientContext; } LogInfo($"{targetClientContext.Web.GetUrl()}", LogStrings.Heading_Summary, LogEntrySignificance.TargetSiteUrl); // Need to add further validation for target template - if (targetClientContext.Web.WebTemplate != "SITEPAGEPUBLISHING" && targetClientContext.Web.WebTemplate != "STS" && + if (transformationTarget == PublishingPageTransformationTarget.CrossSiteCollection && + targetClientContext.Web.WebTemplate != "SITEPAGEPUBLISHING" && targetClientContext.Web.WebTemplate != "STS" && targetClientContext.Web.WebTemplate != "GROUP" && targetClientContext.Web.WebTemplate != "BDR" && targetClientContext.Web.WebTemplate != "DEV") { @@ -301,11 +364,16 @@ public string Transform(PublishingPageTransformationInformation publishingPageTr publishingPageTransformationInformation.TargetPageName = $"{publishingPageTransformationInformation.SourcePage[Constants.FileLeafRefField].ToString()}"; } + // Canonicalize the target page name once, before it is used to probe for a collision, to save the + // page and to read the saved page back. Saving appends the .aspx extension when it is missing, so a + // caller supplied name without the extension would otherwise probe and read back a different file + // than the one that is written, and an existing target page could be overwritten unnoticed. + publishingPageTransformationInformation.TargetPageName = EnsureAspxExtension(publishingPageTransformationInformation.TargetPageName); + // Check if page name is free to use #if DEBUG && MEASURE Start(); #endif - bool pageExists = false; PnPCore.IPage targetPage = null; List pagesLibrary = null; Microsoft.SharePoint.Client.File existingFile = null; @@ -313,46 +381,17 @@ public string Transform(PublishingPageTransformationInformation publishingPageTr //The determines of the target client context has been specified and use that to generate the target page var context = targetClientContext; - try - { - LogDebug(LogStrings.LoadingExistingPageIfExists, LogStrings.Heading_PageCreation); + targetPage = CreateTargetPageAfterCollisionProbe( + transformationTarget, + publishingPageTransformationInformation, + () => Load(sourceClientContext, targetClientContext, publishingPageTransformationInformation, out pagesLibrary), + pageName => context.Web.AddClientSidePage(pageName), + out existingFile); - // Just try to load the page in the fastest possible manner, we only want to see if the page exists or not - existingFile = Load(sourceClientContext, targetClientContext, publishingPageTransformationInformation, out pagesLibrary); - pageExists = true; - } - catch (Exception ex) - { - if(ex is ArgumentException) - { - //Non-critical error generated - LogInfo(LogStrings.CheckPageExistsError, LogStrings.Heading_PageCreation); - } - else - { - //Something else occurred - LogError(LogStrings.CheckPageExistsError, LogStrings.Heading_PageCreation, ex); - } - } - #if DEBUG && MEASURE Stop("Load Page"); #endif - if (pageExists) - { - LogInfo(LogStrings.PageAlreadyExistsInTargetLocation, LogStrings.Heading_PageCreation); - - if (!publishingPageTransformationInformation.Overwrite) - { - var message = $"{LogStrings.PageNotOverwriteIfExists} {publishingPageTransformationInformation.TargetPageName}."; - LogError(message, LogStrings.Heading_PageCreation); - throw new ArgumentException(message); - } - } - - // Create the client side page - targetPage = context.Web.AddClientSidePage($"{publishingPageTransformationInformation.Folder}{publishingPageTransformationInformation.TargetPageName}"); LogInfo($"{LogStrings.ModernPageCreated} ", LogStrings.Heading_PageCreation); #endregion @@ -534,10 +573,10 @@ public string Transform(PublishingPageTransformationInformation publishingPageTr Start(); #endif // Check if we do have item level permissions we want to take over - listItemPermissionsToKeep = GetItemLevelPermissions(true, pagesLibrary, publishingPageTransformationInformation.SourcePage, savedTargetPage.ListItemAllFields); + var usesCrossSitePermissionSemantics = PublishingPageTransformationValidator.UsesCrossSitePermissionSemantics(transformationTarget); + listItemPermissionsToKeep = GetItemLevelPermissions(usesCrossSitePermissionSemantics, pagesLibrary, publishingPageTransformationInformation.SourcePage, savedTargetPage.ListItemAllFields); - // When creating the page in another site collection we'll always want to copy item level permissions if specified - ApplyItemLevelPermissions(true, savedTargetPage.ListItemAllFields, listItemPermissionsToKeep); + ApplyItemLevelPermissions(usesCrossSitePermissionSemantics, savedTargetPage.ListItemAllFields, listItemPermissionsToKeep); #if DEBUG && MEASURE Stop("Permission handling"); #endif @@ -668,6 +707,135 @@ public static string LoadDefaultPageLayoutMappingFile() } #region Helper methods + /// + /// Production seam that keeps the collision probe and the first target-page operation in one fail-closed flow. + /// + internal PnPCore.IPage CreateTargetPageAfterCollisionProbe( + PublishingPageTransformationTarget transformationTarget, + PublishingPageTransformationInformation publishingPageTransformationInformation, + Func targetPageProbe, + Func addClientSidePage, + out Microsoft.SharePoint.Client.File existingFile) + { + // Keep the first target operation fail-closed even if a future caller bypasses the earlier routing guard. + EnsureTransformationTargetAllowed(transformationTarget); + + var pageExists = false; + existingFile = null; + + try + { + LogDebug(LogStrings.LoadingExistingPageIfExists, LogStrings.Heading_PageCreation); + + // A successful probe means the target name is already occupied, even if the caller does not need the file object. + existingFile = targetPageProbe(); + pageExists = true; + } + catch (Exception ex) + { + // Match the "page not found" signal structurally. LogStrings are English-only constants that are + // intended to be localized, so matching on the message text would silently break this routing. + var targetPageDoesNotExist = ex is PageDoesNotExistInWebException; + + if (targetPageDoesNotExist) + { + LogInfo(LogStrings.CheckPageExistsError, LogStrings.Heading_PageCreation); + } + else if (transformationTarget == PublishingPageTransformationTarget.SameWeb) + { + // Stop in-place transformation when the target collision probe is inconclusive. + throw; + } + else + { + // Preserve the existing default cross-site behavior. + LogError(LogStrings.CheckPageExistsError, LogStrings.Heading_PageCreation, ex); + } + } + + if (pageExists) + { + LogInfo(LogStrings.PageAlreadyExistsInTargetLocation, LogStrings.Heading_PageCreation); + + if (!PublishingPageTransformationValidator.CanOverwriteTarget(transformationTarget, publishingPageTransformationInformation.Overwrite)) + { + var message = transformationTarget == PublishingPageTransformationTarget.SameWeb + ? $"{LogStrings.Error_InPlacePublishingPageTargetExists} {publishingPageTransformationInformation.TargetPageName}." + : $"{LogStrings.PageNotOverwriteIfExists} {publishingPageTransformationInformation.TargetPageName}."; + LogError(message, LogStrings.Heading_PageCreation); + throw new ArgumentException(message); + } + } + + return addClientSidePage($"{publishingPageTransformationInformation.Folder}{publishingPageTransformationInformation.TargetPageName}"); + } + + private void EnsureTransformationTargetAllowed(PublishingPageTransformationTarget transformationTarget) + { + switch (transformationTarget) + { + case PublishingPageTransformationTarget.SameSiteCollectionDifferentWeb: + LogError(LogStrings.Error_InPlacePublishingPageDifferentWeb, LogStrings.Heading_SharePointConnection); + throw new ArgumentException(LogStrings.Error_InPlacePublishingPageDifferentWeb); + case PublishingPageTransformationTarget.SameWebRequiresWritableSitePages: + LogError(LogStrings.Error_InPlacePublishingPageRequiresWritableSitePages, LogStrings.Heading_SharePointConnection); + throw new ArgumentException(LogStrings.Error_InPlacePublishingPageRequiresWritableSitePages); + } + } + + private bool HasWritableSitePagesLibrary(ClientContext context) + { + var sitePagesServerRelativeUrl = UrlUtility.Combine(context.Web.ServerRelativeUrl, "SitePages"); + + try + { + var sitePagesLibrary = context.Web.GetList(sitePagesServerRelativeUrl); + context.Load(sitePagesLibrary, + p => p.Id, + p => p.BaseTemplate, + p => p.EffectiveBasePermissions); + context.ExecuteQueryRetry(); + + if (sitePagesLibrary.Id == Guid.Empty) + { + LogError($"{LogStrings.Error_InPlacePublishingPageSitePagesNotFound} Path: {sitePagesServerRelativeUrl}.", LogStrings.Heading_SharePointConnection); + return false; + } + + if (sitePagesLibrary.BaseTemplate != (int)ListTemplateType.WebPageLibrary) + { + LogError($"{LogStrings.Error_InPlacePublishingPageSitePagesInvalidTemplate} Path: {sitePagesServerRelativeUrl}; BaseTemplate: {sitePagesLibrary.BaseTemplate}.", LogStrings.Heading_SharePointConnection); + return false; + } + + var canAdd = sitePagesLibrary.EffectiveBasePermissions.Has(PermissionKind.AddListItems); + var canEdit = sitePagesLibrary.EffectiveBasePermissions.Has(PermissionKind.EditListItems); + if (!canAdd || !canEdit) + { + LogError($"{LogStrings.Error_InPlacePublishingPageSitePagesInsufficientPermissions} Path: {sitePagesServerRelativeUrl}; AddListItems: {canAdd}; EditListItems: {canEdit}.", LogStrings.Heading_SharePointConnection); + return false; + } + + return true; + } + catch (ServerUnauthorizedAccessException ex) + { + LogError($"{LogStrings.Error_InPlacePublishingPageSitePagesProbeUnauthorized} Path: {sitePagesServerRelativeUrl}; {ex.Message}", LogStrings.Heading_SharePointConnection); + return false; + } + catch (ServerException ex) when (string.Equals(ex.ServerErrorTypeName, "System.IO.FileNotFoundException", StringComparison.Ordinal)) + { + LogError($"{LogStrings.Error_InPlacePublishingPageSitePagesNotFound} Path: {sitePagesServerRelativeUrl}; {ex.Message}", LogStrings.Heading_SharePointConnection); + return false; + } + catch (Exception ex) + { + var message = $"{LogStrings.Error_InPlacePublishingPageSitePagesProbeFailed} Path: {sitePagesServerRelativeUrl}; {ex.Message}"; + LogError(message, LogStrings.Heading_SharePointConnection); + throw new InvalidOperationException(message, ex); + } + } + private void SetPageTitle(PublishingPageTransformationInformation publishingPageTransformationInformation, PnPCore.IPage targetPage) { string titleValue = ""; @@ -734,7 +902,7 @@ private Microsoft.SharePoint.Client.File Load(ClientContext sourceContext, Clien if (!file.Exists) { LogInfo(LogStrings.TransformPageDoesNotExistInWeb, LogStrings.Heading_Load); //Not an error this is a check - throw new ArgumentException($"{publishingPageTransformationInformation.TargetPageName} - {LogStrings.TransformPageDoesNotExistInWeb}"); + throw new PageDoesNotExistInWebException($"{publishingPageTransformationInformation.TargetPageName} - {LogStrings.TransformPageDoesNotExistInWeb}"); } return file; diff --git a/src/lib/PnP.Framework/Modernization/Telemetry/LogStrings.cs b/src/lib/PnP.Framework/Modernization/Telemetry/LogStrings.cs index 3f64dbca7..0aadc0e17 100644 --- a/src/lib/PnP.Framework/Modernization/Telemetry/LogStrings.cs +++ b/src/lib/PnP.Framework/Modernization/Telemetry/LogStrings.cs @@ -78,6 +78,14 @@ public static class LogStrings public const string Error_OverridingTagePageTakesSourcePageName = "Overriding 'TargetPageTakesSourcePageName' to ensure that the newly created page in the other site collection gets the same name as the source page"; public const string Error_FallBackToSameSiteTransfer = "Oops, seems source and target point to the same site collection...switch back the 'source only' mode"; public const string Error_SameSiteTransferNoAllowedForPublishingPages = "Oops, seems source and target point to the same site collection...that's a no go for publishing portal page transformation!"; + public const string Error_InPlacePublishingPageDifferentWeb = "Publishing page transformation between different Webs in the same site collection is not supported."; + public const string Error_InPlacePublishingPageRequiresWritableSitePages = "In-place publishing page transformation requires an existing Site Pages library with Add Items and Edit Items permissions."; + public const string Error_InPlacePublishingPageSitePagesNotFound = "The Site Pages library required for in-place publishing page transformation was not found."; + public const string Error_InPlacePublishingPageSitePagesInvalidTemplate = "The Site Pages path does not resolve to a Web Page Library."; + public const string Error_InPlacePublishingPageSitePagesInsufficientPermissions = "The current user cannot add and edit items in the Site Pages library."; + public const string Error_InPlacePublishingPageSitePagesProbeUnauthorized = "The Site Pages capability probe was denied."; + public const string Error_InPlacePublishingPageSitePagesProbeFailed = "The Site Pages capability probe could not determine whether the library is writable; transformation was stopped without creating a target page."; + public const string Error_InPlacePublishingPageTargetExists = "In-place publishing page transformation cannot overwrite an existing target page."; public const string Error_CrossSiteTransferTargetsNonModernSite = "Page transformation for targeting non-modern sites is currently not supported."; public const string Error_GetVersionError = "Setting version stamp error"; public const string Error_MissingSitePagesLibrary = "Site does not have a sitepages library and therefore this page can't be a client side page."; diff --git a/src/lib/PnP.Framework/Modernization/Transform/BasePageTransformator.cs b/src/lib/PnP.Framework/Modernization/Transform/BasePageTransformator.cs index cd959ea89..74f36ce38 100644 --- a/src/lib/PnP.Framework/Modernization/Transform/BasePageTransformator.cs +++ b/src/lib/PnP.Framework/Modernization/Transform/BasePageTransformator.cs @@ -1087,6 +1087,26 @@ internal void Stop(string method) /// /// /// + /// + /// Appends the .aspx extension to a target page name when it is missing, matching the name the page is + /// actually saved under. The comparison is case insensitive, so an existing extension is never duplicated. + /// Saving a client side page appends the extension on its own, so the collision probe, the save and the + /// readback must all be given the same canonical name or they resolve to different files. + /// + /// Target page name supplied by the caller or derived from the source page + /// The target page name as it will exist in the Site Pages library + internal static string EnsureAspxExtension(string targetPageName) + { + if (string.IsNullOrEmpty(targetPageName)) + { + return targetPageName; + } + + return targetPageName.EndsWith(".aspx", StringComparison.InvariantCultureIgnoreCase) + ? targetPageName + : $"{targetPageName}.aspx"; + } + internal void LoadClientObject(ClientContext clientContext, bool isTargetContext) { if (clientContext != null) @@ -1102,7 +1122,9 @@ internal void LoadClientObject(ClientContext clientContext, bool isTargetContext { clientContext.Load(clientContext.Web, p => p.Id, p => p.ServerRelativeUrl, p => p.RootFolder.WelcomePage, p => p.Language); } - clientContext.Load(clientContext.Site, p => p.RootWeb.ServerRelativeUrl, p => p.Id, p => p.Url); + clientContext.Load(clientContext.Site, p => p.Id, p => p.Url); + // Load RootWeb as its own CSOM object instead of expanding it through Site. + clientContext.Load(clientContext.Site.RootWeb, p => p.ServerRelativeUrl); // Use regular ExecuteQuery as we want to send this custom clienttag clientContext.ExecuteQuery(); } diff --git a/src/lib/PnP.Framework/Modernization/Transform/PageDoesNotExistInWebException.cs b/src/lib/PnP.Framework/Modernization/Transform/PageDoesNotExistInWebException.cs new file mode 100644 index 000000000..fe13964b3 --- /dev/null +++ b/src/lib/PnP.Framework/Modernization/Transform/PageDoesNotExistInWebException.cs @@ -0,0 +1,27 @@ +using System; + +namespace PnP.Framework.Modernization.Transform +{ +#pragma warning disable CA1032 + /// + /// Exception class thrown when a probed page does not exist in the web. Derives from + /// so that existing handlers catching + /// keep observing the previous behavior. + /// + public class PageDoesNotExistInWebException : ArgumentException + { + /// + /// Throws a PageDoesNotExistInWebException message + /// + /// Error message + public PageDoesNotExistInWebException(string message) : base(message) { } + + /// + /// Throws a PageDoesNotExistInWebException message + /// + /// Error message + /// Inner exception object + public PageDoesNotExistInWebException(string message, Exception innerException) : base(message, innerException) { } + } +#pragma warning restore CA1032 +} diff --git a/src/lib/PnP.Framework/Modernization/Transform/PageTransformator.cs b/src/lib/PnP.Framework/Modernization/Transform/PageTransformator.cs index 8b4203348..ecd522a00 100644 --- a/src/lib/PnP.Framework/Modernization/Transform/PageTransformator.cs +++ b/src/lib/PnP.Framework/Modernization/Transform/PageTransformator.cs @@ -473,6 +473,12 @@ public string Transform(PageTransformationInformation pageTransformationInformat } + // Canonicalize the target page name once, before it is used to probe for a collision, to save the + // page and to read the saved page back. Saving appends the .aspx extension when it is missing, so a + // caller supplied name without the extension would otherwise probe and read back a different file + // than the one that is written, and an existing target page could be overwritten unnoticed. + pageTransformationInformation.TargetPageName = EnsureAspxExtension(pageTransformationInformation.TargetPageName); + // Check if page name is free to use #if DEBUG && MEASURE Start(); @@ -1278,7 +1284,7 @@ private Microsoft.SharePoint.Client.File Load(ClientContext sourceContext, PageT if (!file.Exists) { LogInfo(LogStrings.TransformPageDoesNotExistInWeb, LogStrings.Heading_Load); - throw new ArgumentException($"{pageTransformationInformation.TargetPageName} - {LogStrings.TransformPageDoesNotExistInWeb}"); + throw new PageDoesNotExistInWebException($"{pageTransformationInformation.TargetPageName} - {LogStrings.TransformPageDoesNotExistInWeb}"); } return file;