1
0
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:
Oscar Hinton
2026-01-13 18:10:01 +01:00
committed by GitHub
parent a9f78487ef
commit f144828a87
105 changed files with 14377 additions and 322 deletions

View File

@@ -65,6 +65,7 @@ public class Startup
default:
break;
}
services.AddTestPlayIdTracking(globalSettings);
// Context
services.AddScoped<ICurrentContext, CurrentContext>();

View File

@@ -36,7 +36,7 @@ public class EmergencyAccessUpdateRequestModel
existingEmergencyAccess.KeyEncrypted = KeyEncrypted;
}
existingEmergencyAccess.Type = Type;
existingEmergencyAccess.WaitTimeDays = WaitTimeDays;
existingEmergencyAccess.WaitTimeDays = (short)WaitTimeDays;
return existingEmergencyAccess;
}
}

View File

@@ -85,6 +85,7 @@ public class Startup
// Repositories
services.AddDatabaseRepositories(globalSettings);
services.AddTestPlayIdTracking(globalSettings);
// Context
services.AddScoped<ICurrentContext, CurrentContext>();

View File

@@ -48,6 +48,7 @@ public class Startup
// Repositories
services.AddDatabaseRepositories(globalSettings);
services.AddTestPlayIdTracking(globalSettings);
// PayPal IPN Client
services.AddHttpClient<IPayPalIPNClient, PayPalIPNClient>();

View File

@@ -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;

View File

@@ -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,
};

View 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
};
}
}

View 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);
}

View 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);
}

View 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);
}

View File

@@ -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;
}
}

View 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();
}
}

View File

@@ -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;
}
}
}

View 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));
}
}
}

View 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

View File

@@ -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; }

View File

@@ -36,6 +36,7 @@ public class Startup
// Repositories
services.AddDatabaseRepositories(globalSettings);
services.AddTestPlayIdTracking(globalSettings);
// Context
services.AddScoped<ICurrentContext, CurrentContext>();

View File

@@ -30,6 +30,7 @@ public class Startup
// Repositories
services.AddDatabaseRepositories(globalSettings);
services.AddTestPlayIdTracking(globalSettings);
// Add event integration services
services.AddDistributedCache(globalSettings);

View File

@@ -49,6 +49,7 @@ public class Startup
// Repositories
services.AddDatabaseRepositories(globalSettings);
services.AddTestPlayIdTracking(globalSettings);
// Context
services.AddScoped<ICurrentContext, CurrentContext>();

View File

@@ -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,

View File

@@ -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>();

View 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);
}
}
}

View File

@@ -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,

View File

@@ -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)"
);
}
}

View File

@@ -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>();

View 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();
}
}

View File

@@ -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; }

View File

@@ -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();
}
}
}

View 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>();
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View 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);
}
}

View File

@@ -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)

View 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

View 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

View 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

View 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);