mirror of
https://github.com/bitwarden/server.git
synced 2026-01-31 14:13:18 +08:00
Merge branch 'main' into update-server-program
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
|
||||
<Version>2025.12.2</Version>
|
||||
<Version>2026.1.0</Version>
|
||||
|
||||
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
@@ -30,4 +30,4 @@
|
||||
|
||||
<AutoFixtureAutoNSubstituteVersion>4.18.1</AutoFixtureAutoNSubstituteVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -0,0 +1,102 @@
|
||||
using Bit.Sso.Utilities;
|
||||
using Duende.IdentityServer.Models;
|
||||
using Duende.IdentityServer.Stores;
|
||||
using ZiggyCreatures.Caching.Fusion;
|
||||
|
||||
namespace Bit.Sso.IdentityServer;
|
||||
|
||||
/// <summary>
|
||||
/// Distributed cache-backed persisted grant store for short-lived grants.
|
||||
/// Uses IFusionCache (which wraps IDistributedCache) for horizontal scaling support,
|
||||
/// and fall back to in-memory caching if Redis is not configured.
|
||||
/// Designed for SSO authorization codes which are short-lived (5 minutes) and single-use.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is purposefully a different implementation from how Identity solves Persisted Grants.
|
||||
/// Because even flavored grant store, e.g., AuthorizationCodeGrantStore, can add intermediary
|
||||
/// logic to a grant's handling by type, the fact that they all wrap IdentityServer's IPersistedGrantStore
|
||||
/// leans on IdentityServer's opinion that all grants, regardless of type, go to the same persistence
|
||||
/// mechanism (cache, database).
|
||||
/// <seealso href="https://docs.duendesoftware.com/identityserver/reference/stores/persisted-grant-store/"/>
|
||||
/// </remarks>
|
||||
public class DistributedCachePersistedGrantStore : IPersistedGrantStore
|
||||
{
|
||||
private readonly IFusionCache _cache;
|
||||
|
||||
public DistributedCachePersistedGrantStore(
|
||||
[FromKeyedServices(PersistedGrantsDistributedCacheConstants.CacheKey)] IFusionCache cache)
|
||||
{
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
public async Task<PersistedGrant?> GetAsync(string key)
|
||||
{
|
||||
var result = await _cache.TryGetAsync<PersistedGrant>(key);
|
||||
|
||||
if (!result.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var grant = result.Value;
|
||||
|
||||
// Check if grant has expired - remove expired grants from cache
|
||||
if (grant.Expiration.HasValue && grant.Expiration.Value < DateTime.UtcNow)
|
||||
{
|
||||
await RemoveAsync(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return grant;
|
||||
}
|
||||
|
||||
public Task<IEnumerable<PersistedGrant>> GetAllAsync(PersistedGrantFilter filter)
|
||||
{
|
||||
// Cache stores are key-value based and don't support querying by filter criteria.
|
||||
// This method is typically used for cleanup operations on long-lived grants in databases.
|
||||
// For SSO's short-lived authorization codes, we rely on TTL expiration instead.
|
||||
|
||||
return Task.FromResult(Enumerable.Empty<PersistedGrant>());
|
||||
}
|
||||
|
||||
public Task RemoveAllAsync(PersistedGrantFilter filter)
|
||||
{
|
||||
// Revocation Strategy: SSO's logout flow (AccountController.LogoutAsync) only clears local
|
||||
// authentication cookies and performs federated logout with external IdPs. It does not invoke
|
||||
// Duende's EndSession or TokenRevocation endpoints. Authorization codes are single-use and expire
|
||||
// within 5 minutes, making explicit revocation unnecessary for SSO's security model.
|
||||
// https://docs.duendesoftware.com/identityserver/reference/stores/persisted-grant-store/
|
||||
|
||||
// Cache stores are key-value based and don't support bulk deletion by filter.
|
||||
// This method is typically used for cleanup operations on long-lived grants in databases.
|
||||
// For SSO's short-lived authorization codes, we rely on TTL expiration instead.
|
||||
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public async Task RemoveAsync(string key)
|
||||
{
|
||||
await _cache.RemoveAsync(key);
|
||||
}
|
||||
|
||||
public async Task StoreAsync(PersistedGrant grant)
|
||||
{
|
||||
// Calculate TTL based on grant expiration
|
||||
var duration = grant.Expiration.HasValue
|
||||
? grant.Expiration.Value - DateTime.UtcNow
|
||||
: TimeSpan.FromMinutes(5); // Default to 5 minutes if no expiration set
|
||||
|
||||
// Ensure positive duration
|
||||
if (duration <= TimeSpan.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache key "sso-grants:" is configured by service registration. Going through the consumed KeyedService will
|
||||
// give us a consistent cache key prefix for these grants.
|
||||
await _cache.SetAsync(
|
||||
grant.Key,
|
||||
grant,
|
||||
new FusionCacheEntryOptions { Duration = duration });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Bit.Sso.Utilities;
|
||||
|
||||
public static class PersistedGrantsDistributedCacheConstants
|
||||
{
|
||||
/// <summary>
|
||||
/// The SSO Persisted Grant cache key. Identifies the keyed service consumed by the SSO Persisted Grant Store as
|
||||
/// well as the cache key/namespace for grant storage.
|
||||
/// </summary>
|
||||
public const string CacheKey = "sso-grants";
|
||||
}
|
||||
@@ -9,6 +9,7 @@ using Bit.Sso.IdentityServer;
|
||||
using Bit.Sso.Models;
|
||||
using Duende.IdentityServer.Models;
|
||||
using Duende.IdentityServer.ResponseHandling;
|
||||
using Duende.IdentityServer.Stores;
|
||||
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
||||
using Sustainsys.Saml2.AspNetCore2;
|
||||
|
||||
@@ -77,6 +78,17 @@ public static class ServiceCollectionExtensions
|
||||
})
|
||||
.AddIdentityServerCertificate(env, globalSettings);
|
||||
|
||||
// PM-23572
|
||||
// Register named FusionCache for SSO authorization code grants.
|
||||
// Provides separation of concerns and automatic Redis/in-memory negotiation
|
||||
// .AddInMemoryCaching should still persist above; this handles configuration caching, etc.,
|
||||
// and is separate from this keyed service, which only serves grant negotiation.
|
||||
services.AddExtendedCache(PersistedGrantsDistributedCacheConstants.CacheKey, globalSettings);
|
||||
|
||||
// Store authorization codes in distributed cache for horizontal scaling
|
||||
// Uses named FusionCache which gracefully degrades to in-memory when Redis isn't configured
|
||||
services.AddSingleton<IPersistedGrantStore, DistributedCachePersistedGrantStore>();
|
||||
|
||||
return identityServerBuilder;
|
||||
}
|
||||
}
|
||||
|
||||
94
bitwarden_license/src/Sso/package-lock.json
generated
94
bitwarden_license/src/Sso/package-lock.json
generated
@@ -17,9 +17,9 @@
|
||||
"css-loader": "7.1.2",
|
||||
"expose-loader": "5.0.1",
|
||||
"mini-css-extract-plugin": "2.9.2",
|
||||
"sass": "1.93.2",
|
||||
"sass": "1.97.2",
|
||||
"sass-loader": "16.0.5",
|
||||
"webpack": "5.102.1",
|
||||
"webpack": "5.104.1",
|
||||
"webpack-cli": "5.1.4"
|
||||
}
|
||||
},
|
||||
@@ -749,9 +749,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.8.18",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.18.tgz",
|
||||
"integrity": "sha512-UYmTpOBwgPScZpS4A+YbapwWuBwasxvO/2IOHArSsAhL/+ZdmATBXTex3t+l2hXwLVYK382ibr/nKoY9GKe86w==",
|
||||
"version": "2.9.13",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.13.tgz",
|
||||
"integrity": "sha512-WhtvB2NG2wjr04+h77sg3klAIwrgOqnjS49GGudnUPGFFgg7G17y7Qecqp+2Dr5kUDxNRBca0SK7cG8JwzkWDQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -792,9 +792,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.26.3",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz",
|
||||
"integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==",
|
||||
"version": "4.28.1",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
||||
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -813,11 +813,11 @@
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.9",
|
||||
"caniuse-lite": "^1.0.30001746",
|
||||
"electron-to-chromium": "^1.5.227",
|
||||
"node-releases": "^2.0.21",
|
||||
"update-browserslist-db": "^1.1.3"
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
"electron-to-chromium": "^1.5.263",
|
||||
"node-releases": "^2.0.27",
|
||||
"update-browserslist-db": "^1.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"browserslist": "cli.js"
|
||||
@@ -834,9 +834,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001751",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz",
|
||||
"integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==",
|
||||
"version": "1.0.30001763",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001763.tgz",
|
||||
"integrity": "sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -988,9 +988,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.237",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz",
|
||||
"integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==",
|
||||
"version": "1.5.267",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
|
||||
"integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@@ -1022,9 +1022,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
||||
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
|
||||
"integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -1418,13 +1418,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/loader-runner": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz",
|
||||
"integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==",
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz",
|
||||
"integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.11.5"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
@@ -1541,9 +1545,9 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.26",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz",
|
||||
"integrity": "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==",
|
||||
"version": "2.0.27",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -1874,9 +1878,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sass": {
|
||||
"version": "1.93.2",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz",
|
||||
"integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==",
|
||||
"version": "1.97.2",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.97.2.tgz",
|
||||
"integrity": "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
@@ -2109,9 +2113,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/terser-webpack-plugin": {
|
||||
"version": "5.3.14",
|
||||
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz",
|
||||
"integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==",
|
||||
"version": "5.3.16",
|
||||
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz",
|
||||
"integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2165,9 +2169,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
|
||||
"integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2217,9 +2221,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/webpack": {
|
||||
"version": "5.102.1",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz",
|
||||
"integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==",
|
||||
"version": "5.104.1",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz",
|
||||
"integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
@@ -2232,21 +2236,21 @@
|
||||
"@webassemblyjs/wasm-parser": "^1.14.1",
|
||||
"acorn": "^8.15.0",
|
||||
"acorn-import-phases": "^1.0.3",
|
||||
"browserslist": "^4.26.3",
|
||||
"browserslist": "^4.28.1",
|
||||
"chrome-trace-event": "^1.0.2",
|
||||
"enhanced-resolve": "^5.17.3",
|
||||
"es-module-lexer": "^1.2.1",
|
||||
"enhanced-resolve": "^5.17.4",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"eslint-scope": "5.1.1",
|
||||
"events": "^3.2.0",
|
||||
"glob-to-regexp": "^0.4.1",
|
||||
"graceful-fs": "^4.2.11",
|
||||
"json-parse-even-better-errors": "^2.3.1",
|
||||
"loader-runner": "^4.2.0",
|
||||
"loader-runner": "^4.3.1",
|
||||
"mime-types": "^2.1.27",
|
||||
"neo-async": "^2.6.2",
|
||||
"schema-utils": "^4.3.3",
|
||||
"tapable": "^2.3.0",
|
||||
"terser-webpack-plugin": "^5.3.11",
|
||||
"terser-webpack-plugin": "^5.3.16",
|
||||
"watchpack": "^2.4.4",
|
||||
"webpack-sources": "^3.3.3"
|
||||
},
|
||||
|
||||
@@ -16,9 +16,9 @@
|
||||
"css-loader": "7.1.2",
|
||||
"expose-loader": "5.0.1",
|
||||
"mini-css-extract-plugin": "2.9.2",
|
||||
"sass": "1.93.2",
|
||||
"sass": "1.97.2",
|
||||
"sass-loader": "16.0.5",
|
||||
"webpack": "5.102.1",
|
||||
"webpack": "5.104.1",
|
||||
"webpack-cli": "5.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
using Bit.Sso.IdentityServer;
|
||||
using Duende.IdentityServer.Models;
|
||||
using Duende.IdentityServer.Stores;
|
||||
using NSubstitute;
|
||||
using ZiggyCreatures.Caching.Fusion;
|
||||
|
||||
namespace Bit.SSO.Test.IdentityServer;
|
||||
|
||||
public class DistributedCachePersistedGrantStoreTests
|
||||
{
|
||||
private readonly IFusionCache _cache;
|
||||
private readonly DistributedCachePersistedGrantStore _sut;
|
||||
|
||||
public DistributedCachePersistedGrantStoreTests()
|
||||
{
|
||||
_cache = Substitute.For<IFusionCache>();
|
||||
_sut = new DistributedCachePersistedGrantStore(_cache);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_StoresGrantWithCalculatedTTL()
|
||||
{
|
||||
// Arrange
|
||||
var grant = CreateTestGrant("test-key", expiration: DateTime.UtcNow.AddMinutes(5));
|
||||
|
||||
// Act
|
||||
await _sut.StoreAsync(grant);
|
||||
|
||||
// Assert
|
||||
await _cache.Received(1).SetAsync(
|
||||
"test-key",
|
||||
grant,
|
||||
Arg.Is<FusionCacheEntryOptions>(opts =>
|
||||
opts.Duration >= TimeSpan.FromMinutes(4.9) &&
|
||||
opts.Duration <= TimeSpan.FromMinutes(5)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_WithNoExpiration_UsesDefaultFiveMinuteTTL()
|
||||
{
|
||||
// Arrange
|
||||
var grant = CreateTestGrant("no-expiry-key", expiration: null);
|
||||
|
||||
// Act
|
||||
await _sut.StoreAsync(grant);
|
||||
|
||||
// Assert
|
||||
await _cache.Received(1).SetAsync(
|
||||
"no-expiry-key",
|
||||
grant,
|
||||
Arg.Is<FusionCacheEntryOptions>(opts => opts.Duration == TimeSpan.FromMinutes(5)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_WithAlreadyExpiredGrant_DoesNotStore()
|
||||
{
|
||||
// Arrange
|
||||
var expiredGrant = CreateTestGrant("expired-key", expiration: DateTime.UtcNow.AddMinutes(-1));
|
||||
|
||||
// Act
|
||||
await _sut.StoreAsync(expiredGrant);
|
||||
|
||||
// Assert
|
||||
await _cache.DidNotReceive().SetAsync(
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<PersistedGrant>(),
|
||||
Arg.Any<FusionCacheEntryOptions?>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_EnablesDistributedCache()
|
||||
{
|
||||
// Arrange
|
||||
var grant = CreateTestGrant("distributed-key", expiration: DateTime.UtcNow.AddMinutes(5));
|
||||
|
||||
// Act
|
||||
await _sut.StoreAsync(grant);
|
||||
|
||||
// Assert
|
||||
await _cache.Received(1).SetAsync(
|
||||
"distributed-key",
|
||||
grant,
|
||||
Arg.Is<FusionCacheEntryOptions>(opts =>
|
||||
opts.SkipDistributedCache == false &&
|
||||
opts.SkipDistributedCacheReadWhenStale == false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_WithValidGrant_ReturnsGrant()
|
||||
{
|
||||
// Arrange
|
||||
var grant = CreateTestGrant("valid-key", expiration: DateTime.UtcNow.AddMinutes(5));
|
||||
_cache.TryGetAsync<PersistedGrant>("valid-key")
|
||||
.Returns(MaybeValue<PersistedGrant>.FromValue(grant));
|
||||
|
||||
// Act
|
||||
var result = await _sut.GetAsync("valid-key");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("valid-key", result.Key);
|
||||
Assert.Equal("authorization_code", result.Type);
|
||||
Assert.Equal("test-subject", result.SubjectId);
|
||||
await _cache.DidNotReceive().RemoveAsync(Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_WithNonExistentKey_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
_cache.TryGetAsync<PersistedGrant>("nonexistent-key")
|
||||
.Returns(MaybeValue<PersistedGrant>.None);
|
||||
|
||||
// Act
|
||||
var result = await _sut.GetAsync("nonexistent-key");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
await _cache.DidNotReceive().RemoveAsync(Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_WithExpiredGrant_RemovesAndReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var expiredGrant = CreateTestGrant("expired-key", expiration: DateTime.UtcNow.AddMinutes(-1));
|
||||
_cache.TryGetAsync<PersistedGrant>("expired-key")
|
||||
.Returns(MaybeValue<PersistedGrant>.FromValue(expiredGrant));
|
||||
|
||||
// Act
|
||||
var result = await _sut.GetAsync("expired-key");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
await _cache.Received(1).RemoveAsync("expired-key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_WithNoExpiration_ReturnsGrant()
|
||||
{
|
||||
// Arrange
|
||||
var grant = CreateTestGrant("no-expiry-key", expiration: null);
|
||||
_cache.TryGetAsync<PersistedGrant>("no-expiry-key")
|
||||
.Returns(MaybeValue<PersistedGrant>.FromValue(grant));
|
||||
|
||||
// Act
|
||||
var result = await _sut.GetAsync("no-expiry-key");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("no-expiry-key", result.Key);
|
||||
Assert.Null(result.Expiration);
|
||||
await _cache.DidNotReceive().RemoveAsync(Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveAsync_RemovesGrantFromCache()
|
||||
{
|
||||
// Act
|
||||
await _sut.RemoveAsync("remove-key");
|
||||
|
||||
// Assert
|
||||
await _cache.Received(1).RemoveAsync("remove-key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllAsync_ReturnsEmptyCollection()
|
||||
{
|
||||
// Arrange
|
||||
var filter = new PersistedGrantFilter
|
||||
{
|
||||
SubjectId = "test-subject",
|
||||
SessionId = "test-session",
|
||||
ClientId = "test-client",
|
||||
Type = "authorization_code"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.GetAllAsync(filter);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveAllAsync_CompletesWithoutError()
|
||||
{
|
||||
// Arrange
|
||||
var filter = new PersistedGrantFilter
|
||||
{
|
||||
SubjectId = "test-subject",
|
||||
ClientId = "test-client"
|
||||
};
|
||||
|
||||
// Act & Assert - should not throw
|
||||
await _sut.RemoveAllAsync(filter);
|
||||
|
||||
// Verify no cache operations were performed
|
||||
await _cache.DidNotReceive().RemoveAsync(Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreAsync_PreservesAllGrantProperties()
|
||||
{
|
||||
// Arrange
|
||||
var grant = new PersistedGrant
|
||||
{
|
||||
Key = "full-grant-key",
|
||||
Type = "authorization_code",
|
||||
SubjectId = "user-123",
|
||||
SessionId = "session-456",
|
||||
ClientId = "client-789",
|
||||
Description = "Test grant",
|
||||
CreationTime = DateTime.UtcNow.AddMinutes(-1),
|
||||
Expiration = DateTime.UtcNow.AddMinutes(5),
|
||||
ConsumedTime = null,
|
||||
Data = "{\"test\":\"data\"}"
|
||||
};
|
||||
|
||||
PersistedGrant? capturedGrant = null;
|
||||
await _cache.SetAsync(
|
||||
Arg.Any<string>(),
|
||||
Arg.Do<PersistedGrant>(g => capturedGrant = g),
|
||||
Arg.Any<FusionCacheEntryOptions?>());
|
||||
|
||||
// Act
|
||||
await _sut.StoreAsync(grant);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedGrant);
|
||||
Assert.Equal(grant.Key, capturedGrant.Key);
|
||||
Assert.Equal(grant.Type, capturedGrant.Type);
|
||||
Assert.Equal(grant.SubjectId, capturedGrant.SubjectId);
|
||||
Assert.Equal(grant.SessionId, capturedGrant.SessionId);
|
||||
Assert.Equal(grant.ClientId, capturedGrant.ClientId);
|
||||
Assert.Equal(grant.Description, capturedGrant.Description);
|
||||
Assert.Equal(grant.CreationTime, capturedGrant.CreationTime);
|
||||
Assert.Equal(grant.Expiration, capturedGrant.Expiration);
|
||||
Assert.Equal(grant.ConsumedTime, capturedGrant.ConsumedTime);
|
||||
Assert.Equal(grant.Data, capturedGrant.Data);
|
||||
}
|
||||
|
||||
private static PersistedGrant CreateTestGrant(string key, DateTime? expiration)
|
||||
{
|
||||
return new PersistedGrant
|
||||
{
|
||||
Key = key,
|
||||
Type = "authorization_code",
|
||||
SubjectId = "test-subject",
|
||||
ClientId = "test-client",
|
||||
CreationTime = DateTime.UtcNow,
|
||||
Expiration = expiration,
|
||||
Data = "{\"test\":\"data\"}"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using System.Data.Common;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Admin.HostedServices;
|
||||
|
||||
@@ -30,7 +30,7 @@ public class DatabaseMigrationHostedService : IHostedService, IDisposable
|
||||
// TODO: Maybe flip a flag somewhere to indicate migration is complete??
|
||||
break;
|
||||
}
|
||||
catch (SqlException e)
|
||||
catch (DbException e)
|
||||
{
|
||||
if (i >= maxMigrationAttempts)
|
||||
{
|
||||
@@ -40,7 +40,7 @@ public class DatabaseMigrationHostedService : IHostedService, IDisposable
|
||||
else
|
||||
{
|
||||
_logger.LogError(e,
|
||||
"Database unavailable for migration. Trying again (attempt #{0})...", i + 1);
|
||||
"Database unavailable for migration. Trying again (attempt #{AttemptNumber})...", i + 1);
|
||||
await Task.Delay(20000, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
94
src/Admin/package-lock.json
generated
94
src/Admin/package-lock.json
generated
@@ -18,9 +18,9 @@
|
||||
"css-loader": "7.1.2",
|
||||
"expose-loader": "5.0.1",
|
||||
"mini-css-extract-plugin": "2.9.2",
|
||||
"sass": "1.93.2",
|
||||
"sass": "1.97.2",
|
||||
"sass-loader": "16.0.5",
|
||||
"webpack": "5.102.1",
|
||||
"webpack": "5.104.1",
|
||||
"webpack-cli": "5.1.4"
|
||||
}
|
||||
},
|
||||
@@ -750,9 +750,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.8.18",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.18.tgz",
|
||||
"integrity": "sha512-UYmTpOBwgPScZpS4A+YbapwWuBwasxvO/2IOHArSsAhL/+ZdmATBXTex3t+l2hXwLVYK382ibr/nKoY9GKe86w==",
|
||||
"version": "2.9.13",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.13.tgz",
|
||||
"integrity": "sha512-WhtvB2NG2wjr04+h77sg3klAIwrgOqnjS49GGudnUPGFFgg7G17y7Qecqp+2Dr5kUDxNRBca0SK7cG8JwzkWDQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -793,9 +793,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.26.3",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz",
|
||||
"integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==",
|
||||
"version": "4.28.1",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
||||
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -814,11 +814,11 @@
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.9",
|
||||
"caniuse-lite": "^1.0.30001746",
|
||||
"electron-to-chromium": "^1.5.227",
|
||||
"node-releases": "^2.0.21",
|
||||
"update-browserslist-db": "^1.1.3"
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
"electron-to-chromium": "^1.5.263",
|
||||
"node-releases": "^2.0.27",
|
||||
"update-browserslist-db": "^1.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"browserslist": "cli.js"
|
||||
@@ -835,9 +835,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001751",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz",
|
||||
"integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==",
|
||||
"version": "1.0.30001763",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001763.tgz",
|
||||
"integrity": "sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -989,9 +989,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.237",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz",
|
||||
"integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==",
|
||||
"version": "1.5.267",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
|
||||
"integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@@ -1023,9 +1023,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
||||
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
|
||||
"integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -1419,13 +1419,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/loader-runner": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz",
|
||||
"integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==",
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz",
|
||||
"integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.11.5"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
@@ -1542,9 +1546,9 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.26",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz",
|
||||
"integrity": "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==",
|
||||
"version": "2.0.27",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -1875,9 +1879,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sass": {
|
||||
"version": "1.93.2",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz",
|
||||
"integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==",
|
||||
"version": "1.97.2",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.97.2.tgz",
|
||||
"integrity": "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
@@ -2110,9 +2114,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/terser-webpack-plugin": {
|
||||
"version": "5.3.14",
|
||||
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz",
|
||||
"integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==",
|
||||
"version": "5.3.16",
|
||||
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz",
|
||||
"integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2174,9 +2178,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
|
||||
"integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2226,9 +2230,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/webpack": {
|
||||
"version": "5.102.1",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz",
|
||||
"integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==",
|
||||
"version": "5.104.1",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz",
|
||||
"integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
@@ -2241,21 +2245,21 @@
|
||||
"@webassemblyjs/wasm-parser": "^1.14.1",
|
||||
"acorn": "^8.15.0",
|
||||
"acorn-import-phases": "^1.0.3",
|
||||
"browserslist": "^4.26.3",
|
||||
"browserslist": "^4.28.1",
|
||||
"chrome-trace-event": "^1.0.2",
|
||||
"enhanced-resolve": "^5.17.3",
|
||||
"es-module-lexer": "^1.2.1",
|
||||
"enhanced-resolve": "^5.17.4",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"eslint-scope": "5.1.1",
|
||||
"events": "^3.2.0",
|
||||
"glob-to-regexp": "^0.4.1",
|
||||
"graceful-fs": "^4.2.11",
|
||||
"json-parse-even-better-errors": "^2.3.1",
|
||||
"loader-runner": "^4.2.0",
|
||||
"loader-runner": "^4.3.1",
|
||||
"mime-types": "^2.1.27",
|
||||
"neo-async": "^2.6.2",
|
||||
"schema-utils": "^4.3.3",
|
||||
"tapable": "^2.3.0",
|
||||
"terser-webpack-plugin": "^5.3.11",
|
||||
"terser-webpack-plugin": "^5.3.16",
|
||||
"watchpack": "^2.4.4",
|
||||
"webpack-sources": "^3.3.3"
|
||||
},
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
"css-loader": "7.1.2",
|
||||
"expose-loader": "5.0.1",
|
||||
"mini-css-extract-plugin": "2.9.2",
|
||||
"sass": "1.93.2",
|
||||
"sass": "1.97.2",
|
||||
"sass-loader": "16.0.5",
|
||||
"webpack": "5.102.1",
|
||||
"webpack": "5.104.1",
|
||||
"webpack-cli": "5.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ using Bit.Api.Tools.Models.Request;
|
||||
using Bit.Api.Tools.Models.Response;
|
||||
using Bit.Api.Utilities;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Auth.Identity;
|
||||
using Bit.Core.Auth.UserFeatures.SendAccess;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Platform.Push;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Tools.Enums;
|
||||
using Bit.Core.Tools.Models.Data;
|
||||
using Bit.Core.Tools.Repositories;
|
||||
@@ -22,7 +24,6 @@ using Microsoft.AspNetCore.Mvc;
|
||||
namespace Bit.Api.Tools.Controllers;
|
||||
|
||||
[Route("sends")]
|
||||
[Authorize("Application")]
|
||||
public class SendsController : Controller
|
||||
{
|
||||
private readonly ISendRepository _sendRepository;
|
||||
@@ -31,11 +32,10 @@ public class SendsController : Controller
|
||||
private readonly ISendFileStorageService _sendFileStorageService;
|
||||
private readonly IAnonymousSendCommand _anonymousSendCommand;
|
||||
private readonly INonAnonymousSendCommand _nonAnonymousSendCommand;
|
||||
|
||||
private readonly ISendOwnerQuery _sendOwnerQuery;
|
||||
|
||||
private readonly ILogger<SendsController> _logger;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IPushNotificationService _pushNotificationService;
|
||||
|
||||
public SendsController(
|
||||
ISendRepository sendRepository,
|
||||
@@ -46,7 +46,8 @@ public class SendsController : Controller
|
||||
ISendOwnerQuery sendOwnerQuery,
|
||||
ISendFileStorageService sendFileStorageService,
|
||||
ILogger<SendsController> logger,
|
||||
GlobalSettings globalSettings)
|
||||
IFeatureService featureService,
|
||||
IPushNotificationService pushNotificationService)
|
||||
{
|
||||
_sendRepository = sendRepository;
|
||||
_userService = userService;
|
||||
@@ -56,10 +57,12 @@ public class SendsController : Controller
|
||||
_sendOwnerQuery = sendOwnerQuery;
|
||||
_sendFileStorageService = sendFileStorageService;
|
||||
_logger = logger;
|
||||
_globalSettings = globalSettings;
|
||||
_featureService = featureService;
|
||||
_pushNotificationService = pushNotificationService;
|
||||
}
|
||||
|
||||
#region Anonymous endpoints
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("access/{id}")]
|
||||
public async Task<IActionResult> Access(string id, [FromBody] SendAccessRequestModel model)
|
||||
@@ -73,21 +76,32 @@ public class SendsController : Controller
|
||||
|
||||
var guid = new Guid(CoreHelpers.Base64UrlDecode(id));
|
||||
var send = await _sendRepository.GetByIdAsync(guid);
|
||||
|
||||
if (send == null)
|
||||
{
|
||||
throw new BadRequestException("Could not locate send");
|
||||
}
|
||||
|
||||
/* This guard can be removed once feature flag is retired*/
|
||||
var sendEmailOtpEnabled = _featureService.IsEnabled(FeatureFlagKeys.SendEmailOTP);
|
||||
if (sendEmailOtpEnabled && send.AuthType == AuthType.Email && send.Emails is not null)
|
||||
{
|
||||
return new UnauthorizedResult();
|
||||
}
|
||||
|
||||
var sendAuthResult =
|
||||
await _sendAuthorizationService.AccessAsync(send, model.Password);
|
||||
if (sendAuthResult.Equals(SendAccessResult.PasswordRequired))
|
||||
{
|
||||
return new UnauthorizedResult();
|
||||
}
|
||||
|
||||
if (sendAuthResult.Equals(SendAccessResult.PasswordInvalid))
|
||||
{
|
||||
await Task.Delay(2000);
|
||||
throw new BadRequestException("Invalid password.");
|
||||
}
|
||||
|
||||
if (sendAuthResult.Equals(SendAccessResult.Denied))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
@@ -99,6 +113,7 @@ public class SendsController : Controller
|
||||
var creator = await _userService.GetUserByIdAsync(send.UserId.Value);
|
||||
sendResponse.CreatorIdentifier = creator.Email;
|
||||
}
|
||||
|
||||
return new ObjectResult(sendResponse);
|
||||
}
|
||||
|
||||
@@ -122,6 +137,13 @@ public class SendsController : Controller
|
||||
throw new BadRequestException("Could not locate send");
|
||||
}
|
||||
|
||||
/* This guard can be removed once feature flag is retired*/
|
||||
var sendEmailOtpEnabled = _featureService.IsEnabled(FeatureFlagKeys.SendEmailOTP);
|
||||
if (sendEmailOtpEnabled && send.AuthType == AuthType.Email && send.Emails is not null)
|
||||
{
|
||||
return new UnauthorizedResult();
|
||||
}
|
||||
|
||||
var (url, result) = await _anonymousSendCommand.GetSendFileDownloadUrlAsync(send, fileId,
|
||||
model.Password);
|
||||
|
||||
@@ -129,21 +151,19 @@ public class SendsController : Controller
|
||||
{
|
||||
return new UnauthorizedResult();
|
||||
}
|
||||
|
||||
if (result.Equals(SendAccessResult.PasswordInvalid))
|
||||
{
|
||||
await Task.Delay(2000);
|
||||
throw new BadRequestException("Invalid password.");
|
||||
}
|
||||
|
||||
if (result.Equals(SendAccessResult.Denied))
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
return new ObjectResult(new SendFileDownloadDataResponseModel()
|
||||
{
|
||||
Id = fileId,
|
||||
Url = url,
|
||||
});
|
||||
return new ObjectResult(new SendFileDownloadDataResponseModel() { Id = fileId, Url = url, });
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
@@ -157,7 +177,8 @@ public class SendsController : Controller
|
||||
{
|
||||
try
|
||||
{
|
||||
var blobName = eventGridEvent.Subject.Split($"{AzureSendFileStorageService.FilesContainerName}/blobs/")[1];
|
||||
var blobName =
|
||||
eventGridEvent.Subject.Split($"{AzureSendFileStorageService.FilesContainerName}/blobs/")[1];
|
||||
var sendId = AzureSendFileStorageService.SendIdFromBlobName(blobName);
|
||||
var send = await _sendRepository.GetByIdAsync(new Guid(sendId));
|
||||
if (send == null)
|
||||
@@ -166,6 +187,7 @@ public class SendsController : Controller
|
||||
{
|
||||
await azureSendFileStorageService.DeleteBlobAsync(blobName);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -173,7 +195,8 @@ public class SendsController : Controller
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "Uncaught exception occurred while handling event grid event: {Event}", JsonSerializer.Serialize(eventGridEvent));
|
||||
_logger.LogError(e, "Uncaught exception occurred while handling event grid event: {Event}",
|
||||
JsonSerializer.Serialize(eventGridEvent));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -185,6 +208,7 @@ public class SendsController : Controller
|
||||
|
||||
#region Non-anonymous endpoints
|
||||
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpGet("{id}")]
|
||||
public async Task<SendResponseModel> Get(string id)
|
||||
{
|
||||
@@ -193,6 +217,7 @@ public class SendsController : Controller
|
||||
return new SendResponseModel(send);
|
||||
}
|
||||
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpGet("")]
|
||||
public async Task<ListResponseModel<SendResponseModel>> GetAll()
|
||||
{
|
||||
@@ -203,6 +228,67 @@ public class SendsController : Controller
|
||||
return result;
|
||||
}
|
||||
|
||||
[Authorize(Policy = Policies.Send)]
|
||||
// [RequireFeature(FeatureFlagKeys.SendEmailOTP)] /* Uncomment once client fallback re-try logic is added */
|
||||
[HttpPost("access/")]
|
||||
public async Task<IActionResult> AccessUsingAuth()
|
||||
{
|
||||
var guid = User.GetSendId();
|
||||
var send = await _sendRepository.GetByIdAsync(guid);
|
||||
if (send == null)
|
||||
{
|
||||
throw new BadRequestException("Could not locate send");
|
||||
}
|
||||
if (send.MaxAccessCount.GetValueOrDefault(int.MaxValue) <= send.AccessCount ||
|
||||
send.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < DateTime.UtcNow || send.Disabled ||
|
||||
send.DeletionDate < DateTime.UtcNow)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var sendResponse = new SendAccessResponseModel(send);
|
||||
if (send.UserId.HasValue && !send.HideEmail.GetValueOrDefault())
|
||||
{
|
||||
var creator = await _userService.GetUserByIdAsync(send.UserId.Value);
|
||||
sendResponse.CreatorIdentifier = creator.Email;
|
||||
}
|
||||
|
||||
send.AccessCount++;
|
||||
await _sendRepository.ReplaceAsync(send);
|
||||
await _pushNotificationService.PushSyncSendUpdateAsync(send);
|
||||
|
||||
return new ObjectResult(sendResponse);
|
||||
}
|
||||
|
||||
[Authorize(Policy = Policies.Send)]
|
||||
// [RequireFeature(FeatureFlagKeys.SendEmailOTP)] /* Uncomment once client fallback re-try logic is added */
|
||||
[HttpPost("access/file/{fileId}")]
|
||||
public async Task<IActionResult> GetSendFileDownloadDataUsingAuth(string fileId)
|
||||
{
|
||||
var sendId = User.GetSendId();
|
||||
var send = await _sendRepository.GetByIdAsync(sendId);
|
||||
|
||||
if (send == null)
|
||||
{
|
||||
throw new BadRequestException("Could not locate send");
|
||||
}
|
||||
if (send.MaxAccessCount.GetValueOrDefault(int.MaxValue) <= send.AccessCount ||
|
||||
send.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < DateTime.UtcNow || send.Disabled ||
|
||||
send.DeletionDate < DateTime.UtcNow)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var url = await _sendFileStorageService.GetSendFileDownloadUrlAsync(send, fileId);
|
||||
|
||||
send.AccessCount++;
|
||||
await _sendRepository.ReplaceAsync(send);
|
||||
await _pushNotificationService.PushSyncSendUpdateAsync(send);
|
||||
|
||||
return new ObjectResult(new SendFileDownloadDataResponseModel() { Id = fileId, Url = url });
|
||||
}
|
||||
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpPost("")]
|
||||
public async Task<SendResponseModel> Post([FromBody] SendRequestModel model)
|
||||
{
|
||||
@@ -213,6 +299,7 @@ public class SendsController : Controller
|
||||
return new SendResponseModel(send);
|
||||
}
|
||||
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpPost("file/v2")]
|
||||
public async Task<SendFileUploadDataResponseModel> PostFile([FromBody] SendRequestModel model)
|
||||
{
|
||||
@@ -243,6 +330,7 @@ public class SendsController : Controller
|
||||
};
|
||||
}
|
||||
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpGet("{id}/file/{fileId}")]
|
||||
public async Task<SendFileUploadDataResponseModel> RenewFileUpload(string id, string fileId)
|
||||
{
|
||||
@@ -267,6 +355,7 @@ public class SendsController : Controller
|
||||
};
|
||||
}
|
||||
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpPost("{id}/file/{fileId}")]
|
||||
[SelfHosted(SelfHostedOnly = true)]
|
||||
[RequestSizeLimit(Constants.FileSize501mb)]
|
||||
@@ -283,12 +372,14 @@ public class SendsController : Controller
|
||||
{
|
||||
throw new BadRequestException("Could not locate send");
|
||||
}
|
||||
|
||||
await Request.GetFileAsync(async (stream) =>
|
||||
{
|
||||
await _nonAnonymousSendCommand.UploadFileToExistingSendAsync(stream, send);
|
||||
});
|
||||
}
|
||||
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpPut("{id}")]
|
||||
public async Task<SendResponseModel> Put(string id, [FromBody] SendRequestModel model)
|
||||
{
|
||||
@@ -304,6 +395,7 @@ public class SendsController : Controller
|
||||
return new SendResponseModel(send);
|
||||
}
|
||||
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpPut("{id}/remove-password")]
|
||||
public async Task<SendResponseModel> PutRemovePassword(string id)
|
||||
{
|
||||
@@ -322,6 +414,28 @@ public class SendsController : Controller
|
||||
return new SendResponseModel(send);
|
||||
}
|
||||
|
||||
// Removes ALL authentication (email or password) if any is present
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpPut("{id}/remove-auth")]
|
||||
public async Task<SendResponseModel> PutRemoveAuth(string id)
|
||||
{
|
||||
var userId = _userService.GetProperUserId(User) ?? throw new InvalidOperationException("User ID not found");
|
||||
var send = await _sendRepository.GetByIdAsync(new Guid(id));
|
||||
if (send == null || send.UserId != userId)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
// This endpoint exists because PUT preserves existing Password/Emails when not provided.
|
||||
// This allows clients to update other fields without re-submitting sensitive auth data.
|
||||
send.Password = null;
|
||||
send.Emails = null;
|
||||
send.AuthType = AuthType.None;
|
||||
await _nonAnonymousSendCommand.SaveSendAsync(send);
|
||||
return new SendResponseModel(send);
|
||||
}
|
||||
|
||||
[Authorize(Policies.Application)]
|
||||
[HttpDelete("{id}")]
|
||||
public async Task Delete(string id)
|
||||
{
|
||||
|
||||
@@ -74,8 +74,12 @@ public class AutomaticUserConfirmationPolicyEventHandler(
|
||||
private async Task<string> ValidateUserComplianceWithSingleOrgAsync(Guid organizationId,
|
||||
ICollection<OrganizationUserUserDetails> organizationUsers)
|
||||
{
|
||||
var hasNonCompliantUser = (await organizationUserRepository.GetManyByManyUsersAsync(
|
||||
organizationUsers.Select(ou => ou.UserId!.Value)))
|
||||
var userIds = organizationUsers.Where(
|
||||
u => u.UserId is not null &&
|
||||
u.Status != OrganizationUserStatusType.Invited)
|
||||
.Select(u => u.UserId!.Value);
|
||||
|
||||
var hasNonCompliantUser = (await organizationUserRepository.GetManyByManyUsersAsync(userIds))
|
||||
.Any(uo => uo.OrganizationId != organizationId
|
||||
&& uo.Status != OrganizationUserStatusType.Invited);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Extensions;
|
||||
@@ -51,7 +52,7 @@ public static class InvoiceExtensions
|
||||
if (string.IsNullOrEmpty(priceInfo) && line.Quantity > 0)
|
||||
{
|
||||
var pricePerItem = (line.Amount / 100m) / line.Quantity;
|
||||
priceInfo = $"(at ${pricePerItem:F2} / month)";
|
||||
priceInfo = string.Format(CultureInfo.InvariantCulture, "(at ${0:F2} / month)", pricePerItem);
|
||||
}
|
||||
|
||||
var taxDescription = $"{line.Quantity} × Tax {priceInfo}";
|
||||
@@ -70,7 +71,7 @@ public static class InvoiceExtensions
|
||||
if (tax > 0)
|
||||
{
|
||||
var taxAmount = tax / 100m;
|
||||
items.Add($"1 × Tax (at ${taxAmount:F2} / month)");
|
||||
items.Add(string.Format(CultureInfo.InvariantCulture, "1 × Tax (at ${0:F2} / month)", taxAmount));
|
||||
}
|
||||
|
||||
return items;
|
||||
|
||||
@@ -361,7 +361,8 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
|
||||
|
||||
var invoice = await stripeAdapter.UpdateInvoiceAsync(subscription.LatestInvoiceId, new InvoiceUpdateOptions
|
||||
{
|
||||
AutoAdvance = false
|
||||
AutoAdvance = false,
|
||||
Expand = ["customer"]
|
||||
});
|
||||
|
||||
await braintreeService.PayInvoice(new UserId(userId), invoice);
|
||||
|
||||
@@ -142,8 +142,7 @@ public static class FeatureFlagKeys
|
||||
public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache";
|
||||
public const string BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration";
|
||||
public const string IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud";
|
||||
public const string BulkRevokeUsersV2 = "pm-28456-bulk-revoke-users-v2";
|
||||
public const string PremiumAccessQuery = "pm-21411-premium-access-query";
|
||||
public const string PremiumAccessQuery = "pm-29495-refactor-premium-interface";
|
||||
|
||||
/* Architecture */
|
||||
public const string DesktopMigrationMilestone1 = "desktop-ui-migration-milestone-1";
|
||||
@@ -230,23 +229,12 @@ public static class FeatureFlagKeys
|
||||
/// Enable this flag to share the send view used by the web and browser clients
|
||||
/// on the desktop client.
|
||||
/// </summary>
|
||||
public const string DesktopSendUIRefresh = "desktop-send-ui-refresh";
|
||||
public const string UseSdkPasswordGenerators = "pm-19976-use-sdk-password-generators";
|
||||
public const string UseChromiumImporter = "pm-23982-chromium-importer";
|
||||
public const string ChromiumImporterWithABE = "pm-25855-chromium-importer-abe";
|
||||
public const string SendUIRefresh = "pm-28175-send-ui-refresh";
|
||||
public const string SendEmailOTP = "pm-19051-send-email-verification";
|
||||
|
||||
/// <summary>
|
||||
/// Enable this flag to output email/OTP authenticated sends from the `GET sends` endpoint. When
|
||||
/// this flag is disabled, the `GET sends` endpoint omits email/OTP authenticated sends.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This flag is server-side only, and only inhibits the endpoint returning all sends.
|
||||
/// Email/OTP sends can still be created and downloaded through other endpoints.
|
||||
/// </remarks>
|
||||
public const string PM19051_ListEmailOtpSends = "tools-send-email-otp-listing";
|
||||
|
||||
/* Vault Team */
|
||||
public const string CipherKeyEncryption = "cipher-key-encryption";
|
||||
public const string PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk";
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
<PackageReference Include="Microsoft.Bot.Builder.Integration.AspNet.Core" Version="4.23.0" />
|
||||
<PackageReference Include="Microsoft.Bot.Connector" Version="4.23.0" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Cosmos" Version="1.7.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Cosmos" Version="1.8.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.SqlServer" Version="8.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="8.0.0" />
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Bit.Core.Tools.Enums;
|
||||
namespace Bit.Core.Tools.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the authentication method required to access a Send.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum AuthType : byte
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Bit.Core.Tools.Models.Data;
|
||||
using Bit.Core.Tools.Enums;
|
||||
using Bit.Core.Tools.Models.Data;
|
||||
using Bit.Core.Tools.Repositories;
|
||||
using Bit.Core.Tools.SendFeatures.Queries.Interfaces;
|
||||
|
||||
@@ -37,8 +38,8 @@ public class SendAuthenticationQuery : ISendAuthenticationQuery
|
||||
{
|
||||
null => NEVER_AUTHENTICATE,
|
||||
var s when s.AccessCount >= s.MaxAccessCount => NEVER_AUTHENTICATE,
|
||||
var s when s.Emails is not null => emailOtp(s.Emails),
|
||||
var s when s.Password is not null => new ResourcePassword(s.Password),
|
||||
var s when s.AuthType == AuthType.Email && s.Emails is not null => emailOtp(s.Emails),
|
||||
var s when s.AuthType == AuthType.Password && s.Password is not null => new ResourcePassword(s.Password),
|
||||
_ => NOT_AUTHENTICATED
|
||||
};
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ namespace Bit.Core.Tools.SendFeatures.Queries;
|
||||
public class SendOwnerQuery : ISendOwnerQuery
|
||||
{
|
||||
private readonly ISendRepository _repository;
|
||||
private readonly IFeatureService _features;
|
||||
private readonly IUserService _users;
|
||||
|
||||
/// <summary>
|
||||
@@ -24,10 +23,9 @@ public class SendOwnerQuery : ISendOwnerQuery
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// Thrown when <paramref name="sendRepository"/> is <see langword="null"/>.
|
||||
/// </exception>
|
||||
public SendOwnerQuery(ISendRepository sendRepository, IFeatureService features, IUserService users)
|
||||
public SendOwnerQuery(ISendRepository sendRepository, IUserService users)
|
||||
{
|
||||
_repository = sendRepository;
|
||||
_features = features ?? throw new ArgumentNullException(nameof(features));
|
||||
_users = users ?? throw new ArgumentNullException(nameof(users));
|
||||
}
|
||||
|
||||
@@ -51,16 +49,6 @@ public class SendOwnerQuery : ISendOwnerQuery
|
||||
var userId = _users.GetProperUserId(user) ?? throw new BadRequestException("invalid user.");
|
||||
var sends = await _repository.GetManyByUserIdAsync(userId);
|
||||
|
||||
var removeEmailOtp = !_features.IsEnabled(FeatureFlagKeys.PM19051_ListEmailOtpSends);
|
||||
if (removeEmailOtp)
|
||||
{
|
||||
// reify list to avoid invalidating the enumerator
|
||||
foreach (var s in sends.Where(s => s.Emails != null).ToList())
|
||||
{
|
||||
sends.Remove(s);
|
||||
}
|
||||
}
|
||||
|
||||
return sends;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -801,7 +801,7 @@ public class CipherRepository : Repository<Core.Vault.Entities.Cipher, Cipher, G
|
||||
var query = from ucd in await userCipherDetailsQuery.Run(dbContext).ToListAsync()
|
||||
join c in cipherEntitiesToCheck
|
||||
on ucd.Id equals c.Id
|
||||
where ucd.Edit && FilterArchivedDate(action, ucd)
|
||||
where FilterArchivedDate(action, ucd)
|
||||
select c;
|
||||
|
||||
var utcNow = DateTime.UtcNow;
|
||||
|
||||
@@ -13,14 +13,13 @@ BEGIN
|
||||
|
||||
INSERT INTO #Temp
|
||||
SELECT
|
||||
[Id],
|
||||
[UserId]
|
||||
ucd.[Id],
|
||||
ucd.[UserId]
|
||||
FROM
|
||||
[dbo].[UserCipherDetails](@UserId)
|
||||
[dbo].[UserCipherDetails](@UserId) ucd
|
||||
INNER JOIN @Ids ids ON ids.Id = ucd.[Id]
|
||||
WHERE
|
||||
[Edit] = 1
|
||||
AND [ArchivedDate] IS NULL
|
||||
AND [Id] IN (SELECT * FROM @Ids)
|
||||
ucd.[ArchivedDate] IS NULL
|
||||
|
||||
DECLARE @UtcNow DATETIME2(7) = SYSUTCDATETIME();
|
||||
UPDATE
|
||||
@@ -32,8 +31,9 @@ BEGIN
|
||||
CONVERT(NVARCHAR(30), @UtcNow, 127)
|
||||
),
|
||||
[RevisionDate] = @UtcNow
|
||||
WHERE
|
||||
[Id] IN (SELECT [Id] FROM #Temp)
|
||||
FROM [dbo].[Cipher] AS c
|
||||
INNER JOIN #Temp AS t
|
||||
ON t.[Id] = c.[Id];
|
||||
|
||||
EXEC [dbo].[User_BumpAccountRevisionDate] @UserId
|
||||
|
||||
|
||||
@@ -13,14 +13,13 @@ BEGIN
|
||||
|
||||
INSERT INTO #Temp
|
||||
SELECT
|
||||
[Id],
|
||||
[UserId]
|
||||
ucd.[Id],
|
||||
ucd.[UserId]
|
||||
FROM
|
||||
[dbo].[UserCipherDetails](@UserId)
|
||||
[dbo].[UserCipherDetails](@UserId) ucd
|
||||
INNER JOIN @Ids ids ON ids.Id = ucd.[Id]
|
||||
WHERE
|
||||
[Edit] = 1
|
||||
AND [ArchivedDate] IS NOT NULL
|
||||
AND [Id] IN (SELECT * FROM @Ids)
|
||||
ucd.[ArchivedDate] IS NOT NULL
|
||||
|
||||
DECLARE @UtcNow DATETIME2(7) = SYSUTCDATETIME();
|
||||
UPDATE
|
||||
@@ -32,8 +31,9 @@ BEGIN
|
||||
NULL
|
||||
),
|
||||
[RevisionDate] = @UtcNow
|
||||
WHERE
|
||||
[Id] IN (SELECT [Id] FROM #Temp)
|
||||
FROM [dbo].[Cipher] AS c
|
||||
INNER JOIN #Temp AS t
|
||||
ON t.[Id] = c.[Id];
|
||||
|
||||
EXEC [dbo].[User_BumpAccountRevisionDate] @UserId
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ using Bit.Api.Tools.Models.Request;
|
||||
using Bit.Api.Tools.Models.Response;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Platform.Push;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Tools.Entities;
|
||||
using Bit.Core.Tools.Enums;
|
||||
using Bit.Core.Tools.Models.Data;
|
||||
@@ -28,7 +28,6 @@ namespace Bit.Api.Test.Tools.Controllers;
|
||||
public class SendsControllerTests : IDisposable
|
||||
{
|
||||
private readonly SendsController _sut;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly IUserService _userService;
|
||||
private readonly ISendRepository _sendRepository;
|
||||
private readonly INonAnonymousSendCommand _nonAnonymousSendCommand;
|
||||
@@ -37,6 +36,8 @@ public class SendsControllerTests : IDisposable
|
||||
private readonly ISendAuthorizationService _sendAuthorizationService;
|
||||
private readonly ISendFileStorageService _sendFileStorageService;
|
||||
private readonly ILogger<SendsController> _logger;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IPushNotificationService _pushNotificationService;
|
||||
|
||||
public SendsControllerTests()
|
||||
{
|
||||
@@ -47,8 +48,9 @@ public class SendsControllerTests : IDisposable
|
||||
_sendOwnerQuery = Substitute.For<ISendOwnerQuery>();
|
||||
_sendAuthorizationService = Substitute.For<ISendAuthorizationService>();
|
||||
_sendFileStorageService = Substitute.For<ISendFileStorageService>();
|
||||
_globalSettings = new GlobalSettings();
|
||||
_logger = Substitute.For<ILogger<SendsController>>();
|
||||
_featureService = Substitute.For<IFeatureService>();
|
||||
_pushNotificationService = Substitute.For<IPushNotificationService>();
|
||||
|
||||
_sut = new SendsController(
|
||||
_sendRepository,
|
||||
@@ -59,7 +61,8 @@ public class SendsControllerTests : IDisposable
|
||||
_sendOwnerQuery,
|
||||
_sendFileStorageService,
|
||||
_logger,
|
||||
_globalSettings
|
||||
_featureService,
|
||||
_pushNotificationService
|
||||
);
|
||||
}
|
||||
|
||||
@@ -96,8 +99,8 @@ public class SendsControllerTests : IDisposable
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var expected = "You cannot have a Send with a deletion date that far " +
|
||||
"into the future. Adjust the Deletion Date to a value less than 31 days from now " +
|
||||
"and try again.";
|
||||
"into the future. Adjust the Deletion Date to a value less than 31 days from now " +
|
||||
"and try again.";
|
||||
var request = new SendRequestModel() { DeletionDate = now.AddDays(32) };
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() => _sut.Post(request));
|
||||
@@ -109,9 +112,10 @@ public class SendsControllerTests : IDisposable
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var expected = "You cannot have a Send with a deletion date that far " +
|
||||
"into the future. Adjust the Deletion Date to a value less than 31 days from now " +
|
||||
"and try again.";
|
||||
var request = new SendRequestModel() { Type = SendType.File, FileLength = 1024L, DeletionDate = now.AddDays(32) };
|
||||
"into the future. Adjust the Deletion Date to a value less than 31 days from now " +
|
||||
"and try again.";
|
||||
var request =
|
||||
new SendRequestModel() { Type = SendType.File, FileLength = 1024L, DeletionDate = now.AddDays(32) };
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() => _sut.PostFile(request));
|
||||
Assert.Equal(expected, exception.Message);
|
||||
@@ -409,7 +413,8 @@ public class SendsControllerTests : IDisposable
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task PutRemovePassword_WithWrongUser_ThrowsNotFoundException(Guid userId, Guid otherUserId, Guid sendId)
|
||||
public async Task PutRemovePassword_WithWrongUser_ThrowsNotFoundException(Guid userId, Guid otherUserId,
|
||||
Guid sendId)
|
||||
{
|
||||
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
|
||||
var existingSend = new Send
|
||||
@@ -753,4 +758,683 @@ public class SendsControllerTests : IDisposable
|
||||
s.Password == null &&
|
||||
s.Emails == null));
|
||||
}
|
||||
|
||||
#region Authenticated Access Endpoints
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task AccessUsingAuth_WithValidSend_ReturnsSendAccessResponse(Guid sendId, User creator)
|
||||
{
|
||||
var send = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
UserId = creator.Id,
|
||||
Type = SendType.Text,
|
||||
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
|
||||
HideEmail = false,
|
||||
DeletionDate = DateTime.UtcNow.AddDays(7),
|
||||
ExpirationDate = null,
|
||||
Disabled = false,
|
||||
AccessCount = 0,
|
||||
MaxAccessCount = null
|
||||
};
|
||||
var user = CreateUserWithSendIdClaim(sendId);
|
||||
_sut.ControllerContext = CreateControllerContextWithUser(user);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
_userService.GetUserByIdAsync(creator.Id).Returns(creator);
|
||||
|
||||
var result = await _sut.AccessUsingAuth();
|
||||
|
||||
Assert.NotNull(result);
|
||||
var objectResult = Assert.IsType<ObjectResult>(result);
|
||||
var response = Assert.IsType<SendAccessResponseModel>(objectResult.Value);
|
||||
Assert.Equal(CoreHelpers.Base64UrlEncode(sendId.ToByteArray()), response.Id);
|
||||
Assert.Equal(creator.Email, response.CreatorIdentifier);
|
||||
await _sendRepository.Received(1).GetByIdAsync(sendId);
|
||||
await _userService.Received(1).GetUserByIdAsync(creator.Id);
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task AccessUsingAuth_WithHideEmail_DoesNotIncludeCreatorIdentifier(Guid sendId, User creator)
|
||||
{
|
||||
var send = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
UserId = creator.Id,
|
||||
Type = SendType.Text,
|
||||
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
|
||||
HideEmail = true,
|
||||
DeletionDate = DateTime.UtcNow.AddDays(7),
|
||||
ExpirationDate = null,
|
||||
Disabled = false,
|
||||
AccessCount = 0,
|
||||
MaxAccessCount = null
|
||||
};
|
||||
var user = CreateUserWithSendIdClaim(sendId);
|
||||
_sut.ControllerContext = CreateControllerContextWithUser(user);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
|
||||
var result = await _sut.AccessUsingAuth();
|
||||
|
||||
Assert.NotNull(result);
|
||||
var objectResult = Assert.IsType<ObjectResult>(result);
|
||||
var response = Assert.IsType<SendAccessResponseModel>(objectResult.Value);
|
||||
Assert.Equal(CoreHelpers.Base64UrlEncode(sendId.ToByteArray()), response.Id);
|
||||
Assert.Null(response.CreatorIdentifier);
|
||||
await _sendRepository.Received(1).GetByIdAsync(sendId);
|
||||
await _userService.DidNotReceive().GetUserByIdAsync(Arg.Any<Guid>());
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task AccessUsingAuth_WithNoUserId_DoesNotIncludeCreatorIdentifier(Guid sendId)
|
||||
{
|
||||
var send = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
UserId = null,
|
||||
Type = SendType.Text,
|
||||
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
|
||||
HideEmail = false,
|
||||
DeletionDate = DateTime.UtcNow.AddDays(7),
|
||||
ExpirationDate = null,
|
||||
Disabled = false,
|
||||
AccessCount = 0,
|
||||
MaxAccessCount = null
|
||||
};
|
||||
var user = CreateUserWithSendIdClaim(sendId);
|
||||
_sut.ControllerContext = CreateControllerContextWithUser(user);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
|
||||
var result = await _sut.AccessUsingAuth();
|
||||
|
||||
Assert.NotNull(result);
|
||||
var objectResult = Assert.IsType<ObjectResult>(result);
|
||||
var response = Assert.IsType<SendAccessResponseModel>(objectResult.Value);
|
||||
Assert.Equal(CoreHelpers.Base64UrlEncode(sendId.ToByteArray()), response.Id);
|
||||
Assert.Null(response.CreatorIdentifier);
|
||||
await _sendRepository.Received(1).GetByIdAsync(sendId);
|
||||
await _userService.DidNotReceive().GetUserByIdAsync(Arg.Any<Guid>());
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task AccessUsingAuth_WithNonExistentSend_ThrowsBadRequestException(Guid sendId)
|
||||
{
|
||||
var user = CreateUserWithSendIdClaim(sendId);
|
||||
_sut.ControllerContext = CreateControllerContextWithUser(user);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns((Send)null);
|
||||
|
||||
var exception =
|
||||
await Assert.ThrowsAsync<BadRequestException>(() => _sut.AccessUsingAuth());
|
||||
|
||||
Assert.Equal("Could not locate send", exception.Message);
|
||||
await _sendRepository.Received(1).GetByIdAsync(sendId);
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task AccessUsingAuth_WithFileSend_ReturnsCorrectResponse(Guid sendId, User creator)
|
||||
{
|
||||
var fileData = new SendFileData("Test File", "Notes", "document.pdf") { Id = "file-123", Size = 2048 };
|
||||
var send = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
UserId = creator.Id,
|
||||
Type = SendType.File,
|
||||
Data = JsonSerializer.Serialize(fileData),
|
||||
HideEmail = false,
|
||||
DeletionDate = DateTime.UtcNow.AddDays(7),
|
||||
ExpirationDate = null,
|
||||
Disabled = false,
|
||||
AccessCount = 0,
|
||||
MaxAccessCount = null
|
||||
};
|
||||
var user = CreateUserWithSendIdClaim(sendId);
|
||||
_sut.ControllerContext = CreateControllerContextWithUser(user);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
_userService.GetUserByIdAsync(creator.Id).Returns(creator);
|
||||
|
||||
var result = await _sut.AccessUsingAuth();
|
||||
|
||||
Assert.NotNull(result);
|
||||
var objectResult = Assert.IsType<ObjectResult>(result);
|
||||
var response = Assert.IsType<SendAccessResponseModel>(objectResult.Value);
|
||||
Assert.Equal(CoreHelpers.Base64UrlEncode(sendId.ToByteArray()), response.Id);
|
||||
Assert.Equal(SendType.File, response.Type);
|
||||
Assert.NotNull(response.File);
|
||||
Assert.Equal("file-123", response.File.Id);
|
||||
Assert.Equal(creator.Email, response.CreatorIdentifier);
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task GetSendFileDownloadDataUsingAuth_WithValidFileId_ReturnsDownloadUrl(
|
||||
Guid sendId, string fileId, string expectedUrl)
|
||||
{
|
||||
var fileData = new SendFileData("Test File", "Notes", "document.pdf") { Id = fileId, Size = 2048 };
|
||||
var send = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
Type = SendType.File,
|
||||
Data = JsonSerializer.Serialize(fileData),
|
||||
DeletionDate = DateTime.UtcNow.AddDays(7),
|
||||
ExpirationDate = null,
|
||||
Disabled = false,
|
||||
AccessCount = 0,
|
||||
MaxAccessCount = null
|
||||
};
|
||||
var user = CreateUserWithSendIdClaim(sendId);
|
||||
_sut.ControllerContext = CreateControllerContextWithUser(user);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
_sendFileStorageService.GetSendFileDownloadUrlAsync(send, fileId).Returns(expectedUrl);
|
||||
|
||||
var result = await _sut.GetSendFileDownloadDataUsingAuth(fileId);
|
||||
|
||||
Assert.NotNull(result);
|
||||
var objectResult = Assert.IsType<ObjectResult>(result);
|
||||
var response = Assert.IsType<SendFileDownloadDataResponseModel>(objectResult.Value);
|
||||
Assert.Equal(fileId, response.Id);
|
||||
Assert.Equal(expectedUrl, response.Url);
|
||||
await _sendRepository.Received(1).GetByIdAsync(sendId);
|
||||
await _sendFileStorageService.Received(1).GetSendFileDownloadUrlAsync(send, fileId);
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task GetSendFileDownloadDataUsingAuth_WithNonExistentSend_ThrowsBadRequestException(
|
||||
Guid sendId, string fileId)
|
||||
{
|
||||
var user = CreateUserWithSendIdClaim(sendId);
|
||||
_sut.ControllerContext = CreateControllerContextWithUser(user);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns((Send)null);
|
||||
|
||||
var exception =
|
||||
await Assert.ThrowsAsync<BadRequestException>(() => _sut.GetSendFileDownloadDataUsingAuth(fileId));
|
||||
|
||||
Assert.Equal("Could not locate send", exception.Message);
|
||||
await _sendRepository.Received(1).GetByIdAsync(sendId);
|
||||
await _sendFileStorageService.DidNotReceive()
|
||||
.GetSendFileDownloadUrlAsync(Arg.Any<Send>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task GetSendFileDownloadDataUsingAuth_WithTextSend_StillReturnsResponse(
|
||||
Guid sendId, string fileId, string expectedUrl)
|
||||
{
|
||||
var send = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
Type = SendType.Text,
|
||||
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
|
||||
DeletionDate = DateTime.UtcNow.AddDays(7),
|
||||
ExpirationDate = null,
|
||||
Disabled = false,
|
||||
AccessCount = 0,
|
||||
MaxAccessCount = null
|
||||
};
|
||||
var user = CreateUserWithSendIdClaim(sendId);
|
||||
_sut.ControllerContext = CreateControllerContextWithUser(user);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
_sendFileStorageService.GetSendFileDownloadUrlAsync(send, fileId).Returns(expectedUrl);
|
||||
|
||||
var result = await _sut.GetSendFileDownloadDataUsingAuth(fileId);
|
||||
|
||||
Assert.NotNull(result);
|
||||
var objectResult = Assert.IsType<ObjectResult>(result);
|
||||
var response = Assert.IsType<SendFileDownloadDataResponseModel>(objectResult.Value);
|
||||
Assert.Equal(fileId, response.Id);
|
||||
Assert.Equal(expectedUrl, response.Url);
|
||||
}
|
||||
|
||||
#region AccessUsingAuth Validation Tests
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task AccessUsingAuth_WithExpiredSend_ThrowsNotFoundException(Guid sendId)
|
||||
{
|
||||
var send = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
UserId = Guid.NewGuid(),
|
||||
Type = SendType.Text,
|
||||
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
|
||||
DeletionDate = DateTime.UtcNow.AddDays(7),
|
||||
ExpirationDate = DateTime.UtcNow.AddDays(-1), // Expired yesterday
|
||||
Disabled = false,
|
||||
AccessCount = 0,
|
||||
MaxAccessCount = null
|
||||
};
|
||||
var user = CreateUserWithSendIdClaim(sendId);
|
||||
_sut.ControllerContext = CreateControllerContextWithUser(user);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => _sut.AccessUsingAuth());
|
||||
|
||||
await _sendRepository.Received(1).GetByIdAsync(sendId);
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task AccessUsingAuth_WithDeletedSend_ThrowsNotFoundException(Guid sendId)
|
||||
{
|
||||
var send = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
UserId = Guid.NewGuid(),
|
||||
Type = SendType.Text,
|
||||
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
|
||||
DeletionDate = DateTime.UtcNow.AddDays(-1), // Should have been deleted yesterday
|
||||
ExpirationDate = null,
|
||||
Disabled = false,
|
||||
AccessCount = 0,
|
||||
MaxAccessCount = null
|
||||
};
|
||||
var user = CreateUserWithSendIdClaim(sendId);
|
||||
_sut.ControllerContext = CreateControllerContextWithUser(user);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => _sut.AccessUsingAuth());
|
||||
|
||||
await _sendRepository.Received(1).GetByIdAsync(sendId);
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task AccessUsingAuth_WithDisabledSend_ThrowsNotFoundException(Guid sendId)
|
||||
{
|
||||
var send = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
UserId = Guid.NewGuid(),
|
||||
Type = SendType.Text,
|
||||
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
|
||||
DeletionDate = DateTime.UtcNow.AddDays(7),
|
||||
ExpirationDate = null,
|
||||
Disabled = true, // Disabled
|
||||
AccessCount = 0,
|
||||
MaxAccessCount = null
|
||||
};
|
||||
var user = CreateUserWithSendIdClaim(sendId);
|
||||
_sut.ControllerContext = CreateControllerContextWithUser(user);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => _sut.AccessUsingAuth());
|
||||
|
||||
await _sendRepository.Received(1).GetByIdAsync(sendId);
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task AccessUsingAuth_WithAccessCountExceeded_ThrowsNotFoundException(Guid sendId)
|
||||
{
|
||||
var send = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
UserId = Guid.NewGuid(),
|
||||
Type = SendType.Text,
|
||||
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
|
||||
DeletionDate = DateTime.UtcNow.AddDays(7),
|
||||
ExpirationDate = null,
|
||||
Disabled = false,
|
||||
AccessCount = 5,
|
||||
MaxAccessCount = 5 // Limit reached
|
||||
};
|
||||
var user = CreateUserWithSendIdClaim(sendId);
|
||||
_sut.ControllerContext = CreateControllerContextWithUser(user);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => _sut.AccessUsingAuth());
|
||||
|
||||
await _sendRepository.Received(1).GetByIdAsync(sendId);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetSendFileDownloadDataUsingAuth Validation Tests
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task GetSendFileDownloadDataUsingAuth_WithExpiredSend_ThrowsNotFoundException(
|
||||
Guid sendId, string fileId)
|
||||
{
|
||||
var send = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
Type = SendType.File,
|
||||
Data = JsonSerializer.Serialize(new SendFileData("Test", "Notes", "file.pdf")),
|
||||
DeletionDate = DateTime.UtcNow.AddDays(7),
|
||||
ExpirationDate = DateTime.UtcNow.AddDays(-1), // Expired
|
||||
Disabled = false,
|
||||
AccessCount = 0,
|
||||
MaxAccessCount = null
|
||||
};
|
||||
var user = CreateUserWithSendIdClaim(sendId);
|
||||
_sut.ControllerContext = CreateControllerContextWithUser(user);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => _sut.GetSendFileDownloadDataUsingAuth(fileId));
|
||||
|
||||
await _sendRepository.Received(1).GetByIdAsync(sendId);
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task GetSendFileDownloadDataUsingAuth_WithDeletedSend_ThrowsNotFoundException(
|
||||
Guid sendId, string fileId)
|
||||
{
|
||||
var send = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
Type = SendType.File,
|
||||
Data = JsonSerializer.Serialize(new SendFileData("Test", "Notes", "file.pdf")),
|
||||
DeletionDate = DateTime.UtcNow.AddDays(-1), // Deleted
|
||||
ExpirationDate = null,
|
||||
Disabled = false,
|
||||
AccessCount = 0,
|
||||
MaxAccessCount = null
|
||||
};
|
||||
var user = CreateUserWithSendIdClaim(sendId);
|
||||
_sut.ControllerContext = CreateControllerContextWithUser(user);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => _sut.GetSendFileDownloadDataUsingAuth(fileId));
|
||||
|
||||
await _sendRepository.Received(1).GetByIdAsync(sendId);
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task GetSendFileDownloadDataUsingAuth_WithDisabledSend_ThrowsNotFoundException(
|
||||
Guid sendId, string fileId)
|
||||
{
|
||||
var send = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
Type = SendType.File,
|
||||
Data = JsonSerializer.Serialize(new SendFileData("Test", "Notes", "file.pdf")),
|
||||
DeletionDate = DateTime.UtcNow.AddDays(7),
|
||||
ExpirationDate = null,
|
||||
Disabled = true, // Disabled
|
||||
AccessCount = 0,
|
||||
MaxAccessCount = null
|
||||
};
|
||||
var user = CreateUserWithSendIdClaim(sendId);
|
||||
_sut.ControllerContext = CreateControllerContextWithUser(user);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => _sut.GetSendFileDownloadDataUsingAuth(fileId));
|
||||
|
||||
await _sendRepository.Received(1).GetByIdAsync(sendId);
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task GetSendFileDownloadDataUsingAuth_WithAccessCountExceeded_ThrowsNotFoundException(
|
||||
Guid sendId, string fileId)
|
||||
{
|
||||
var send = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
Type = SendType.File,
|
||||
Data = JsonSerializer.Serialize(new SendFileData("Test", "Notes", "file.pdf")),
|
||||
DeletionDate = DateTime.UtcNow.AddDays(7),
|
||||
ExpirationDate = null,
|
||||
Disabled = false,
|
||||
AccessCount = 10,
|
||||
MaxAccessCount = 10 // Limit reached
|
||||
};
|
||||
var user = CreateUserWithSendIdClaim(sendId);
|
||||
_sut.ControllerContext = CreateControllerContextWithUser(user);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => _sut.GetSendFileDownloadDataUsingAuth(fileId));
|
||||
|
||||
await _sendRepository.Received(1).GetByIdAsync(sendId);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#endregion
|
||||
|
||||
#region PutRemoveAuth Tests
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task PutRemoveAuth_WithPasswordProtectedSend_RemovesPasswordAndSetsAuthTypeNone(Guid userId,
|
||||
Guid sendId)
|
||||
{
|
||||
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
|
||||
var existingSend = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
UserId = userId,
|
||||
Type = SendType.Text,
|
||||
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
|
||||
Password = "hashed-password",
|
||||
Emails = null,
|
||||
AuthType = AuthType.Password
|
||||
};
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(existingSend);
|
||||
|
||||
var result = await _sut.PutRemoveAuth(sendId.ToString());
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(sendId, result.Id);
|
||||
Assert.Equal(AuthType.None, result.AuthType);
|
||||
Assert.Null(result.Password);
|
||||
Assert.Null(result.Emails);
|
||||
await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is<Send>(s =>
|
||||
s.Id == sendId &&
|
||||
s.Password == null &&
|
||||
s.Emails == null &&
|
||||
s.AuthType == AuthType.None));
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task PutRemoveAuth_WithEmailProtectedSend_RemovesEmailsAndSetsAuthTypeNone(Guid userId, Guid sendId)
|
||||
{
|
||||
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
|
||||
var existingSend = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
UserId = userId,
|
||||
Type = SendType.Text,
|
||||
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
|
||||
Password = null,
|
||||
Emails = "test@example.com,user@example.com",
|
||||
AuthType = AuthType.Email
|
||||
};
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(existingSend);
|
||||
|
||||
var result = await _sut.PutRemoveAuth(sendId.ToString());
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(sendId, result.Id);
|
||||
Assert.Equal(AuthType.None, result.AuthType);
|
||||
Assert.Null(result.Password);
|
||||
Assert.Null(result.Emails);
|
||||
await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is<Send>(s =>
|
||||
s.Id == sendId &&
|
||||
s.Password == null &&
|
||||
s.Emails == null &&
|
||||
s.AuthType == AuthType.None));
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task PutRemoveAuth_WithSendAlreadyHavingNoAuth_StillSucceeds(Guid userId, Guid sendId)
|
||||
{
|
||||
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
|
||||
var existingSend = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
UserId = userId,
|
||||
Type = SendType.Text,
|
||||
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
|
||||
Password = null,
|
||||
Emails = null,
|
||||
AuthType = AuthType.None
|
||||
};
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(existingSend);
|
||||
|
||||
var result = await _sut.PutRemoveAuth(sendId.ToString());
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(sendId, result.Id);
|
||||
Assert.Equal(AuthType.None, result.AuthType);
|
||||
Assert.Null(result.Password);
|
||||
Assert.Null(result.Emails);
|
||||
await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is<Send>(s =>
|
||||
s.Id == sendId &&
|
||||
s.Password == null &&
|
||||
s.Emails == null &&
|
||||
s.AuthType == AuthType.None));
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task PutRemoveAuth_WithFileSend_RemovesAuthAndPreservesFileData(Guid userId, Guid sendId)
|
||||
{
|
||||
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
|
||||
var fileData = new SendFileData("Test File", "Notes", "document.pdf") { Id = "file-123", Size = 2048 };
|
||||
var existingSend = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
UserId = userId,
|
||||
Type = SendType.File,
|
||||
Data = JsonSerializer.Serialize(fileData),
|
||||
Password = "hashed-password",
|
||||
Emails = null,
|
||||
AuthType = AuthType.Password
|
||||
};
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(existingSend);
|
||||
|
||||
var result = await _sut.PutRemoveAuth(sendId.ToString());
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(sendId, result.Id);
|
||||
Assert.Equal(AuthType.None, result.AuthType);
|
||||
Assert.Equal(SendType.File, result.Type);
|
||||
Assert.NotNull(result.File);
|
||||
Assert.Equal("file-123", result.File.Id);
|
||||
Assert.Null(result.Password);
|
||||
Assert.Null(result.Emails);
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task PutRemoveAuth_WithNonExistentSend_ThrowsNotFoundException(Guid userId, Guid sendId)
|
||||
{
|
||||
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns((Send)null);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => _sut.PutRemoveAuth(sendId.ToString()));
|
||||
|
||||
await _sendRepository.Received(1).GetByIdAsync(sendId);
|
||||
await _nonAnonymousSendCommand.DidNotReceive().SaveSendAsync(Arg.Any<Send>());
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task PutRemoveAuth_WithWrongUser_ThrowsNotFoundException(Guid userId, Guid otherUserId, Guid sendId)
|
||||
{
|
||||
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
|
||||
var existingSend = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
UserId = otherUserId,
|
||||
Type = SendType.Text,
|
||||
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
|
||||
Password = "hashed-password",
|
||||
AuthType = AuthType.Password
|
||||
};
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(existingSend);
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() => _sut.PutRemoveAuth(sendId.ToString()));
|
||||
|
||||
await _sendRepository.Received(1).GetByIdAsync(sendId);
|
||||
await _nonAnonymousSendCommand.DidNotReceive().SaveSendAsync(Arg.Any<Send>());
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task PutRemoveAuth_WithNullUserId_ThrowsInvalidOperationException(Guid sendId)
|
||||
{
|
||||
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns((Guid?)null);
|
||||
|
||||
var exception =
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.PutRemoveAuth(sendId.ToString()));
|
||||
|
||||
Assert.Equal("User ID not found", exception.Message);
|
||||
await _sendRepository.DidNotReceive().GetByIdAsync(Arg.Any<Guid>());
|
||||
await _nonAnonymousSendCommand.DidNotReceive().SaveSendAsync(Arg.Any<Send>());
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task PutRemoveAuth_WithSendHavingBothPasswordAndEmails_RemovesBoth(Guid userId, Guid sendId)
|
||||
{
|
||||
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
|
||||
var existingSend = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
UserId = userId,
|
||||
Type = SendType.Text,
|
||||
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
|
||||
Password = "hashed-password",
|
||||
Emails = "test@example.com",
|
||||
AuthType = AuthType.Password
|
||||
};
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(existingSend);
|
||||
|
||||
var result = await _sut.PutRemoveAuth(sendId.ToString());
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(sendId, result.Id);
|
||||
Assert.Equal(AuthType.None, result.AuthType);
|
||||
Assert.Null(result.Password);
|
||||
Assert.Null(result.Emails);
|
||||
await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is<Send>(s =>
|
||||
s.Id == sendId &&
|
||||
s.Password == null &&
|
||||
s.Emails == null &&
|
||||
s.AuthType == AuthType.None));
|
||||
}
|
||||
|
||||
[Theory, AutoData]
|
||||
public async Task PutRemoveAuth_PreservesOtherSendProperties(Guid userId, Guid sendId)
|
||||
{
|
||||
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
|
||||
var deletionDate = DateTime.UtcNow.AddDays(7);
|
||||
var expirationDate = DateTime.UtcNow.AddDays(3);
|
||||
var existingSend = new Send
|
||||
{
|
||||
Id = sendId,
|
||||
UserId = userId,
|
||||
Type = SendType.Text,
|
||||
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
|
||||
Password = "hashed-password",
|
||||
AuthType = AuthType.Password,
|
||||
Key = "encryption-key",
|
||||
MaxAccessCount = 10,
|
||||
AccessCount = 3,
|
||||
DeletionDate = deletionDate,
|
||||
ExpirationDate = expirationDate,
|
||||
Disabled = false,
|
||||
HideEmail = true
|
||||
};
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(existingSend);
|
||||
|
||||
var result = await _sut.PutRemoveAuth(sendId.ToString());
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(sendId, result.Id);
|
||||
Assert.Equal(AuthType.None, result.AuthType);
|
||||
// Verify other properties are preserved
|
||||
Assert.Equal("encryption-key", result.Key);
|
||||
Assert.Equal(10, result.MaxAccessCount);
|
||||
Assert.Equal(3, result.AccessCount);
|
||||
Assert.Equal(deletionDate, result.DeletionDate);
|
||||
Assert.Equal(expirationDate, result.ExpirationDate);
|
||||
Assert.False(result.Disabled);
|
||||
Assert.True(result.HideEmail);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Helpers
|
||||
|
||||
private static ClaimsPrincipal CreateUserWithSendIdClaim(Guid sendId)
|
||||
{
|
||||
var claims = new List<Claim> { new Claim("send_id", sendId.ToString()) };
|
||||
var identity = new ClaimsIdentity(claims, "TestAuth");
|
||||
return new ClaimsPrincipal(identity);
|
||||
}
|
||||
|
||||
private static ControllerContext CreateControllerContextWithUser(ClaimsPrincipal user)
|
||||
{
|
||||
return new ControllerContext { HttpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext { User = user } };
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -283,7 +283,7 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
|
||||
OrganizationId = policyUpdate.OrganizationId,
|
||||
Type = OrganizationUserType.User,
|
||||
Status = OrganizationUserStatusType.Invited,
|
||||
UserId = Guid.NewGuid(),
|
||||
UserId = null,
|
||||
Email = "invited@example.com"
|
||||
};
|
||||
|
||||
@@ -302,6 +302,56 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
|
||||
Assert.True(string.IsNullOrEmpty(result));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_EnablingPolicy_MixedUsersWithNullUserId_HandlesCorrectly(
|
||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||
Guid confirmedUserId,
|
||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var invitedUser = new OrganizationUserUserDetails
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrganizationId = policyUpdate.OrganizationId,
|
||||
Type = OrganizationUserType.User,
|
||||
Status = OrganizationUserStatusType.Invited,
|
||||
UserId = null,
|
||||
Email = "invited@example.com"
|
||||
};
|
||||
|
||||
var confirmedUser = new OrganizationUserUserDetails
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrganizationId = policyUpdate.OrganizationId,
|
||||
Type = OrganizationUserType.User,
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
UserId = confirmedUserId,
|
||||
Email = "confirmed@example.com"
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([invitedUser, confirmedUser]);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns([]);
|
||||
|
||||
sutProvider.GetDependency<IProviderUserRepository>()
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns([]);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
|
||||
|
||||
// Assert
|
||||
Assert.True(string.IsNullOrEmpty(result));
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.Received(1)
|
||||
.GetManyByManyUsersAsync(Arg.Is<IEnumerable<Guid>>(ids => ids.Count() == 1 && ids.First() == confirmedUserId));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_EnablingPolicy_RevokedUsersIncluded_InComplianceCheck(
|
||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||
|
||||
@@ -266,7 +266,10 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>());
|
||||
await _subscriberService.Received(1).CreateBraintreeCustomer(user, paymentMethod.Token);
|
||||
await _stripeAdapter.Received(1).UpdateInvoiceAsync(mockSubscription.LatestInvoiceId,
|
||||
Arg.Is<InvoiceUpdateOptions>(opts => opts.AutoAdvance == false));
|
||||
Arg.Is<InvoiceUpdateOptions>(opts =>
|
||||
opts.AutoAdvance == false &&
|
||||
opts.Expand != null &&
|
||||
opts.Expand.Contains("customer")));
|
||||
await _braintreeService.Received(1).PayInvoice(Arg.Any<SubscriberId>(), mockInvoice);
|
||||
await _userService.Received(1).SaveUserAsync(user);
|
||||
await _pushNotificationService.Received(1).PushSyncVaultAsync(user.Id);
|
||||
@@ -502,7 +505,10 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
Assert.True(user.Premium);
|
||||
Assert.Equal(mockSubscription.GetCurrentPeriodEnd(), user.PremiumExpirationDate);
|
||||
await _stripeAdapter.Received(1).UpdateInvoiceAsync(mockSubscription.LatestInvoiceId,
|
||||
Arg.Is<InvoiceUpdateOptions>(opts => opts.AutoAdvance == false));
|
||||
Arg.Is<InvoiceUpdateOptions>(opts =>
|
||||
opts.AutoAdvance == false &&
|
||||
opts.Expand != null &&
|
||||
opts.Expand.Contains("customer")));
|
||||
await _braintreeService.Received(1).PayInvoice(Arg.Any<SubscriberId>(), mockInvoice);
|
||||
}
|
||||
|
||||
@@ -612,7 +618,10 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
Assert.False(user.Premium);
|
||||
Assert.Null(user.PremiumExpirationDate);
|
||||
await _stripeAdapter.Received(1).UpdateInvoiceAsync(mockSubscription.LatestInvoiceId,
|
||||
Arg.Is<InvoiceUpdateOptions>(opts => opts.AutoAdvance == false));
|
||||
Arg.Is<InvoiceUpdateOptions>(opts =>
|
||||
opts.AutoAdvance == false &&
|
||||
opts.Expand != null &&
|
||||
opts.Expand.Contains("customer")));
|
||||
await _braintreeService.Received(1).PayInvoice(Arg.Any<SubscriberId>(), mockInvoice);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Bit.Core.Tools.Entities;
|
||||
using Bit.Core.Tools.Enums;
|
||||
using Bit.Core.Tools.Models.Data;
|
||||
using Bit.Core.Tools.Repositories;
|
||||
using Bit.Core.Tools.SendFeatures.Queries;
|
||||
@@ -47,7 +48,7 @@ public class SendAuthenticationQueryTests
|
||||
{
|
||||
// Arrange
|
||||
var sendId = Guid.NewGuid();
|
||||
var send = CreateSend(accessCount: 0, maxAccessCount: 10, emails: emailString, password: null);
|
||||
var send = CreateSend(accessCount: 0, maxAccessCount: 10, emails: emailString, password: null, AuthType.Email);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
|
||||
// Act
|
||||
@@ -63,7 +64,7 @@ public class SendAuthenticationQueryTests
|
||||
{
|
||||
// Arrange
|
||||
var sendId = Guid.NewGuid();
|
||||
var send = CreateSend(accessCount: 0, maxAccessCount: 10, emails: "test@example.com", password: "hashedpassword");
|
||||
var send = CreateSend(accessCount: 0, maxAccessCount: 10, emails: "test@example.com", password: "hashedpassword", AuthType.Email);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
|
||||
// Act
|
||||
@@ -78,7 +79,7 @@ public class SendAuthenticationQueryTests
|
||||
{
|
||||
// Arrange
|
||||
var sendId = Guid.NewGuid();
|
||||
var send = CreateSend(accessCount: 0, maxAccessCount: 10, emails: null, password: null);
|
||||
var send = CreateSend(accessCount: 0, maxAccessCount: 10, emails: null, password: null, AuthType.None);
|
||||
_sendRepository.GetByIdAsync(sendId).Returns(send);
|
||||
|
||||
// Act
|
||||
@@ -105,11 +106,11 @@ public class SendAuthenticationQueryTests
|
||||
public static IEnumerable<object[]> AuthenticationMethodTestCases()
|
||||
{
|
||||
yield return new object[] { null, typeof(NeverAuthenticate) };
|
||||
yield return new object[] { CreateSend(accessCount: 5, maxAccessCount: 5, emails: null, password: null), typeof(NeverAuthenticate) };
|
||||
yield return new object[] { CreateSend(accessCount: 6, maxAccessCount: 5, emails: null, password: null), typeof(NeverAuthenticate) };
|
||||
yield return new object[] { CreateSend(accessCount: 0, maxAccessCount: 10, emails: "test@example.com", password: null), typeof(EmailOtp) };
|
||||
yield return new object[] { CreateSend(accessCount: 0, maxAccessCount: 10, emails: null, password: "hashedpassword"), typeof(ResourcePassword) };
|
||||
yield return new object[] { CreateSend(accessCount: 0, maxAccessCount: 10, emails: null, password: null), typeof(NotAuthenticated) };
|
||||
yield return new object[] { CreateSend(accessCount: 5, maxAccessCount: 5, emails: null, password: null, AuthType.None), typeof(NeverAuthenticate) };
|
||||
yield return new object[] { CreateSend(accessCount: 6, maxAccessCount: 5, emails: null, password: null, AuthType.None), typeof(NeverAuthenticate) };
|
||||
yield return new object[] { CreateSend(accessCount: 0, maxAccessCount: 10, emails: "test@example.com", password: null, AuthType.Email), typeof(EmailOtp) };
|
||||
yield return new object[] { CreateSend(accessCount: 0, maxAccessCount: 10, emails: null, password: "hashedpassword", AuthType.Password), typeof(ResourcePassword) };
|
||||
yield return new object[] { CreateSend(accessCount: 0, maxAccessCount: 10, emails: null, password: null, AuthType.None), typeof(NotAuthenticated) };
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> EmailParsingTestCases()
|
||||
@@ -121,7 +122,7 @@ public class SendAuthenticationQueryTests
|
||||
yield return new object[] { " , test@example.com, ,other@example.com, ", new[] { "test@example.com", "other@example.com" } };
|
||||
}
|
||||
|
||||
private static Send CreateSend(int accessCount, int? maxAccessCount, string? emails, string? password)
|
||||
private static Send CreateSend(int accessCount, int? maxAccessCount, string? emails, string? password, AuthType? authType)
|
||||
{
|
||||
return new Send
|
||||
{
|
||||
@@ -129,7 +130,8 @@ public class SendAuthenticationQueryTests
|
||||
AccessCount = accessCount,
|
||||
MaxAccessCount = maxAccessCount,
|
||||
Emails = emails,
|
||||
Password = password
|
||||
Password = password,
|
||||
AuthType = authType
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ namespace Bit.Core.Test.Tools.Services;
|
||||
public class SendOwnerQueryTests
|
||||
{
|
||||
private readonly ISendRepository _sendRepository;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IUserService _userService;
|
||||
private readonly SendOwnerQuery _sendOwnerQuery;
|
||||
private readonly Guid _currentUserId = Guid.NewGuid();
|
||||
@@ -21,11 +20,10 @@ public class SendOwnerQueryTests
|
||||
public SendOwnerQueryTests()
|
||||
{
|
||||
_sendRepository = Substitute.For<ISendRepository>();
|
||||
_featureService = Substitute.For<IFeatureService>();
|
||||
_userService = Substitute.For<IUserService>();
|
||||
_user = new ClaimsPrincipal();
|
||||
_userService.GetProperUserId(_user).Returns(_currentUserId);
|
||||
_sendOwnerQuery = new SendOwnerQuery(_sendRepository, _featureService, _userService);
|
||||
_sendOwnerQuery = new SendOwnerQuery(_sendRepository, _userService);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -84,7 +82,7 @@ public class SendOwnerQueryTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOwned_WithFeatureFlagEnabled_ReturnsAllSends()
|
||||
public async Task GetOwned_ReturnsAllSendsIncludingEmailOTP()
|
||||
{
|
||||
// Arrange
|
||||
var sends = new List<Send>
|
||||
@@ -94,7 +92,6 @@ public class SendOwnerQueryTests
|
||||
CreateSend(Guid.NewGuid(), _currentUserId, emails: "other@example.com")
|
||||
};
|
||||
_sendRepository.GetManyByUserIdAsync(_currentUserId).Returns(sends);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM19051_ListEmailOtpSends).Returns(true);
|
||||
|
||||
// Act
|
||||
var result = await _sendOwnerQuery.GetOwned(_user);
|
||||
@@ -105,28 +102,6 @@ public class SendOwnerQueryTests
|
||||
Assert.Contains(sends[1], result);
|
||||
Assert.Contains(sends[2], result);
|
||||
await _sendRepository.Received(1).GetManyByUserIdAsync(_currentUserId);
|
||||
_featureService.Received(1).IsEnabled(FeatureFlagKeys.PM19051_ListEmailOtpSends);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOwned_WithFeatureFlagDisabled_FiltersOutEmailOtpSends()
|
||||
{
|
||||
// Arrange
|
||||
var sendWithoutEmails = CreateSend(Guid.NewGuid(), _currentUserId, emails: null);
|
||||
var sendWithEmails = CreateSend(Guid.NewGuid(), _currentUserId, emails: "test@example.com");
|
||||
var sends = new List<Send> { sendWithoutEmails, sendWithEmails };
|
||||
_sendRepository.GetManyByUserIdAsync(_currentUserId).Returns(sends);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM19051_ListEmailOtpSends).Returns(false);
|
||||
|
||||
// Act
|
||||
var result = await _sendOwnerQuery.GetOwned(_user);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Contains(sendWithoutEmails, result);
|
||||
Assert.DoesNotContain(sendWithEmails, result);
|
||||
await _sendRepository.Received(1).GetManyByUserIdAsync(_currentUserId);
|
||||
_featureService.Received(1).IsEnabled(FeatureFlagKeys.PM19051_ListEmailOtpSends);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -147,7 +122,6 @@ public class SendOwnerQueryTests
|
||||
// Arrange
|
||||
var emptySends = new List<Send>();
|
||||
_sendRepository.GetManyByUserIdAsync(_currentUserId).Returns(emptySends);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM19051_ListEmailOtpSends).Returns(true);
|
||||
|
||||
// Act
|
||||
var result = await _sendOwnerQuery.GetOwned(_user);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Test.AutoFixture.Attributes;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Core.Vault.Entities;
|
||||
using Bit.Infrastructure.EFIntegration.Test.AutoFixture;
|
||||
using Bit.Infrastructure.EFIntegration.Test.Repositories.EqualityComparers;
|
||||
@@ -279,4 +280,92 @@ public class CipherRepositoryTests
|
||||
Assert.Equal(Core.Vault.Enums.CipherRepromptType.Password, savedCipher.Reprompt);
|
||||
}
|
||||
}
|
||||
|
||||
[CiSkippedTheory, EfUserCipherCustomize, BitAutoData]
|
||||
public async Task ArchiveAsync_SetsArchivesJsonAndBumpsUserAccountRevisionDate(
|
||||
Cipher cipher,
|
||||
User user,
|
||||
List<EfVaultRepo.CipherRepository> suts,
|
||||
List<EfRepo.UserRepository> efUserRepos)
|
||||
{
|
||||
foreach (var sut in suts)
|
||||
{
|
||||
var i = suts.IndexOf(sut);
|
||||
|
||||
var efUser = await efUserRepos[i].CreateAsync(user);
|
||||
efUserRepos[i].ClearChangeTracking();
|
||||
|
||||
cipher.UserId = efUser.Id;
|
||||
cipher.OrganizationId = null;
|
||||
|
||||
var createdCipher = await sut.CreateAsync(cipher);
|
||||
sut.ClearChangeTracking();
|
||||
|
||||
var archiveUtcNow = await sut.ArchiveAsync(new[] { createdCipher.Id }, efUser.Id);
|
||||
sut.ClearChangeTracking();
|
||||
|
||||
var savedCipher = await sut.GetByIdAsync(createdCipher.Id);
|
||||
Assert.NotNull(savedCipher);
|
||||
|
||||
Assert.Equal(archiveUtcNow, savedCipher.RevisionDate);
|
||||
|
||||
Assert.False(string.IsNullOrWhiteSpace(savedCipher.Archives));
|
||||
var archives = CoreHelpers.LoadClassFromJsonData<Dictionary<Guid, DateTime>>(savedCipher.Archives);
|
||||
Assert.NotNull(archives);
|
||||
Assert.True(archives.ContainsKey(efUser.Id));
|
||||
Assert.Equal(archiveUtcNow, archives[efUser.Id]);
|
||||
|
||||
var bumpedUser = await efUserRepos[i].GetByIdAsync(efUser.Id);
|
||||
Assert.Equal(DateTime.UtcNow.ToShortDateString(), bumpedUser.AccountRevisionDate.ToShortDateString());
|
||||
}
|
||||
}
|
||||
|
||||
[CiSkippedTheory, EfUserCipherCustomize, BitAutoData]
|
||||
public async Task UnarchiveAsync_RemovesUserFromArchivesJsonAndBumpsUserAccountRevisionDate(
|
||||
Cipher cipher,
|
||||
User user,
|
||||
List<EfVaultRepo.CipherRepository> suts,
|
||||
List<EfRepo.UserRepository> efUserRepos)
|
||||
{
|
||||
foreach (var sut in suts)
|
||||
{
|
||||
var i = suts.IndexOf(sut);
|
||||
|
||||
var efUser = await efUserRepos[i].CreateAsync(user);
|
||||
efUserRepos[i].ClearChangeTracking();
|
||||
|
||||
cipher.UserId = efUser.Id;
|
||||
cipher.OrganizationId = null;
|
||||
|
||||
var createdCipher = await sut.CreateAsync(cipher);
|
||||
sut.ClearChangeTracking();
|
||||
|
||||
// Precondition: archived
|
||||
await sut.ArchiveAsync(new[] { createdCipher.Id }, efUser.Id);
|
||||
sut.ClearChangeTracking();
|
||||
|
||||
var unarchiveUtcNow = await sut.UnarchiveAsync(new[] { createdCipher.Id }, efUser.Id);
|
||||
sut.ClearChangeTracking();
|
||||
|
||||
var savedCipher = await sut.GetByIdAsync(createdCipher.Id);
|
||||
Assert.NotNull(savedCipher);
|
||||
|
||||
Assert.Equal(unarchiveUtcNow, savedCipher.RevisionDate);
|
||||
|
||||
// Archives should be null or not contain this user (repo clears string when map empty)
|
||||
if (!string.IsNullOrWhiteSpace(savedCipher.Archives))
|
||||
{
|
||||
var archives = CoreHelpers.LoadClassFromJsonData<Dictionary<Guid, DateTime>>(savedCipher.Archives)
|
||||
?? new Dictionary<Guid, DateTime>();
|
||||
Assert.False(archives.ContainsKey(efUser.Id));
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Null(savedCipher.Archives);
|
||||
}
|
||||
|
||||
var bumpedUser = await efUserRepos[i].GetByIdAsync(efUser.Id);
|
||||
Assert.Equal(DateTime.UtcNow.ToShortDateString(), bumpedUser.AccountRevisionDate.ToShortDateString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
CREATE OR ALTER PROCEDURE [dbo].[Cipher_Unarchive]
|
||||
@Ids AS [dbo].[GuidIdArray] READONLY,
|
||||
@UserId AS UNIQUEIDENTIFIER
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
CREATE TABLE #Temp
|
||||
(
|
||||
[Id] UNIQUEIDENTIFIER NOT NULL,
|
||||
[UserId] UNIQUEIDENTIFIER NULL
|
||||
)
|
||||
|
||||
INSERT INTO #Temp
|
||||
SELECT
|
||||
ucd.[Id],
|
||||
ucd.[UserId]
|
||||
FROM
|
||||
[dbo].[UserCipherDetails](@UserId) ucd
|
||||
INNER JOIN @Ids ids ON ids.Id = ucd.[Id]
|
||||
WHERE
|
||||
ucd.[ArchivedDate] IS NOT NULL
|
||||
|
||||
DECLARE @UtcNow DATETIME2(7) = SYSUTCDATETIME();
|
||||
UPDATE
|
||||
[dbo].[Cipher]
|
||||
SET
|
||||
[Archives] = JSON_MODIFY(
|
||||
COALESCE([Archives], N'{}'),
|
||||
CONCAT('$."', @UserId, '"'),
|
||||
NULL
|
||||
),
|
||||
[RevisionDate] = @UtcNow
|
||||
FROM [dbo].[Cipher] AS c
|
||||
INNER JOIN #Temp AS t
|
||||
ON t.[Id] = c.[Id];
|
||||
|
||||
EXEC [dbo].[User_BumpAccountRevisionDate] @UserId
|
||||
|
||||
DROP TABLE #Temp
|
||||
|
||||
SELECT @UtcNow
|
||||
END
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE [dbo].[Cipher_Archive]
|
||||
@Ids AS [dbo].[GuidIdArray] READONLY,
|
||||
@UserId AS UNIQUEIDENTIFIER
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
CREATE TABLE #Temp
|
||||
(
|
||||
[Id] UNIQUEIDENTIFIER NOT NULL,
|
||||
[UserId] UNIQUEIDENTIFIER NULL
|
||||
)
|
||||
|
||||
INSERT INTO #Temp
|
||||
SELECT
|
||||
ucd.[Id],
|
||||
ucd.[UserId]
|
||||
FROM
|
||||
[dbo].[UserCipherDetails](@UserId) ucd
|
||||
INNER JOIN @Ids ids ON ids.Id = ucd.[Id]
|
||||
WHERE
|
||||
ucd.[ArchivedDate] IS NULL
|
||||
|
||||
DECLARE @UtcNow DATETIME2(7) = SYSUTCDATETIME();
|
||||
UPDATE
|
||||
[dbo].[Cipher]
|
||||
SET
|
||||
[Archives] = JSON_MODIFY(
|
||||
COALESCE([Archives], N'{}'),
|
||||
CONCAT('$."', @UserId, '"'),
|
||||
CONVERT(NVARCHAR(30), @UtcNow, 127)
|
||||
),
|
||||
[RevisionDate] = @UtcNow
|
||||
FROM [dbo].[Cipher] AS c
|
||||
INNER JOIN #Temp AS t
|
||||
ON t.[Id] = c.[Id];
|
||||
|
||||
EXEC [dbo].[User_BumpAccountRevisionDate] @UserId
|
||||
|
||||
DROP TABLE #Temp
|
||||
|
||||
SELECT @UtcNow
|
||||
END
|
||||
GO
|
||||
Reference in New Issue
Block a user