From fd968d792b947ca867ba1a5d5df898f6862c3015 Mon Sep 17 00:00:00 2001 From: Li Date: Sun, 30 Jun 2024 19:48:58 +1000 Subject: [PATCH] Add an API for creating shared items --- .csharpierignore | 1 + .github/workflows/api-test.yml | 49 +++ .../{unit-test.yml => ui-unit-test.yml} | 9 +- .../Controllers/SharedItemController.cs | 79 +++++ LiftLog.Api/Controllers/UserController.cs | 5 +- LiftLog.Api/Db/UserDataContext.cs | 8 + .../20240630015536_SharedItems.Designer.cs | 289 ++++++++++++++++++ .../UserData/20240630015536_SharedItems.cs | 51 ++++ .../UserData/UserDataContextModelSnapshot.cs | 53 ++++ LiftLog.Api/Models/SharedItem.cs | 25 ++ LiftLog.Api/Program.cs | 2 + LiftLog.Api/Service/IdEncodingService.cs | 11 +- .../Validators/SharedItemRequestValidators.cs | 21 ++ LiftLog.Lib/Models/ShareActions.cs | 19 ++ LiftLog.Ui/AssemblyInfo.cs | 2 +- LiftLog.sln | 38 ++- ...ppStorePurchaseVerificationServiceTests.cs | 2 +- tests/LiftLog.Tests.Api/GlobalUsings.cs | 5 + .../Integration/Helpers/UserHelper.cs | 33 ++ .../Integration/SharedItemIntegrationTests.cs | 135 ++++++++ .../LiftLog.Tests.Api.csproj | 32 ++ .../LiftLog.Tests.App}/Blueprints.cs | 0 .../Encryption/OsEncryptionServiceTests.cs | 0 .../LiftLog.Tests.App}/GlobalUsings.cs | 0 .../LiftLog.Tests.App}/Helpers.cs | 0 .../LiftLog.Tests.App.csproj | 11 +- .../Reducers/CurrentSessionReducerTests.cs | 0 .../Serialization/SerializationTests.cs | 0 .../LiftLog.Tests.App}/Sessions.cs | 0 29 files changed, 862 insertions(+), 18 deletions(-) create mode 100644 .csharpierignore create mode 100644 .github/workflows/api-test.yml rename .github/workflows/{unit-test.yml => ui-unit-test.yml} (70%) create mode 100644 LiftLog.Api/Controllers/SharedItemController.cs create mode 100644 LiftLog.Api/Migrations/UserData/20240630015536_SharedItems.Designer.cs create mode 100644 LiftLog.Api/Migrations/UserData/20240630015536_SharedItems.cs create mode 100644 LiftLog.Api/Models/SharedItem.cs create mode 100644 LiftLog.Api/Validators/SharedItemRequestValidators.cs create mode 100644 LiftLog.Lib/Models/ShareActions.cs rename {LiftLog.Tests/Backend => tests/LiftLog.Tests.Api}/AppleAppStorePurchaseVerificationServiceTests.cs (97%) create mode 100644 tests/LiftLog.Tests.Api/GlobalUsings.cs create mode 100644 tests/LiftLog.Tests.Api/Integration/Helpers/UserHelper.cs create mode 100644 tests/LiftLog.Tests.Api/Integration/SharedItemIntegrationTests.cs create mode 100644 tests/LiftLog.Tests.Api/LiftLog.Tests.Api.csproj rename {LiftLog.Tests => tests/LiftLog.Tests.App}/Blueprints.cs (100%) rename {LiftLog.Tests => tests/LiftLog.Tests.App}/Encryption/OsEncryptionServiceTests.cs (100%) rename {LiftLog.Tests => tests/LiftLog.Tests.App}/GlobalUsings.cs (100%) rename {LiftLog.Tests => tests/LiftLog.Tests.App}/Helpers.cs (100%) rename LiftLog.Tests/LiftLog.Tests.csproj => tests/LiftLog.Tests.App/LiftLog.Tests.App.csproj (77%) rename {LiftLog.Tests => tests/LiftLog.Tests.App}/Reducers/CurrentSessionReducerTests.cs (100%) rename {LiftLog.Tests => tests/LiftLog.Tests.App}/Serialization/SerializationTests.cs (100%) rename {LiftLog.Tests => tests/LiftLog.Tests.App}/Sessions.cs (100%) diff --git a/.csharpierignore b/.csharpierignore new file mode 100644 index 00000000..42c7c943 --- /dev/null +++ b/.csharpierignore @@ -0,0 +1 @@ +**/Migrations/**/* diff --git a/.github/workflows/api-test.yml b/.github/workflows/api-test.yml new file mode 100644 index 00000000..ed8d63de --- /dev/null +++ b/.github/workflows/api-test.yml @@ -0,0 +1,49 @@ +# This workflow will build a .NET project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net + +name: Api Tests + +on: + push: + branches-ignore: [main] + +jobs: + api-test: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_PASSWORD: password + POSTGRES_DB: liftlog + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + # Maps tcp port 5432 on service container to the host + - 5432:5432 + env: + ConnectionStrings__UserDataContext: Host=localhost;Port=5432;Database=liftlog;Username=postgres;Password=password + ConnectionStrings__RateLimitContext: Host=localhost;Port=5432;Database=liftlog;Username=postgres;Password=password + OpenAiApiKey: "sk-123" + WebAuthApiKey: "1234" + GooglePlayServiceAccountEmail: "123" + GooglePlayServiceAccountKeyBase64: "123" + IdEncodingService__Alphabet: "123abc" + steps: + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + - name: Restore dependencies + run: dotnet restore + working-directory: ./tests/LiftLog.Tests.Api + - name: Build + run: dotnet build + working-directory: ./tests/LiftLog.Tests.Api + - name: Test + run: dotnet test --no-restore --verbosity normal + working-directory: ./tests/LiftLog.Tests.Api diff --git a/.github/workflows/unit-test.yml b/.github/workflows/ui-unit-test.yml similarity index 70% rename from .github/workflows/unit-test.yml rename to .github/workflows/ui-unit-test.yml index 4ac1498b..4394ac26 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/ui-unit-test.yml @@ -1,7 +1,7 @@ # This workflow will build a .NET project # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net -name: Unit Tests +name: UI Unit Tests on: push: @@ -19,7 +19,10 @@ jobs: dotnet-version: 8.0.x - name: Restore dependencies run: dotnet restore - working-directory: ./LiftLog.Tests + working-directory: ./tests/LiftLog.Tests.App + - name: Build + run: dotnet build + working-directory: ./tests/LiftLog.Tests.App - name: Test run: dotnet test --no-restore --verbosity normal - working-directory: ./LiftLog.Tests + working-directory: ./tests/LiftLog.Tests.App diff --git a/LiftLog.Api/Controllers/SharedItemController.cs b/LiftLog.Api/Controllers/SharedItemController.cs new file mode 100644 index 00000000..96000032 --- /dev/null +++ b/LiftLog.Api/Controllers/SharedItemController.cs @@ -0,0 +1,79 @@ +using System.Security.Cryptography.X509Certificates; +using FluentValidation; +using LiftLog.Api.Db; +using LiftLog.Api.Models; +using LiftLog.Api.Service; +using LiftLog.Lib.Models; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace LiftLog.Api.Controllers; + +[ApiController] +public class SharedItemController( + UserDataContext db, + PasswordService passwordService, + IdEncodingService idEncodingService +) : ControllerBase +{ + [Route("[controller]")] + [HttpPost] + public async Task CreateShared( + CreateSharedItemRequest request, + [FromServices] IValidator validator + ) + { + var validationResult = await validator.ValidateAsync(request); + if (!validationResult.IsValid) + { + return BadRequest(validationResult.Errors); + } + var user = await db.Users.FindAsync(request.UserId); + if (user == null) + { + return Unauthorized(); + } + + if (!passwordService.VerifyPassword(request.Password, user.HashedPassword, user.Salt)) + { + return Unauthorized(); + } + + var sharedItem = new SharedItem + { + UserId = request.UserId, + EncryptedPayload = request.EncryptedPayload, + EncryptionIV = request.EncryptionIV, + Expiry = request.Expiry, + }; + + await db.SharedItems.AddAsync(sharedItem); + await db.SaveChangesAsync(); + return Ok(new CreateSharedItemResponse(Id: idEncodingService.EncodeId(sharedItem.Id))); + } + + [Route("[controller]/{id}")] + [HttpGet] + public async Task GetSharedItem(string id) + { + if (!idEncodingService.TryDecodeId(id, out var idNumber)) + { + return NotFound(); + } + var sharedItem = await db + .SharedItems.Include(x => x.User) + .FirstOrDefaultAsync(x => x.Id == idNumber); + if (sharedItem == null) + { + return NotFound(); + } + + return Ok( + new GetSharedItemResponse( + RsaPublicKey: new Lib.Services.RsaPublicKey(sharedItem.User.RsaPublicKey), + EncryptedPayload: sharedItem.EncryptedPayload, + EncryptionIV: sharedItem.EncryptionIV + ) + ); + } +} diff --git a/LiftLog.Api/Controllers/UserController.cs b/LiftLog.Api/Controllers/UserController.cs index 411038e0..cd812be6 100644 --- a/LiftLog.Api/Controllers/UserController.cs +++ b/LiftLog.Api/Controllers/UserController.cs @@ -62,14 +62,13 @@ [FromServices] IValidator validator [HttpGet] public async Task GetUser(string idOrLookup) { - User? user; + User? user = null; if (Guid.TryParse(idOrLookup, out var id)) { user = await db.Users.FindAsync(id); } - else + else if (idEncodingService.TryDecodeId(idOrLookup, out var userNumber)) { - var userNumber = idEncodingService.DecodeId(idOrLookup); user = await db.Users.FirstOrDefaultAsync(x => x.UserLookup == userNumber); } if (user == null) diff --git a/LiftLog.Api/Db/UserDataContext.cs b/LiftLog.Api/Db/UserDataContext.cs index ecaa8b73..efbdb2d6 100644 --- a/LiftLog.Api/Db/UserDataContext.cs +++ b/LiftLog.Api/Db/UserDataContext.cs @@ -13,6 +13,8 @@ public class UserDataContext(DbContextOptions options) : DbCont public DbSet UserInboxItems { get; set; } = null!; + public DbSet SharedItems { get; set; } = null!; + /// /// Used to register the user event filter tuple type as a DbSet for use in FromSqlRaw. /// @@ -50,6 +52,12 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .WithOne(x => x.User) .OnDelete(DeleteBehavior.Cascade); + modelBuilder + .Entity() + .HasMany() + .WithOne(x => x.User) + .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity().HasIndex(x => x.Expiry); modelBuilder.Entity().HasKey(x => new { x.UserId, x.Id }); diff --git a/LiftLog.Api/Migrations/UserData/20240630015536_SharedItems.Designer.cs b/LiftLog.Api/Migrations/UserData/20240630015536_SharedItems.Designer.cs new file mode 100644 index 00000000..6cf565f2 --- /dev/null +++ b/LiftLog.Api/Migrations/UserData/20240630015536_SharedItems.Designer.cs @@ -0,0 +1,289 @@ +// +using System; +using LiftLog.Api.Db; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LiftLog.Api.Migrations +{ + [DbContext(typeof(UserDataContext))] + [Migration("20240630015536_SharedItems")] + partial class SharedItems + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.HasSequence("user_lookup_sequence"); + + modelBuilder.Entity("LiftLog.Api.Models.SharedItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(5120) + .HasColumnType("bytea") + .HasColumnName("encrypted_payload"); + + b.Property("EncryptionIV") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("encryption_iv"); + + b.Property("Expiry") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiry"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone") + .HasColumnName("timestamp"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_shared_items"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_shared_items_user_id"); + + b.ToTable("shared_items", (string)null); + }); + + modelBuilder.Entity("LiftLog.Api.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("EncryptedCurrentPlan") + .HasColumnType("bytea") + .HasColumnName("encrypted_current_plan"); + + b.Property("EncryptedName") + .HasColumnType("bytea") + .HasColumnName("encrypted_name"); + + b.Property("EncryptedProfilePicture") + .HasColumnType("bytea") + .HasColumnName("encrypted_profile_picture"); + + b.Property("EncryptionIV") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("encryption_iv"); + + b.Property("HashedPassword") + .IsRequired() + .HasColumnType("text") + .HasColumnName("hashed_password"); + + b.Property("LastAccessed") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_accessed"); + + b.Property("RsaPublicKey") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("rsa_public_key"); + + b.Property("Salt") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("salt"); + + b.Property("UserLookup") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("user_lookup") + .HasDefaultValueSql("nextval('user_lookup_sequence')"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("UserLookup") + .IsUnique() + .HasDatabaseName("ix_users_user_lookup"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("LiftLog.Api.Models.UserEvent", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("EncryptedEvent") + .IsRequired() + .HasMaxLength(5120) + .HasColumnType("bytea") + .HasColumnName("encrypted_event"); + + b.Property("EncryptionIV") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("encryption_iv"); + + b.Property("Expiry") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiry"); + + b.Property("LastAccessed") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_accessed"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone") + .HasColumnName("timestamp"); + + b.HasKey("UserId", "Id") + .HasName("pk_user_events"); + + b.HasIndex("Expiry") + .HasDatabaseName("ix_user_events_expiry"); + + b.ToTable("user_events", (string)null); + }); + + modelBuilder.Entity("LiftLog.Api.Models.UserEventFilter", b => + { + b.Property("Since") + .HasColumnType("timestamp with time zone") + .HasColumnName("since"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.ToTable("tmp_stub_table", null, t => + { + t.ExcludeFromMigrations(); + }); + }); + + modelBuilder.Entity("LiftLog.Api.Models.UserFollowSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("value"); + + b.HasKey("Id") + .HasName("pk_user_follow_secrets"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_follow_secrets_user_id"); + + b.ToTable("user_follow_secrets", (string)null); + }); + + modelBuilder.Entity("LiftLog.Api.Models.UserInboxItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("EncryptedMessage") + .IsRequired() + .HasColumnType("bytea[]") + .HasColumnName("encrypted_message"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_inbox_items"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_inbox_items_user_id"); + + b.ToTable("user_inbox_items", (string)null); + }); + + modelBuilder.Entity("LiftLog.Api.Models.SharedItem", b => + { + b.HasOne("LiftLog.Api.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_shared_items_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LiftLog.Api.Models.UserEvent", b => + { + b.HasOne("LiftLog.Api.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_events_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LiftLog.Api.Models.UserFollowSecret", b => + { + b.HasOne("LiftLog.Api.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_follow_secrets_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LiftLog.Api.Models.UserInboxItem", b => + { + b.HasOne("LiftLog.Api.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_inbox_items_users_user_id"); + + b.Navigation("User"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/LiftLog.Api/Migrations/UserData/20240630015536_SharedItems.cs b/LiftLog.Api/Migrations/UserData/20240630015536_SharedItems.cs new file mode 100644 index 00000000..970c1e30 --- /dev/null +++ b/LiftLog.Api/Migrations/UserData/20240630015536_SharedItems.cs @@ -0,0 +1,51 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LiftLog.Api.Migrations +{ + /// + public partial class SharedItems : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "shared_items", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + user_id = table.Column(type: "uuid", nullable: false), + timestamp = table.Column(type: "timestamp with time zone", nullable: false), + expiry = table.Column(type: "timestamp with time zone", nullable: false), + encrypted_payload = table.Column(type: "bytea", maxLength: 5120, nullable: false), + encryption_iv = table.Column(type: "bytea", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_shared_items", x => x.id); + table.ForeignKey( + name: "fk_shared_items_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_shared_items_user_id", + table: "shared_items", + column: "user_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "shared_items"); + } + } +} diff --git a/LiftLog.Api/Migrations/UserData/UserDataContextModelSnapshot.cs b/LiftLog.Api/Migrations/UserData/UserDataContextModelSnapshot.cs index 4ad3c92e..3957b2ea 100644 --- a/LiftLog.Api/Migrations/UserData/UserDataContextModelSnapshot.cs +++ b/LiftLog.Api/Migrations/UserData/UserDataContextModelSnapshot.cs @@ -24,6 +24,47 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.HasSequence("user_lookup_sequence"); + modelBuilder.Entity("LiftLog.Api.Models.SharedItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(5120) + .HasColumnType("bytea") + .HasColumnName("encrypted_payload"); + + b.Property("EncryptionIV") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("encryption_iv"); + + b.Property("Expiry") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiry"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone") + .HasColumnName("timestamp"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_shared_items"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_shared_items_user_id"); + + b.ToTable("shared_items", (string)null); + }); + modelBuilder.Entity("LiftLog.Api.Models.User", b => { b.Property("Id") @@ -192,6 +233,18 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("user_inbox_items", (string)null); }); + modelBuilder.Entity("LiftLog.Api.Models.SharedItem", b => + { + b.HasOne("LiftLog.Api.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_shared_items_users_user_id"); + + b.Navigation("User"); + }); + modelBuilder.Entity("LiftLog.Api.Models.UserEvent", b => { b.HasOne("LiftLog.Api.Models.User", "User") diff --git a/LiftLog.Api/Models/SharedItem.cs b/LiftLog.Api/Models/SharedItem.cs new file mode 100644 index 00000000..ed40eec3 --- /dev/null +++ b/LiftLog.Api/Models/SharedItem.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; + +namespace LiftLog.Api.Models; + +public class SharedItem +{ + public int Id { get; set; } + public Guid UserId { get; set; } + public User User { get; set; } = null!; + + public DateTimeOffset Timestamp { get; set; } + + // A user supplied expiry time for this event, this does not ensure that the event will last this long, just that it will definitely be deleted after this time + // We may delete the event before this time if it is not accessed for a long time + public DateTimeOffset Expiry { get; set; } + + // This payload is encrypted with a AES key generated for this event. It is not stored, it only exists in the share url + // The inner payload is signed with the user's private key + // Its schema is defined in LiftLog.Ui/Models/SharedItem.proto - we don't reference this proto since the server doesn't need to deserialize it + [MaxLength(5 * 1024)] + public byte[] EncryptedPayload { get; set; } = null!; + + // The IV can be considered public, as long as the encryption key is kept secret + public byte[] EncryptionIV { get; set; } = null!; +} diff --git a/LiftLog.Api/Program.cs b/LiftLog.Api/Program.cs index 7f5b3477..dbe027a9 100644 --- a/LiftLog.Api/Program.cs +++ b/LiftLog.Api/Program.cs @@ -134,3 +134,5 @@ } app.Run(); + +public partial class Program { } diff --git a/LiftLog.Api/Service/IdEncodingService.cs b/LiftLog.Api/Service/IdEncodingService.cs index 0107205e..4ed7bb55 100644 --- a/LiftLog.Api/Service/IdEncodingService.cs +++ b/LiftLog.Api/Service/IdEncodingService.cs @@ -23,8 +23,15 @@ public string EncodeId(int id) return encoder.Encode(id); } - public int DecodeId(string encodedId) + public bool TryDecodeId(string encodedId, out int decodedId) { - return encoder.Decode(encodedId)[0]; + var decoded = encoder.Decode(encodedId); + if (decoded.Count == 0) + { + decodedId = 0; + return false; + } + decodedId = decoded[0]; + return true; } } diff --git a/LiftLog.Api/Validators/SharedItemRequestValidators.cs b/LiftLog.Api/Validators/SharedItemRequestValidators.cs new file mode 100644 index 00000000..38e7bbf9 --- /dev/null +++ b/LiftLog.Api/Validators/SharedItemRequestValidators.cs @@ -0,0 +1,21 @@ +using System.Data; +using FluentValidation; +using LiftLog.Lib.Models; + +namespace LiftLog.Api.Validators; + +public class CreateSharedItemRequestValidator : AbstractValidator +{ + const int KB = 1024; + + public CreateSharedItemRequestValidator() + { + RuleFor(x => x.UserId).NotEmpty(); + RuleFor(x => x.Password).NotEmpty().MaximumLength(40); + RuleFor(x => x.EncryptedPayload).NotEmpty(); + RuleFor(x => x.EncryptedPayload.Length).InclusiveBetween(0, 5 * KB); + RuleFor(x => x.EncryptionIV).NotEmpty(); + RuleFor(x => x.EncryptionIV.Length).Equal(16); + RuleFor(x => x.Expiry).NotEmpty(); + } +} diff --git a/LiftLog.Lib/Models/ShareActions.cs b/LiftLog.Lib/Models/ShareActions.cs new file mode 100644 index 00000000..5139f500 --- /dev/null +++ b/LiftLog.Lib/Models/ShareActions.cs @@ -0,0 +1,19 @@ +using LiftLog.Lib.Services; + +namespace LiftLog.Lib.Models; + +public record CreateSharedItemRequest( + Guid UserId, + string Password, + byte[] EncryptedPayload, + byte[] EncryptionIV, + DateTimeOffset Expiry +); + +public record CreateSharedItemResponse(string Id); + +public record GetSharedItemResponse( + RsaPublicKey RsaPublicKey, + byte[] EncryptedPayload, + byte[] EncryptionIV +); diff --git a/LiftLog.Ui/AssemblyInfo.cs b/LiftLog.Ui/AssemblyInfo.cs index ff050623..731c620e 100644 --- a/LiftLog.Ui/AssemblyInfo.cs +++ b/LiftLog.Ui/AssemblyInfo.cs @@ -1,3 +1,3 @@ using System.Runtime.CompilerServices; -[assembly: InternalsVisibleTo("LiftLog.Tests")] +[assembly: InternalsVisibleTo("LiftLog.Tests.App")] diff --git a/LiftLog.sln b/LiftLog.sln index c36790bf..b16400a9 100644 --- a/LiftLog.sln +++ b/LiftLog.sln @@ -19,10 +19,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiftLog.Ui", "LiftLog.Ui\Li EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiftLog.App", "LiftLog.App\LiftLog.App.csproj", "{08D7B5DB-F7A2-4ECA-BE13-81128AB252B1}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiftLog.Tests", "LiftLog.Tests\LiftLog.Tests.csproj", "{75F0873F-B7FC-4396-BC1F-1E9DC27D8F38}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiftLog.Api", "LiftLog.Api\LiftLog.Api.csproj", "{2569C97C-BA58-4DF6-B4CB-0E21E59E50F4}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiftLog.Tests.Api", "tests\LiftLog.Tests.Api\LiftLog.Tests.Api.csproj", "{8306A2A4-BD01-48F5-B2F8-7A85B93D87FD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiftLog.Tests.App", "tests\LiftLog.Tests.App\LiftLog.Tests.App.csproj", "{EAFB4E00-343C-4AA2-9CA0-CD186AC390C7}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -208,6 +210,38 @@ Global {2569C97C-BA58-4DF6-B4CB-0E21E59E50F4}.Release|x64.Build.0 = Release|Any CPU {2569C97C-BA58-4DF6-B4CB-0E21E59E50F4}.Release|x86.ActiveCfg = Release|Any CPU {2569C97C-BA58-4DF6-B4CB-0E21E59E50F4}.Release|x86.Build.0 = Release|Any CPU + {8306A2A4-BD01-48F5-B2F8-7A85B93D87FD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8306A2A4-BD01-48F5-B2F8-7A85B93D87FD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8306A2A4-BD01-48F5-B2F8-7A85B93D87FD}.Debug|arm64.ActiveCfg = Debug|Any CPU + {8306A2A4-BD01-48F5-B2F8-7A85B93D87FD}.Debug|arm64.Build.0 = Debug|Any CPU + {8306A2A4-BD01-48F5-B2F8-7A85B93D87FD}.Debug|x64.ActiveCfg = Debug|Any CPU + {8306A2A4-BD01-48F5-B2F8-7A85B93D87FD}.Debug|x64.Build.0 = Debug|Any CPU + {8306A2A4-BD01-48F5-B2F8-7A85B93D87FD}.Debug|x86.ActiveCfg = Debug|Any CPU + {8306A2A4-BD01-48F5-B2F8-7A85B93D87FD}.Debug|x86.Build.0 = Debug|Any CPU + {8306A2A4-BD01-48F5-B2F8-7A85B93D87FD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8306A2A4-BD01-48F5-B2F8-7A85B93D87FD}.Release|Any CPU.Build.0 = Release|Any CPU + {8306A2A4-BD01-48F5-B2F8-7A85B93D87FD}.Release|arm64.ActiveCfg = Release|Any CPU + {8306A2A4-BD01-48F5-B2F8-7A85B93D87FD}.Release|arm64.Build.0 = Release|Any CPU + {8306A2A4-BD01-48F5-B2F8-7A85B93D87FD}.Release|x64.ActiveCfg = Release|Any CPU + {8306A2A4-BD01-48F5-B2F8-7A85B93D87FD}.Release|x64.Build.0 = Release|Any CPU + {8306A2A4-BD01-48F5-B2F8-7A85B93D87FD}.Release|x86.ActiveCfg = Release|Any CPU + {8306A2A4-BD01-48F5-B2F8-7A85B93D87FD}.Release|x86.Build.0 = Release|Any CPU + {EAFB4E00-343C-4AA2-9CA0-CD186AC390C7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EAFB4E00-343C-4AA2-9CA0-CD186AC390C7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EAFB4E00-343C-4AA2-9CA0-CD186AC390C7}.Debug|arm64.ActiveCfg = Debug|Any CPU + {EAFB4E00-343C-4AA2-9CA0-CD186AC390C7}.Debug|arm64.Build.0 = Debug|Any CPU + {EAFB4E00-343C-4AA2-9CA0-CD186AC390C7}.Debug|x64.ActiveCfg = Debug|Any CPU + {EAFB4E00-343C-4AA2-9CA0-CD186AC390C7}.Debug|x64.Build.0 = Debug|Any CPU + {EAFB4E00-343C-4AA2-9CA0-CD186AC390C7}.Debug|x86.ActiveCfg = Debug|Any CPU + {EAFB4E00-343C-4AA2-9CA0-CD186AC390C7}.Debug|x86.Build.0 = Debug|Any CPU + {EAFB4E00-343C-4AA2-9CA0-CD186AC390C7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EAFB4E00-343C-4AA2-9CA0-CD186AC390C7}.Release|Any CPU.Build.0 = Release|Any CPU + {EAFB4E00-343C-4AA2-9CA0-CD186AC390C7}.Release|arm64.ActiveCfg = Release|Any CPU + {EAFB4E00-343C-4AA2-9CA0-CD186AC390C7}.Release|arm64.Build.0 = Release|Any CPU + {EAFB4E00-343C-4AA2-9CA0-CD186AC390C7}.Release|x64.ActiveCfg = Release|Any CPU + {EAFB4E00-343C-4AA2-9CA0-CD186AC390C7}.Release|x64.Build.0 = Release|Any CPU + {EAFB4E00-343C-4AA2-9CA0-CD186AC390C7}.Release|x86.ActiveCfg = Release|Any CPU + {EAFB4E00-343C-4AA2-9CA0-CD186AC390C7}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/LiftLog.Tests/Backend/AppleAppStorePurchaseVerificationServiceTests.cs b/tests/LiftLog.Tests.Api/AppleAppStorePurchaseVerificationServiceTests.cs similarity index 97% rename from LiftLog.Tests/Backend/AppleAppStorePurchaseVerificationServiceTests.cs rename to tests/LiftLog.Tests.Api/AppleAppStorePurchaseVerificationServiceTests.cs index 1edf7a6e..aa919ebd 100644 --- a/LiftLog.Tests/Backend/AppleAppStorePurchaseVerificationServiceTests.cs +++ b/tests/LiftLog.Tests.Api/AppleAppStorePurchaseVerificationServiceTests.cs @@ -3,7 +3,7 @@ using LiftLog.Api.Validators; using Microsoft.Extensions.Logging; -namespace LiftLog.Tests.Backend; +namespace LiftLog.Tests.Api; public class AppleAppStorePurchaseVerificationServiceTests { diff --git a/tests/LiftLog.Tests.Api/GlobalUsings.cs b/tests/LiftLog.Tests.Api/GlobalUsings.cs new file mode 100644 index 00000000..3fbeefcd --- /dev/null +++ b/tests/LiftLog.Tests.Api/GlobalUsings.cs @@ -0,0 +1,5 @@ +global using System.Collections.Immutable; +global using FluentAssertions; +global using LiftLog.Tests.Api.Integration.Helpers; +global using NSubstitute; +global using Xunit; diff --git a/tests/LiftLog.Tests.Api/Integration/Helpers/UserHelper.cs b/tests/LiftLog.Tests.Api/Integration/Helpers/UserHelper.cs new file mode 100644 index 00000000..f0b9bae6 --- /dev/null +++ b/tests/LiftLog.Tests.Api/Integration/Helpers/UserHelper.cs @@ -0,0 +1,33 @@ +using LiftLog.Lib.Models; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace LiftLog.Tests.Api.Integration.Helpers; + +public static class UserHelper +{ + public static async Task CreateUserAsync( + HttpClient client, + byte[] encryptionIV, + byte[] rsaPublicKey + ) + { + var userCreateRequest = new CreateUserRequest(null); + var createUserResponse = await ( + await client.PostAsJsonAsync("/user/create", userCreateRequest) + ).Content.ReadFromJsonAsync()!; + + var putUserDataRequest = new PutUserDataRequest( + createUserResponse!.Id, + createUserResponse.Password, + null, + null, + null, + encryptionIV, + rsaPublicKey + ); + + (await client.PutAsJsonAsync("/user", putUserDataRequest)).EnsureSuccessStatusCode(); + + return createUserResponse; + } +} diff --git a/tests/LiftLog.Tests.Api/Integration/SharedItemIntegrationTests.cs b/tests/LiftLog.Tests.Api/Integration/SharedItemIntegrationTests.cs new file mode 100644 index 00000000..840af501 --- /dev/null +++ b/tests/LiftLog.Tests.Api/Integration/SharedItemIntegrationTests.cs @@ -0,0 +1,135 @@ +using System.Net; +using LiftLog.Api.Service; +using LiftLog.Lib.Models; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace LiftLog.Tests.ApiErrorType.Integration; + +public class SharedItemIntegrationTests(WebApplicationFactory factory) + : IClassFixture> +{ + const string url = "/shareditem"; + private static readonly byte[] encryptedPayload = new byte[] { 0x01, 0x02, 0x03 }; + private static readonly byte[] encryptionIV = Enumerable.Repeat((byte)0x04, 16).ToArray(); + private static readonly byte[] rsaPublicKey = Enumerable.Repeat((byte)0x05, 16).ToArray(); + private readonly WebApplicationFactory _factory = factory; + + [Fact] + public async Task Post_SharedItemGivesAnId() + { + // Arrange + var client = _factory.CreateClient(); + + var createUserResponse = await UserHelper.CreateUserAsync( + client, + encryptionIV, + rsaPublicKey + ); + + var sharedItemCreateRequest = new CreateSharedItemRequest( + createUserResponse.Id, + createUserResponse.Password, + encryptedPayload, + encryptionIV, + DateTimeOffset.UtcNow.AddDays(1) + ); + + // Act + var response = await client.PostAsJsonAsync(url, sharedItemCreateRequest); + + // Assert + response.EnsureSuccessStatusCode(); // Status Code 200-299 + var responseBody = await response.Content.ReadFromJsonAsync(); + + responseBody.Should().NotBeNull(); + responseBody!.Id.Should().NotBeEmpty(); + + var getSharedItemResponse = await client.GetFromJsonAsync( + url + "/" + responseBody.Id + ); + + getSharedItemResponse.Should().NotBeNull(); + getSharedItemResponse! + .RsaPublicKey.SpkiPublicKeyBytes.Should() + .BeEquivalentTo(rsaPublicKey); + getSharedItemResponse.EncryptedPayload.Should().BeEquivalentTo(encryptedPayload); + getSharedItemResponse.EncryptionIV.Should().BeEquivalentTo(encryptionIV); + } + + [Fact] + public async Task Post_SharedItemWithInvalidUserId_ReturnsUnauthorized() + { + // Arrange + var client = _factory.CreateClient(); + + var sharedItemCreateRequest = new CreateSharedItemRequest( + Guid.NewGuid(), + "password", + encryptedPayload, + encryptionIV, + DateTimeOffset.UtcNow.AddDays(1) + ); + + // Act + var response = await client.PostAsJsonAsync(url, sharedItemCreateRequest); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Post_SharedItemWithInvalidPassword_ReturnsUnauthorized() + { + // Arrange + var client = _factory.CreateClient(); + + var createUserResponse = await UserHelper.CreateUserAsync( + client, + encryptionIV, + rsaPublicKey + ); + + var sharedItemCreateRequest = new CreateSharedItemRequest( + createUserResponse.Id, + new string('a', 29), + encryptedPayload, + encryptionIV, + DateTimeOffset.UtcNow.AddDays(1) + ); + + // Act + var response = await client.PostAsJsonAsync(url, sharedItemCreateRequest); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Get_SharedItemWithInvalidId_ReturnsNotFound() + { + // Arrange + var client = _factory.CreateClient(); + + // Act + var response = await client.GetAsync(url + "/" + Guid.NewGuid()); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Get_SharedItemWithValidNonExistantId_ReturnsNotFound() + { + // Arrange + var client = _factory.CreateClient(); + var encodedId = _factory + .Services.GetRequiredService() + .EncodeId(int.MaxValue); + + // Act + var response = await client.GetAsync(url + "/" + encodedId); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } +} diff --git a/tests/LiftLog.Tests.Api/LiftLog.Tests.Api.csproj b/tests/LiftLog.Tests.Api/LiftLog.Tests.Api.csproj new file mode 100644 index 00000000..2bcb4718 --- /dev/null +++ b/tests/LiftLog.Tests.Api/LiftLog.Tests.Api.csproj @@ -0,0 +1,32 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/LiftLog.Tests/Blueprints.cs b/tests/LiftLog.Tests.App/Blueprints.cs similarity index 100% rename from LiftLog.Tests/Blueprints.cs rename to tests/LiftLog.Tests.App/Blueprints.cs diff --git a/LiftLog.Tests/Encryption/OsEncryptionServiceTests.cs b/tests/LiftLog.Tests.App/Encryption/OsEncryptionServiceTests.cs similarity index 100% rename from LiftLog.Tests/Encryption/OsEncryptionServiceTests.cs rename to tests/LiftLog.Tests.App/Encryption/OsEncryptionServiceTests.cs diff --git a/LiftLog.Tests/GlobalUsings.cs b/tests/LiftLog.Tests.App/GlobalUsings.cs similarity index 100% rename from LiftLog.Tests/GlobalUsings.cs rename to tests/LiftLog.Tests.App/GlobalUsings.cs diff --git a/LiftLog.Tests/Helpers.cs b/tests/LiftLog.Tests.App/Helpers.cs similarity index 100% rename from LiftLog.Tests/Helpers.cs rename to tests/LiftLog.Tests.App/Helpers.cs diff --git a/LiftLog.Tests/LiftLog.Tests.csproj b/tests/LiftLog.Tests.App/LiftLog.Tests.App.csproj similarity index 77% rename from LiftLog.Tests/LiftLog.Tests.csproj rename to tests/LiftLog.Tests.App/LiftLog.Tests.App.csproj index 9c860c8f..d1379f60 100644 --- a/LiftLog.Tests/LiftLog.Tests.csproj +++ b/tests/LiftLog.Tests.App/LiftLog.Tests.App.csproj @@ -10,10 +10,10 @@ - - - - + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -25,8 +25,7 @@ - - + diff --git a/LiftLog.Tests/Reducers/CurrentSessionReducerTests.cs b/tests/LiftLog.Tests.App/Reducers/CurrentSessionReducerTests.cs similarity index 100% rename from LiftLog.Tests/Reducers/CurrentSessionReducerTests.cs rename to tests/LiftLog.Tests.App/Reducers/CurrentSessionReducerTests.cs diff --git a/LiftLog.Tests/Serialization/SerializationTests.cs b/tests/LiftLog.Tests.App/Serialization/SerializationTests.cs similarity index 100% rename from LiftLog.Tests/Serialization/SerializationTests.cs rename to tests/LiftLog.Tests.App/Serialization/SerializationTests.cs diff --git a/LiftLog.Tests/Sessions.cs b/tests/LiftLog.Tests.App/Sessions.cs similarity index 100% rename from LiftLog.Tests/Sessions.cs rename to tests/LiftLog.Tests.App/Sessions.cs