Browse Source

Refactoring/backups (#460)

* More tests for backups and SRP improvements.
pull/462/head
Sebastian Stehle 6 years ago
committed by GitHub
parent
commit
fa506459ea
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 10
      backend/src/Squidex.Domain.Apps.Entities/Apps/AppCommandMiddleware.cs
  2. 140
      backend/src/Squidex.Domain.Apps.Entities/Apps/BackupApps.cs
  3. 2
      backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AssignContributor.cs
  4. 47
      backend/src/Squidex.Domain.Apps.Entities/Apps/DefaultAppImageStore.cs
  5. 2
      backend/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppContributors.cs
  6. 21
      backend/src/Squidex.Domain.Apps.Entities/Apps/IAppImageStore.cs
  7. 2
      backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InviteUserCommandMiddleware.cs
  8. 18
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs
  9. 92
      backend/src/Squidex.Domain.Apps.Entities/Assets/BackupAssets.cs
  10. 75
      backend/src/Squidex.Domain.Apps.Entities/Assets/DefaultAssetFileStore.cs
  11. 31
      backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetFileStore.cs
  12. 25
      backend/src/Squidex.Domain.Apps.Entities/Backup/BackupContext.cs
  13. 33
      backend/src/Squidex.Domain.Apps.Entities/Backup/BackupContextBase.cs
  14. 133
      backend/src/Squidex.Domain.Apps.Entities/Backup/BackupGrain.cs
  15. 54
      backend/src/Squidex.Domain.Apps.Entities/Backup/BackupHandlerWithStore.cs
  16. 2
      backend/src/Squidex.Domain.Apps.Entities/Backup/BackupReader.cs
  17. 76
      backend/src/Squidex.Domain.Apps.Entities/Backup/BackupService.cs
  18. 2
      backend/src/Squidex.Domain.Apps.Entities/Backup/BackupWriter.cs
  19. 54
      backend/src/Squidex.Domain.Apps.Entities/Backup/DefaultBackupArchiveStore.cs
  20. 5
      backend/src/Squidex.Domain.Apps.Entities/Backup/GuidMapper.cs
  21. 87
      backend/src/Squidex.Domain.Apps.Entities/Backup/Helpers/Downloader.cs
  22. 62
      backend/src/Squidex.Domain.Apps.Entities/Backup/Helpers/Safe.cs
  23. 7
      backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupArchiveLocation.cs
  24. 23
      backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupArchiveStore.cs
  25. 3
      backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupGrain.cs
  26. 19
      backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupHandler.cs
  27. 30
      backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupReader.cs
  28. 29
      backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupService.cs
  29. 27
      backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupWriter.cs
  30. 2
      backend/src/Squidex.Domain.Apps.Entities/Backup/IRestoreGrain.cs
  31. 24
      backend/src/Squidex.Domain.Apps.Entities/Backup/IUserMapping.cs
  32. 25
      backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreContext.cs
  33. 140
      backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreGrain.cs
  34. 2
      backend/src/Squidex.Domain.Apps.Entities/Backup/State/BackupJob.cs
  35. 2
      backend/src/Squidex.Domain.Apps.Entities/Backup/State/BackupState.cs
  36. 4
      backend/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreJob.cs
  37. 4
      backend/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreState2.cs
  38. 87
      backend/src/Squidex.Domain.Apps.Entities/Backup/TempFolderBackupArchiveLocation.cs
  39. 127
      backend/src/Squidex.Domain.Apps.Entities/Backup/UserMapping.cs
  40. 30
      backend/src/Squidex.Domain.Apps.Entities/Contents/BackupContents.cs
  41. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLModel.cs
  42. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphQLUrlGenerator.cs
  43. 4
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/AssetIndexStorage.cs
  44. 10
      backend/src/Squidex.Domain.Apps.Entities/Rules/BackupRules.cs
  45. 10
      backend/src/Squidex.Domain.Apps.Entities/Schemas/BackupSchemas.cs
  46. 24
      backend/src/Squidex.Domain.Users/DefaultUserPictureStore.cs
  47. 2
      backend/src/Squidex.Domain.Users/DefaultUserResolver.cs
  48. 5
      backend/src/Squidex.Domain.Users/IUserPictureStore.cs
  49. 2
      backend/src/Squidex.Domain.Users/UserWithClaims.cs
  50. 74
      backend/src/Squidex.Infrastructure/Assets/AssetStoreExtensions.cs
  51. 2
      backend/src/Squidex.Infrastructure/Assets/FTPAssetStore.cs
  52. 114
      backend/src/Squidex.Infrastructure/Commands/Rebuilder.cs
  53. 2
      backend/src/Squidex.Infrastructure/Email/SmtpEmailSender.cs
  54. 4
      backend/src/Squidex.Infrastructure/Log/Internal/AnsiLogConsole.cs
  55. 2
      backend/src/Squidex.Infrastructure/Log/Internal/ConsoleLogProcessor.cs
  56. 4
      backend/src/Squidex.Infrastructure/Log/Internal/FileLogProcessor.cs
  57. 4
      backend/src/Squidex.Infrastructure/Log/Internal/WindowsLogConsole.cs
  58. 2
      backend/src/Squidex.Infrastructure/Plugins/PluginLoaders.cs
  59. 2
      backend/src/Squidex.Infrastructure/Translations/DeepLTranslator.cs
  60. 2
      backend/src/Squidex.Shared/Users/IUserResolver.cs
  61. 11
      backend/src/Squidex.Web/Services/UrlGenerator.cs
  62. 12
      backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs
  63. 23
      backend/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs
  64. 21
      backend/src/Squidex/Areas/Api/Controllers/Backups/BackupContentController.cs
  65. 22
      backend/src/Squidex/Areas/Api/Controllers/Backups/BackupsController.cs
  66. 20
      backend/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs
  67. 12
      backend/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs
  68. 2
      backend/src/Squidex/Config/Authentication/IdentityServices.cs
  69. 3
      backend/src/Squidex/Config/Domain/AppsServices.cs
  70. 3
      backend/src/Squidex/Config/Domain/AssetServices.cs
  71. 16
      backend/src/Squidex/Config/Domain/BackupsServices.cs
  72. 4
      backend/src/Squidex/Config/Domain/EventSourcingServices.cs
  73. 3
      backend/src/Squidex/Config/Domain/MigrationServices.cs
  74. 4
      backend/src/Squidex/Config/Domain/QueryServices.cs
  75. 15
      backend/tests/RunCoverage.ps1
  76. 6
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppCommandMiddlewareTests.cs
  77. 369
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/BackupAppsTests.cs
  78. 54
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DefaultAppImageStoreTests.cs
  79. 4
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppContributorsTests.cs
  80. 12
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/InviteUserCommandMiddlewareTests.cs
  81. 25
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCommandMiddlewareTests.cs
  82. 256
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/BackupAssetsTests.cs
  83. 106
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/DefaultAssetFileStoreTests.cs
  84. 16
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Backup/BackupReaderWriterTests.cs
  85. 142
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Backup/BackupServiceTests.cs
  86. 63
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Backup/DefaultBackupArchiveStoreTests.cs
  87. 160
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Backup/UserMappingTests.cs
  88. 105
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/BackupContentsTests.cs
  89. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeUrlGenerator.cs
  90. 82
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/BackupRulesTests.cs
  91. 82
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/BackupSchemasTests.cs
  92. 10
      backend/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/HandlerTestBase.cs
  93. 31
      backend/tests/Squidex.Domain.Users.Tests/DefaultUserPictureStoreTests.cs
  94. 112
      backend/tests/Squidex.Infrastructure.Tests/Assets/AssetExtensionTests.cs
  95. 1
      backend/tools/Migrate_01/Migrations/RebuildApps.cs
  96. 1
      backend/tools/Migrate_01/Migrations/RebuildAssets.cs
  97. 1
      backend/tools/Migrate_01/Migrations/RebuildContents.cs
  98. 1
      backend/tools/Migrate_01/Migrations/RebuildSnapshots.cs
  99. 1
      backend/tools/Migrate_01/RebuildRunner.cs
  100. 125
      backend/tools/Migrate_01/Rebuilder.cs

10
backend/src/Squidex.Domain.Apps.Entities/Apps/AppCommandMiddleware.cs

@ -18,22 +18,22 @@ namespace Squidex.Domain.Apps.Entities.Apps
{
public sealed class AppCommandMiddleware : GrainCommandMiddleware<AppCommand, IAppGrain>
{
private readonly IAssetStore assetStore;
private readonly IAppImageStore appImageStore;
private readonly IAssetThumbnailGenerator assetThumbnailGenerator;
private readonly IContextProvider contextProvider;
public AppCommandMiddleware(
IGrainFactory grainFactory,
IAssetStore assetStore,
IAppImageStore appImageStore,
IAssetThumbnailGenerator assetThumbnailGenerator,
IContextProvider contextProvider)
: base(grainFactory)
{
Guard.NotNull(contextProvider);
Guard.NotNull(assetStore);
Guard.NotNull(appImageStore);
Guard.NotNull(assetThumbnailGenerator);
this.assetStore = assetStore;
this.appImageStore = appImageStore;
this.assetThumbnailGenerator = assetThumbnailGenerator;
this.contextProvider = contextProvider;
}
@ -66,7 +66,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
throw new ValidationException("File is not an image.");
}
await assetStore.UploadAsync(uploadImage.AppId.ToString(), file.OpenRead(), true);
await appImageStore.UploadAsync(uploadImage.AppId, file.OpenRead());
}
}
}

140
backend/src/Squidex.Domain.Apps.Entities/Apps/BackupApps.cs

@ -10,82 +10,78 @@ using System.Collections.Generic;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Entities.Apps.Indexes;
using Squidex.Domain.Apps.Entities.Backup;
using Squidex.Domain.Apps.Events;
using Squidex.Domain.Apps.Events.Apps;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Assets;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Json.Objects;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Apps
{
public sealed class BackupApps : BackupHandler
public sealed class BackupApps : IBackupHandler
{
private const string UsersFile = "Users.json";
private const string SettingsFile = "Settings.json";
private readonly IAppUISettings appUISettings;
private const string AvatarFile = "Avatar.image";
private readonly IAppImageStore appImageStore;
private readonly IAppsIndex appsIndex;
private readonly IUserResolver userResolver;
private readonly IAppUISettings appUISettings;
private readonly HashSet<string> contributors = new HashSet<string>();
private readonly Dictionary<string, RefToken> userMapping = new Dictionary<string, RefToken>();
private Dictionary<string, string> usersWithEmail = new Dictionary<string, string>();
private string? appReservation;
private string appName;
public override string Name { get; } = "Apps";
public string Name { get; } = "Apps";
public BackupApps(IAppUISettings appUISettings, IAppsIndex appsIndex, IUserResolver userResolver)
public BackupApps(IAppImageStore appImageStore, IAppsIndex appsIndex, IAppUISettings appUISettings)
{
Guard.NotNull(appImageStore);
Guard.NotNull(appsIndex);
Guard.NotNull(appUISettings);
Guard.NotNull(userResolver);
this.appsIndex = appsIndex;
this.appImageStore = appImageStore;
this.appUISettings = appUISettings;
this.userResolver = userResolver;
}
public override async Task BackupEventAsync(Envelope<IEvent> @event, Guid appId, BackupWriter writer)
{
if (@event.Payload is AppContributorAssigned appContributorAssigned)
{
var userId = appContributorAssigned.ContributorId;
if (!usersWithEmail.ContainsKey(userId))
public async Task BackupEventAsync(Envelope<IEvent> @event, BackupContext context)
{
var user = await userResolver.FindByIdOrEmailAsync(userId);
if (user != null)
switch (@event.Payload)
{
usersWithEmail.Add(userId, user.Email);
}
}
case AppContributorAssigned appContributorAssigned:
context.UserMapping.Backup(appContributorAssigned.ContributorId);
break;
case AppImageUploaded _:
await WriteAssetAsync(context.AppId, context.Writer);
break;
}
}
public override async Task BackupAsync(Guid appId, BackupWriter writer)
public async Task BackupAsync(BackupContext context)
{
await WriteUsersAsync(writer);
await WriteSettingsAsync(writer, appId);
var json = await appUISettings.GetAsync(context.AppId, null);
await context.Writer.WriteJsonAsync(SettingsFile, json);
}
public override async Task<bool> RestoreEventAsync(Envelope<IEvent> @event, Guid appId, BackupReader reader, RefToken actor)
public async Task<bool> RestoreEventAsync(Envelope<IEvent> @event, RestoreContext context)
{
switch (@event.Payload)
{
case AppCreated appCreated:
{
appName = appCreated.Name;
await ReserveAppAsync(context.AppId, appCreated.Name);
break;
}
await ResolveUsersAsync(reader);
await ReserveAppAsync(appId);
case AppImageUploaded _:
{
await ReadAssetAsync(context.AppId, context.Reader);
break;
}
case AppContributorAssigned contributorAssigned:
{
if (!userMapping.TryGetValue(contributorAssigned.ContributorId, out var user) || user.Equals(actor))
if (!context.UserMapping.TryMap(contributorAssigned.ContributorId, out var user) || user.Equals(context.Initiator))
{
return false;
}
@ -97,7 +93,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
case AppContributorRemoved contributorRemoved:
{
if (!userMapping.TryGetValue(contributorRemoved.ContributorId, out var user) || user.Equals(actor))
if (!context.UserMapping.TryMap(contributorRemoved.ContributorId, out var user) || user.Equals(context.Initiator))
{
return false;
}
@ -108,20 +104,17 @@ namespace Squidex.Domain.Apps.Entities.Apps
}
}
if (@event.Payload is SquidexEvent squidexEvent)
{
squidexEvent.Actor = MapUser(squidexEvent.Actor.Identifier, actor);
}
return true;
}
public override Task RestoreAsync(Guid appId, BackupReader reader)
public async Task RestoreAsync(RestoreContext context)
{
return ReadSettingsAsync(reader, appId);
var json = await context.Reader.ReadJsonAttachmentAsync<JsonObject>(SettingsFile);
await appUISettings.SetAsync(context.AppId, null, json);
}
private async Task ReserveAppAsync(Guid appId)
private async Task ReserveAppAsync(Guid appId, string appName)
{
appReservation = await appsIndex.ReserveAsync(appId, appName);
@ -131,7 +124,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
}
}
public override async Task CleanupRestoreErrorAsync(Guid appId)
public async Task CleanupRestoreErrorAsync(Guid appId)
{
if (appReservation != null)
{
@ -139,66 +132,39 @@ namespace Squidex.Domain.Apps.Entities.Apps
}
}
private RefToken MapUser(string userId, RefToken fallback)
public async Task CompleteRestoreAsync(RestoreContext context)
{
return userMapping.GetOrAdd(userId, fallback);
await appsIndex.AddAsync(appReservation);
await appsIndex.RebuildByContributorsAsync(context.AppId, contributors);
}
private async Task ResolveUsersAsync(BackupReader reader)
private Task WriteAssetAsync(Guid appId, IBackupWriter writer)
{
await ReadUsersAsync(reader);
foreach (var kvp in usersWithEmail)
return writer.WriteBlobAsync(AvatarFile, async stream =>
{
var email = kvp.Value;
var user = await userResolver.FindByIdOrEmailAsync(email);
if (user == null && await userResolver.CreateUserIfNotExists(kvp.Value))
try
{
user = await userResolver.FindByIdOrEmailAsync(email);
await appImageStore.DownloadAsync(appId, stream);
}
if (user != null)
catch (AssetNotFoundException)
{
userMapping[kvp.Key] = new RefToken(RefTokenType.Subject, user.Id);
}
}
});
}
private async Task ReadUsersAsync(BackupReader reader)
private async Task ReadAssetAsync(Guid appId, IBackupReader reader)
{
var json = await reader.ReadJsonAttachmentAsync<Dictionary<string, string>>(UsersFile);
usersWithEmail = json;
}
private async Task WriteUsersAsync(BackupWriter writer)
await reader.ReadBlobAsync(AvatarFile, async stream =>
{
var json = usersWithEmail;
await writer.WriteJsonAsync(UsersFile, json);
}
private async Task WriteSettingsAsync(BackupWriter writer, Guid appId)
try
{
var json = await appUISettings.GetAsync(appId, null);
await writer.WriteJsonAsync(SettingsFile, json);
await appImageStore.UploadAsync(appId, stream);
}
private async Task ReadSettingsAsync(BackupReader reader, Guid appId)
catch (AssetAlreadyExistsException)
{
var json = await reader.ReadJsonAttachmentAsync<JsonObject>(SettingsFile);
await appUISettings.SetAsync(appId, null, json);
}
public override async Task CompleteRestoreAsync(Guid appId, BackupReader reader)
{
await appsIndex.AddAsync(appReservation);
await appsIndex.RebuildByContributorsAsync(appId, contributors);
});
}
}
}

2
backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AssignContributor.cs

@ -15,7 +15,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Commands
public string Role { get; set; } = Roles.Editor;
public bool IsRestore { get; set; }
public bool Restoring { get; set; }
public bool Invite { get; set; }
}

47
backend/src/Squidex.Domain.Apps.Entities/Apps/DefaultAppImageStore.cs

@ -0,0 +1,47 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Assets;
namespace Squidex.Domain.Apps.Entities.Apps
{
public sealed class DefaultAppImageStore : IAppImageStore
{
private readonly IAssetStore assetStore;
public DefaultAppImageStore(IAssetStore assetStore)
{
Guard.NotNull(assetStore, nameof(assetStore));
this.assetStore = assetStore;
}
public Task DownloadAsync(Guid backupId, Stream stream, CancellationToken ct = default)
{
var fileName = GetFileName(backupId);
return assetStore.DownloadAsync(fileName, stream, ct);
}
public Task UploadAsync(Guid backupId, Stream stream, CancellationToken ct = default)
{
var fileName = GetFileName(backupId);
return assetStore.UploadAsync(fileName, stream, true, ct);
}
private string GetFileName(Guid backupId)
{
return backupId.ToString();
}
}
}

2
backend/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppContributors.cs

@ -45,7 +45,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards
command.ContributorId = user.Id;
if (!command.IsRestore)
if (!command.Restoring)
{
if (string.Equals(command.ContributorId, command.Actor?.Identifier, StringComparison.OrdinalIgnoreCase))
{

21
backend/src/Squidex.Domain.Apps.Entities/Apps/IAppImageStore.cs

@ -0,0 +1,21 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace Squidex.Domain.Apps.Entities.Apps
{
public interface IAppImageStore
{
Task UploadAsync(Guid appId, Stream stream, CancellationToken ct = default);
Task DownloadAsync(Guid appId, Stream stream, CancellationToken ct = default);
}
}

2
backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InviteUserCommandMiddleware.cs

@ -29,7 +29,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation
{
if (context.Command is AssignContributor assignContributor && ShouldInvite(assignContributor))
{
var created = await userResolver.CreateUserIfNotExists(assignContributor.ContributorId, true);
var created = await userResolver.CreateUserIfNotExistsAsync(assignContributor.ContributorId, true);
await next();

18
backend/src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs

@ -20,7 +20,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
{
public sealed class AssetCommandMiddleware : GrainCommandMiddleware<AssetCommand, IAssetGrain>
{
private readonly IAssetStore assetStore;
private readonly IAssetFileStore assetFileStore;
private readonly IAssetEnricher assetEnricher;
private readonly IAssetQueryService assetQuery;
private readonly IAssetThumbnailGenerator assetThumbnailGenerator;
@ -31,20 +31,20 @@ namespace Squidex.Domain.Apps.Entities.Assets
IGrainFactory grainFactory,
IAssetEnricher assetEnricher,
IAssetQueryService assetQuery,
IAssetStore assetStore,
IAssetFileStore assetFileStore,
IAssetThumbnailGenerator assetThumbnailGenerator,
IContextProvider contextProvider,
IEnumerable<ITagGenerator<CreateAsset>> tagGenerators)
: base(grainFactory)
{
Guard.NotNull(assetEnricher);
Guard.NotNull(assetStore);
Guard.NotNull(assetFileStore);
Guard.NotNull(assetQuery);
Guard.NotNull(assetThumbnailGenerator);
Guard.NotNull(contextProvider);
Guard.NotNull(tagGenerators);
this.assetStore = assetStore;
this.assetFileStore = assetFileStore;
this.assetEnricher = assetEnricher;
this.assetQuery = assetQuery;
this.assetThumbnailGenerator = assetThumbnailGenerator;
@ -90,11 +90,11 @@ namespace Squidex.Domain.Apps.Entities.Assets
context.Complete(new AssetCreatedResult(asset, false));
await assetStore.CopyAsync(tempFile, createAsset.AssetId.ToString(), asset.FileVersion, null);
await assetFileStore.CopyAsync(tempFile, createAsset.AssetId, asset.FileVersion);
}
finally
{
await assetStore.DeleteAsync(tempFile);
await assetFileStore.DeleteAsync(tempFile);
}
break;
@ -111,11 +111,11 @@ namespace Squidex.Domain.Apps.Entities.Assets
var asset = context.Result<IEnrichedAssetEntity>();
await assetStore.CopyAsync(tempFile, updateAsset.AssetId.ToString(), asset.FileVersion, null);
await assetFileStore.CopyAsync(tempFile, updateAsset.AssetId, asset.FileVersion);
}
finally
{
await assetStore.DeleteAsync(tempFile);
await assetFileStore.DeleteAsync(tempFile);
}
break;
@ -153,7 +153,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
{
using (var hashStream = new HasherStream(command.File.OpenRead(), HashAlgorithmName.SHA256))
{
await assetStore.UploadAsync(tempFile, hashStream);
await assetFileStore.UploadAsync(tempFile, hashStream);
command.FileHash = $"{hashStream.GetHashStringAndReset()}{command.File.FileName}{command.File.FileSize}".Sha256Base64();
}

92
backend/src/Squidex.Domain.Apps.Entities/Assets/BackupAssets.cs

@ -13,53 +13,53 @@ using Squidex.Domain.Apps.Entities.Assets.State;
using Squidex.Domain.Apps.Entities.Backup;
using Squidex.Domain.Apps.Events.Assets;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Assets;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.States;
using Squidex.Infrastructure.Tasks;
namespace Squidex.Domain.Apps.Entities.Assets
{
public sealed class BackupAssets : BackupHandlerWithStore
public sealed class BackupAssets : IBackupHandler
{
private const string TagsFile = "AssetTags.json";
private readonly HashSet<Guid> assetIds = new HashSet<Guid>();
private readonly HashSet<Guid> assetFolderIds = new HashSet<Guid>();
private readonly IAssetStore assetStore;
private readonly Rebuilder rebuilder;
private readonly IAssetFileStore assetFileStore;
private readonly ITagService tagService;
public override string Name { get; } = "Assets";
public string Name { get; } = "Assets";
public BackupAssets(IStore<Guid> store, IAssetStore assetStore, ITagService tagService)
: base(store)
public BackupAssets(Rebuilder rebuilder, IAssetFileStore assetFileStore, ITagService tagService)
{
Guard.NotNull(assetStore);
Guard.NotNull(rebuilder);
Guard.NotNull(assetFileStore);
Guard.NotNull(tagService);
this.assetStore = assetStore;
this.rebuilder = rebuilder;
this.assetFileStore = assetFileStore;
this.tagService = tagService;
}
public override Task BackupAsync(Guid appId, BackupWriter writer)
public Task BackupAsync(BackupContext context)
{
return BackupTagsAsync(appId, writer);
return BackupTagsAsync(context);
}
public override Task BackupEventAsync(Envelope<IEvent> @event, Guid appId, BackupWriter writer)
public Task BackupEventAsync(Envelope<IEvent> @event, BackupContext context)
{
switch (@event.Payload)
{
case AssetCreated assetCreated:
return WriteAssetAsync(assetCreated.AssetId, assetCreated.FileVersion, writer);
return WriteAssetAsync(assetCreated.AssetId, assetCreated.FileVersion, context.Writer);
case AssetUpdated assetUpdated:
return WriteAssetAsync(assetUpdated.AssetId, assetUpdated.FileVersion, writer);
return WriteAssetAsync(assetUpdated.AssetId, assetUpdated.FileVersion, context.Writer);
}
return TaskHelper.Done;
}
public override async Task<bool> RestoreEventAsync(Envelope<IEvent> @event, Guid appId, BackupReader reader, RefToken actor)
public async Task<bool> RestoreEventAsync(Envelope<IEvent> @event, RestoreContext context)
{
switch (@event.Payload)
{
@ -67,60 +67,72 @@ namespace Squidex.Domain.Apps.Entities.Assets
assetFolderIds.Add(assetFolderCreated.AssetFolderId);
break;
case AssetCreated assetCreated:
await ReadAssetAsync(assetCreated.AssetId, assetCreated.FileVersion, reader);
await ReadAssetAsync(assetCreated.AssetId, assetCreated.FileVersion, context.Reader);
break;
case AssetUpdated assetUpdated:
await ReadAssetAsync(assetUpdated.AssetId, assetUpdated.FileVersion, reader);
await ReadAssetAsync(assetUpdated.AssetId, assetUpdated.FileVersion, context.Reader);
break;
}
return true;
}
public override async Task RestoreAsync(Guid appId, BackupReader reader)
public async Task RestoreAsync(RestoreContext context)
{
await RestoreTagsAsync(appId, reader);
await RestoreTagsAsync(context);
await RebuildManyAsync(assetIds, RebuildAsync<AssetState, AssetGrain>);
await RebuildManyAsync(assetFolderIds, RebuildAsync<AssetFolderState, AssetFolderGrain>);
if (assetIds.Count > 0)
{
await rebuilder.InsertManyAsync<AssetState, AssetGrain>(async target =>
{
foreach (var id in assetIds)
{
await target(id);
}
});
}
if (assetFolderIds.Count > 0)
{
await rebuilder.InsertManyAsync<AssetFolderState, AssetFolderGrain>(async target =>
{
foreach (var id in assetFolderIds)
{
await target(id);
}
});
}
}
private async Task RestoreTagsAsync(Guid appId, BackupReader reader)
private async Task RestoreTagsAsync(RestoreContext context)
{
var tags = await reader.ReadJsonAttachmentAsync<TagsExport>(TagsFile);
var tags = await context.Reader.ReadJsonAttachmentAsync<TagsExport>(TagsFile);
await tagService.RebuildTagsAsync(appId, TagGroups.Assets, tags);
await tagService.RebuildTagsAsync(context.AppId, TagGroups.Assets, tags);
}
private async Task BackupTagsAsync(Guid appId, BackupWriter writer)
private async Task BackupTagsAsync(BackupContext context)
{
var tags = await tagService.GetExportableTagsAsync(appId, TagGroups.Assets);
var tags = await tagService.GetExportableTagsAsync(context.AppId, TagGroups.Assets);
await writer.WriteJsonAsync(TagsFile, tags);
await context.Writer.WriteJsonAsync(TagsFile, tags);
}
private Task WriteAssetAsync(Guid assetId, long fileVersion, BackupWriter writer)
private Task WriteAssetAsync(Guid assetId, long fileVersion, IBackupWriter writer)
{
return writer.WriteBlobAsync(GetName(assetId, fileVersion), stream =>
{
return assetStore.DownloadAsync(assetId.ToString(), fileVersion, null, stream);
return assetFileStore.DownloadAsync(assetId, fileVersion, stream);
});
}
private Task ReadAssetAsync(Guid assetId, long fileVersion, BackupReader reader)
private Task ReadAssetAsync(Guid assetId, long fileVersion, IBackupReader reader)
{
assetIds.Add(assetId);
return reader.ReadBlobAsync(GetName(reader.OldGuid(assetId), fileVersion), async stream =>
return reader.ReadBlobAsync(GetName(reader.OldGuid(assetId), fileVersion), stream =>
{
try
{
await assetStore.UploadAsync(assetId.ToString(), fileVersion, null, stream, true);
}
catch (AssetAlreadyExistsException)
{
return;
}
return assetFileStore.UploadAsync(assetId, fileVersion, stream);
});
}

75
backend/src/Squidex.Domain.Apps.Entities/Assets/DefaultAssetFileStore.cs

@ -0,0 +1,75 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Squidex.Infrastructure.Assets;
namespace Squidex.Domain.Apps.Entities.Assets
{
public sealed class DefaultAssetFileStore : IAssetFileStore
{
private readonly IAssetStore assetStore;
public DefaultAssetFileStore(IAssetStore assetStore)
{
this.assetStore = assetStore;
}
public string? GeneratePublicUrl(Guid id, long fileVersion)
{
var fileName = GetFileName(id, fileVersion);
return assetStore.GeneratePublicUrl(fileName);
}
public Task UploadAsync(Guid id, long fileVersion, Stream stream, CancellationToken ct = default)
{
var fileName = GetFileName(id, fileVersion);
return assetStore.UploadAsync(fileName, stream, true, ct);
}
public Task UploadAsync(string tempFile, Stream stream, CancellationToken ct = default)
{
return assetStore.UploadAsync(tempFile, stream, false, ct);
}
public Task CopyAsync(string tempFile, Guid id, long fileVersion, CancellationToken ct = default)
{
var fileName = GetFileName(id, fileVersion);
return assetStore.CopyAsync(tempFile, fileName, ct);
}
public Task DownloadAsync(Guid id, long fileVersion, Stream stream, CancellationToken ct = default)
{
var fileName = GetFileName(id, fileVersion);
return assetStore.DownloadAsync(fileName, stream, ct);
}
public Task DeleteAsync(Guid id, long fileVersion)
{
var fileName = GetFileName(id, fileVersion);
return assetStore.DeleteAsync(fileName);
}
public Task DeleteAsync(string tempFile)
{
return assetStore.DeleteAsync(tempFile);
}
private static string GetFileName(Guid id, long fileVersion)
{
return $"{id}_{fileVersion}";
}
}
}

31
backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetFileStore.cs

@ -0,0 +1,31 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace Squidex.Domain.Apps.Entities.Assets
{
public interface IAssetFileStore
{
string? GeneratePublicUrl(Guid id, long fileVersion);
Task CopyAsync(string tempFile, Guid id, long fileVersion, CancellationToken ct = default);
Task UploadAsync(string tempFile, Stream stream, CancellationToken ct = default);
Task UploadAsync(Guid id, long fileVersion, Stream stream, CancellationToken ct = default);
Task DownloadAsync(Guid id, long fileVersion, Stream stream, CancellationToken ct = default);
Task DeleteAsync(string tempFile);
Task DeleteAsync(Guid id, long fileVersion);
}
}

25
backend/src/Squidex.Domain.Apps.Entities/Backup/BackupContext.cs

@ -0,0 +1,25 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Backup
{
public sealed class BackupContext : BackupContextBase
{
public IBackupWriter Writer { get; }
public BackupContext(Guid appId, IUserMapping userMapping, IBackupWriter writer)
: base(appId, userMapping)
{
Guard.NotNull(writer);
Writer = writer;
}
}
}

33
backend/src/Squidex.Domain.Apps.Entities/Backup/BackupContextBase.cs

@ -0,0 +1,33 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Backup
{
public abstract class BackupContextBase
{
public IUserMapping UserMapping { get; }
public Guid AppId { get; set; }
public RefToken Initiator
{
get { return UserMapping.Initiator; }
}
protected BackupContextBase(Guid appId, IUserMapping userMapping)
{
Guard.NotNull(userMapping);
AppId = appId;
UserMapping = userMapping;
}
}
}

133
backend/src/Squidex.Domain.Apps.Entities/Backup/BackupGrain.cs

@ -13,16 +13,14 @@ using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using NodaTime;
using Orleans.Concurrency;
using Squidex.Domain.Apps.Entities.Backup.Helpers;
using Squidex.Domain.Apps.Entities.Backup.State;
using Squidex.Domain.Apps.Events;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Assets;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Json;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Orleans;
using Squidex.Infrastructure.Tasks;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Backup
{
@ -31,47 +29,48 @@ namespace Squidex.Domain.Apps.Entities.Backup
{
private const int MaxBackups = 10;
private static readonly Duration UpdateDuration = Duration.FromSeconds(1);
private readonly IAssetStore assetStore;
private readonly IBackupArchiveLocation backupArchiveLocation;
private readonly IBackupArchiveStore backupArchiveStore;
private readonly IClock clock;
private readonly IJsonSerializer serializer;
private readonly IServiceProvider serviceProvider;
private readonly IEventDataFormatter eventDataFormatter;
private readonly IEventStore eventStore;
private readonly ISemanticLog log;
private readonly IGrainState<BackupState> state;
private CancellationTokenSource? currentTask;
private BackupStateJob? currentJob;
private readonly IUserResolver userResolver;
private CancellationTokenSource? currentTaskToken;
private BackupJob? currentJob;
public BackupGrain(
IAssetStore assetStore,
IBackupArchiveLocation backupArchiveLocation,
IBackupArchiveStore backupArchiveStore,
IClock clock,
IEventStore eventStore,
IEventDataFormatter eventDataFormatter,
IJsonSerializer serializer,
IEventStore eventStore,
IGrainState<BackupState> state,
IServiceProvider serviceProvider,
ISemanticLog log,
IGrainState<BackupState> state)
IUserResolver userResolver,
ISemanticLog log)
{
Guard.NotNull(assetStore);
Guard.NotNull(backupArchiveLocation);
Guard.NotNull(backupArchiveStore);
Guard.NotNull(clock);
Guard.NotNull(eventStore);
Guard.NotNull(eventDataFormatter);
Guard.NotNull(eventStore);
Guard.NotNull(serviceProvider);
Guard.NotNull(serializer);
Guard.NotNull(state);
Guard.NotNull(userResolver);
Guard.NotNull(log);
this.assetStore = assetStore;
this.backupArchiveLocation = backupArchiveLocation;
this.backupArchiveStore = backupArchiveStore;
this.clock = clock;
this.eventStore = eventStore;
this.eventDataFormatter = eventDataFormatter;
this.serializer = serializer;
this.eventStore = eventStore;
this.serviceProvider = serviceProvider;
this.state = state;
this.userResolver = userResolver;
this.log = log;
}
@ -84,27 +83,14 @@ namespace Squidex.Domain.Apps.Entities.Backup
private async Task RecoverAfterRestartAsync()
{
foreach (var job in state.Value.Jobs)
{
if (!job.Stopped.HasValue)
{
var jobId = job.Id.ToString();
job.Stopped = clock.GetCurrentInstant();
await Safe.DeleteAsync(backupArchiveLocation, jobId, log);
await Safe.DeleteAsync(assetStore, jobId, log);
job.Status = JobStatus.Failed;
state.Value.Jobs.RemoveAll(x => !x.Stopped.HasValue);
await state.WriteAsync();
}
}
}
public async Task RunAsync()
public async Task BackupAsync(RefToken actor)
{
if (currentTask != null)
if (currentTaskToken != null)
{
throw new DomainException("Another backup process is already running.");
}
@ -114,53 +100,60 @@ namespace Squidex.Domain.Apps.Entities.Backup
throw new DomainException($"You cannot have more than {MaxBackups} backups.");
}
var job = new BackupStateJob
var job = new BackupJob
{
Id = Guid.NewGuid(),
Started = clock.GetCurrentInstant(),
Status = JobStatus.Started
};
currentTask = new CancellationTokenSource();
currentTaskToken = new CancellationTokenSource();
currentJob = job;
state.Value.Jobs.Insert(0, job);
await state.WriteAsync();
Process(job, currentTask.Token);
Process(job, actor, currentTaskToken.Token);
}
private void Process(BackupStateJob job, CancellationToken ct)
private void Process(BackupJob job, RefToken actor, CancellationToken ct)
{
ProcessAsync(job, ct).Forget();
ProcessAsync(job, actor, ct).Forget();
}
private async Task ProcessAsync(BackupStateJob job, CancellationToken ct)
private async Task ProcessAsync(BackupJob job, RefToken actor, CancellationToken ct)
{
var jobId = job.Id.ToString();
var handlers = CreateHandlers();
var lastTimestamp = job.Started;
try
{
using (var stream = await backupArchiveLocation.OpenStreamAsync(jobId))
using (var stream = backupArchiveLocation.OpenStream(job.Id))
{
using (var writer = new BackupWriter(serializer, stream, true))
using (var writer = await backupArchiveLocation.OpenWriterAsync(stream))
{
var userMapping = new UserMapping(actor);
var context = new BackupContext(Key, userMapping, writer);
await eventStore.QueryAsync(async storedEvent =>
{
var @event = eventDataFormatter.Parse(storedEvent.Data);
writer.WriteEvent(storedEvent);
if (@event.Payload is SquidexEvent squidexEvent)
{
context.UserMapping.Backup(squidexEvent.Actor);
}
foreach (var handler in handlers)
{
await handler.BackupEventAsync(@event, Key, writer);
await handler.BackupEventAsync(@event, context);
}
writer.WriteEvent(storedEvent);
job.HandledEvents = writer.WrittenEvents;
job.HandledAssets = writer.WrittenAttachments;
@ -169,27 +162,37 @@ namespace Squidex.Domain.Apps.Entities.Backup
foreach (var handler in handlers)
{
await handler.BackupAsync(Key, writer);
ct.ThrowIfCancellationRequested();
await handler.BackupAsync(context);
}
foreach (var handler in handlers)
{
await handler.CompleteBackupAsync(Key, writer);
ct.ThrowIfCancellationRequested();
await handler.CompleteBackupAsync(context);
}
await userMapping.StoreAsync(writer, userResolver);
}
stream.Position = 0;
ct.ThrowIfCancellationRequested();
await assetStore.UploadAsync(jobId, 0, null, stream, false, ct);
await backupArchiveStore.UploadAsync(job.Id, stream, ct);
}
job.Status = JobStatus.Completed;
}
catch (OperationCanceledException)
{
await RemoveAsync(job);
}
catch (Exception ex)
{
log.LogError(ex, jobId, (ctx, w) => w
log.LogError(ex, job.Id.ToString(), (ctx, w) => w
.WriteProperty("action", "makeBackup")
.WriteProperty("status", "failed")
.WriteProperty("backupId", ctx));
@ -198,13 +201,11 @@ namespace Squidex.Domain.Apps.Entities.Backup
}
finally
{
await Safe.DeleteAsync(backupArchiveLocation, jobId, log);
job.Stopped = clock.GetCurrentInstant();
await state.WriteAsync();
currentTask = null;
currentTaskToken = null;
currentJob = null;
}
}
@ -234,24 +235,36 @@ namespace Squidex.Domain.Apps.Entities.Backup
if (currentJob == job)
{
currentTask?.Cancel();
currentTaskToken?.Cancel();
}
else
{
var jobId = job.Id.ToString();
await RemoveAsync(job);
}
}
await Safe.DeleteAsync(backupArchiveLocation, jobId, log);
await Safe.DeleteAsync(assetStore, jobId, log);
private async Task RemoveAsync(BackupJob job)
{
try
{
await backupArchiveStore.DeleteAsync(job.Id);
}
catch (Exception ex)
{
log.LogError(ex, job.Id.ToString(), (logOperationId, w) => w
.WriteProperty("action", "deleteBackup")
.WriteProperty("status", "failed")
.WriteProperty("operationId", logOperationId));
}
state.Value.Jobs.Remove(job);
await state.WriteAsync();
}
}
private IEnumerable<BackupHandler> CreateHandlers()
private IEnumerable<IBackupHandler> CreateHandlers()
{
return serviceProvider.GetRequiredService<IEnumerable<BackupHandler>>();
return serviceProvider.GetRequiredService<IEnumerable<IBackupHandler>>();
}
public Task<J<List<IBackupJob>>> GetStateAsync()

54
backend/src/Squidex.Domain.Apps.Entities/Backup/BackupHandlerWithStore.cs

@ -1,54 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.Backup
{
public abstract class BackupHandlerWithStore : BackupHandler
{
private readonly IStore<Guid> store;
protected BackupHandlerWithStore(IStore<Guid> store)
{
Guard.NotNull(store);
this.store = store;
}
protected async Task RebuildManyAsync(IEnumerable<Guid> ids, Func<Guid, Task> action)
{
foreach (var id in ids)
{
await action(id);
}
}
protected async Task RebuildAsync<TState, TGrain>(Guid key) where TState : IDomainState<TState>, new()
{
var state = new TState
{
Version = EtagVersion.Empty
};
var persistence = store.WithSnapshotsAndEventSourcing(typeof(TGrain), key, (TState s) => state = s, e =>
{
state = state.Apply(e);
state.Version++;
});
await persistence.ReadAsync();
await persistence.WriteSnapshotAsync(state);
}
}
}

2
backend/src/Squidex.Domain.Apps.Entities/Backup/BackupReader.cs

@ -22,7 +22,7 @@ using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.Backup
{
public sealed class BackupReader : DisposableObjectBase
public class BackupReader : DisposableObjectBase, IBackupReader
{
private readonly GuidMapper guidMapper = new GuidMapper();
private readonly ZipArchive archive;

76
backend/src/Squidex.Domain.Apps.Entities/Backup/BackupService.cs

@ -0,0 +1,76 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Orleans;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Orleans;
namespace Squidex.Domain.Apps.Entities.Backup
{
public sealed class BackupService : IBackupService
{
private readonly IGrainFactory grainFactory;
public BackupService(IGrainFactory grainFactory)
{
Guard.NotNull(grainFactory);
this.grainFactory = grainFactory;
}
public Task StartBackupAsync(Guid appId, RefToken actor)
{
var grain = grainFactory.GetGrain<IBackupGrain>(appId);
return grain.BackupAsync(actor);
}
public Task StartRestoreAsync(RefToken actor, Uri url, string? newAppName)
{
var grain = grainFactory.GetGrain<IRestoreGrain>(SingleGrain.Id);
return grain.RestoreAsync(url, actor, newAppName);
}
public async Task<IRestoreJob?> GetRestoreAsync()
{
var grain = grainFactory.GetGrain<IRestoreGrain>(SingleGrain.Id);
var state = await grain.GetStateAsync();
return state.Value;
}
public async Task<List<IBackupJob>> GetBackupsAsync(Guid appId)
{
var grain = grainFactory.GetGrain<IBackupGrain>(appId);
var state = await grain.GetStateAsync();
return state.Value;
}
public async Task<IBackupJob?> GetBackupAsync(Guid appId, Guid backupId)
{
var grain = grainFactory.GetGrain<IBackupGrain>(appId);
var state = await grain.GetStateAsync();
return state.Value.Find(x => x.Id == backupId);
}
public Task DeleteBackupAsync(Guid appId, Guid backupId)
{
var grain = grainFactory.GetGrain<IBackupGrain>(appId);
return grain.DeleteAsync(backupId);
}
}
}

2
backend/src/Squidex.Domain.Apps.Entities/Backup/BackupWriter.cs

@ -18,7 +18,7 @@ using Squidex.Infrastructure.Tasks;
namespace Squidex.Domain.Apps.Entities.Backup
{
public sealed class BackupWriter : DisposableObjectBase
public sealed class BackupWriter : DisposableObjectBase, IBackupWriter
{
private readonly ZipArchive archive;
private readonly IJsonSerializer serializer;

54
backend/src/Squidex.Domain.Apps.Entities/Backup/DefaultBackupArchiveStore.cs

@ -0,0 +1,54 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Assets;
namespace Squidex.Domain.Apps.Entities.Backup
{
public sealed class DefaultBackupArchiveStore : IBackupArchiveStore
{
private readonly IAssetStore assetStore;
public DefaultBackupArchiveStore(IAssetStore assetStore)
{
Guard.NotNull(assetStore, nameof(assetStore));
this.assetStore = assetStore;
}
public Task DownloadAsync(Guid backupId, Stream stream, CancellationToken ct = default)
{
var fileName = GetFileName(backupId);
return assetStore.DownloadAsync(fileName, stream, ct);
}
public Task UploadAsync(Guid backupId, Stream stream, CancellationToken ct = default)
{
var fileName = GetFileName(backupId);
return assetStore.UploadAsync(fileName, stream, true, ct);
}
public Task DeleteAsync(Guid backupId)
{
var fileName = GetFileName(backupId);
return assetStore.DeleteAsync(fileName);
}
private string GetFileName(Guid backupId)
{
return $"{backupId}_0";
}
}
}

5
backend/src/Squidex.Domain.Apps.Entities/Backup/GuidMapper.cs

@ -94,6 +94,11 @@ namespace Squidex.Domain.Apps.Entities.Backup
private Guid GenerateNewGuid(Guid oldGuid)
{
if (oldGuid == Guid.Empty)
{
return Guid.Empty;
}
return oldToNewGuid.GetOrAdd(oldGuid, GuidGenerator);
}

87
backend/src/Squidex.Domain.Apps.Entities/Backup/Helpers/Downloader.cs

@ -1,87 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.IO;
using System.Net.Http;
using System.Threading.Tasks;
using Squidex.Infrastructure.Json;
namespace Squidex.Domain.Apps.Entities.Backup.Helpers
{
public static class Downloader
{
public static async Task DownloadAsync(this IBackupArchiveLocation backupArchiveLocation, Uri url, string id)
{
if (string.Equals(url.Scheme, "file"))
{
try
{
using (var targetStream = await backupArchiveLocation.OpenStreamAsync(id))
{
using (var sourceStream = new FileStream(url.LocalPath, FileMode.Open, FileAccess.Read))
{
await sourceStream.CopyToAsync(targetStream);
}
}
}
catch (IOException ex)
{
throw new BackupRestoreException($"Cannot download the archive: {ex.Message}.", ex);
}
}
else
{
HttpResponseMessage? response = null;
try
{
using (var client = new HttpClient())
{
response = await client.GetAsync(url);
response.EnsureSuccessStatusCode();
using (var sourceStream = await response.Content.ReadAsStreamAsync())
{
using (var targetStream = await backupArchiveLocation.OpenStreamAsync(id))
{
await sourceStream.CopyToAsync(targetStream);
}
}
}
}
catch (HttpRequestException ex)
{
throw new BackupRestoreException($"Cannot download the archive. Got status code: {response?.StatusCode}.", ex);
}
}
}
public static async Task<BackupReader> OpenArchiveAsync(this IBackupArchiveLocation backupArchiveLocation, string id, IJsonSerializer serializer)
{
Stream? stream = null;
try
{
stream = await backupArchiveLocation.OpenStreamAsync(id);
return new BackupReader(serializer, stream);
}
catch (IOException)
{
stream?.Dispose();
throw new BackupRestoreException("The backup archive is correupt and cannot be opened.");
}
catch (Exception)
{
stream?.Dispose();
throw;
}
}
}
}

62
backend/src/Squidex.Domain.Apps.Entities/Backup/Helpers/Safe.cs

@ -1,62 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Threading.Tasks;
using Squidex.Infrastructure.Assets;
using Squidex.Infrastructure.Log;
namespace Squidex.Domain.Apps.Entities.Backup.Helpers
{
public static class Safe
{
public static async Task DeleteAsync(IBackupArchiveLocation backupArchiveLocation, string id, ISemanticLog log)
{
try
{
await backupArchiveLocation.DeleteArchiveAsync(id);
}
catch (Exception ex)
{
log.LogError(ex, id, (logOperationId, w) => w
.WriteProperty("action", "deleteArchive")
.WriteProperty("status", "failed")
.WriteProperty("operationId", logOperationId));
}
}
public static async Task DeleteAsync(IAssetStore assetStore, string id, ISemanticLog log)
{
try
{
await assetStore.DeleteAsync(id, 0, null);
}
catch (Exception ex)
{
log.LogError(ex, id, (logOperationId, w) => w
.WriteProperty("action", "deleteBackup")
.WriteProperty("status", "failed")
.WriteProperty("operationId", logOperationId));
}
}
public static async Task CleanupRestoreErrorAsync(BackupHandler handler, Guid appId, Guid id, ISemanticLog log)
{
try
{
await handler.CleanupRestoreErrorAsync(appId);
}
catch (Exception ex)
{
log.LogError(ex, id.ToString(), (logOperationId, w) => w
.WriteProperty("action", "cleanupRestore")
.WriteProperty("status", "failed")
.WriteProperty("operationId", logOperationId));
}
}
}
}

7
backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupArchiveLocation.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.IO;
using System.Threading.Tasks;
@ -12,8 +13,10 @@ namespace Squidex.Domain.Apps.Entities.Backup
{
public interface IBackupArchiveLocation
{
Task<Stream> OpenStreamAsync(string backupId);
Stream OpenStream(Guid backupId);
Task DeleteArchiveAsync(string backupId);
Task<IBackupWriter> OpenWriterAsync(Stream stream);
Task<IBackupReader> OpenReaderAsync(Uri url, Guid id);
}
}

23
backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupArchiveStore.cs

@ -0,0 +1,23 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace Squidex.Domain.Apps.Entities.Backup
{
public interface IBackupArchiveStore
{
Task UploadAsync(Guid backupId, Stream stream, CancellationToken ct = default);
Task DownloadAsync(Guid backupId, Stream stream, CancellationToken ct = default);
Task DeleteAsync(Guid backupId);
}
}

3
backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupGrain.cs

@ -9,13 +9,14 @@ using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Orleans;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Orleans;
namespace Squidex.Domain.Apps.Entities.Backup
{
public interface IBackupGrain : IGrainWithGuidKey
{
Task RunAsync();
Task BackupAsync(RefToken actor);
Task DeleteAsync(Guid id);

19
backend/src/Squidex.Domain.Apps.Entities/Backup/BackupHandler.cs → backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupHandler.cs

@ -7,47 +7,46 @@
using System;
using System.Threading.Tasks;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Tasks;
namespace Squidex.Domain.Apps.Entities.Backup
{
public abstract class BackupHandler
public interface IBackupHandler
{
public abstract string Name { get; }
string Name { get; }
public virtual Task<bool> RestoreEventAsync(Envelope<IEvent> @event, Guid appId, BackupReader reader, RefToken actor)
public Task<bool> RestoreEventAsync(Envelope<IEvent> @event, RestoreContext context)
{
return TaskHelper.True;
}
public virtual Task BackupEventAsync(Envelope<IEvent> @event, Guid appId, BackupWriter writer)
public Task BackupEventAsync(Envelope<IEvent> @event, BackupContext context)
{
return TaskHelper.Done;
}
public virtual Task RestoreAsync(Guid appId, BackupReader reader)
public Task RestoreAsync(RestoreContext context)
{
return TaskHelper.Done;
}
public virtual Task BackupAsync(Guid appId, BackupWriter writer)
public Task BackupAsync(BackupContext context)
{
return TaskHelper.Done;
}
public virtual Task CleanupRestoreErrorAsync(Guid appId)
public Task CleanupRestoreErrorAsync(Guid appId)
{
return TaskHelper.Done;
}
public virtual Task CompleteRestoreAsync(Guid appId, BackupReader reader)
public Task CompleteRestoreAsync(RestoreContext context)
{
return TaskHelper.Done;
}
public virtual Task CompleteBackupAsync(Guid appId, BackupWriter writer)
public Task CompleteBackupAsync(BackupContext context)
{
return TaskHelper.Done;
}

30
backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupReader.cs

@ -0,0 +1,30 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.IO;
using System.Threading.Tasks;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.Backup
{
public interface IBackupReader : IDisposable
{
int ReadAttachments { get; }
int ReadEvents { get; }
Guid OldGuid(Guid newId);
Task ReadBlobAsync(string name, Func<Stream, Task> handler);
Task ReadEventsAsync(IStreamNameResolver streamNameResolver, IEventDataFormatter formatter, Func<(string Stream, Envelope<IEvent> Event), Task> handler);
Task<T> ReadJsonAttachmentAsync<T>(string name);
}
}

29
backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupService.cs

@ -0,0 +1,29 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Backup
{
public interface IBackupService
{
Task StartBackupAsync(Guid appId, RefToken actor);
Task StartRestoreAsync(RefToken actor, Uri url, string? newAppName);
Task<IRestoreJob?> GetRestoreAsync();
Task<List<IBackupJob>> GetBackupsAsync(Guid appId);
Task<IBackupJob?> GetBackupAsync(Guid appId, Guid backupId);
Task DeleteBackupAsync(Guid appId, Guid backupId);
}
}

27
backend/src/Squidex.Domain.Apps.Entities/Backup/IBackupWriter.cs

@ -0,0 +1,27 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.IO;
using System.Threading.Tasks;
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Entities.Backup
{
public interface IBackupWriter : IDisposable
{
int WrittenAttachments { get; }
int WrittenEvents { get; }
Task WriteBlobAsync(string name, Func<Stream, Task> handler);
void WriteEvent(StoredEvent storedEvent);
Task WriteJsonAsync(string name, object value);
}
}

2
backend/src/Squidex.Domain.Apps.Entities/Backup/IRestoreGrain.cs

@ -17,6 +17,6 @@ namespace Squidex.Domain.Apps.Entities.Backup
{
Task RestoreAsync(Uri url, RefToken actor, string? newAppName = null);
Task<J<IRestoreJob>> GetJobAsync();
Task<J<IRestoreJob>> GetStateAsync();
}
}

24
backend/src/Squidex.Domain.Apps.Entities/Backup/IUserMapping.cs

@ -0,0 +1,24 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Backup
{
public interface IUserMapping
{
RefToken Initiator { get; }
void Backup(RefToken token);
void Backup(string userId);
bool TryMap(RefToken token, out RefToken result);
bool TryMap(string userId, out RefToken result);
}
}

25
backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreContext.cs

@ -0,0 +1,25 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Backup
{
public sealed class RestoreContext : BackupContextBase
{
public IBackupReader Reader { get; }
public RestoreContext(Guid appId, IUserMapping userMapping, IBackupReader reader)
: base(appId, userMapping)
{
Guard.NotNull(reader);
Reader = reader;
}
}
}

140
backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreGrain.cs

@ -12,18 +12,17 @@ using Microsoft.Extensions.DependencyInjection;
using NodaTime;
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Entities.Apps.Commands;
using Squidex.Domain.Apps.Entities.Backup.Helpers;
using Squidex.Domain.Apps.Entities.Backup.State;
using Squidex.Domain.Apps.Events;
using Squidex.Domain.Apps.Events.Apps;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Json;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Orleans;
using Squidex.Infrastructure.States;
using Squidex.Infrastructure.Tasks;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Backup
{
@ -32,50 +31,52 @@ namespace Squidex.Domain.Apps.Entities.Backup
private readonly IBackupArchiveLocation backupArchiveLocation;
private readonly IClock clock;
private readonly ICommandBus commandBus;
private readonly IJsonSerializer serializer;
private readonly IEventStore eventStore;
private readonly IEventDataFormatter eventDataFormatter;
private readonly ISemanticLog log;
private readonly IServiceProvider serviceProvider;
private readonly IStreamNameResolver streamNameResolver;
private readonly IGrainState<RestoreState> state;
private readonly IUserResolver userResolver;
private readonly IGrainState<RestoreState2> state;
private RestoreContext restoreContext;
private RestoreStateJob CurrentJob
private RestoreJob CurrentJob
{
get { return state.Value.Job; }
}
public RestoreGrain(IBackupArchiveLocation backupArchiveLocation,
public RestoreGrain(
IBackupArchiveLocation backupArchiveLocation,
IClock clock,
ICommandBus commandBus,
IEventStore eventStore,
IEventDataFormatter eventDataFormatter,
IJsonSerializer serializer,
ISemanticLog log,
IEventStore eventStore,
IGrainState<RestoreState2> state,
IServiceProvider serviceProvider,
IStreamNameResolver streamNameResolver,
IGrainState<RestoreState> state)
IUserResolver userResolver,
ISemanticLog log)
{
Guard.NotNull(backupArchiveLocation);
Guard.NotNull(clock);
Guard.NotNull(commandBus);
Guard.NotNull(eventStore);
Guard.NotNull(eventDataFormatter);
Guard.NotNull(serializer);
Guard.NotNull(eventStore);
Guard.NotNull(serviceProvider);
Guard.NotNull(state);
Guard.NotNull(streamNameResolver);
Guard.NotNull(userResolver);
Guard.NotNull(log);
this.backupArchiveLocation = backupArchiveLocation;
this.clock = clock;
this.commandBus = commandBus;
this.eventStore = eventStore;
this.eventDataFormatter = eventDataFormatter;
this.serializer = serializer;
this.eventStore = eventStore;
this.serviceProvider = serviceProvider;
this.streamNameResolver = streamNameResolver;
this.state = state;
this.streamNameResolver = streamNameResolver;
this.userResolver = userResolver;
this.log = log;
}
@ -90,14 +91,10 @@ namespace Squidex.Domain.Apps.Entities.Backup
{
if (CurrentJob?.Status == JobStatus.Started)
{
var handlers = CreateHandlers();
Log("Failed due application restart");
CurrentJob.Status = JobStatus.Failed;
await CleanupAsync(handlers);
await state.WriteAsync();
}
}
@ -117,7 +114,7 @@ namespace Squidex.Domain.Apps.Entities.Backup
throw new DomainException("A restore operation is already running.");
}
state.Value.Job = new RestoreStateJob
state.Value.Job = new RestoreJob
{
Id = Guid.NewGuid(),
NewAppName = newAppName,
@ -159,12 +156,7 @@ namespace Squidex.Domain.Apps.Entities.Backup
.WriteProperty("operationId", ctx.jobId)
.WriteProperty("url", ctx.jobUrl));
using (Profiler.Trace("Download"))
{
await DownloadAsync();
}
using (var reader = await backupArchiveLocation.OpenArchiveAsync(CurrentJob.Id.ToString(), serializer))
using (var reader = await DownloadAsync())
{
using (Profiler.Trace("ReadEvents"))
{
@ -173,9 +165,9 @@ namespace Squidex.Domain.Apps.Entities.Backup
foreach (var handler in handlers)
{
using (Profiler.TraceMethod(handler.GetType(), nameof(BackupHandler.RestoreAsync)))
using (Profiler.TraceMethod(handler.GetType(), nameof(IBackupHandler.RestoreAsync)))
{
await handler.RestoreAsync(CurrentJob.AppId, reader);
await handler.RestoreAsync(restoreContext);
}
Log($"Restored {handler.Name}");
@ -183,9 +175,9 @@ namespace Squidex.Domain.Apps.Entities.Backup
foreach (var handler in handlers)
{
using (Profiler.TraceMethod(handler.GetType(), nameof(BackupHandler.CompleteRestoreAsync)))
using (Profiler.TraceMethod(handler.GetType(), nameof(IBackupHandler.CompleteRestoreAsync)))
{
await handler.CompleteRestoreAsync(CurrentJob.AppId, reader);
await handler.CompleteRestoreAsync(restoreContext);
}
Log($"Completed {handler.Name}");
@ -253,9 +245,9 @@ namespace Squidex.Domain.Apps.Entities.Backup
await commandBus.PublishAsync(new AssignContributor
{
Actor = actor,
AppId = CurrentJob.AppId,
AppId = CurrentJob.AppId.Id,
ContributorId = actor.Identifier,
IsRestore = true,
Restoring = true,
Role = Role.Owner
});
@ -272,29 +264,44 @@ namespace Squidex.Domain.Apps.Entities.Backup
}
}
private async Task CleanupAsync(IEnumerable<BackupHandler> handlers)
private async Task CleanupAsync(IEnumerable<IBackupHandler> handlers)
{
await Safe.DeleteAsync(backupArchiveLocation, CurrentJob.Id.ToString(), log);
if (CurrentJob.AppId != Guid.Empty)
if (CurrentJob.AppId != null)
{
var appId = CurrentJob.AppId.Id;
foreach (var handler in handlers)
{
await Safe.CleanupRestoreErrorAsync(handler, CurrentJob.AppId, CurrentJob.Id, log);
try
{
await handler.CleanupRestoreErrorAsync(appId);
}
catch (Exception ex)
{
log.LogError(ex, appId.ToString(), (logOperationId, w) => w
.WriteProperty("action", "cleanupRestore")
.WriteProperty("status", "failed")
.WriteProperty("operationId", logOperationId));
}
}
}
}
private async Task DownloadAsync()
private async Task<IBackupReader> DownloadAsync()
{
using (Profiler.Trace("Download"))
{
Log("Downloading Backup");
await backupArchiveLocation.DownloadAsync(CurrentJob.Url, CurrentJob.Id.ToString());
var reader = await backupArchiveLocation.OpenReaderAsync(CurrentJob.Url, CurrentJob.Id);
Log("Downloaded Backup");
return reader;
}
}
private async Task ReadEventsAsync(BackupReader reader, IEnumerable<BackupHandler> handlers)
private async Task ReadEventsAsync(IBackupReader reader, IEnumerable<IBackupHandler> handlers)
{
await reader.ReadEventsAsync(streamNameResolver, eventDataFormatter, async storedEvent =>
{
@ -304,31 +311,40 @@ namespace Squidex.Domain.Apps.Entities.Backup
Log($"Reading {reader.ReadEvents} events and {reader.ReadAttachments} attachments completed.", true);
}
private async Task HandleEventAsync(BackupReader reader, IEnumerable<BackupHandler> handlers, string stream, Envelope<IEvent> @event)
{
if (@event.Payload is SquidexEvent squidexEvent)
private async Task HandleEventAsync(IBackupReader reader, IEnumerable<IBackupHandler> handlers, string stream, Envelope<IEvent> @event)
{
squidexEvent.Actor = CurrentJob.Actor;
}
if (@event.Payload is AppCreated appCreated)
{
CurrentJob.AppId = appCreated.AppId.Id;
if (!string.IsNullOrWhiteSpace(CurrentJob.NewAppName))
{
appCreated.Name = CurrentJob.NewAppName;
CurrentJob.AppId = NamedId.Of(appCreated.AppId.Id, CurrentJob.NewAppName);
}
else
{
CurrentJob.AppId = appCreated.AppId;
}
await CreateContextAsync(reader);
}
if (@event.Payload is SquidexEvent squidexEvent)
{
if (restoreContext.UserMapping.TryMap(squidexEvent.Actor, out var newUser))
{
squidexEvent.Actor = newUser;
}
}
if (@event.Payload is AppEvent appEvent && !string.IsNullOrWhiteSpace(CurrentJob.NewAppName))
if (@event.Payload is AppEvent appEvent)
{
appEvent.AppId = NamedId.Of(appEvent.AppId.Id, CurrentJob.NewAppName);
appEvent.AppId = CurrentJob.AppId;
}
foreach (var handler in handlers)
{
if (!await handler.RestoreEventAsync(@event, CurrentJob.AppId, reader, CurrentJob.Actor))
if (!await handler.RestoreEventAsync(@event, restoreContext))
{
return;
}
@ -342,6 +358,22 @@ namespace Squidex.Domain.Apps.Entities.Backup
Log($"Read {reader.ReadEvents} events and {reader.ReadAttachments} attachments.", true);
}
private async Task CreateContextAsync(IBackupReader reader)
{
var userMapping = new UserMapping(CurrentJob.Actor);
using (Profiler.Trace("CreateUsers"))
{
Log("Creating Users");
await userMapping.RestoreAsync(reader, userResolver);
Log("Created Users");
}
restoreContext = new RestoreContext(CurrentJob.AppId.Id, userMapping, reader);
}
private void Log(string message, bool replace = false)
{
if (replace && CurrentJob.Log.Count > 0)
@ -354,12 +386,12 @@ namespace Squidex.Domain.Apps.Entities.Backup
}
}
private IEnumerable<BackupHandler> CreateHandlers()
private IEnumerable<IBackupHandler> CreateHandlers()
{
return serviceProvider.GetRequiredService<IEnumerable<BackupHandler>>();
return serviceProvider.GetRequiredService<IEnumerable<IBackupHandler>>();
}
public Task<J<IRestoreJob>> GetJobAsync()
public Task<J<IRestoreJob>> GetStateAsync()
{
return Task.FromResult<J<IRestoreJob>>(CurrentJob);
}

2
backend/src/Squidex.Domain.Apps.Entities/Backup/State/BackupStateJob.cs → backend/src/Squidex.Domain.Apps.Entities/Backup/State/BackupJob.cs

@ -11,7 +11,7 @@ using NodaTime;
namespace Squidex.Domain.Apps.Entities.Backup.State
{
public sealed class BackupStateJob : IBackupJob
public sealed class BackupJob : IBackupJob
{
[DataMember]
public Guid Id { get; set; }

2
backend/src/Squidex.Domain.Apps.Entities/Backup/State/BackupState.cs

@ -13,6 +13,6 @@ namespace Squidex.Domain.Apps.Entities.Backup.State
public sealed class BackupState
{
[DataMember]
public List<BackupStateJob> Jobs { get; } = new List<BackupStateJob>();
public List<BackupJob> Jobs { get; } = new List<BackupJob>();
}
}

4
backend/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreStateJob.cs → backend/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreJob.cs

@ -14,7 +14,7 @@ using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Backup.State
{
[DataContract]
public sealed class RestoreStateJob : IRestoreJob
public sealed class RestoreJob : IRestoreJob
{
[DataMember]
public string AppName { get; set; }
@ -23,7 +23,7 @@ namespace Squidex.Domain.Apps.Entities.Backup.State
public Guid Id { get; set; }
[DataMember]
public Guid AppId { get; set; }
public NamedId<Guid> AppId { get; set; }
[DataMember]
public RefToken Actor { get; set; }

4
backend/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreState.cs → backend/src/Squidex.Domain.Apps.Entities/Backup/State/RestoreState2.cs

@ -9,9 +9,9 @@ using System.Runtime.Serialization;
namespace Squidex.Domain.Apps.Entities.Backup.State
{
public class RestoreState
public class RestoreState2
{
[DataMember]
public RestoreStateJob Job { get; set; }
public RestoreJob Job { get; set; }
}
}

87
backend/src/Squidex.Domain.Apps.Entities/Backup/TempFolderBackupArchiveLocation.cs

@ -5,39 +5,106 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Net.Http;
using System.Threading.Tasks;
using Squidex.Infrastructure.Tasks;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Json;
namespace Squidex.Domain.Apps.Entities.Backup
{
[ExcludeFromCodeCoverage]
public sealed class TempFolderBackupArchiveLocation : IBackupArchiveLocation
{
public Task<Stream> OpenStreamAsync(string backupId)
private readonly IJsonSerializer jsonSerializer;
public TempFolderBackupArchiveLocation(IJsonSerializer jsonSerializer)
{
Guard.NotNull(jsonSerializer);
this.jsonSerializer = jsonSerializer;
}
public async Task<IBackupReader> OpenReaderAsync(Uri url, Guid id)
{
var tempFile = GetTempFile(backupId);
var stream = OpenStream(id);
return Task.FromResult<Stream>(new FileStream(tempFile, FileMode.OpenOrCreate, FileAccess.ReadWrite));
if (string.Equals(url.Scheme, "file"))
{
try
{
using (var sourceStream = new FileStream(url.LocalPath, FileMode.Open, FileAccess.Read))
{
await sourceStream.CopyToAsync(stream);
}
}
catch (IOException ex)
{
throw new BackupRestoreException($"Cannot download the archive: {ex.Message}.", ex);
}
}
else
{
HttpResponseMessage? response = null;
try
{
using (var client = new HttpClient())
{
response = await client.GetAsync(url);
response.EnsureSuccessStatusCode();
public Task DeleteArchiveAsync(string backupId)
using (var sourceStream = await response.Content.ReadAsStreamAsync())
{
await sourceStream.CopyToAsync(stream);
}
}
}
catch (HttpRequestException ex)
{
var tempFile = GetTempFile(backupId);
throw new BackupRestoreException($"Cannot download the archive. Got status code: {response?.StatusCode}.", ex);
}
}
try
{
File.Delete(tempFile);
return new BackupReader(jsonSerializer, stream);
}
catch (IOException)
{
stream.Dispose();
throw new BackupRestoreException("The backup archive is corrupt and cannot be opened.");
}
catch (Exception)
{
stream.Dispose();
throw;
}
}
return TaskHelper.Done;
public Stream OpenStream(Guid backupId)
{
var tempFile = Path.Combine(Path.GetTempPath(), backupId + ".zip");
var fileStream = new FileStream(
tempFile,
FileMode.Create,
FileAccess.ReadWrite,
FileShare.None,
4096,
FileOptions.DeleteOnClose);
return fileStream;
}
private static string GetTempFile(string backupId)
public Task<IBackupWriter> OpenWriterAsync(Stream stream)
{
return Path.Combine(Path.GetTempPath(), backupId + ".zip");
var writer = new BackupWriter(jsonSerializer, stream, true);
return Task.FromResult<IBackupWriter>(writer);
}
}
}

127
backend/src/Squidex.Domain.Apps.Entities/Backup/UserMapping.cs

@ -0,0 +1,127 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Squidex.Infrastructure;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Backup
{
public class UserMapping : IUserMapping
{
private const string UsersFile = "Users.json";
private readonly Dictionary<string, RefToken> userMap = new Dictionary<string, RefToken>();
private readonly RefToken initiator;
public RefToken Initiator
{
get { return initiator; }
}
public UserMapping(RefToken initiator)
{
Guard.NotNull(initiator);
this.initiator = initiator;
}
public void Backup(RefToken token)
{
Guard.NotNull(userMap);
if (!token.IsSubject)
{
return;
}
userMap[token.Identifier] = token;
}
public void Backup(string userId)
{
Guard.NotNullOrEmpty(userId);
if (!userMap.ContainsKey(userId))
{
userMap[userId] = new RefToken(RefTokenType.Subject, userId);
}
}
public async Task StoreAsync(IBackupWriter writer, IUserResolver userResolver)
{
Guard.NotNull(writer);
Guard.NotNull(userResolver);
var users = await userResolver.QueryManyAsync(userMap.Keys.ToArray());
var json = users.ToDictionary(x => x.Key, x => x.Value.Email);
await writer.WriteJsonAsync(UsersFile, json);
}
public async Task RestoreAsync(IBackupReader reader, IUserResolver userResolver)
{
Guard.NotNull(reader);
Guard.NotNull(userResolver);
var json = await reader.ReadJsonAttachmentAsync<Dictionary<string, string>>(UsersFile);
foreach (var kvp in json)
{
var email = kvp.Value;
var user = await userResolver.FindByIdOrEmailAsync(email);
if (user == null && await userResolver.CreateUserIfNotExistsAsync(kvp.Value, false))
{
user = await userResolver.FindByIdOrEmailAsync(email);
}
if (user != null)
{
userMap[kvp.Key] = new RefToken(RefTokenType.Subject, user.Id);
}
}
}
public bool TryMap(string userId, out RefToken result)
{
Guard.NotNullOrEmpty(userId);
if (userMap.TryGetValue(userId, out var mapped))
{
result = mapped;
return true;
}
result = initiator;
return false;
}
public bool TryMap(RefToken token, out RefToken result)
{
Guard.NotNull(token);
if (token.IsClient)
{
result = token;
return true;
}
if (userMap.TryGetValue(token.Identifier, out var mapped))
{
result = mapped;
return true;
}
result = initiator;
return false;
}
}
}

30
backend/src/Squidex.Domain.Apps.Entities/Contents/BackupContents.cs

@ -14,24 +14,27 @@ using Squidex.Domain.Apps.Entities.Contents.State;
using Squidex.Domain.Apps.Events.Contents;
using Squidex.Domain.Apps.Events.Schemas;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.States;
using Squidex.Infrastructure.Tasks;
namespace Squidex.Domain.Apps.Entities.Contents
{
public sealed class BackupContents : BackupHandlerWithStore
public sealed class BackupContents : IBackupHandler
{
private readonly Dictionary<Guid, HashSet<Guid>> contentIdsBySchemaId = new Dictionary<Guid, HashSet<Guid>>();
private readonly Rebuilder rebuilder;
public override string Name { get; } = "Contents";
public string Name { get; } = "Contents";
public BackupContents(IStore<Guid> store)
: base(store)
public BackupContents(Rebuilder rebuilder)
{
Guard.NotNull(rebuilder);
this.rebuilder = rebuilder;
}
public override Task<bool> RestoreEventAsync(Envelope<IEvent> @event, Guid appId, BackupReader reader, RefToken actor)
public Task<bool> RestoreEventAsync(Envelope<IEvent> @event, RestoreContext context)
{
switch (@event.Payload)
{
@ -46,11 +49,18 @@ namespace Squidex.Domain.Apps.Entities.Contents
return TaskHelper.True;
}
public override Task RestoreAsync(Guid appId, BackupReader reader)
public async Task RestoreAsync(RestoreContext context)
{
var contentIds = contentIdsBySchemaId.Values.SelectMany(x => x);
return RebuildManyAsync(contentIds, RebuildAsync<ContentState, ContentGrain>);
if (contentIdsBySchemaId.Count > 0)
{
await rebuilder.InsertManyAsync<ContentState, ContentGrain>(async target =>
{
foreach (var contentId in contentIdsBySchemaId.Values.SelectMany(x => x))
{
await target(contentId);
}
});
}
}
}
}

2
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLModel.cs

@ -111,7 +111,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
{
var context = (GraphQLExecutionContext)c.UserContext;
return context.UrlGenerator.GenerateAssetSourceUrl(app, c.Source);
return context.UrlGenerator.GenerateAssetSourceUrl(c.Source);
});
return resolver;

2
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphQLUrlGenerator.cs

@ -17,7 +17,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
string? GenerateAssetThumbnailUrl(IAppEntity app, IAssetEntity asset);
string? GenerateAssetSourceUrl(IAppEntity app, IAssetEntity asset);
string? GenerateAssetSourceUrl(IAssetEntity asset);
string GenerateAssetUrl(IAppEntity app, IAssetEntity asset);

4
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/AssetIndexStorage.cs

@ -45,7 +45,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text
{
try
{
await assetStore.DownloadAsync(directoryInfo.Name, 0, string.Empty, fileStream);
await assetStore.DownloadAsync(directoryInfo.Name, fileStream);
fileStream.Position = 0;
@ -99,7 +99,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text
fileStream.Position = 0;
await assetStore.UploadAsync(directoryInfo.Name, 0, string.Empty, fileStream, true);
await assetStore.UploadAsync(directoryInfo.Name, fileStream, true);
}
}
finally

10
backend/src/Squidex.Domain.Apps.Entities/Rules/BackupRules.cs

@ -17,12 +17,12 @@ using Squidex.Infrastructure.Tasks;
namespace Squidex.Domain.Apps.Entities.Rules
{
public sealed class BackupRules : BackupHandler
public sealed class BackupRules : IBackupHandler
{
private readonly HashSet<Guid> ruleIds = new HashSet<Guid>();
private readonly IRulesIndex indexForRules;
public override string Name { get; } = "Rules";
public string Name { get; } = "Rules";
public BackupRules(IRulesIndex indexForRules)
{
@ -31,7 +31,7 @@ namespace Squidex.Domain.Apps.Entities.Rules
this.indexForRules = indexForRules;
}
public override Task<bool> RestoreEventAsync(Envelope<IEvent> @event, Guid appId, BackupReader reader, RefToken actor)
public Task<bool> RestoreEventAsync(Envelope<IEvent> @event, RestoreContext context)
{
switch (@event.Payload)
{
@ -46,9 +46,9 @@ namespace Squidex.Domain.Apps.Entities.Rules
return TaskHelper.True;
}
public override Task RestoreAsync(Guid appId, BackupReader reader)
public Task RestoreAsync(RestoreContext context)
{
return indexForRules.RebuildAsync(appId, ruleIds);
return indexForRules.RebuildAsync(context.AppId, ruleIds);
}
}
}

10
backend/src/Squidex.Domain.Apps.Entities/Schemas/BackupSchemas.cs

@ -17,12 +17,12 @@ using Squidex.Infrastructure.Tasks;
namespace Squidex.Domain.Apps.Entities.Schemas
{
public sealed class BackupSchemas : BackupHandler
public sealed class BackupSchemas : IBackupHandler
{
private readonly Dictionary<string, Guid> schemasByName = new Dictionary<string, Guid>();
private readonly ISchemasIndex indexSchemas;
public override string Name { get; } = "Schemas";
public string Name { get; } = "Schemas";
public BackupSchemas(ISchemasIndex indexSchemas)
{
@ -31,7 +31,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas
this.indexSchemas = indexSchemas;
}
public override Task<bool> RestoreEventAsync(Envelope<IEvent> @event, Guid appId, BackupReader reader, RefToken actor)
public Task<bool> RestoreEventAsync(Envelope<IEvent> @event, RestoreContext context)
{
switch (@event.Payload)
{
@ -46,9 +46,9 @@ namespace Squidex.Domain.Apps.Entities.Schemas
return TaskHelper.True;
}
public override Task RestoreAsync(Guid appId, BackupReader reader)
public Task RestoreAsync(RestoreContext context)
{
return indexSchemas.RebuildAsync(appId, schemasByName);
return indexSchemas.RebuildAsync(context.AppId, schemasByName);
}
}
}

24
backend/src/Squidex.Domain.Users/AssetUserPictureStore.cs → backend/src/Squidex.Domain.Users/DefaultUserPictureStore.cs

@ -6,37 +6,41 @@
// ==========================================================================
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Assets;
namespace Squidex.Domain.Users
{
public sealed class AssetUserPictureStore : IUserPictureStore
public sealed class DefaultUserPictureStore : IUserPictureStore
{
private readonly IAssetStore assetStore;
public AssetUserPictureStore(IAssetStore assetStore)
public DefaultUserPictureStore(IAssetStore assetStore)
{
Guard.NotNull(assetStore);
this.assetStore = assetStore;
}
public Task UploadAsync(string userId, Stream stream)
public Task UploadAsync(string userId, Stream stream, CancellationToken ct = default)
{
return assetStore.UploadAsync(userId, 0, "picture", stream, true);
var fileName = GetFileName(userId);
return assetStore.UploadAsync(fileName, stream, true, ct);
}
public async Task<Stream> DownloadAsync(string userId)
public Task DownloadAsync(string userId, Stream stream, CancellationToken ct = default)
{
var memoryStream = new MemoryStream();
await assetStore.DownloadAsync(userId, 0, "picture", memoryStream);
var fileName = GetFileName(userId);
memoryStream.Position = 0;
return assetStore.DownloadAsync(fileName, stream, ct);
}
return memoryStream;
private static string GetFileName(string userId)
{
return $"{userId}_0_picture";
}
}
}

2
backend/src/Squidex.Domain.Users/DefaultUserResolver.cs

@ -27,7 +27,7 @@ namespace Squidex.Domain.Users
this.serviceProvider = serviceProvider;
}
public async Task<bool> CreateUserIfNotExists(string email, bool invited)
public async Task<bool> CreateUserIfNotExistsAsync(string email, bool invited)
{
Guard.NotNullOrEmpty(email);

5
backend/src/Squidex.Domain.Users/IUserPictureStore.cs

@ -6,14 +6,15 @@
// ==========================================================================
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace Squidex.Domain.Users
{
public interface IUserPictureStore
{
Task UploadAsync(string userId, Stream stream);
Task UploadAsync(string userId, Stream stream, CancellationToken ct = default);
Task<Stream> DownloadAsync(string userId);
Task DownloadAsync(string userId, Stream stream, CancellationToken ct = default);
}
}

2
backend/src/Squidex.Domain.Users/UserWithClaims.cs

@ -33,7 +33,7 @@ namespace Squidex.Domain.Users
public bool IsLocked
{
get { return Identity.LockoutEnd > DateTime.Now.ToUniversalTime(); }
get { return Identity.LockoutEnd > DateTime.UtcNow; }
}
IReadOnlyList<Claim> IUser.Claims

74
backend/src/Squidex.Infrastructure/Assets/AssetStoreExtensions.cs

@ -1,74 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace Squidex.Infrastructure.Assets
{
public static class AssetStoreExtensions
{
public static string? GeneratePublicUrl(this IAssetStore store, Guid id, long version, string? suffix)
{
return store.GeneratePublicUrl(id.ToString(), version, suffix);
}
public static string? GeneratePublicUrl(this IAssetStore store, string id, long version, string? suffix)
{
return store.GeneratePublicUrl(GetFileName(id, version, suffix));
}
public static Task CopyAsync(this IAssetStore store, string sourceFileName, Guid id, long version, string? suffix, CancellationToken ct = default)
{
return store.CopyAsync(sourceFileName, id.ToString(), version, suffix, ct);
}
public static Task CopyAsync(this IAssetStore store, string sourceFileName, string id, long version, string? suffix, CancellationToken ct = default)
{
return store.CopyAsync(sourceFileName, GetFileName(id, version, suffix), ct);
}
public static Task DownloadAsync(this IAssetStore store, Guid id, long version, string? suffix, Stream stream, CancellationToken ct = default)
{
return store.DownloadAsync(id.ToString(), version, suffix, stream, ct);
}
public static Task DownloadAsync(this IAssetStore store, string id, long version, string? suffix, Stream stream, CancellationToken ct = default)
{
return store.DownloadAsync(GetFileName(id, version, suffix), stream, ct);
}
public static Task UploadAsync(this IAssetStore store, Guid id, long version, string? suffix, Stream stream, bool overwrite = false, CancellationToken ct = default)
{
return store.UploadAsync(id.ToString(), version, suffix, stream, overwrite, ct);
}
public static Task UploadAsync(this IAssetStore store, string id, long version, string? suffix, Stream stream, bool overwrite = false, CancellationToken ct = default)
{
return store.UploadAsync(GetFileName(id, version, suffix), stream, overwrite, ct);
}
public static Task DeleteAsync(this IAssetStore store, Guid id, long version, string? suffix)
{
return store.DeleteAsync(id.ToString(), version, suffix);
}
public static Task DeleteAsync(this IAssetStore store, string id, long version, string? suffix)
{
return store.DeleteAsync(GetFileName(id, version, suffix));
}
public static string GetFileName(string id, long version, string? suffix = null)
{
Guard.NotNullOrEmpty(id);
return StringExtensions.JoinNonEmpty("_", id, version.ToString(), suffix);
}
}
}

2
backend/src/Squidex.Infrastructure/Assets/FTPAssetStore.cs

@ -6,6 +6,7 @@
// ==========================================================================
using System;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
@ -14,6 +15,7 @@ using Squidex.Infrastructure.Log;
namespace Squidex.Infrastructure.Assets
{
[ExcludeFromCodeCoverage]
public sealed class FTPAssetStore : IAssetStore, IInitializable
{
private readonly string path;

114
backend/src/Squidex.Infrastructure/Commands/Rebuilder.cs

@ -0,0 +1,114 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using System.Threading.Tasks.Dataflow;
using Squidex.Infrastructure.Caching;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.States;
namespace Squidex.Infrastructure.Commands
{
public delegate Task IdSource(Func<Guid, Task> add);
public class Rebuilder
{
private readonly ILocalCache localCache;
private readonly IStore<Guid> store;
private readonly IEventStore eventStore;
public Rebuilder(
ILocalCache localCache,
IStore<Guid> store,
IEventStore eventStore)
{
Guard.NotNull(localCache);
Guard.NotNull(store);
Guard.NotNull(eventStore);
this.eventStore = eventStore;
this.localCache = localCache;
this.store = store;
}
public Task RebuildAsync<TState, TGrain>(string filter, CancellationToken ct) where TState : IDomainState<TState>, new()
{
return RebuildAsync<TState, TGrain>(async target =>
{
await eventStore.QueryAsync(async storedEvent =>
{
var id = storedEvent.Data.Headers.AggregateId();
await target(id);
}, filter, ct: ct);
}, ct);
}
public virtual async Task RebuildAsync<TState, TGrain>(IdSource source, CancellationToken ct = default) where TState : IDomainState<TState>, new()
{
Guard.NotNull(source);
await store.GetSnapshotStore<TState>().ClearAsync();
await InsertManyAsync<TState, TGrain>(source, ct);
}
public virtual async Task InsertManyAsync<TState, TGrain>(IdSource source, CancellationToken ct = default) where TState : IDomainState<TState>, new()
{
Guard.NotNull(source);
var worker = new ActionBlock<Guid>(async id =>
{
try
{
var state = new TState
{
Version = EtagVersion.Empty
};
var persistence = store.WithSnapshotsAndEventSourcing(typeof(TGrain), id, (TState s) => state = s, e =>
{
state = state.Apply(e);
state.Version++;
});
await persistence.ReadAsync();
await persistence.WriteSnapshotAsync(state);
}
catch (DomainObjectNotFoundException)
{
return;
}
},
new ExecutionDataflowBlockOptions
{
MaxDegreeOfParallelism = Environment.ProcessorCount * 2
});
var handledIds = new HashSet<Guid>();
using (localCache.StartContext())
{
await source(new Func<Guid, Task>(async id =>
{
if (handledIds.Add(id))
{
await worker.SendAsync(id, ct);
}
}));
worker.Complete();
await worker.Completion;
}
}
}
}

2
backend/src/Squidex.Infrastructure/Email/SmtpEmailSender.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Net.Mail;
using System.Threading.Tasks;
@ -12,6 +13,7 @@ using Microsoft.Extensions.Options;
namespace Squidex.Infrastructure.Email
{
[ExcludeFromCodeCoverage]
public sealed class SmtpEmailSender : IEmailSender
{
private readonly SmtpClient smtpClient;

4
backend/src/Squidex.Infrastructure/Log/Internal/AnsiLogConsole.cs

@ -6,10 +6,12 @@
// ==========================================================================
using System;
using System.Diagnostics.CodeAnalysis;
namespace Squidex.Infrastructure.Log.Internal
{
public class AnsiLogConsole : IConsole
[ExcludeFromCodeCoverage]
public sealed class AnsiLogConsole : IConsole
{
private readonly bool logToStdError;

2
backend/src/Squidex.Infrastructure/Log/Internal/ConsoleLogProcessor.cs

@ -8,11 +8,13 @@
using System;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.InteropServices;
using System.Threading;
namespace Squidex.Infrastructure.Log.Internal
{
[ExcludeFromCodeCoverage]
public sealed class ConsoleLogProcessor : DisposableObjectBase
{
private const int MaxQueuedMessages = 1024;

4
backend/src/Squidex.Infrastructure/Log/Internal/FileLogProcessor.cs

@ -8,6 +8,7 @@
using System;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Text;
using System.Threading;
@ -15,7 +16,8 @@ using NodaTime;
namespace Squidex.Infrastructure.Log.Internal
{
public class FileLogProcessor : DisposableObjectBase
[ExcludeFromCodeCoverage]
public sealed class FileLogProcessor : DisposableObjectBase
{
private const int MaxQueuedMessages = 1024;
private const int Retries = 10;

4
backend/src/Squidex.Infrastructure/Log/Internal/WindowsLogConsole.cs

@ -6,10 +6,12 @@
// ==========================================================================
using System;
using System.Diagnostics.CodeAnalysis;
namespace Squidex.Infrastructure.Log.Internal
{
public class WindowsLogConsole : IConsole
[ExcludeFromCodeCoverage]
public sealed class WindowsLogConsole : IConsole
{
private readonly bool logToStdError;

2
backend/src/Squidex.Infrastructure/Plugins/PluginLoaders.cs

@ -7,12 +7,14 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Reflection;
using McMaster.NETCore.Plugins;
namespace Squidex.Infrastructure.Plugins
{
[ExcludeFromCodeCoverage]
public static class PluginLoaders
{
public static PluginLoader? LoadPlugin(string pluginPath, AssemblyName[] sharedAssemblies)

2
backend/src/Squidex.Infrastructure/Translations/DeepLTranslator.cs

@ -6,6 +6,7 @@
// ==========================================================================
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Net.Http;
using System.Threading;
@ -17,6 +18,7 @@ using Squidex.Infrastructure.Json;
namespace Squidex.Infrastructure.Translations
{
[ExcludeFromCodeCoverage]
public sealed class DeepLTranslator : ITranslator
{
private const string Url = "https://api.deepl.com/v2/translate";

2
backend/src/Squidex.Shared/Users/IUserResolver.cs

@ -12,7 +12,7 @@ namespace Squidex.Shared.Users
{
public interface IUserResolver
{
Task<bool> CreateUserIfNotExists(string email, bool invited = false);
Task<bool> CreateUserIfNotExistsAsync(string email, bool invited = false);
Task<IUser?> FindByIdOrEmailAsync(string idOrEmail);

11
backend/src/Squidex.Web/Services/UrlGenerator.cs

@ -16,20 +16,19 @@ using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Contents.GraphQL;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Assets;
namespace Squidex.Web.Services
{
public sealed class UrlGenerator : IGraphQLUrlGenerator, IRuleUrlGenerator, IAssetUrlGenerator, IEmailUrlGenerator
{
private readonly IAssetStore assetStore;
private readonly IAssetFileStore assetFileStore;
private readonly UrlsOptions urlsOptions;
public bool CanGenerateAssetSourceUrl { get; }
public UrlGenerator(IOptions<UrlsOptions> urlsOptions, IAssetStore assetStore, bool allowAssetSourceUrl)
public UrlGenerator(IOptions<UrlsOptions> urlsOptions, IAssetFileStore assetFileStore, bool allowAssetSourceUrl)
{
this.assetStore = assetStore;
this.assetFileStore = assetFileStore;
this.urlsOptions = urlsOptions.Value;
CanGenerateAssetSourceUrl = allowAssetSourceUrl;
@ -70,9 +69,9 @@ namespace Squidex.Web.Services
return urlsOptions.BuildUrl("app/");
}
public string? GenerateAssetSourceUrl(IAppEntity app, IAssetEntity asset)
public string? GenerateAssetSourceUrl(IAssetEntity asset)
{
return assetStore.GeneratePublicUrl(asset.Id.ToString(), asset.FileVersion, null);
return assetFileStore.GeneratePublicUrl(asset.Id, asset.FileVersion);
}
}
}

12
backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs

@ -36,18 +36,21 @@ namespace Squidex.Areas.Api.Controllers.Apps
[ApiExplorerSettings(GroupName = nameof(Apps))]
public sealed class AppsController : ApiController
{
private readonly IAppImageStore appImageStore;
private readonly IAssetStore assetStore;
private readonly IAssetThumbnailGenerator assetThumbnailGenerator;
private readonly IAppProvider appProvider;
private readonly IAppPlansProvider appPlansProvider;
public AppsController(ICommandBus commandBus,
IAppImageStore appImageStore,
IAssetStore assetStore,
IAssetThumbnailGenerator assetThumbnailGenerator,
IAppProvider appProvider,
IAppPlansProvider appPlansProvider)
: base(commandBus)
{
this.appImageStore = appImageStore;
this.assetStore = assetStore;
this.assetThumbnailGenerator = assetThumbnailGenerator;
this.appProvider = appProvider;
@ -179,12 +182,11 @@ namespace Squidex.Areas.Api.Controllers.Apps
var handler = new Func<Stream, Task>(async bodyStream =>
{
var assetId = App.Id.ToString();
var assetResizedId = $"{assetId}_{etag}_Resized";
var resizedAsset = $"{App.Id}_{etag}_Resized";
try
{
await assetStore.DownloadAsync(assetResizedId, bodyStream);
await assetStore.DownloadAsync(resizedAsset, bodyStream);
}
catch (AssetNotFoundException)
{
@ -196,7 +198,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
{
using (Profiler.Trace("ResizeDownload"))
{
await assetStore.DownloadAsync(assetId, sourceStream);
await appImageStore.DownloadAsync(App.Id, sourceStream);
sourceStream.Position = 0;
}
@ -208,7 +210,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
using (Profiler.Trace("ResizeUpload"))
{
await assetStore.UploadAsync(assetResizedId, destinationStream);
await assetStore.UploadAsync(resizedAsset, destinationStream);
destinationStream.Position = 0;
}

23
backend/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs

@ -30,19 +30,22 @@ namespace Squidex.Areas.Api.Controllers.Assets
[ApiExplorerSettings(GroupName = nameof(Assets))]
public sealed class AssetContentController : ApiController
{
private readonly IAssetStore assetStore;
private readonly IAssetFileStore assetFileStore;
private readonly IAssetRepository assetRepository;
private readonly IAssetStore assetStore;
private readonly IAssetThumbnailGenerator assetThumbnailGenerator;
public AssetContentController(
ICommandBus commandBus,
IAssetStore assetStore,
IAssetFileStore assetFileStore,
IAssetRepository assetRepository,
IAssetStore assetStore,
IAssetThumbnailGenerator assetThumbnailGenerator)
: base(commandBus)
{
this.assetStore = assetStore;
this.assetFileStore = assetFileStore;
this.assetRepository = assetRepository;
this.assetStore = assetStore;
this.assetThumbnailGenerator = assetThumbnailGenerator;
}
@ -123,20 +126,18 @@ namespace Squidex.Areas.Api.Controllers.Assets
var handler = new Func<Stream, Task>(async bodyStream =>
{
var assetId = asset.Id.ToString();
if (asset.IsImage && query.ShouldResize())
{
var assetSuffix = $"{query.Width}_{query.Height}_{query.Mode}";
var resizedAsset = $"{asset.Id}_{asset.FileVersion}_{query.Width}_{query.Height}_{query.Mode}";
if (query.Quality.HasValue)
{
assetSuffix += $"_{query.Quality}";
resizedAsset += $"_{query.Quality}";
}
try
{
await assetStore.DownloadAsync(assetId, fileVersion, assetSuffix, bodyStream);
await assetStore.DownloadAsync(resizedAsset, bodyStream);
}
catch (AssetNotFoundException)
{
@ -148,7 +149,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
{
using (Profiler.Trace("ResizeDownload"))
{
await assetStore.DownloadAsync(assetId, fileVersion, null, sourceStream);
await assetFileStore.DownloadAsync(asset.Id, fileVersion, sourceStream);
sourceStream.Position = 0;
}
@ -160,7 +161,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
using (Profiler.Trace("ResizeUpload"))
{
await assetStore.UploadAsync(assetId, fileVersion, assetSuffix, destinationStream);
await assetStore.UploadAsync(resizedAsset, destinationStream);
destinationStream.Position = 0;
}
@ -172,7 +173,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
}
else
{
await assetStore.DownloadAsync(assetId, fileVersion, null, bodyStream);
await assetFileStore.DownloadAsync(asset.Id, fileVersion, bodyStream);
}
});

21
backend/src/Squidex/Areas/Api/Controllers/Backups/BackupContentController.cs

@ -9,9 +9,7 @@ using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Orleans;
using Squidex.Domain.Apps.Entities.Backup;
using Squidex.Infrastructure.Assets;
using Squidex.Infrastructure.Commands;
using Squidex.Web;
@ -23,14 +21,16 @@ namespace Squidex.Areas.Api.Controllers.Backups
[ApiExplorerSettings(GroupName = nameof(Backups))]
public class BackupContentController : ApiController
{
private readonly IAssetStore assetStore;
private readonly IGrainFactory grainFactory;
private readonly IBackupArchiveStore backupArchiveStore;
private readonly IBackupService backupservice;
public BackupContentController(ICommandBus commandBus, IAssetStore assetStore, IGrainFactory grainFactory)
public BackupContentController(ICommandBus commandBus,
IBackupArchiveStore backupArchiveStore,
IBackupService backupservice)
: base(commandBus)
{
this.assetStore = assetStore;
this.grainFactory = grainFactory;
this.backupArchiveStore = backupArchiveStore;
this.backupservice = backupservice;
}
/// <summary>
@ -50,10 +50,7 @@ namespace Squidex.Areas.Api.Controllers.Backups
[AllowAnonymous]
public async Task<IActionResult> GetBackupContent(string app, Guid id)
{
var backupGrain = grainFactory.GetGrain<IBackupGrain>(AppId);
var backups = await backupGrain.GetStateAsync();
var backup = backups.Value.Find(x => x.Id == id);
var backup = await backupservice.GetBackupAsync(AppId, id);
if (backup == null || backup.Status != JobStatus.Completed)
{
@ -64,7 +61,7 @@ namespace Squidex.Areas.Api.Controllers.Backups
return new FileCallbackResult("application/zip", fileName, false, bodyStream =>
{
return assetStore.DownloadAsync(id.ToString(), 0, null, bodyStream);
return backupArchiveStore.DownloadAsync(id, bodyStream);
});
}
}

22
backend/src/Squidex/Areas/Api/Controllers/Backups/BackupsController.cs

@ -9,10 +9,10 @@ using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Orleans;
using Squidex.Areas.Api.Controllers.Backups.Models;
using Squidex.Domain.Apps.Entities.Backup;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Security;
using Squidex.Infrastructure.Tasks;
using Squidex.Shared;
using Squidex.Web;
@ -25,12 +25,12 @@ namespace Squidex.Areas.Api.Controllers.Backups
[ApiExplorerSettings(GroupName = nameof(Backups))]
public class BackupsController : ApiController
{
private readonly IGrainFactory grainFactory;
private readonly IBackupService backupService;
public BackupsController(ICommandBus commandBus, IGrainFactory grainFactory)
public BackupsController(ICommandBus commandBus, IBackupService backupService)
: base(commandBus)
{
this.grainFactory = grainFactory;
this.backupService = backupService;
}
/// <summary>
@ -48,11 +48,9 @@ namespace Squidex.Areas.Api.Controllers.Backups
[ApiCosts(0)]
public async Task<IActionResult> GetBackups(string app)
{
var backupGrain = grainFactory.GetGrain<IBackupGrain>(AppId);
var jobs = await backupService.GetBackupsAsync(AppId);
var jobs = await backupGrain.GetStateAsync();
var response = BackupJobsDto.FromBackups(jobs.Value, this, app);
var response = BackupJobsDto.FromBackups(jobs, this, app);
return Ok(response);
}
@ -72,9 +70,7 @@ namespace Squidex.Areas.Api.Controllers.Backups
[ApiCosts(0)]
public IActionResult PostBackup(string app)
{
var backupGrain = grainFactory.GetGrain<IBackupGrain>(AppId);
backupGrain.RunAsync().Forget();
backupService.StartBackupAsync(App.Id, User.Token()!).Forget();
return NoContent();
}
@ -95,9 +91,7 @@ namespace Squidex.Areas.Api.Controllers.Backups
[ApiCosts(0)]
public async Task<IActionResult> DeleteBackup(string app, Guid id)
{
var backupGrain = grainFactory.GetGrain<IBackupGrain>(AppId);
await backupGrain.DeleteAsync(id);
await backupService.DeleteBackupAsync(AppId, id);
return NoContent();
}

20
backend/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs

@ -7,11 +7,9 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Orleans;
using Squidex.Areas.Api.Controllers.Backups.Models;
using Squidex.Domain.Apps.Entities.Backup;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Orleans;
using Squidex.Infrastructure.Security;
using Squidex.Shared;
using Squidex.Web;
@ -24,12 +22,12 @@ namespace Squidex.Areas.Api.Controllers.Backups
[ApiExplorerSettings(GroupName = nameof(Backups))]
public class RestoreController : ApiController
{
private readonly IGrainFactory grainFactory;
private readonly IBackupService backupService;
public RestoreController(ICommandBus commandBus, IGrainFactory grainFactory)
public RestoreController(ICommandBus commandBus, IBackupService backupService)
: base(commandBus)
{
this.grainFactory = grainFactory;
this.backupService = backupService;
}
/// <summary>
@ -44,16 +42,14 @@ namespace Squidex.Areas.Api.Controllers.Backups
[ApiPermission(Permissions.AdminRestore)]
public async Task<IActionResult> GetRestoreJob()
{
var restoreGrain = grainFactory.GetGrain<IRestoreGrain>(SingleGrain.Id);
var job = await backupService.GetRestoreAsync();
var job = await restoreGrain.GetJobAsync();
if (job.Value == null)
if (job == null)
{
return NotFound();
}
var response = RestoreJobDto.FromJob(job.Value);
var response = RestoreJobDto.FromJob(job);
return Ok(response);
}
@ -70,9 +66,7 @@ namespace Squidex.Areas.Api.Controllers.Backups
[ApiPermission(Permissions.AdminRestore)]
public async Task<IActionResult> PostRestoreJob([FromBody] RestoreRequestDto request)
{
var restoreGrain = grainFactory.GetGrain<IRestoreGrain>(SingleGrain.Id);
await restoreGrain.RestoreAsync(request.Url, User.Token()!, request.Name);
await backupService.StartRestoreAsync(User.Token()!, request.Url, request.Name);
return NoContent();
}

12
backend/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs

@ -164,7 +164,17 @@ namespace Squidex.Areas.Api.Controllers.Users
{
if (entity.IsPictureUrlStored())
{
return new FileStreamResult(await userPictureStore.DownloadAsync(entity.Id), "image/png");
return new FileCallbackResult("image/png", null, false, async stream =>
{
try
{
await userPictureStore.DownloadAsync(entity.Id, stream);
}
catch
{
await stream.WriteAsync(AvatarBytes);
}
});
}
using (var client = new HttpClient())

2
backend/src/Squidex/Config/Authentication/IdentityServices.cs

@ -22,7 +22,7 @@ namespace Squidex.Config.Authentication
services.AddSingletonAs<DefaultUserResolver>()
.AsOptional<IUserResolver>();
services.AddSingletonAs<AssetUserPictureStore>()
services.AddSingletonAs<DefaultUserPictureStore>()
.AsOptional<IUserPictureStore>();
}
}

3
backend/src/Squidex/Config/Domain/AppsServices.cs

@ -26,6 +26,9 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<AppHistoryEventsCreator>()
.As<IHistoryEventsCreator>();
services.AddSingletonAs<DefaultAppImageStore>()
.As<IAppImageStore>();
services.AddSingletonAs<AppProvider>()
.As<IAppProvider>();

3
backend/src/Squidex/Config/Domain/AssetServices.cs

@ -33,6 +33,9 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<AssetQueryParser>()
.AsSelf();
services.AddSingletonAs<DefaultAssetFileStore>()
.As<IAssetFileStore>();
services.AddSingletonAs<AssetEnricher>()
.As<IAssetEnricher>();

16
backend/src/Squidex/Config/Domain/BackupsServices.cs

@ -22,20 +22,26 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<TempFolderBackupArchiveLocation>()
.As<IBackupArchiveLocation>();
services.AddSingletonAs<DefaultBackupArchiveStore>()
.As<IBackupArchiveStore>();
services.AddTransientAs<BackupService>()
.As<IBackupService>();
services.AddTransientAs<BackupApps>()
.As<BackupHandler>();
.As<IBackupHandler>();
services.AddTransientAs<BackupAssets>()
.As<BackupHandler>();
.As<IBackupHandler>();
services.AddTransientAs<BackupContents>()
.As<BackupHandler>();
.As<IBackupHandler>();
services.AddTransientAs<BackupRules>()
.As<BackupHandler>();
.As<IBackupHandler>();
services.AddTransientAs<BackupSchemas>()
.As<BackupHandler>();
.As<IBackupHandler>();
}
}
}

4
backend/src/Squidex/Config/Domain/EventSourcingServices.cs

@ -14,6 +14,7 @@ using Microsoft.Extensions.DependencyInjection;
using MongoDB.Driver;
using Newtonsoft.Json;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Diagnostics;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.EventSourcing.Grains;
@ -85,6 +86,9 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<OrleansEventNotifier>()
.As<IEventNotifier>();
services.AddTransientAs<Rebuilder>()
.AsSelf();
services.AddSingletonAs<DefaultStreamNameResolver>()
.As<IStreamNameResolver>();

3
backend/src/Squidex/Config/Domain/MigrationServices.cs

@ -23,9 +23,6 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<Migrator>()
.AsSelf();
services.AddTransientAs<Rebuilder>()
.AsSelf();
services.AddTransientAs<RebuildRunner>()
.AsSelf();

4
backend/src/Squidex/Config/Domain/QueryServices.cs

@ -13,8 +13,8 @@ using Microsoft.Extensions.Options;
using Squidex.Domain.Apps.Core.ConvertContent;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Entities;
using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.Contents.GraphQL;
using Squidex.Infrastructure.Assets;
using Squidex.Web;
using Squidex.Web.Services;
@ -28,7 +28,7 @@ namespace Squidex.Config.Domain
services.AddSingletonAs(c => new UrlGenerator(
c.GetRequiredService<IOptions<UrlsOptions>>(),
c.GetRequiredService<IAssetStore>(),
c.GetRequiredService<IAssetFileStore>(),
exposeSourceUrl))
.As<IGraphQLUrlGenerator>().As<IRuleUrlGenerator>().As<IAssetUrlGenerator>().As<IEmailUrlGenerator>();

15
backend/tests/RunCoverage.ps1

@ -26,7 +26,8 @@ if ($all -Or $infrastructure) {
-register:user `
-target:"C:\Program Files\dotnet\dotnet.exe" `
-targetargs:"test --filter Category!=Dependencies $folderWorking\Squidex.Infrastructure.Tests\Squidex.Infrastructure.Tests.csproj" `
-filter:"+[Squidex.*]* -[Squidex.*]*CodeGen*" `
-filter:"+[Squidex.*]* -[*.Tests]* -[Squidex.*]*CodeGen*" `
-excludebyattribute:*.ExcludeFromCodeCoverage* `
-skipautoprops `
-output:"$folderWorking\$folderReports\Infrastructure.xml" `
-oldStyle
@ -37,7 +38,8 @@ if ($all -Or $appsCore) {
-register:user `
-target:"C:\Program Files\dotnet\dotnet.exe" `
-targetargs:"test $folderWorking\Squidex.Domain.Apps.Core.Tests\Squidex.Domain.Apps.Core.Tests.csproj" `
-filter:"+[Squidex.*]* -[Squidex.*]*CodeGen*" `
-filter:"+[Squidex.*]* -[*.Tests]* -[Squidex.*]*CodeGen*" `
-excludebyattribute:*.ExcludeFromCodeCoverage* `
-skipautoprops `
-output:"$folderWorking\$folderReports\Core.xml" `
-oldStyle
@ -48,7 +50,8 @@ if ($all -Or $appsEntities) {
-register:user `
-target:"C:\Program Files\dotnet\dotnet.exe" `
-targetargs:"test $folderWorking\Squidex.Domain.Apps.Entities.Tests\Squidex.Domain.Apps.Entities.Tests.csproj" `
-filter:"+[Squidex.*]* -[Squidex.*]*CodeGen*" `
-filter:"+[Squidex.*]* -[*.Tests]* -[Squidex.*]*CodeGen*" `
-excludebyattribute:*.ExcludeFromCodeCoverage* `
-skipautoprops `
-output:"$folderWorking\$folderReports\Entities.xml" `
-oldStyle
@ -59,7 +62,8 @@ if ($all -Or $users) {
-register:user `
-target:"C:\Program Files\dotnet\dotnet.exe" `
-targetargs:"test $folderWorking\Squidex.Domain.Users.Tests\Squidex.Domain.Users.Tests.csproj" `
-filter:"+[Squidex.*]* -[Squidex.*]*CodeGen*" `
-filter:"+[Squidex.*]* -[*.Tests]* -[Squidex.*]*CodeGen*" `
-excludebyattribute:*.ExcludeFromCodeCoverage* `
-skipautoprops `
-output:"$folderWorking\$folderReports\Users.xml" `
-oldStyle
@ -70,7 +74,8 @@ if ($all -Or $web) {
-register:user `
-target:"C:\Program Files\dotnet\dotnet.exe" `
-targetargs:"test $folderWorking\Squidex.Web.Tests\Squidex.Web.Tests.csproj" `
-filter:"+[Squidex.*]* -[Squidex.*]*CodeGen*" `
-filter:"+[Squidex.*]* -[*.Tests]* -[Squidex.*]*CodeGen*" `
-excludebyattribute:*.ExcludeFromCodeCoverage* `
-skipautoprops `
-output:"$folderWorking\$folderReports\Web.xml" `
-oldStyle

6
backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppCommandMiddlewareTests.cs

@ -26,7 +26,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
public class AppCommandMiddlewareTests : HandlerTestBase<AppState>
{
private readonly IContextProvider contextProvider = A.Fake<IContextProvider>();
private readonly IAssetStore assetStore = A.Fake<IAssetStore>();
private readonly IAppImageStore appImageStore = A.Fake<IAppImageStore>();
private readonly IAssetThumbnailGenerator assetThumbnailGenerator = A.Fake<IAssetThumbnailGenerator>();
private readonly Guid appId = Guid.NewGuid();
private readonly Context requestContext = Context.Anonymous();
@ -46,7 +46,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
A.CallTo(() => contextProvider.Context)
.Returns(requestContext);
sut = new AppCommandMiddleware(A.Fake<IGrainFactory>(), assetStore, assetThumbnailGenerator, contextProvider);
sut = new AppCommandMiddleware(A.Fake<IGrainFactory>(), appImageStore, assetThumbnailGenerator, contextProvider);
}
[Fact]
@ -79,7 +79,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
await sut.HandleAsync(context);
A.CallTo(() => assetStore.UploadAsync(appId.ToString(), stream, true, A<CancellationToken>.Ignored))
A.CallTo(() => appImageStore.UploadAsync(appId, stream, A<CancellationToken>.Ignored))
.MustHaveHappened();
}

369
backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/BackupAppsTests.cs

@ -0,0 +1,369 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using FakeItEasy;
using Squidex.Domain.Apps.Entities.Apps.Indexes;
using Squidex.Domain.Apps.Entities.Backup;
using Squidex.Domain.Apps.Events.Apps;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Assets;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Json.Objects;
using Xunit;
#pragma warning disable IDE0067 // Dispose objects before losing scope
namespace Squidex.Domain.Apps.Entities.Apps
{
public class BackupAppsTests
{
private readonly IAppsIndex index = A.Fake<IAppsIndex>();
private readonly IAppUISettings appUISettings = A.Fake<IAppUISettings>();
private readonly IAppImageStore appImageStore = A.Fake<IAppImageStore>();
private readonly Guid appId = Guid.NewGuid();
private readonly RefToken actor = new RefToken(RefTokenType.Subject, "123");
private readonly BackupApps sut;
public BackupAppsTests()
{
sut = new BackupApps(appImageStore, index, appUISettings);
}
[Fact]
public void Should_provide_name()
{
Assert.Equal("Apps", sut.Name);
}
[Fact]
public async Task Should_reserve_app_name()
{
const string appName = "my-app";
var context = CreateRestoreContext();
A.CallTo(() => index.ReserveAsync(appId, appName))
.Returns("Reservation");
await sut.RestoreEventAsync(Envelope.Create(new AppCreated
{
Name = appName
}), context);
A.CallTo(() => index.ReserveAsync(appId, appName))
.MustHaveHappened();
}
[Fact]
public async Task Should_complete_reservation_with_previous_token()
{
const string appName = "my-app";
var context = CreateRestoreContext();
A.CallTo(() => index.ReserveAsync(appId, appName))
.Returns("Reservation");
await sut.RestoreEventAsync(Envelope.Create(new AppCreated
{
Name = appName
}), context);
await sut.CompleteRestoreAsync(context);
A.CallTo(() => index.AddAsync("Reservation"))
.MustHaveHappened();
}
[Fact]
public async Task Should_cleanup_reservation_with_previous_token()
{
const string appName = "my-app";
var context = CreateRestoreContext();
A.CallTo(() => index.ReserveAsync(appId, appName))
.Returns("Reservation");
await sut.RestoreEventAsync(Envelope.Create(new AppCreated
{
Name = appName
}), context);
await sut.CleanupRestoreErrorAsync(appId);
A.CallTo(() => index.RemoveReservationAsync("Reservation"))
.MustHaveHappened();
}
[Fact]
public async Task Should_throw_exception_when_no_reservation_token_returned()
{
const string appName = "my-app";
var context = CreateRestoreContext();
A.CallTo(() => index.ReserveAsync(appId, appName))
.Returns(Task.FromResult<string?>(null));
await Assert.ThrowsAsync<BackupRestoreException>(() =>
{
return sut.RestoreEventAsync(Envelope.Create(new AppCreated
{
Name = appName
}), context);
});
}
[Fact]
public async Task Should_not_cleanup_reservation_when_no_reservation_token_hold()
{
var context = CreateRestoreContext();
await sut.CleanupRestoreErrorAsync(appId);
A.CallTo(() => index.RemoveReservationAsync("Reservation"))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_writer_user_settings()
{
var settings = JsonValue.Object();
var context = CreateBackupContext();
A.CallTo(() => appUISettings.GetAsync(appId, null))
.Returns(settings);
await sut.BackupAsync(context);
A.CallTo(() => context.Writer.WriteJsonAsync(A<string>.Ignored, settings))
.MustHaveHappened();
}
[Fact]
public async Task Should_read_user_settings()
{
var settings = JsonValue.Object();
var context = CreateRestoreContext();
A.CallTo(() => context.Reader.ReadJsonAttachmentAsync<JsonObject>(A<string>.Ignored))
.Returns(settings);
await sut.RestoreAsync(context);
A.CallTo(() => appUISettings.SetAsync(appId, null, settings))
.MustHaveHappened();
}
[Fact]
public async Task Should_map_contributor_id_when_assigned()
{
var context = CreateRestoreContext();
var @event = Envelope.Create(new AppContributorAssigned
{
ContributorId = "found"
});
var result = await sut.RestoreEventAsync(@event, context);
Assert.True(result);
Assert.Equal("found_mapped", @event.Payload.ContributorId);
}
[Fact]
public async Task Should_ignore_contributor_event_when_assigned_user_not_mapped()
{
var context = CreateRestoreContext();
var @event = Envelope.Create(new AppContributorAssigned
{
ContributorId = "unknown"
});
var result = await sut.RestoreEventAsync(@event, context);
Assert.False(result);
Assert.Equal("unknown", @event.Payload.ContributorId);
}
[Fact]
public async Task Should_map_contributor_id_when_revoked()
{
var context = CreateRestoreContext();
var @event = Envelope.Create(new AppContributorRemoved
{
ContributorId = "found"
});
var result = await sut.RestoreEventAsync(@event, context);
Assert.True(result);
Assert.Equal("found_mapped", @event.Payload.ContributorId);
}
[Fact]
public async Task Should_ignore_contributor_event_when_removed_user_not_mapped()
{
var context = CreateRestoreContext();
var @event = Envelope.Create(new AppContributorRemoved
{
ContributorId = "unknown"
});
var result = await sut.RestoreEventAsync(@event, context);
Assert.False(result);
Assert.Equal("unknown", @event.Payload.ContributorId);
}
[Fact]
public async Task Should_ignore_exception_when_app_image_to_backup_does_not_exist()
{
var imageStream = new MemoryStream();
var context = CreateBackupContext();
A.CallTo(() => context.Writer.WriteBlobAsync(A<string>.Ignored, A<Func<Stream, Task>>.Ignored))
.Invokes((string _, Func<Stream, Task> handler) => handler(imageStream));
A.CallTo(() => appImageStore.DownloadAsync(appId, imageStream, default))
.Throws(new AssetNotFoundException("Image"));
await sut.BackupEventAsync(Envelope.Create(new AppImageUploaded()), context);
}
[Fact]
public async Task Should_backup_app_image()
{
var imageStream = new MemoryStream();
var context = CreateBackupContext();
A.CallTo(() => context.Writer.WriteBlobAsync(A<string>.Ignored, A<Func<Stream, Task>>.Ignored))
.Invokes((string _, Func<Stream, Task> handler) => handler(imageStream));
await sut.BackupEventAsync(Envelope.Create(new AppImageUploaded()), context);
A.CallTo(() => appImageStore.DownloadAsync(appId, imageStream, default))
.MustHaveHappened();
}
[Fact]
public async Task Should_restore_app_image()
{
var imageStream = new MemoryStream();
var context = CreateRestoreContext();
A.CallTo(() => context.Reader.ReadBlobAsync(A<string>.Ignored, A<Func<Stream, Task>>.Ignored))
.Invokes((string _, Func<Stream, Task> handler) => handler(imageStream));
await sut.RestoreEventAsync(Envelope.Create(new AppImageUploaded()), context);
A.CallTo(() => appImageStore.UploadAsync(appId, imageStream, default))
.MustHaveHappened();
}
[Fact]
public async Task Should_ignore_exception_when_app_image_cannot_be_overriden()
{
var imageStream = new MemoryStream();
var context = CreateRestoreContext();
A.CallTo(() => context.Reader.ReadBlobAsync(A<string>.Ignored, A<Func<Stream, Task>>.Ignored))
.Invokes((string _, Func<Stream, Task> handler) => handler(imageStream));
A.CallTo(() => appImageStore.UploadAsync(appId, imageStream, default))
.Throws(new AssetAlreadyExistsException("Image"));
await sut.RestoreEventAsync(Envelope.Create(new AppImageUploaded()), context);
}
[Fact]
public async Task Should_restore_indices_for_all_non_deleted_schemas()
{
var userId1 = "found1";
var userId2 = "found2";
var userId3 = "found3";
var context = CreateRestoreContext();
await sut.RestoreEventAsync(Envelope.Create(new AppContributorAssigned
{
ContributorId = userId1
}), context);
await sut.RestoreEventAsync(Envelope.Create(new AppContributorAssigned
{
ContributorId = userId2
}), context);
await sut.RestoreEventAsync(Envelope.Create(new AppContributorAssigned
{
ContributorId = userId3
}), context);
await sut.RestoreEventAsync(Envelope.Create(new AppContributorRemoved
{
ContributorId = userId3
}), context);
HashSet<string>? newIndex = null;
A.CallTo(() => index.RebuildByContributorsAsync(appId, A<HashSet<string>>.Ignored))
.Invokes(new Action<Guid, HashSet<string>>((_, i) => newIndex = i));
await sut.CompleteRestoreAsync(context);
Assert.Equal(new HashSet<string>
{
"found1_mapped",
"found2_mapped",
}, newIndex);
}
private BackupContext CreateBackupContext()
{
return new BackupContext(appId, CreateUserMapping(), A.Fake<IBackupWriter>());
}
private RestoreContext CreateRestoreContext()
{
return new RestoreContext(appId, CreateUserMapping(), A.Fake<IBackupReader>());
}
private IUserMapping CreateUserMapping()
{
var mapping = A.Fake<IUserMapping>();
A.CallTo(() => mapping.Initiator).Returns(actor);
RefToken mapped;
A.CallTo(() => mapping.TryMap(A<string>.That.Matches(x => x.StartsWith("found", StringComparison.OrdinalIgnoreCase)), out mapped))
.Returns(true)
.AssignsOutAndRefParametersLazily(
new Func<string, RefToken, object[]>((x, _) =>
new[] { new RefToken(RefTokenType.Subject, $"{x}_mapped") }));
A.CallTo(() => mapping.TryMap("notfound", out mapped))
.Returns(false);
return mapping;
}
}
}

54
backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DefaultAppImageStoreTests.cs

@ -0,0 +1,54 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using FakeItEasy;
using Squidex.Infrastructure.Assets;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Apps
{
public class DefaultAppImageStoreTests
{
private readonly IAssetStore assetStore = A.Fake<IAssetStore>();
private readonly Guid appId = Guid.NewGuid();
private readonly string fileName;
private readonly DefaultAppImageStore sut;
public DefaultAppImageStoreTests()
{
fileName = appId.ToString();
sut = new DefaultAppImageStore(assetStore);
}
[Fact]
public async Task Should_invoke_asset_store_to_upload_archive()
{
var stream = new MemoryStream();
await sut.UploadAsync(appId, stream);
A.CallTo(() => assetStore.UploadAsync(fileName, stream, true, CancellationToken.None))
.MustHaveHappened();
}
[Fact]
public async Task Should_invoke_asset_store_to_download_archive()
{
var stream = new MemoryStream();
await sut.DownloadAsync(appId, stream);
A.CallTo(() => assetStore.DownloadAsync(fileName, stream, CancellationToken.None))
.MustHaveHappened();
}
}
}

4
backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppContributorsTests.cs

@ -83,7 +83,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards
[Fact]
public async Task CanAssign_should_not_throw_exception_if_user_already_exists_with_some_role_but_is_from_restore()
{
var command = new AssignContributor { ContributorId = "1", Role = Role.Owner, IsRestore = true };
var command = new AssignContributor { ContributorId = "1", Role = Role.Owner, Restoring = true };
var contributors_1 = contributors_0.Assign("1", Role.Owner);
@ -172,7 +172,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards
A.CallTo(() => appPlan.MaxContributors)
.Returns(2);
var command = new AssignContributor { ContributorId = "3", IsRestore = true };
var command = new AssignContributor { ContributorId = "3", Restoring = true };
var contributors_1 = contributors_0.Assign("1", Role.Editor);
var contributors_2 = contributors_1.Assign("2", Role.Editor);

12
backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Invitation/InviteUserCommandMiddlewareTests.cs

@ -36,14 +36,14 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation
new CommandContext(new AssignContributor { ContributorId = "me@email.com", Invite = true }, commandBus)
.Complete(app);
A.CallTo(() => userResolver.CreateUserIfNotExists("me@email.com", true))
A.CallTo(() => userResolver.CreateUserIfNotExistsAsync("me@email.com", true))
.Returns(true);
await sut.HandleAsync(context);
Assert.Same(context.Result<InvitedResult>().App, app);
A.CallTo(() => userResolver.CreateUserIfNotExists("me@email.com", true))
A.CallTo(() => userResolver.CreateUserIfNotExistsAsync("me@email.com", true))
.MustHaveHappened();
}
@ -54,14 +54,14 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation
new CommandContext(new AssignContributor { ContributorId = "me@email.com", Invite = true }, commandBus)
.Complete(app);
A.CallTo(() => userResolver.CreateUserIfNotExists("me@email.com", true))
A.CallTo(() => userResolver.CreateUserIfNotExistsAsync("me@email.com", true))
.Returns(false);
await sut.HandleAsync(context);
Assert.Same(context.Result<IAppEntity>(), app);
A.CallTo(() => userResolver.CreateUserIfNotExists("me@email.com", true))
A.CallTo(() => userResolver.CreateUserIfNotExistsAsync("me@email.com", true))
.MustHaveHappened();
}
@ -74,7 +74,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation
await sut.HandleAsync(context);
A.CallTo(() => userResolver.CreateUserIfNotExists(A<string>.Ignored, A<bool>.Ignored))
A.CallTo(() => userResolver.CreateUserIfNotExistsAsync(A<string>.Ignored, A<bool>.Ignored))
.MustNotHaveHappened();
}
@ -87,7 +87,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation
await sut.HandleAsync(context);
A.CallTo(() => userResolver.CreateUserIfNotExists(A<string>.Ignored, A<bool>.Ignored))
A.CallTo(() => userResolver.CreateUserIfNotExistsAsync(A<string>.Ignored, A<bool>.Ignored))
.MustNotHaveHappened();
}

25
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCommandMiddlewareTests.cs

@ -29,14 +29,14 @@ namespace Squidex.Domain.Apps.Entities.Assets
{
public class AssetCommandMiddlewareTests : HandlerTestBase<AssetState>
{
private readonly IAssetQueryService assetQuery = A.Fake<IAssetQueryService>();
private readonly IAssetEnricher assetEnricher = A.Fake<IAssetEnricher>();
private readonly IAssetFileStore assetFileStore = A.Fake<IAssetFileStore>();
private readonly IAssetQueryService assetQuery = A.Fake<IAssetQueryService>();
private readonly IAssetThumbnailGenerator assetThumbnailGenerator = A.Fake<IAssetThumbnailGenerator>();
private readonly IAssetStore assetStore = A.Fake<MemoryAssetStore>();
private readonly IContextProvider contextProvider = A.Fake<IContextProvider>();
private readonly ITagService tagService = A.Fake<ITagService>();
private readonly ITagGenerator<CreateAsset> tagGenerator = A.Fake<ITagGenerator<CreateAsset>>();
private readonly IGrainFactory grainFactory = A.Fake<IGrainFactory>();
private readonly ITagGenerator<CreateAsset> tagGenerator = A.Fake<ITagGenerator<CreateAsset>>();
private readonly ITagService tagService = A.Fake<ITagService>();
private readonly Guid assetId = Guid.NewGuid();
private readonly Stream stream = new MemoryStream();
private readonly ImageInfo image = new ImageInfo(2048, 2048);
@ -79,7 +79,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
sut = new AssetCommandMiddleware(grainFactory,
assetEnricher,
assetQuery,
assetStore,
assetFileStore,
assetThumbnailGenerator,
contextProvider, new[] { tagGenerator });
}
@ -147,6 +147,9 @@ namespace Squidex.Domain.Apps.Entities.Assets
var result = context.Result<AssetCreatedResult>();
result.Asset.Should().BeEquivalentTo(asset.Snapshot, x => x.ExcludingMissingMembers());
AssertAssetHasBeenUploaded(0);
AssertAssetImageChecked();
}
[Fact]
@ -230,7 +233,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
await sut.HandleAsync(context);
AssertAssetHasBeenUploaded(1, context.ContextId);
AssertAssetHasBeenUploaded(1);
AssertAssetImageChecked();
}
@ -282,15 +285,13 @@ namespace Squidex.Domain.Apps.Entities.Assets
return asset.ExecuteAsync(CreateCommand(new CreateAsset { AssetId = Id, File = file }));
}
private void AssertAssetHasBeenUploaded(long version, Guid commitId)
private void AssertAssetHasBeenUploaded(long version)
{
var fileName = AssetStoreExtensions.GetFileName(assetId.ToString(), version);
A.CallTo(() => assetStore.UploadAsync(commitId.ToString(), A<HasherStream>.Ignored, false, CancellationToken.None))
A.CallTo(() => assetFileStore.UploadAsync(A<string>.Ignored, A<HasherStream>.Ignored, CancellationToken.None))
.MustHaveHappened();
A.CallTo(() => assetStore.CopyAsync(commitId.ToString(), fileName, CancellationToken.None))
A.CallTo(() => assetFileStore.CopyAsync(A<string>.Ignored, Id, version, CancellationToken.None))
.MustHaveHappened();
A.CallTo(() => assetStore.DeleteAsync(commitId.ToString()))
A.CallTo(() => assetFileStore.DeleteAsync(A<string>.Ignored))
.MustHaveHappened();
}

256
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/BackupAssetsTests.cs

@ -0,0 +1,256 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using FakeItEasy;
using Squidex.Domain.Apps.Core.Tags;
using Squidex.Domain.Apps.Entities.Assets.State;
using Squidex.Domain.Apps.Entities.Backup;
using Squidex.Domain.Apps.Events.Assets;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Tasks;
using Xunit;
#pragma warning disable IDE0067 // Dispose objects before losing scope
namespace Squidex.Domain.Apps.Entities.Assets
{
public class BackupAssetsTests
{
private readonly Rebuilder rebuilder = A.Fake<Rebuilder>();
private readonly IAssetFileStore assetFileStore = A.Fake<IAssetFileStore>();
private readonly ITagService tagService = A.Fake<ITagService>();
private readonly Guid appId = Guid.NewGuid();
private readonly RefToken actor = new RefToken(RefTokenType.Subject, "123");
private readonly BackupAssets sut;
public BackupAssetsTests()
{
sut = new BackupAssets(rebuilder, assetFileStore, tagService);
}
[Fact]
public void Should_provide_name()
{
Assert.Equal("Assets", sut.Name);
}
[Fact]
public async Task Should_writer_tags()
{
var tags = new TagsExport();
var context = CreateBackupContext();
A.CallTo(() => tagService.GetExportableTagsAsync(appId, TagGroups.Assets))
.Returns(tags);
await sut.BackupAsync(context);
A.CallTo(() => context.Writer.WriteJsonAsync(A<string>.Ignored, tags))
.MustHaveHappened();
}
[Fact]
public async Task Should_read_tags()
{
var tags = new TagsExport();
var context = CreateRestoreContext();
A.CallTo(() => context.Reader.ReadJsonAttachmentAsync<TagsExport>(A<string>.Ignored))
.Returns(tags);
await sut.RestoreAsync(context);
A.CallTo(() => tagService.RebuildTagsAsync(appId, TagGroups.Assets, tags))
.MustHaveHappened();
}
[Fact]
public async Task Should_backup_created_asset()
{
var @event = new AssetCreated { AssetId = Guid.NewGuid() };
await TestBackupEventAsync(@event, 0);
}
[Fact]
public async Task Should_backup_updated_asset()
{
var @event = new AssetUpdated { AssetId = Guid.NewGuid(), FileVersion = 3 };
await TestBackupEventAsync(@event, @event.FileVersion);
}
private async Task TestBackupEventAsync(AssetEvent @event, long version)
{
var assetStream = new MemoryStream();
var assetId = @event.AssetId;
var context = CreateBackupContext();
A.CallTo(() => context.Writer.WriteBlobAsync($"{assetId}_{version}.asset", A<Func<Stream, Task>>.Ignored))
.Invokes((string _, Func<Stream, Task> handler) => handler(assetStream));
await sut.BackupEventAsync(Envelope.Create(@event), context);
A.CallTo(() => assetFileStore.DownloadAsync(assetId, version, assetStream, default))
.MustHaveHappened();
}
[Fact]
public async Task Should_restore_created_asset()
{
var @event = new AssetCreated { AssetId = Guid.NewGuid() };
await TestRestoreAsync(@event, 0);
}
[Fact]
public async Task Should_restore_updated_asset()
{
var @event = new AssetUpdated { AssetId = Guid.NewGuid(), FileVersion = 3 };
await TestRestoreAsync(@event, @event.FileVersion);
}
private async Task TestRestoreAsync(AssetEvent @event, long version)
{
var oldId = Guid.NewGuid();
var assetStream = new MemoryStream();
var assetId = @event.AssetId;
var context = CreateRestoreContext();
A.CallTo(() => context.Reader.OldGuid(assetId))
.Returns(oldId);
A.CallTo(() => context.Reader.ReadBlobAsync($"{oldId}_{version}.asset", A<Func<Stream, Task>>.Ignored))
.Invokes((string _, Func<Stream, Task> handler) => handler(assetStream));
await sut.RestoreEventAsync(Envelope.Create(@event), context);
A.CallTo(() => assetFileStore.UploadAsync(assetId, version, assetStream, default))
.MustHaveHappened();
}
[Fact]
public async Task Should_restore_states_for_all_assets()
{
var assetId1 = Guid.NewGuid();
var assetId2 = Guid.NewGuid();
var context = CreateRestoreContext();
await sut.RestoreEventAsync(Envelope.Create(new AssetCreated
{
AssetId = assetId1
}), context);
await sut.RestoreEventAsync(Envelope.Create(new AssetCreated
{
AssetId = assetId2
}), context);
await sut.RestoreEventAsync(Envelope.Create(new AssetDeleted
{
AssetId = assetId2
}), context);
var rebuildAssets = new HashSet<Guid>();
var add = new Func<Guid, Task>(id =>
{
rebuildAssets.Add(id);
return TaskHelper.Done;
});
A.CallTo(() => rebuilder.InsertManyAsync<AssetState, AssetGrain>(A<IdSource>.Ignored, A<CancellationToken>.Ignored))
.Invokes((IdSource source, CancellationToken _) => source(add));
await sut.RestoreAsync(context);
Assert.Equal(new HashSet<Guid>
{
assetId1,
assetId2
}, rebuildAssets);
}
[Fact]
public async Task Should_restore_states_for_all_asset_folders()
{
var assetFolderId1 = Guid.NewGuid();
var assetFolderId2 = Guid.NewGuid();
var context = CreateRestoreContext();
await sut.RestoreEventAsync(Envelope.Create(new AssetFolderCreated
{
AssetFolderId = assetFolderId1
}), context);
await sut.RestoreEventAsync(Envelope.Create(new AssetFolderCreated
{
AssetFolderId = assetFolderId2
}), context);
await sut.RestoreEventAsync(Envelope.Create(new AssetFolderDeleted
{
AssetFolderId = assetFolderId2
}), context);
var rebuildAssets = new HashSet<Guid>();
var add = new Func<Guid, Task>(id =>
{
rebuildAssets.Add(id);
return TaskHelper.Done;
});
A.CallTo(() => rebuilder.InsertManyAsync<AssetFolderState, AssetFolderGrain>(A<IdSource>.Ignored, A<CancellationToken>.Ignored))
.Invokes((IdSource source, CancellationToken _) => source(add));
await sut.RestoreAsync(context);
Assert.Equal(new HashSet<Guid>
{
assetFolderId1,
assetFolderId2
}, rebuildAssets);
}
private BackupContext CreateBackupContext()
{
return new BackupContext(appId, CreateUserMapping(), A.Fake<IBackupWriter>());
}
private RestoreContext CreateRestoreContext()
{
return new RestoreContext(appId, CreateUserMapping(), A.Fake<IBackupReader>());
}
private IUserMapping CreateUserMapping()
{
var mapping = A.Fake<IUserMapping>();
A.CallTo(() => mapping.Initiator).Returns(actor);
return mapping;
}
}
}

106
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/DefaultAssetFileStoreTests.cs

@ -0,0 +1,106 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using FakeItEasy;
using Squidex.Infrastructure.Assets;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Assets
{
public class DefaultAssetFileStoreTests
{
private readonly IAssetStore assetStore = A.Fake<IAssetStore>();
private readonly Guid assetId = Guid.NewGuid();
private readonly long assetFileVersion = 21;
private readonly string fileName;
private readonly DefaultAssetFileStore sut;
public DefaultAssetFileStoreTests()
{
fileName = $"{assetId}_{assetFileVersion}";
sut = new DefaultAssetFileStore(assetStore);
}
[Fact]
public void Should_invoke_asset_store_to_generate_public_url()
{
var url = "http_//squidex.io/assets";
A.CallTo(() => assetStore.GeneratePublicUrl(fileName))
.Returns(url);
var result = sut.GeneratePublicUrl(assetId, assetFileVersion);
Assert.Equal(url, result);
}
[Fact]
public async Task Should_invoke_asset_store_to_temporary_upload_file()
{
var stream = new MemoryStream();
await sut.UploadAsync("Temp", stream);
A.CallTo(() => assetStore.UploadAsync("Temp", stream, false, CancellationToken.None))
.MustHaveHappened();
}
[Fact]
public async Task Should_invoke_asset_store_to_upload_file()
{
var stream = new MemoryStream();
await sut.UploadAsync(assetId, assetFileVersion, stream);
A.CallTo(() => assetStore.UploadAsync(fileName, stream, true, CancellationToken.None))
.MustHaveHappened();
}
[Fact]
public async Task Should_invoke_asset_store_to_download_file()
{
var stream = new MemoryStream();
await sut.DownloadAsync(assetId, assetFileVersion, stream);
A.CallTo(() => assetStore.DownloadAsync(fileName, stream, CancellationToken.None))
.MustHaveHappened();
}
[Fact]
public async Task Should_invoke_asset_store_to_copy_from_temporary_file()
{
await sut.CopyAsync("Temp", assetId, assetFileVersion);
A.CallTo(() => assetStore.CopyAsync("Temp", fileName, CancellationToken.None))
.MustHaveHappened();
}
[Fact]
public async Task Should_invoke_asset_store_to_delete_temporary_file()
{
await sut.DeleteAsync("Temp");
A.CallTo(() => assetStore.DeleteAsync("Temp"))
.MustHaveHappened();
}
[Fact]
public async Task Should_invoke_asset_store_to_delete_file()
{
await sut.DeleteAsync(assetId, assetFileVersion);
A.CallTo(() => assetStore.DeleteAsync(fileName))
.MustHaveHappened();
}
}
}

16
backend/tests/Squidex.Domain.Apps.Entities.Tests/Backup/BackupReaderWriterTests.cs

@ -34,6 +34,8 @@ namespace Squidex.Domain.Apps.Entities.Backup
{
public Guid GuidRaw { get; set; }
public Guid GuidEmpty { get; set; }
public NamedId<Guid> GuidNamed { get; set; }
public Dictionary<Guid, string> Values { get; set; }
@ -158,14 +160,16 @@ namespace Squidex.Domain.Apps.Entities.Backup
for (var i = 0; i < targetEvents.Count; i++)
{
var source = targetEvents[i].Event.To<MyEvent>();
var target = targetEvents[i].Event.To<MyEvent>();
var source = sourceEvents[i].Event.To<MyEvent>();
var target = sourceEvents[i].Event.To<MyEvent>();
CompareGuid(source.Payload.Values.First().Key, target.Payload.Values.First().Key);
CompareGuid(source.Payload.GuidRaw, target.Payload.GuidRaw);
CompareGuid(source.Payload.GuidNamed.Id, target.Payload.GuidNamed.Id);
CompareGuid(source.Headers.GetGuid("Id"), target.Headers.GetGuid("Id"));
CompareGuid(target.Payload.Values.First().Key, source.Payload.Values.First().Key);
CompareGuid(target.Payload.GuidRaw, source.Payload.GuidRaw);
CompareGuid(target.Payload.GuidNamed.Id, source.Payload.GuidNamed.Id);
CompareGuid(target.Headers.GetGuid("Id"), source.Headers.GetGuid("Id"));
Assert.Equal(Guid.Empty, target.Payload.GuidEmpty);
}
}
}

142
backend/tests/Squidex.Domain.Apps.Entities.Tests/Backup/BackupServiceTests.cs

@ -0,0 +1,142 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using FakeItEasy;
using Orleans;
using Squidex.Domain.Apps.Entities.Backup.State;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Orleans;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Backup
{
public class BackupServiceTests
{
private readonly IGrainFactory grainFactory = A.Fake<IGrainFactory>();
private readonly Guid appId = Guid.NewGuid();
private readonly Guid backupId = Guid.NewGuid();
private readonly RefToken actor = new RefToken(RefTokenType.Subject, "me");
private readonly BackupService sut;
public BackupServiceTests()
{
sut = new BackupService(grainFactory);
}
[Fact]
public async Task Should_call_grain_when_restoring_backup()
{
var grain = A.Fake<IRestoreGrain>();
A.CallTo(() => grainFactory.GetGrain<IRestoreGrain>(SingleGrain.Id, null))
.Returns(grain);
var initiator = new RefToken(RefTokenType.Subject, "me");
var restoreUrl = new Uri("http://squidex.io");
var restoreAppName = "New App";
await sut.StartRestoreAsync(initiator, restoreUrl, restoreAppName);
A.CallTo(() => grain.RestoreAsync(restoreUrl, initiator, restoreAppName))
.MustHaveHappened();
}
[Fact]
public async Task Should_call_grain_to_get_restore_status()
{
IRestoreJob state = new RestoreJob();
var grain = A.Fake<IRestoreGrain>();
A.CallTo(() => grainFactory.GetGrain<IRestoreGrain>(SingleGrain.Id, null))
.Returns(grain);
A.CallTo(() => grain.GetStateAsync())
.Returns(state.AsJ());
var result = await sut.GetRestoreAsync();
Assert.Same(state, result);
}
[Fact]
public async Task Should_call_grain_to_start_backup()
{
var grain = A.Fake<IBackupGrain>();
A.CallTo(() => grainFactory.GetGrain<IBackupGrain>(appId, null))
.Returns(grain);
await sut.StartBackupAsync(appId, actor);
A.CallTo(() => grain.BackupAsync(actor))
.MustHaveHappened();
}
[Fact]
public async Task Should_call_grain_to_get_backups()
{
var state = new List<IBackupJob>
{
new BackupJob { Id = backupId }
};
var grain = A.Fake<IBackupGrain>();
A.CallTo(() => grainFactory.GetGrain<IBackupGrain>(appId, null))
.Returns(grain);
A.CallTo(() => grain.GetStateAsync())
.Returns(state.AsJ());
var result = await sut.GetBackupsAsync(appId);
Assert.Same(state, result);
}
[Fact]
public async Task Should_call_grain_to_get_backup()
{
var state = new List<IBackupJob>
{
new BackupJob { Id = backupId }
};
var grain = A.Fake<IBackupGrain>();
A.CallTo(() => grainFactory.GetGrain<IBackupGrain>(appId, null))
.Returns(grain);
A.CallTo(() => grain.GetStateAsync())
.Returns(state.AsJ());
var result1 = await sut.GetBackupAsync(appId, backupId);
var result2 = await sut.GetBackupAsync(appId, Guid.NewGuid());
Assert.Same(state[0], result1);
Assert.Null(result2);
}
[Fact]
public async Task Should_call_grain_to_delete_backup()
{
var grain = A.Fake<IBackupGrain>();
A.CallTo(() => grainFactory.GetGrain<IBackupGrain>(appId, null))
.Returns(grain);
await sut.DeleteBackupAsync(appId, backupId);
A.CallTo(() => grain.DeleteAsync(backupId))
.MustHaveHappened();
}
}
}

63
backend/tests/Squidex.Domain.Apps.Entities.Tests/Backup/DefaultBackupArchiveStoreTests.cs

@ -0,0 +1,63 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using FakeItEasy;
using Squidex.Infrastructure.Assets;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Backup
{
public class DefaultBackupArchiveStoreTests
{
private readonly IAssetStore assetStore = A.Fake<IAssetStore>();
private readonly Guid backupId = Guid.NewGuid();
private readonly string fileName;
private readonly DefaultBackupArchiveStore sut;
public DefaultBackupArchiveStoreTests()
{
fileName = $"{backupId}_0";
sut = new DefaultBackupArchiveStore(assetStore);
}
[Fact]
public async Task Should_invoke_asset_store_to_upload_archive_using_suffix_for_compatibility()
{
var stream = new MemoryStream();
await sut.UploadAsync(backupId, stream);
A.CallTo(() => assetStore.UploadAsync(fileName, stream, true, CancellationToken.None))
.MustHaveHappened();
}
[Fact]
public async Task Should_invoke_asset_store_to_download_archive_using_suffix_for_compatibility()
{
var stream = new MemoryStream();
await sut.DownloadAsync(backupId, stream);
A.CallTo(() => assetStore.DownloadAsync(fileName, stream, CancellationToken.None))
.MustHaveHappened();
}
[Fact]
public async Task Should_invoke_asset_store_to_delete_archive_using_suffix_for_compatibility()
{
await sut.DeleteAsync(backupId);
A.CallTo(() => assetStore.DeleteAsync(fileName))
.MustHaveHappened();
}
}
}

160
backend/tests/Squidex.Domain.Apps.Entities.Tests/Backup/UserMappingTests.cs

@ -0,0 +1,160 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using FakeItEasy;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure;
using Squidex.Shared.Users;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Backup
{
public class UserMappingTests
{
private readonly RefToken initiator = Subject("me");
private readonly UserMapping sut;
public UserMappingTests()
{
sut = new UserMapping(initiator);
}
[Fact]
public async Task Should_backup_users_but_no_clients()
{
sut.Backup("user1");
sut.Backup(Subject("user2"));
sut.Backup(Client("client"));
var user1 = CreateUser("user1", "mail1@squidex.io");
var user2 = CreateUser("user2", "mail2@squidex.io");
var users = new Dictionary<string, IUser>
{
[user1.Id] = user1,
[user2.Id] = user2
};
var userResolver = A.Fake<IUserResolver>();
A.CallTo(() => userResolver.QueryManyAsync(A<string[]>.That.Is(user1.Id, user2.Id)))
.Returns(users);
var writer = A.Fake<IBackupWriter>();
Dictionary<string, string>? storedUsers = null;
A.CallTo(() => writer.WriteJsonAsync(A<string>.Ignored, A<object>.Ignored))
.Invokes((string _, object json) => storedUsers = (Dictionary<string, string>)json);
await sut.StoreAsync(writer, userResolver);
Assert.Equal(new Dictionary<string, string>
{
[user1.Id] = user1.Email,
[user2.Id] = user2.Email
}, storedUsers);
}
[Fact]
public async Task Should_restore_users()
{
var user1 = CreateUser("user1", "mail1@squidex.io");
var user2 = CreateUser("user2", "mail2@squidex.io");
var reader = SetupReader(user1, user2);
var userResolver = A.Fake<IUserResolver>();
A.CallTo(() => userResolver.FindByIdOrEmailAsync(user1.Email))
.Returns(user1);
A.CallTo(() => userResolver.FindByIdOrEmailAsync(user2.Email))
.Returns(user2);
await sut.RestoreAsync(reader, userResolver);
Assert.True(sut.TryMap("user1_old", out var mapped1));
Assert.Equal(Subject("user1"), mapped1);
Assert.True(sut.TryMap(Subject("user2_old"), out var mapped2));
Assert.Equal(Subject("user2"), mapped2);
}
[Fact]
public async Task Should_create_user_if_not_found()
{
var user = CreateUser("newId1", "mail1@squidex.io");
var reader = SetupReader(user);
var userResolver = A.Fake<IUserResolver>();
A.CallTo(() => userResolver.FindByIdOrEmailAsync(user.Email))
.Returns(Task.FromResult<IUser?>(null));
await sut.RestoreAsync(reader, userResolver);
A.CallTo(() => userResolver.CreateUserIfNotExistsAsync(user.Email, false))
.MustHaveHappened();
}
[Fact]
public void Should_return_initiator_if_user_not_found()
{
var user = Subject("user1");
Assert.False(sut.TryMap(user, out var mapped));
Assert.Same(initiator, mapped);
}
[Fact]
public void Should_create_same_token_if_mapping_client()
{
var client = Client("client1");
Assert.True(sut.TryMap(client, out var mapped));
Assert.Same(client, mapped);
}
private IUser CreateUser(string id, string email)
{
var user = A.Fake<IUser>();
A.CallTo(() => user.Id).Returns(id);
A.CallTo(() => user.Email).Returns(email);
return user;
}
private static IBackupReader SetupReader(params IUser[] users)
{
var storedUsers = users.ToDictionary(x => $"{x.Id}_old", x => x.Email);
var reader = A.Fake<IBackupReader>();
A.CallTo(() => reader.ReadJsonAttachmentAsync<Dictionary<string, string>>(A<string>.Ignored))
.Returns(storedUsers);
return reader;
}
private static RefToken Client(string identifier)
{
return new RefToken(RefTokenType.Client, identifier);
}
private static RefToken Subject(string identifier)
{
return new RefToken(RefTokenType.Subject, identifier);
}
}
}

105
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/BackupContentsTests.cs

@ -0,0 +1,105 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using FakeItEasy;
using Squidex.Domain.Apps.Entities.Backup;
using Squidex.Domain.Apps.Entities.Contents.State;
using Squidex.Domain.Apps.Events.Contents;
using Squidex.Domain.Apps.Events.Schemas;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Tasks;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Contents
{
public class BackupContentsTests
{
private readonly Rebuilder rebuilder = A.Fake<Rebuilder>();
private readonly BackupContents sut;
public BackupContentsTests()
{
sut = new BackupContents(rebuilder);
}
[Fact]
public void Should_provide_name()
{
Assert.Equal("Contents", sut.Name);
}
[Fact]
public async Task Should_restore_states_for_all_contents()
{
var appId = Guid.NewGuid();
var schemaId1 = NamedId.Of(Guid.NewGuid(), "my-schema1");
var schemaId2 = NamedId.Of(Guid.NewGuid(), "my-schema2");
var contentId1 = Guid.NewGuid();
var contentId2 = Guid.NewGuid();
var contentId3 = Guid.NewGuid();
var context = new RestoreContext(appId, new UserMapping(new RefToken(RefTokenType.Subject, "123")), A.Fake<IBackupReader>());
await sut.RestoreEventAsync(Envelope.Create(new ContentCreated
{
ContentId = contentId1,
SchemaId = schemaId1
}), context);
await sut.RestoreEventAsync(Envelope.Create(new ContentCreated
{
ContentId = contentId2,
SchemaId = schemaId1
}), context);
await sut.RestoreEventAsync(Envelope.Create(new ContentCreated
{
ContentId = contentId3,
SchemaId = schemaId2
}), context);
await sut.RestoreEventAsync(Envelope.Create(new ContentDeleted
{
ContentId = contentId2,
SchemaId = schemaId1
}), context);
await sut.RestoreEventAsync(Envelope.Create(new SchemaDeleted
{
SchemaId = schemaId2
}), context);
var rebuildContents = new HashSet<Guid>();
var add = new Func<Guid, Task>(id =>
{
rebuildContents.Add(id);
return TaskHelper.Done;
});
A.CallTo(() => rebuilder.InsertManyAsync<ContentState, ContentGrain>(A<IdSource>.Ignored, A<CancellationToken>.Ignored))
.Invokes((IdSource source, CancellationToken _) => source(add));
await sut.RestoreAsync(context);
Assert.Equal(new HashSet<Guid>
{
contentId1,
contentId2
}, rebuildContents);
}
}
}

2
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeUrlGenerator.cs

@ -26,7 +26,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.TestData
return $"assets/{asset.Id}?width=100";
}
public string GenerateAssetSourceUrl(IAppEntity app, IAssetEntity asset)
public string GenerateAssetSourceUrl(IAssetEntity asset)
{
return $"assets/source/{asset.Id}";
}

82
backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/BackupRulesTests.cs

@ -0,0 +1,82 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using FakeItEasy;
using Squidex.Domain.Apps.Entities.Backup;
using Squidex.Domain.Apps.Entities.Rules.Indexes;
using Squidex.Domain.Apps.Events.Rules;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Rules
{
public class BackupRulesTests
{
private readonly IRulesIndex index = A.Fake<IRulesIndex>();
private readonly BackupRules sut;
public BackupRulesTests()
{
sut = new BackupRules(index);
}
[Fact]
public void Should_provide_name()
{
Assert.Equal("Rules", sut.Name);
}
[Fact]
public async Task Should_restore_indices_for_all_non_deleted_rules()
{
var appId = Guid.NewGuid();
var ruleId1 = Guid.NewGuid();
var ruleId2 = Guid.NewGuid();
var ruleId3 = Guid.NewGuid();
var context = new RestoreContext(appId, new UserMapping(new RefToken(RefTokenType.Subject, "123")), A.Fake<IBackupReader>());
await sut.RestoreEventAsync(Envelope.Create(new RuleCreated
{
RuleId = ruleId1
}), context);
await sut.RestoreEventAsync(Envelope.Create(new RuleCreated
{
RuleId = ruleId2
}), context);
await sut.RestoreEventAsync(Envelope.Create(new RuleCreated
{
RuleId = ruleId3
}), context);
await sut.RestoreEventAsync(Envelope.Create(new RuleDeleted
{
RuleId = ruleId3
}), context);
HashSet<Guid>? newIndex = null;
A.CallTo(() => index.RebuildAsync(appId, A<HashSet<Guid>>.Ignored))
.Invokes(new Action<Guid, HashSet<Guid>>((_, i) => newIndex = i));
await sut.RestoreAsync(context);
Assert.Equal(new HashSet<Guid>
{
ruleId1,
ruleId2,
}, newIndex);
}
}
}

82
backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/BackupSchemasTests.cs

@ -0,0 +1,82 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using FakeItEasy;
using Squidex.Domain.Apps.Entities.Backup;
using Squidex.Domain.Apps.Entities.Schemas.Indexes;
using Squidex.Domain.Apps.Events.Schemas;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Schemas
{
public class BackupSchemasTests
{
private readonly ISchemasIndex index = A.Fake<ISchemasIndex>();
private readonly BackupSchemas sut;
public BackupSchemasTests()
{
sut = new BackupSchemas(index);
}
[Fact]
public void Should_provide_name()
{
Assert.Equal("Schemas", sut.Name);
}
[Fact]
public async Task Should_restore_indices_for_all_non_deleted_schemas()
{
var appId = Guid.NewGuid();
var schemaId1 = NamedId.Of(Guid.NewGuid(), "my-schema1");
var schemaId2 = NamedId.Of(Guid.NewGuid(), "my-schema2");
var schemaId3 = NamedId.Of(Guid.NewGuid(), "my-schema3");
var context = new RestoreContext(appId, new UserMapping(new RefToken(RefTokenType.Subject, "123")), A.Fake<IBackupReader>());
await sut.RestoreEventAsync(Envelope.Create(new SchemaCreated
{
SchemaId = schemaId1
}), context);
await sut.RestoreEventAsync(Envelope.Create(new SchemaCreated
{
SchemaId = schemaId2
}), context);
await sut.RestoreEventAsync(Envelope.Create(new SchemaCreated
{
SchemaId = schemaId3
}), context);
await sut.RestoreEventAsync(Envelope.Create(new SchemaDeleted
{
SchemaId = schemaId3
}), context);
Dictionary<string, Guid>? newIndex = null;
A.CallTo(() => index.RebuildAsync(appId, A<Dictionary<string, Guid>>.Ignored))
.Invokes(new Action<Guid, Dictionary<string, Guid>>((_, i) => newIndex = i));
await sut.RestoreAsync(context);
Assert.Equal(new Dictionary<string, Guid>
{
[schemaId1.Name] = schemaId1.Id,
[schemaId2.Name] = schemaId2.Id
}, newIndex);
}
}
}

10
backend/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/HandlerTestBase.cs

@ -67,16 +67,10 @@ namespace Squidex.Domain.Apps.Entities.TestHelpers
.Returns(persistence2);
A.CallTo(() => persistence1.WriteEventsAsync(A<IEnumerable<Envelope<IEvent>>>.Ignored))
.Invokes(new Action<IEnumerable<Envelope<IEvent>>>(events =>
{
LastEvents = events;
}));
.Invokes((IEnumerable<Envelope<IEvent>> events) => LastEvents = events);
A.CallTo(() => persistence2.WriteEventsAsync(A<IEnumerable<Envelope<IEvent>>>.Ignored))
.Invokes(new Action<IEnumerable<Envelope<IEvent>>>(events =>
{
LastEvents = events;
}));
.Invokes((IEnumerable<Envelope<IEvent>> events) => LastEvents = events);
}
protected CommandContext CreateContextForCommand<TCommand>(TCommand command) where TCommand : SquidexCommand

31
backend/tests/Squidex.Domain.Users.Tests/AssetUserPictureStoreTests.cs → backend/tests/Squidex.Domain.Users.Tests/DefaultUserPictureStoreTests.cs

@ -13,26 +13,24 @@ using FakeItEasy;
using Squidex.Infrastructure.Assets;
using Xunit;
#pragma warning disable RECS0165 // Asynchronous methods should return a Task instead of void
namespace Squidex.Domain.Users
{
public class AssetUserPictureStoreTests
public class DefaultUserPictureStoreTests
{
private readonly IAssetStore assetStore = A.Fake<IAssetStore>();
private readonly AssetUserPictureStore sut;
private readonly DefaultUserPictureStore sut;
private readonly string userId = Guid.NewGuid().ToString();
private readonly string file;
public AssetUserPictureStoreTests()
public DefaultUserPictureStoreTests()
{
file = AssetStoreExtensions.GetFileName(userId, 0, "picture");
file = $"{userId}_0_picture";
sut = new AssetUserPictureStore(assetStore);
sut = new DefaultUserPictureStore(assetStore);
}
[Fact]
public async Task Should_invoke_asset_store_to_upload_picture()
public async Task Should_invoke_asset_store_to_upload_picture_using_suffix_for_compatibility()
{
var stream = new MemoryStream();
@ -43,22 +41,13 @@ namespace Squidex.Domain.Users
}
[Fact]
public async Task Should_invoke_asset_store_to_download_picture()
{
A.CallTo(() => assetStore.DownloadAsync(file, A<Stream>.Ignored, CancellationToken.None))
.Invokes(async call =>
public async Task Should_invoke_asset_store_to_download_picture_using_suffix_for_compatibility()
{
var stream = call.GetArgument<Stream>(1);
await stream.WriteAsync(new byte[] { 1, 2, 3, 4 }, 0, 4);
});
var result = await sut.DownloadAsync(userId);
var stream = new MemoryStream();
Assert.Equal(0, result.Position);
Assert.Equal(4, result.Length);
await sut.DownloadAsync(userId, stream);
A.CallTo(() => assetStore.DownloadAsync(file, A<Stream>.Ignored, CancellationToken.None))
A.CallTo(() => assetStore.DownloadAsync(file, stream, CancellationToken.None))
.MustHaveHappened();
}
}

112
backend/tests/Squidex.Infrastructure.Tests/Assets/AssetExtensionTests.cs

@ -1,112 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.IO;
using FakeItEasy;
using Xunit;
namespace Squidex.Infrastructure.Assets
{
public class AssetExtensionTests
{
private readonly IAssetStore sut = A.Fake<IAssetStore>();
private readonly Guid id = Guid.NewGuid();
private readonly Stream stream = new MemoryStream();
private readonly string fileName = Guid.NewGuid().ToString();
[Fact]
public void Should_copy_with_id_and_version()
{
sut.CopyAsync(fileName, id, 1, string.Empty);
A.CallTo(() => sut.CopyAsync(fileName, $"{id}_1", default))
.MustHaveHappened();
}
[Fact]
public void Should_copy_with_id_and_version_and_suffix()
{
sut.CopyAsync(fileName, id, 1, "Crop");
A.CallTo(() => sut.CopyAsync(fileName, $"{id}_1_Crop", default))
.MustHaveHappened();
}
[Fact]
public void Should_upload_with_id_and_version()
{
sut.UploadAsync(id, 1, string.Empty, stream, true);
A.CallTo(() => sut.UploadAsync($"{id}_1", stream, true, default))
.MustHaveHappened();
}
[Fact]
public void Should_upload_with_id_and_version_and_suffix()
{
sut.UploadAsync(id, 1, "Crop", stream, true);
A.CallTo(() => sut.UploadAsync($"{id}_1_Crop", stream, true, default))
.MustHaveHappened();
}
[Fact]
public void Should_download_with_id_and_version()
{
sut.DownloadAsync(id, 1, string.Empty, stream);
A.CallTo(() => sut.DownloadAsync($"{id}_1", stream, default))
.MustHaveHappened();
}
[Fact]
public void Should_download_with_id_and_version_and_suffix()
{
sut.DownloadAsync(id, 1, "Crop", stream);
A.CallTo(() => sut.DownloadAsync($"{id}_1_Crop", stream, default))
.MustHaveHappened();
}
[Fact]
public void Should_delete_with_id_and_version()
{
sut.DeleteAsync(id, 1, string.Empty);
A.CallTo(() => sut.DeleteAsync($"{id}_1"))
.MustHaveHappened();
}
[Fact]
public void Should_delete_with_id_and_version_and_suffix()
{
sut.DeleteAsync(id, 1, "Crop");
A.CallTo(() => sut.DeleteAsync($"{id}_1_Crop"))
.MustHaveHappened();
}
[Fact]
public void Should_generate_url_with_id_and_version()
{
sut.GeneratePublicUrl(id, 1, string.Empty);
A.CallTo(() => sut.GeneratePublicUrl($"{id}_1"))
.MustHaveHappened();
}
[Fact]
public void Should_generate_url_with_id_and_version_and_suffix()
{
sut.GeneratePublicUrl(id, 1, "Crop");
A.CallTo(() => sut.GeneratePublicUrl($"{id}_1_Crop"))
.MustHaveHappened();
}
}
}

1
backend/tools/Migrate_01/Migrations/RebuildApps.cs

@ -6,6 +6,7 @@
// ==========================================================================
using System.Threading.Tasks;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Migrations;
namespace Migrate_01.Migrations

1
backend/tools/Migrate_01/Migrations/RebuildAssets.cs

@ -6,6 +6,7 @@
// ==========================================================================
using System.Threading.Tasks;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Migrations;
namespace Migrate_01.Migrations

1
backend/tools/Migrate_01/Migrations/RebuildContents.cs

@ -6,6 +6,7 @@
// ==========================================================================
using System.Threading.Tasks;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Migrations;
namespace Migrate_01.Migrations

1
backend/tools/Migrate_01/Migrations/RebuildSnapshots.cs

@ -6,6 +6,7 @@
// ==========================================================================
using System.Threading.Tasks;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Migrations;
namespace Migrate_01.Migrations

1
backend/tools/Migrate_01/RebuildRunner.cs

@ -10,6 +10,7 @@ using System.Threading.Tasks;
using Microsoft.Extensions.Options;
using Migrate_01.Migrations;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
namespace Migrate_01
{

125
backend/tools/Migrate_01/Rebuilder.cs

@ -1,125 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using System.Threading.Tasks.Dataflow;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Apps.State;
using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.Assets.State;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Contents.State;
using Squidex.Domain.Apps.Entities.Rules;
using Squidex.Domain.Apps.Entities.Rules.State;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Domain.Apps.Entities.Schemas.State;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Caching;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.States;
namespace Migrate_01
{
public sealed class Rebuilder
{
private readonly ILocalCache localCache;
private readonly IStore<Guid> store;
private readonly IEventStore eventStore;
public Rebuilder(
ILocalCache localCache,
IStore<Guid> store,
IEventStore eventStore)
{
this.eventStore = eventStore;
this.localCache = localCache;
this.store = store;
}
public Task RebuildAppsAsync(CancellationToken ct = default)
{
return RebuildManyAsync<AppState, AppGrain>("^app\\-", ct);
}
public Task RebuildSchemasAsync(CancellationToken ct = default)
{
return RebuildManyAsync<SchemaState, SchemaGrain>("^schema\\-", ct);
}
public Task RebuildRulesAsync(CancellationToken ct = default)
{
return RebuildManyAsync<RuleState, RuleGrain>("^rule\\-", ct);
}
public Task RebuildAssetsAsync(CancellationToken ct = default)
{
return RebuildManyAsync<AssetState, AssetGrain>("^asset\\-", ct);
}
public Task RebuildContentAsync(CancellationToken ct = default)
{
return RebuildManyAsync<ContentState, ContentGrain>("^content\\-", ct);
}
private async Task RebuildManyAsync<TState, TGrain>(string filter, CancellationToken ct) where TState : IDomainState<TState>, new()
{
var handledIds = new HashSet<Guid>();
var worker = new ActionBlock<Guid>(async id =>
{
try
{
var state = new TState
{
Version = EtagVersion.Empty
};
var persistence = store.WithSnapshotsAndEventSourcing(typeof(TGrain), id, (TState s) => state = s, e =>
{
state = state.Apply(e);
state.Version++;
});
await persistence.ReadAsync();
await persistence.WriteSnapshotAsync(state);
}
catch (DomainObjectNotFoundException)
{
return;
}
},
new ExecutionDataflowBlockOptions
{
MaxDegreeOfParallelism = Environment.ProcessorCount * 2
});
using (localCache.StartContext())
{
await store.GetSnapshotStore<TState>().ClearAsync();
await eventStore.QueryAsync(async storedEvent =>
{
var id = storedEvent.Data.Headers.AggregateId();
if (handledIds.Add(id))
{
await worker.SendAsync(id, ct);
}
}, filter, ct: ct);
worker.Complete();
await worker.Completion;
}
}
}
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save