1
0
mirror of https://github.com/bitwarden/server synced 2026-01-14 22:43:19 +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

84
.vscode/launch.json vendored
View File

@@ -69,6 +69,28 @@
"preLaunchTask": "buildFullServer",
"stopAll": true
},
{
"name": "Full Server with Seeder API",
"configurations": [
"run-Admin",
"run-API",
"run-Events",
"run-EventsProcessor",
"run-Identity",
"run-Sso",
"run-Icons",
"run-Billing",
"run-Notifications",
"run-SeederAPI"
],
"presentation": {
"hidden": false,
"group": "AA_compounds",
"order": 6
},
"preLaunchTask": "buildFullServerWithSeederApi",
"stopAll": true
},
{
"name": "Self Host: Bit",
"configurations": [
@@ -204,6 +226,17 @@
},
"preLaunchTask": "buildSso",
},
{
"name": "Seeder API",
"configurations": [
"run-SeederAPI"
],
"presentation": {
"hidden": false,
"group": "cloud",
},
"preLaunchTask": "buildSeederAPI",
},
{
"name": "Admin Self Host",
"configurations": [
@@ -270,6 +303,17 @@
},
"preLaunchTask": "buildSso",
},
{
"name": "Seeder API Self Host",
"configurations": [
"run-SeederAPI-SelfHost"
],
"presentation": {
"hidden": false,
"group": "self-host",
},
"preLaunchTask": "buildSeederAPI",
}
],
"configurations": [
// Configurations represent run-only scenarios so that they can be used in multiple compounds
@@ -311,6 +355,25 @@
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": "run-SeederAPI",
"presentation": {
"hidden": true,
},
"requireExactSource": true,
"type": "coreclr",
"request": "launch",
"program": "${workspaceFolder}/util/SeederApi/bin/Debug/net8.0/SeederApi.dll",
"args": [],
"cwd": "${workspaceFolder}/util/SeederApi",
"stopAtEntry": false,
"env": {
"ASPNETCORE_ENVIRONMENT": "Development",
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": "run-Billing",
"presentation": {
@@ -488,6 +551,27 @@
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": "run-SeederAPI-SelfHost",
"presentation": {
"hidden": true,
},
"requireExactSource": true,
"type": "coreclr",
"request": "launch",
"program": "${workspaceFolder}/util/SeederApi/bin/Debug/net8.0/SeederApi.dll",
"args": [],
"cwd": "${workspaceFolder}/util/SeederApi",
"stopAtEntry": false,
"env": {
"ASPNETCORE_ENVIRONMENT": "Development",
"ASPNETCORE_URLS": "http://localhost:5048",
"developSelfHosted": "true",
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": "run-Admin-SelfHost",
"presentation": {

69
.vscode/tasks.json vendored
View File

@@ -43,6 +43,21 @@
"label": "buildFullServer",
"hide": true,
"dependsOrder": "sequence",
"dependsOn": [
"buildAdmin",
"buildAPI",
"buildEventsProcessor",
"buildIdentity",
"buildSso",
"buildIcons",
"buildBilling",
"buildNotifications"
],
},
{
"label": "buildFullServerWithSeederApi",
"hide": true,
"dependsOrder": "sequence",
"dependsOn": [
"buildAdmin",
"buildAPI",
@@ -52,6 +67,7 @@
"buildIcons",
"buildBilling",
"buildNotifications",
"buildSeederAPI"
],
},
{
@@ -89,6 +105,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile"
},
{
@@ -102,6 +121,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile"
},
{
@@ -115,6 +137,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile"
},
{
@@ -128,6 +153,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile"
},
{
@@ -141,6 +169,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile"
},
{
@@ -154,6 +185,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile"
},
{
@@ -167,6 +201,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile"
},
{
@@ -180,6 +217,29 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile",
"group": {
"kind": "build",
"isDefault": true
}
},
{
"label": "buildSeederAPI",
"hide": true,
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/util/SeederApi/SeederApi.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile",
"group": {
"kind": "build",
@@ -197,6 +257,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile",
"group": {
"kind": "build",
@@ -214,6 +277,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile",
"group": {
"kind": "build",
@@ -224,6 +290,9 @@
"label": "test",
"type": "shell",
"command": "dotnet test",
"options": {
"cwd": "${workspaceFolder}"
},
"group": {
"kind": "test",
"isDefault": true

View File

@@ -137,6 +137,9 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RustSdk", "util\RustSdk\Rus
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharedWeb.Test", "test\SharedWeb.Test\SharedWeb.Test.csproj", "{AD59537D-5259-4B7A-948F-0CF58E80B359}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeederApi", "util\SeederApi\SeederApi.csproj", "{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeederApi.IntegrationTest", "test\SeederApi.IntegrationTest\SeederApi.IntegrationTest.csproj", "{A2E067EF-609C-4D13-895A-E054C61D48BB}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SSO.Test", "bitwarden_license\test\SSO.Test\SSO.Test.csproj", "{7D98784C-C253-43FB-9873-25B65C6250D6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sso.IntegrationTest", "bitwarden_license\test\Sso.IntegrationTest\Sso.IntegrationTest.csproj", "{FFB09376-595B-6F93-36F0-70CAE90AFECB}"
@@ -353,6 +356,14 @@ Global
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.Build.0 = Release|Any CPU
{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}.Release|Any CPU.Build.0 = Release|Any CPU
{A2E067EF-609C-4D13-895A-E054C61D48BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A2E067EF-609C-4D13-895A-E054C61D48BB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A2E067EF-609C-4D13-895A-E054C61D48BB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A2E067EF-609C-4D13-895A-E054C61D48BB}.Release|Any CPU.Build.0 = Release|Any CPU
{7D98784C-C253-43FB-9873-25B65C6250D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7D98784C-C253-43FB-9873-25B65C6250D6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7D98784C-C253-43FB-9873-25B65C6250D6}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -417,6 +428,8 @@ Global
{17A89266-260A-4A03-81AE-C0468C6EE06E} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
{AD59537D-5259-4B7A-948F-0CF58E80B359} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
{A2E067EF-609C-4D13-895A-E054C61D48BB} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{7D98784C-C253-43FB-9873-25B65C6250D6} = {287CFF34-BBDB-4BC4-AF88-1E19A5A4679B}
{FFB09376-595B-6F93-36F0-70CAE90AFECB} = {287CFF34-BBDB-4BC4-AF88-1E19A5A4679B}
EndGlobalSection

View File

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

View File

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

23
dev/setup_secrets.ps1 Normal file → Executable file
View File

@@ -2,7 +2,7 @@
# Helper script for applying the same user secrets to each project
param (
[switch]$clear,
[Parameter(ValueFromRemainingArguments = $true, Position=1)]
[Parameter(ValueFromRemainingArguments = $true, Position = 1)]
$cmdArgs
)
@@ -16,17 +16,18 @@ if ($clear -eq $true) {
}
$projects = @{
Admin = "../src/Admin"
Api = "../src/Api"
Billing = "../src/Billing"
Events = "../src/Events"
EventsProcessor = "../src/EventsProcessor"
Icons = "../src/Icons"
Identity = "../src/Identity"
Notifications = "../src/Notifications"
Sso = "../bitwarden_license/src/Sso"
Scim = "../bitwarden_license/src/Scim"
Admin = "../src/Admin"
Api = "../src/Api"
Billing = "../src/Billing"
Events = "../src/Events"
EventsProcessor = "../src/EventsProcessor"
Icons = "../src/Icons"
Identity = "../src/Identity"
Notifications = "../src/Notifications"
Sso = "../bitwarden_license/src/Sso"
Scim = "../bitwarden_license/src/Scim"
IntegrationTests = "../test/Infrastructure.IntegrationTest"
SeederApi = "../util/SeederApi"
}
foreach ($key in $projects.keys) {

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

View File

@@ -26,6 +26,7 @@ public class SutProvider<TSut> : ISutProvider
public TSut Sut { get; private set; }
public Type SutType => typeof(TSut);
public IFixture Fixture => _fixture;
public SutProvider() : this(new Fixture()) { }
@@ -65,6 +66,19 @@ public class SutProvider<TSut> : ISutProvider
return this;
}
/// <summary>
/// Creates and registers a dependency to be injected when the sut is created.
/// </summary>
/// <typeparam name="TDep">The Dependency type to create</typeparam>
/// <param name="parameterName">The (optional) parameter name to register the dependency under</param>
/// <returns>The created dependency value</returns>
public TDep CreateDependency<TDep>(string parameterName = "")
{
var dependency = _fixture.Create<TDep>();
SetDependency(dependency, parameterName);
return dependency;
}
/// <summary>
/// Gets a dependency of the sut. Can only be called after the dependency has been set, either explicitly with
/// <see cref="SetDependency{T}"/> or automatically with <see cref="Create"/>.

View File

@@ -0,0 +1,211 @@
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.Services;
[SutProviderCustomize]
public class PlayIdServiceTests
{
[Theory]
[BitAutoData]
public void InPlay_WhenPlayIdSetAndDevelopment_ReturnsTrue(
string playId,
SutProvider<PlayIdService> sutProvider)
{
sutProvider.GetDependency<IHostEnvironment>().EnvironmentName.Returns(Environments.Development);
sutProvider.Sut.PlayId = playId;
var result = sutProvider.Sut.InPlay(out var resultPlayId);
Assert.True(result);
Assert.Equal(playId, resultPlayId);
}
[Theory]
[BitAutoData]
public void InPlay_WhenPlayIdSetButNotDevelopment_ReturnsFalse(
string playId,
SutProvider<PlayIdService> sutProvider)
{
sutProvider.GetDependency<IHostEnvironment>().EnvironmentName.Returns(Environments.Production);
sutProvider.Sut.PlayId = playId;
var result = sutProvider.Sut.InPlay(out var resultPlayId);
Assert.False(result);
Assert.Equal(playId, resultPlayId);
}
[Theory]
[BitAutoData((string?)null)]
[BitAutoData("")]
public void InPlay_WhenPlayIdNullOrEmptyAndDevelopment_ReturnsFalse(
string? playId,
SutProvider<PlayIdService> sutProvider)
{
sutProvider.GetDependency<IHostEnvironment>().EnvironmentName.Returns(Environments.Development);
sutProvider.Sut.PlayId = playId;
var result = sutProvider.Sut.InPlay(out var resultPlayId);
Assert.False(result);
Assert.Empty(resultPlayId);
}
[Theory]
[BitAutoData]
public void PlayId_CanGetAndSet(string playId)
{
var hostEnvironment = Substitute.For<IHostEnvironment>();
var sut = new PlayIdService(hostEnvironment);
sut.PlayId = playId;
Assert.Equal(playId, sut.PlayId);
}
}
[SutProviderCustomize]
public class NeverPlayIdServicesTests
{
[Fact]
public void InPlay_ReturnsFalse()
{
var sut = new NeverPlayIdServices();
var result = sut.InPlay(out var playId);
Assert.False(result);
Assert.Empty(playId);
}
[Theory]
[InlineData("test-play-id")]
[InlineData(null)]
public void PlayId_SetterDoesNothing_GetterReturnsNull(string? value)
{
var sut = new NeverPlayIdServices();
sut.PlayId = value;
Assert.Null(sut.PlayId);
}
}
[SutProviderCustomize]
public class PlayIdSingletonServiceTests
{
public static IEnumerable<object[]> SutProvider()
{
var sutProvider = new SutProvider<PlayIdSingletonService>();
var httpContext = sutProvider.CreateDependency<HttpContext>();
var serviceProvider = sutProvider.CreateDependency<IServiceProvider>();
var hostEnvironment = sutProvider.CreateDependency<IHostEnvironment>();
var playIdService = new PlayIdService(hostEnvironment);
sutProvider.SetDependency(playIdService);
httpContext.RequestServices.Returns(serviceProvider);
serviceProvider.GetService<PlayIdService>().Returns(playIdService);
serviceProvider.GetRequiredService<PlayIdService>().Returns(playIdService);
sutProvider.CreateDependency<IHttpContextAccessor>().HttpContext.Returns(httpContext);
sutProvider.Create();
return [[sutProvider]];
}
private void PrepHttpContext(
SutProvider<PlayIdSingletonService> sutProvider)
{
var httpContext = sutProvider.CreateDependency<HttpContext>();
var serviceProvider = sutProvider.CreateDependency<IServiceProvider>();
var PlayIdService = sutProvider.CreateDependency<PlayIdService>();
httpContext.RequestServices.Returns(serviceProvider);
serviceProvider.GetRequiredService<PlayIdService>().Returns(PlayIdService);
sutProvider.GetDependency<IHttpContextAccessor>().HttpContext.Returns(httpContext);
}
[Theory]
[BitMemberAutoData(nameof(SutProvider))]
public void InPlay_WhenNoHttpContext_ReturnsFalse(
SutProvider<PlayIdSingletonService> sutProvider)
{
sutProvider.GetDependency<IHttpContextAccessor>().HttpContext.Returns((HttpContext?)null);
sutProvider.GetDependency<IHostEnvironment>().EnvironmentName.Returns(Environments.Development);
var result = sutProvider.Sut.InPlay(out var playId);
Assert.False(result);
Assert.Empty(playId);
}
[Theory]
[BitMemberAutoData(nameof(SutProvider))]
public void InPlay_WhenNotDevelopment_ReturnsFalse(
SutProvider<PlayIdSingletonService> sutProvider,
string playIdValue)
{
var scopedPlayIdService = sutProvider.GetDependency<PlayIdService>();
scopedPlayIdService.PlayId = playIdValue;
sutProvider.GetDependency<IHostEnvironment>().EnvironmentName.Returns(Environments.Production);
var result = sutProvider.Sut.InPlay(out var playId);
Assert.False(result);
Assert.Empty(playId);
}
[Theory]
[BitMemberAutoData(nameof(SutProvider))]
public void InPlay_WhenDevelopmentAndHttpContextWithPlayId_ReturnsTrue(
SutProvider<PlayIdSingletonService> sutProvider,
string playIdValue)
{
sutProvider.GetDependency<PlayIdService>().PlayId = playIdValue;
sutProvider.GetDependency<IHostEnvironment>().EnvironmentName.Returns(Environments.Development);
var result = sutProvider.Sut.InPlay(out var playId);
Assert.True(result);
Assert.Equal(playIdValue, playId);
}
[Theory]
[BitMemberAutoData(nameof(SutProvider))]
public void PlayId_SetterSetsOnScopedService(
SutProvider<PlayIdSingletonService> sutProvider,
string playIdValue)
{
var scopedPlayIdService = sutProvider.GetDependency<PlayIdService>();
sutProvider.Sut.PlayId = playIdValue;
Assert.Equal(playIdValue, scopedPlayIdService.PlayId);
}
[Theory]
[BitMemberAutoData(nameof(SutProvider))]
public void PlayId_WhenNoHttpContext_GetterReturnsNull(
SutProvider<PlayIdSingletonService> sutProvider)
{
sutProvider.GetDependency<IHttpContextAccessor>().HttpContext.Returns((HttpContext?)null);
var result = sutProvider.Sut.PlayId;
Assert.Null(result);
}
[Theory]
[BitMemberAutoData(nameof(SutProvider))]
public void PlayId_WhenNoHttpContext_SetterDoesNotThrow(
SutProvider<PlayIdSingletonService> sutProvider,
string playIdValue)
{
sutProvider.GetDependency<IHttpContextAccessor>().HttpContext.Returns((HttpContext?)null);
sutProvider.Sut.PlayId = playIdValue;
}
}

View File

@@ -0,0 +1,143 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Entities;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.Services;
[SutProviderCustomize]
public class PlayItemServiceTests
{
[Theory]
[BitAutoData]
public async Task Record_User_WhenInPlay_RecordsPlayItem(
string playId,
User user,
SutProvider<PlayItemService> sutProvider)
{
sutProvider.GetDependency<IPlayIdService>()
.InPlay(out Arg.Any<string>())
.Returns(x =>
{
x[0] = playId;
return true;
});
await sutProvider.Sut.Record(user);
await sutProvider.GetDependency<IPlayItemRepository>()
.Received(1)
.CreateAsync(Arg.Is<PlayItem>(pd =>
pd.PlayId == playId &&
pd.UserId == user.Id &&
pd.OrganizationId == null));
sutProvider.GetDependency<ILogger<PlayItemService>>()
.Received(1)
.Log(
LogLevel.Information,
Arg.Any<EventId>(),
Arg.Is<object>(o => o.ToString().Contains(user.Id.ToString()) && o.ToString().Contains(playId)),
null,
Arg.Any<Func<object, Exception?, string>>());
}
[Theory]
[BitAutoData]
public async Task Record_User_WhenNotInPlay_DoesNotRecordPlayItem(
User user,
SutProvider<PlayItemService> sutProvider)
{
sutProvider.GetDependency<IPlayIdService>()
.InPlay(out Arg.Any<string>())
.Returns(x =>
{
x[0] = null;
return false;
});
await sutProvider.Sut.Record(user);
await sutProvider.GetDependency<IPlayItemRepository>()
.DidNotReceive()
.CreateAsync(Arg.Any<PlayItem>());
sutProvider.GetDependency<ILogger<PlayItemService>>()
.DidNotReceive()
.Log(
LogLevel.Information,
Arg.Any<EventId>(),
Arg.Any<object>(),
Arg.Any<Exception>(),
Arg.Any<Func<object, Exception?, string>>());
}
[Theory]
[BitAutoData]
public async Task Record_Organization_WhenInPlay_RecordsPlayItem(
string playId,
Organization organization,
SutProvider<PlayItemService> sutProvider)
{
sutProvider.GetDependency<IPlayIdService>()
.InPlay(out Arg.Any<string>())
.Returns(x =>
{
x[0] = playId;
return true;
});
await sutProvider.Sut.Record(organization);
await sutProvider.GetDependency<IPlayItemRepository>()
.Received(1)
.CreateAsync(Arg.Is<PlayItem>(pd =>
pd.PlayId == playId &&
pd.OrganizationId == organization.Id &&
pd.UserId == null));
sutProvider.GetDependency<ILogger<PlayItemService>>()
.Received(1)
.Log(
LogLevel.Information,
Arg.Any<EventId>(),
Arg.Is<object>(o => o.ToString().Contains(organization.Id.ToString()) && o.ToString().Contains(playId)),
null,
Arg.Any<Func<object, Exception?, string>>());
}
[Theory]
[BitAutoData]
public async Task Record_Organization_WhenNotInPlay_DoesNotRecordPlayItem(
Organization organization,
SutProvider<PlayItemService> sutProvider)
{
sutProvider.GetDependency<IPlayIdService>()
.InPlay(out Arg.Any<string>())
.Returns(x =>
{
x[0] = null;
return false;
});
await sutProvider.Sut.Record(organization);
await sutProvider.GetDependency<IPlayItemRepository>()
.DidNotReceive()
.CreateAsync(Arg.Any<PlayItem>());
sutProvider.GetDependency<ILogger<PlayItemService>>()
.DidNotReceive()
.Log(
LogLevel.Information,
Arg.Any<EventId>(),
Arg.Any<object>(),
Arg.Any<Exception>(),
Arg.Any<Func<object, Exception?, string>>());
}
}

View File

@@ -128,7 +128,6 @@ public class DatabaseDataAttribute : DataAttribute
private void AddDapperServices(IServiceCollection services, Database database)
{
services.AddDapperRepositories(SelfHosted);
var globalSettings = new GlobalSettings
{
DatabaseProvider = "sqlServer",
@@ -141,6 +140,7 @@ public class DatabaseDataAttribute : DataAttribute
UserRequestExpiration = TimeSpan.FromMinutes(15),
}
};
services.AddDapperRepositories(SelfHosted);
services.AddSingleton(globalSettings);
services.AddSingleton<IGlobalSettings>(globalSettings);
services.AddSingleton(database);
@@ -160,7 +160,6 @@ public class DatabaseDataAttribute : DataAttribute
private void AddEfServices(IServiceCollection services, Database database)
{
services.SetupEntityFramework(database.ConnectionString, database.Type);
services.AddPasswordManagerEFRepositories(SelfHosted);
var globalSettings = new GlobalSettings
{
@@ -169,6 +168,7 @@ public class DatabaseDataAttribute : DataAttribute
UserRequestExpiration = TimeSpan.FromMinutes(15),
},
};
services.AddPasswordManagerEFRepositories(SelfHosted);
services.AddSingleton(globalSettings);
services.AddSingleton<IGlobalSettings>(globalSettings);

View File

@@ -47,7 +47,7 @@ public abstract class WebApplicationFactoryBase<T> : WebApplicationFactory<T>
/// </remarks>
public bool ManagesDatabase { get; set; } = true;
private readonly List<Action<IServiceCollection>> _configureTestServices = new();
protected readonly List<Action<IServiceCollection>> _configureTestServices = new();
private readonly List<Action<IConfigurationBuilder>> _configureAppConfiguration = new();
public void SubstituteService<TService>(Action<TService> mockService)

View File

@@ -0,0 +1,40 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
namespace Bit.SeederApi.IntegrationTest;
public static class HttpClientExtensions
{
/// <summary>
/// Sends a POST request with JSON content and attaches the x-play-id header.
/// </summary>
/// <typeparam name="TValue">The type of the value to serialize.</typeparam>
/// <param name="client">The HTTP client.</param>
/// <param name="requestUri">The URI the request is sent to.</param>
/// <param name="value">The value to serialize.</param>
/// <param name="playId">The play ID to attach as x-play-id header.</param>
/// <param name="options">Options to control the behavior during serialization.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>The task object representing the asynchronous operation.</returns>
public static Task<HttpResponseMessage> PostAsJsonAsync<TValue>(
this HttpClient client,
[StringSyntax(StringSyntaxAttribute.Uri)] string? requestUri,
TValue value,
string playId,
JsonSerializerOptions? options = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(client);
if (string.IsNullOrWhiteSpace(playId))
{
throw new ArgumentException("Play ID cannot be null or whitespace.", nameof(playId));
}
var content = JsonContent.Create(value, mediaType: null, options);
content.Headers.Remove("x-play-id");
content.Headers.Add("x-play-id", playId);
return client.PostAsync(requestUri, content, cancellationToken);
}
}

View File

@@ -0,0 +1,75 @@
using System.Net;
using Bit.SeederApi.Models.Request;
using Xunit;
namespace Bit.SeederApi.IntegrationTest;
public class QueryControllerTests : IClassFixture<SeederApiApplicationFactory>, IAsyncLifetime
{
private readonly HttpClient _client;
private readonly SeederApiApplicationFactory _factory;
public QueryControllerTests(SeederApiApplicationFactory factory)
{
_factory = factory;
_client = _factory.CreateClient();
}
public Task InitializeAsync()
{
return Task.CompletedTask;
}
public Task DisposeAsync()
{
_client.Dispose();
return Task.CompletedTask;
}
[Fact]
public async Task QueryEndpoint_WithValidQueryAndArguments_ReturnsOk()
{
var testEmail = $"emergency-test-{Guid.NewGuid()}@bitwarden.com";
var response = await _client.PostAsJsonAsync("/query", new QueryRequestModel
{
Template = "EmergencyAccessInviteQuery",
Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = testEmail })
});
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadAsStringAsync();
Assert.NotNull(result);
var urls = System.Text.Json.JsonSerializer.Deserialize<List<string>>(result);
Assert.NotNull(urls);
// For a non-existent email, we expect an empty list
Assert.Empty(urls);
}
[Fact]
public async Task QueryEndpoint_WithInvalidQueryName_ReturnsNotFound()
{
var response = await _client.PostAsJsonAsync("/query", new QueryRequestModel
{
Template = "NonExistentQuery",
Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = "test@example.com" })
});
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task QueryEndpoint_WithMissingRequiredField_ReturnsBadRequest()
{
// EmergencyAccessInviteQuery requires 'email' field
var response = await _client.PostAsJsonAsync("/query", new QueryRequestModel
{
Template = "EmergencyAccessInviteQuery",
Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { wrongField = "value" })
});
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
}

View File

@@ -0,0 +1,222 @@
using System.Net;
using Bit.SeederApi.Models.Request;
using Bit.SeederApi.Models.Response;
using Xunit;
namespace Bit.SeederApi.IntegrationTest;
public class SeedControllerTests : IClassFixture<SeederApiApplicationFactory>, IAsyncLifetime
{
private readonly HttpClient _client;
private readonly SeederApiApplicationFactory _factory;
public SeedControllerTests(SeederApiApplicationFactory factory)
{
_factory = factory;
_client = _factory.CreateClient();
}
public Task InitializeAsync()
{
return Task.CompletedTask;
}
public async Task DisposeAsync()
{
// Clean up any seeded data after each test
await _client.DeleteAsync("/seed");
_client.Dispose();
}
[Fact]
public async Task SeedEndpoint_WithValidScene_ReturnsOk()
{
var testEmail = $"seed-test-{Guid.NewGuid()}@bitwarden.com";
var playId = Guid.NewGuid().ToString();
var response = await _client.PostAsJsonAsync("/seed", new SeedRequestModel
{
Template = "SingleUserScene",
Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = testEmail })
}, playId);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<SceneResponseModel>();
Assert.NotNull(result);
Assert.NotNull(result.MangleMap);
Assert.Null(result.Result);
}
[Fact]
public async Task SeedEndpoint_WithInvalidSceneName_ReturnsNotFound()
{
var response = await _client.PostAsJsonAsync("/seed", new SeedRequestModel
{
Template = "NonExistentScene",
Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = "test@example.com" })
});
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task SeedEndpoint_WithMissingRequiredField_ReturnsBadRequest()
{
// SingleUserScene requires 'email' field
var response = await _client.PostAsJsonAsync("/seed", new SeedRequestModel
{
Template = "SingleUserScene",
Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { wrongField = "value" })
});
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task DeleteEndpoint_WithValidPlayId_ReturnsOk()
{
var testEmail = $"delete-test-{Guid.NewGuid()}@bitwarden.com";
var playId = Guid.NewGuid().ToString();
var seedResponse = await _client.PostAsJsonAsync("/seed", new SeedRequestModel
{
Template = "SingleUserScene",
Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = testEmail })
}, playId);
seedResponse.EnsureSuccessStatusCode();
var seedResult = await seedResponse.Content.ReadFromJsonAsync<SceneResponseModel>();
Assert.NotNull(seedResult);
var deleteResponse = await _client.DeleteAsync($"/seed/{playId}");
deleteResponse.EnsureSuccessStatusCode();
}
[Fact]
public async Task DeleteEndpoint_WithInvalidPlayId_ReturnsOk()
{
// DestroyRecipe is idempotent - returns null for non-existent play IDs
var nonExistentPlayId = Guid.NewGuid().ToString();
var response = await _client.DeleteAsync($"/seed/{nonExistentPlayId}");
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
Assert.Equal($$"""{"playId":"{{nonExistentPlayId}}"}""", content);
}
[Fact]
public async Task DeleteBatchEndpoint_WithValidPlayIds_ReturnsOk()
{
// Create multiple seeds with different play IDs
var playIds = new List<string>();
for (var i = 0; i < 3; i++)
{
var playId = Guid.NewGuid().ToString();
playIds.Add(playId);
var testEmail = $"batch-test-{Guid.NewGuid()}@bitwarden.com";
var seedResponse = await _client.PostAsJsonAsync("/seed", new SeedRequestModel
{
Template = "SingleUserScene",
Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = testEmail })
}, playId);
seedResponse.EnsureSuccessStatusCode();
var seedResult = await seedResponse.Content.ReadFromJsonAsync<SceneResponseModel>();
Assert.NotNull(seedResult);
}
// Delete them in batch
var request = new HttpRequestMessage(HttpMethod.Delete, "/seed/batch")
{
Content = JsonContent.Create(playIds)
};
var deleteResponse = await _client.SendAsync(request);
deleteResponse.EnsureSuccessStatusCode();
var result = await deleteResponse.Content.ReadFromJsonAsync<BatchDeleteResponse>();
Assert.NotNull(result);
Assert.Equal("Batch delete completed successfully", result.Message);
}
[Fact]
public async Task DeleteBatchEndpoint_WithSomeInvalidIds_ReturnsOk()
{
// DestroyRecipe is idempotent - batch delete succeeds even with non-existent IDs
// Create one valid seed with a play ID
var validPlayId = Guid.NewGuid().ToString();
var testEmail = $"batch-partial-test-{Guid.NewGuid()}@bitwarden.com";
var seedResponse = await _client.PostAsJsonAsync("/seed", new SeedRequestModel
{
Template = "SingleUserScene",
Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = testEmail })
}, validPlayId);
seedResponse.EnsureSuccessStatusCode();
var seedResult = await seedResponse.Content.ReadFromJsonAsync<SceneResponseModel>();
Assert.NotNull(seedResult);
// Try to delete with mix of valid and invalid IDs
var playIds = new List<string> { validPlayId, Guid.NewGuid().ToString(), Guid.NewGuid().ToString() };
var request = new HttpRequestMessage(HttpMethod.Delete, "/seed/batch")
{
Content = JsonContent.Create(playIds)
};
var deleteResponse = await _client.SendAsync(request);
deleteResponse.EnsureSuccessStatusCode();
var result = await deleteResponse.Content.ReadFromJsonAsync<BatchDeleteResponse>();
Assert.NotNull(result);
Assert.Equal("Batch delete completed successfully", result.Message);
}
[Fact]
public async Task DeleteAllEndpoint_DeletesAllSeededData()
{
// Create multiple seeds
for (var i = 0; i < 2; i++)
{
var playId = Guid.NewGuid().ToString();
var testEmail = $"deleteall-test-{Guid.NewGuid()}@bitwarden.com";
var seedResponse = await _client.PostAsJsonAsync("/seed", new SeedRequestModel
{
Template = "SingleUserScene",
Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = testEmail })
}, playId);
seedResponse.EnsureSuccessStatusCode();
}
// Delete all
var deleteResponse = await _client.DeleteAsync("/seed");
Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode);
}
[Fact]
public async Task SeedEndpoint_VerifyResponseContainsMangleMapAndResult()
{
var testEmail = $"verify-response-{Guid.NewGuid()}@bitwarden.com";
var playId = Guid.NewGuid().ToString();
var response = await _client.PostAsJsonAsync("/seed", new SeedRequestModel
{
Template = "SingleUserScene",
Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = testEmail })
}, playId);
response.EnsureSuccessStatusCode();
var jsonString = await response.Content.ReadAsStringAsync();
// Verify the response contains MangleMap and Result fields
Assert.Contains("mangleMap", jsonString, StringComparison.OrdinalIgnoreCase);
Assert.Contains("result", jsonString, StringComparison.OrdinalIgnoreCase);
}
private class BatchDeleteResponse
{
public string? Message { get; set; }
}
}

View File

@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
<PackageReference Include="xunit" Version="$(XUnitVersion)" />
<PackageReference Include="xunit.runner.visualstudio" Version="$(XUnitRunnerVisualStudioVersion)">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="$(CoverletCollectorVersion)">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\util\SeederApi\SeederApi.csproj" />
<ProjectReference Include="..\..\util\Seeder\Seeder.csproj" />
<ProjectReference Include="..\IntegrationTestCommon\IntegrationTestCommon.csproj" />
<Content Include="..\..\util\SeederApi\appsettings.*.json">
<Link>%(RecursiveDir)%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,18 @@
using Bit.Core.Services;
using Bit.IntegrationTestCommon;
using Bit.IntegrationTestCommon.Factories;
namespace Bit.SeederApi.IntegrationTest;
public class SeederApiApplicationFactory : WebApplicationFactoryBase<Startup>
{
public SeederApiApplicationFactory()
{
TestDatabase = new SqliteTestDatabase();
_configureTestServices.Add(serviceCollection =>
{
serviceCollection.AddSingleton<IPlayIdService, NeverPlayIdServices>();
serviceCollection.AddHttpContextAccessor();
});
}
}

View File

@@ -0,0 +1,102 @@
using Bit.Core.Services;
using Bit.SharedWeb.Utilities;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Hosting;
using NSubstitute;
namespace SharedWeb.Test;
public class PlayIdMiddlewareTests
{
private readonly PlayIdService _playIdService;
private readonly RequestDelegate _next;
private readonly PlayIdMiddleware _middleware;
public PlayIdMiddlewareTests()
{
var hostEnvironment = Substitute.For<IHostEnvironment>();
hostEnvironment.EnvironmentName.Returns(Environments.Development);
_playIdService = new PlayIdService(hostEnvironment);
_next = Substitute.For<RequestDelegate>();
_middleware = new PlayIdMiddleware(_next);
}
[Fact]
public async Task Invoke_WithValidPlayId_SetsPlayIdAndCallsNext()
{
var context = new DefaultHttpContext();
context.Request.Headers["x-play-id"] = "test-play-id";
await _middleware.Invoke(context, _playIdService);
Assert.Equal("test-play-id", _playIdService.PlayId);
await _next.Received(1).Invoke(context);
}
[Fact]
public async Task Invoke_WithoutPlayIdHeader_CallsNext()
{
var context = new DefaultHttpContext();
await _middleware.Invoke(context, _playIdService);
Assert.Null(_playIdService.PlayId);
await _next.Received(1).Invoke(context);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("\t")]
public async Task Invoke_WithEmptyOrWhitespacePlayId_Returns400(string playId)
{
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
context.Request.Headers["x-play-id"] = playId;
await _middleware.Invoke(context, _playIdService);
Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode);
await _next.DidNotReceive().Invoke(context);
}
[Fact]
public async Task Invoke_WithPlayIdExceedingMaxLength_Returns400()
{
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
var longPlayId = new string('a', 257); // Exceeds 256 character limit
context.Request.Headers["x-play-id"] = longPlayId;
await _middleware.Invoke(context, _playIdService);
Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode);
await _next.DidNotReceive().Invoke(context);
}
[Fact]
public async Task Invoke_WithPlayIdAtMaxLength_SetsPlayIdAndCallsNext()
{
var context = new DefaultHttpContext();
var maxLengthPlayId = new string('a', 256); // Exactly 256 characters
context.Request.Headers["x-play-id"] = maxLengthPlayId;
await _middleware.Invoke(context, _playIdService);
Assert.Equal(maxLengthPlayId, _playIdService.PlayId);
await _next.Received(1).Invoke(context);
}
[Fact]
public async Task Invoke_WithSpecialCharactersInPlayId_SetsPlayIdAndCallsNext()
{
var context = new DefaultHttpContext();
context.Request.Headers["x-play-id"] = "test-play_id.123";
await _middleware.Invoke(context, _playIdService);
Assert.Equal("test-play_id.123", _playIdService.PlayId);
await _next.Received(1).Invoke(context);
}
}

View File

@@ -0,0 +1,90 @@
-- Create PlayItem table
IF OBJECT_ID('dbo.PlayItem') IS NULL
BEGIN
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))
);
CREATE NONCLUSTERED INDEX [IX_PlayItem_PlayId]
ON [dbo].[PlayItem]([PlayId] ASC);
CREATE NONCLUSTERED INDEX [IX_PlayItem_UserId]
ON [dbo].[PlayItem]([UserId] ASC);
CREATE NONCLUSTERED INDEX [IX_PlayItem_OrganizationId]
ON [dbo].[PlayItem]([OrganizationId] ASC);
END
GO
-- Create PlayItem_Create stored procedure
CREATE OR ALTER 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
GO
-- Create PlayItem_ReadByPlayId stored procedure
CREATE OR ALTER 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
GO
-- Create PlayItem_DeleteByPlayId stored procedure
CREATE OR ALTER PROCEDURE [dbo].[PlayItem_DeleteByPlayId]
@PlayId NVARCHAR(256)
AS
BEGIN
SET NOCOUNT ON
DELETE
FROM
[dbo].[PlayItem]
WHERE
[PlayId] = @PlayId
END
GO

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,65 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.MySqlMigrations.Migrations;
/// <inheritdoc />
public partial class CreatePlayItem : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PlayItem",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
PlayId = table.Column<string>(type: "varchar(256)", maxLength: 256, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
UserId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
OrganizationId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
CreationDate = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PlayItem", x => x.Id);
table.CheckConstraint("CK_PlayItem_UserOrOrganization", "(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)");
table.ForeignKey(
name: "FK_PlayItem_Organization_OrganizationId",
column: x => x.OrganizationId,
principalTable: "Organization",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_PlayItem_User_UserId",
column: x => x.UserId,
principalTable: "User",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_PlayItem_OrganizationId",
table: "PlayItem",
column: "OrganizationId");
migrationBuilder.CreateIndex(
name: "IX_PlayItem_PlayId",
table: "PlayItem",
column: "PlayId");
migrationBuilder.CreateIndex(
name: "IX_PlayItem_UserId",
table: "PlayItem",
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PlayItem");
}
}

View File

@@ -282,71 +282,6 @@ namespace Bit.MySqlMigrations.Migrations
b.ToTable("Organization", (string)null);
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b =>
{
b.Property<Guid>("Id")
.HasColumnType("char(36)");
b.Property<string>("Configuration")
.HasColumnType("longtext");
b.Property<DateTime>("CreationDate")
.HasColumnType("datetime(6)");
b.Property<Guid>("OrganizationId")
.HasColumnType("char(36)");
b.Property<DateTime>("RevisionDate")
.HasColumnType("datetime(6)");
b.Property<int>("Type")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("OrganizationId")
.HasAnnotation("SqlServer:Clustered", false);
b.HasIndex("OrganizationId", "Type")
.IsUnique()
.HasAnnotation("SqlServer:Clustered", false);
b.ToTable("OrganizationIntegration", (string)null);
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b =>
{
b.Property<Guid>("Id")
.HasColumnType("char(36)");
b.Property<string>("Configuration")
.HasColumnType("longtext");
b.Property<DateTime>("CreationDate")
.HasColumnType("datetime(6)");
b.Property<int?>("EventType")
.HasColumnType("int");
b.Property<string>("Filters")
.HasColumnType("longtext");
b.Property<Guid>("OrganizationIntegrationId")
.HasColumnType("char(36)");
b.Property<DateTime>("RevisionDate")
.HasColumnType("datetime(6)");
b.Property<string>("Template")
.HasColumnType("longtext");
b.HasKey("Id");
b.HasIndex("OrganizationIntegrationId");
b.ToTable("OrganizationIntegrationConfiguration", (string)null);
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b =>
{
b.Property<Guid>("Id")
@@ -626,8 +561,8 @@ namespace Bit.MySqlMigrations.Migrations
b.Property<byte>("Type")
.HasColumnType("tinyint unsigned");
b.Property<int>("WaitTimeDays")
.HasColumnType("int");
b.Property<short>("WaitTimeDays")
.HasColumnType("smallint");
b.HasKey("Id");
@@ -1015,6 +950,71 @@ namespace Bit.MySqlMigrations.Migrations
b.ToTable("OrganizationApplication", (string)null);
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b =>
{
b.Property<Guid>("Id")
.HasColumnType("char(36)");
b.Property<string>("Configuration")
.HasColumnType("longtext");
b.Property<DateTime>("CreationDate")
.HasColumnType("datetime(6)");
b.Property<Guid>("OrganizationId")
.HasColumnType("char(36)");
b.Property<DateTime>("RevisionDate")
.HasColumnType("datetime(6)");
b.Property<int>("Type")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("OrganizationId")
.HasAnnotation("SqlServer:Clustered", false);
b.HasIndex("OrganizationId", "Type")
.IsUnique()
.HasAnnotation("SqlServer:Clustered", false);
b.ToTable("OrganizationIntegration", (string)null);
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b =>
{
b.Property<Guid>("Id")
.HasColumnType("char(36)");
b.Property<string>("Configuration")
.HasColumnType("longtext");
b.Property<DateTime>("CreationDate")
.HasColumnType("datetime(6)");
b.Property<int?>("EventType")
.HasColumnType("int");
b.Property<string>("Filters")
.HasColumnType("longtext");
b.Property<Guid>("OrganizationIntegrationId")
.HasColumnType("char(36)");
b.Property<DateTime>("RevisionDate")
.HasColumnType("datetime(6)");
b.Property<string>("Template")
.HasColumnType("longtext");
b.HasKey("Id");
b.HasIndex("OrganizationIntegrationId");
b.ToTable("OrganizationIntegrationConfiguration", (string)null);
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b =>
{
b.Property<Guid>("Id")
@@ -1627,6 +1627,42 @@ namespace Bit.MySqlMigrations.Migrations
b.ToTable("OrganizationUser", (string)null);
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b =>
{
b.Property<Guid>("Id")
.HasColumnType("char(36)");
b.Property<DateTime>("CreationDate")
.HasColumnType("datetime(6)");
b.Property<Guid?>("OrganizationId")
.HasColumnType("char(36)");
b.Property<string>("PlayId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("varchar(256)");
b.Property<Guid?>("UserId")
.HasColumnType("char(36)");
b.HasKey("Id");
b.HasIndex("OrganizationId")
.HasAnnotation("SqlServer:Clustered", false);
b.HasIndex("PlayId")
.HasAnnotation("SqlServer:Clustered", false);
b.HasIndex("UserId")
.HasAnnotation("SqlServer:Clustered", false);
b.ToTable("PlayItem", null, t =>
{
t.HasCheckConstraint("CK_PlayItem_UserOrOrganization", "(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)");
});
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b =>
{
b.Property<Guid>("Id")
@@ -2607,28 +2643,6 @@ namespace Bit.MySqlMigrations.Migrations
b.HasDiscriminator().HasValue("user_service_account");
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b =>
{
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")
.WithMany()
.HasForeignKey("OrganizationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Organization");
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b =>
{
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", "OrganizationIntegration")
.WithMany()
.HasForeignKey("OrganizationIntegrationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("OrganizationIntegration");
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b =>
{
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")
@@ -2807,6 +2821,28 @@ namespace Bit.MySqlMigrations.Migrations
b.Navigation("Organization");
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b =>
{
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")
.WithMany()
.HasForeignKey("OrganizationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Organization");
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b =>
{
b.HasOne("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", "OrganizationIntegration")
.WithMany()
.HasForeignKey("OrganizationIntegrationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("OrganizationIntegration");
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b =>
{
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")
@@ -3003,6 +3039,23 @@ namespace Bit.MySqlMigrations.Migrations
b.Navigation("User");
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b =>
{
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")
.WithMany()
.HasForeignKey("OrganizationId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("Organization");
b.Navigation("User");
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b =>
{
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,63 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.PostgresMigrations.Migrations;
/// <inheritdoc />
public partial class CreatePlayItem : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PlayItem",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
PlayId = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: true),
OrganizationId = table.Column<Guid>(type: "uuid", nullable: true),
CreationDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PlayItem", x => x.Id);
table.CheckConstraint("CK_PlayItem_UserOrOrganization", "(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)");
table.ForeignKey(
name: "FK_PlayItem_Organization_OrganizationId",
column: x => x.OrganizationId,
principalTable: "Organization",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_PlayItem_User_UserId",
column: x => x.UserId,
principalTable: "User",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_PlayItem_OrganizationId",
table: "PlayItem",
column: "OrganizationId");
migrationBuilder.CreateIndex(
name: "IX_PlayItem_PlayId",
table: "PlayItem",
column: "PlayId");
migrationBuilder.CreateIndex(
name: "IX_PlayItem_UserId",
table: "PlayItem",
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PlayItem");
}
}

View File

@@ -285,71 +285,6 @@ namespace Bit.PostgresMigrations.Migrations
b.ToTable("Organization", (string)null);
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("Configuration")
.HasColumnType("text");
b.Property<DateTime>("CreationDate")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.Property<DateTime>("RevisionDate")
.HasColumnType("timestamp with time zone");
b.Property<int>("Type")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("OrganizationId")
.HasAnnotation("SqlServer:Clustered", false);
b.HasIndex("OrganizationId", "Type")
.IsUnique()
.HasAnnotation("SqlServer:Clustered", false);
b.ToTable("OrganizationIntegration", (string)null);
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("Configuration")
.HasColumnType("text");
b.Property<DateTime>("CreationDate")
.HasColumnType("timestamp with time zone");
b.Property<int?>("EventType")
.HasColumnType("integer");
b.Property<string>("Filters")
.HasColumnType("text");
b.Property<Guid>("OrganizationIntegrationId")
.HasColumnType("uuid");
b.Property<DateTime>("RevisionDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Template")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("OrganizationIntegrationId");
b.ToTable("OrganizationIntegrationConfiguration", (string)null);
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b =>
{
b.Property<Guid>("Id")
@@ -629,8 +564,8 @@ namespace Bit.PostgresMigrations.Migrations
b.Property<byte>("Type")
.HasColumnType("smallint");
b.Property<int>("WaitTimeDays")
.HasColumnType("integer");
b.Property<short>("WaitTimeDays")
.HasColumnType("smallint");
b.HasKey("Id");
@@ -1020,6 +955,71 @@ namespace Bit.PostgresMigrations.Migrations
b.ToTable("OrganizationApplication", (string)null);
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("Configuration")
.HasColumnType("text");
b.Property<DateTime>("CreationDate")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.Property<DateTime>("RevisionDate")
.HasColumnType("timestamp with time zone");
b.Property<int>("Type")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("OrganizationId")
.HasAnnotation("SqlServer:Clustered", false);
b.HasIndex("OrganizationId", "Type")
.IsUnique()
.HasAnnotation("SqlServer:Clustered", false);
b.ToTable("OrganizationIntegration", (string)null);
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("Configuration")
.HasColumnType("text");
b.Property<DateTime>("CreationDate")
.HasColumnType("timestamp with time zone");
b.Property<int?>("EventType")
.HasColumnType("integer");
b.Property<string>("Filters")
.HasColumnType("text");
b.Property<Guid>("OrganizationIntegrationId")
.HasColumnType("uuid");
b.Property<DateTime>("RevisionDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Template")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("OrganizationIntegrationId");
b.ToTable("OrganizationIntegrationConfiguration", (string)null);
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b =>
{
b.Property<Guid>("Id")
@@ -1632,6 +1632,42 @@ namespace Bit.PostgresMigrations.Migrations
b.ToTable("OrganizationUser", (string)null);
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTime>("CreationDate")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("OrganizationId")
.HasColumnType("uuid");
b.Property<string>("PlayId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid?>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("OrganizationId")
.HasAnnotation("SqlServer:Clustered", false);
b.HasIndex("PlayId")
.HasAnnotation("SqlServer:Clustered", false);
b.HasIndex("UserId")
.HasAnnotation("SqlServer:Clustered", false);
b.ToTable("PlayItem", null, t =>
{
t.HasCheckConstraint("CK_PlayItem_UserOrOrganization", "(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)");
});
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b =>
{
b.Property<Guid>("Id")
@@ -2613,28 +2649,6 @@ namespace Bit.PostgresMigrations.Migrations
b.HasDiscriminator().HasValue("user_service_account");
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b =>
{
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")
.WithMany()
.HasForeignKey("OrganizationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Organization");
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b =>
{
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", "OrganizationIntegration")
.WithMany()
.HasForeignKey("OrganizationIntegrationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("OrganizationIntegration");
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b =>
{
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")
@@ -2813,6 +2827,28 @@ namespace Bit.PostgresMigrations.Migrations
b.Navigation("Organization");
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", b =>
{
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")
.WithMany()
.HasForeignKey("OrganizationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Organization");
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegrationConfiguration", b =>
{
b.HasOne("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationIntegration", "OrganizationIntegration")
.WithMany()
.HasForeignKey("OrganizationIntegrationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("OrganizationIntegration");
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b =>
{
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")
@@ -3009,6 +3045,23 @@ namespace Bit.PostgresMigrations.Migrations
b.Navigation("User");
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.PlayItem", b =>
{
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")
.WithMany()
.HasForeignKey("OrganizationId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("Organization");
b.Navigation("User");
});
modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b =>
{
b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization")

View File

@@ -10,6 +10,14 @@
</PropertyGroup>
<ItemGroup>
<!-- This is a work around because this file is compiled by the PreBuild event below, and won't
always be detected -->
<Compile Remove="NativeMethods.g.cs" />
</ItemGroup>
<ItemGroup>
<Compile Include="NativeMethods.g.cs" />
<Content Include="rust/target/release/libsdk*.dylib">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
<PackageCopyToOutput>true</PackageCopyToOutput>
@@ -18,23 +26,36 @@
<Content Include="./rust/target/release/libsdk*.so">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
<PackageCopyToOutput>true</PackageCopyToOutput>
<Link>runtimes/linux-x64/native/libsdk.dylib</Link>
<Link>runtimes/linux-x64/native/libsdk.so</Link>
</Content>
<Content Include="./rust/target/release/libsdk*.dll">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
<PackageCopyToOutput>true</PackageCopyToOutput>
<Link>runtimes/windows-x64/native/libsdk.dylib</Link>
<Link>runtimes/windows-x64/native/libsdk.dll</Link>
</Content>
<!-- This is a work around because this file is compiled by the PreBuild event below, and won't
always be detected -->
<Compile Remove="NativeMethods.g.cs" />
</ItemGroup>
<Target Name="PreBuild" BeforeTargets="PreBuildEvent">
<Exec Command="cargo build --release" WorkingDirectory="$(ProjectDir)/rust" />
<ItemGroup>
<Compile Include="NativeMethods.g.cs" />
<!-- Include native libraries after they've been built -->
<Content Include="rust/target/release/libsdk*.dylib">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
<PackageCopyToOutput>true</PackageCopyToOutput>
<Link>runtimes/osx-arm64/native/libsdk.dylib</Link>
</Content>
<Content Include="./rust/target/release/libsdk*.so">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
<PackageCopyToOutput>true</PackageCopyToOutput>
<Link>runtimes/linux-x64/native/libsdk.so</Link>
</Content>
<Content Include="./rust/target/release/libsdk*.dll">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
<PackageCopyToOutput>true</PackageCopyToOutput>
<Link>runtimes/windows-x64/native/libsdk.dll</Link>
</Content>
</ItemGroup>
</Target>

View File

@@ -0,0 +1,2 @@
[toolchain]
channel = "1.87.0"

View File

@@ -1,7 +1,7 @@
using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Infrastructure.EntityFramework.AdminConsole.Models;
using Bit.Infrastructure.EntityFramework.Models;
namespace Bit.Seeder.Factories;

View File

@@ -1,13 +1,58 @@
using Bit.Core.Enums;
using Bit.Infrastructure.EntityFramework.Models;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Utilities;
using Bit.RustSDK;
using Microsoft.AspNetCore.Identity;
namespace Bit.Seeder.Factories;
public class UserSeeder
public struct UserData
{
public static User CreateUser(string email)
public string Email;
public Guid Id;
public string? Key;
public string? PublicKey;
public string? PrivateKey;
public string? ApiKey;
public KdfType Kdf;
public int KdfIterations;
}
public class UserSeeder(RustSdkService sdkService, IPasswordHasher<Bit.Core.Entities.User> passwordHasher, MangleId mangleId)
{
private string MangleEmail(string email)
{
return $"{mangleId}+{email}";
}
public User CreateUser(string email, bool emailVerified = false, bool premium = false)
{
email = MangleEmail(email);
var keys = sdkService.GenerateUserKeys(email, "asdfasdfasdf");
var user = new User
{
Id = CoreHelpers.GenerateComb(),
Email = email,
EmailVerified = emailVerified,
MasterPassword = null,
SecurityStamp = "4830e359-e150-4eae-be2a-996c81c5e609",
Key = keys.EncryptedUserKey,
PublicKey = keys.PublicKey,
PrivateKey = keys.PrivateKey,
Premium = premium,
ApiKey = "7gp59kKHt9kMlks0BuNC4IjNXYkljR",
Kdf = KdfType.PBKDF2_SHA256,
KdfIterations = 5_000,
};
user.MasterPassword = passwordHasher.HashPassword(user, keys.MasterPasswordHash);
return user;
}
public static User CreateUserNoMangle(string email)
{
return new User
{
@@ -25,28 +70,35 @@ public class UserSeeder
};
}
public static (User user, string userKey) CreateSdkUser(IPasswordHasher<Bit.Core.Entities.User> passwordHasher, string email)
public Dictionary<string, string?> GetMangleMap(User user, UserData expectedUserData)
{
var nativeService = RustSdkServiceFactory.CreateSingleton();
var keys = nativeService.GenerateUserKeys(email, "asdfasdfasdf");
var user = new User
var mangleMap = new Dictionary<string, string?>
{
Id = Guid.NewGuid(),
Email = email,
MasterPassword = null,
SecurityStamp = "4830e359-e150-4eae-be2a-996c81c5e609",
Key = keys.EncryptedUserKey,
PublicKey = keys.PublicKey,
PrivateKey = keys.PrivateKey,
ApiKey = "7gp59kKHt9kMlks0BuNC4IjNXYkljR",
Kdf = KdfType.PBKDF2_SHA256,
KdfIterations = 5_000,
{ expectedUserData.Email, MangleEmail(expectedUserData.Email) },
{ expectedUserData.Id.ToString(), user.Id.ToString() },
{ expectedUserData.Kdf.ToString(), user.Kdf.ToString() },
{ expectedUserData.KdfIterations.ToString(), user.KdfIterations.ToString() }
};
if (expectedUserData.Key != null)
{
mangleMap[expectedUserData.Key] = user.Key;
}
user.MasterPassword = passwordHasher.HashPassword(user, keys.MasterPasswordHash);
if (expectedUserData.PublicKey != null)
{
mangleMap[expectedUserData.PublicKey] = user.PublicKey;
}
return (user, keys.Key);
if (expectedUserData.PrivateKey != null)
{
mangleMap[expectedUserData.PrivateKey] = user.PrivateKey;
}
if (expectedUserData.ApiKey != null)
{
mangleMap[expectedUserData.ApiKey] = user.ApiKey;
}
return mangleMap;
}
}

60
util/Seeder/IQuery.cs Normal file
View File

@@ -0,0 +1,60 @@
namespace Bit.Seeder;
/// <summary>
/// Base interface for query operations in the seeding system. The base interface should not be used directly, rather use `IQuery&lt;TRequest, TResult&gt;`.
/// </summary>
/// <remarks>
/// Queries are synchronous, read-only operations that retrieve data from the seeding context.
/// Unlike scenes which create data, queries fetch existing data based on request parameters.
/// They follow a type-safe pattern using generics to ensure proper request/response handling
/// while maintaining a common non-generic interface for dynamic invocation.
/// </remarks>
public interface IQuery
{
/// <summary>
/// Gets the type of request this query expects.
/// </summary>
/// <returns>The request type that this query can process.</returns>
Type GetRequestType();
/// <summary>
/// Executes the query based on the provided request object.
/// </summary>
/// <param name="request">The request object containing parameters for the query operation.</param>
/// <returns>The query result data as an object.</returns>
object Execute(object request);
}
/// <summary>
/// Generic query interface for synchronous, read-only operations with specific request and result types.
/// </summary>
/// <typeparam name="TRequest">The type of request object this query accepts.</typeparam>
/// <typeparam name="TResult">The type of data this query returns.</typeparam>
/// <remarks>
/// Use this interface when you need to retrieve existing data from the seeding context based on
/// specific request parameters. Queries are synchronous and do not modify data - they only read
/// and return information. The explicit interface implementations allow dynamic invocation while
/// maintaining type safety in the implementation.
/// </remarks>
public interface IQuery<TRequest, TResult> : IQuery where TRequest : class where TResult : class
{
/// <summary>
/// Executes the query based on the provided strongly-typed request and returns typed result data.
/// </summary>
/// <param name="request">The request object containing parameters for the query operation.</param>
/// <returns>The typed query result data.</returns>
TResult Execute(TRequest request);
/// <summary>
/// Gets the request type for this query.
/// </summary>
/// <returns>The type of TRequest.</returns>
Type IQuery.GetRequestType() => typeof(TRequest);
/// <summary>
/// Adapts the non-generic Execute to the strongly-typed version.
/// </summary>
/// <param name="request">The request object to cast and process.</param>
/// <returns>The typed result cast to object.</returns>
object IQuery.Execute(object request) => Execute((TRequest)request);
}

96
util/Seeder/IScene.cs Normal file
View File

@@ -0,0 +1,96 @@
namespace Bit.Seeder;
/// <summary>
/// Base interface for seeding operations. The base interface should not be used directly, rather use `IScene&lt;Request&gt;`.
/// </summary>
/// <remarks>
/// Scenes are components in the seeding system that create and configure test data. They follow
/// a type-safe pattern using generics to ensure proper request/response handling while maintaining
/// a common non-generic interface for dynamic invocation.
/// </remarks>
public interface IScene
{
/// <summary>
/// Gets the type of request this scene expects.
/// </summary>
/// <returns>The request type that this scene can process.</returns>
Type GetRequestType();
/// <summary>
/// Seeds data based on the provided request object.
/// </summary>
/// <param name="request">The request object containing parameters for the seeding operation.</param>
/// <returns>A scene result containing any returned data, mangle map, and entity tracking information.</returns>
Task<SceneResult<object?>> SeedAsync(object request);
}
/// <summary>
/// Generic scene interface for seeding operations with a specific request type. Does not return a value beyond tracking entities and a mangle map.
/// </summary>
/// <typeparam name="TRequest">The type of request object this scene accepts.</typeparam>
/// <remarks>
/// Use this interface when your scene needs to process a specific request type but doesn't need to
/// return any data beyond the standard mangle map for ID transformations and entity tracking.
/// The explicit interface implementations allow this scene to be invoked dynamically through the
/// base IScene interface while maintaining type safety in the implementation.
/// </remarks>
public interface IScene<TRequest> : IScene where TRequest : class
{
/// <summary>
/// Seeds data based on the provided strongly-typed request.
/// </summary>
/// <param name="request">The request object containing parameters for the seeding operation.</param>
/// <returns>A scene result containing the mangle map and entity tracking information.</returns>
Task<SceneResult> SeedAsync(TRequest request);
/// <summary>
/// Gets the request type for this scene.
/// </summary>
/// <returns>The type of TRequest.</returns>
Type IScene.GetRequestType() => typeof(TRequest);
/// <summary>
/// Adapts the non-generic SeedAsync to the strongly-typed version.
/// </summary>
/// <param name="request">The request object to cast and process.</param>
/// <returns>A scene result wrapped as an object result.</returns>
async Task<SceneResult<object?>> IScene.SeedAsync(object request)
{
var result = await SeedAsync((TRequest)request);
return new SceneResult(mangleMap: result.MangleMap);
}
}
/// <summary>
/// Generic scene interface for seeding operations with a specific request type that returns typed data.
/// </summary>
/// <typeparam name="TRequest">The type of request object this scene accepts. Must be a reference type.</typeparam>
/// <typeparam name="TResult">The type of data this scene returns. Must be a reference type.</typeparam>
/// <remarks>
/// Use this interface when your scene needs to return specific data that can be used by subsequent
/// scenes or test logic. The result is wrapped in a SceneResult that also includes the mangle map
/// and entity tracking information. The explicit interface implementations allow dynamic invocation
/// while preserving type safety in the implementation.
/// </remarks>
public interface IScene<TRequest, TResult> : IScene where TRequest : class where TResult : class
{
/// <summary>
/// Seeds data based on the provided strongly-typed request and returns typed result data.
/// </summary>
/// <param name="request">The request object containing parameters for the seeding operation.</param>
/// <returns>A scene result containing the typed result data, mangle map, and entity tracking information.</returns>
Task<SceneResult<TResult>> SeedAsync(TRequest request);
/// <summary>
/// Gets the request type for this scene.
/// </summary>
/// <returns>The type of TRequest.</returns>
Type IScene.GetRequestType() => typeof(TRequest);
/// <summary>
/// Adapts the non-generic SeedAsync to the strongly-typed version.
/// </summary>
/// <param name="request">The request object to cast and process.</param>
/// <returns>A scene result with the typed result cast to object.</returns>
async Task<SceneResult<object?>> IScene.SeedAsync(object request) => (SceneResult<object?>)await SeedAsync((TRequest)request);
}

19
util/Seeder/MangleId.cs Normal file
View File

@@ -0,0 +1,19 @@
namespace Bit.Seeder;
/// <summary>
/// Helper for generating unique identifier suffixes to prevent collisions in test data.
/// "Mangling" adds a random suffix to test data identifiers (usernames, emails, org names, etc.)
/// to ensure uniqueness across multiple test runs and parallel test executions.
/// </summary>
public class MangleId
{
public readonly string Value;
public MangleId()
{
// Generate a short random string (6 char) to use as the mangle ID
Value = Random.Shared.NextInt64().ToString("x").Substring(0, 8);
}
public override string ToString() => Value;
}

View File

@@ -0,0 +1,35 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Tokens;
using Bit.Infrastructure.EntityFramework.Repositories;
namespace Bit.Seeder.Queries;
/// <summary>
/// Retrieves all emergency access invite urls for the provided email.
/// </summary>
public class EmergencyAccessInviteQuery(
DatabaseContext db,
IDataProtectorTokenFactory<EmergencyAccessInviteTokenable> dataProtectorTokenizer)
: IQuery<EmergencyAccessInviteQuery.Request, IEnumerable<string>>
{
public class Request
{
[Required]
public required string Email { get; set; }
}
public IEnumerable<string> Execute(Request request)
{
var invites = db.EmergencyAccesses
.Where(ea => ea.Email == request.Email).ToList().Select(ea =>
{
var token = dataProtectorTokenizer.Protect(
new EmergencyAccessInviteTokenable(ea, hoursTillExpiration: 1)
);
return $"/accept-emergency?id={ea.Id}&name=Dummy&email={ea.Email}&token={token}";
});
return invites;
}
}

View File

@@ -1,5 +1,5 @@
using Bit.Core.Enums;
using Bit.Infrastructure.EntityFramework.Models;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.Seeder.Factories;
using LinqToDB.EntityFrameworkCore;
@@ -12,14 +12,14 @@ public class OrganizationWithUsersRecipe(DatabaseContext db)
{
var seats = Math.Max(users + 1, 1000);
var organization = OrganizationSeeder.CreateEnterprise(name, domain, seats);
var ownerUser = UserSeeder.CreateUser($"owner@{domain}");
var ownerUser = UserSeeder.CreateUserNoMangle($"owner@{domain}");
var ownerOrgUser = organization.CreateOrganizationUser(ownerUser, OrganizationUserType.Owner, OrganizationUserStatusType.Confirmed);
var additionalUsers = new List<User>();
var additionalOrgUsers = new List<OrganizationUser>();
for (var i = 0; i < users; i++)
{
var additionalUser = UserSeeder.CreateUser($"user{i}@{domain}");
var additionalUser = UserSeeder.CreateUserNoMangle($"user{i}@{domain}");
additionalUsers.Add(additionalUser);
additionalOrgUsers.Add(organization.CreateOrganizationUser(additionalUser, OrganizationUserType.User, usersStatus));
}

View File

@@ -0,0 +1,28 @@
namespace Bit.Seeder;
/// <summary>
/// Helper for exposing a <see cref="IScene" /> interface with a SeedAsync method.
/// </summary>
public class SceneResult(Dictionary<string, string?> mangleMap)
: SceneResult<object?>(result: null, mangleMap: mangleMap);
/// <summary>
/// Generic result from executing a Scene.
/// Contains custom scene-specific data and a mangle map that maps magic strings from the
/// request to their mangled (collision-free) values inserted into the database.
/// </summary>
/// <typeparam name="TResult">The type of custom result data returned by the scene.</typeparam>
public class SceneResult<TResult>(TResult result, Dictionary<string, string?> mangleMap)
{
public TResult Result { get; init; } = result;
public Dictionary<string, string?> MangleMap { get; init; } = mangleMap;
public static explicit operator SceneResult<object?>(SceneResult<TResult> v)
{
var result = v.Result;
return result is null
? new SceneResult<object?>(result: null, mangleMap: v.MangleMap)
: new SceneResult<object?>(result: result, mangleMap: v.MangleMap);
}
}

View File

@@ -0,0 +1,38 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.Repositories;
using Bit.Seeder.Factories;
namespace Bit.Seeder.Scenes;
/// <summary>
/// Creates a single user using the provided account details.
/// </summary>
public class SingleUserScene(UserSeeder userSeeder, IUserRepository userRepository) : IScene<SingleUserScene.Request>
{
public class Request
{
[Required]
public required string Email { get; set; }
public bool EmailVerified { get; set; } = false;
public bool Premium { get; set; } = false;
}
public async Task<SceneResult> SeedAsync(Request request)
{
var user = userSeeder.CreateUser(request.Email, request.EmailVerified, request.Premium);
await userRepository.CreateAsync(user);
return new SceneResult(mangleMap: userSeeder.GetMangleMap(user, new UserData
{
Email = request.Email,
Id = user.Id,
Key = user.Key,
PublicKey = user.PublicKey,
PrivateKey = user.PrivateKey,
ApiKey = user.ApiKey,
Kdf = user.Kdf,
KdfIterations = user.KdfIterations,
}));
}
}

View File

@@ -12,10 +12,6 @@
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<Folder Include="Settings\" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Core\Core.csproj" />
<ProjectReference Include="..\..\src\Infrastructure.EntityFramework\Infrastructure.EntityFramework.csproj" />

View File

@@ -0,0 +1,36 @@
using Bit.SeederApi.Commands.Interfaces;
namespace Bit.SeederApi.Commands;
public class DestroyBatchScenesCommand(
ILogger<DestroyBatchScenesCommand> logger,
IDestroySceneCommand destroySceneCommand) : IDestroyBatchScenesCommand
{
public async Task DestroyAsync(IEnumerable<string> playIds)
{
var exceptions = new List<Exception>();
var deleteTasks = playIds.Select(async playId =>
{
try
{
await destroySceneCommand.DestroyAsync(playId);
}
catch (Exception ex)
{
lock (exceptions)
{
exceptions.Add(ex);
}
logger.LogError(ex, "Error deleting seeded data: {PlayId}", playId);
}
});
await Task.WhenAll(deleteTasks);
if (exceptions.Count > 0)
{
throw new AggregateException("One or more errors occurred while deleting seeded data", exceptions);
}
}
}

View File

@@ -0,0 +1,57 @@
using Bit.Core.Repositories;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.SeederApi.Commands.Interfaces;
using Bit.SeederApi.Services;
namespace Bit.SeederApi.Commands;
public class DestroySceneCommand(
DatabaseContext databaseContext,
ILogger<DestroySceneCommand> logger,
IUserRepository userRepository,
IPlayItemRepository playItemRepository,
IOrganizationRepository organizationRepository) : IDestroySceneCommand
{
public async Task<object?> DestroyAsync(string playId)
{
// Note, delete cascade will remove PlayItem entries
var playItem = await playItemRepository.GetByPlayIdAsync(playId);
var userIds = playItem.Select(pd => pd.UserId).Distinct().ToList();
var organizationIds = playItem.Select(pd => pd.OrganizationId).Distinct().ToList();
// Delete Users before Organizations to respect foreign key constraints
if (userIds.Count > 0)
{
var users = databaseContext.Users.Where(u => userIds.Contains(u.Id));
await userRepository.DeleteManyAsync(users);
}
if (organizationIds.Count > 0)
{
var organizations = databaseContext.Organizations.Where(o => organizationIds.Contains(o.Id));
var aggregateException = new AggregateException();
foreach (var org in organizations)
{
try
{
await organizationRepository.DeleteAsync(org);
}
catch (Exception ex)
{
aggregateException = new AggregateException(aggregateException, ex);
}
}
if (aggregateException.InnerExceptions.Count > 0)
{
throw new SceneExecutionException(
$"One or more errors occurred while deleting organizations for seed ID {playId}",
aggregateException);
}
}
logger.LogInformation("Successfully destroyed seeded data with ID {PlayId}", playId);
return new { PlayId = playId };
}
}

View File

@@ -0,0 +1,14 @@
namespace Bit.SeederApi.Commands.Interfaces;
/// <summary>
/// Command for destroying multiple scenes in parallel.
/// </summary>
public interface IDestroyBatchScenesCommand
{
/// <summary>
/// Destroys multiple scenes by their play IDs in parallel.
/// </summary>
/// <param name="playIds">The list of play IDs to destroy</param>
/// <exception cref="AggregateException">Thrown when one or more scenes fail to destroy</exception>
Task DestroyAsync(IEnumerable<string> playIds);
}

View File

@@ -0,0 +1,15 @@
namespace Bit.SeederApi.Commands.Interfaces;
/// <summary>
/// Command for destroying data created by a single scene.
/// </summary>
public interface IDestroySceneCommand
{
/// <summary>
/// Destroys data created by a scene using the seeded data ID.
/// </summary>
/// <param name="playId">The ID of the seeded data to destroy</param>
/// <returns>The result of the destroy operation</returns>
/// <exception cref="Services.SceneExecutionException">Thrown when there's an error destroying the seeded data</exception>
Task<object?> DestroyAsync(string playId);
}

View File

@@ -0,0 +1,20 @@
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Mvc;
namespace Bit.SeederApi.Controllers;
public class InfoController : Controller
{
[HttpGet("~/alive")]
[HttpGet("~/now")]
public DateTime GetAlive()
{
return DateTime.UtcNow;
}
[HttpGet("~/version")]
public JsonResult GetVersion()
{
return Json(AssemblyHelpers.GetVersion());
}
}

View File

@@ -0,0 +1,32 @@
using Bit.SeederApi.Execution;
using Bit.SeederApi.Models.Request;
using Bit.SeederApi.Services;
using Microsoft.AspNetCore.Mvc;
namespace Bit.SeederApi.Controllers;
[Route("query")]
public class QueryController(ILogger<QueryController> logger, IQueryExecutor queryExecutor) : Controller
{
[HttpPost]
public IActionResult Query([FromBody] QueryRequestModel request)
{
logger.LogInformation("Executing query: {Query}", request.Template);
try
{
var result = queryExecutor.Execute(request.Template, request.Arguments);
return Json(result);
}
catch (QueryNotFoundException ex)
{
return NotFound(new { Error = ex.Message });
}
catch (QueryExecutionException ex)
{
logger.LogError(ex, "Error executing query: {Query}", request.Template);
return BadRequest(new { Error = ex.Message, Details = ex.InnerException?.Message });
}
}
}

View File

@@ -0,0 +1,100 @@
using Bit.SeederApi.Commands.Interfaces;
using Bit.SeederApi.Execution;
using Bit.SeederApi.Models.Request;
using Bit.SeederApi.Queries.Interfaces;
using Bit.SeederApi.Services;
using Microsoft.AspNetCore.Mvc;
namespace Bit.SeederApi.Controllers;
[Route("seed")]
public class SeedController(
ILogger<SeedController> logger,
ISceneExecutor sceneExecutor,
IDestroySceneCommand destroySceneCommand,
IDestroyBatchScenesCommand destroyBatchScenesCommand,
IGetAllPlayIdsQuery getAllPlayIdsQuery) : Controller
{
[HttpPost]
public async Task<IActionResult> SeedAsync([FromBody] SeedRequestModel request)
{
logger.LogInformation("Received seed request with template: {Template}", request.Template);
try
{
var response = await sceneExecutor.ExecuteAsync(request.Template, request.Arguments);
return Json(response);
}
catch (SceneNotFoundException ex)
{
return NotFound(new { Error = ex.Message });
}
catch (SceneExecutionException ex)
{
logger.LogError(ex, "Error executing scene: {Template}", request.Template);
return BadRequest(new { Error = ex.Message, Details = ex.InnerException?.Message });
}
}
[HttpDelete("batch")]
public async Task<IActionResult> DeleteBatchAsync([FromBody] List<string> playIds)
{
logger.LogInformation("Deleting batch of seeded data with IDs: {PlayIds}", string.Join(", ", playIds));
try
{
await destroyBatchScenesCommand.DestroyAsync(playIds);
return Ok(new { Message = "Batch delete completed successfully" });
}
catch (AggregateException ex)
{
return BadRequest(new
{
Error = ex.Message,
Details = ex.InnerExceptions.Select(e => e.Message).ToList()
});
}
}
[HttpDelete("{playId}")]
public async Task<IActionResult> DeleteAsync([FromRoute] string playId)
{
logger.LogInformation("Deleting seeded data with ID: {PlayId}", playId);
try
{
var result = await destroySceneCommand.DestroyAsync(playId);
return Json(result);
}
catch (SceneExecutionException ex)
{
logger.LogError(ex, "Error deleting seeded data: {PlayId}", playId);
return BadRequest(new { Error = ex.Message, Details = ex.InnerException?.Message });
}
}
[HttpDelete]
public async Task<IActionResult> DeleteAllAsync()
{
logger.LogInformation("Deleting all seeded data");
var playIds = getAllPlayIdsQuery.GetAllPlayIds();
try
{
await destroyBatchScenesCommand.DestroyAsync(playIds);
return NoContent();
}
catch (AggregateException ex)
{
return BadRequest(new
{
Error = ex.Message,
Details = ex.InnerExceptions.Select(e => e.Message).ToList()
});
}
}
}

View File

@@ -0,0 +1,22 @@
using System.Text.Json;
namespace Bit.SeederApi.Execution;
/// <summary>
/// Executor for dynamically resolving and executing queries by name.
/// This is an infrastructure component that orchestrates query execution,
/// not a domain-level query.
/// </summary>
public interface IQueryExecutor
{
/// <summary>
/// Executes a query with the given query name and arguments.
/// Queries are read-only and do not track entities or create seed IDs.
/// </summary>
/// <param name="queryName">The name of the query (e.g., "EmergencyAccessInviteQuery")</param>
/// <param name="arguments">Optional JSON arguments to pass to the query's Execute method</param>
/// <returns>The result of the query execution</returns>
/// <exception cref="Services.QueryNotFoundException">Thrown when the query is not found</exception>
/// <exception cref="Services.QueryExecutionException">Thrown when there's an error executing the query</exception>
object Execute(string queryName, JsonElement? arguments);
}

View File

@@ -0,0 +1,22 @@
using System.Text.Json;
using Bit.SeederApi.Models.Response;
namespace Bit.SeederApi.Execution;
/// <summary>
/// Executor for dynamically resolving and executing scenes by template name.
/// This is an infrastructure component that orchestrates scene execution,
/// not a domain-level command.
/// </summary>
public interface ISceneExecutor
{
/// <summary>
/// Executes a scene with the given template name and arguments.
/// </summary>
/// <param name="templateName">The name of the scene template (e.g., "SingleUserScene")</param>
/// <param name="arguments">Optional JSON arguments to pass to the scene's Seed method</param>
/// <returns>A scene response model containing the result and mangle map</returns>
/// <exception cref="Services.SceneNotFoundException">Thrown when the scene template is not found</exception>
/// <exception cref="Services.SceneExecutionException">Thrown when there's an error executing the scene</exception>
Task<SceneResponseModel> ExecuteAsync(string templateName, JsonElement? arguments);
}

View File

@@ -0,0 +1,19 @@
using System.Text.Json;
namespace Bit.SeederApi.Execution;
/// <summary>
/// Provides shared JSON serialization configuration for executors.
/// </summary>
internal static class JsonConfiguration
{
/// <summary>
/// Standard JSON serializer options used for deserializing scene and query request models.
/// Uses case-insensitive property matching and camelCase naming policy.
/// </summary>
internal static readonly JsonSerializerOptions Options = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
}

View File

@@ -0,0 +1,77 @@
using System.Text.Json;
using Bit.Seeder;
using Bit.SeederApi.Services;
namespace Bit.SeederApi.Execution;
public class QueryExecutor(
ILogger<QueryExecutor> logger,
IServiceProvider serviceProvider) : IQueryExecutor
{
public object Execute(string queryName, JsonElement? arguments)
{
try
{
var query = serviceProvider.GetKeyedService<IQuery>(queryName)
?? throw new QueryNotFoundException(queryName);
var requestType = query.GetRequestType();
var requestModel = DeserializeRequestModel(queryName, requestType, arguments);
var result = query.Execute(requestModel);
logger.LogInformation("Successfully executed query: {QueryName}", queryName);
return result;
}
catch (Exception ex) when (ex is not QueryNotFoundException and not QueryExecutionException)
{
logger.LogError(ex, "Unexpected error executing query: {QueryName}", queryName);
throw new QueryExecutionException(
$"An unexpected error occurred while executing query '{queryName}'",
ex.InnerException ?? ex);
}
}
private object DeserializeRequestModel(string queryName, Type requestType, JsonElement? arguments)
{
if (arguments == null)
{
return CreateDefaultRequestModel(queryName, requestType);
}
try
{
var requestModel = JsonSerializer.Deserialize(arguments.Value.GetRawText(), requestType, JsonConfiguration.Options);
if (requestModel == null)
{
throw new QueryExecutionException(
$"Failed to deserialize request model for query '{queryName}'");
}
return requestModel;
}
catch (JsonException ex)
{
throw new QueryExecutionException(
$"Failed to deserialize request model for query '{queryName}': {ex.Message}", ex);
}
}
private object CreateDefaultRequestModel(string queryName, Type requestType)
{
try
{
var requestModel = Activator.CreateInstance(requestType);
if (requestModel == null)
{
throw new QueryExecutionException(
$"Arguments are required for query '{queryName}'");
}
return requestModel;
}
catch
{
throw new QueryExecutionException(
$"Arguments are required for query '{queryName}'");
}
}
}

View File

@@ -0,0 +1,78 @@
using System.Text.Json;
using Bit.Seeder;
using Bit.SeederApi.Models.Response;
using Bit.SeederApi.Services;
namespace Bit.SeederApi.Execution;
public class SceneExecutor(
ILogger<SceneExecutor> logger,
IServiceProvider serviceProvider) : ISceneExecutor
{
public async Task<SceneResponseModel> ExecuteAsync(string templateName, JsonElement? arguments)
{
try
{
var scene = serviceProvider.GetKeyedService<IScene>(templateName)
?? throw new SceneNotFoundException(templateName);
var requestType = scene.GetRequestType();
var requestModel = DeserializeRequestModel(templateName, requestType, arguments);
var result = await scene.SeedAsync(requestModel);
logger.LogInformation("Successfully executed scene: {TemplateName}", templateName);
return SceneResponseModel.FromSceneResult(result);
}
catch (Exception ex) when (ex is not SceneNotFoundException and not SceneExecutionException)
{
logger.LogError(ex, "Unexpected error executing scene: {TemplateName}", templateName);
throw new SceneExecutionException(
$"An unexpected error occurred while executing scene '{templateName}'",
ex.InnerException ?? ex);
}
}
private object DeserializeRequestModel(string templateName, Type requestType, JsonElement? arguments)
{
if (arguments == null)
{
return CreateDefaultRequestModel(templateName, requestType);
}
try
{
var requestModel = JsonSerializer.Deserialize(arguments.Value.GetRawText(), requestType, JsonConfiguration.Options);
if (requestModel == null)
{
throw new SceneExecutionException(
$"Failed to deserialize request model for scene '{templateName}'");
}
return requestModel;
}
catch (JsonException ex)
{
throw new SceneExecutionException(
$"Failed to deserialize request model for scene '{templateName}': {ex.Message}", ex);
}
}
private object CreateDefaultRequestModel(string templateName, Type requestType)
{
try
{
var requestModel = Activator.CreateInstance(requestType);
if (requestModel == null)
{
throw new SceneExecutionException(
$"Arguments are required for scene '{templateName}'");
}
return requestModel;
}
catch
{
throw new SceneExecutionException(
$"Arguments are required for scene '{templateName}'");
}
}
}

View File

@@ -0,0 +1,76 @@
using System.Reflection;
using Bit.Seeder;
using Bit.SeederApi.Commands;
using Bit.SeederApi.Commands.Interfaces;
using Bit.SeederApi.Execution;
using Bit.SeederApi.Queries;
using Bit.SeederApi.Queries.Interfaces;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Bit.SeederApi.Extensions;
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers SeederApi executors, commands, and queries.
/// </summary>
public static IServiceCollection AddSeederApiServices(this IServiceCollection services)
{
services.AddScoped<ISceneExecutor, SceneExecutor>();
services.AddScoped<IQueryExecutor, QueryExecutor>();
services.AddScoped<IDestroySceneCommand, DestroySceneCommand>();
services.AddScoped<IDestroyBatchScenesCommand, DestroyBatchScenesCommand>();
services.AddScoped<IGetAllPlayIdsQuery, GetAllPlayIdsQuery>();
return services;
}
/// <summary>
/// Dynamically registers all scene types that implement IScene&lt;TRequest&gt; from the Seeder assembly.
/// Scenes are registered as keyed scoped services using their class name as the key.
/// </summary>
public static IServiceCollection AddScenes(this IServiceCollection services)
{
var iSceneType1 = typeof(IScene<>);
var iSceneType2 = typeof(IScene<,>);
var isIScene = (Type t) => t == iSceneType1 || t == iSceneType2;
var seederAssembly = Assembly.Load("Seeder");
var sceneTypes = seederAssembly.GetTypes()
.Where(t => t is { IsClass: true, IsAbstract: false } &&
t.GetInterfaces().Any(i => i.IsGenericType &&
isIScene(i.GetGenericTypeDefinition())));
foreach (var sceneType in sceneTypes)
{
services.TryAddScoped(sceneType);
services.TryAddKeyedScoped(typeof(IScene), sceneType.Name, (sp, _) => sp.GetRequiredService(sceneType));
}
return services;
}
/// <summary>
/// Dynamically registers all query types that implement IQuery&lt;TRequest&gt; from the Seeder assembly.
/// Queries are registered as keyed scoped services using their class name as the key.
/// </summary>
public static IServiceCollection AddQueries(this IServiceCollection services)
{
var iQueryType = typeof(IQuery<,>);
var seederAssembly = Assembly.Load("Seeder");
var queryTypes = seederAssembly.GetTypes()
.Where(t => t is { IsClass: true, IsAbstract: false } &&
t.GetInterfaces().Any(i => i.IsGenericType &&
i.GetGenericTypeDefinition() == iQueryType));
foreach (var queryType in queryTypes)
{
services.TryAddScoped(queryType);
services.TryAddKeyedScoped(typeof(IQuery), queryType.Name, (sp, _) => sp.GetRequiredService(queryType));
}
return services;
}
}

View File

@@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
namespace Bit.SeederApi.Models.Request;
public class QueryRequestModel
{
[Required]
public required string Template { get; set; }
public JsonElement? Arguments { get; set; }
}

View File

@@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
namespace Bit.SeederApi.Models.Request;
public class SeedRequestModel
{
[Required]
public required string Template { get; set; }
public JsonElement? Arguments { get; set; }
}

View File

@@ -0,0 +1,18 @@
using Bit.Seeder;
namespace Bit.SeederApi.Models.Response;
public class SceneResponseModel
{
public required Dictionary<string, string?>? MangleMap { get; init; }
public required object? Result { get; init; }
public static SceneResponseModel FromSceneResult<T>(SceneResult<T> sceneResult)
{
return new SceneResponseModel
{
Result = sceneResult.Result,
MangleMap = sceneResult.MangleMap,
};
}
}

20
util/SeederApi/Program.cs Normal file
View File

@@ -0,0 +1,20 @@
using Bit.Core.Utilities;
namespace Bit.SeederApi;
public class Program
{
public static void Main(string[] args)
{
Host
.CreateDefaultBuilder(args)
.ConfigureCustomAppConfiguration(args)
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
})
.AddSerilogFileLogging()
.Build()
.Run();
}
}

View File

@@ -0,0 +1,37 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:5047",
"sslPort": 0
}
},
"profiles": {
"SeederApi": {
"commandName": "Project",
"dotnetRunMessages": true,
"applicationUrl": "http://localhost:5047",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"SeederApi-SelfHost": {
"commandName": "Project",
"dotnetRunMessages": true,
"applicationUrl": "http://localhost:5048",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"developSelfHosted": "true"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,15 @@
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.SeederApi.Queries.Interfaces;
namespace Bit.SeederApi.Queries;
public class GetAllPlayIdsQuery(DatabaseContext databaseContext) : IGetAllPlayIdsQuery
{
public List<string> GetAllPlayIds()
{
return databaseContext.PlayItem
.Select(pd => pd.PlayId)
.Distinct()
.ToList();
}
}

View File

@@ -0,0 +1,13 @@
namespace Bit.SeederApi.Queries.Interfaces;
/// <summary>
/// Query for retrieving all play IDs for currently tracked seeded data.
/// </summary>
public interface IGetAllPlayIdsQuery
{
/// <summary>
/// Retrieves all play IDs for currently tracked seeded data.
/// </summary>
/// <returns>A list of play IDs representing active seeded data that can be destroyed.</returns>
List<string> GetAllPlayIds();
}

185
util/SeederApi/README.md Normal file
View File

@@ -0,0 +1,185 @@
# SeederApi
A web API for dynamically seeding and querying test data in the Bitwarden database during development and testing.
## Overview
The SeederApi provides HTTP endpoints to execute [Seeder](../Seeder/README.md) scenes and queries, enabling automated test data
generation and retrieval through a RESTful interface. This is particularly useful for integration testing, local
development workflows, and automated test environments.
## Architecture
The SeederApi consists of three main components:
1. **Controllers** - HTTP endpoints for seeding, querying, and managing test data
2. **Services** - Business logic for scene and query execution
3. **Models** - Request/response models for API communication
### Key Components
- **SeedController** (`/seed`) - Creates and destroys seeded test data
- **QueryController** (`/query`) - Executes read-only queries against existing data
- **InfoController** (`/alive`, `/version`) - Health check and version information
- **SceneService** - Manages scene execution and cleanup with play ID tracking
- **QueryService** - Executes read-only query operations
## How To Use
### Starting the API
```bash
cd util/SeederApi
dotnet run
```
The API will start on the configured port (typically `http://localhost:5000`).
### Seeding Data
Send a POST request to `/seed` with a scene template name and optional arguments. Include the `X-Play-Id` header to
track the seeded data for later cleanup:
```bash
curl -X POST http://localhost:5000/seed \
-H "Content-Type: application/json" \
-H "X-Play-Id: test-run-123" \
-d '{
"template": "SingleUserScene",
"arguments": {
"email": "test@example.com"
}
}'
```
**Response:**
```json
{
"mangleMap": {
"test@example.com": "1854b016+test@example.com",
"42bcf05d-7ad0-4e27-8b53-b3b700acc664": "42bcf05d-7ad0-4e27-8b53-b3b700acc664"
},
"result": null
}
```
The `result` contains the data returned by the scene, and `mangleMap` contains ID mappings if ID mangling is enabled.
Use the `X-Play-Id` header value to later destroy the seeded data.
### Querying Data
Send a POST request to `/query` to execute read-only queries:
```bash
curl -X POST http://localhost:5000/query \
-H "Content-Type: application/json" \
-d '{
"template": "EmergencyAccessInviteQuery",
"arguments": {
"email": "test@example.com"
}
}'
```
**Response:**
```json
["/accept-emergency?..."]
```
### Destroying Seeded Data
#### Delete by Play ID
Use the same play ID value you provided in the `X-Play-Id` header:
```bash
curl -X DELETE http://localhost:5000/seed/test-run-123
```
#### Delete Multiple by Play IDs
```bash
curl -X DELETE http://localhost:5000/seed/batch \
-H "Content-Type: application/json" \
-d '["test-run-123", "test-run-456"]'
```
#### Delete All Seeded Data
```bash
curl -X DELETE http://localhost:5000/seed
```
### Health Checks
```bash
# Check if API is alive
curl http://localhost:5000/alive
# Get API version
curl http://localhost:5000/version
```
## Creating Scenes and Queries
Scenes and queries are defined in the [Seeder](../Seeder/README.md) project. The SeederApi automatically discovers and registers all
classes implementing the scene and query interfaces.
## Configuration
The SeederApi uses the standard Bitwarden configuration system:
- `appsettings.json` - Base configuration
- `appsettings.Development.json` - Development overrides
- `dev/secrets.json` - Local secrets (database connection strings, etc.)
- User Secrets ID: `bitwarden-seeder-api`
### Required Settings
The SeederApi requires the following configuration:
- **Database Connection** - Connection string to the Bitwarden database
- **Global Settings** - Standard Bitwarden `GlobalSettings` configuration
## Play ID Tracking
Certain entities such as Users and Organizations are tracked when created by a request including a PlayId. This enables
entities to be deleted after using the PlayId.
### The X-Play-Id Header
**Important:** All seed requests should include the `X-Play-Id` header:
```bash
-H "X-Play-Id: your-unique-identifier"
```
The play ID can be any string that uniquely identifies your test run or session. Common patterns:
### How Play ID Tracking Works
When `TestPlayIdTrackingEnabled` is enabled in GlobalSettings, the `PlayIdMiddleware`
(see `src/SharedWeb/Utilities/PlayIdMiddleware.cs:7-23`) automatically:
1. **Extracts** the `X-Play-Id` header from incoming requests
2. **Sets** the play ID in the `PlayIdService` for the request scope
3. **Tracks** all entities (users, organizations, etc.) created during the request
4. **Associates** them with the play ID in the `PlayItem` table
5. **Enables** complete cleanup via the delete endpoints
This tracking works for **any API request** that includes the `X-Play-Id` header, not just SeederApi endpoints. This means
you can track entities created through:
- **Scene executions** - Data seeded via `/seed` endpoint
- **Regular API operations** - Users signing up, creating organizations, inviting members, etc.
- **Integration tests** - Any HTTP requests to the Bitwarden API during test execution
Without the `X-Play-Id` header, entities will not be tracked and cannot be cleaned up using the delete endpoints.
## Security Considerations
> [!WARNING]
> The SeederApi is intended for **development and testing environments only**. Never deploy this API to production
> environments.

View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<UserSecretsId>bitwarden-seeder-api</UserSecretsId>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<StaticWebAssetsEnabled>false</StaticWebAssetsEnabled>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\SharedWeb\SharedWeb.csproj" />
<ProjectReference Include="..\Seeder\Seeder.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,10 @@
namespace Bit.SeederApi.Services;
public class QueryNotFoundException(string query) : Exception($"Query '{query}' not found");
public class QueryExecutionException : Exception
{
public QueryExecutionException(string message) : base(message) { }
public QueryExecutionException(string message, Exception innerException)
: base(message, innerException) { }
}

View File

@@ -0,0 +1,10 @@
namespace Bit.SeederApi.Services;
public class SceneNotFoundException(string scene) : Exception($"Scene '{scene}' not found");
public class SceneExecutionException : Exception
{
public SceneExecutionException(string message) : base(message) { }
public SceneExecutionException(string message, Exception innerException)
: base(message, innerException) { }
}

80
util/SeederApi/Startup.cs Normal file
View File

@@ -0,0 +1,80 @@
using System.Globalization;
using Bit.Core.Settings;
using Bit.Seeder;
using Bit.Seeder.Factories;
using Bit.SeederApi.Extensions;
using Bit.SharedWeb.Utilities;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Bit.SeederApi;
public class Startup
{
public Startup(IWebHostEnvironment env, IConfiguration configuration)
{
CultureInfo.DefaultThreadCurrentCulture = new CultureInfo("en-US");
Configuration = configuration;
Environment = env;
}
public IConfiguration Configuration { get; private set; }
public IWebHostEnvironment Environment { get; set; }
public void ConfigureServices(IServiceCollection services)
{
services.AddOptions();
var globalSettings = services.AddGlobalSettingsServices(Configuration, Environment);
services.AddCustomDataProtectionServices(Environment, globalSettings);
services.AddTokenizers();
services.AddDatabaseRepositories(globalSettings);
services.AddTestPlayIdTracking(globalSettings);
services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
services.AddScoped<IPasswordHasher<Core.Entities.User>, PasswordHasher<Core.Entities.User>>();
services.AddSingleton<RustSDK.RustSdkService>();
services.AddScoped<UserSeeder>();
services.AddSeederApiServices();
services.AddScoped<MangleId>(_ => new MangleId());
services.AddScenes();
services.AddQueries();
services.AddControllers();
}
public void Configure(
IApplicationBuilder app,
IWebHostEnvironment env,
IHostApplicationLifetime appLifetime,
GlobalSettings globalSettings)
{
if (env.IsProduction())
{
throw new InvalidOperationException(
"SeederApi cannot be run in production environments. This service is intended for test data generation only.");
}
if (globalSettings.TestPlayIdTrackingEnabled)
{
app.UseMiddleware<PlayIdMiddleware>();
}
if (!env.IsDevelopment())
{
app.UseExceptionHandler("/Home/Error");
}
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(name: "default", pattern: "{controller=Seed}/{action=Index}/{id?}");
});
}
}

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