From 478359c99a20c736bb6580848251068037b46ab3 Mon Sep 17 00:00:00 2001 From: romandykyi Date: Sat, 30 Mar 2024 12:56:22 +0100 Subject: [PATCH 1/8] Add invitation link model and a new permission --- .../Models/TodoLists/InvitationLink.cs | 36 + .../TodoLists/Members/RolePermissions.cs | 10 +- .../Data/ApplicationDbContext.cs | 1 + ...40330120758_AddInvitationLinks.Designer.cs | 644 ++++++++++++++++++ .../20240330120758_AddInvitationLinks.cs | 57 ++ .../ApplicationDbContextModelSnapshot.cs | 44 +- 6 files changed, 787 insertions(+), 5 deletions(-) create mode 100644 AdvancedTodoList.Core/Models/TodoLists/InvitationLink.cs create mode 100644 AdvancedTodoList.Infrastructure/Migrations/20240330120758_AddInvitationLinks.Designer.cs create mode 100644 AdvancedTodoList.Infrastructure/Migrations/20240330120758_AddInvitationLinks.cs diff --git a/AdvancedTodoList.Core/Models/TodoLists/InvitationLink.cs b/AdvancedTodoList.Core/Models/TodoLists/InvitationLink.cs new file mode 100644 index 0000000..b88dc02 --- /dev/null +++ b/AdvancedTodoList.Core/Models/TodoLists/InvitationLink.cs @@ -0,0 +1,36 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace AdvancedTodoList.Core.Models.TodoLists; + +/// +/// Represents a to-do list invitation link entity. +/// +public class InvitationLink : IEntity +{ + /// + /// A unique identifier for the to-do list item. + /// + [Key] + public int Id { get; set; } + + /// + /// Foreign key of the to-do list where the link is active. + /// + [ForeignKey(nameof(TodoList))] + public required string TodoListId { get; set; } + /// + /// Navigation property to the to-do list associated with this link. + /// + public TodoList TodoList { get; set; } = null!; + + /// + /// A unique string value representing the link. + /// + public required string Value { get; set; } + + /// + /// Date after which the link becomes invalid. + /// + public DateTime ValidTo { get; set; } +} diff --git a/AdvancedTodoList.Core/Models/TodoLists/Members/RolePermissions.cs b/AdvancedTodoList.Core/Models/TodoLists/Members/RolePermissions.cs index 0de3f9c..6f4cd89 100644 --- a/AdvancedTodoList.Core/Models/TodoLists/Members/RolePermissions.cs +++ b/AdvancedTodoList.Core/Models/TodoLists/Members/RolePermissions.cs @@ -7,11 +7,12 @@ /// A flag that determines whether user can add to-do list items. /// A flag that determines whether user can edit to-do list items of other users and the to-do list itself. /// A flag that determines whether user can delete to-do list items of other users. -/// A flag that determines whether user can add members. +/// A flag that determines whether user can add members and create invitation links. /// A flag that determines whether user can remove members. /// A flag that determines whether user can assign a role to other member. /// A flag that determines whether user can edit/delete existing roles and add new roles. -/// A flag that determines whether user can cedit/delete existing categories and add new categories. +/// A flag that determines whether user can edit/delete existing categories and add new categories. +/// A flag that determines whether user can delete existing invitation links. public record struct RolePermissions( bool SetItemsState = false, bool AddItems = false, @@ -21,11 +22,12 @@ public record struct RolePermissions( bool RemoveMembers = false, bool AssignRoles = false, bool EditRoles = false, - bool EditCategories = false + bool EditCategories = false, + bool ManageInvitationLinks = false ) { /// /// Instance of a structure with all permissions. /// - public static readonly RolePermissions All = new(true, true, true, true, true, true, true, true, true); + public static readonly RolePermissions All = new(true, true, true, true, true, true, true, true, true, true); } diff --git a/AdvancedTodoList.Infrastructure/Data/ApplicationDbContext.cs b/AdvancedTodoList.Infrastructure/Data/ApplicationDbContext.cs index badbc3c..db4ede9 100644 --- a/AdvancedTodoList.Infrastructure/Data/ApplicationDbContext.cs +++ b/AdvancedTodoList.Infrastructure/Data/ApplicationDbContext.cs @@ -12,6 +12,7 @@ public class ApplicationDbContext(DbContextOptions options public DbSet TodoLists { get; set; } public DbSet TodoItems { get; set; } public DbSet TodoItemCategories { get; set; } + public DbSet InvitationLinks { get; set; } public DbSet TodoListsMembers { get; set; } public DbSet TodoListRoles { get; set; } public DbSet UserRefreshTokens { get; set; } diff --git a/AdvancedTodoList.Infrastructure/Migrations/20240330120758_AddInvitationLinks.Designer.cs b/AdvancedTodoList.Infrastructure/Migrations/20240330120758_AddInvitationLinks.Designer.cs new file mode 100644 index 0000000..457a82f --- /dev/null +++ b/AdvancedTodoList.Infrastructure/Migrations/20240330120758_AddInvitationLinks.Designer.cs @@ -0,0 +1,644 @@ +// +using System; +using System.Collections.Generic; +using AdvancedTodoList.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace AdvancedTodoList.Infrastructure.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20240330120758_AddInvitationLinks")] + partial class AddInvitationLinks + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("AdvancedTodoList.Core.Models.Auth.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("AdvancedTodoList.Core.Models.Auth.UserRefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Token") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("ValidTo") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserRefreshTokens"); + }); + + modelBuilder.Entity("AdvancedTodoList.Core.Models.TodoLists.InvitationLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("TodoListId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("ValidTo") + .HasColumnType("datetime2"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("TodoListId"); + + b.ToTable("InvitationLinks"); + }); + + modelBuilder.Entity("AdvancedTodoList.Core.Models.TodoLists.Members.TodoListMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("RoleId") + .HasColumnType("int"); + + b.Property("TodoListId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("TodoListId"); + + b.HasIndex("UserId", "TodoListId") + .IsUnique(); + + b.ToTable("TodoListsMembers"); + }); + + modelBuilder.Entity("AdvancedTodoList.Core.Models.TodoLists.Members.TodoListRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.Property("TodoListId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.ComplexProperty>("Permissions", "AdvancedTodoList.Core.Models.TodoLists.Members.TodoListRole.Permissions#RolePermissions", b1 => + { + b1.Property("AddItems") + .HasColumnType("bit"); + + b1.Property("AddMembers") + .HasColumnType("bit"); + + b1.Property("AssignRoles") + .HasColumnType("bit"); + + b1.Property("DeleteItems") + .HasColumnType("bit"); + + b1.Property("EditCategories") + .HasColumnType("bit"); + + b1.Property("EditItems") + .HasColumnType("bit"); + + b1.Property("EditRoles") + .HasColumnType("bit"); + + b1.Property("ManageInvitationLinks") + .HasColumnType("bit"); + + b1.Property("RemoveMembers") + .HasColumnType("bit"); + + b1.Property("SetItemsState") + .HasColumnType("bit"); + }); + + b.HasKey("Id"); + + b.HasIndex("TodoListId"); + + b.ToTable("TodoListRoles"); + }); + + modelBuilder.Entity("AdvancedTodoList.Core.Models.TodoLists.TodoItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("DeadlineDate") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(10000) + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OwnerId") + .HasColumnType("nvarchar(450)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.Property("State") + .HasColumnType("tinyint"); + + b.Property("TodoListId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("OwnerId"); + + b.HasIndex("TodoListId"); + + b.ToTable("TodoItems"); + }); + + modelBuilder.Entity("AdvancedTodoList.Core.Models.TodoLists.TodoItemCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("TodoListId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("TodoListId"); + + b.ToTable("TodoItemCategories"); + }); + + modelBuilder.Entity("AdvancedTodoList.Core.Models.TodoLists.TodoList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("nvarchar(450)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(25000) + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("OwnerId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.ToTable("TodoLists"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("AdvancedTodoList.Core.Models.Auth.UserRefreshToken", b => + { + b.HasOne("AdvancedTodoList.Core.Models.Auth.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AdvancedTodoList.Core.Models.TodoLists.InvitationLink", b => + { + b.HasOne("AdvancedTodoList.Core.Models.TodoLists.TodoList", "TodoList") + .WithMany() + .HasForeignKey("TodoListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TodoList"); + }); + + modelBuilder.Entity("AdvancedTodoList.Core.Models.TodoLists.Members.TodoListMember", b => + { + b.HasOne("AdvancedTodoList.Core.Models.TodoLists.Members.TodoListRole", "Role") + .WithMany() + .HasForeignKey("RoleId"); + + b.HasOne("AdvancedTodoList.Core.Models.TodoLists.TodoList", "TodoList") + .WithMany("TodoListMembers") + .HasForeignKey("TodoListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("AdvancedTodoList.Core.Models.Auth.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("TodoList"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("AdvancedTodoList.Core.Models.TodoLists.Members.TodoListRole", b => + { + b.HasOne("AdvancedTodoList.Core.Models.TodoLists.TodoList", "TodoList") + .WithMany() + .HasForeignKey("TodoListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TodoList"); + }); + + modelBuilder.Entity("AdvancedTodoList.Core.Models.TodoLists.TodoItem", b => + { + b.HasOne("AdvancedTodoList.Core.Models.TodoLists.TodoItemCategory", "Category") + .WithMany("TodoItems") + .HasForeignKey("CategoryId"); + + b.HasOne("AdvancedTodoList.Core.Models.Auth.ApplicationUser", "Owner") + .WithMany() + .HasForeignKey("OwnerId"); + + b.HasOne("AdvancedTodoList.Core.Models.TodoLists.TodoList", "TodoList") + .WithMany("TodoItems") + .HasForeignKey("TodoListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Category"); + + b.Navigation("Owner"); + + b.Navigation("TodoList"); + }); + + modelBuilder.Entity("AdvancedTodoList.Core.Models.TodoLists.TodoItemCategory", b => + { + b.HasOne("AdvancedTodoList.Core.Models.TodoLists.TodoList", "TodoList") + .WithMany() + .HasForeignKey("TodoListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TodoList"); + }); + + modelBuilder.Entity("AdvancedTodoList.Core.Models.TodoLists.TodoList", b => + { + b.HasOne("AdvancedTodoList.Core.Models.Auth.ApplicationUser", "Owner") + .WithMany() + .HasForeignKey("OwnerId"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("AdvancedTodoList.Core.Models.Auth.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("AdvancedTodoList.Core.Models.Auth.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("AdvancedTodoList.Core.Models.Auth.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("AdvancedTodoList.Core.Models.Auth.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AdvancedTodoList.Core.Models.TodoLists.TodoItemCategory", b => + { + b.Navigation("TodoItems"); + }); + + modelBuilder.Entity("AdvancedTodoList.Core.Models.TodoLists.TodoList", b => + { + b.Navigation("TodoItems"); + + b.Navigation("TodoListMembers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/AdvancedTodoList.Infrastructure/Migrations/20240330120758_AddInvitationLinks.cs b/AdvancedTodoList.Infrastructure/Migrations/20240330120758_AddInvitationLinks.cs new file mode 100644 index 0000000..b2cd0f3 --- /dev/null +++ b/AdvancedTodoList.Infrastructure/Migrations/20240330120758_AddInvitationLinks.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace AdvancedTodoList.Infrastructure.Migrations; + +/// +public partial class AddInvitationLinks : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Permissions_ManageInvitationLinks", + table: "TodoListRoles", + type: "bit", + nullable: false, + defaultValue: false); + + migrationBuilder.CreateTable( + name: "InvitationLinks", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + TodoListId = table.Column(type: "nvarchar(450)", nullable: false), + Value = table.Column(type: "nvarchar(max)", nullable: false), + ValidTo = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_InvitationLinks", x => x.Id); + table.ForeignKey( + name: "FK_InvitationLinks_TodoLists_TodoListId", + column: x => x.TodoListId, + principalTable: "TodoLists", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_InvitationLinks_TodoListId", + table: "InvitationLinks", + column: "TodoListId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "InvitationLinks"); + + migrationBuilder.DropColumn( + name: "Permissions_ManageInvitationLinks", + table: "TodoListRoles"); + } +} diff --git a/AdvancedTodoList.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs b/AdvancedTodoList.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs index 695e0da..de1871b 100644 --- a/AdvancedTodoList.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/AdvancedTodoList.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs @@ -124,6 +124,32 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("UserRefreshTokens"); }); + modelBuilder.Entity("AdvancedTodoList.Core.Models.TodoLists.InvitationLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("TodoListId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("ValidTo") + .HasColumnType("datetime2"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("TodoListId"); + + b.ToTable("InvitationLinks"); + }); + modelBuilder.Entity("AdvancedTodoList.Core.Models.TodoLists.Members.TodoListMember", b => { b.Property("Id") @@ -198,6 +224,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b1.Property("EditRoles") .HasColumnType("bit"); + b1.Property("ManageInvitationLinks") + .HasColumnType("bit"); + b1.Property("RemoveMembers") .HasColumnType("bit"); @@ -454,6 +483,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("AdvancedTodoList.Core.Models.TodoLists.InvitationLink", b => + { + b.HasOne("AdvancedTodoList.Core.Models.TodoLists.TodoList", "TodoList") + .WithMany() + .HasForeignKey("TodoListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TodoList"); + }); + modelBuilder.Entity("AdvancedTodoList.Core.Models.TodoLists.Members.TodoListMember", b => { b.HasOne("AdvancedTodoList.Core.Models.TodoLists.Members.TodoListRole", "Role") @@ -461,7 +501,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasForeignKey("RoleId"); b.HasOne("AdvancedTodoList.Core.Models.TodoLists.TodoList", "TodoList") - .WithMany() + .WithMany("TodoListMembers") .HasForeignKey("TodoListId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); @@ -592,6 +632,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("AdvancedTodoList.Core.Models.TodoLists.TodoList", b => { b.Navigation("TodoItems"); + + b.Navigation("TodoListMembers"); }); #pragma warning restore 612, 618 } From fb346a74d803b68423908f9cc438abf35849c560 Mon Sep 17 00:00:00 2001 From: romandykyi Date: Sat, 30 Mar 2024 13:15:37 +0100 Subject: [PATCH 2/8] Implement invitation links repository --- .../Repositories/InvitationLinksRepository.cs | 27 +++++++++ .../InvitationLinksRepositoryTests.cs | 60 +++++++++++++++++++ .../Utils/TestModels.cs | 13 ++++ AdvancedTodoList/Program.cs | 2 + 4 files changed, 102 insertions(+) create mode 100644 AdvancedTodoList.Infrastructure/Repositories/InvitationLinksRepository.cs create mode 100644 AdvancedTodoList.IntegrationTests/Repositories/InvitationLinksRepositoryTests.cs diff --git a/AdvancedTodoList.Infrastructure/Repositories/InvitationLinksRepository.cs b/AdvancedTodoList.Infrastructure/Repositories/InvitationLinksRepository.cs new file mode 100644 index 0000000..7581f75 --- /dev/null +++ b/AdvancedTodoList.Infrastructure/Repositories/InvitationLinksRepository.cs @@ -0,0 +1,27 @@ +using AdvancedTodoList.Core.Models.TodoLists; +using AdvancedTodoList.Core.Repositories; +using AdvancedTodoList.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; + +namespace AdvancedTodoList.Infrastructure.Repositories; + +/// +/// Represents a repository for CRUD operations on invitation links. +/// +public class InvitationLinksRepository(ApplicationDbContext dbContext) : + BaseRepository(dbContext), IInvitationLinksRepository +{ + /// + /// Finds an invintation link by its value asynchronously. + /// + /// Value of the link. + /// + /// A task representing asynchronous operation which contains requested link. + /// + public Task FindAsync(string linkValue) + { + return DbContext.InvitationLinks + .Where(x => x.Value == linkValue) + .FirstOrDefaultAsync(); + } +} diff --git a/AdvancedTodoList.IntegrationTests/Repositories/InvitationLinksRepositoryTests.cs b/AdvancedTodoList.IntegrationTests/Repositories/InvitationLinksRepositoryTests.cs new file mode 100644 index 0000000..e0e2082 --- /dev/null +++ b/AdvancedTodoList.IntegrationTests/Repositories/InvitationLinksRepositoryTests.cs @@ -0,0 +1,60 @@ +using AdvancedTodoList.Core.Models.TodoLists; +using AdvancedTodoList.Core.Repositories; +using AdvancedTodoList.IntegrationTests.Utils; + +namespace AdvancedTodoList.IntegrationTests.Repositories; + +public class InvitationLinksRepositoryTests : BaseRepositoryTests +{ + private IInvitationLinksRepository LinksRepository => (IInvitationLinksRepository)Repository; + + protected override int NonExistingId => -1; + + private readonly DateTime UpdatedValidTo = DateTime.UtcNow.AddDays(10); + + protected override void AssertUpdated(InvitationLink updatedEntity) + { + Assert.That(updatedEntity.ValidTo, Is.EqualTo(UpdatedValidTo)); + } + + protected override async Task CreateTestEntityAsync() + { + TodoList todoList = TestModels.CreateTestTodoList(); + DbContext.Add(todoList); + await DbContext.SaveChangesAsync(); + + return TestModels.CreateTestInvitationLink(todoList.Id); + } + + protected override void UpdateEntity(InvitationLink entity) + { + entity.ValidTo = UpdatedValidTo; + } + + [Test] + public async Task FindAsync_EntityExists_ReturnsValidLink() + { + // Arrange + var link = await AddTestEntityToDbAsync(); + + // Act + var result = await LinksRepository.FindAsync(link.Value); + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result.Id, Is.EqualTo(link.Id)); + } + + [Test] + public async Task FindAsync_EntityDoesNotExist_ReturnsNull() + { + // Arrange + string link = "bad link"; + + // Act + var result = await LinksRepository.FindAsync(link); + + // Assert + Assert.That(result, Is.Null); + } +} diff --git a/AdvancedTodoList.IntegrationTests/Utils/TestModels.cs b/AdvancedTodoList.IntegrationTests/Utils/TestModels.cs index 1d47eb7..813de5c 100644 --- a/AdvancedTodoList.IntegrationTests/Utils/TestModels.cs +++ b/AdvancedTodoList.IntegrationTests/Utils/TestModels.cs @@ -74,10 +74,23 @@ public static ApplicationUser CreateTestUser() Token = "TestToken", ValidTo = DateTime.UtcNow.AddDays(180) }; + /// + /// Creates and returns a valid model of a todo-list role. + /// public static TodoListRole CreateTestRole(string todoListId) => new() { Name = "Role1", Priority = 5, TodoListId = todoListId }; + + /// + /// Creates and returns a valid model of an invitation link. + /// + public static InvitationLink CreateTestInvitationLink(string todoListId) => new() + { + Value = Guid.NewGuid().ToString(), + TodoListId = todoListId, + ValidTo = DateTime.Now.AddDays(5) + }; } diff --git a/AdvancedTodoList/Program.cs b/AdvancedTodoList/Program.cs index 360f601..c3038a4 100644 --- a/AdvancedTodoList/Program.cs +++ b/AdvancedTodoList/Program.cs @@ -115,6 +115,8 @@ builder.Services.AddScoped, TodoListRepository>(); builder.Services.AddScoped, TodoItemsRepository>(); builder.Services.AddScoped, TodoItemCategoriesRepository>(); +builder.Services.AddScoped, InvitationLinksRepository>(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped, TodoListMembersRepository>(); builder.Services.AddScoped, TodoListRolesRepository>(); From 0fd054c3fd501992cafb8164835becae1d7ab65c Mon Sep 17 00:00:00 2001 From: romandykyi Date: Sat, 30 Mar 2024 17:58:16 +0100 Subject: [PATCH 3/8] Implement invitation links specification --- .../InvitationLinksSpecification.cs | 32 +++++++++++++++ .../InvitationLinksSpecificationTests.cs | 40 +++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 AdvancedTodoList.Infrastructure/Specifications/InvitationLinksSpecification.cs create mode 100644 AdvancedTodoList.UnitTests/Specifications/InvitationLinksSpecificationTests.cs diff --git a/AdvancedTodoList.Infrastructure/Specifications/InvitationLinksSpecification.cs b/AdvancedTodoList.Infrastructure/Specifications/InvitationLinksSpecification.cs new file mode 100644 index 0000000..2900c07 --- /dev/null +++ b/AdvancedTodoList.Infrastructure/Specifications/InvitationLinksSpecification.cs @@ -0,0 +1,32 @@ +using AdvancedTodoList.Core.Models.TodoLists; +using AdvancedTodoList.Core.Specifications; +using System.Linq.Expressions; + +namespace AdvancedTodoList.Infrastructure.Specifications; + +/// +/// Represents a specification that defines criteria for filtering invitation links. +/// +/// ID of the list invitation links of which will be obtained. +public class InvitationLinksSpecification(string todoListId) : ISpecification +{ + /// + /// Gets the ID of the to-do list to filter entities by. + /// + public string? TodoListId { get; } = todoListId; + + /// + /// Gets the criteria expression that defines the filtering conditions. + /// + public virtual Expression> Criteria => x => x.TodoListId == TodoListId; + + /// + /// Gets the list of include expressions specifying related entities to be included in the query results. + /// + public virtual List>> Includes { get; init; } = []; + + /// + /// Gets the list of include strings specifying related entities to be included in the query results. + /// + public virtual List IncludeStrings { get; init; } = []; +} diff --git a/AdvancedTodoList.UnitTests/Specifications/InvitationLinksSpecificationTests.cs b/AdvancedTodoList.UnitTests/Specifications/InvitationLinksSpecificationTests.cs new file mode 100644 index 0000000..115be84 --- /dev/null +++ b/AdvancedTodoList.UnitTests/Specifications/InvitationLinksSpecificationTests.cs @@ -0,0 +1,40 @@ +using AdvancedTodoList.Core.Models.TodoLists; +using AdvancedTodoList.Infrastructure.Specifications; + +namespace AdvancedTodoList.UnitTests.Specifications; + +[TestFixture] +public class InvitationLinksSpecificationTests +{ + [Test] + public void Criteria_TodoListIdMatches_ReturnsTrue() + { + // Arrange + const string todoListId = "ID"; + InvitationLink link = new() { TodoListId = todoListId, Value = "link" }; + InvitationLinksSpecification specification = new(todoListId); + var function = specification.Criteria.Compile(); + + // Act + bool result = function(link); + + // Assert + Assert.That(result, Is.True); + } + + [Test] + public void Criteria_TodoListIdDoesNotMatch_ReturnsFalse() + { + // Arrange + const string todoListId = "ID"; + InvitationLink link = new() { TodoListId = todoListId, Value = "link" }; + InvitationLinksSpecification specification = new("Wrong ID"); + var function = specification.Criteria.Compile(); + + // Act + bool result = function(link); + + // Assert + Assert.That(result, Is.False); + } +} From 76c4f03827b13159dd70a0f8b5f7c485a66fc41c Mon Sep 17 00:00:00 2001 From: romandykyi Date: Sat, 30 Mar 2024 19:46:25 +0100 Subject: [PATCH 4/8] Update comments --- .../Models/TodoLists/Members/RolePermissions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AdvancedTodoList.Core/Models/TodoLists/Members/RolePermissions.cs b/AdvancedTodoList.Core/Models/TodoLists/Members/RolePermissions.cs index 6f4cd89..100065e 100644 --- a/AdvancedTodoList.Core/Models/TodoLists/Members/RolePermissions.cs +++ b/AdvancedTodoList.Core/Models/TodoLists/Members/RolePermissions.cs @@ -12,7 +12,7 @@ /// A flag that determines whether user can assign a role to other member. /// A flag that determines whether user can edit/delete existing roles and add new roles. /// A flag that determines whether user can edit/delete existing categories and add new categories. -/// A flag that determines whether user can delete existing invitation links. +/// A flag that determines whether user can view/delete existing invitation links. public record struct RolePermissions( bool SetItemsState = false, bool AddItems = false, From 1ea6e6e2b9123f72980ebfdde217ed65027b0d8d Mon Sep 17 00:00:00 2001 From: romandykyi Date: Sat, 30 Mar 2024 20:12:58 +0100 Subject: [PATCH 5/8] Implement invitation links service --- .../Dtos/InvitationLinkDtos.cs | 6 + .../Options/InvitationLinkOptions.cs | 17 + .../IInvitationLinksRepository.cs | 16 + .../Services/IInvitationLinksService.cs | 54 +++ .../Services/JoinByInvitationLinkResult.cs | 42 +++ .../Services/InvitationLinksService.cs | 156 +++++++++ .../BusinessLogicWebApplicationFactory.cs | 3 + .../Services/InvitationLinksServiceTests.cs | 325 ++++++++++++++++++ .../Utils/TestModels.cs | 4 +- AdvancedTodoList/Program.cs | 3 + AdvancedTodoList/appsettings.json | 6 + 11 files changed, 630 insertions(+), 2 deletions(-) create mode 100644 AdvancedTodoList.Core/Dtos/InvitationLinkDtos.cs create mode 100644 AdvancedTodoList.Core/Options/InvitationLinkOptions.cs create mode 100644 AdvancedTodoList.Core/Repositories/IInvitationLinksRepository.cs create mode 100644 AdvancedTodoList.Core/Services/IInvitationLinksService.cs create mode 100644 AdvancedTodoList.Core/Services/JoinByInvitationLinkResult.cs create mode 100644 AdvancedTodoList.Infrastructure/Services/InvitationLinksService.cs create mode 100644 AdvancedTodoList.IntegrationTests/Services/InvitationLinksServiceTests.cs diff --git a/AdvancedTodoList.Core/Dtos/InvitationLinkDtos.cs b/AdvancedTodoList.Core/Dtos/InvitationLinkDtos.cs new file mode 100644 index 0000000..5b2a4ac --- /dev/null +++ b/AdvancedTodoList.Core/Dtos/InvitationLinkDtos.cs @@ -0,0 +1,6 @@ +namespace AdvancedTodoList.Core.Dtos; + +/// +/// Represents a DTO for invitation link view. +/// +public record InvitationLinkDto(int Id, string Value, DateTime ValidTo); diff --git a/AdvancedTodoList.Core/Options/InvitationLinkOptions.cs b/AdvancedTodoList.Core/Options/InvitationLinkOptions.cs new file mode 100644 index 0000000..8eae032 --- /dev/null +++ b/AdvancedTodoList.Core/Options/InvitationLinkOptions.cs @@ -0,0 +1,17 @@ +namespace AdvancedTodoList.Core.Options; + +/// +/// A class that contains invitation link options. +/// +public class InvitationLinkOptions +{ + /// + /// Size of the refresh token in bytes. + /// + public int Size { get; set; } + + /// + /// Days before token expires. + /// + public int ExpirationDays { get; set; } +} diff --git a/AdvancedTodoList.Core/Repositories/IInvitationLinksRepository.cs b/AdvancedTodoList.Core/Repositories/IInvitationLinksRepository.cs new file mode 100644 index 0000000..6d686b9 --- /dev/null +++ b/AdvancedTodoList.Core/Repositories/IInvitationLinksRepository.cs @@ -0,0 +1,16 @@ +using AdvancedTodoList.Core.Models.TodoLists; + +namespace AdvancedTodoList.Core.Repositories; + +public interface IInvitationLinksRepository : IRepository +{ + /// + /// Finds an invintation link by its value asynchronously. + /// + /// Value of the link. + /// + /// A task representing asynchronous operation which contains requested link or + /// it was not found. + /// + Task FindAsync(string linkValue); +} diff --git a/AdvancedTodoList.Core/Services/IInvitationLinksService.cs b/AdvancedTodoList.Core/Services/IInvitationLinksService.cs new file mode 100644 index 0000000..9e9da10 --- /dev/null +++ b/AdvancedTodoList.Core/Services/IInvitationLinksService.cs @@ -0,0 +1,54 @@ +using AdvancedTodoList.Core.Dtos; +using AdvancedTodoList.Core.Pagination; + +namespace AdvancedTodoList.Core.Services; + +/// +/// An interface for a service that manages invitation links. +/// +public interface IInvitationLinksService +{ + /// + /// Joins the caller to the to-do list by invitation list asynchronously. + /// + /// ID of the caller. + /// Invitation link to use. + /// + /// A task representing the asynchronous operation. The task contains + /// a result of the operation. + /// + Task JoinAsync(string callerId, string invitationLinkValue); + + /// + /// Gets invitation links associated with the to-do list asynchronously. + /// + /// To-do list context of the operation. + /// Pagination parameters to use. + /// + /// A task representing the asynchronous operation. The task contains + /// a result of the operation. + /// + Task>> GetInvitationLinksAsync(TodoListContext context, + PaginationParameters parameters); + + /// + /// Creates an invitation link associated to the to-do list asynchronously. + /// + /// To-do list context. + /// + /// A task representing the asynchronous operation. The task contains + /// a result of the operation. + /// + Task> CreateAsync(TodoListContext context); + + /// + /// Deletes an invitation link associted to the to-do list asynchronously. + /// + /// To-do list context. + /// ID of the link. + /// + /// A task representing the asynchronous operation. The task contains + /// a result of the operation. + /// + Task DeleteAsync(TodoListContext context, int linkId); +} diff --git a/AdvancedTodoList.Core/Services/JoinByInvitationLinkResult.cs b/AdvancedTodoList.Core/Services/JoinByInvitationLinkResult.cs new file mode 100644 index 0000000..0da518a --- /dev/null +++ b/AdvancedTodoList.Core/Services/JoinByInvitationLinkResult.cs @@ -0,0 +1,42 @@ +using AdvancedTodoList.Core.Dtos; + +namespace AdvancedTodoList.Core.Services; + +/// +/// Represents possible results of the join to-do list by invitatation link operation. +/// +public class JoinByInvitationLinkResult(JoinByInvitationLinkStatus status, TodoListMemberMinimalViewDto? dto = null) +{ + /// + /// Status of the operation. + /// + public JoinByInvitationLinkStatus Status { get; } = status; + + /// + /// Gets additional DTO of the member, can be . + /// + public TodoListMemberMinimalViewDto? Dto { get; } = dto; +} + +/// +/// Enum that represents possible result statuses of the join to-do list by invitatation link operation. +/// +public enum JoinByInvitationLinkStatus +{ + /// + /// Operation was successfull. + /// + Success, + /// + /// Invitation link was not found. + /// + NotFound, + /// + /// Invitation link is expired. + /// + Expired, + /// + /// User is already a member of the to-do list. + /// + UserIsAlreadyMember +} \ No newline at end of file diff --git a/AdvancedTodoList.Infrastructure/Services/InvitationLinksService.cs b/AdvancedTodoList.Infrastructure/Services/InvitationLinksService.cs new file mode 100644 index 0000000..1987d0b --- /dev/null +++ b/AdvancedTodoList.Infrastructure/Services/InvitationLinksService.cs @@ -0,0 +1,156 @@ +using AdvancedTodoList.Core.Dtos; +using AdvancedTodoList.Core.Models.TodoLists; +using AdvancedTodoList.Core.Models.TodoLists.Members; +using AdvancedTodoList.Core.Options; +using AdvancedTodoList.Core.Pagination; +using AdvancedTodoList.Core.Repositories; +using AdvancedTodoList.Core.Services; +using AdvancedTodoList.Core.Services.Auth; +using AdvancedTodoList.Infrastructure.Specifications; +using Mapster; +using Microsoft.Extensions.Options; +using System.Security.Cryptography; + +namespace AdvancedTodoList.Infrastructure.Services; + +/// +/// A service that manages invitation links. +/// +public class InvitationLinksService( + IPermissionsChecker permissionsChecker, + IInvitationLinksRepository linksRepository, + ITodoListMembersRepository membersRepository, + IEntityExistenceChecker existenceChecker, + IOptions options + ) : IInvitationLinksService +{ + private readonly IPermissionsChecker _permissionsChecker = permissionsChecker; + private readonly IInvitationLinksRepository _linksRepository = linksRepository; + private readonly ITodoListMembersRepository _membersRepository = membersRepository; + private readonly IEntityExistenceChecker _existenceChecker = existenceChecker; + private readonly InvitationLinkOptions _options = options.Value; + + /// + /// Joins the caller to the to-do list by invitation list asynchronously. + /// + /// ID of the caller. + /// Invitation link to use. + /// + /// A task representing the asynchronous operation. The task contains + /// a result of the operation. + /// + public async Task JoinAsync(string callerId, string invitationLinkValue) + { + // Try to find a link + InvitationLink? invitationLink = await _linksRepository.FindAsync(invitationLinkValue); + if (invitationLink == null) return new(JoinByInvitationLinkStatus.NotFound); + + // Check if link is still valid + if (invitationLink.ValidTo < DateTime.UtcNow) return new(JoinByInvitationLinkStatus.Expired); + + // Check if user is not already a member + var member = await _membersRepository.FindAsync(invitationLink.TodoListId, callerId); + if (member != null) return new(JoinByInvitationLinkStatus.UserIsAlreadyMember); + + // Add a new member + TodoListMember newMember = new() + { + TodoListId = invitationLink.TodoListId, + UserId = callerId + }; + await _membersRepository.AddAsync(newMember); + var dto = newMember.Adapt(); + return new(JoinByInvitationLinkStatus.Success, dto); + } + + /// + /// Gets invitation links associated with the to-do list asynchronously. + /// + /// To-do list context of the operation. + /// Pagination parameters to use. + /// + /// A task representing the asynchronous operation. The task contains + /// a result of the operation. + /// + public async Task>> GetInvitationLinksAsync(TodoListContext context, + PaginationParameters parameters) + { + // Check if to-do list exists + if (!await _existenceChecker.ExistsAsync(context.TodoListId)) + return new(ServiceResponseStatus.NotFound); + + // Check if user has the permission to see links + if (!await _permissionsChecker.HasPermissionAsync( + context, x => x.ManageInvitationLinks || x.AddMembers)) + return new(ServiceResponseStatus.Forbidden); + + // Get the requested page + InvitationLinksSpecification specification = new(context.TodoListId); + var page = await _linksRepository + .GetPageAsync(parameters, specification); + // Return the page + return new(ServiceResponseStatus.Success, page); + } + + /// + /// Creates an invitation link associated to the to-do list asynchronously. + /// + /// To-do list context. + /// + /// A task representing the asynchronous operation. The task contains + /// a result of the operation. + /// + public async Task> CreateAsync(TodoListContext context) + { + // Check if to-do list exists + if (!await _existenceChecker.ExistsAsync(context.TodoListId)) + return new(ServiceResponseStatus.NotFound); + // Check if the user has the permission + if (!await _permissionsChecker.HasPermissionAsync(context, x => x.AddMembers)) + return new(ServiceResponseStatus.Forbidden); + + // Generate the link + using RandomNumberGenerator rng = RandomNumberGenerator.Create(); + byte[] valueBytes = new byte[_options.Size]; + rng.GetBytes(valueBytes); + + // Create the link + InvitationLink link = new() + { + TodoListId = context.TodoListId, + Value = Convert.ToBase64String(valueBytes), + ValidTo = DateTime.UtcNow.AddDays(_options.ExpirationDays) + }; + // Save it + await _linksRepository.AddAsync(link); + // Map it to DTO and return + var result = link.Adapt(); + return new(ServiceResponseStatus.Success, result); + } + + /// + /// Deletes an invitation link associted to the to-do list asynchronously. + /// + /// To-do list context. + /// ID of the link. + /// + /// A task representing the asynchronous operation. The task contains + /// a result of the operation. + /// + public async Task DeleteAsync(TodoListContext context, int linkId) + { + // Get the model of a link + var link = await _linksRepository.GetByIdAsync(linkId); + // Check if it's valid + if (link == null || link.TodoListId != context.TodoListId) + return ServiceResponseStatus.NotFound; + // Check if user has the permission + if (!await _permissionsChecker.HasPermissionAsync(context, x => x.ManageInvitationLinks)) + return ServiceResponseStatus.Forbidden; + + // Delete the link + await _linksRepository.DeleteAsync(link); + + return ServiceResponseStatus.Success; + } +} diff --git a/AdvancedTodoList.IntegrationTests/Factories/BusinessLogicWebApplicationFactory.cs b/AdvancedTodoList.IntegrationTests/Factories/BusinessLogicWebApplicationFactory.cs index 9fac5f8..8e368a6 100644 --- a/AdvancedTodoList.IntegrationTests/Factories/BusinessLogicWebApplicationFactory.cs +++ b/AdvancedTodoList.IntegrationTests/Factories/BusinessLogicWebApplicationFactory.cs @@ -20,6 +20,7 @@ public class BusinessLogicWebApplicationFactory : WebApplicationFactory public IRepository TodoItemsRepository { get; private set; } = null!; public IRepository TodoItemCategoriesRepository { get; private set; } = null!; public IRepository TestTodoListDependantEntitiesRepository { get; private set; } = null!; + public IInvitationLinksRepository InvitationLinksRepository { get; private set; } = null!; public ITodoListDependantEntitiesService TodoItemsHelperService { get; set; } = null!; public ITodoListDependantEntitiesService TodoItemCategoriesHelperService { get; set; } = null!; public ITodoListDependantEntitiesService TodoRolesHelperService { get; set; } = null!; @@ -38,6 +39,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) TodoItemsRepository = Substitute.For>(); TodoItemCategoriesRepository = Substitute.For>(); TestTodoListDependantEntitiesRepository = Substitute.For>(); + InvitationLinksRepository = Substitute.For(); TodoItemsHelperService = Substitute.For>(); TodoItemCategoriesHelperService = Substitute.For>(); TodoRolesHelperService = Substitute.For>(); @@ -59,6 +61,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) services.AddScoped(_ => TodoItemsRepository); services.AddScoped(_ => TodoItemCategoriesRepository); services.AddScoped(_ => TestTodoListDependantEntitiesRepository); + services.AddScoped(_ => InvitationLinksRepository); services.AddScoped(_ => TodoItemsHelperService); services.AddScoped(_ => TodoItemCategoriesHelperService); services.AddScoped(_ => TodoRolesHelperService); diff --git a/AdvancedTodoList.IntegrationTests/Services/InvitationLinksServiceTests.cs b/AdvancedTodoList.IntegrationTests/Services/InvitationLinksServiceTests.cs new file mode 100644 index 0000000..eb4caad --- /dev/null +++ b/AdvancedTodoList.IntegrationTests/Services/InvitationLinksServiceTests.cs @@ -0,0 +1,325 @@ +using AdvancedTodoList.Core.Dtos; +using AdvancedTodoList.Core.Models.TodoLists; +using AdvancedTodoList.Core.Models.TodoLists.Members; +using AdvancedTodoList.Core.Pagination; +using AdvancedTodoList.Core.Services; +using AdvancedTodoList.Core.Specifications; +using AdvancedTodoList.Infrastructure.Specifications; +using AdvancedTodoList.IntegrationTests.Fixtures; +using AdvancedTodoList.IntegrationTests.Utils; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace AdvancedTodoList.IntegrationTests.Services; + +[TestFixture] +public class InvitationLinksServiceTests : BusinessLogicFixture +{ + private const string TestCallerId = "CallerId"; + private const string TestTodoListId = "ListId"; + private readonly TodoListContext TestContext = new("ListId", "TestUserId"); + private IInvitationLinksService _service; + + [SetUp] + public void SetUp() + { + _service = ServiceScope.ServiceProvider.GetService()!; + } + + [Test] + public async Task JoinAsync_ValidCall_ReturnsSuccess() + { + // Arrange + InvitationLink link = TestModels.CreateTestInvitationLink(TestTodoListId); + WebApplicationFactory.InvitationLinksRepository + .FindAsync(link.Value) + .Returns(link); + WebApplicationFactory.TodoListMembersRepository + .FindAsync(TestTodoListId, TestCallerId) + .ReturnsNull(); + + // Act + var response = await _service.JoinAsync(TestCallerId, link.Value); + + // Assert + Assert.Multiple(() => + { + Assert.That(response.Status, Is.EqualTo(JoinByInvitationLinkStatus.Success)); + Assert.That(response.Dto, Is.Not.Null); + }); + } + + [Test] + public async Task JoinAsync_LinkDoesNotExist_ReturnsNotFound() + { + // Arrange + string linkValue = "0wnh0w93n"; + WebApplicationFactory.InvitationLinksRepository + .FindAsync(linkValue) + .ReturnsNull(); + + // Act + var response = await _service.JoinAsync(TestCallerId, linkValue); + + // Assert + Assert.That(response.Status, Is.EqualTo(JoinByInvitationLinkStatus.NotFound)); + } + + [Test] + public async Task JoinAsync_ExpiredLink_ReturnsExpired() + { + // Arrange + InvitationLink link = TestModels.CreateTestInvitationLink( + TestTodoListId, DateTime.UtcNow.AddDays(-30)); + WebApplicationFactory.InvitationLinksRepository + .FindAsync(link.Value) + .Returns(link); + + // Act + var response = await _service.JoinAsync(TestCallerId, link.Value); + + // Assert + Assert.That(response.Status, Is.EqualTo(JoinByInvitationLinkStatus.Expired)); + } + + [Test] + public async Task JoinAsync_CallerIsTodoListMember_ReturnsUserIsAlreadyMember() + { + // Arrange + InvitationLink link = TestModels.CreateTestInvitationLink(TestTodoListId); + WebApplicationFactory.InvitationLinksRepository + .FindAsync(link.Value) + .Returns(link); + WebApplicationFactory.TodoListMembersRepository + .FindAsync(TestTodoListId, TestCallerId) + .Returns(new TodoListMember() { TodoListId = TestTodoListId, UserId = TestCallerId }); + + // Act + var response = await _service.JoinAsync(TestCallerId, link.Value); + + // Assert + Assert.That(response.Status, Is.EqualTo(JoinByInvitationLinkStatus.UserIsAlreadyMember)); + } + + [Test] + [TestCase(true, false)] + [TestCase(false, true)] + [TestCase(true, true)] + public async Task GetPageAsync_ListExists_AppliesSpecification(bool addMembersPermission, bool manageLinksPermission) + { + // Arrange + PaginationParameters parameters = new(2, 5); + RolePermissions validPermissions = new( + AddMembers: addMembersPermission, + ManageInvitationLinks: manageLinksPermission); + Page page = new([], parameters.Page, parameters.PageSize, 0); + WebApplicationFactory.EntityExistenceChecker + .ExistsAsync(TestContext.TodoListId) + .Returns(true); + WebApplicationFactory.PermissionsChecker + .HasPermissionAsync(TestContext, Arg.Any>()) + .Returns(x => ((Func)x[1])(validPermissions)); + WebApplicationFactory.InvitationLinksRepository + .GetPageAsync(Arg.Any(), + Arg.Any>()) + .Returns(page); + + // Act + var result = await _service.GetInvitationLinksAsync(TestContext, parameters); + + // Assert + Assert.That(result.Status, Is.EqualTo(ServiceResponseStatus.Success)); + await WebApplicationFactory.InvitationLinksRepository + .Received() + .GetPageAsync(parameters, Arg.Any()); + } + + [Test] + public async Task GetPageAsync_ListDoesNotExist_ReturnsNotFoundStatus() + { + // Arrange + PaginationParameters parameters = new(2, 5); + WebApplicationFactory.EntityExistenceChecker + .ExistsAsync(TestContext.TodoListId) + .Returns(false); + WebApplicationFactory.PermissionsChecker + .HasPermissionAsync(TestContext, Arg.Any>()) + .Returns(x => true); + + // Act + var result = await _service.GetInvitationLinksAsync(TestContext, parameters); + + // Assert + Assert.That(result.Status, Is.EqualTo(ServiceResponseStatus.NotFound)); + } + + [Test] + public async Task GetPageAsync_UserIsNotMember_ReturnsForbidden() + { + // Arrange + PaginationParameters parameters = new(2, 5); + WebApplicationFactory.EntityExistenceChecker + .ExistsAsync(TestContext.TodoListId) + .Returns(true); + WebApplicationFactory.PermissionsChecker + .IsMemberOfListAsync(TestContext) + .Returns(false); + + // Act + var result = await _service.GetInvitationLinksAsync(TestContext, parameters); + + // Assert + Assert.That(result.Status, Is.EqualTo(ServiceResponseStatus.Forbidden)); + } + + + [Test] + public async Task CreateAsync_ValidCall_AddsEntityToDb() + { + // Arrange + RolePermissions validPermissions = new(AddMembers: true); + WebApplicationFactory.PermissionsChecker + .HasPermissionAsync(TestContext, Arg.Any>()) + .Returns(x => ((Func)x[1])(validPermissions)); + WebApplicationFactory.EntityExistenceChecker + .ExistsAsync(TestContext.TodoListId) + .Returns(true); + WebApplicationFactory.InvitationLinksRepository + .AddAsync(Arg.Any()) + .Returns(Task.FromResult); + + // Act + var response = await _service.CreateAsync(TestContext); + + // Assert + Assert.That(response.Status, Is.EqualTo(ServiceResponseStatus.Success)); + await WebApplicationFactory.InvitationLinksRepository + .Received() + .AddAsync(Arg.Is(x => x.TodoListId == TestContext.TodoListId)); + Assert.That(response.Result, Is.Not.Null); + // Refresh token is not a default base64 string + byte[] base64Link = Convert.FromBase64String(response.Result.Value); + Assert.That(base64Link.All(x => x == 0), Is.False); + } + + [Test] + public async Task CreateAsync_TodoListDoesNotExist_ReturnsNotFound() + { + // Arrange + WebApplicationFactory.PermissionsChecker + .HasPermissionAsync(TestContext, Arg.Any>()) + .Returns(true); + WebApplicationFactory.EntityExistenceChecker + .ExistsAsync(TestContext.TodoListId) + .Returns(false); + + // Act: call the method + var result = await _service.CreateAsync(TestContext); + + // Assert + Assert.That(result.Status, Is.EqualTo(ServiceResponseStatus.NotFound)); + } + + [Test] + public async Task CreateAsync_UserHasNoPermssion_ReturnsForbidden() + { + // Arrange + WebApplicationFactory.PermissionsChecker + .HasPermissionAsync(TestContext, Arg.Any>()) + .Returns(false); + WebApplicationFactory.EntityExistenceChecker + .ExistsAsync(TestContext.TodoListId) + .Returns(true); + + // Act: call the method + var result = await _service.CreateAsync(TestContext); + + // Assert + Assert.That(result.Status, Is.EqualTo(ServiceResponseStatus.Forbidden)); + } + + [Test] + public async Task DeleteAsync_ValidCall_Succeeds() + { + // Arrange + var entity = TestModels.CreateTestInvitationLink(TestContext.TodoListId); + RolePermissions validPermissions = new(ManageInvitationLinks: true); + WebApplicationFactory.PermissionsChecker + .HasPermissionAsync(TestContext, Arg.Any>()) + .Returns(x => ((Func)x[1])(validPermissions)); + WebApplicationFactory.InvitationLinksRepository + .GetByIdAsync(entity.Id) + .Returns(entity); + WebApplicationFactory.InvitationLinksRepository + .DeleteAsync(Arg.Any()) + .Returns(Task.FromResult); + + // Act + var result = await _service.DeleteAsync(TestContext, entity.Id); + + // Assert + Assert.That(result, Is.EqualTo(ServiceResponseStatus.Success)); + // Assert that delete was called + await WebApplicationFactory.InvitationLinksRepository + .Received() + .DeleteAsync(Arg.Is(x => x.Id == entity.Id)); + } + + [Test] + public async Task DeleteAsync_EntityDoesNotExist_ReturnsNotFound() + { + // Arrange + int entityId = 500; + WebApplicationFactory.InvitationLinksRepository + .GetByIdAsync(entityId) + .ReturnsNull(); + + // Act + var result = await _service.DeleteAsync(TestContext, entityId); + + // Assert + Assert.That(result, Is.EqualTo(ServiceResponseStatus.NotFound)); + } + + [Test] + public async Task DeleteAsync_InvalidTodoListId_ReturnsNotFound() + { + // Arrange + int entityId = 500; + var entity = TestModels.CreateTestInvitationLink("Wrong to-do list ID"); + WebApplicationFactory.InvitationLinksRepository + .GetByIdAsync(entity.Id) + .Returns(entity); + WebApplicationFactory.PermissionsChecker + .HasPermissionAsync(TestContext, Arg.Any>()) + .Returns(x => false); + + // Act + var result = await _service.DeleteAsync(TestContext, entityId); + + // Assert + Assert.That(result, Is.EqualTo(ServiceResponseStatus.NotFound)); + } + + [Test] + public async Task DeleteAsync_UserHasNoPermission_ReturnsForbidden() + { + // Arrange + var entity = TestModels.CreateTestInvitationLink(TestContext.TodoListId); + WebApplicationFactory.InvitationLinksRepository + .GetByIdAsync(entity.Id) + .Returns(entity); + WebApplicationFactory.PermissionsChecker + .HasPermissionAsync(TestContext, Arg.Any>()) + .Returns(x => false); + + // Act + var result = await _service.DeleteAsync(TestContext, entity.Id); + + // Assert + Assert.That(result, Is.EqualTo(ServiceResponseStatus.Forbidden)); + } +} diff --git a/AdvancedTodoList.IntegrationTests/Utils/TestModels.cs b/AdvancedTodoList.IntegrationTests/Utils/TestModels.cs index 813de5c..ff92285 100644 --- a/AdvancedTodoList.IntegrationTests/Utils/TestModels.cs +++ b/AdvancedTodoList.IntegrationTests/Utils/TestModels.cs @@ -87,10 +87,10 @@ public static ApplicationUser CreateTestUser() /// /// Creates and returns a valid model of an invitation link. /// - public static InvitationLink CreateTestInvitationLink(string todoListId) => new() + public static InvitationLink CreateTestInvitationLink(string todoListId, DateTime? validTo = null) => new() { Value = Guid.NewGuid().ToString(), TodoListId = todoListId, - ValidTo = DateTime.Now.AddDays(5) + ValidTo = validTo ?? DateTime.Now.AddDays(5) }; } diff --git a/AdvancedTodoList/Program.cs b/AdvancedTodoList/Program.cs index c3038a4..0c3a6e0 100644 --- a/AdvancedTodoList/Program.cs +++ b/AdvancedTodoList/Program.cs @@ -95,6 +95,8 @@ builder.Configuration.GetSection("Auth:AccessToken")); builder.Services.Configure( builder.Configuration.GetSection("Auth:RefreshToken")); +builder.Services.Configure( + builder.Configuration.GetSection("Todo:InvitationLink")); // Register application services builder.Services.AddScoped(); @@ -104,6 +106,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); diff --git a/AdvancedTodoList/appsettings.json b/AdvancedTodoList/appsettings.json index cbc0608..10aa40a 100644 --- a/AdvancedTodoList/appsettings.json +++ b/AdvancedTodoList/appsettings.json @@ -20,5 +20,11 @@ "ExpirationDays": "90" } }, + "Todo": { + "InvitationLink": { + "Size": "12", + "ExpirationDays": "14" + } + }, "AllowedHosts": "*" } From 176ecc2530180ec352933a04b890b15c7afc38d1 Mon Sep 17 00:00:00 2001 From: romandykyi Date: Mon, 1 Apr 2024 20:03:03 +0200 Subject: [PATCH 6/8] Implement join by invitation link endpoint --- .../Dtos/TodoListMembersDtos.cs | 2 +- .../Endpoints/JoinTodoListEndpointsTests.cs | 96 +++++++++++++++++++ .../EndpointsWebApplicationFactory.cs | 3 + .../Controllers/JoinTodoListController.cs | 46 +++++++++ 4 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 AdvancedTodoList.IntegrationTests/Endpoints/JoinTodoListEndpointsTests.cs create mode 100644 AdvancedTodoList/Controllers/JoinTodoListController.cs diff --git a/AdvancedTodoList.Core/Dtos/TodoListMembersDtos.cs b/AdvancedTodoList.Core/Dtos/TodoListMembersDtos.cs index d9d568e..4179c78 100644 --- a/AdvancedTodoList.Core/Dtos/TodoListMembersDtos.cs +++ b/AdvancedTodoList.Core/Dtos/TodoListMembersDtos.cs @@ -1,7 +1,7 @@ namespace AdvancedTodoList.Core.Dtos; /// -/// DTO for a minimal view of a to-do list. +/// DTO for a minimal view of a to-do list member. /// public record TodoListMemberMinimalViewDto(int Id, string UserId, string TodoListId, int? RoleId); diff --git a/AdvancedTodoList.IntegrationTests/Endpoints/JoinTodoListEndpointsTests.cs b/AdvancedTodoList.IntegrationTests/Endpoints/JoinTodoListEndpointsTests.cs new file mode 100644 index 0000000..1300777 --- /dev/null +++ b/AdvancedTodoList.IntegrationTests/Endpoints/JoinTodoListEndpointsTests.cs @@ -0,0 +1,96 @@ +using AdvancedTodoList.Core.Dtos; +using AdvancedTodoList.Core.Services; +using AdvancedTodoList.IntegrationTests.Fixtures; +using System.Net; +using System.Net.Http.Json; + +namespace AdvancedTodoList.IntegrationTests.Endpoints; + +[TestFixture] +public class JoinTodoListEndpointsTests : EndpointsFixture +{ + private const string TestCallerId = TestUserId; + private const string TestInvitationLink = "abc"; + + [Test] + public async Task JoinByInvitationLink_ValidCall_Succeeds() + { + // Arrange + TodoListMemberMinimalViewDto expectedDto = new(1, TestCallerId, "to-do-list-id", null); + JoinByInvitationLinkResult response = new(JoinByInvitationLinkStatus.Success, expectedDto); + WebApplicationFactory.InvitationLinksService + .JoinAsync(TestCallerId, TestInvitationLink) + .Returns(response); + using HttpClient client = CreateAuthorizedHttpClient(); + + // Act: send the request + var result = await client.PostAsync($"api/todo/join/{TestInvitationLink}", null); + + // Assert that response indicates success + result.EnsureSuccessStatusCode(); + // Assert that expected dto was returned + var returnedDto = await result.Content.ReadFromJsonAsync(); + Assert.That(returnedDto, Is.EqualTo(expectedDto)); + } + + [Test] + public async Task JoinByInvitationLink_ExpiredLinkStatus_Fails() + { + // Arrange + WebApplicationFactory.InvitationLinksService + .JoinAsync(TestCallerId, TestInvitationLink) + .Returns(new JoinByInvitationLinkResult(JoinByInvitationLinkStatus.Expired)); + using HttpClient client = CreateAuthorizedHttpClient(); + + // Act: send the request + var result = await client.PostAsync($"api/todo/join/{TestInvitationLink}", null); + + // Assert that response code indicates failure + Assert.That(result.IsSuccessStatusCode, Is.False, "Status code that indicated failure was expected."); + } + + [Test] + public async Task JoinByInvitationLink_UserIsAlreadyMemberStatus_Fails() + { + // Arrange + WebApplicationFactory.InvitationLinksService + .JoinAsync(TestCallerId, TestInvitationLink) + .Returns(new JoinByInvitationLinkResult(JoinByInvitationLinkStatus.UserIsAlreadyMember)); + using HttpClient client = CreateAuthorizedHttpClient(); + + // Act: send the request + var result = await client.PostAsync($"api/todo/join/{TestInvitationLink}", null); + + // Assert that response code indicates failure + Assert.That(result.IsSuccessStatusCode, Is.False, "Status code that indicated failure was expected."); + } + + [Test] + public async Task JoinByInvitationLink_NotFoundStatus_Returns404() + { + // Arrange + WebApplicationFactory.InvitationLinksService + .JoinAsync(TestCallerId, TestInvitationLink) + .Returns(new JoinByInvitationLinkResult(JoinByInvitationLinkStatus.NotFound)); + using HttpClient client = CreateAuthorizedHttpClient(); + + // Act: send the request + var result = await client.PostAsync($"api/todo/join/{TestInvitationLink}", null); + + // Assert that response code is 404 + Assert.That(result.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + } + + [Test] + public async Task JoinByInvitationLink_NoAuthHeaderProvided_Returns401() + { + // Arrange + using HttpClient client = WebApplicationFactory.CreateClient(); + + // Act: send the request + var result = await client.PostAsync($"api/todo/join/{TestInvitationLink}", null); + + // Assert that response code is 401 + Assert.That(result.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); + } +} diff --git a/AdvancedTodoList.IntegrationTests/Factories/EndpointsWebApplicationFactory.cs b/AdvancedTodoList.IntegrationTests/Factories/EndpointsWebApplicationFactory.cs index 284375c..1916297 100644 --- a/AdvancedTodoList.IntegrationTests/Factories/EndpointsWebApplicationFactory.cs +++ b/AdvancedTodoList.IntegrationTests/Factories/EndpointsWebApplicationFactory.cs @@ -17,6 +17,7 @@ public class EndpointsWebApplicationFactory : WebApplicationFactory public ITodoItemCategoriesService TodoItemCategoriesService { get; private set; } = null!; public ITodoListRolesService TodoListRolesService { get; private set; } = null!; public ITodoListMembersService TodoListMembersService { get; private set; } = null!; + public IInvitationLinksService InvitationLinksService { get; private set; } = null!; public IEntityExistenceChecker EntityExistenceChecker { get; private set; } = null!; protected override void ConfigureWebHost(IWebHostBuilder builder) @@ -28,6 +29,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) TodoItemCategoriesService = Substitute.For(); TodoListRolesService = Substitute.For(); TodoListMembersService = Substitute.For(); + InvitationLinksService = Substitute.For(); EntityExistenceChecker = Substitute.For(); builder.ConfigureTestServices(services => @@ -38,6 +40,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) services.AddScoped(_ => TodoItemCategoriesService); services.AddScoped(_ => TodoListRolesService); services.AddScoped(_ => TodoListMembersService); + services.AddScoped(_ => InvitationLinksService); services.AddScoped(_ => EntityExistenceChecker); }); } diff --git a/AdvancedTodoList/Controllers/JoinTodoListController.cs b/AdvancedTodoList/Controllers/JoinTodoListController.cs new file mode 100644 index 0000000..144a91e --- /dev/null +++ b/AdvancedTodoList/Controllers/JoinTodoListController.cs @@ -0,0 +1,46 @@ +using AdvancedTodoList.Core.Dtos; +using AdvancedTodoList.Core.Services; +using AdvancedTodoList.Extensions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace AdvancedTodoList.Controllers; + +[Authorize] +[ApiController] +[Route("api/todo/join")] +public class JoinTodoListController(IInvitationLinksService invitationLinksService) : ControllerBase +{ + private readonly IInvitationLinksService _invitationLinksService = invitationLinksService; + + /// + /// Uses the invitation link to join the caller to a to-do list. + /// + /// Value of the invitation link. + /// Successfully joined. + /// Authentication failed. + /// Link was not found. + /// Link is expired. + /// Caller is already the member of the to-do list. + [HttpPost("{invitationLinkValue}")] + [ProducesResponseType(typeof(TodoListMemberMinimalViewDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status410Gone)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + public async Task JoinByInvitationLinkAsync([FromRoute] string invitationLinkValue) + { + var response = await _invitationLinksService.JoinAsync(User.GetUserId()!, invitationLinkValue); + + return response.Status switch + { + JoinByInvitationLinkStatus.Success => Ok(response.Dto), + JoinByInvitationLinkStatus.NotFound => NotFound(), + JoinByInvitationLinkStatus.Expired => Problem( + statusCode: StatusCodes.Status410Gone, detail: "Invitation link is expired."), + JoinByInvitationLinkStatus.UserIsAlreadyMember => Problem( + statusCode: StatusCodes.Status422UnprocessableEntity, detail: "Caller is already the member of the to-do list."), + _ => throw new InvalidOperationException("Invalid invitation links service response.") + }; + } +} From 780b3e8392365cc254f01d6ecd34ef1fcdbaf96f Mon Sep 17 00:00:00 2001 From: romandykyi Date: Mon, 1 Apr 2024 20:33:37 +0200 Subject: [PATCH 7/8] Implement invitation links endpoints --- .../InvitationLinksEndpointsTests.cs | 241 ++++++++++++++++++ .../Controllers/InvitationLinksController.cs | 80 ++++++ 2 files changed, 321 insertions(+) create mode 100644 AdvancedTodoList.IntegrationTests/Endpoints/InvitationLinksEndpointsTests.cs create mode 100644 AdvancedTodoList/Controllers/InvitationLinksController.cs diff --git a/AdvancedTodoList.IntegrationTests/Endpoints/InvitationLinksEndpointsTests.cs b/AdvancedTodoList.IntegrationTests/Endpoints/InvitationLinksEndpointsTests.cs new file mode 100644 index 0000000..28e92e3 --- /dev/null +++ b/AdvancedTodoList.IntegrationTests/Endpoints/InvitationLinksEndpointsTests.cs @@ -0,0 +1,241 @@ +using AdvancedTodoList.Core.Dtos; +using AdvancedTodoList.Core.Pagination; +using AdvancedTodoList.Core.Services; +using AdvancedTodoList.IntegrationTests.Fixtures; +using System.Net; +using System.Net.Http.Json; + +namespace AdvancedTodoList.IntegrationTests.Endpoints; + +[TestFixture] +public class InvitationLinksEndpointsTests : EndpointsFixture +{ + private readonly TodoListContext TestContext = new("TodoListId", TestUserId); + + [Test] + public async Task GetInvitationLinks_ValidCall_SucceedsAndReturnsLinks() + { + // Arrange + PaginationParameters parameters = new(Page: 2, PageSize: 20); + InvitationLinkDto[] invitationLinks = + [ + new(1, "1", DateTime.UtcNow.AddDays(-30)), + new(2, "2", DateTime.UtcNow), + ]; + WebApplicationFactory.InvitationLinksService + .GetInvitationLinksAsync(TestContext, parameters) + .Returns(x => new ServiceResponse>( + ServiceResponseStatus.Success, new(invitationLinks, ((PaginationParameters)x[1]).Page, + ((PaginationParameters)x[1]).PageSize, 22))); + using HttpClient client = CreateAuthorizedHttpClient(); + + // Act: send the request + var result = await client.GetAsync($"api/todo/{TestContext.TodoListId}/invitationLinks?page={parameters.Page}&pageSize={parameters.PageSize}"); + + // Assert that response indicates success + result.EnsureSuccessStatusCode(); + // Assert that valid page was returned + var returnedPage = await result.Content.ReadFromJsonAsync>(); + Assert.That(returnedPage, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(returnedPage.PageNumber, Is.EqualTo(parameters.Page)); + Assert.That(returnedPage.PageSize, Is.EqualTo(parameters.PageSize)); + Assert.That(returnedPage.Items, Is.EquivalentTo(invitationLinks)); + }); + } + + [Test] + public async Task GetInvitationLinks_WrongPaginationParams_Returns400() + { + // Arrange + using HttpClient client = CreateAuthorizedHttpClient(); + int page = -1; + int pageSize = 0; + + // Act: send the request + var result = await client.GetAsync($"api/todo/{TestContext.TodoListId}/invitationLinks?page={page}&pageSize={pageSize}"); + + // Assert that response code is 400 + Assert.That(result.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + } + + [Test] + public async Task GetInvitationLinks_NoAuthHeaderProvided_Returns401() + { + // Arrange + using HttpClient client = WebApplicationFactory.CreateClient(); + + // Act: send the request + var result = await client.GetAsync($"api/todo/{TestContext.TodoListId}/invitationLinks?page=1&pageSize=20"); + + // Assert that response code is 401 + Assert.That(result.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); + } + + [Test] + public async Task GetInvitationLinks_NotFoundStatus_Returns404() + { + // Arrange + WebApplicationFactory.InvitationLinksService + .GetInvitationLinksAsync(TestContext, Arg.Any()) + .Returns(x => new ServiceResponse>(ServiceResponseStatus.NotFound)); + using HttpClient client = CreateAuthorizedHttpClient(); + + // Act: send the request + var result = await client.GetAsync($"api/todo/{TestContext.TodoListId}/invitationLinks?page=1&pageSize=20"); + + // Assert that response code is 404 + Assert.That(result.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + } + + [Test] + public async Task GetInvitationLinks_ForbiddenStatus_Returns403() + { + // Arrange + WebApplicationFactory.InvitationLinksService + .GetInvitationLinksAsync(TestContext, Arg.Any()) + .Returns(x => new ServiceResponse>(ServiceResponseStatus.Forbidden)); + using HttpClient client = CreateAuthorizedHttpClient(); + + // Act: send the request + var result = await client.GetAsync($"api/todo/{TestContext.TodoListId}/invitationLinks?page=1&pageSize=20"); + + // Assert that response code is 403 + Assert.That(result.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); + } + + [Test] + public async Task PostInvitationLink_ValidCall_Succeeds() + { + // Arrange + WebApplicationFactory.InvitationLinksService + .CreateAsync(TestContext) + .Returns(new ServiceResponse(ServiceResponseStatus.Success)); + using HttpClient client = CreateAuthorizedHttpClient(); + + // Act: send the request + var result = await client.PostAsync($"api/todo/{TestContext.TodoListId}/invitationLinks", null); + + // Assert that response indicates success + result.EnsureSuccessStatusCode(); + // Assert that create method was called + await WebApplicationFactory.InvitationLinksService + .Received() + .CreateAsync(TestContext); + } + + [Test] + public async Task PostInvitationLink_NotFoundStatus_Returns404() + { + // Arrange + WebApplicationFactory.InvitationLinksService + .CreateAsync(TestContext) + .Returns(new ServiceResponse(ServiceResponseStatus.NotFound)); + using HttpClient client = CreateAuthorizedHttpClient(); + + // Act: send the request + var result = await client.PostAsync($"api/todo/{TestContext.TodoListId}/invitationLinks", null); + + // Assert that response code is 404 + Assert.That(result.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + } + + [Test] + public async Task PostInvitationLink_ForbiddenStatus_Returns403() + { + // Arrange + WebApplicationFactory.InvitationLinksService + .CreateAsync(TestContext) + .Returns(new ServiceResponse(ServiceResponseStatus.Forbidden)); + using HttpClient client = CreateAuthorizedHttpClient(); + + // Act: send the request + var result = await client.PostAsync($"api/todo/{TestContext.TodoListId}/invitationLinks", null); + + // Assert that response code is 403 + Assert.That(result.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); + } + + [Test] + public async Task PostInvitationLink_NoAuthHeaderProvided_Returns401() + { + // Arrange + using HttpClient client = WebApplicationFactory.CreateClient(); + + // Act: send the request + var result = await client.PostAsync($"api/todo/{TestContext.TodoListId}/invitationLinks", null); + + // Assert that response code is 401 + Assert.That(result.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); + } + + [Test] + public async Task DeleteInvitationLink_ValidCall_Succeeds() + { + // Arrange + int testInvitationLinkId = 504030; + WebApplicationFactory.InvitationLinksService + .DeleteAsync(TestContext, testInvitationLinkId) + .Returns(ServiceResponseStatus.Success); + using HttpClient client = CreateAuthorizedHttpClient(); + + // Act: send the request + var result = await client.DeleteAsync($"api/todo/{TestContext.TodoListId}/invitationLinks/{testInvitationLinkId}"); + + // Assert that response indicates success + result.EnsureSuccessStatusCode(); + // Assert that delete was called + await WebApplicationFactory.InvitationLinksService + .Received() + .DeleteAsync(TestContext, testInvitationLinkId); + } + + [Test] + public async Task DeleteInvitationLink_NotFoundStatus_Returns404() + { + // Arrange + int testInvitationLinkId = 504030; + WebApplicationFactory.InvitationLinksService + .DeleteAsync(TestContext, testInvitationLinkId) + .Returns(ServiceResponseStatus.NotFound); + using HttpClient client = CreateAuthorizedHttpClient(); + + // Act: send the request + var result = await client.DeleteAsync($"api/todo/{TestContext.TodoListId}/invitationLinks/{testInvitationLinkId}"); + + // Assert that response code is 404 + Assert.That(result.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + } + + [Test] + public async Task DeleteInvitationLink_ForbiddenStatus_Returns403() + { + // Arrange + int testInvitationLinkId = 504030; + WebApplicationFactory.InvitationLinksService + .DeleteAsync(TestContext, testInvitationLinkId) + .Returns(ServiceResponseStatus.Forbidden); + using HttpClient client = CreateAuthorizedHttpClient(); + + // Act: send the request + var result = await client.DeleteAsync($"api/todo/{TestContext.TodoListId}/invitationLinks/{testInvitationLinkId}"); + + // Assert that response code is 403 + Assert.That(result.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); + } + + [Test] + public async Task DeleteInvitationLink_NoAuthHeaderProvided_Returns401() + { + // Arrange + int testInvitationLinkId = 504030; + using HttpClient client = WebApplicationFactory.CreateClient(); + + // Act: send the request + var result = await client.DeleteAsync($"api/todo/{TestContext.TodoListId}/invitationLinks/{testInvitationLinkId}"); + + // Assert that response code is 401 + Assert.That(result.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized)); + } +} diff --git a/AdvancedTodoList/Controllers/InvitationLinksController.cs b/AdvancedTodoList/Controllers/InvitationLinksController.cs new file mode 100644 index 0000000..9e924b5 --- /dev/null +++ b/AdvancedTodoList/Controllers/InvitationLinksController.cs @@ -0,0 +1,80 @@ +using AdvancedTodoList.Core.Dtos; +using AdvancedTodoList.Core.Pagination; +using AdvancedTodoList.Core.Services; +using AdvancedTodoList.Extensions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace AdvancedTodoList.Controllers; + +[Authorize] +[ApiController] +[Route("api/todo/{listId}/invitationLinks")] +public class InvitationLinksController(IInvitationLinksService invitationLinksService) : ControllerBase +{ + private readonly IInvitationLinksService _invitationLinksService = invitationLinksService; + + /// + /// Gets a page with invitation links of the to-do list with the specified ID. + /// + /// ID of the to-do list. + /// Paginations parameters to apply. + /// Returns invitation links of the to-do list. + /// Authentication failed. + /// User has no permission to perform this action. + /// To-do list was not found. + [HttpGet(Name = nameof(GetInvitationLinksAsync))] + [ProducesResponseType(typeof(Page), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetInvitationLinksAsync( + [FromRoute] string listId, [FromQuery] PaginationParameters paginationParameters) + { + TodoListContext context = new(listId, User.GetUserId()!); + var result = await _invitationLinksService.GetInvitationLinksAsync(context, paginationParameters); + return result.ToActionResult(); + } + + /// + /// Creates a new to-do list invitation link. + /// + /// ID of the to-do list which will contain the invitation link. + /// Successfully created. + /// Validation failed. + /// Authentication failed. + /// User has no permission to perform this action. + /// To-do list was not found. + [HttpPost] + [ProducesResponseType(typeof(InvitationLinkDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task PostInvitationLinkAsync([FromRoute] string listId) + { + TodoListContext context = new(listId, User.GetUserId()!); + var response = await _invitationLinksService.CreateAsync(context); + return response.ToActionResult(); + } + + /// + /// Deletes a to-do list invitation link. + /// + /// Success. + /// Authentication failed. + /// User has no permission to perform this action. + /// To-do list invitation link was not found. + [HttpDelete("{invitationLinkId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeleteInvitationLinkAsync( + [FromRoute] string listId, [FromRoute] int invitationLinkId) + { + TodoListContext context = new(listId, User.GetUserId()!); + var result = await _invitationLinksService.DeleteAsync(context, invitationLinkId); + return result.ToActionResult(); + } +} From 14053bcda9119468135e1d3f4d3bebe669ad6723 Mon Sep 17 00:00:00 2001 From: romandykyi Date: Mon, 1 Apr 2024 20:39:20 +0200 Subject: [PATCH 8/8] Run Visual Studio code cleanup --- .../Services/InvitationLinksServiceTests.cs | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/AdvancedTodoList.IntegrationTests/Services/InvitationLinksServiceTests.cs b/AdvancedTodoList.IntegrationTests/Services/InvitationLinksServiceTests.cs index eb4caad..89dca79 100644 --- a/AdvancedTodoList.IntegrationTests/Services/InvitationLinksServiceTests.cs +++ b/AdvancedTodoList.IntegrationTests/Services/InvitationLinksServiceTests.cs @@ -7,11 +7,6 @@ using AdvancedTodoList.Infrastructure.Specifications; using AdvancedTodoList.IntegrationTests.Fixtures; using AdvancedTodoList.IntegrationTests.Utils; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace AdvancedTodoList.IntegrationTests.Services; @@ -77,7 +72,7 @@ public async Task JoinAsync_ExpiredLink_ReturnsExpired() WebApplicationFactory.InvitationLinksRepository .FindAsync(link.Value) .Returns(link); - + // Act var response = await _service.JoinAsync(TestCallerId, link.Value); @@ -96,7 +91,7 @@ public async Task JoinAsync_CallerIsTodoListMember_ReturnsUserIsAlreadyMember() WebApplicationFactory.TodoListMembersRepository .FindAsync(TestTodoListId, TestCallerId) .Returns(new TodoListMember() { TodoListId = TestTodoListId, UserId = TestCallerId }); - + // Act var response = await _service.JoinAsync(TestCallerId, link.Value); @@ -113,7 +108,7 @@ public async Task GetPageAsync_ListExists_AppliesSpecification(bool addMembersPe // Arrange PaginationParameters parameters = new(2, 5); RolePermissions validPermissions = new( - AddMembers: addMembersPermission, + AddMembers: addMembersPermission, ManageInvitationLinks: manageLinksPermission); Page page = new([], parameters.Page, parameters.PageSize, 0); WebApplicationFactory.EntityExistenceChecker @@ -128,7 +123,7 @@ public async Task GetPageAsync_ListExists_AppliesSpecification(bool addMembersPe .Returns(page); // Act - var result = await _service.GetInvitationLinksAsync(TestContext, parameters); + var result = await _service.GetInvitationLinksAsync(TestContext, parameters); // Assert Assert.That(result.Status, Is.EqualTo(ServiceResponseStatus.Success));