diff --git a/bitwarden-server.sln b/bitwarden-server.sln index d75460f2df..4b0a3685d3 100644 --- a/bitwarden-server.sln +++ b/bitwarden-server.sln @@ -134,10 +134,13 @@ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DbSeederUtility", "util\DbSeederUtility\DbSeederUtility.csproj", "{17A89266-260A-4A03-81AE-C0468C6EE06E}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RustSdk", "util\RustSdk\RustSdk.csproj", "{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}" +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}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -354,6 +357,10 @@ Global {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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -411,6 +418,7 @@ Global {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} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F} diff --git a/test/SeederApi.IntegrationTest/QueryControllerTest.cs b/test/SeederApi.IntegrationTest/QueryControllerTest.cs new file mode 100644 index 0000000000..4043c6267f --- /dev/null +++ b/test/SeederApi.IntegrationTest/QueryControllerTest.cs @@ -0,0 +1,108 @@ +using System.Net; +using Bit.SeederApi.Models.Requests; +using Xunit; + +namespace Bit.SeederApi.IntegrationTest; + +public class EmergencyAccessInviteQueryTests : IClassFixture, IAsyncLifetime +{ + private readonly HttpClient _client; + private readonly SeederApiApplicationFactory _factory; + + public EmergencyAccessInviteQueryTests(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.ReadFromJsonAsync(); + + Assert.NotNull(result); + Assert.NotNull(result.Result); + + // The result should be a JSON array (even if empty for non-existent email) + var resultElement = result.Result as System.Text.Json.JsonElement?; + Assert.NotNull(resultElement); + + var urls = System.Text.Json.JsonSerializer.Deserialize>(resultElement.Value.GetRawText()); + 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); + } + + [Fact] + public async Task QueryEndpoint_VerifyQueryDoesNotCreateSeedId() + { + var testEmail = $"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.ReadFromJsonAsync(); + + Assert.NotNull(result); + + // Verify the response only has Result field, not SeedId + // (Queries are read-only and don't track entities) + var jsonString = await response.Content.ReadAsStringAsync(); + Assert.DoesNotContain("seedId", jsonString, StringComparison.OrdinalIgnoreCase); + Assert.Contains("result", jsonString, StringComparison.OrdinalIgnoreCase); + } + + private class QueryResponse + { + public object? Result { get; set; } + } +} diff --git a/test/SeederApi.IntegrationTest/SeedControllerTest.cs b/test/SeederApi.IntegrationTest/SeedControllerTest.cs new file mode 100644 index 0000000000..547d0bf98c --- /dev/null +++ b/test/SeederApi.IntegrationTest/SeedControllerTest.cs @@ -0,0 +1,214 @@ +using System.Net; +using Bit.SeederApi.Models.Requests; +using Bit.SeederApi.Models.Response; +using Xunit; + +namespace Bit.SeederApi.IntegrationTest; + +public class SeedControllerTests : IClassFixture, 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_ReturnsOkWithSeedId() + { + var testEmail = $"seed-test-{Guid.NewGuid()}@bitwarden.com"; + + var response = await _client.PostAsJsonAsync("/seed", new SeedRequestModel + { + Template = "SingleUserScene", + Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = testEmail }) + }); + + response.EnsureSuccessStatusCode(); + var result = await response.Content.ReadFromJsonAsync(); + + Assert.NotNull(result); + Assert.NotEqual(Guid.Empty, result.SeedId); + Assert.NotNull(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_WithValidSeedId_ReturnsOk() + { + var testEmail = $"delete-test-{Guid.NewGuid()}@bitwarden.com"; + var seedResponse = await _client.PostAsJsonAsync("/seed", new SeedRequestModel + { + Template = "SingleUserScene", + Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = testEmail }) + }); + + seedResponse.EnsureSuccessStatusCode(); + var seedResult = await seedResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(seedResult); + + var deleteResponse = await _client.DeleteAsync($"/seed/{seedResult.SeedId}"); + deleteResponse.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task DeleteEndpoint_WithInvalidSeedId_ReturnsOkWithNull() + { + // DestroyRecipe is idempotent - returns null for non-existent seeds + var nonExistentSeedId = Guid.NewGuid(); + var response = await _client.DeleteAsync($"/seed/{nonExistentSeedId}"); + + response.EnsureSuccessStatusCode(); + var content = await response.Content.ReadAsStringAsync(); + Assert.Equal("null", content); + } + + [Fact] + public async Task DeleteBatchEndpoint_WithValidSeedIds_ReturnsOk() + { + // Create multiple seeds + var seedIds = new List(); + for (var i = 0; i < 3; i++) + { + 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 }) + }); + + seedResponse.EnsureSuccessStatusCode(); + var seedResult = await seedResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(seedResult); + Assert.NotNull(seedResult.SeedId); + seedIds.Add(seedResult.SeedId.Value); + } + + // Delete them in batch + var request = new HttpRequestMessage(HttpMethod.Delete, "/seed/batch") + { + Content = JsonContent.Create(seedIds) + }; + var deleteResponse = await _client.SendAsync(request); + deleteResponse.EnsureSuccessStatusCode(); + + var result = await deleteResponse.Content.ReadFromJsonAsync(); + 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 + 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 }) + }); + + seedResponse.EnsureSuccessStatusCode(); + var seedResult = await seedResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(seedResult); + + // Try to delete with mix of valid and invalid IDs + Assert.NotNull(seedResult.SeedId); + var seedIds = new List { seedResult.SeedId.Value, Guid.NewGuid(), Guid.NewGuid() }; + var request = new HttpRequestMessage(HttpMethod.Delete, "/seed/batch") + { + Content = JsonContent.Create(seedIds) + }; + var deleteResponse = await _client.SendAsync(request); + + deleteResponse.EnsureSuccessStatusCode(); + var result = await deleteResponse.Content.ReadFromJsonAsync(); + 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 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 }) + }); + + seedResponse.EnsureSuccessStatusCode(); + } + + // Delete all + var deleteResponse = await _client.DeleteAsync("/seed"); + Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode); + } + + [Fact] + public async Task SeedEndpoint_VerifyResponseContainsSeedIdAndResult() + { + var testEmail = $"verify-response-{Guid.NewGuid()}@bitwarden.com"; + + var response = await _client.PostAsJsonAsync("/seed", new SeedRequestModel + { + Template = "SingleUserScene", + Arguments = System.Text.Json.JsonSerializer.SerializeToElement(new { email = testEmail }) + }); + + response.EnsureSuccessStatusCode(); + var jsonString = await response.Content.ReadAsStringAsync(); + + // Verify the response contains both SeedId and Result fields + Assert.Contains("seedId", jsonString, StringComparison.OrdinalIgnoreCase); + Assert.Contains("result", jsonString, StringComparison.OrdinalIgnoreCase); + } + + private class BatchDeleteResponse + { + public string? Message { get; set; } + } +} diff --git a/test/SeederApi.IntegrationTest/SeederApi.IntegrationTest.csproj b/test/SeederApi.IntegrationTest/SeederApi.IntegrationTest.csproj new file mode 100644 index 0000000000..a4709ea58a --- /dev/null +++ b/test/SeederApi.IntegrationTest/SeederApi.IntegrationTest.csproj @@ -0,0 +1,29 @@ + + + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + %(RecursiveDir)%(Filename)%(Extension) + PreserveNewest + + + diff --git a/test/SeederApi.IntegrationTest/SeederApiApplicationFactory.cs b/test/SeederApi.IntegrationTest/SeederApiApplicationFactory.cs new file mode 100644 index 0000000000..4deae017e2 --- /dev/null +++ b/test/SeederApi.IntegrationTest/SeederApiApplicationFactory.cs @@ -0,0 +1,7 @@ +using Bit.IntegrationTestCommon.Factories; + +namespace Bit.SeederApi.IntegrationTest; + +public class SeederApiApplicationFactory : WebApplicationFactoryBase +{ +} diff --git a/util/SeederApi/Controllers/QueryController.cs b/util/SeederApi/Controllers/QueryController.cs new file mode 100644 index 0000000000..357f3a6304 --- /dev/null +++ b/util/SeederApi/Controllers/QueryController.cs @@ -0,0 +1,37 @@ +using Bit.SeederApi.Models.Requests; +using Bit.SeederApi.Services; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.SeederApi.Controllers +{ + [Route("query")] + public class QueryController(ILogger logger, IRecipeService recipeService) + : Controller + { + [HttpPost] + public IActionResult Query([FromBody] QueryRequestModel request) + { + logger.LogInformation("Executing query: {Query}", request.Template); + + try + { + var result = recipeService.ExecuteQuery(request.Template, request.Arguments); + + return Json(new { Result = result }); + } + catch (RecipeNotFoundException ex) + { + return NotFound(new { Error = ex.Message }); + } + catch (RecipeExecutionException ex) + { + logger.LogError(ex, "Error executing query: {Query}", request.Template); + return BadRequest(new + { + Error = ex.Message, + Details = ex.InnerException?.Message + }); + } + } + } +} diff --git a/util/SeederApi/Controllers/SeedController.cs b/util/SeederApi/Controllers/SeedController.cs index cb816e3d8a..e794fa135d 100644 --- a/util/SeederApi/Controllers/SeedController.cs +++ b/util/SeederApi/Controllers/SeedController.cs @@ -5,37 +5,11 @@ using Microsoft.AspNetCore.Mvc; namespace Bit.SeederApi.Controllers { - [Route("")] + [Route("seed")] public class SeedController(ILogger logger, IRecipeService recipeService) : Controller { - [HttpPost("/query")] - public IActionResult Query([FromBody] SeedRequestModel request) - { - logger.LogInformation("Executing query: {Query}", request.Template); - - try - { - var result = recipeService.ExecuteQuery(request.Template, request.Arguments); - - return Json(new { Result = result }); - } - catch (RecipeNotFoundException ex) - { - return NotFound(new { Error = ex.Message }); - } - catch (RecipeExecutionException ex) - { - logger.LogError(ex, "Error executing query: {Query}", request.Template); - return BadRequest(new - { - Error = ex.Message, - Details = ex.InnerException?.Message - }); - } - } - - [HttpPost("/seed")] + [HttpPost] public IActionResult Seed([FromBody] SeedRequestModel request) { logger.LogInformation("Seeding with template: {Template}", request.Template); @@ -65,7 +39,7 @@ namespace Bit.SeederApi.Controllers } } - [HttpDelete("/seed/batch")] + [HttpDelete("batch")] public async Task DeleteBatch([FromBody] List seedIds) { logger.LogInformation("Deleting batch of seeded data with IDs: {SeedIds}", string.Join(", ", seedIds)); @@ -102,7 +76,7 @@ namespace Bit.SeederApi.Controllers }); } - [HttpDelete("/seed/{seedId}")] + [HttpDelete("{seedId}")] public async Task Delete([FromRoute] Guid seedId) { logger.LogInformation("Deleting seeded data with ID: {SeedId}", seedId); @@ -125,7 +99,7 @@ namespace Bit.SeederApi.Controllers } - [HttpDelete("/seed")] + [HttpDelete] public async Task DeleteAll() { logger.LogInformation("Deleting all seeded data"); diff --git a/util/SeederApi/Models/Request/QueryRequestModel.cs b/util/SeederApi/Models/Request/QueryRequestModel.cs new file mode 100644 index 0000000000..1c85a47c47 --- /dev/null +++ b/util/SeederApi/Models/Request/QueryRequestModel.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json; + +namespace Bit.SeederApi.Models.Requests; + +public class QueryRequestModel +{ + [Required] + public required string Template { get; set; } + public JsonElement? Arguments { get; set; } +} \ No newline at end of file diff --git a/util/SeederApi/Program.cs b/util/SeederApi/Program.cs index e92d2ea4da..86ed2363ea 100644 --- a/util/SeederApi/Program.cs +++ b/util/SeederApi/Program.cs @@ -37,3 +37,6 @@ app.UseRouting(); app.MapControllerRoute(name: "default", pattern: "{controller=Seed}/{action=Index}/{id?}"); app.Run(); + +// Make Program class accessible for integration tests +public partial class Program { }