mirror of
https://github.com/bitwarden/server
synced 2026-01-15 23:13:56 +00:00
[PM-22263] [PM-29849] Initial PoC of seeder API (#6424)
We want to reduce the amount of business critical test data in the company. One way of doing that is to generate test data on demand prior to client side testing. Clients will request a scene to be set up with a JSON body set of options, specific to a given scene. Successful seed requests will be responded to with a mangleMap which maps magic strings present in the request to the mangled, non-colliding versions inserted into the database. This way, the server is solely responsible for understanding uniqueness requirements in the database. scenes also are able to return custom data, depending on the scene. For example, user creation would benefit from a return value of the userId for further test setup on the client side. Clients will indicate they are running tests by including a unique header, x-play-id which specifies a unique testing context. The server uses this PlayId as the seed for any mangling that occurs. This allows the client to decide it will reuse a given PlayId if the test context builds on top of previously executed tests. When a given context is no longer needed, the API user will delete all test data associated with the PlayId by calling a delete endpoint. --------- Co-authored-by: Matt Gibson <mgibson@bitwarden.com>
This commit is contained in:
@@ -65,6 +65,7 @@ public class Startup
|
||||
default:
|
||||
break;
|
||||
}
|
||||
services.AddTestPlayIdTracking(globalSettings);
|
||||
|
||||
// Context
|
||||
services.AddScoped<ICurrentContext, CurrentContext>();
|
||||
|
||||
@@ -36,7 +36,7 @@ public class EmergencyAccessUpdateRequestModel
|
||||
existingEmergencyAccess.KeyEncrypted = KeyEncrypted;
|
||||
}
|
||||
existingEmergencyAccess.Type = Type;
|
||||
existingEmergencyAccess.WaitTimeDays = WaitTimeDays;
|
||||
existingEmergencyAccess.WaitTimeDays = (short)WaitTimeDays;
|
||||
return existingEmergencyAccess;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ public class Startup
|
||||
|
||||
// Repositories
|
||||
services.AddDatabaseRepositories(globalSettings);
|
||||
services.AddTestPlayIdTracking(globalSettings);
|
||||
|
||||
// Context
|
||||
services.AddScoped<ICurrentContext, CurrentContext>();
|
||||
|
||||
@@ -48,6 +48,7 @@ public class Startup
|
||||
|
||||
// Repositories
|
||||
services.AddDatabaseRepositories(globalSettings);
|
||||
services.AddTestPlayIdTracking(globalSettings);
|
||||
|
||||
// PayPal IPN Client
|
||||
services.AddHttpClient<IPayPalIPNClient, PayPalIPNClient>();
|
||||
|
||||
@@ -18,7 +18,7 @@ public class EmergencyAccess : ITableObject<Guid>
|
||||
public string KeyEncrypted { get; set; }
|
||||
public EmergencyAccessType Type { get; set; }
|
||||
public EmergencyAccessStatusType Status { get; set; }
|
||||
public int WaitTimeDays { get; set; }
|
||||
public short WaitTimeDays { get; set; }
|
||||
public DateTime? RecoveryInitiatedDate { get; set; }
|
||||
public DateTime? LastNotificationDate { get; set; }
|
||||
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
|
||||
|
||||
@@ -79,7 +79,7 @@ public class EmergencyAccessService : IEmergencyAccessService
|
||||
Email = emergencyContactEmail.ToLowerInvariant(),
|
||||
Status = EmergencyAccessStatusType.Invited,
|
||||
Type = accessType,
|
||||
WaitTimeDays = waitTime,
|
||||
WaitTimeDays = (short)waitTime,
|
||||
CreationDate = DateTime.UtcNow,
|
||||
RevisionDate = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
60
src/Core/Entities/PlayItem.cs
Normal file
60
src/Core/Entities/PlayItem.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// PlayItem is a join table tracking entities created during automated testing.
|
||||
/// A `PlayId` is supplied by the clients in the `x-play-id` header to inform the server
|
||||
/// that any data created should be associated with the play, and therefore cleaned up with it.
|
||||
/// </summary>
|
||||
public class PlayItem : ITableObject<Guid>
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
[MaxLength(256)]
|
||||
public required string PlayId { get; init; }
|
||||
public Guid? UserId { get; init; }
|
||||
public Guid? OrganizationId { get; init; }
|
||||
public DateTime CreationDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Generates and sets a new COMB GUID for the Id property.
|
||||
/// </summary>
|
||||
public void SetNewId()
|
||||
{
|
||||
Id = CoreHelpers.GenerateComb();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new PlayItem record associated with a User.
|
||||
/// </summary>
|
||||
/// <param name="user">The user entity created during the play.</param>
|
||||
/// <param name="playId">The play identifier from the x-play-id header.</param>
|
||||
/// <returns>A new PlayItem instance tracking the user.</returns>
|
||||
public static PlayItem Create(User user, string playId)
|
||||
{
|
||||
return new PlayItem
|
||||
{
|
||||
PlayId = playId,
|
||||
UserId = user.Id,
|
||||
CreationDate = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new PlayItem record associated with an Organization.
|
||||
/// </summary>
|
||||
/// <param name="organization">The organization entity created during the play.</param>
|
||||
/// <param name="playId">The play identifier from the x-play-id header.</param>
|
||||
/// <returns>A new PlayItem instance tracking the organization.</returns>
|
||||
public static PlayItem Create(Organization organization, string playId)
|
||||
{
|
||||
return new PlayItem
|
||||
{
|
||||
PlayId = playId,
|
||||
OrganizationId = organization.Id,
|
||||
CreationDate = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
11
src/Core/Repositories/IPlayItemRepository.cs
Normal file
11
src/Core/Repositories/IPlayItemRepository.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using Bit.Core.Entities;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.Repositories;
|
||||
|
||||
public interface IPlayItemRepository : IRepository<PlayItem, Guid>
|
||||
{
|
||||
Task<ICollection<PlayItem>> GetByPlayIdAsync(string playId);
|
||||
Task DeleteByPlayIdAsync(string playId);
|
||||
}
|
||||
23
src/Core/Services/Play/IPlayIdService.cs
Normal file
23
src/Core/Services/Play/IPlayIdService.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing Play identifiers in automated testing infrastructure.
|
||||
/// A "Play" is a test session that groups entities created during testing to enable cleanup.
|
||||
/// The PlayId flows from client request (x-play-id header) through PlayIdMiddleware to this service,
|
||||
/// which repositories query to create PlayItem tracking records via IPlayItemService. The SeederAPI uses these records
|
||||
/// to bulk delete all entities associated with a PlayId. Only active in Development environments.
|
||||
/// </summary>
|
||||
public interface IPlayIdService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the current Play identifier from the x-play-id request header.
|
||||
/// </summary>
|
||||
string? PlayId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the current request is part of an active Play session.
|
||||
/// </summary>
|
||||
/// <param name="playId">The Play identifier if active, otherwise empty string.</param>
|
||||
/// <returns>True if in a Play session (has PlayId and in Development environment), otherwise false.</returns>
|
||||
bool InPlay(out string playId);
|
||||
}
|
||||
27
src/Core/Services/Play/IPlayItemService.cs
Normal file
27
src/Core/Services/Play/IPlayItemService.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Entities;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service used to track added users and organizations during a Play session.
|
||||
/// </summary>
|
||||
public interface IPlayItemService
|
||||
{
|
||||
/// <summary>
|
||||
/// Records a PlayItem entry for the given User created during a Play session.
|
||||
///
|
||||
/// Does nothing if no Play Id is set for this http scope.
|
||||
/// </summary>
|
||||
/// <param name="user"></param>
|
||||
/// <returns></returns>
|
||||
Task Record(User user);
|
||||
/// <summary>
|
||||
/// Records a PlayItem entry for the given Organization created during a Play session.
|
||||
///
|
||||
/// Does nothing if no Play Id is set for this http scope.
|
||||
/// </summary>
|
||||
/// <param name="organization"></param>
|
||||
/// <returns></returns>
|
||||
Task Record(Organization organization);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public class NeverPlayIdServices : IPlayIdService
|
||||
{
|
||||
public string? PlayId
|
||||
{
|
||||
get => null;
|
||||
set { }
|
||||
}
|
||||
|
||||
public bool InPlay(out string playId)
|
||||
{
|
||||
playId = string.Empty;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
13
src/Core/Services/Play/Implementations/PlayIdService.cs
Normal file
13
src/Core/Services/Play/Implementations/PlayIdService.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public class PlayIdService(IHostEnvironment hostEnvironment) : IPlayIdService
|
||||
{
|
||||
public string? PlayId { get; set; }
|
||||
public bool InPlay(out string playId)
|
||||
{
|
||||
playId = PlayId ?? string.Empty;
|
||||
return !string.IsNullOrEmpty(PlayId) && hostEnvironment.IsDevelopment();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Singleton wrapper service that bridges singleton-scoped service boundaries for PlayId tracking.
|
||||
/// This allows singleton services to access the scoped PlayIdService via HttpContext.RequestServices.
|
||||
///
|
||||
/// Uses IHttpContextAccessor to retrieve the current request's scoped PlayIdService instance, enabling
|
||||
/// singleton services to participate in Play session tracking without violating DI lifetime rules.
|
||||
/// Falls back to NeverPlayIdServices when no HttpContext is available (e.g., background jobs).
|
||||
/// </summary>
|
||||
public class PlayIdSingletonService(IHttpContextAccessor httpContextAccessor, IHostEnvironment hostEnvironment) : IPlayIdService
|
||||
{
|
||||
private IPlayIdService Current
|
||||
{
|
||||
get
|
||||
{
|
||||
var httpContext = httpContextAccessor.HttpContext;
|
||||
if (httpContext == null)
|
||||
{
|
||||
return new NeverPlayIdServices();
|
||||
}
|
||||
return httpContext.RequestServices.GetRequiredService<PlayIdService>();
|
||||
}
|
||||
}
|
||||
|
||||
public string? PlayId
|
||||
{
|
||||
get => Current.PlayId;
|
||||
set => Current.PlayId = value;
|
||||
}
|
||||
|
||||
public bool InPlay(out string playId)
|
||||
{
|
||||
if (hostEnvironment.IsDevelopment())
|
||||
{
|
||||
return Current.InPlay(out playId);
|
||||
}
|
||||
else
|
||||
{
|
||||
playId = string.Empty;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
26
src/Core/Services/Play/Implementations/PlayItemService.cs
Normal file
26
src/Core/Services/Play/Implementations/PlayItemService.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Repositories;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public class PlayItemService(IPlayIdService playIdService, IPlayItemRepository playItemRepository, ILogger<PlayItemService> logger) : IPlayItemService
|
||||
{
|
||||
public async Task Record(User user)
|
||||
{
|
||||
if (playIdService.InPlay(out var playId))
|
||||
{
|
||||
logger.LogInformation("Associating user {UserId} with Play ID {PlayId}", user.Id, playId);
|
||||
await playItemRepository.CreateAsync(PlayItem.Create(user, playId));
|
||||
}
|
||||
}
|
||||
public async Task Record(Organization organization)
|
||||
{
|
||||
if (playIdService.InPlay(out var playId))
|
||||
{
|
||||
logger.LogInformation("Associating organization {OrganizationId} with Play ID {PlayId}", organization.Id, playId);
|
||||
await playItemRepository.CreateAsync(PlayItem.Create(organization, playId));
|
||||
}
|
||||
}
|
||||
}
|
||||
27
src/Core/Services/Play/README.md
Normal file
27
src/Core/Services/Play/README.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Play Services
|
||||
|
||||
## Overview
|
||||
|
||||
The Play services provide automated testing infrastructure for tracking and cleaning up test data in development
|
||||
environments. A "Play" is a test session that groups entities (users, organizations, etc.) created during testing to
|
||||
enable bulk cleanup via the SeederAPI.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Test client sends `x-play-id` header with a unique Play identifier
|
||||
2. `PlayIdMiddleware` extracts the header and sets it on `IPlayIdService`
|
||||
3. Repositories check `IPlayIdService.InPlay()` when creating entities
|
||||
4. `IPlayItemService` records PlayItem entries for tracked entities
|
||||
5. SeederAPI uses PlayItem records to bulk delete all entities associated with a PlayId
|
||||
|
||||
Play services are **only active in Development environments**.
|
||||
|
||||
## Classes
|
||||
|
||||
- **`IPlayIdService`** - Interface for managing Play identifiers in the current request scope
|
||||
- **`IPlayItemService`** - Interface for tracking entities created during a Play session
|
||||
- **`PlayIdService`** - Default scoped implementation for tracking Play sessions per HTTP request
|
||||
- **`NeverPlayIdServices`** - No-op implementation used as fallback when no HttpContext is available
|
||||
- **`PlayIdSingletonService`** - Singleton wrapper that allows singleton services to access scoped PlayIdService via
|
||||
HttpContext
|
||||
- **`PlayItemService`** - Implementation that records PlayItem entries for entities created during Play sessions
|
||||
@@ -44,6 +44,7 @@ public class GlobalSettings : IGlobalSettings
|
||||
public virtual bool EnableCloudCommunication { get; set; } = false;
|
||||
public virtual int OrganizationInviteExpirationHours { get; set; } = 120; // 5 days
|
||||
public virtual string EventGridKey { get; set; }
|
||||
public virtual bool TestPlayIdTrackingEnabled { get; set; } = false;
|
||||
public virtual IInstallationSettings Installation { get; set; } = new InstallationSettings();
|
||||
public virtual IBaseServiceUriSettings BaseServiceUri { get; set; }
|
||||
public virtual string DatabaseProvider { get; set; }
|
||||
|
||||
@@ -36,6 +36,7 @@ public class Startup
|
||||
|
||||
// Repositories
|
||||
services.AddDatabaseRepositories(globalSettings);
|
||||
services.AddTestPlayIdTracking(globalSettings);
|
||||
|
||||
// Context
|
||||
services.AddScoped<ICurrentContext, CurrentContext>();
|
||||
|
||||
@@ -30,6 +30,7 @@ public class Startup
|
||||
|
||||
// Repositories
|
||||
services.AddDatabaseRepositories(globalSettings);
|
||||
services.AddTestPlayIdTracking(globalSettings);
|
||||
|
||||
// Add event integration services
|
||||
services.AddDistributedCache(globalSettings);
|
||||
|
||||
@@ -49,6 +49,7 @@ public class Startup
|
||||
|
||||
// Repositories
|
||||
services.AddDatabaseRepositories(globalSettings);
|
||||
services.AddTestPlayIdTracking(globalSettings);
|
||||
|
||||
// Context
|
||||
services.AddScoped<ICurrentContext, CurrentContext>();
|
||||
|
||||
@@ -17,7 +17,7 @@ namespace Bit.Infrastructure.Dapper.Repositories;
|
||||
|
||||
public class OrganizationRepository : Repository<Organization, Guid>, IOrganizationRepository
|
||||
{
|
||||
private readonly ILogger<OrganizationRepository> _logger;
|
||||
protected readonly ILogger<OrganizationRepository> _logger;
|
||||
|
||||
public OrganizationRepository(
|
||||
GlobalSettings globalSettings,
|
||||
|
||||
@@ -51,6 +51,7 @@ public static class DapperServiceCollectionExtensions
|
||||
services.AddSingleton<IOrganizationRepository, OrganizationRepository>();
|
||||
services.AddSingleton<IOrganizationSponsorshipRepository, OrganizationSponsorshipRepository>();
|
||||
services.AddSingleton<IOrganizationUserRepository, OrganizationUserRepository>();
|
||||
services.AddSingleton<IPlayItemRepository, PlayItemRepository>();
|
||||
services.AddSingleton<IPolicyRepository, PolicyRepository>();
|
||||
services.AddSingleton<IProviderOrganizationRepository, ProviderOrganizationRepository>();
|
||||
services.AddSingleton<IProviderRepository, ProviderRepository>();
|
||||
|
||||
45
src/Infrastructure.Dapper/Repositories/PlayItemRepository.cs
Normal file
45
src/Infrastructure.Dapper/Repositories/PlayItemRepository.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using System.Data;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Settings;
|
||||
using Dapper;
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Infrastructure.Dapper.Repositories;
|
||||
|
||||
public class PlayItemRepository : Repository<PlayItem, Guid>, IPlayItemRepository
|
||||
{
|
||||
public PlayItemRepository(GlobalSettings globalSettings)
|
||||
: this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString)
|
||||
{ }
|
||||
|
||||
public PlayItemRepository(string connectionString, string readOnlyConnectionString)
|
||||
: base(connectionString, readOnlyConnectionString)
|
||||
{ }
|
||||
|
||||
public async Task<ICollection<PlayItem>> GetByPlayIdAsync(string playId)
|
||||
{
|
||||
using (var connection = new SqlConnection(ConnectionString))
|
||||
{
|
||||
var results = await connection.QueryAsync<PlayItem>(
|
||||
"[dbo].[PlayItem_ReadByPlayId]",
|
||||
new { PlayId = playId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
|
||||
return results.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteByPlayIdAsync(string playId)
|
||||
{
|
||||
using (var connection = new SqlConnection(ConnectionString))
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
"[dbo].[PlayItem_DeleteByPlayId]",
|
||||
new { PlayId = playId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ namespace Bit.Infrastructure.EntityFramework.Repositories;
|
||||
|
||||
public class OrganizationRepository : Repository<Core.AdminConsole.Entities.Organization, Organization, Guid>, IOrganizationRepository
|
||||
{
|
||||
private readonly ILogger<OrganizationRepository> _logger;
|
||||
protected readonly ILogger<OrganizationRepository> _logger;
|
||||
|
||||
public OrganizationRepository(
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using Bit.Infrastructure.EntityFramework.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Bit.Infrastructure.EntityFramework.Configurations;
|
||||
|
||||
public class PlayItemEntityTypeConfiguration : IEntityTypeConfiguration<PlayItem>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PlayItem> builder)
|
||||
{
|
||||
builder
|
||||
.Property(pd => pd.Id)
|
||||
.ValueGeneratedNever();
|
||||
|
||||
builder
|
||||
.HasIndex(pd => pd.PlayId)
|
||||
.IsClustered(false);
|
||||
|
||||
builder
|
||||
.HasIndex(pd => pd.UserId)
|
||||
.IsClustered(false);
|
||||
|
||||
builder
|
||||
.HasIndex(pd => pd.OrganizationId)
|
||||
.IsClustered(false);
|
||||
|
||||
builder
|
||||
.HasOne(pd => pd.User)
|
||||
.WithMany()
|
||||
.HasForeignKey(pd => pd.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder
|
||||
.HasOne(pd => pd.Organization)
|
||||
.WithMany()
|
||||
.HasForeignKey(pd => pd.OrganizationId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder
|
||||
.ToTable(nameof(PlayItem))
|
||||
.HasCheckConstraint(
|
||||
"CK_PlayItem_UserOrOrganization",
|
||||
"(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,7 @@ public static class EntityFrameworkServiceCollectionExtensions
|
||||
services.AddSingleton<IOrganizationRepository, OrganizationRepository>();
|
||||
services.AddSingleton<IOrganizationSponsorshipRepository, OrganizationSponsorshipRepository>();
|
||||
services.AddSingleton<IOrganizationUserRepository, OrganizationUserRepository>();
|
||||
services.AddSingleton<IPlayItemRepository, PlayItemRepository>();
|
||||
services.AddSingleton<IPolicyRepository, PolicyRepository>();
|
||||
services.AddSingleton<IProviderOrganizationRepository, ProviderOrganizationRepository>();
|
||||
services.AddSingleton<IProviderRepository, ProviderRepository>();
|
||||
|
||||
19
src/Infrastructure.EntityFramework/Models/PlayItem.cs
Normal file
19
src/Infrastructure.EntityFramework/Models/PlayItem.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
#nullable enable
|
||||
|
||||
using AutoMapper;
|
||||
|
||||
namespace Bit.Infrastructure.EntityFramework.Models;
|
||||
|
||||
public class PlayItem : Core.Entities.PlayItem
|
||||
{
|
||||
public virtual User? User { get; set; }
|
||||
public virtual AdminConsole.Models.Organization? Organization { get; set; }
|
||||
}
|
||||
|
||||
public class PlayItemMapperProfile : Profile
|
||||
{
|
||||
public PlayItemMapperProfile()
|
||||
{
|
||||
CreateMap<Core.Entities.PlayItem, PlayItem>().ReverseMap();
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@ public class DatabaseContext : DbContext
|
||||
public DbSet<OrganizationApiKey> OrganizationApiKeys { get; set; }
|
||||
public DbSet<OrganizationSponsorship> OrganizationSponsorships { get; set; }
|
||||
public DbSet<OrganizationConnection> OrganizationConnections { get; set; }
|
||||
public DbSet<PlayItem> PlayItem { get; set; }
|
||||
public DbSet<OrganizationIntegration> OrganizationIntegrations { get; set; }
|
||||
public DbSet<OrganizationIntegrationConfiguration> OrganizationIntegrationConfigurations { get; set; }
|
||||
public DbSet<OrganizationUser> OrganizationUsers { get; set; }
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using AutoMapper;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Infrastructure.EntityFramework.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Infrastructure.EntityFramework.Repositories;
|
||||
|
||||
public class PlayItemRepository : Repository<Core.Entities.PlayItem, PlayItem, Guid>, IPlayItemRepository
|
||||
{
|
||||
public PlayItemRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper)
|
||||
: base(serviceScopeFactory, mapper, (DatabaseContext context) => context.PlayItem)
|
||||
{ }
|
||||
|
||||
public async Task<ICollection<Core.Entities.PlayItem>> GetByPlayIdAsync(string playId)
|
||||
{
|
||||
using (var scope = ServiceScopeFactory.CreateScope())
|
||||
{
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
var playItemEntities = await GetDbSet(dbContext)
|
||||
.Where(pd => pd.PlayId == playId)
|
||||
.ToListAsync();
|
||||
return Mapper.Map<List<Core.Entities.PlayItem>>(playItemEntities);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteByPlayIdAsync(string playId)
|
||||
{
|
||||
using (var scope = ServiceScopeFactory.CreateScope())
|
||||
{
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
var entities = await GetDbSet(dbContext)
|
||||
.Where(pd => pd.PlayId == playId)
|
||||
.ToListAsync();
|
||||
|
||||
dbContext.PlayItem.RemoveRange(entities);
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
30
src/SharedWeb/Play/PlayServiceCollectionExtensions.cs
Normal file
30
src/SharedWeb/Play/PlayServiceCollectionExtensions.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.SharedWeb.Play.Repositories;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Bit.SharedWeb.Play;
|
||||
|
||||
public static class PlayServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds PlayId tracking decorators for User and Organization repositories using Dapper implementations.
|
||||
/// This replaces the standard repository implementations with tracking versions
|
||||
/// that record created entities for test data cleanup. Only call when TestPlayIdTrackingEnabled is true.
|
||||
/// </summary>
|
||||
public static void AddPlayIdTrackingDapperRepositories(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IOrganizationRepository, DapperTestOrganizationTrackingOrganizationRepository>();
|
||||
services.AddSingleton<IUserRepository, DapperTestUserTrackingUserRepository>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds PlayId tracking decorators for User and Organization repositories using EntityFramework implementations.
|
||||
/// This replaces the standard repository implementations with tracking versions
|
||||
/// that record created entities for test data cleanup. Only call when TestPlayIdTrackingEnabled is true.
|
||||
/// </summary>
|
||||
public static void AddPlayIdTrackingEFRepositories(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IOrganizationRepository, EFTestOrganizationTrackingOrganizationRepository>();
|
||||
services.AddSingleton<IUserRepository, EFTestUserTrackingUserRepository>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Infrastructure.Dapper.Repositories;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Bit.SharedWeb.Play.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Dapper decorator around the <see cref="Bit.Infrastructure.Dapper.Repositories.OrganizationRepository"/> that tracks
|
||||
/// created Organizations for seeding.
|
||||
/// </summary>
|
||||
public class DapperTestOrganizationTrackingOrganizationRepository : OrganizationRepository
|
||||
{
|
||||
private readonly IPlayItemService _playItemService;
|
||||
|
||||
public DapperTestOrganizationTrackingOrganizationRepository(
|
||||
IPlayItemService playItemService,
|
||||
GlobalSettings globalSettings,
|
||||
ILogger<OrganizationRepository> logger)
|
||||
: base(globalSettings, logger)
|
||||
{
|
||||
_playItemService = playItemService;
|
||||
}
|
||||
|
||||
public override async Task<Organization> CreateAsync(Organization obj)
|
||||
{
|
||||
var createdOrganization = await base.CreateAsync(obj);
|
||||
await _playItemService.Record(createdOrganization);
|
||||
return createdOrganization;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Infrastructure.Dapper.Repositories;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
|
||||
namespace Bit.SharedWeb.Play.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Dapper decorator around the <see cref="Bit.Infrastructure.Dapper.Repositories.UserRepository"/> that tracks
|
||||
/// created Users for seeding.
|
||||
/// </summary>
|
||||
public class DapperTestUserTrackingUserRepository : UserRepository
|
||||
{
|
||||
private readonly IPlayItemService _playItemService;
|
||||
|
||||
public DapperTestUserTrackingUserRepository(
|
||||
IPlayItemService playItemService,
|
||||
GlobalSettings globalSettings,
|
||||
IDataProtectionProvider dataProtectionProvider)
|
||||
: base(globalSettings, dataProtectionProvider)
|
||||
{
|
||||
_playItemService = playItemService;
|
||||
}
|
||||
|
||||
public override async Task<User> CreateAsync(User user)
|
||||
{
|
||||
var createdUser = await base.CreateAsync(user);
|
||||
|
||||
await _playItemService.Record(createdUser);
|
||||
return createdUser;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using AutoMapper;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Infrastructure.EntityFramework.Repositories;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Bit.SharedWeb.Play.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EntityFramework decorator around the <see cref="Bit.Infrastructure.EntityFramework.Repositories.OrganizationRepository"/> that tracks
|
||||
/// created Organizations for seeding.
|
||||
/// </summary>
|
||||
public class EFTestOrganizationTrackingOrganizationRepository : OrganizationRepository
|
||||
{
|
||||
private readonly IPlayItemService _playItemService;
|
||||
|
||||
public EFTestOrganizationTrackingOrganizationRepository(
|
||||
IPlayItemService playItemService,
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
IMapper mapper,
|
||||
ILogger<OrganizationRepository> logger)
|
||||
: base(serviceScopeFactory, mapper, logger)
|
||||
{
|
||||
_playItemService = playItemService;
|
||||
}
|
||||
|
||||
public override async Task<Core.AdminConsole.Entities.Organization> CreateAsync(Core.AdminConsole.Entities.Organization organization)
|
||||
{
|
||||
var createdOrganization = await base.CreateAsync(organization);
|
||||
await _playItemService.Record(createdOrganization);
|
||||
return createdOrganization;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using AutoMapper;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Infrastructure.EntityFramework.Repositories;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Bit.SharedWeb.Play.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EntityFramework decorator around the <see cref="Bit.Infrastructure.EntityFramework.Repositories.UserRepository"/> that tracks
|
||||
/// created Users for seeding.
|
||||
/// </summary>
|
||||
public class EFTestUserTrackingUserRepository : UserRepository
|
||||
{
|
||||
private readonly IPlayItemService _playItemService;
|
||||
|
||||
public EFTestUserTrackingUserRepository(
|
||||
IPlayItemService playItemService,
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
IMapper mapper)
|
||||
: base(serviceScopeFactory, mapper)
|
||||
{
|
||||
_playItemService = playItemService;
|
||||
}
|
||||
|
||||
public override async Task<Core.Entities.User> CreateAsync(Core.Entities.User user)
|
||||
{
|
||||
var createdUser = await base.CreateAsync(user);
|
||||
await _playItemService.Record(createdUser);
|
||||
return createdUser;
|
||||
}
|
||||
}
|
||||
41
src/SharedWeb/Utilities/PlayIdMiddleware.cs
Normal file
41
src/SharedWeb/Utilities/PlayIdMiddleware.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using Bit.Core.Services;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Bit.SharedWeb.Utilities;
|
||||
|
||||
/// <summary>
|
||||
/// Middleware to extract the x-play-id header and set it in the PlayIdService.
|
||||
///
|
||||
/// PlayId is used in testing infrastructure to track data created during automated testing and fa cilitate cleanup.
|
||||
/// </summary>
|
||||
/// <param name="next"></param>
|
||||
public sealed class PlayIdMiddleware(RequestDelegate next)
|
||||
{
|
||||
private const int MaxPlayIdLength = 256;
|
||||
|
||||
public async Task Invoke(HttpContext context, PlayIdService playIdService)
|
||||
{
|
||||
if (context.Request.Headers.TryGetValue("x-play-id", out var playId))
|
||||
{
|
||||
var playIdValue = playId.ToString();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(playIdValue))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
await context.Response.WriteAsJsonAsync(new { Error = "x-play-id header cannot be empty or whitespace" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (playIdValue.Length > MaxPlayIdLength)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
await context.Response.WriteAsJsonAsync(new { Error = $"x-play-id header cannot exceed {MaxPlayIdLength} characters" });
|
||||
return;
|
||||
}
|
||||
|
||||
playIdService.PlayId = playIdValue;
|
||||
}
|
||||
|
||||
await next(context);
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,7 @@ using Bit.Core.Vault;
|
||||
using Bit.Core.Vault.Services;
|
||||
using Bit.Infrastructure.Dapper;
|
||||
using Bit.Infrastructure.EntityFramework;
|
||||
using Bit.SharedWeb.Play;
|
||||
using DnsClient;
|
||||
using Duende.IdentityModel;
|
||||
using LaunchDarkly.Sdk.Server;
|
||||
@@ -117,6 +118,40 @@ public static class ServiceCollectionExtensions
|
||||
return provider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers test PlayId tracking services for test data management and cleanup.
|
||||
/// This infrastructure is isolated to test environments and enables tracking of test-generated entities.
|
||||
/// </summary>
|
||||
public static void AddTestPlayIdTracking(this IServiceCollection services, GlobalSettings globalSettings)
|
||||
{
|
||||
if (globalSettings.TestPlayIdTrackingEnabled)
|
||||
{
|
||||
var (provider, _) = GetDatabaseProvider(globalSettings);
|
||||
|
||||
// Include PlayIdService for tracking Play Ids in repositories
|
||||
// We need the http context accessor to use the Singleton version, which pulls from the scoped version
|
||||
services.AddHttpContextAccessor();
|
||||
|
||||
services.AddSingleton<IPlayItemService, PlayItemService>();
|
||||
services.AddSingleton<IPlayIdService, PlayIdSingletonService>();
|
||||
services.AddScoped<PlayIdService>();
|
||||
|
||||
// Replace standard repositories with PlayId tracking decorators
|
||||
if (provider == SupportedDatabaseProviders.SqlServer)
|
||||
{
|
||||
services.AddPlayIdTrackingDapperRepositories();
|
||||
}
|
||||
else
|
||||
{
|
||||
services.AddPlayIdTrackingEFRepositories();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
services.AddSingleton<IPlayIdService, NeverPlayIdServices>();
|
||||
}
|
||||
}
|
||||
|
||||
public static void AddBaseServices(this IServiceCollection services, IGlobalSettings globalSettings)
|
||||
{
|
||||
services.AddScoped<ICipherService, CipherService>();
|
||||
@@ -522,6 +557,10 @@ public static class ServiceCollectionExtensions
|
||||
IWebHostEnvironment env, GlobalSettings globalSettings)
|
||||
{
|
||||
app.UseMiddleware<RequestLoggingMiddleware>();
|
||||
if (globalSettings.TestPlayIdTrackingEnabled)
|
||||
{
|
||||
app.UseMiddleware<PlayIdMiddleware>();
|
||||
}
|
||||
}
|
||||
|
||||
public static void UseForwardedHeaders(this IApplicationBuilder app, IGlobalSettings globalSettings)
|
||||
|
||||
27
src/Sql/dbo/Stored Procedures/PlayItem_Create.sql
Normal file
27
src/Sql/dbo/Stored Procedures/PlayItem_Create.sql
Normal file
@@ -0,0 +1,27 @@
|
||||
CREATE PROCEDURE [dbo].[PlayItem_Create]
|
||||
@Id UNIQUEIDENTIFIER OUTPUT,
|
||||
@PlayId NVARCHAR(256),
|
||||
@UserId UNIQUEIDENTIFIER,
|
||||
@OrganizationId UNIQUEIDENTIFIER,
|
||||
@CreationDate DATETIME2(7)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
INSERT INTO [dbo].[PlayItem]
|
||||
(
|
||||
[Id],
|
||||
[PlayId],
|
||||
[UserId],
|
||||
[OrganizationId],
|
||||
[CreationDate]
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
@Id,
|
||||
@PlayId,
|
||||
@UserId,
|
||||
@OrganizationId,
|
||||
@CreationDate
|
||||
)
|
||||
END
|
||||
12
src/Sql/dbo/Stored Procedures/PlayItem_DeleteByPlayId.sql
Normal file
12
src/Sql/dbo/Stored Procedures/PlayItem_DeleteByPlayId.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
CREATE PROCEDURE [dbo].[PlayItem_DeleteByPlayId]
|
||||
@PlayId NVARCHAR(256)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
DELETE
|
||||
FROM
|
||||
[dbo].[PlayItem]
|
||||
WHERE
|
||||
[PlayId] = @PlayId
|
||||
END
|
||||
17
src/Sql/dbo/Stored Procedures/PlayItem_ReadByPlayId.sql
Normal file
17
src/Sql/dbo/Stored Procedures/PlayItem_ReadByPlayId.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
CREATE PROCEDURE [dbo].[PlayItem_ReadByPlayId]
|
||||
@PlayId NVARCHAR(256)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
SELECT
|
||||
[Id],
|
||||
[PlayId],
|
||||
[UserId],
|
||||
[OrganizationId],
|
||||
[CreationDate]
|
||||
FROM
|
||||
[dbo].[PlayItem]
|
||||
WHERE
|
||||
[PlayId] = @PlayId
|
||||
END
|
||||
23
src/Sql/dbo/Tables/PlayItem.sql
Normal file
23
src/Sql/dbo/Tables/PlayItem.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
CREATE TABLE [dbo].[PlayItem] (
|
||||
[Id] UNIQUEIDENTIFIER NOT NULL,
|
||||
[PlayId] NVARCHAR (256) NOT NULL,
|
||||
[UserId] UNIQUEIDENTIFIER NULL,
|
||||
[OrganizationId] UNIQUEIDENTIFIER NULL,
|
||||
[CreationDate] DATETIME2 (7) NOT NULL,
|
||||
CONSTRAINT [PK_PlayItem] PRIMARY KEY CLUSTERED ([Id] ASC),
|
||||
CONSTRAINT [FK_PlayItem_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]) ON DELETE CASCADE,
|
||||
CONSTRAINT [FK_PlayItem_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE,
|
||||
CONSTRAINT [CK_PlayItem_UserOrOrganization] CHECK (([UserId] IS NOT NULL AND [OrganizationId] IS NULL) OR ([UserId] IS NULL AND [OrganizationId] IS NOT NULL))
|
||||
);
|
||||
|
||||
GO
|
||||
CREATE NONCLUSTERED INDEX [IX_PlayItem_PlayId]
|
||||
ON [dbo].[PlayItem]([PlayId] ASC);
|
||||
|
||||
GO
|
||||
CREATE NONCLUSTERED INDEX [IX_PlayItem_UserId]
|
||||
ON [dbo].[PlayItem]([UserId] ASC);
|
||||
|
||||
GO
|
||||
CREATE NONCLUSTERED INDEX [IX_PlayItem_OrganizationId]
|
||||
ON [dbo].[PlayItem]([OrganizationId] ASC);
|
||||
Reference in New Issue
Block a user