Add factory reset functionality and update identity service
- Introduced a new DELETE endpoint `/api/admin/maintenance/factory-reset` for a complete reset of the admin panel, removing all users except the current admin and clearing various data. - Implemented the `FactoryReset` method in `AdminMaintenanceEndpoints` to handle the reset logic. - Added a new method `ListAllUserIdsExceptAsync` in `IIdentityService` to retrieve user IDs excluding a specified user, aiding in the factory reset process. - Updated the frontend to include a confirmation dialog for the factory reset action, enhancing user experience and safety. - Enhanced localization support for the new factory reset feature in both Russian and English, ensuring clarity for all users.
This commit is contained in:
@@ -23,6 +23,7 @@ public static class AdminMaintenanceEndpoints
|
||||
admin
|
||||
.MapDelete("/apps/disabled", DeleteDisabledApps)
|
||||
.Produces<MaintenanceCleanupResponseDto>();
|
||||
admin.MapDelete("/factory-reset", FactoryReset).Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -58,6 +59,15 @@ public static class AdminMaintenanceEndpoints
|
||||
return ToResponse(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> FactoryReset(
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new FactoryResetCommand(), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static IResult ToResponse(Result<int> result) =>
|
||||
result.IsSuccess
|
||||
? Results.Ok(new MaintenanceCleanupResponseDto(result.Value))
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Maintenance;
|
||||
|
||||
/// <summary>
|
||||
/// Полный сброс панели к состоянию свежего деплоя: все пользователи кроме текущего админа,
|
||||
/// конфиги, ноды/инбаунды, тикеты (+вложения), новости, аудит и кастомные роли удаляются;
|
||||
/// каталог приложений пересеивается из seed/client-apps.json. Необратимо.
|
||||
/// </summary>
|
||||
public sealed record FactoryResetCommand : ICommand<Result>;
|
||||
@@ -0,0 +1,121 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PnvPanel.Application.Auth;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Messaging;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Domain.Audit;
|
||||
using PnvPanel.Domain.Configs;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Maintenance;
|
||||
|
||||
public sealed class FactoryResetCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
IIdentityService identityService,
|
||||
IRoleService roleService,
|
||||
IXuiPanelGateway gateway,
|
||||
IFileStorage fileStorage,
|
||||
IClientAppCatalogSeeder catalogSeeder,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<FactoryResetCommand, Result>
|
||||
{
|
||||
public async Task<Result> Handle(
|
||||
FactoryResetCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (currentUser.UserId is not { } adminId)
|
||||
return Result.Failure(AuthErrors.Unauthorized);
|
||||
|
||||
await RevokeAllConfigsOnPanelsAsync(cancellationToken);
|
||||
await DeleteTicketAttachmentFilesAsync(cancellationToken);
|
||||
WipeApplicationData();
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Пользователи/роли удаляются через Identity, а не через dbContext — коммитим сначала
|
||||
// "плоские" данные выше, чтобы к этому моменту на них уже никто не ссылался.
|
||||
var otherUserIds = await identityService.ListAllUserIdsExceptAsync(
|
||||
adminId,
|
||||
cancellationToken
|
||||
);
|
||||
foreach (var userId in otherUserIds)
|
||||
await identityService.DeleteUserAsync(userId, cancellationToken);
|
||||
|
||||
var roles = await roleService.ListRolesAsync(cancellationToken);
|
||||
foreach (var role in roles.Where(r => !r.IsSystem))
|
||||
await roleService.DeleteRoleAsync(role.Id, cancellationToken);
|
||||
|
||||
// ClientApps уже пуста (удалена в WipeApplicationData + сохранена выше) — пересеиваем.
|
||||
await catalogSeeder.SeedIfEmptyAsync(cancellationToken);
|
||||
|
||||
// Финальная запись — уже после очистки самого журнала, чтобы отметить факт сброса.
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
adminId,
|
||||
"FactoryReset",
|
||||
"System",
|
||||
"all",
|
||||
metadata: null,
|
||||
AuditSource.Web
|
||||
)
|
||||
);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
/// <summary>Best-effort — недоступная/уже удаляемая нода не должна блокировать сброс остального.</summary>
|
||||
private async Task RevokeAllConfigsOnPanelsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var configs = await dbContext
|
||||
.VpnConfigs.AsNoTracking()
|
||||
.Where(c => c.Status != ConfigStatus.Revoked)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (configs.Count == 0)
|
||||
return;
|
||||
|
||||
var inbounds = await dbContext.Inbounds.AsNoTracking().ToListAsync(cancellationToken);
|
||||
var nodes = await dbContext.Nodes.AsNoTracking().ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var config in configs)
|
||||
{
|
||||
var inbound = inbounds.FirstOrDefault(i => i.Id == config.InboundId);
|
||||
var node = inbound is null ? null : nodes.FirstOrDefault(n => n.Id == inbound.NodeId);
|
||||
if (inbound is null || node is null)
|
||||
continue;
|
||||
|
||||
await gateway.RemoveClientAsync(
|
||||
node,
|
||||
inbound.RemoteInboundId,
|
||||
config.ClientExternalId,
|
||||
config.Protocol,
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteTicketAttachmentFilesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var attachments = await dbContext
|
||||
.TicketAttachments.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (var attachment in attachments)
|
||||
await fileStorage.DeleteAsync(attachment.StoredFileName, cancellationToken);
|
||||
}
|
||||
|
||||
private void WipeApplicationData()
|
||||
{
|
||||
dbContext.TicketAttachments.RemoveRange(dbContext.TicketAttachments);
|
||||
dbContext.TicketComments.RemoveRange(dbContext.TicketComments);
|
||||
dbContext.SupportTickets.RemoveRange(dbContext.SupportTickets);
|
||||
dbContext.TrafficSamples.RemoveRange(dbContext.TrafficSamples);
|
||||
dbContext.VpnConfigs.RemoveRange(dbContext.VpnConfigs);
|
||||
dbContext.Inbounds.RemoveRange(dbContext.Inbounds);
|
||||
dbContext.Nodes.RemoveRange(dbContext.Nodes);
|
||||
dbContext.ActivationRequests.RemoveRange(dbContext.ActivationRequests);
|
||||
dbContext.TelegramLoginRequests.RemoveRange(dbContext.TelegramLoginRequests);
|
||||
dbContext.TelegramLinkTokens.RemoveRange(dbContext.TelegramLinkTokens);
|
||||
dbContext.NewsPosts.RemoveRange(dbContext.NewsPosts);
|
||||
dbContext.ClientApps.RemoveRange(dbContext.ClientApps);
|
||||
dbContext.AuditLogs.RemoveRange(dbContext.AuditLogs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace PnvPanel.Application.Common.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Сидинг стартового каталога клиентских приложений из seed/client-apps.json. Идемпотентно —
|
||||
/// не трогает таблицу, если в ней уже что-то есть (используется и при старте, и после полного
|
||||
/// сброса панели, когда таблица заведомо пуста).
|
||||
/// </summary>
|
||||
public interface IClientAppCatalogSeeder
|
||||
{
|
||||
Task SeedIfEmptyAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -128,4 +128,11 @@ public interface IIdentityService
|
||||
Task<IReadOnlyCollection<long>> GetActivatedLinkedTelegramUserIdsAsync(
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
/// <summary>Все Id пользователей, кроме указанного — для полного сброса панели (сохраняется
|
||||
/// только текущий админ).</summary>
|
||||
Task<IReadOnlyList<Guid>> ListAllUserIdsExceptAsync(
|
||||
Guid exceptUserId,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Domain.Apps;
|
||||
using PnvPanel.Infrastructure.Persistence;
|
||||
|
||||
namespace PnvPanel.Infrastructure.Apps;
|
||||
|
||||
internal sealed class ClientAppCatalogSeeder(
|
||||
AppDbContext dbContext,
|
||||
ILogger<ClientAppCatalogSeeder> logger
|
||||
) : IClientAppCatalogSeeder
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
public async Task SeedIfEmptyAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (await dbContext.ClientApps.AnyAsync(cancellationToken))
|
||||
return;
|
||||
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "seed", "client-apps.json");
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
logger.LogWarning("Client app catalog seed file not found: {Path}", path);
|
||||
return;
|
||||
}
|
||||
|
||||
var json = await File.ReadAllTextAsync(path, cancellationToken);
|
||||
var entries = JsonSerializer.Deserialize<List<ClientAppSeedEntry>>(json, JsonOptions) ?? [];
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
if (!Enum.TryParse<OsPlatform>(entry.OperatingSystem, ignoreCase: true, out var os))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Unknown OS '{Os}' in client app catalog seed — skipped",
|
||||
entry.OperatingSystem
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
var app = ClientApp.Create(
|
||||
entry.Name,
|
||||
new Uri(entry.DownloadUrl, UriKind.Absolute),
|
||||
os,
|
||||
entry.Description,
|
||||
iconUrl: null,
|
||||
entry.SortOrder
|
||||
);
|
||||
dbContext.ClientApps.Add(app);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation("Seeded client app catalog: {Count} entries", entries.Count);
|
||||
}
|
||||
|
||||
private sealed record ClientAppSeedEntry(
|
||||
string Name,
|
||||
string OperatingSystem,
|
||||
string DownloadUrl,
|
||||
string? Description,
|
||||
int SortOrder,
|
||||
bool IsEnabled
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Infrastructure.Apps;
|
||||
using PnvPanel.Infrastructure.BackgroundJobs;
|
||||
using PnvPanel.Infrastructure.Identity;
|
||||
using PnvPanel.Infrastructure.Persistence;
|
||||
@@ -135,6 +136,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<IJwtTokenService, JwtTokenService>();
|
||||
services.AddScoped<IRefreshTokenService, RefreshTokenService>();
|
||||
services.AddScoped<IRoleService, RoleService>();
|
||||
services.AddScoped<IClientAppCatalogSeeder, ClientAppCatalogSeeder>();
|
||||
// Один и тот же экземпляр CurrentUser на scope — и как ICurrentUser (чтение), и как
|
||||
// ICurrentUserSetter (запись, только для Telegram-бота, см. TelegramBotHostedService).
|
||||
services.AddScoped<CurrentUser>();
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using PnvPanel.Domain.Apps;
|
||||
using PnvPanel.Infrastructure.Persistence;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
|
||||
namespace PnvPanel.Infrastructure.Identity;
|
||||
|
||||
@@ -13,17 +10,12 @@ namespace PnvPanel.Infrastructure.Identity;
|
||||
public sealed class DbInitializer(
|
||||
RoleManager<AppRole> roleManager,
|
||||
UserManager<AppUser> userManager,
|
||||
AppDbContext dbContext,
|
||||
IClientAppCatalogSeeder clientAppCatalogSeeder,
|
||||
IOptions<AdminSeedOptions> adminSeedOptions,
|
||||
IOptions<RolesOptions> rolesOptions,
|
||||
ILogger<DbInitializer> logger
|
||||
)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
public async Task SeedAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureRoleAsync(
|
||||
@@ -39,7 +31,7 @@ public sealed class DbInitializer(
|
||||
isSystem: true
|
||||
);
|
||||
await SeedAdminAsync();
|
||||
await SeedClientAppsAsync(cancellationToken);
|
||||
await clientAppCatalogSeeder.SeedIfEmptyAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task EnsureRoleAsync(string name, int maxConfigs, int maxIpLimit, bool isSystem)
|
||||
@@ -100,54 +92,4 @@ public sealed class DbInitializer(
|
||||
await userManager.AddToRoleAsync(admin, RoleNames.Admin);
|
||||
logger.LogInformation("Created admin account {Username}", options.Username);
|
||||
}
|
||||
|
||||
private async Task SeedClientAppsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (await dbContext.ClientApps.AnyAsync(cancellationToken))
|
||||
return;
|
||||
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "seed", "client-apps.json");
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
logger.LogWarning("Client app catalog seed file not found: {Path}", path);
|
||||
return;
|
||||
}
|
||||
|
||||
var json = await File.ReadAllTextAsync(path, cancellationToken);
|
||||
var entries = JsonSerializer.Deserialize<List<ClientAppSeedEntry>>(json, JsonOptions) ?? [];
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
if (!Enum.TryParse<OsPlatform>(entry.OperatingSystem, ignoreCase: true, out var os))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Unknown OS '{Os}' in client app catalog seed — skipped",
|
||||
entry.OperatingSystem
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
var app = ClientApp.Create(
|
||||
entry.Name,
|
||||
new Uri(entry.DownloadUrl, UriKind.Absolute),
|
||||
os,
|
||||
entry.Description,
|
||||
iconUrl: null,
|
||||
entry.SortOrder
|
||||
);
|
||||
dbContext.ClientApps.Add(app);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
logger.LogInformation("Seeded client app catalog: {Count} entries", entries.Count);
|
||||
}
|
||||
|
||||
private sealed record ClientAppSeedEntry(
|
||||
string Name,
|
||||
string OperatingSystem,
|
||||
string DownloadUrl,
|
||||
string? Description,
|
||||
int SortOrder,
|
||||
bool IsEnabled
|
||||
);
|
||||
}
|
||||
|
||||
@@ -364,6 +364,18 @@ internal sealed class IdentityService(
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Guid>> ListAllUserIdsExceptAsync(
|
||||
Guid exceptUserId,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
return await userManager
|
||||
.Users.AsNoTracking()
|
||||
.Where(u => u.Id != exceptUserId)
|
||||
.Select(u => u.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<string> GetPrimaryRoleNameAsync(AppUser user)
|
||||
{
|
||||
var roles = await userManager.GetRolesAsync(user);
|
||||
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
using NSubstitute;
|
||||
using PnvPanel.Application.Admin.Maintenance;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Application.Tests.TestSupport;
|
||||
using PnvPanel.Domain.Audit;
|
||||
using PnvPanel.Domain.Configs;
|
||||
using PnvPanel.Domain.Inbounds;
|
||||
using PnvPanel.Domain.News;
|
||||
using PnvPanel.Domain.Nodes;
|
||||
using PnvPanel.Domain.Support;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Application.Tests.Admin.Maintenance;
|
||||
|
||||
public class FactoryResetCommandHandlerTests
|
||||
{
|
||||
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
|
||||
private readonly IRoleService _roleService = Substitute.For<IRoleService>();
|
||||
private readonly IXuiPanelGateway _gateway = Substitute.For<IXuiPanelGateway>();
|
||||
private readonly IFileStorage _fileStorage = Substitute.For<IFileStorage>();
|
||||
private readonly IClientAppCatalogSeeder _catalogSeeder =
|
||||
Substitute.For<IClientAppCatalogSeeder>();
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WipesEverythingExceptCurrentAdminAndSystemRoles()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var adminId = Guid.NewGuid();
|
||||
var otherUserId = Guid.NewGuid();
|
||||
|
||||
var node = Node.Register(
|
||||
"node-1",
|
||||
new Uri("https://node.example.com"),
|
||||
new NodeCredentials("u", "p"),
|
||||
null
|
||||
);
|
||||
var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "remark", 443);
|
||||
var config = VpnConfig.Create(otherUserId, inbound.Id, VpnProtocol.Vless, "label");
|
||||
dbContext.Nodes.Add(node);
|
||||
dbContext.Inbounds.Add(inbound);
|
||||
dbContext.VpnConfigs.Add(config);
|
||||
|
||||
var ticket = SupportTicket.CreateBugReport(otherUserId);
|
||||
dbContext.SupportTickets.Add(ticket);
|
||||
var comment = TicketComment.Create(ticket.Id, otherUserId, "текст");
|
||||
dbContext.TicketComments.Add(comment);
|
||||
var attachment = TicketAttachment.Create(
|
||||
comment.Id,
|
||||
"shot.png",
|
||||
"stored-name",
|
||||
"image/png",
|
||||
10
|
||||
);
|
||||
dbContext.TicketAttachments.Add(attachment);
|
||||
|
||||
dbContext.NewsPosts.Add(NewsPost.Create("Заголовок", "Текст"));
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(adminId, "SomeOldAction", "Test", "1", null, AuditSource.Web)
|
||||
);
|
||||
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var adminRoleId = Guid.NewGuid();
|
||||
var customRoleId = Guid.NewGuid();
|
||||
_roleService
|
||||
.ListRolesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(
|
||||
new List<RoleDto>
|
||||
{
|
||||
new(adminRoleId, "admin", -1, -1, IsSystem: true),
|
||||
new(customRoleId, "premium", 10, 5, IsSystem: false),
|
||||
}
|
||||
);
|
||||
_roleService
|
||||
.DeleteRoleAsync(customRoleId, Arg.Any<CancellationToken>())
|
||||
.Returns(Result.Success());
|
||||
|
||||
_identityService
|
||||
.ListAllUserIdsExceptAsync(adminId, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Guid> { otherUserId });
|
||||
_identityService
|
||||
.DeleteUserAsync(otherUserId, Arg.Any<CancellationToken>())
|
||||
.Returns(Result.Success());
|
||||
|
||||
_gateway
|
||||
.RemoveClientAsync(
|
||||
Arg.Any<Node>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<VpnProtocol>(),
|
||||
Arg.Any<CancellationToken>()
|
||||
)
|
||||
.Returns(Result.Success());
|
||||
|
||||
var currentUser = FakeCurrentUser.Authenticated(adminId, "admin");
|
||||
var handler = new FactoryResetCommandHandler(
|
||||
dbContext,
|
||||
_identityService,
|
||||
_roleService,
|
||||
_gateway,
|
||||
_fileStorage,
|
||||
_catalogSeeder,
|
||||
currentUser
|
||||
);
|
||||
|
||||
var result = await handler.Handle(new FactoryResetCommand(), CancellationToken.None);
|
||||
// Хендлер сам коммитит несколько раз (в проде это делает он же, а не диспетчер) — фиксируем финально.
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
|
||||
Assert.False(dbContext.VpnConfigs.Any());
|
||||
Assert.False(dbContext.Inbounds.Any());
|
||||
Assert.False(dbContext.Nodes.Any());
|
||||
Assert.False(dbContext.SupportTickets.Any());
|
||||
Assert.False(dbContext.TicketComments.Any());
|
||||
Assert.False(dbContext.TicketAttachments.Any());
|
||||
Assert.False(dbContext.NewsPosts.Any());
|
||||
|
||||
// Аудит очищен, но остаётся ровно одна собственная запись о факте сброса.
|
||||
var auditLog = Assert.Single(dbContext.AuditLogs);
|
||||
Assert.Equal("FactoryReset", auditLog.Action);
|
||||
|
||||
await _identityService
|
||||
.Received(1)
|
||||
.DeleteUserAsync(otherUserId, Arg.Any<CancellationToken>());
|
||||
await _roleService.Received(1).DeleteRoleAsync(customRoleId, Arg.Any<CancellationToken>());
|
||||
await _roleService
|
||||
.DidNotReceive()
|
||||
.DeleteRoleAsync(adminRoleId, Arg.Any<CancellationToken>());
|
||||
await _gateway
|
||||
.Received(1)
|
||||
.RemoveClientAsync(
|
||||
Arg.Any<Node>(),
|
||||
inbound.RemoteInboundId,
|
||||
config.ClientExternalId,
|
||||
config.Protocol,
|
||||
Arg.Any<CancellationToken>()
|
||||
);
|
||||
await _fileStorage.Received(1).DeleteAsync("stored-name", Arg.Any<CancellationToken>());
|
||||
await _catalogSeeder.Received(1).SeedIfEmptyAsync(Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user