Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions test/SeederApi.IntegrationTest/Commands/DestroySceneCommandTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
ο»Ώusing AutoMapper;
using Bit.Core.Entities;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Bit.Infrastructure.EntityFramework.AdminConsole.Repositories;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.SeederApi.Commands;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Xunit;

namespace Bit.SeederApi.IntegrationTest.Commands;

/// <summary>Exercises <see cref="DestroySceneCommand"/> premium license-file cleanup against in-memory SQLite.</summary>
public sealed class DestroySceneCommandTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly ServiceProvider _provider;
private readonly string _licenseDirectory;

public DestroySceneCommandTests()
{
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();

var services = new ServiceCollection();
services.AddLogging();
// DatabaseContext.OnModelCreating resolves IDataProtectionProvider for the
// User.Key / User.MasterPassword field converters, so DI must include it.
services.AddDataProtection();
services.AddDbContext<DatabaseContext>(opts => opts.UseSqlite(_connection));
services.AddAutoMapper(typeof(UserRepository));

_provider = services.BuildServiceProvider();
_provider.GetRequiredService<DatabaseContext>().Database.EnsureCreated();

_licenseDirectory = Path.Combine(Path.GetTempPath(), $"seeder-license-tests-{Guid.NewGuid():N}");
Comment thread
nthompson-bitwarden marked this conversation as resolved.
Dismissed
Directory.CreateDirectory(Path.Combine(_licenseDirectory, "user"));
Comment thread
nthompson-bitwarden marked this conversation as resolved.
Dismissed
}

public void Dispose()
{
_provider.Dispose();
_connection.Dispose();
if (Directory.Exists(_licenseDirectory))
{
Directory.Delete(_licenseDirectory, recursive: true);
}
}

[Fact]
public async Task DestroyAsync_SelfHosted_DeletesSeededUserLicenseFile()
{
var playId = Guid.NewGuid().ToString();
var user = await SeedUserWithPlayItemAsync(playId);
var licenseFile = WriteLicenseFile(user.Id);

await BuildCommand(selfHosted: true).DestroyAsync(playId);

Assert.False(File.Exists(licenseFile));
Assert.False(UserExists(user.Id));
}

[Fact]
public async Task DestroyAsync_NotSelfHosted_LeavesLicenseFileUntouched()
{
var playId = Guid.NewGuid().ToString();
var user = await SeedUserWithPlayItemAsync(playId);
var licenseFile = WriteLicenseFile(user.Id);

await BuildCommand(selfHosted: false).DestroyAsync(playId);

Assert.True(File.Exists(licenseFile));
Assert.False(UserExists(user.Id));
}

[Fact]
public async Task DestroyAsync_SelfHosted_MissingLicenseFile_StillSucceeds()
{
var playId = Guid.NewGuid().ToString();
var user = await SeedUserWithPlayItemAsync(playId);

await BuildCommand(selfHosted: true).DestroyAsync(playId);

Assert.False(UserExists(user.Id));
}

[Fact]
public async Task DestroyAsync_SelfHosted_UndeletableLicenseFile_DoesNotAbortDestroy()
{
var playId = Guid.NewGuid().ToString();
var user = await SeedUserWithPlayItemAsync(playId);

// A directory at the license file path forces File.Delete to throw; the best-effort cleanup must
// swallow it so the database teardown still succeeds.
var blockingPath = Path.Combine(_licenseDirectory, "user", $"{user.Id}.json");
Comment thread
nthompson-bitwarden marked this conversation as resolved.
Dismissed
Directory.CreateDirectory(blockingPath);

await BuildCommand(selfHosted: true).DestroyAsync(playId);

Assert.False(UserExists(user.Id));
Assert.True(Directory.Exists(blockingPath));
}

private DestroySceneCommand BuildCommand(bool selfHosted)
{
var scopeFactory = _provider.GetRequiredService<IServiceScopeFactory>();
var mapper = _provider.GetRequiredService<IMapper>();
var globalSettings = new GlobalSettings
{
SelfHosted = selfHosted,
LicenseDirectory = _licenseDirectory,
};

return new DestroySceneCommand(
_provider.GetRequiredService<DatabaseContext>(),
_provider.GetRequiredService<ILogger<DestroySceneCommand>>(),
new UserRepository(scopeFactory, mapper),
new PlayItemRepository(scopeFactory, mapper),
new ProviderRepository(scopeFactory, mapper),
new OrganizationRepository(scopeFactory, mapper,
_provider.GetRequiredService<ILogger<OrganizationRepository>>()),
globalSettings);
}

private async Task<User> SeedUserWithPlayItemAsync(string playId)
{
var scopeFactory = _provider.GetRequiredService<IServiceScopeFactory>();
var mapper = _provider.GetRequiredService<IMapper>();

var user = new User
{
Id = CombGuid.Generate(),
Email = $"destroy-{Guid.NewGuid():N}@bw.example",
SecurityStamp = Guid.NewGuid().ToString(),
ApiKey = "test-api-key",
};
await new UserRepository(scopeFactory, mapper).CreateAsync(user);

var playItem = PlayItem.Create(user, playId);
playItem.SetNewId();
await new PlayItemRepository(scopeFactory, mapper).CreateAsync(playItem);

return user;
}

private string WriteLicenseFile(Guid userId)
{
var path = Path.Combine(_licenseDirectory, "user", $"{userId}.json");
Comment thread
nthompson-bitwarden marked this conversation as resolved.
Dismissed
File.WriteAllText(path, "{}");
return path;
}

private bool UserExists(Guid userId) =>
_provider.GetRequiredService<DatabaseContext>().Users.Any(u => u.Id == userId);
}
55 changes: 55 additions & 0 deletions test/SeederApi.IntegrationTest/LicenseTestHelpers.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
ο»Ώusing System.Security.Claims;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Models.Business;
using Bit.Core.Billing.Organizations.Models;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
using Bit.Core.Models.Business;
using Bit.Seeder.Services;

namespace Bit.SeederApi.IntegrationTest;

/// <summary>
/// Shared harness for the self-hosted premium license tests: a premium-owner factory plus hand-written
/// licensing stubs.
/// </summary>
internal static class LicenseTestHelpers
{
internal static User NewPremiumOwner() => new()
{
Id = Guid.NewGuid(),
Email = "premium.user@example.com",
Premium = true,
};

internal sealed class StubSeederLicenseSigner(Func<User, Task<LicenseSigningResult>> behavior) : ISeederLicenseSigner
{
public Task<LicenseSigningResult> CreateUserTokenAsync(User user) => behavior(user);
}

/// <summary>
/// Captures the licenses passed to <see cref="ILicensingService.WriteUserLicenseAsync"/> and runs
/// <paramref name="onWrite"/> to drive the success or failure path.
/// </summary>
internal sealed class StubLicensingService(Func<User, UserLicense, Task> onWrite) : ILicensingService
{
public List<UserLicense> WrittenLicenses { get; } = [];

public Task WriteUserLicenseAsync(User user, UserLicense license)
{
WrittenLicenses.Add(license);
return onWrite(user, license);
}

public Task ValidateOrganizationsAsync() => throw new NotImplementedException();
public Task ValidateUsersAsync() => throw new NotImplementedException();
public Task<bool> ValidateUserPremiumAsync(User user) => throw new NotImplementedException();
public bool VerifyLicense(ILicense license) => throw new NotImplementedException();
public byte[] SignLicense(ILicense license) => throw new NotImplementedException();
public Task<OrganizationLicense?> ReadOrganizationLicenseAsync(Organization organization) => throw new NotImplementedException();
public Task<OrganizationLicense?> ReadOrganizationLicenseAsync(Guid organizationId) => throw new NotImplementedException();
public ClaimsPrincipal? GetClaimsPrincipalFromLicense(ILicense license) => throw new NotImplementedException();
public Task<string?> CreateOrganizationTokenAsync(Organization organization, Guid installationId, SubscriptionInfo subscriptionInfo) => throw new NotImplementedException();
public Task<string?> CreateUserTokenAsync(User user, SubscriptionInfo subscriptionInfo) => throw new NotImplementedException();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ private RecipeOrchestrator NewOrchestrator(IManglerService mangler)
new PasswordHasher<User>(),
mangler,
null!,
null!,
null!);
return new RecipeOrchestrator(deps);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,9 @@ public void CreateIndividualUser_ProducesTwoStepsInOrder()
var builder = services.AddRecipe("test");

builder.CreateIndividualUser("user@example.com", true, 1, true);
services.AddLogging();
services.AddSingleton<ILicensingService, StubLicensingService>();
services.AddSingleton<ISeederLicenseSigner, StubSeederLicenseSigner>();

using var provider = services.BuildServiceProvider();
var steps = provider.GetKeyedServices<OrderedStep>("test")
Expand Down Expand Up @@ -272,6 +274,12 @@ private sealed class StubLicensingService : ILicensingService
public Task WriteUserLicenseAsync(User user, UserLicense license) => throw new NotImplementedException();
}

private sealed class StubSeederLicenseSigner : ISeederLicenseSigner
{
public Task<LicenseSigningResult> CreateUserTokenAsync(User user) =>
Task.FromResult(LicenseSigningResult.Skipped("no signing certificate configured"));
}

private sealed class StubSeedReader(bool hasOwner) : ISeedReader
{
public T Read<T>(string seedName) =>
Expand Down
54 changes: 46 additions & 8 deletions test/SeederApi.IntegrationTest/SeedControllerTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
ο»Ώusing System.Net;
using System.Text.Json;
using Bit.Seeder.Scenes;
using Bit.SeederApi.Models.Request;
using Bit.SeederApi.Models.Response;
Expand Down Expand Up @@ -44,7 +45,7 @@ public async Task SeedEndpoint_WithValidScene_ReturnsOk()
var response = await _client.PostAsJsonAsync("/seed", new SeedRequestModel
{
Template = "SingleUserScene",
Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new SingleUserScene.Request() { Email = testEmail, Password = "asdfasdfasdf" })
Arguments = JsonSerializer.SerializeToElement(new SingleUserScene.Request() { Email = testEmail, Password = "asdfasdfasdf" })
}, playId);

response.EnsureSuccessStatusCode();
Expand All @@ -55,13 +56,50 @@ public async Task SeedEndpoint_WithValidScene_ReturnsOk()
Assert.NotNull(result.Result);
}

[Fact]
public async Task SeedEndpoint_SelfHostedPremiumUser_ReportsPremiumLicenseOutcome()
{
var testEmail = $"premium-selfhost-{Guid.NewGuid()}@bitwarden.com";

var response = await _client.PostAsJsonAsync("/seed", new SeedRequestModel
{
Template = "SingleUserScene",
Arguments = JsonSerializer.SerializeToElement(new SingleUserScene.Request
{
Email = testEmail,
Password = "asdfasdfasdf",
Premium = true,
SelfHosted = true
})
}, Guid.NewGuid().ToString());

response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<SceneResponseModel>();

Assert.NotNull(result);
var written = GetResultProperty(result, "premiumLicenseWritten").GetBoolean();
var warning = GetResultProperty(result, "premiumLicenseWarning");

// The test factory configures no licensing certificate, so signing is skipped and warns.
Assert.False(written);
Assert.False(string.IsNullOrWhiteSpace(warning.GetString()));
}

private static JsonElement GetResultProperty(SceneResponseModel response, string propertyName)
{
var result = Assert.IsType<JsonElement>(response.Result);
Assert.True(result.TryGetProperty(propertyName, out var property),
$"Scene result did not contain '{propertyName}'.");
return property;
}

[Fact]
public async Task SeedEndpoint_WithInvalidSceneName_ReturnsNotFound()
{
var response = await _client.PostAsJsonAsync("/seed", new SeedRequestModel
{
Template = "NonExistentScene",
Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new SingleUserScene.Request() { Email = "test@example.com", Password = "asdfasdfasdf" })
Arguments = JsonSerializer.SerializeToElement(new SingleUserScene.Request() { Email = "test@example.com", Password = "asdfasdfasdf" })
});

Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
Expand All @@ -74,7 +112,7 @@ public async Task SeedEndpoint_WithMissingRequiredField_ReturnsBadRequest()
var response = await _client.PostAsJsonAsync("/seed", new SeedRequestModel
{
Template = "SingleUserScene",
Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { wrongField = "value" })
Arguments = JsonSerializer.SerializeToElement(new { wrongField = "value" })
});

Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Expand All @@ -89,7 +127,7 @@ public async Task DeleteEndpoint_WithValidPlayId_ReturnsOk()
var seedResponse = await _client.PostAsJsonAsync("/seed", new SeedRequestModel
{
Template = "SingleUserScene",
Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new SingleUserScene.Request() { Email = testEmail, Password = "asdfasdfasdf" })
Arguments = JsonSerializer.SerializeToElement(new SingleUserScene.Request() { Email = testEmail, Password = "asdfasdfasdf" })
}, playId);

seedResponse.EnsureSuccessStatusCode();
Expand Down Expand Up @@ -126,7 +164,7 @@ public async Task DeleteBatchEndpoint_WithValidPlayIds_ReturnsOk()
var seedResponse = await _client.PostAsJsonAsync("/seed", new SeedRequestModel
{
Template = "SingleUserScene",
Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new SingleUserScene.Request() { Email = testEmail, Password = "asdfasdfasdf" })
Arguments = JsonSerializer.SerializeToElement(new SingleUserScene.Request() { Email = testEmail, Password = "asdfasdfasdf" })
}, playId);

seedResponse.EnsureSuccessStatusCode();
Expand Down Expand Up @@ -158,7 +196,7 @@ public async Task DeleteBatchEndpoint_WithSomeInvalidIds_ReturnsOk()
var seedResponse = await _client.PostAsJsonAsync("/seed", new SeedRequestModel
{
Template = "SingleUserScene",
Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new SingleUserScene.Request() { Email = testEmail, Password = "asdfasdfasdf" })
Arguments = JsonSerializer.SerializeToElement(new SingleUserScene.Request() { Email = testEmail, Password = "asdfasdfasdf" })
}, validPlayId);

seedResponse.EnsureSuccessStatusCode();
Expand Down Expand Up @@ -191,7 +229,7 @@ public async Task DeleteAllEndpoint_DeletesAllSeededData()
var seedResponse = await _client.PostAsJsonAsync("/seed", new SeedRequestModel
{
Template = "SingleUserScene",
Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new SingleUserScene.Request() { Email = testEmail, Password = "asdfasdfasdf" })
Arguments = JsonSerializer.SerializeToElement(new SingleUserScene.Request() { Email = testEmail, Password = "asdfasdfasdf" })
}, playId);

seedResponse.EnsureSuccessStatusCode();
Expand All @@ -211,7 +249,7 @@ public async Task SeedEndpoint_VerifyResponseContainsMangleMapAndResult()
var response = await _client.PostAsJsonAsync("/seed", new SeedRequestModel
{
Template = "SingleUserScene",
Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new SingleUserScene.Request() { Email = testEmail, Password = "asdfasdfasdf" })
Arguments = JsonSerializer.SerializeToElement(new SingleUserScene.Request() { Email = testEmail, Password = "asdfasdfasdf" })
}, playId);

response.EnsureSuccessStatusCode();
Expand Down
Loading
Loading