1
0
mirror of https://github.com/bitwarden/server.git synced 2025-04-04 20:50:21 -05:00

[PM-10563] Notification Center API (#4852)

* PM-10563: Notification Center API

* PM-10563: continuation token hack

* PM-10563: Resolving merge conflicts

* PM-10563: Unit Tests

* PM-10563: Paging simplification by page number and size in database

* PM-10563: Request validation

* PM-10563: Read, Deleted status filters change

* PM-10563: Plural name for tests

* PM-10563: Request validation to always for int type

* PM-10563: Continuation Token returns null on response when no more records available

* PM-10563: Integration tests for GET

* PM-10563: Mark notification read, deleted commands date typos fix

* PM-10563: Integration tests for PATCH read, deleted

* PM-10563: Request, Response models tests

* PM-10563: EditorConfig compliance

* PM-10563: Extracting to const

* PM-10563: Update db migration script date

* PM-10563: Update migration script date
This commit is contained in:
Maciej Zieniuk 2024-12-18 15:59:50 +01:00 committed by GitHub
parent de2dc243fc
commit 21fcfcd5e8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1272 additions and 39 deletions

View File

@ -0,0 +1,71 @@
#nullable enable
using Bit.Api.Models.Response;
using Bit.Api.NotificationCenter.Models.Request;
using Bit.Api.NotificationCenter.Models.Response;
using Bit.Core.Models.Data;
using Bit.Core.NotificationCenter.Commands.Interfaces;
using Bit.Core.NotificationCenter.Models.Filter;
using Bit.Core.NotificationCenter.Queries.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.NotificationCenter.Controllers;
[Route("notifications")]
[Authorize("Application")]
public class NotificationsController : Controller
{
private readonly IGetNotificationStatusDetailsForUserQuery _getNotificationStatusDetailsForUserQuery;
private readonly IMarkNotificationDeletedCommand _markNotificationDeletedCommand;
private readonly IMarkNotificationReadCommand _markNotificationReadCommand;
public NotificationsController(
IGetNotificationStatusDetailsForUserQuery getNotificationStatusDetailsForUserQuery,
IMarkNotificationDeletedCommand markNotificationDeletedCommand,
IMarkNotificationReadCommand markNotificationReadCommand)
{
_getNotificationStatusDetailsForUserQuery = getNotificationStatusDetailsForUserQuery;
_markNotificationDeletedCommand = markNotificationDeletedCommand;
_markNotificationReadCommand = markNotificationReadCommand;
}
[HttpGet("")]
public async Task<ListResponseModel<NotificationResponseModel>> ListAsync(
[FromQuery] NotificationFilterRequestModel filter)
{
var pageOptions = new PageOptions
{
ContinuationToken = filter.ContinuationToken,
PageSize = filter.PageSize
};
var notificationStatusFilter = new NotificationStatusFilter
{
Read = filter.ReadStatusFilter,
Deleted = filter.DeletedStatusFilter
};
var notificationStatusDetailsPagedResult =
await _getNotificationStatusDetailsForUserQuery.GetByUserIdStatusFilterAsync(notificationStatusFilter,
pageOptions);
var responses = notificationStatusDetailsPagedResult.Data
.Select(n => new NotificationResponseModel(n))
.ToList();
return new ListResponseModel<NotificationResponseModel>(responses,
notificationStatusDetailsPagedResult.ContinuationToken);
}
[HttpPatch("{id}/delete")]
public async Task MarkAsDeletedAsync([FromRoute] Guid id)
{
await _markNotificationDeletedCommand.MarkDeletedAsync(id);
}
[HttpPatch("{id}/read")]
public async Task MarkAsReadAsync([FromRoute] Guid id)
{
await _markNotificationReadCommand.MarkReadAsync(id);
}
}

View File

@ -0,0 +1,41 @@
#nullable enable
using System.ComponentModel.DataAnnotations;
namespace Bit.Api.NotificationCenter.Models.Request;
public class NotificationFilterRequestModel : IValidatableObject
{
/// <summary>
/// Filters notifications by read status. When not set, includes notifications without a status.
/// </summary>
public bool? ReadStatusFilter { get; set; }
/// <summary>
/// Filters notifications by deleted status. When not set, includes notifications without a status.
/// </summary>
public bool? DeletedStatusFilter { get; set; }
/// <summary>
/// A cursor for use in pagination.
/// </summary>
[StringLength(9)]
public string? ContinuationToken { get; set; }
/// <summary>
/// The number of items to return in a single page.
/// Default 10. Minimum 10, maximum 1000.
/// </summary>
[Range(10, 1000)]
public int PageSize { get; set; } = 10;
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (!string.IsNullOrWhiteSpace(ContinuationToken) &&
(!int.TryParse(ContinuationToken, out var pageNumber) || pageNumber <= 0))
{
yield return new ValidationResult(
"Continuation token must be a positive, non zero integer.",
[nameof(ContinuationToken)]);
}
}
}

View File

@ -0,0 +1,46 @@
#nullable enable
using Bit.Core.Models.Api;
using Bit.Core.NotificationCenter.Enums;
using Bit.Core.NotificationCenter.Models.Data;
namespace Bit.Api.NotificationCenter.Models.Response;
public class NotificationResponseModel : ResponseModel
{
private const string _objectName = "notification";
public NotificationResponseModel(NotificationStatusDetails notificationStatusDetails, string obj = _objectName)
: base(obj)
{
if (notificationStatusDetails == null)
{
throw new ArgumentNullException(nameof(notificationStatusDetails));
}
Id = notificationStatusDetails.Id;
Priority = notificationStatusDetails.Priority;
Title = notificationStatusDetails.Title;
Body = notificationStatusDetails.Body;
Date = notificationStatusDetails.RevisionDate;
ReadDate = notificationStatusDetails.ReadDate;
DeletedDate = notificationStatusDetails.DeletedDate;
}
public NotificationResponseModel() : base(_objectName)
{
}
public Guid Id { get; set; }
public Priority Priority { get; set; }
public string? Title { get; set; }
public string? Body { get; set; }
public DateTime Date { get; set; }
public DateTime? ReadDate { get; set; }
public DateTime? DeletedDate { get; set; }
}

View File

@ -0,0 +1,29 @@
#nullable enable
using Bit.Core.NotificationCenter.Authorization;
using Bit.Core.NotificationCenter.Commands;
using Bit.Core.NotificationCenter.Commands.Interfaces;
using Bit.Core.NotificationCenter.Queries;
using Bit.Core.NotificationCenter.Queries.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Core.NotificationCenter;
public static class NotificationCenterServiceCollectionExtensions
{
public static void AddNotificationCenterServices(this IServiceCollection services)
{
// Authorization Handlers
services.AddScoped<IAuthorizationHandler, NotificationAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, NotificationStatusAuthorizationHandler>();
// Commands
services.AddScoped<ICreateNotificationCommand, CreateNotificationCommand>();
services.AddScoped<ICreateNotificationStatusCommand, CreateNotificationStatusCommand>();
services.AddScoped<IMarkNotificationDeletedCommand, MarkNotificationDeletedCommand>();
services.AddScoped<IMarkNotificationReadCommand, MarkNotificationReadCommand>();
services.AddScoped<IUpdateNotificationCommand, UpdateNotificationCommand>();
// Queries
services.AddScoped<IGetNotificationStatusDetailsForUserQuery, GetNotificationStatusDetailsForUserQuery>();
services.AddScoped<IGetNotificationStatusForUserQuery, GetNotificationStatusForUserQuery>();
}
}

View File

@ -1,6 +1,7 @@
#nullable enable #nullable enable
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Models.Data;
using Bit.Core.NotificationCenter.Models.Data; using Bit.Core.NotificationCenter.Models.Data;
using Bit.Core.NotificationCenter.Models.Filter; using Bit.Core.NotificationCenter.Models.Filter;
using Bit.Core.NotificationCenter.Queries.Interfaces; using Bit.Core.NotificationCenter.Queries.Interfaces;
@ -21,8 +22,8 @@ public class GetNotificationStatusDetailsForUserQuery : IGetNotificationStatusDe
_notificationRepository = notificationRepository; _notificationRepository = notificationRepository;
} }
public async Task<IEnumerable<NotificationStatusDetails>> GetByUserIdStatusFilterAsync( public async Task<PagedResult<NotificationStatusDetails>> GetByUserIdStatusFilterAsync(
NotificationStatusFilter statusFilter) NotificationStatusFilter statusFilter, PageOptions pageOptions)
{ {
if (!_currentContext.UserId.HasValue) if (!_currentContext.UserId.HasValue)
{ {
@ -33,6 +34,6 @@ public class GetNotificationStatusDetailsForUserQuery : IGetNotificationStatusDe
// Note: only returns the user's notifications - no authorization check needed // Note: only returns the user's notifications - no authorization check needed
return await _notificationRepository.GetByUserIdAndStatusAsync(_currentContext.UserId.Value, clientType, return await _notificationRepository.GetByUserIdAndStatusAsync(_currentContext.UserId.Value, clientType,
statusFilter); statusFilter, pageOptions);
} }
} }

View File

@ -1,4 +1,5 @@
#nullable enable #nullable enable
using Bit.Core.Models.Data;
using Bit.Core.NotificationCenter.Models.Data; using Bit.Core.NotificationCenter.Models.Data;
using Bit.Core.NotificationCenter.Models.Filter; using Bit.Core.NotificationCenter.Models.Filter;
@ -6,5 +7,6 @@ namespace Bit.Core.NotificationCenter.Queries.Interfaces;
public interface IGetNotificationStatusDetailsForUserQuery public interface IGetNotificationStatusDetailsForUserQuery
{ {
Task<IEnumerable<NotificationStatusDetails>> GetByUserIdStatusFilterAsync(NotificationStatusFilter statusFilter); Task<PagedResult<NotificationStatusDetails>> GetByUserIdStatusFilterAsync(NotificationStatusFilter statusFilter,
PageOptions pageOptions);
} }

View File

@ -1,5 +1,6 @@
#nullable enable #nullable enable
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.NotificationCenter.Entities; using Bit.Core.NotificationCenter.Entities;
using Bit.Core.NotificationCenter.Models.Data; using Bit.Core.NotificationCenter.Models.Data;
using Bit.Core.NotificationCenter.Models.Filter; using Bit.Core.NotificationCenter.Models.Filter;
@ -22,10 +23,13 @@ public interface INotificationRepository : IRepository<Notification, Guid>
/// If both <see cref="NotificationStatusFilter.Read"/> and <see cref="NotificationStatusFilter.Deleted"/> /// If both <see cref="NotificationStatusFilter.Read"/> and <see cref="NotificationStatusFilter.Deleted"/>
/// are not set, includes notifications without a status. /// are not set, includes notifications without a status.
/// </param> /// </param>
/// <param name="pageOptions">
/// Pagination options.
/// </param>
/// <returns> /// <returns>
/// Ordered by priority (highest to lowest) and creation date (descending). /// Paged results ordered by priority (descending, highest to lowest) and creation date (descending).
/// Includes all fields from <see cref="Notification"/> and <see cref="NotificationStatus"/> /// Includes all fields from <see cref="Notification"/> and <see cref="NotificationStatus"/>
/// </returns> /// </returns>
Task<IEnumerable<NotificationStatusDetails>> GetByUserIdAndStatusAsync(Guid userId, ClientType clientType, Task<PagedResult<NotificationStatusDetails>> GetByUserIdAndStatusAsync(Guid userId, ClientType clientType,
NotificationStatusFilter? statusFilter); NotificationStatusFilter? statusFilter, PageOptions pageOptions);
} }

View File

@ -1,6 +1,7 @@
#nullable enable #nullable enable
using System.Data; using System.Data;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.NotificationCenter.Entities; using Bit.Core.NotificationCenter.Entities;
using Bit.Core.NotificationCenter.Models.Data; using Bit.Core.NotificationCenter.Models.Data;
using Bit.Core.NotificationCenter.Models.Filter; using Bit.Core.NotificationCenter.Models.Filter;
@ -24,16 +25,35 @@ public class NotificationRepository : Repository<Notification, Guid>, INotificat
{ {
} }
public async Task<IEnumerable<NotificationStatusDetails>> GetByUserIdAndStatusAsync(Guid userId, public async Task<PagedResult<NotificationStatusDetails>> GetByUserIdAndStatusAsync(Guid userId,
ClientType clientType, NotificationStatusFilter? statusFilter) ClientType clientType, NotificationStatusFilter? statusFilter, PageOptions pageOptions)
{ {
await using var connection = new SqlConnection(ConnectionString); await using var connection = new SqlConnection(ConnectionString);
if (!int.TryParse(pageOptions.ContinuationToken, out var pageNumber))
{
pageNumber = 1;
}
var results = await connection.QueryAsync<NotificationStatusDetails>( var results = await connection.QueryAsync<NotificationStatusDetails>(
"[dbo].[Notification_ReadByUserIdAndStatus]", "[dbo].[Notification_ReadByUserIdAndStatus]",
new { UserId = userId, ClientType = clientType, statusFilter?.Read, statusFilter?.Deleted }, new
{
UserId = userId,
ClientType = clientType,
statusFilter?.Read,
statusFilter?.Deleted,
PageNumber = pageNumber,
pageOptions.PageSize
},
commandType: CommandType.StoredProcedure); commandType: CommandType.StoredProcedure);
return results.ToList(); var data = results.ToList();
return new PagedResult<NotificationStatusDetails>
{
Data = data,
ContinuationToken = data.Count < pageOptions.PageSize ? null : (pageNumber + 1).ToString()
};
} }
} }

View File

@ -1,6 +1,7 @@
#nullable enable #nullable enable
using AutoMapper; using AutoMapper;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.NotificationCenter.Models.Data; using Bit.Core.NotificationCenter.Models.Data;
using Bit.Core.NotificationCenter.Models.Filter; using Bit.Core.NotificationCenter.Models.Filter;
using Bit.Core.NotificationCenter.Repositories; using Bit.Core.NotificationCenter.Repositories;
@ -36,28 +37,41 @@ public class NotificationRepository : Repository<Core.NotificationCenter.Entitie
return Mapper.Map<List<Core.NotificationCenter.Entities.Notification>>(notifications); return Mapper.Map<List<Core.NotificationCenter.Entities.Notification>>(notifications);
} }
public async Task<IEnumerable<NotificationStatusDetails>> GetByUserIdAndStatusAsync(Guid userId, public async Task<PagedResult<NotificationStatusDetails>> GetByUserIdAndStatusAsync(Guid userId,
ClientType clientType, NotificationStatusFilter? statusFilter) ClientType clientType, NotificationStatusFilter? statusFilter, PageOptions pageOptions)
{ {
await using var scope = ServiceScopeFactory.CreateAsyncScope(); await using var scope = ServiceScopeFactory.CreateAsyncScope();
var dbContext = GetDatabaseContext(scope); var dbContext = GetDatabaseContext(scope);
if (!int.TryParse(pageOptions.ContinuationToken, out var pageNumber))
{
pageNumber = 1;
}
var notificationStatusDetailsViewQuery = new NotificationStatusDetailsViewQuery(userId, clientType); var notificationStatusDetailsViewQuery = new NotificationStatusDetailsViewQuery(userId, clientType);
var query = notificationStatusDetailsViewQuery.Run(dbContext); var query = notificationStatusDetailsViewQuery.Run(dbContext);
if (statusFilter != null && (statusFilter.Read != null || statusFilter.Deleted != null)) if (statusFilter != null && (statusFilter.Read != null || statusFilter.Deleted != null))
{ {
query = from n in query query = from n in query
where statusFilter.Read == null || where (statusFilter.Read == null ||
(statusFilter.Read == true ? n.ReadDate != null : n.ReadDate == null) || (statusFilter.Read == true ? n.ReadDate != null : n.ReadDate == null)) &&
statusFilter.Deleted == null || (statusFilter.Deleted == null ||
(statusFilter.Deleted == true ? n.DeletedDate != null : n.DeletedDate == null) (statusFilter.Deleted == true ? n.DeletedDate != null : n.DeletedDate == null))
select n; select n;
} }
return await query var results = await query
.OrderByDescending(n => n.Priority) .OrderByDescending(n => n.Priority)
.ThenByDescending(n => n.CreationDate) .ThenByDescending(n => n.CreationDate)
.Skip(pageOptions.PageSize * (pageNumber - 1))
.Take(pageOptions.PageSize)
.ToListAsync(); .ToListAsync();
return new PagedResult<NotificationStatusDetails>
{
Data = results,
ContinuationToken = results.Count < pageOptions.PageSize ? null : (pageNumber + 1).ToString()
};
} }
} }

View File

@ -27,6 +27,7 @@ using Bit.Core.HostedServices;
using Bit.Core.Identity; using Bit.Core.Identity;
using Bit.Core.IdentityServer; using Bit.Core.IdentityServer;
using Bit.Core.KeyManagement; using Bit.Core.KeyManagement;
using Bit.Core.NotificationCenter;
using Bit.Core.NotificationHub; using Bit.Core.NotificationHub;
using Bit.Core.OrganizationFeatures; using Bit.Core.OrganizationFeatures;
using Bit.Core.Repositories; using Bit.Core.Repositories;
@ -122,6 +123,7 @@ public static class ServiceCollectionExtensions
services.AddVaultServices(); services.AddVaultServices();
services.AddReportingServices(); services.AddReportingServices();
services.AddKeyManagementServices(); services.AddKeyManagementServices();
services.AddNotificationCenterServices();
} }
public static void AddTokenizers(this IServiceCollection services) public static void AddTokenizers(this IServiceCollection services)

View File

@ -2,7 +2,9 @@ CREATE PROCEDURE [dbo].[Notification_ReadByUserIdAndStatus]
@UserId UNIQUEIDENTIFIER, @UserId UNIQUEIDENTIFIER,
@ClientType TINYINT, @ClientType TINYINT,
@Read BIT, @Read BIT,
@Deleted BIT @Deleted BIT,
@PageNumber INT = 1,
@PageSize INT = 10
AS AS
BEGIN BEGIN
SET NOCOUNT ON SET NOCOUNT ON
@ -21,13 +23,14 @@ BEGIN
AND ou.[OrganizationId] IS NOT NULL)) AND ou.[OrganizationId] IS NOT NULL))
AND ((@Read IS NULL AND @Deleted IS NULL) AND ((@Read IS NULL AND @Deleted IS NULL)
OR (n.[NotificationStatusUserId] IS NOT NULL OR (n.[NotificationStatusUserId] IS NOT NULL
AND ((@Read IS NULL AND (@Read IS NULL
OR IIF((@Read = 1 AND n.[ReadDate] IS NOT NULL) OR OR IIF((@Read = 1 AND n.[ReadDate] IS NOT NULL) OR
(@Read = 0 AND n.[ReadDate] IS NULL), (@Read = 0 AND n.[ReadDate] IS NULL),
1, 0) = 1) 1, 0) = 1)
OR (@Deleted IS NULL AND (@Deleted IS NULL
OR IIF((@Deleted = 1 AND n.[DeletedDate] IS NOT NULL) OR OR IIF((@Deleted = 1 AND n.[DeletedDate] IS NOT NULL) OR
(@Deleted = 0 AND n.[DeletedDate] IS NULL), (@Deleted = 0 AND n.[DeletedDate] IS NULL),
1, 0) = 1)))) 1, 0) = 1)))
ORDER BY [Priority] DESC, n.[CreationDate] DESC ORDER BY [Priority] DESC, n.[CreationDate] DESC
OFFSET @PageSize * (@PageNumber - 1) ROWS FETCH NEXT @PageSize ROWS ONLY
END END

View File

@ -0,0 +1,582 @@
using System.Net;
using Bit.Api.IntegrationTest.Factories;
using Bit.Api.IntegrationTest.Helpers;
using Bit.Api.Models.Response;
using Bit.Api.NotificationCenter.Models.Response;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Api;
using Bit.Core.NotificationCenter.Entities;
using Bit.Core.NotificationCenter.Enums;
using Bit.Core.NotificationCenter.Repositories;
using Bit.Core.Repositories;
using Xunit;
namespace Bit.Api.IntegrationTest.NotificationCenter.Controllers;
public class NotificationsControllerTests : IClassFixture<ApiApplicationFactory>, IAsyncLifetime
{
private static readonly string _mockEncryptedBody =
"2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk=";
private static readonly string _mockEncryptedTitle =
"2.06CDSJjTZaigYHUuswIq5A==|trxgZl2RCkYrrmCvGE9WNA==|w5p05eI5wsaYeSyWtsAPvBX63vj798kIMxBTfSB0BQg=";
private static readonly Random _random = new();
private static TimeSpan OneMinuteTimeSpan => TimeSpan.FromMinutes(1);
private readonly HttpClient _client;
private readonly ApiApplicationFactory _factory;
private readonly LoginHelper _loginHelper;
private readonly INotificationRepository _notificationRepository;
private readonly INotificationStatusRepository _notificationStatusRepository;
private readonly IUserRepository _userRepository;
private Organization _organization = null!;
private OrganizationUser _organizationUserOwner = null!;
private string _ownerEmail = null!;
private List<(Notification, NotificationStatus?)> _notificationsWithStatuses = null!;
public NotificationsControllerTests(ApiApplicationFactory factory)
{
_factory = factory;
_client = factory.CreateClient();
_loginHelper = new LoginHelper(_factory, _client);
_notificationRepository = _factory.GetService<INotificationRepository>();
_notificationStatusRepository = _factory.GetService<INotificationStatusRepository>();
_userRepository = _factory.GetService<IUserRepository>();
}
public async Task InitializeAsync()
{
// Create the owner account
_ownerEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com";
await _factory.LoginWithNewAccount(_ownerEmail);
// Create the organization
(_organization, _organizationUserOwner) = await OrganizationTestHelpers.SignUpAsync(_factory,
plan: PlanType.EnterpriseAnnually, ownerEmail: _ownerEmail, passwordManagerSeats: 10,
paymentMethod: PaymentMethodType.Card);
_notificationsWithStatuses = await CreateNotificationsWithStatusesAsync();
}
public Task DisposeAsync()
{
_client.Dispose();
foreach (var (notification, _) in _notificationsWithStatuses)
{
_notificationRepository.DeleteAsync(notification);
}
return Task.CompletedTask;
}
[Theory]
[InlineData("invalid")]
[InlineData("-1")]
[InlineData("0")]
public async Task ListAsync_RequestValidationContinuationInvalidNumber_BadRequest(string continuationToken)
{
await _loginHelper.LoginAsync(_ownerEmail);
var response = await _client.GetAsync($"/notifications?continuationToken={continuationToken}");
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<ErrorResponseModel>();
Assert.NotNull(result);
Assert.Contains("ContinuationToken", result.ValidationErrors);
Assert.Contains("Continuation token must be a positive, non zero integer.",
result.ValidationErrors["ContinuationToken"]);
}
[Fact]
public async Task ListAsync_RequestValidationContinuationTokenMaxLengthExceeded_BadRequest()
{
await _loginHelper.LoginAsync(_ownerEmail);
var response = await _client.GetAsync("/notifications?continuationToken=1234567890");
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<ErrorResponseModel>();
Assert.NotNull(result);
Assert.Contains("ContinuationToken", result.ValidationErrors);
Assert.Contains("The field ContinuationToken must be a string with a maximum length of 9.",
result.ValidationErrors["ContinuationToken"]);
}
[Theory]
[InlineData("9")]
[InlineData("1001")]
public async Task ListAsync_RequestValidationPageSizeInvalidRange_BadRequest(string pageSize)
{
await _loginHelper.LoginAsync(_ownerEmail);
var response = await _client.GetAsync($"/notifications?pageSize={pageSize}");
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<ErrorResponseModel>();
Assert.NotNull(result);
Assert.Contains("PageSize", result.ValidationErrors);
Assert.Contains("The field PageSize must be between 10 and 1000.",
result.ValidationErrors["PageSize"]);
}
[Fact]
public async Task ListAsync_NotLoggedIn_Unauthorized()
{
var response = await _client.GetAsync("/notifications");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Theory]
[InlineData(null, null, "2", 10)]
[InlineData(10, null, "2", 10)]
[InlineData(10, 2, "3", 10)]
[InlineData(10, 3, null, 0)]
[InlineData(15, null, "2", 15)]
[InlineData(15, 2, null, 5)]
[InlineData(20, null, "2", 20)]
[InlineData(20, 2, null, 0)]
[InlineData(1000, null, null, 20)]
public async Task ListAsync_PaginationFilter_ReturnsNextPageOfNotificationsCorrectOrder(
int? pageSize, int? pageNumber, string? expectedContinuationToken, int expectedCount)
{
var pageSizeWithDefault = pageSize ?? 10;
await _loginHelper.LoginAsync(_ownerEmail);
var skip = pageNumber == null ? 0 : (pageNumber.Value - 1) * pageSizeWithDefault;
var notificationsInOrder = _notificationsWithStatuses.OrderByDescending(e => e.Item1.Priority)
.ThenByDescending(e => e.Item1.CreationDate)
.Skip(skip)
.Take(pageSizeWithDefault)
.ToList();
var url = "/notifications";
if (pageNumber != null)
{
url += $"?continuationToken={pageNumber}";
}
if (pageSize != null)
{
url += url.Contains('?') ? "&" : "?";
url += $"pageSize={pageSize}";
}
var response = await _client.GetAsync(url);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<ListResponseModel<NotificationResponseModel>>();
Assert.NotNull(result?.Data);
Assert.InRange(result.Data.Count(), 0, pageSizeWithDefault);
Assert.Equal(expectedCount, notificationsInOrder.Count);
Assert.Equal(notificationsInOrder.Count, result.Data.Count());
AssertNotificationResponseModels(result.Data, notificationsInOrder);
Assert.Equal(expectedContinuationToken, result.ContinuationToken);
}
[Theory]
[InlineData(null, null)]
[InlineData(null, false)]
[InlineData(null, true)]
[InlineData(false, null)]
[InlineData(true, null)]
[InlineData(false, false)]
[InlineData(false, true)]
[InlineData(true, false)]
[InlineData(true, true)]
public async Task ListAsync_ReadStatusDeletedStatusFilter_ReturnsFilteredNotificationsCorrectOrder(
bool? readStatusFilter, bool? deletedStatusFilter)
{
await _loginHelper.LoginAsync(_ownerEmail);
var notificationsInOrder = _notificationsWithStatuses.FindAll(e =>
(readStatusFilter == null || readStatusFilter == (e.Item2?.ReadDate != null)) &&
(deletedStatusFilter == null || deletedStatusFilter == (e.Item2?.DeletedDate != null)))
.OrderByDescending(e => e.Item1.Priority)
.ThenByDescending(e => e.Item1.CreationDate)
.Take(10)
.ToList();
var url = "/notifications";
if (readStatusFilter != null)
{
url += $"?readStatusFilter={readStatusFilter}";
}
if (deletedStatusFilter != null)
{
url += url.Contains('?') ? "&" : "?";
url += $"deletedStatusFilter={deletedStatusFilter}";
}
var response = await _client.GetAsync(url);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<ListResponseModel<NotificationResponseModel>>();
Assert.NotNull(result?.Data);
Assert.InRange(result.Data.Count(), 0, 10);
Assert.Equal(notificationsInOrder.Count, result.Data.Count());
AssertNotificationResponseModels(result.Data, notificationsInOrder);
}
[Fact]
private async void MarkAsDeletedAsync_NotLoggedIn_Unauthorized()
{
var url = $"/notifications/{Guid.NewGuid().ToString()}/delete";
var response = await _client.PatchAsync(url, new StringContent(""));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
private async void MarkAsDeletedAsync_NonExistentNotificationId_NotFound()
{
await _loginHelper.LoginAsync(_ownerEmail);
var url = $"/notifications/{Guid.NewGuid()}/delete";
var response = await _client.PatchAsync(url, new StringContent(""));
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
private async void MarkAsDeletedAsync_UserIdNotMatching_NotFound()
{
var email = $"integration-test{Guid.NewGuid()}@bitwarden.com";
await _factory.LoginWithNewAccount(email);
var user = (await _userRepository.GetByEmailAsync(email))!;
var notifications = await CreateNotificationsAsync(user.Id);
await _loginHelper.LoginAsync(_ownerEmail);
var url = $"/notifications/{notifications[0].Id}/delete";
var response = await _client.PatchAsync(url, new StringContent(""));
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
private async void MarkAsDeletedAsync_OrganizationIdNotMatchingUserNotPartOfOrganization_NotFound()
{
var email = $"integration-test{Guid.NewGuid()}@bitwarden.com";
await _factory.LoginWithNewAccount(email);
var user = (await _userRepository.GetByEmailAsync(email))!;
var notifications = await CreateNotificationsAsync(user.Id, _organization.Id);
await _loginHelper.LoginAsync(email);
var url = $"/notifications/{notifications[0].Id}/delete";
var response = await _client.PatchAsync(url, new StringContent(""));
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
private async void MarkAsDeletedAsync_OrganizationIdNotMatchingUserPartOfDifferentOrganization_NotFound()
{
var (organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory,
plan: PlanType.EnterpriseAnnually, ownerEmail: _ownerEmail, passwordManagerSeats: 10,
paymentMethod: PaymentMethodType.Card);
var email = $"integration-test{Guid.NewGuid()}@bitwarden.com";
await _factory.LoginWithNewAccount(email);
var user = (await _userRepository.GetByEmailAsync(email))!;
await OrganizationTestHelpers.CreateUserAsync(_factory, organization.Id, email, OrganizationUserType.User);
var notifications = await CreateNotificationsAsync(user.Id, _organization.Id);
await _loginHelper.LoginAsync(email);
var url = $"/notifications/{notifications[0].Id}/delete";
var response = await _client.PatchAsync(url, new StringContent(""));
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
private async void MarkAsDeletedAsync_NotificationStatusNotExisting_Created()
{
var notifications = await CreateNotificationsAsync(_organizationUserOwner.UserId);
await _loginHelper.LoginAsync(_ownerEmail);
var url = $"/notifications/{notifications[0].Id}/delete";
var response = await _client.PatchAsync(url, new StringContent(""));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var notificationStatus = await _notificationStatusRepository.GetByNotificationIdAndUserIdAsync(
notifications[0].Id, _organizationUserOwner.UserId!.Value);
Assert.NotNull(notificationStatus);
Assert.NotNull(notificationStatus.DeletedDate);
Assert.Equal(DateTime.UtcNow, notificationStatus.DeletedDate.Value, OneMinuteTimeSpan);
Assert.Null(notificationStatus.ReadDate);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
private async void MarkAsDeletedAsync_NotificationStatusExisting_Updated(bool deletedDateNull)
{
var notifications = await CreateNotificationsAsync(_organizationUserOwner.UserId);
await _notificationStatusRepository.CreateAsync(new NotificationStatus
{
NotificationId = notifications[0].Id,
UserId = _organizationUserOwner.UserId!.Value,
ReadDate = null,
DeletedDate = deletedDateNull ? null : DateTime.UtcNow - TimeSpan.FromMinutes(_random.Next(3600))
});
await _loginHelper.LoginAsync(_ownerEmail);
var url = $"/notifications/{notifications[0].Id}/delete";
var response = await _client.PatchAsync(url, new StringContent(""));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var notificationStatus = await _notificationStatusRepository.GetByNotificationIdAndUserIdAsync(
notifications[0].Id, _organizationUserOwner.UserId!.Value);
Assert.NotNull(notificationStatus);
Assert.NotNull(notificationStatus.DeletedDate);
Assert.Equal(DateTime.UtcNow, notificationStatus.DeletedDate.Value, OneMinuteTimeSpan);
Assert.Null(notificationStatus.ReadDate);
}
[Fact]
private async void MarkAsReadAsync_NotLoggedIn_Unauthorized()
{
var url = $"/notifications/{Guid.NewGuid().ToString()}/read";
var response = await _client.PatchAsync(url, new StringContent(""));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
private async void MarkAsReadAsync_NonExistentNotificationId_NotFound()
{
await _loginHelper.LoginAsync(_ownerEmail);
var url = $"/notifications/{Guid.NewGuid()}/read";
var response = await _client.PatchAsync(url, new StringContent(""));
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
private async void MarkAsReadAsync_UserIdNotMatching_NotFound()
{
var email = $"integration-test{Guid.NewGuid()}@bitwarden.com";
await _factory.LoginWithNewAccount(email);
var user = (await _userRepository.GetByEmailAsync(email))!;
var notifications = await CreateNotificationsAsync(user.Id);
await _loginHelper.LoginAsync(_ownerEmail);
var url = $"/notifications/{notifications[0].Id}/read";
var response = await _client.PatchAsync(url, new StringContent(""));
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
private async void MarkAsReadAsync_OrganizationIdNotMatchingUserNotPartOfOrganization_NotFound()
{
var email = $"integration-test{Guid.NewGuid()}@bitwarden.com";
await _factory.LoginWithNewAccount(email);
var user = (await _userRepository.GetByEmailAsync(email))!;
var notifications = await CreateNotificationsAsync(user.Id, _organization.Id);
await _loginHelper.LoginAsync(email);
var url = $"/notifications/{notifications[0].Id}/read";
var response = await _client.PatchAsync(url, new StringContent(""));
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
private async void MarkAsReadAsync_OrganizationIdNotMatchingUserPartOfDifferentOrganization_NotFound()
{
var (organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory,
plan: PlanType.EnterpriseAnnually, ownerEmail: _ownerEmail, passwordManagerSeats: 10,
paymentMethod: PaymentMethodType.Card);
var email = $"integration-test{Guid.NewGuid()}@bitwarden.com";
await _factory.LoginWithNewAccount(email);
var user = (await _userRepository.GetByEmailAsync(email))!;
await OrganizationTestHelpers.CreateUserAsync(_factory, organization.Id, email, OrganizationUserType.User);
var notifications = await CreateNotificationsAsync(user.Id, _organization.Id);
await _loginHelper.LoginAsync(email);
var url = $"/notifications/{notifications[0].Id}/read";
var response = await _client.PatchAsync(url, new StringContent(""));
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
private async void MarkAsReadAsync_NotificationStatusNotExisting_Created()
{
var notifications = await CreateNotificationsAsync(_organizationUserOwner.UserId);
await _loginHelper.LoginAsync(_ownerEmail);
var url = $"/notifications/{notifications[0].Id}/read";
var response = await _client.PatchAsync(url, new StringContent(""));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var notificationStatus = await _notificationStatusRepository.GetByNotificationIdAndUserIdAsync(
notifications[0].Id, _organizationUserOwner.UserId!.Value);
Assert.NotNull(notificationStatus);
Assert.NotNull(notificationStatus.ReadDate);
Assert.Equal(DateTime.UtcNow, notificationStatus.ReadDate.Value, OneMinuteTimeSpan);
Assert.Null(notificationStatus.DeletedDate);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
private async void MarkAsReadAsync_NotificationStatusExisting_Updated(bool readDateNull)
{
var notifications = await CreateNotificationsAsync(_organizationUserOwner.UserId);
await _notificationStatusRepository.CreateAsync(new NotificationStatus
{
NotificationId = notifications[0].Id,
UserId = _organizationUserOwner.UserId!.Value,
ReadDate = readDateNull ? null : DateTime.UtcNow - TimeSpan.FromMinutes(_random.Next(3600)),
DeletedDate = null
});
await _loginHelper.LoginAsync(_ownerEmail);
var url = $"/notifications/{notifications[0].Id}/read";
var response = await _client.PatchAsync(url, new StringContent(""));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var notificationStatus = await _notificationStatusRepository.GetByNotificationIdAndUserIdAsync(
notifications[0].Id, _organizationUserOwner.UserId!.Value);
Assert.NotNull(notificationStatus);
Assert.NotNull(notificationStatus.ReadDate);
Assert.Equal(DateTime.UtcNow, notificationStatus.ReadDate.Value, OneMinuteTimeSpan);
Assert.Null(notificationStatus.DeletedDate);
}
private static void AssertNotificationResponseModels(
IEnumerable<NotificationResponseModel> notificationResponseModels,
List<(Notification, NotificationStatus?)> expectedNotificationsWithStatuses)
{
var i = 0;
foreach (var notificationResponseModel in notificationResponseModels)
{
Assert.Contains(expectedNotificationsWithStatuses, e => e.Item1.Id == notificationResponseModel.Id);
var (expectedNotification, expectedNotificationStatus) = expectedNotificationsWithStatuses[i];
Assert.NotNull(expectedNotification);
Assert.Equal(expectedNotification.Priority, notificationResponseModel.Priority);
Assert.Equal(expectedNotification.Title, notificationResponseModel.Title);
Assert.Equal(expectedNotification.Body, notificationResponseModel.Body);
Assert.Equal(expectedNotification.RevisionDate, notificationResponseModel.Date);
if (expectedNotificationStatus != null)
{
Assert.Equal(expectedNotificationStatus.ReadDate, notificationResponseModel.ReadDate);
Assert.Equal(expectedNotificationStatus.DeletedDate, notificationResponseModel.DeletedDate);
}
else
{
Assert.Null(notificationResponseModel.ReadDate);
Assert.Null(notificationResponseModel.DeletedDate);
}
Assert.Equal("notification", notificationResponseModel.Object);
i++;
}
}
private async Task<List<(Notification, NotificationStatus?)>> CreateNotificationsWithStatusesAsync()
{
var userId = (Guid)_organizationUserOwner.UserId!;
var globalNotifications = await CreateNotificationsAsync();
var userWithoutOrganizationNotifications = await CreateNotificationsAsync(userId: userId);
var organizationWithoutUserNotifications = await CreateNotificationsAsync(organizationId: _organization.Id);
var userPartOrOrganizationNotifications = await CreateNotificationsAsync(userId: userId,
organizationId: _organization.Id);
var globalNotificationWithStatuses = await CreateNotificationStatusesAsync(globalNotifications, userId);
var userWithoutOrganizationNotificationWithStatuses =
await CreateNotificationStatusesAsync(userWithoutOrganizationNotifications, userId);
var organizationWithoutUserNotificationWithStatuses =
await CreateNotificationStatusesAsync(organizationWithoutUserNotifications, userId);
var userPartOrOrganizationNotificationWithStatuses =
await CreateNotificationStatusesAsync(userPartOrOrganizationNotifications, userId);
return new List<List<(Notification, NotificationStatus?)>>
{
globalNotificationWithStatuses,
userWithoutOrganizationNotificationWithStatuses,
organizationWithoutUserNotificationWithStatuses,
userPartOrOrganizationNotificationWithStatuses
}
.SelectMany(n => n)
.ToList();
}
private async Task<List<Notification>> CreateNotificationsAsync(Guid? userId = null, Guid? organizationId = null,
int numberToCreate = 5)
{
var priorities = Enum.GetValues<Priority>();
var clientTypes = Enum.GetValues<ClientType>();
var notifications = new List<Notification>();
foreach (var clientType in clientTypes)
{
for (var i = 0; i < numberToCreate; i++)
{
var notification = new Notification
{
Global = userId == null && organizationId == null,
UserId = userId,
OrganizationId = organizationId,
Title = _mockEncryptedTitle,
Body = _mockEncryptedBody,
Priority = (Priority)priorities.GetValue(_random.Next(priorities.Length))!,
ClientType = clientType,
CreationDate = DateTime.UtcNow - TimeSpan.FromMinutes(_random.Next(3600)),
RevisionDate = DateTime.UtcNow - TimeSpan.FromMinutes(_random.Next(3600))
};
notification = await _notificationRepository.CreateAsync(notification);
notifications.Add(notification);
}
}
return notifications;
}
private async Task<List<(Notification, NotificationStatus?)>> CreateNotificationStatusesAsync(
List<Notification> notifications, Guid userId)
{
var readDateNotificationStatus = await _notificationStatusRepository.CreateAsync(new NotificationStatus
{
NotificationId = notifications[0].Id,
UserId = userId,
ReadDate = DateTime.UtcNow - TimeSpan.FromMinutes(_random.Next(3600)),
DeletedDate = null
});
var deletedDateNotificationStatus = await _notificationStatusRepository.CreateAsync(new NotificationStatus
{
NotificationId = notifications[1].Id,
UserId = userId,
ReadDate = null,
DeletedDate = DateTime.UtcNow - TimeSpan.FromMinutes(_random.Next(3600))
});
var readDateAndDeletedDateNotificationStatus = await _notificationStatusRepository.CreateAsync(
new NotificationStatus
{
NotificationId = notifications[2].Id,
UserId = userId,
ReadDate = DateTime.UtcNow - TimeSpan.FromMinutes(_random.Next(3600)),
DeletedDate = DateTime.UtcNow - TimeSpan.FromMinutes(_random.Next(3600))
});
return
[
(notifications[0], readDateNotificationStatus),
(notifications[1], deletedDateNotificationStatus),
(notifications[2], readDateAndDeletedDateNotificationStatus),
(notifications[3], null),
(notifications[4], null)
];
}
}

View File

@ -0,0 +1,202 @@
#nullable enable
using Bit.Api.NotificationCenter.Controllers;
using Bit.Api.NotificationCenter.Models.Request;
using Bit.Core.Models.Data;
using Bit.Core.NotificationCenter.Commands.Interfaces;
using Bit.Core.NotificationCenter.Models.Data;
using Bit.Core.NotificationCenter.Models.Filter;
using Bit.Core.NotificationCenter.Queries.Interfaces;
using Bit.Core.Test.NotificationCenter.AutoFixture;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
namespace Bit.Api.Test.NotificationCenter.Controllers;
[ControllerCustomize(typeof(NotificationsController))]
[SutProviderCustomize]
public class NotificationsControllerTests
{
[Theory]
[BitAutoData([null, null])]
[BitAutoData([null, false])]
[BitAutoData([null, true])]
[BitAutoData(false, null)]
[BitAutoData(true, null)]
[BitAutoData(false, false)]
[BitAutoData(false, true)]
[BitAutoData(true, false)]
[BitAutoData(true, true)]
[NotificationStatusDetailsListCustomize(5)]
public async Task ListAsync_StatusFilter_ReturnedMatchingNotifications(bool? readStatusFilter, bool? deletedStatusFilter,
SutProvider<NotificationsController> sutProvider,
IEnumerable<NotificationStatusDetails> notificationStatusDetailsEnumerable)
{
var notificationStatusDetailsList = notificationStatusDetailsEnumerable
.OrderByDescending(n => n.Priority)
.ThenByDescending(n => n.CreationDate)
.ToList();
sutProvider.GetDependency<IGetNotificationStatusDetailsForUserQuery>()
.GetByUserIdStatusFilterAsync(Arg.Any<NotificationStatusFilter>(), Arg.Any<PageOptions>())
.Returns(new PagedResult<NotificationStatusDetails> { Data = notificationStatusDetailsList });
var expectedNotificationStatusDetailsMap = notificationStatusDetailsList
.Take(10)
.ToDictionary(n => n.Id);
var listResponse = await sutProvider.Sut.ListAsync(new NotificationFilterRequestModel
{
ReadStatusFilter = readStatusFilter,
DeletedStatusFilter = deletedStatusFilter
});
Assert.Equal("list", listResponse.Object);
Assert.Equal(5, listResponse.Data.Count());
Assert.All(listResponse.Data, notificationResponseModel =>
{
Assert.Equal("notification", notificationResponseModel.Object);
Assert.True(expectedNotificationStatusDetailsMap.ContainsKey(notificationResponseModel.Id));
var expectedNotificationStatusDetails = expectedNotificationStatusDetailsMap[notificationResponseModel.Id];
Assert.NotNull(expectedNotificationStatusDetails);
Assert.Equal(expectedNotificationStatusDetails.Id, notificationResponseModel.Id);
Assert.Equal(expectedNotificationStatusDetails.Priority, notificationResponseModel.Priority);
Assert.Equal(expectedNotificationStatusDetails.Title, notificationResponseModel.Title);
Assert.Equal(expectedNotificationStatusDetails.Body, notificationResponseModel.Body);
Assert.Equal(expectedNotificationStatusDetails.RevisionDate, notificationResponseModel.Date);
Assert.Equal(expectedNotificationStatusDetails.ReadDate, notificationResponseModel.ReadDate);
Assert.Equal(expectedNotificationStatusDetails.DeletedDate, notificationResponseModel.DeletedDate);
});
Assert.Null(listResponse.ContinuationToken);
await sutProvider.GetDependency<IGetNotificationStatusDetailsForUserQuery>()
.Received(1)
.GetByUserIdStatusFilterAsync(Arg.Is<NotificationStatusFilter>(filter =>
filter.Read == readStatusFilter && filter.Deleted == deletedStatusFilter),
Arg.Is<PageOptions>(pageOptions =>
pageOptions.ContinuationToken == null && pageOptions.PageSize == 10));
}
[Theory]
[BitAutoData]
[NotificationStatusDetailsListCustomize(19)]
public async Task ListAsync_PagingRequestNoContinuationToken_ReturnedFirst10MatchingNotifications(
SutProvider<NotificationsController> sutProvider,
IEnumerable<NotificationStatusDetails> notificationStatusDetailsEnumerable)
{
var notificationStatusDetailsList = notificationStatusDetailsEnumerable
.OrderByDescending(n => n.Priority)
.ThenByDescending(n => n.CreationDate)
.ToList();
sutProvider.GetDependency<IGetNotificationStatusDetailsForUserQuery>()
.GetByUserIdStatusFilterAsync(Arg.Any<NotificationStatusFilter>(), Arg.Any<PageOptions>())
.Returns(new PagedResult<NotificationStatusDetails>
{ Data = notificationStatusDetailsList.Take(10).ToList(), ContinuationToken = "2" });
var expectedNotificationStatusDetailsMap = notificationStatusDetailsList
.Take(10)
.ToDictionary(n => n.Id);
var listResponse = await sutProvider.Sut.ListAsync(new NotificationFilterRequestModel());
Assert.Equal("list", listResponse.Object);
Assert.Equal(10, listResponse.Data.Count());
Assert.All(listResponse.Data, notificationResponseModel =>
{
Assert.Equal("notification", notificationResponseModel.Object);
Assert.True(expectedNotificationStatusDetailsMap.ContainsKey(notificationResponseModel.Id));
var expectedNotificationStatusDetails = expectedNotificationStatusDetailsMap[notificationResponseModel.Id];
Assert.NotNull(expectedNotificationStatusDetails);
Assert.Equal(expectedNotificationStatusDetails.Id, notificationResponseModel.Id);
Assert.Equal(expectedNotificationStatusDetails.Priority, notificationResponseModel.Priority);
Assert.Equal(expectedNotificationStatusDetails.Title, notificationResponseModel.Title);
Assert.Equal(expectedNotificationStatusDetails.Body, notificationResponseModel.Body);
Assert.Equal(expectedNotificationStatusDetails.RevisionDate, notificationResponseModel.Date);
Assert.Equal(expectedNotificationStatusDetails.ReadDate, notificationResponseModel.ReadDate);
Assert.Equal(expectedNotificationStatusDetails.DeletedDate, notificationResponseModel.DeletedDate);
});
Assert.Equal("2", listResponse.ContinuationToken);
await sutProvider.GetDependency<IGetNotificationStatusDetailsForUserQuery>()
.Received(1)
.GetByUserIdStatusFilterAsync(Arg.Any<NotificationStatusFilter>(),
Arg.Is<PageOptions>(pageOptions =>
pageOptions.ContinuationToken == null && pageOptions.PageSize == 10));
}
[Theory]
[BitAutoData]
[NotificationStatusDetailsListCustomize(19)]
public async Task ListAsync_PagingRequestUsingContinuationToken_ReturnedLast9MatchingNotifications(
SutProvider<NotificationsController> sutProvider,
IEnumerable<NotificationStatusDetails> notificationStatusDetailsEnumerable)
{
var notificationStatusDetailsList = notificationStatusDetailsEnumerable
.OrderByDescending(n => n.Priority)
.ThenByDescending(n => n.CreationDate)
.ToList();
sutProvider.GetDependency<IGetNotificationStatusDetailsForUserQuery>()
.GetByUserIdStatusFilterAsync(Arg.Any<NotificationStatusFilter>(), Arg.Any<PageOptions>())
.Returns(new PagedResult<NotificationStatusDetails>
{ Data = notificationStatusDetailsList.Skip(10).ToList() });
var expectedNotificationStatusDetailsMap = notificationStatusDetailsList
.Skip(10)
.ToDictionary(n => n.Id);
var listResponse = await sutProvider.Sut.ListAsync(new NotificationFilterRequestModel { ContinuationToken = "2" });
Assert.Equal("list", listResponse.Object);
Assert.Equal(9, listResponse.Data.Count());
Assert.All(listResponse.Data, notificationResponseModel =>
{
Assert.Equal("notification", notificationResponseModel.Object);
Assert.True(expectedNotificationStatusDetailsMap.ContainsKey(notificationResponseModel.Id));
var expectedNotificationStatusDetails = expectedNotificationStatusDetailsMap[notificationResponseModel.Id];
Assert.NotNull(expectedNotificationStatusDetails);
Assert.Equal(expectedNotificationStatusDetails.Id, notificationResponseModel.Id);
Assert.Equal(expectedNotificationStatusDetails.Priority, notificationResponseModel.Priority);
Assert.Equal(expectedNotificationStatusDetails.Title, notificationResponseModel.Title);
Assert.Equal(expectedNotificationStatusDetails.Body, notificationResponseModel.Body);
Assert.Equal(expectedNotificationStatusDetails.RevisionDate, notificationResponseModel.Date);
Assert.Equal(expectedNotificationStatusDetails.ReadDate, notificationResponseModel.ReadDate);
Assert.Equal(expectedNotificationStatusDetails.DeletedDate, notificationResponseModel.DeletedDate);
});
Assert.Null(listResponse.ContinuationToken);
await sutProvider.GetDependency<IGetNotificationStatusDetailsForUserQuery>()
.Received(1)
.GetByUserIdStatusFilterAsync(Arg.Any<NotificationStatusFilter>(),
Arg.Is<PageOptions>(pageOptions =>
pageOptions.ContinuationToken == "2" && pageOptions.PageSize == 10));
}
[Theory]
[BitAutoData]
public async Task MarkAsDeletedAsync_NotificationId_MarkedAsDeleted(
SutProvider<NotificationsController> sutProvider,
Guid notificationId)
{
await sutProvider.Sut.MarkAsDeletedAsync(notificationId);
await sutProvider.GetDependency<IMarkNotificationDeletedCommand>()
.Received(1)
.MarkDeletedAsync(notificationId);
}
[Theory]
[BitAutoData]
public async Task MarkAsReadAsync_NotificationId_MarkedAsRead(
SutProvider<NotificationsController> sutProvider,
Guid notificationId)
{
await sutProvider.Sut.MarkAsReadAsync(notificationId);
await sutProvider.GetDependency<IMarkNotificationReadCommand>()
.Received(1)
.MarkReadAsync(notificationId);
}
}

View File

@ -0,0 +1,93 @@
#nullable enable
using System.ComponentModel.DataAnnotations;
using Bit.Api.NotificationCenter.Models.Request;
using Xunit;
namespace Bit.Api.Test.NotificationCenter.Models.Request;
public class NotificationFilterRequestModelTests
{
[Theory]
[InlineData("invalid")]
[InlineData("-1")]
[InlineData("0")]
public void Validate_ContinuationTokenInvalidNumber_Invalid(string continuationToken)
{
var model = new NotificationFilterRequestModel
{
ContinuationToken = continuationToken,
};
var result = Validate(model);
Assert.Single(result);
Assert.Contains("Continuation token must be a positive, non zero integer.", result[0].ErrorMessage);
Assert.Contains("ContinuationToken", result[0].MemberNames);
}
[Fact]
public void Validate_ContinuationTokenMaxLengthExceeded_Invalid()
{
var model = new NotificationFilterRequestModel
{
ContinuationToken = "1234567890"
};
var result = Validate(model);
Assert.Single(result);
Assert.Contains("The field ContinuationToken must be a string with a maximum length of 9.",
result[0].ErrorMessage);
Assert.Contains("ContinuationToken", result[0].MemberNames);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("1")]
[InlineData("123456789")]
public void Validate_ContinuationTokenCorrect_Valid(string? continuationToken)
{
var model = new NotificationFilterRequestModel
{
ContinuationToken = continuationToken
};
var result = Validate(model);
Assert.Empty(result);
}
[Theory]
[InlineData(9)]
[InlineData(1001)]
public void Validate_PageSizeInvalidRange_Invalid(int pageSize)
{
var model = new NotificationFilterRequestModel
{
PageSize = pageSize
};
var result = Validate(model);
Assert.Single(result);
Assert.Contains("The field PageSize must be between 10 and 1000.", result[0].ErrorMessage);
Assert.Contains("PageSize", result[0].MemberNames);
}
[Theory]
[InlineData(null)]
[InlineData(10)]
[InlineData(1000)]
public void Validate_PageSizeCorrect_Valid(int? pageSize)
{
var model = pageSize == null
? new NotificationFilterRequestModel()
: new NotificationFilterRequestModel
{
PageSize = pageSize.Value
};
var result = Validate(model);
Assert.Empty(result);
}
private static List<ValidationResult> Validate(NotificationFilterRequestModel model)
{
var results = new List<ValidationResult>();
Validator.TryValidateObject(model, new ValidationContext(model), results, true);
return results;
}
}

View File

@ -0,0 +1,43 @@
#nullable enable
using Bit.Api.NotificationCenter.Models.Response;
using Bit.Core.Enums;
using Bit.Core.NotificationCenter.Enums;
using Bit.Core.NotificationCenter.Models.Data;
using Xunit;
namespace Bit.Api.Test.NotificationCenter.Models.Response;
public class NotificationResponseModelTests
{
[Fact]
public void Constructor_NotificationStatusDetailsNull_CorrectFields()
{
Assert.Throws<ArgumentNullException>(() => new NotificationResponseModel(null!));
}
[Fact]
public void Constructor_NotificationStatusDetails_CorrectFields()
{
var notificationStatusDetails = new NotificationStatusDetails
{
Id = Guid.NewGuid(),
Global = true,
Priority = Priority.High,
ClientType = ClientType.All,
Title = "Test Title",
Body = "Test Body",
RevisionDate = DateTime.UtcNow - TimeSpan.FromMinutes(3),
ReadDate = DateTime.UtcNow - TimeSpan.FromMinutes(1),
DeletedDate = DateTime.UtcNow,
};
var model = new NotificationResponseModel(notificationStatusDetails);
Assert.Equal(model.Id, notificationStatusDetails.Id);
Assert.Equal(model.Priority, notificationStatusDetails.Priority);
Assert.Equal(model.Title, notificationStatusDetails.Title);
Assert.Equal(model.Body, notificationStatusDetails.Body);
Assert.Equal(model.Date, notificationStatusDetails.RevisionDate);
Assert.Equal(model.ReadDate, notificationStatusDetails.ReadDate);
Assert.Equal(model.DeletedDate, notificationStatusDetails.DeletedDate);
}
}

View File

@ -9,9 +9,32 @@ public class NotificationStatusDetailsCustomization : ICustomization
{ {
public void Customize(IFixture fixture) public void Customize(IFixture fixture)
{ {
fixture.Customize<NotificationStatusDetails>(composer => composer.With(n => n.Id, Guid.NewGuid()) fixture.Customize<NotificationStatusDetails>(composer =>
.With(n => n.UserId, Guid.NewGuid()) {
.With(n => n.OrganizationId, Guid.NewGuid())); return composer.With(n => n.Id, Guid.NewGuid())
.With(n => n.UserId, Guid.NewGuid())
.With(n => n.OrganizationId, Guid.NewGuid());
});
}
}
public class NotificationStatusDetailsListCustomization(int count) : ICustomization
{
public void Customize(IFixture fixture)
{
var customization = new NotificationStatusDetailsCustomization();
fixture.Customize<IEnumerable<NotificationStatusDetails>>(composer => composer.FromFactory(() =>
{
var notifications = new List<NotificationStatusDetails>();
for (var i = 0; i < count; i++)
{
customization.Customize(fixture);
var notificationStatusDetails = fixture.Create<NotificationStatusDetails>();
notifications.Add(notificationStatusDetails);
}
return notifications;
}));
} }
} }
@ -19,3 +42,8 @@ public class NotificationStatusDetailsCustomizeAttribute : BitCustomizeAttribute
{ {
public override ICustomization GetCustomization() => new NotificationStatusDetailsCustomization(); public override ICustomization GetCustomization() => new NotificationStatusDetailsCustomization();
} }
public class NotificationStatusDetailsListCustomizeAttribute(int count) : BitCustomizeAttribute
{
public override ICustomization GetCustomization() => new NotificationStatusDetailsListCustomization(count);
}

View File

@ -2,6 +2,7 @@
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Models.Data;
using Bit.Core.NotificationCenter.Models.Data; using Bit.Core.NotificationCenter.Models.Data;
using Bit.Core.NotificationCenter.Models.Filter; using Bit.Core.NotificationCenter.Models.Filter;
using Bit.Core.NotificationCenter.Queries; using Bit.Core.NotificationCenter.Queries;
@ -19,37 +20,49 @@ namespace Bit.Core.Test.NotificationCenter.Queries;
public class GetNotificationStatusDetailsForUserQueryTest public class GetNotificationStatusDetailsForUserQueryTest
{ {
private static void Setup(SutProvider<GetNotificationStatusDetailsForUserQuery> sutProvider, private static void Setup(SutProvider<GetNotificationStatusDetailsForUserQuery> sutProvider,
List<NotificationStatusDetails> notificationsStatusDetails, NotificationStatusFilter statusFilter, Guid? userId) List<NotificationStatusDetails> notificationsStatusDetails, NotificationStatusFilter statusFilter, Guid? userId,
PageOptions pageOptions, string? continuationToken)
{ {
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(userId); sutProvider.GetDependency<ICurrentContext>().UserId.Returns(userId);
sutProvider.GetDependency<INotificationRepository>().GetByUserIdAndStatusAsync( sutProvider.GetDependency<INotificationRepository>()
userId.GetValueOrDefault(Guid.NewGuid()), Arg.Any<ClientType>(), statusFilter) .GetByUserIdAndStatusAsync(userId.GetValueOrDefault(Guid.NewGuid()), Arg.Any<ClientType>(), statusFilter,
.Returns(notificationsStatusDetails); pageOptions)
.Returns(new PagedResult<NotificationStatusDetails>
{
Data = notificationsStatusDetails,
ContinuationToken = continuationToken
});
} }
[Theory] [Theory]
[BitAutoData] [BitAutoData]
public async Task GetByUserIdStatusFilterAsync_NotLoggedIn_NotFoundException( public async Task GetByUserIdStatusFilterAsync_NotLoggedIn_NotFoundException(
SutProvider<GetNotificationStatusDetailsForUserQuery> sutProvider, SutProvider<GetNotificationStatusDetailsForUserQuery> sutProvider,
List<NotificationStatusDetails> notificationsStatusDetails, NotificationStatusFilter notificationStatusFilter) List<NotificationStatusDetails> notificationsStatusDetails, NotificationStatusFilter notificationStatusFilter,
PageOptions pageOptions, string? continuationToken)
{ {
Setup(sutProvider, notificationsStatusDetails, notificationStatusFilter, userId: null); Setup(sutProvider, notificationsStatusDetails, notificationStatusFilter, userId: null, pageOptions,
continuationToken);
await Assert.ThrowsAsync<NotFoundException>(() => await Assert.ThrowsAsync<NotFoundException>(() =>
sutProvider.Sut.GetByUserIdStatusFilterAsync(notificationStatusFilter)); sutProvider.Sut.GetByUserIdStatusFilterAsync(notificationStatusFilter, pageOptions));
} }
[Theory] [Theory]
[BitAutoData] [BitAutoData]
public async Task GetByUserIdStatusFilterAsync_NotificationsFound_Returned( public async Task GetByUserIdStatusFilterAsync_NotificationsFound_Returned(
SutProvider<GetNotificationStatusDetailsForUserQuery> sutProvider, SutProvider<GetNotificationStatusDetailsForUserQuery> sutProvider,
List<NotificationStatusDetails> notificationsStatusDetails, NotificationStatusFilter notificationStatusFilter) List<NotificationStatusDetails> notificationsStatusDetails, NotificationStatusFilter notificationStatusFilter,
PageOptions pageOptions, string? continuationToken)
{ {
Setup(sutProvider, notificationsStatusDetails, notificationStatusFilter, Guid.NewGuid()); Setup(sutProvider, notificationsStatusDetails, notificationStatusFilter, Guid.NewGuid(), pageOptions,
continuationToken);
var actualNotificationsStatusDetails = var actualNotificationsStatusDetailsPagedResult =
await sutProvider.Sut.GetByUserIdStatusFilterAsync(notificationStatusFilter); await sutProvider.Sut.GetByUserIdStatusFilterAsync(notificationStatusFilter, pageOptions);
Assert.Equal(notificationsStatusDetails, actualNotificationsStatusDetails); Assert.NotNull(actualNotificationsStatusDetailsPagedResult);
Assert.Equal(notificationsStatusDetails, actualNotificationsStatusDetailsPagedResult.Data);
Assert.Equal(continuationToken, actualNotificationsStatusDetailsPagedResult.ContinuationToken);
} }
} }

View File

@ -0,0 +1,39 @@
-- Stored Procedure Notification_ReadByUserIdAndStatus
CREATE OR ALTER PROCEDURE [dbo].[Notification_ReadByUserIdAndStatus]
@UserId UNIQUEIDENTIFIER,
@ClientType TINYINT,
@Read BIT,
@Deleted BIT,
@PageNumber INT = 1,
@PageSize INT = 10
AS
BEGIN
SET NOCOUNT ON
SELECT n.*
FROM [dbo].[NotificationStatusDetailsView] n
LEFT JOIN [dbo].[OrganizationUserView] ou ON n.[OrganizationId] = ou.[OrganizationId]
AND ou.[UserId] = @UserId
WHERE (n.[NotificationStatusUserId] IS NULL OR n.[NotificationStatusUserId] = @UserId)
AND [ClientType] IN (0, CASE WHEN @ClientType != 0 THEN @ClientType END)
AND ([Global] = 1
OR (n.[UserId] = @UserId
AND (n.[OrganizationId] IS NULL
OR ou.[OrganizationId] IS NOT NULL))
OR (n.[UserId] IS NULL
AND ou.[OrganizationId] IS NOT NULL))
AND ((@Read IS NULL AND @Deleted IS NULL)
OR (n.[NotificationStatusUserId] IS NOT NULL
AND (@Read IS NULL
OR IIF((@Read = 1 AND n.[ReadDate] IS NOT NULL) OR
(@Read = 0 AND n.[ReadDate] IS NULL),
1, 0) = 1)
AND (@Deleted IS NULL
OR IIF((@Deleted = 1 AND n.[DeletedDate] IS NOT NULL) OR
(@Deleted = 0 AND n.[DeletedDate] IS NULL),
1, 0) = 1)))
ORDER BY [Priority] DESC, n.[CreationDate] DESC
OFFSET @PageSize * (@PageNumber - 1) ROWS FETCH NEXT @PageSize ROWS ONLY
END
GO