1
0
mirror of https://github.com/bitwarden/server synced 2026-01-02 00:23:40 +00:00

Merge branch 'main' into auth/pm-22975/client-version-validator

This commit is contained in:
Patrick-Pimentel-Bitwarden
2025-11-21 15:08:45 -05:00
committed by GitHub
62 changed files with 484 additions and 742 deletions

View File

@@ -0,0 +1,70 @@
using Bit.Core.Auth.Attributes;
using Bit.Core.Auth.Models.Api.Request.Accounts;
using Xunit;
namespace Bit.Core.Test.Auth.Attributes;
public class MarketingInitiativeValidationAttributeTests
{
[Fact]
public void IsValid_NullValue_ReturnsTrue()
{
var sut = new MarketingInitiativeValidationAttribute();
var actual = sut.IsValid(null);
Assert.True(actual);
}
[Theory]
[InlineData(MarketingInitiativeConstants.Premium)]
public void IsValid_AcceptedValue_ReturnsTrue(string value)
{
var sut = new MarketingInitiativeValidationAttribute();
var actual = sut.IsValid(value);
Assert.True(actual);
}
[Theory]
[InlineData("invalid")]
[InlineData("")]
[InlineData("Premium")] // case sensitive - capitalized
[InlineData("PREMIUM")] // case sensitive - uppercase
[InlineData("premium ")] // trailing space
[InlineData(" premium")] // leading space
public void IsValid_InvalidStringValue_ReturnsFalse(string value)
{
var sut = new MarketingInitiativeValidationAttribute();
var actual = sut.IsValid(value);
Assert.False(actual);
}
[Theory]
[InlineData(123)] // integer
[InlineData(true)] // boolean
[InlineData(45.67)] // double
public void IsValid_NonStringValue_ReturnsFalse(object value)
{
var sut = new MarketingInitiativeValidationAttribute();
var actual = sut.IsValid(value);
Assert.False(actual);
}
[Fact]
public void ErrorMessage_ContainsAcceptedValues()
{
var sut = new MarketingInitiativeValidationAttribute();
var errorMessage = sut.ErrorMessage;
Assert.NotNull(errorMessage);
Assert.Contains("premium", errorMessage);
Assert.Contains("Marketing initiative type must be one of:", errorMessage);
}
}

View File

@@ -0,0 +1,18 @@
using Bit.Core.Auth.Models.Api.Request.Accounts;
using Xunit;
namespace Bit.Core.Test.Auth.Models.Api.Request.Accounts;
/// <summary>
/// Snapshot tests to ensure the string constants in <see cref="MarketingInitiativeConstants"/> do not change unintentionally.
/// If you intentionally change any of these values, please update the tests to reflect the new expected values.
/// </summary>
public class MarketingInitiativeConstantsSnapshotTests
{
[Fact]
public void MarketingInitiativeConstants_HaveCorrectValues()
{
// Assert
Assert.Equal("premium", MarketingInitiativeConstants.Premium);
}
}

View File

@@ -1,13 +1,9 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Hosting;
using Bit.Core.Utilities;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Serilog;
using Serilog.Extensions.Logging;
using Xunit;
@@ -23,18 +19,6 @@ public class LoggerFactoryExtensionsTests
Assert.Empty(providers);
}
[Fact]
public void AddSerilog_IsDevelopment_DevLoggingEnabled_AddsSerilog()
{
var providers = GetProviders(new Dictionary<string, string?>
{
{ "GlobalSettings:EnableDevLogging", "true" },
}, "Development");
var provider = Assert.Single(providers);
Assert.IsAssignableFrom<SerilogLoggerProvider>(provider);
}
[Fact]
public void AddSerilog_IsProduction_AddsSerilog()
{
@@ -52,7 +36,7 @@ public class LoggerFactoryExtensionsTests
var providers = GetProviders(new Dictionary<string, string?>
{
{ "GlobalSettings:ProjectName", "Test" },
{ "GlobalSetting:LogDirectoryByProject", "true" },
{ "GlobalSettings:LogDirectoryByProject", "true" },
{ "GlobalSettings:LogDirectory", tempDir.FullName },
});
@@ -62,6 +46,8 @@ public class LoggerFactoryExtensionsTests
var logger = provider.CreateLogger("Test");
logger.LogWarning("This is a test");
provider.Dispose();
var logFile = Assert.Single(tempDir.EnumerateFiles("Test/*.txt"));
var logFileContents = await File.ReadAllTextAsync(logFile.FullName);
@@ -104,62 +90,6 @@ public class LoggerFactoryExtensionsTests
logFileContents
);
}
[Fact(Skip = "Only for local development.")]
public async Task AddSerilog_SyslogConfigured_Warns()
{
// Setup a fake syslog server
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20));
using var listener = new TcpListener(IPAddress.Parse("127.0.0.1"), 25000);
listener.Start();
var provider = GetServiceProvider(new Dictionary<string, string?>
{
{ "GlobalSettings:SysLog:Destination", "tcp://127.0.0.1:25000" },
{ "GlobalSettings:SiteName", "TestSite" },
{ "GlobalSettings:ProjectName", "TestProject" },
}, "Production");
var loggerFactory = provider.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("Test");
logger.LogWarning("This is a test");
// Look in syslog for data
using var socket = await listener.AcceptSocketAsync(cts.Token);
// This is rather lazy as opposed to implementing smarter syslog message
// reading but thats not what this test about, so instead just give
// the sink time to finish its work in the background
List<string> messages = [];
while (true)
{
var buffer = new byte[1024];
var received = await socket.ReceiveAsync(buffer, SocketFlags.None, cts.Token);
if (received == 0)
{
break;
}
var response = Encoding.ASCII.GetString(buffer, 0, received);
messages.Add(response);
if (messages.Count == 2)
{
break;
}
}
Assert.Collection(
messages,
(firstMessage) => Assert.Contains("Syslog for logging has been deprecated", firstMessage),
(secondMessage) => Assert.Contains("This is a test", secondMessage)
);
}
private static IEnumerable<ILoggerProvider> GetProviders(Dictionary<string, string?> initialData, string environment = "Production")
{
var provider = GetServiceProvider(initialData, environment);
@@ -172,23 +102,34 @@ public class LoggerFactoryExtensionsTests
.AddInMemoryCollection(initialData)
.Build();
var hostingEnvironment = Substitute.For<IWebHostEnvironment>();
var hostingEnvironment = Substitute.For<IHostEnvironment>();
hostingEnvironment
.EnvironmentName
.Returns(environment);
var context = new WebHostBuilderContext
var context = new HostBuilderContext(new Dictionary<object, object>())
{
HostingEnvironment = hostingEnvironment,
Configuration = config,
};
var services = new ServiceCollection();
services.AddLogging(builder =>
{
builder.AddSerilog(context);
});
var hostBuilder = Substitute.For<IHostBuilder>();
hostBuilder
.When(h => h.ConfigureServices(Arg.Any<Action<HostBuilderContext, IServiceCollection>>()))
.Do(call =>
{
var configureAction = call.Arg<Action<HostBuilderContext, IServiceCollection>>();
configureAction(context, services);
});
hostBuilder.AddSerilogFileLogging();
hostBuilder
.ConfigureServices(Arg.Any<Action<HostBuilderContext, IServiceCollection>>())
.Received(1);
return services.BuildServiceProvider();
}

View File

@@ -75,7 +75,7 @@ public class AccountsControllerTests : IDisposable
}
[Fact]
public async Task PostPrelogin_WhenUserExists_ShouldReturnUserKdfInfo()
public async Task PostPasswordPrelogin_WhenUserExists_ShouldReturnUserKdfInfo()
{
var userKdfInfo = new UserKdfInformation
{
@@ -84,30 +84,113 @@ public class AccountsControllerTests : IDisposable
};
_userRepository.GetKdfInformationByEmailAsync(Arg.Any<string>()).Returns(userKdfInfo);
var response = await _sut.PostPrelogin(new PreloginRequestModel { Email = "user@example.com" });
var response = await _sut.PostPasswordPrelogin(new PasswordPreloginRequestModel { Email = "user@example.com" });
Assert.Equal(userKdfInfo.Kdf, response.Kdf);
Assert.Equal(userKdfInfo.KdfIterations, response.KdfIterations);
}
[Fact]
public async Task PostPrelogin_WhenUserDoesNotExistAndNoDefaultKdfHmacKeySet_ShouldDefaultToPBKDF()
public async Task PostPrelogin_And_PostPasswordPrelogin_ShouldUseSamePreloginLogic()
{
// Arrange: No user exists and no default HMAC key to force default path
var email = "same-user@example.com";
SetDefaultKdfHmacKey(null);
_userRepository.GetKdfInformationByEmailAsync(Arg.Any<string>()).Returns(Task.FromResult<UserKdfInformation?>(null));
// Act
var legacyResponse = await _sut.PostPrelogin(new PasswordPreloginRequestModel { Email = email });
var newResponse = await _sut.PostPasswordPrelogin(new PasswordPreloginRequestModel { Email = email });
// Assert: Both endpoints yield identical results, implying shared logic path
Assert.Equal(legacyResponse.Kdf, newResponse.Kdf);
Assert.Equal(legacyResponse.KdfIterations, newResponse.KdfIterations);
Assert.Equal(legacyResponse.KdfMemory, newResponse.KdfMemory);
Assert.Equal(legacyResponse.KdfParallelism, newResponse.KdfParallelism);
Assert.Equal(legacyResponse.Salt, newResponse.Salt);
Assert.NotNull(legacyResponse.KdfSettings);
Assert.NotNull(newResponse.KdfSettings);
Assert.Equal(legacyResponse.KdfSettings!.KdfType, newResponse.KdfSettings!.KdfType);
Assert.Equal(legacyResponse.KdfSettings!.Iterations, newResponse.KdfSettings!.Iterations);
Assert.Equal(legacyResponse.KdfSettings!.Memory, newResponse.KdfSettings!.Memory);
Assert.Equal(legacyResponse.KdfSettings!.Parallelism, newResponse.KdfSettings!.Parallelism);
// Both methods should consult the repository once each with the same email
await _userRepository.Received(2).GetKdfInformationByEmailAsync(Arg.Is<string>(e => e == email));
}
[Fact]
public async Task PostPasswordPrelogin_WhenUserExists_ReturnsNewFieldsAlignedWithLegacy_Argon2()
{
var email = "user@example.com";
var userKdfInfo = new UserKdfInformation
{
Kdf = KdfType.Argon2id,
KdfIterations = AuthConstants.ARGON2_ITERATIONS.Default,
KdfMemory = AuthConstants.ARGON2_MEMORY.Default,
KdfParallelism = AuthConstants.ARGON2_PARALLELISM.Default
};
_userRepository.GetKdfInformationByEmailAsync(Arg.Any<string>()).Returns(userKdfInfo);
var response = await _sut.PostPasswordPrelogin(new PasswordPreloginRequestModel { Email = email });
// New fields exist and match repository values
Assert.NotNull(response.KdfSettings);
Assert.Equal(userKdfInfo.Kdf, response.KdfSettings!.KdfType);
Assert.Equal(userKdfInfo.KdfIterations, response.KdfSettings!.Iterations);
Assert.Equal(userKdfInfo.KdfMemory, response.KdfSettings!.Memory);
Assert.Equal(userKdfInfo.KdfParallelism, response.KdfSettings!.Parallelism);
// New and legacy fields are aligned during migration
Assert.Equal(response.Kdf, response.KdfSettings!.KdfType);
Assert.Equal(response.KdfIterations, response.KdfSettings!.Iterations);
Assert.Equal(response.KdfMemory, response.KdfSettings!.Memory);
Assert.Equal(response.KdfParallelism, response.KdfSettings!.Parallelism);
// Salt is set to the input email during migration
Assert.Equal(email, response.Salt);
}
[Fact]
public async Task PostPasswordPrelogin_WhenUserDoesNotExistAndNoDefaultKdfHmacKeySet_ShouldDefaultToPBKDF()
{
SetDefaultKdfHmacKey(null);
_userRepository.GetKdfInformationByEmailAsync(Arg.Any<string>()).Returns(Task.FromResult<UserKdfInformation?>(null));
var response = await _sut.PostPrelogin(new PreloginRequestModel { Email = "user@example.com" });
var response = await _sut.PostPasswordPrelogin(new PasswordPreloginRequestModel { Email = "user@example.com" });
Assert.Equal(KdfType.PBKDF2_SHA256, response.Kdf);
Assert.Equal(AuthConstants.PBKDF2_ITERATIONS.Default, response.KdfIterations);
}
[Fact]
public async Task PostPasswordPrelogin_NoUser_NoDefaultHmacKey_ReturnsAlignedNewFieldsAndSalt()
{
var email = "user@example.com";
SetDefaultKdfHmacKey(null);
_userRepository.GetKdfInformationByEmailAsync(Arg.Any<string>()).Returns(Task.FromResult<UserKdfInformation?>(null));
var response = await _sut.PostPasswordPrelogin(new PasswordPreloginRequestModel { Email = email });
// New fields exist
Assert.NotNull(response.KdfSettings);
// New and legacy fields are aligned during migration
Assert.Equal(response.Kdf, response.KdfSettings!.KdfType);
Assert.Equal(response.KdfIterations, response.KdfSettings!.Iterations);
Assert.Equal(response.KdfMemory, response.KdfSettings!.Memory);
Assert.Equal(response.KdfParallelism, response.KdfSettings!.Parallelism);
// Salt is set to the input email during migration
Assert.Equal(email, response.Salt);
}
[Theory]
[BitAutoData]
public async Task PostPrelogin_WhenUserDoesNotExistAndDefaultKdfHmacKeyIsSet_ShouldComputeHmacAndReturnExpectedKdf(string email)
public async Task PostPasswordPrelogin_WhenUserDoesNotExistAndDefaultKdfHmacKeyIsSet_ShouldComputeHmacAndReturnExpectedKdf(string email)
{
// Arrange:
var defaultKey = Encoding.UTF8.GetBytes("my-secret-key");
var defaultKey = "my-secret-key"u8.ToArray();
SetDefaultKdfHmacKey(defaultKey);
_userRepository.GetKdfInformationByEmailAsync(Arg.Any<string>()).Returns(Task.FromResult<UserKdfInformation?>(null));
@@ -122,7 +205,7 @@ public class AccountsControllerTests : IDisposable
var expectedKdf = defaultKdfResults[expectedIndex];
// Act
var response = await _sut.PostPrelogin(new PreloginRequestModel { Email = email });
var response = await _sut.PostPasswordPrelogin(new PasswordPreloginRequestModel { Email = email });
// Assert: Ensure the returned KDF matches the expected one from the computed hash
Assert.Equal(expectedKdf.Kdf, response.Kdf);
@@ -132,6 +215,16 @@ public class AccountsControllerTests : IDisposable
Assert.Equal(expectedKdf.KdfMemory, response.KdfMemory);
Assert.Equal(expectedKdf.KdfParallelism, response.KdfParallelism);
}
// New and legacy fields are aligned during migration
Assert.NotNull(response.KdfSettings);
Assert.Equal(response.Kdf, response.KdfSettings!.KdfType);
Assert.Equal(response.KdfIterations, response.KdfSettings!.Iterations);
Assert.Equal(response.KdfMemory, response.KdfSettings!.Memory);
Assert.Equal(response.KdfParallelism, response.KdfSettings!.Parallelism);
// Salt is set to the input email during migration
Assert.Equal(email, response.Salt);
}
[Theory]