< Summary

Information
Class: Elsa.Secrets.Repositories.FileSecretRepository
Assembly: Elsa.Secrets
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.Secrets/Repositories/FileSecretRepository.cs
Line coverage
95%
Covered lines: 113
Uncovered lines: 5
Coverable lines: 118
Total lines: 263
Line coverage: 95.7%
Branch coverage
85%
Covered branches: 36
Total branches: 42
Branch coverage: 85.7%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
.ctor(...)100%11100%
.ctor(...)100%11100%
.ctor(...)100%11100%
GetAsync()100%11100%
ListAsync()100%11100%
AddAsync()87.5%8890.9%
TryAddOrReplaceDeletedAsync()87.5%161689.47%
SaveAsync()90%101092.85%
ReadAllAsync()100%11100%
ReadAllUnsafeAsync()75%88100%
WriteAllUnsafeAsync()75%4488.88%
ReplaceTenantOwnedSecret(...)100%22100%
ReplaceIdentityAndTenant(...)100%22100%
GetPath()50%22100%

File(s)

/home/runner/work/elsa-core/elsa-core/src/modules/Elsa.Secrets/Repositories/FileSecretRepository.cs

#LineLine coverage
 1using System.Text.Json;
 2using System.Text.Json.Serialization;
 3using Elsa.Common.Multitenancy;
 4using Elsa.Secrets.Contracts;
 5using Elsa.Secrets.Services;
 6using Elsa.Tenants.Options;
 7using Microsoft.Extensions.Logging;
 8using Microsoft.Extensions.Options;
 9
 10namespace Elsa.Secrets.Repositories;
 11
 12public class FileSecretRepository : ISecretRepository
 13{
 14    private readonly IOptions<SecretsOptions> options;
 15    private readonly ILogger<FileSecretRepository>? logger;
 16    private readonly ITenantAccessor? tenantAccessor;
 17    private readonly bool tenancyEnabled;
 18    private readonly ISecretNameValidator nameValidator;
 19
 20    public FileSecretRepository(
 21        IOptions<SecretsOptions> options,
 22        ILogger<FileSecretRepository>? logger)
 123        : this(options, logger, null)
 24    {
 125    }
 26
 27    public FileSecretRepository(
 28        IOptions<SecretsOptions> options,
 29        ILogger<FileSecretRepository>? logger = null,
 30        ITenantAccessor? tenantAccessor = null)
 2731        : this(options, logger, tenantAccessor, tenantAccessor is not null, new DefaultSecretNameValidator())
 32    {
 2733    }
 34
 35    public FileSecretRepository(
 36        IOptions<SecretsOptions> options,
 37        IOptions<TenantsOptions> tenantsOptions,
 38        ISecretNameValidator nameValidator,
 39        ILogger<FileSecretRepository>? logger = null,
 40        ITenantAccessor? tenantAccessor = null)
 341        : this(options, logger, tenantAccessor, tenantsOptions.Value.IsEnabled, nameValidator)
 42    {
 343    }
 44
 3045    private FileSecretRepository(
 3046        IOptions<SecretsOptions> options,
 3047        ILogger<FileSecretRepository>? logger,
 3048        ITenantAccessor? tenantAccessor,
 3049        bool tenancyEnabled,
 3050        ISecretNameValidator nameValidator)
 51    {
 3052        this.options = options;
 3053        this.logger = logger;
 3054        this.tenantAccessor = tenantAccessor;
 3055        this.tenancyEnabled = tenancyEnabled;
 3056        this.nameValidator = nameValidator;
 3057    }
 58
 3059    private readonly SemaphoreSlim _lock = new(1, 1);
 3060    private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web)
 3061    {
 3062        Converters = { new JsonStringEnumConverter() },
 3063        WriteIndented = true
 3064    };
 65
 66    public async Task<Secret?> GetAsync(string normalizedName, CancellationToken cancellationToken = default)
 67    {
 3768        var secrets = await ReadAllAsync(cancellationToken);
 8069        return secrets.FirstOrDefault(x => SecretRepositoryTenant.IsVisible(x, tenantAccessor, tenancyEnabled) && Secret
 3770    }
 71
 72    public async Task<IReadOnlyCollection<Secret>> ListAsync(CancellationToken cancellationToken = default)
 73    {
 2574        return (await ReadAllAsync(cancellationToken)).Where(x => SecretRepositoryTenant.IsVisible(x, tenantAccessor, te
 975    }
 76
 77    public async Task AddAsync(Secret secret, CancellationToken cancellationToken = default)
 78    {
 3979        SecretRepositoryTenant.Stamp(secret, tenantAccessor, tenancyEnabled);
 80
 3981        await _lock.WaitAsync(cancellationToken);
 82        try
 83        {
 3984            var secrets = await ReadAllUnsafeAsync(cancellationToken);
 5885            if (secrets.Any(x => SecretRepositoryTenant.IsVisible(x, tenantAccessor, tenancyEnabled) && SecretRepository
 686                throw new InvalidOperationException($"A secret named '{secret.Name}' already exists.");
 87
 4688            if (secrets.Any(x => SecretRepositoryTenant.HasSameTenantName(x, secret, nameValidator, tenancyEnabled)))
 289                throw new InvalidOperationException($"A secret named '{secret.Name}' already exists.");
 90
 4291            if (secrets.Any(x => x.Id == secret.Id))
 092                throw new InvalidOperationException($"A secret with ID '{secret.Id}' already exists.");
 93
 3194            secrets.Add(secret);
 3195            await WriteAllUnsafeAsync(secrets, cancellationToken);
 3196        }
 97        finally
 98        {
 3999            _lock.Release();
 100        }
 31101    }
 102
 103    public async Task<bool> TryAddOrReplaceDeletedAsync(Secret secret, CancellationToken cancellationToken = default)
 104    {
 13105        SecretRepositoryTenant.Stamp(secret, tenantAccessor, tenancyEnabled);
 106
 13107        await _lock.WaitAsync(cancellationToken);
 108        try
 109        {
 13110            var secrets = await ReadAllUnsafeAsync(cancellationToken);
 27111            var index = secrets.FindIndex(x => SecretRepositoryTenant.IsVisible(x, tenantAccessor, tenancyEnabled) && Se
 13112            if (index >= 0)
 113            {
 8114                if (secrets[index].Status != SecretStatus.Deleted)
 3115                    return false;
 116
 5117                if (secrets[index].IsLifecycleManaged)
 118                {
 0119                    return false;
 120                }
 121
 5122                if (!SecretRepositoryTenant.CanReplace(secrets[index], secret, tenantAccessor, tenancyEnabled))
 0123                    return false;
 124
 13125                if (secrets.Where((_, i) => i != index).Any(x => x.Id == secret.Id))
 1126                    return false;
 127
 4128                secrets[index] = ReplaceTenantOwnedSecret(secrets[index], secret, tenancyEnabled);
 129            }
 130            else
 131            {
 10132                if (secrets.Any(x => SecretRepositoryTenant.HasSameTenantName(x, secret, nameValidator, tenancyEnabled))
 2133                    return false;
 134
 6135                if (secrets.Any(x => x.Id == secret.Id))
 1136                    return false;
 137
 2138                secrets.Add(secret);
 139            }
 140
 6141            await WriteAllUnsafeAsync(secrets, cancellationToken);
 6142            return true;
 143        }
 144        finally
 145        {
 13146            _lock.Release();
 147        }
 13148    }
 149
 150    public async Task SaveAsync(Secret secret, CancellationToken cancellationToken = default)
 151    {
 12152        SecretRepositoryTenant.Stamp(secret, tenantAccessor, tenancyEnabled);
 153
 12154        await _lock.WaitAsync(cancellationToken);
 155        try
 156        {
 12157            var secrets = await ReadAllUnsafeAsync(cancellationToken);
 21158            var index = secrets.FindIndex(x => SecretRepositoryTenant.IsVisible(x, tenantAccessor, tenancyEnabled) && Se
 12159            if (index < 0)
 160            {
 9161                if (secrets.Any(x => SecretRepositoryTenant.HasSameTenantName(x, secret, nameValidator, tenancyEnabled))
 2162                    throw new InvalidOperationException($"A secret named '{secret.Name}' already exists.");
 163
 5164                if (secrets.Any(x => x.Id == secret.Id))
 0165                    throw new InvalidOperationException($"A secret with ID '{secret.Id}' already exists.");
 166
 4167                secrets.Add(secret);
 168            }
 169            else
 170            {
 6171                if (!SecretRepositoryTenant.CanReplace(secrets[index], secret, tenantAccessor, tenancyEnabled))
 1172                    throw new InvalidOperationException($"A secret named '{secret.Name}' belongs to another tenant.");
 173
 5174                secrets[index] = ReplaceIdentityAndTenant(secrets[index], secret, tenancyEnabled);
 175            }
 176
 9177            await WriteAllUnsafeAsync(secrets, cancellationToken);
 9178        }
 179        finally
 180        {
 12181            _lock.Release();
 182        }
 9183    }
 184
 185    private async Task<List<Secret>> ReadAllAsync(CancellationToken cancellationToken)
 186    {
 46187        await _lock.WaitAsync(cancellationToken);
 188        try
 189        {
 46190            return await ReadAllUnsafeAsync(cancellationToken);
 191        }
 192        finally
 193        {
 46194            _lock.Release();
 195        }
 46196    }
 197
 198    private async Task<List<Secret>> ReadAllUnsafeAsync(CancellationToken cancellationToken)
 199    {
 110200        var path = GetPath();
 110201        if (!File.Exists(path))
 23202            return [];
 203
 87204        await using var stream = File.OpenRead(path);
 205        try
 206        {
 87207            return await JsonSerializer.DeserializeAsync<List<Secret>>(stream, _jsonOptions, cancellationToken) ?? [];
 208        }
 2209        catch (JsonException e)
 210        {
 2211            logger?.LogError(e, "The secrets repository file '{Path}' could not be read because it contains invalid JSON
 2212            return [];
 213        }
 110214    }
 215
 216    private async Task WriteAllUnsafeAsync(List<Secret> secrets, CancellationToken cancellationToken)
 217    {
 46218        var path = GetPath();
 46219        Directory.CreateDirectory(Path.GetDirectoryName(path)!);
 220
 46221        var temporaryPath = $"{path}.{Guid.NewGuid():N}.tmp";
 222        try
 223        {
 46224            await using (var stream = File.Create(temporaryPath))
 75225                await JsonSerializer.SerializeAsync(stream, secrets.OrderBy(x => x.Name).ToList(), _jsonOptions, cancell
 226
 46227            File.Move(temporaryPath, path, true);
 46228        }
 229        finally
 230        {
 46231            if (File.Exists(temporaryPath))
 0232                File.Delete(temporaryPath);
 233        }
 46234    }
 235
 236    /// <summary>
 237    /// Updates the aggregate payload while retaining the row identity and, when enabled, tenant ownership.
 238    /// </summary>
 239    private static Secret ReplaceTenantOwnedSecret(Secret existing, Secret incoming, bool tenancyEnabled)
 240    {
 4241        incoming.ManagedOwnerId = existing.ManagedOwnerId;
 4242        incoming.ManagedGenerationId = existing.ManagedGenerationId;
 243
 4244        if (tenancyEnabled)
 2245            incoming.TenantId = existing.TenantId;
 246
 4247        return incoming;
 248    }
 249
 250    private static Secret ReplaceIdentityAndTenant(Secret existing, Secret incoming, bool tenancyEnabled)
 251    {
 5252        incoming.Id = existing.Id;
 5253        incoming.ManagedOwnerId = existing.ManagedOwnerId;
 5254        incoming.ManagedGenerationId = existing.ManagedGenerationId;
 255
 5256        if (tenancyEnabled)
 2257            incoming.TenantId = existing.TenantId;
 258
 5259        return incoming;
 260    }
 261
 156262    private string GetPath() => options.Value.RepositoryFilePath ?? SecretsOptions.DefaultRepositoryFilePath;
 263}