Skip to content

Commit

Permalink
Extend MsappArchive APIs
Browse files Browse the repository at this point in the history
  • Loading branch information
joem-msft committed Jun 25, 2024
1 parent 0fa0bd8 commit 04c74b0
Show file tree
Hide file tree
Showing 6 changed files with 313 additions and 57 deletions.
7 changes: 4 additions & 3 deletions src/Persistence.Tests/MsApp/MsappArchiveSaveTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ public class MsappArchiveSaveTests : TestBase
{
[TestMethod]
[DataRow(@" Hello ", $"src/Hello.pa.yaml", @"_TestData/ValidYaml-CI/Screen-Hello1.pa.yaml")]
[DataRow(@"..\..\Hello", $"src/Hello.pa.yaml", @"_TestData/ValidYaml-CI/Screen-Hello2.pa.yaml")]
[DataRow(@"c:\win\..\..\Hello", $"src/cWinHello.pa.yaml", @"_TestData/ValidYaml-CI/Screen-Hello3.pa.yaml")]
[DataRow(@"//..?HelloScreen", $"src/HelloScreen.pa.yaml", @"_TestData/ValidYaml-CI/Screen-Hello4.pa.yaml")]
[DataRow(@"..\..\Hello", $"src/....Hello.pa.yaml", @"_TestData/ValidYaml-CI/Screen-Hello2.pa.yaml")]
[DataRow(@"c:\win\..\..\Hello", $"src/cWin....Hello.pa.yaml", @"_TestData/ValidYaml-CI/Screen-Hello3.pa.yaml")]
[DataRow(@"//..?HelloScreen", $"src/..HelloScreen.pa.yaml", @"_TestData/ValidYaml-CI/Screen-Hello4.pa.yaml")]
[DataRow(@"Hello Space", $"src/Hello Space.pa.yaml", @"_TestData/ValidYaml-CI/Screen-Hello5.pa.yaml")]
public void Msapp_ShouldSave_Screen(string screenName, string screenEntryName, string expectedYamlPath)
{
Expand All @@ -32,6 +32,7 @@ public void Msapp_ShouldSave_Screen(string screenName, string screenEntryName, s
using var msappValidation = MsappArchiveFactory.Open(tempFile);
msappValidation.App.Should().BeNull();
msappValidation.CanonicalEntries.Count.Should().Be(2);
msappValidation.DoesEntryExist(screenEntryName).Should().BeTrue();
var screenEntry = msappValidation.CanonicalEntries[MsappArchive.NormalizePath(screenEntryName)];
screenEntry.Should().NotBeNull();
using var streamReader = new StreamReader(msappValidation.GetRequiredEntry(screenEntryName).Open());
Expand Down
194 changes: 174 additions & 20 deletions src/Persistence.Tests/MsApp/MsappArchiveTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,25 @@
using System.IO.Compression;
using Microsoft.PowerPlatform.PowerApps.Persistence;
using Microsoft.PowerPlatform.PowerApps.Persistence.MsApp;
using Microsoft.PowerPlatform.PowerApps.Persistence.Yaml;
using Moq;

namespace Persistence.Tests.MsApp;

[TestClass]
public class MsappArchiveTests : TestBase
{
private readonly Mock<IYamlSerializationFactory> _mockYamlSerializationFactory;

public MsappArchiveTests()
{
_mockYamlSerializationFactory = new(MockBehavior.Strict);
_mockYamlSerializationFactory.Setup(f => f.CreateSerializer(It.IsAny<YamlSerializationOptions>()))
.Returns(new Mock<IYamlSerializer>(MockBehavior.Strict).Object);
_mockYamlSerializationFactory.Setup(f => f.CreateDeserializer(It.IsAny<YamlSerializationOptions>()))
.Returns(new Mock<IYamlDeserializer>(MockBehavior.Strict).Object);
}

[DataRow(new string[] { "abc.txt" }, MsappArchive.Directories.Resources, null, 0, 0)]
[DataRow(new string[] { "abc.txt", @$"{MsappArchive.Directories.Resources}\", @$"{MsappArchive.Directories.Resources}\abc.txt" }, null, null, 1, 2)]
[DataRow(new string[] { "abc.txt", @$"{MsappArchive.Directories.Resources}\", @$"{MsappArchive.Directories.Resources}\abc.txt" }, null, ".txt", 1, 2)]
Expand Down Expand Up @@ -65,11 +78,12 @@ public void AddEntryTests(string[] entries, string[] expectedEntries)

// Assert
msappArchive.CanonicalEntries.Count.Should().Be(entries.Length);
for (var i = 0; i != expectedEntries.Length; ++i)
foreach (var expectedEntry in expectedEntries)
{
msappArchive.CanonicalEntries.ContainsKey(expectedEntries[i])
msappArchive.CanonicalEntries.ContainsKey(expectedEntry)
.Should()
.BeTrue($"Expected entry {expectedEntries[i]} to exist in the archive");
.BeTrue($"Expected entry {expectedEntry} to exist in the archive");
msappArchive.DoesEntryExist(expectedEntry).Should().BeTrue();
}

// Get the required entry should throw if it doesn't exist
Expand All @@ -80,23 +94,21 @@ public void AddEntryTests(string[] entries, string[] expectedEntries)

private static IEnumerable<object[]> AddEntryTestsData()
{
return new[] {
new [] {
new[] { "abc.txt" },
new[] { "abc.txt" }
},
new[]{
new [] { "abc.txt", @$"{MsappArchive.Directories.Resources}\abc.txt" },
new [] { "abc.txt", @$"{MsappArchive.Directories.Resources}/abc.txt".ToLowerInvariant() },
},
new[]{
new [] { "abc.txt", @$"{MsappArchive.Directories.Resources}\DEF.txt" },
new [] { "abc.txt", @$"{MsappArchive.Directories.Resources}/DEF.txt".ToLowerInvariant() },
},
new[]{
new [] { "abc.txt", @$"{MsappArchive.Directories.Resources}\DEF.txt", @"\start-with-slash\test.json" },
new [] { "abc.txt", @$"{MsappArchive.Directories.Resources}/DEF.txt".ToLowerInvariant(), @"start-with-slash/test.json" },
}
yield return new string[][] {
[ "abc.txt" ],
[ "abc.txt" ]
};
yield return new string[][] {
[ "abc.txt", @$"{MsappArchive.Directories.Resources}\abc.txt" ],
[ "abc.txt", @$"{MsappArchive.Directories.Resources}/abc.txt".ToLowerInvariant() ],
};
yield return new string[][] {
[ "abc.txt", @$"{MsappArchive.Directories.Resources}\DEF.txt" ],
[ "abc.txt", @$"{MsappArchive.Directories.Resources}/DEF.txt".ToLowerInvariant() ],
};
yield return new string[][] {
[ "abc.txt", @$"{MsappArchive.Directories.Resources}\DEF.txt", @"\start-with-slash\test.json" ],
[ "abc.txt", @$"{MsappArchive.Directories.Resources}/DEF.txt".ToLowerInvariant(), @"start-with-slash/test.json" ],
};
}

Expand Down Expand Up @@ -128,4 +140,146 @@ public void Msapp_ShouldHave_Screens(string testDirectory, int allEntriesCount,

var screen = msappArchive.App.Screens.Single(c => c.Name == topLevelControlName);
}

[TestMethod]
public void DoesEntryExistTests()
{
// Setup test archive with a couple entries in it already
using var archiveMemStream = new MemoryStream();
using var archive = new MsappArchive(archiveMemStream, ZipArchiveMode.Create, _mockYamlSerializationFactory.Object);
archive.CreateEntry("entryA");
archive.CreateEntry("entryB");
archive.CreateEntry("dir1/entryA");
archive.CreateEntry("dir1/entryB");
archive.CreateEntry("dir2/entryA");
archive.CreateEntry("dir2/entryC");

// Test for entries that should exist, exact case
archive.DoesEntryExist("entryA").Should().BeTrue();
archive.DoesEntryExist("entryB").Should().BeTrue();
archive.DoesEntryExist("dir1/entryA").Should().BeTrue();
archive.DoesEntryExist("dir1/entryB").Should().BeTrue();
archive.DoesEntryExist("dir2/entryA").Should().BeTrue();
archive.DoesEntryExist("dir2/entryC").Should().BeTrue();

// Should exist, but not exact case or may use non-normalized path
archive.DoesEntryExist("ENTRYa").Should().BeTrue();
archive.DoesEntryExist("entryB").Should().BeTrue();
archive.DoesEntryExist("dir1/entryA").Should().BeTrue();
archive.DoesEntryExist("Dir1\\ENTRYa").Should().BeTrue();
archive.DoesEntryExist("dir1/entryb").Should().BeTrue();
archive.DoesEntryExist("dir1\\entryb").Should().BeTrue();

// Test for entries that should not exist
archive.DoesEntryExist("entryC").Should().BeFalse();
archive.DoesEntryExist("entryC").Should().BeFalse();
}

[TestMethod]
public void DoesEntryExistWorksWithNewEntriesCreated()
{
// Setup test archive with a couple entries in it already
using var archiveMemStream = new MemoryStream();
using var archive = new MsappArchive(archiveMemStream, ZipArchiveMode.Create, _mockYamlSerializationFactory.Object);
archive.CreateEntry("entryA");
archive.CreateEntry("entryB");
archive.CreateEntry("dir2/entryA");
archive.CreateEntry("dir2/entryC");

// Make sure our new entry does not exist, can be created, and then exists
archive.DoesEntryExist("dir1/newEntryD").Should().BeFalse();
archive.CreateEntry("dir1/newEntryD");
archive.DoesEntryExist("dir1/newEntryD").Should().BeTrue();
}

[TestMethod]
public void TryGenerateUniqueEntryPathTests()
{
// Setup test archive with a couple entries in it already
using var archiveMemStream = new MemoryStream();
using var archive = new MsappArchive(archiveMemStream, ZipArchiveMode.Create, _mockYamlSerializationFactory.Object);
archive.CreateEntry("entryA.pa.yaml");
archive.CreateEntry("entryB.pa.yaml");
archive.CreateEntry("dir1/entryC.pa.yaml");
archive.CreateEntry("dir1/entryD.pa.yaml");

//
string? actualEntryPath;
archive.TryGenerateUniqueEntryPath(null, "entryA", ".pa.yaml", out actualEntryPath).Should().BeTrue();
actualEntryPath.Should().Be("entryA1.pa.yaml");
archive.TryGenerateUniqueEntryPath(null, "entryC", ".pa.yaml", out actualEntryPath).Should().BeTrue();
actualEntryPath.Should().Be("entryC.pa.yaml");
archive.TryGenerateUniqueEntryPath("dir1", "entryA", ".pa.yaml", out actualEntryPath).Should().BeTrue();
actualEntryPath.Should().Be("dir1\\entryA.pa.yaml");
archive.TryGenerateUniqueEntryPath("dir1", "entryC", ".pa.yaml", out actualEntryPath).Should().BeTrue();
actualEntryPath.Should().Be("dir1\\entryC1.pa.yaml");

// Verify repeated calls will keep incrementing the suffix
archive.TryGenerateUniqueEntryPath(null, "entryA", ".pa.yaml", out actualEntryPath).Should().BeTrue();
actualEntryPath.Should().Be("entryA1.pa.yaml");
archive.CreateEntry(actualEntryPath!);

archive.TryGenerateUniqueEntryPath(null, "entryA", ".pa.yaml", out actualEntryPath).Should().BeTrue();
actualEntryPath.Should().Be("entryA2.pa.yaml");
archive.CreateEntry(actualEntryPath!);

archive.TryGenerateUniqueEntryPath(null, "entryA", ".pa.yaml", out actualEntryPath).Should().BeTrue();
actualEntryPath.Should().Be("entryA3.pa.yaml");

// Verify when using a custom separator
archive.TryGenerateUniqueEntryPath(null, "entryA", ".pa.yaml", out actualEntryPath, uniqueSuffixSeparator: "_").Should().BeTrue();
actualEntryPath.Should().Be("entryA_1.pa.yaml");
archive.TryGenerateUniqueEntryPath("dir1", "entryA", ".pa.yaml", out actualEntryPath, uniqueSuffixSeparator: "_").Should().BeTrue();
actualEntryPath.Should().Be("dir1\\entryA.pa.yaml");
}

[TestMethod]
[DataRow(":%/\\?!", false, DisplayName = "Unsafe chars only")]
[DataRow(" :%/\\ ?! ", false, DisplayName = "Unsafe and whitespace chars only")]
[DataRow("", false, DisplayName = "empty string")]
[DataRow(" ", false, DisplayName = "whitespace chars only")]
[DataRow("Foo.Bar", true, "Foo.Bar")]
[DataRow(" Foo Bar ", true, "Foo Bar", DisplayName = "with leading/trailing whitespace")]
[DataRow("Foo:%/\\-?!Bar", true, "Foo-Bar")]
public void TryMakeSafeForEntryPathSegmentWithDefaultReplacementTests(string unsafeName, bool expectedReturn, string? expectedSafeName = null)
{
MsappArchive.TryMakeSafeForEntryPathSegment(unsafeName, out var safeName).Should().Be(expectedReturn);
if (expectedReturn)
{
safeName.ShouldNotBeNull();
if (expectedSafeName != null)
{
safeName.Should().Be(expectedSafeName);
}
}
else
{
safeName.Should().BeNull();
}
}

[TestMethod]
[DataRow(":%/\\?!", true, "______", DisplayName = "Unsafe chars only")]
[DataRow(" :%/\\ ?! ", true, "____ __", DisplayName = "Unsafe and whitespace chars only")]
[DataRow("", false, DisplayName = "empty string")]
[DataRow(" ", false, DisplayName = "whitespace chars only")]
[DataRow("Foo.Bar", true, "Foo.Bar")]
[DataRow(" Foo Bar ", true, "Foo Bar", DisplayName = "with leading/trailing whitespace")]
[DataRow("Foo:%/\\-?!Bar", true, "Foo____-__Bar")]
public void TryMakeSafeForEntryPathSegmentWithUnderscoreReplacementTests(string unsafeName, bool expectedReturn, string? expectedSafeName = null)
{
MsappArchive.TryMakeSafeForEntryPathSegment(unsafeName, out var safeName, unsafeCharReplacementText: "_").Should().Be(expectedReturn);
if (expectedReturn)
{
safeName.ShouldNotBeNull();
if (expectedSafeName != null)
{
safeName.Should().Be(expectedSafeName);
}
}
else
{
safeName.Should().BeNull();
}
}
}
1 change: 1 addition & 0 deletions src/Persistence.Tests/Persistence.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
<PackageReference Include="MSTest" Version="$(MSTest)" />
<PackageReference Include="coverlet.collector" Version="$(CoverletCollectorVersion)" />
<PackageReference Include="FluentAssertions" Version="$(FluentAssertionsVersion)" />
<PackageReference Include="Moq" Version="4.16.0" />
</ItemGroup>

<ItemGroup Label="Global usings">
Expand Down
17 changes: 17 additions & 0 deletions src/Persistence/Extensions/StringExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Globalization;

namespace Microsoft.PowerPlatform.PowerApps.Persistence.Extensions;

public static class StringExtensions
{
public static string FirstCharToUpper(this string input)
Expand All @@ -29,4 +30,20 @@ public static bool StartsWithOrdinal(this string input, string value)

return input.StartsWith(value, StringComparison.Ordinal);
}

/// <summary>
/// Converts a string to null if it is empty or whitespace.
/// </summary>
public static string? WhiteSpaceToNull(this string? source)
{
return string.IsNullOrWhiteSpace(source) ? null : source;
}

/// <summary>
/// Converts a string to null if it is empty.
/// </summary>
public static string? EmptyToNull(this string? source)
{
return string.IsNullOrEmpty(source) ? null : source;
}
}
27 changes: 25 additions & 2 deletions src/Persistence/MsApp/IMsappArchive.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ public interface IMsappArchive : IDisposable
/// <returns>the name of the resource</returns>
string AddImage(string fileName, Stream imageStream);

/// <summary>
/// Determine whether an entry with the given path exists in the archive.
/// </summary>
bool DoesEntryExist(string entryPath);

/// <summary>
/// Creates a new entry in the archive with the given name.
/// </summary>
Expand All @@ -65,7 +70,24 @@ public interface IMsappArchive : IDisposable
ZipArchiveEntry CreateEntry(string entryName);

/// <summary>
/// Returns the entry in the archive with the given name.
/// Attempts to generate a unique entry path in the specified directory, based on a starter file name and extension.
/// </summary>
/// <param name="directory">The directory path for the entry; or null. Note: Directory path is expected to already be safe, as it is expected to not contain customer data.</param>
/// <param name="fileNameNoExtension">The name of the file, without an extension. This file name should already have been made 'safe' by the caller. If needed, use <see cref="MsappArchive.TryMakeSafeForEntryPathSegment"/> to make it safe.</param>
/// <param name="extension">The file extension to add for the file path or null for no extension. Note: This should already contain only safe chars, as it is expected to not contain customer data.</param>
/// <param name="uniqueSuffixSeparator">The string added just before the unique file number and extension. Default is empty string.</param>
/// <param name="entryPath">The entry path which is unique in the specified <paramref name="directory"/>.</param>
/// <returns>A value indicating whether a unique entry was able to be created. If true, then <paramref name="entryPath"/> will contain the unique path.</returns>
/// <exception cref="ArgumentException">directory is empty or whitespace.</exception>
bool TryGenerateUniqueEntryPath(
string? directory,
string fileNameNoExtension,
string? extension,
[NotNullWhen(true)] out string? entryPath,
string uniqueSuffixSeparator = "");

/// <summary>
/// Returns the entry in the archive with the given name or null when not found.
/// </summary>
/// <param name="entryName"></param>
/// <returns>the entry or null when not found.</returns>
Expand All @@ -80,7 +102,7 @@ public interface IMsappArchive : IDisposable
bool TryGetEntry(string entryName, [MaybeNullWhen(false)] out ZipArchiveEntry zipArchiveEntry);

/// <summary>
/// Returns the entry in the archive with the given name.
/// Returns the entry in the archive with the given name or throws if it does not exist.
/// </summary>
ZipArchiveEntry GetRequiredEntry(string entryName);

Expand All @@ -91,6 +113,7 @@ public interface IMsappArchive : IDisposable

/// <summary>
/// Dictionary of all entries in the archive.
/// The keys are normalized paths for the entry computed using <see cref="MsappArchive.NormalizePath"/>.
/// </summary>
IReadOnlyDictionary<string, ZipArchiveEntry> CanonicalEntries { get; }

Expand Down
Loading

0 comments on commit 04c74b0

Please sign in to comment.