diff --git a/.gitignore b/.gitignore index 79ac9cf..1c55394 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ */obj/ */bin/ -.idea \ No newline at end of file +.idea + +# Postgres data +data/ diff --git a/Epsilon/Controllers/TestController.cs b/Epsilon/Controllers/TestController.cs new file mode 100644 index 0000000..d774c69 --- /dev/null +++ b/Epsilon/Controllers/TestController.cs @@ -0,0 +1,25 @@ +using Epsilon.Data; +using Epsilon.Models; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Epsilon.Controllers; + +[ApiController] +[Route("api")] +public class TestController +{ + + private readonly EpsilonDbContext _epsilonDbContext; + + public TestController(EpsilonDbContext epsilonDbContext) + { + _epsilonDbContext = epsilonDbContext; + } + + [HttpGet("users")] + public async Task>> GetUsers() + { + return await _epsilonDbContext.Users.ToListAsync(); + } +} \ No newline at end of file diff --git a/Epsilon/Data/EpsilonDbContext.cs b/Epsilon/Data/EpsilonDbContext.cs new file mode 100644 index 0000000..ae60474 --- /dev/null +++ b/Epsilon/Data/EpsilonDbContext.cs @@ -0,0 +1,14 @@ +using Epsilon.Models; +using Microsoft.EntityFrameworkCore; + +namespace Epsilon.Data; + +public class EpsilonDbContext : DbContext +{ + public EpsilonDbContext(DbContextOptions options) : base(options) + { + } + + public DbSet Messages { get; set; } + public DbSet Users { get; set; } +} \ No newline at end of file diff --git a/Epsilon/DatabaseInitializer.cs b/Epsilon/DatabaseInitializer.cs new file mode 100644 index 0000000..53c72e7 --- /dev/null +++ b/Epsilon/DatabaseInitializer.cs @@ -0,0 +1,77 @@ +using System.Runtime.InteropServices.JavaScript; +using Epsilon.Data; +using Epsilon.Models; +using Microsoft.EntityFrameworkCore; + +namespace Epsilon; +public class DatabaseInitializer +{ + public static void Seed(WebApplication app) + { + using (var serviceScope = app.Services.CreateScope()) + { + var context = serviceScope.ServiceProvider.GetService(); + + if (context != null) + { + if (!context.Users.Any()) + InitUsers(context); + + if (!context.Messages.Any()) + InitMessages(context); + } + } + + } + + public static void Migrate(WebApplication app) + { + // Migrate db + using (var scope = app.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.Migrate(); + } + } + + private static byte[] GenerateRandomBytes(int length) + { + byte[] randomBytes = new byte[length]; + Random random = new Random(); + + random.NextBytes(randomBytes); + + return randomBytes; + } + + private static void InitUsers(EpsilonDbContext context) + { + var usersToAdd = new List + { + new User { Id = GenerateRandomBytes(32), PublicKey = GenerateRandomBytes(256), Username = "user_1"}, + new User { Id = GenerateRandomBytes(32), PublicKey = GenerateRandomBytes(256), Username = "user_2"}, + new User { Id = GenerateRandomBytes(32), PublicKey = GenerateRandomBytes(256), Username = "user_3"}, + new User { Id = GenerateRandomBytes(32), PublicKey = GenerateRandomBytes(256), Username = "user_4"}, + }; + foreach (var user in usersToAdd) + context.Users.Add(user); + + context.SaveChanges(); + } + + private static void InitMessages(EpsilonDbContext context) + { + var messagesToAdd = new List + { + new Message { EncryptedMessage = GenerateRandomBytes(256), SenderId = GenerateRandomBytes(32), RecipientId = GenerateRandomBytes(32), CreatedAt = new DateTime() }, + new Message { EncryptedMessage = GenerateRandomBytes(256), SenderId = GenerateRandomBytes(32), RecipientId = GenerateRandomBytes(32), CreatedAt = new DateTime() }, + new Message { EncryptedMessage = GenerateRandomBytes(256), SenderId = GenerateRandomBytes(32), RecipientId = GenerateRandomBytes(32), CreatedAt = new DateTime() }, + new Message { EncryptedMessage = GenerateRandomBytes(256), SenderId = GenerateRandomBytes(32), RecipientId = GenerateRandomBytes(32), CreatedAt = new DateTime() } + }; + + foreach (var message in messagesToAdd) + context.Messages.Add(message); + + context.SaveChanges(); + } +} \ No newline at end of file diff --git a/Epsilon/Epsilon.csproj b/Epsilon/Epsilon.csproj index 6158569..6558900 100644 --- a/Epsilon/Epsilon.csproj +++ b/Epsilon/Epsilon.csproj @@ -8,7 +8,9 @@ + + diff --git a/Epsilon/IMessageManager.cs b/Epsilon/IMessageManager.cs new file mode 100644 index 0000000..98f97c1 --- /dev/null +++ b/Epsilon/IMessageManager.cs @@ -0,0 +1,12 @@ +using Epsilon.Models; + +namespace Epsilon; + +public interface IMessageManager +{ + public Task AddMessage(Message message); + public Task DeleteMessage(byte[] messageId); + public Task> GetAllMessages(); + public Task> GetMessagesBetweenUsers(byte[] userId1, byte[] userId2, DateTime? startDate = null, DateTime? endDate = null); + public Task GetMessageById(byte[] messageId); +} \ No newline at end of file diff --git a/Epsilon/IUserManager.cs b/Epsilon/IUserManager.cs new file mode 100644 index 0000000..22e7b46 --- /dev/null +++ b/Epsilon/IUserManager.cs @@ -0,0 +1,11 @@ +using Epsilon.Models; + +namespace Epsilon; + +public interface IUserManager +{ + Task AddUser(User user); + Task DeleteUser(byte[] userId); + Task> GetAllUsers(); + Task GetUserById(byte[] userId); +} \ No newline at end of file diff --git a/Epsilon/MessageManager.cs b/Epsilon/MessageManager.cs new file mode 100644 index 0000000..6012576 --- /dev/null +++ b/Epsilon/MessageManager.cs @@ -0,0 +1,32 @@ +using Epsilon.Models; + +namespace Epsilon; + +public class MessageManager : IMessageManager +{ + public Task AddMessage(Message message) + { + throw new NotImplementedException(); + } + + public Task DeleteMessage(byte[] messageId) + { + throw new NotImplementedException(); + } + + public Task> GetAllMessages() + { + throw new NotImplementedException(); + } + + public Task> GetMessagesBetweenUsers(byte[] userId1, byte[] userId2, DateTime? startDate = null, + DateTime? endDate = null) + { + throw new NotImplementedException(); + } + + public Task GetMessageById(byte[] messageId) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/Epsilon/Migrations/20240625191035_InitialCreate.Designer.cs b/Epsilon/Migrations/20240625191035_InitialCreate.Designer.cs new file mode 100644 index 0000000..5530823 --- /dev/null +++ b/Epsilon/Migrations/20240625191035_InitialCreate.Designer.cs @@ -0,0 +1,78 @@ +// +using System; +using Epsilon.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Epsilon.Migrations +{ + [DbContext(typeof(EpsilonDbContext))] + [Migration("20240625191035_InitialCreate")] + partial class InitialCreate + { + /// + 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.Entity("Epsilon.Models.Message", b => + { + b.Property("MessageId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("MessageId")); + + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedMessage") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("RecipientId") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("SenderId") + .IsRequired() + .HasColumnType("bytea"); + + b.HasKey("MessageId"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("Epsilon.Models.User", b => + { + b.Property("Id") + .HasColumnType("bytea"); + + b.Property("PublicKey") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Epsilon/Migrations/20240625191035_InitialCreate.cs b/Epsilon/Migrations/20240625191035_InitialCreate.cs new file mode 100644 index 0000000..f2ff5eb --- /dev/null +++ b/Epsilon/Migrations/20240625191035_InitialCreate.cs @@ -0,0 +1,55 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Epsilon.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Messages", + columns: table => new + { + MessageId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + SenderId = table.Column(type: "bytea", nullable: false), + RecipientId = table.Column(type: "bytea", nullable: false), + EncryptedMessage = table.Column(type: "bytea", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Messages", x => x.MessageId); + }); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "bytea", nullable: false), + PublicKey = table.Column(type: "bytea", nullable: false), + Username = table.Column(type: "character varying(16)", maxLength: 16, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Messages"); + + migrationBuilder.DropTable( + name: "Users"); + } + } +} diff --git a/Epsilon/Migrations/EpsilonDbContextModelSnapshot.cs b/Epsilon/Migrations/EpsilonDbContextModelSnapshot.cs new file mode 100644 index 0000000..f762a05 --- /dev/null +++ b/Epsilon/Migrations/EpsilonDbContextModelSnapshot.cs @@ -0,0 +1,75 @@ +// +using System; +using Epsilon.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Epsilon.Migrations +{ + [DbContext(typeof(EpsilonDbContext))] + partial class EpsilonDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Epsilon.Models.Message", b => + { + b.Property("MessageId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("MessageId")); + + b.Property("CreatedAt") + .IsRequired() + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedMessage") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("RecipientId") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("SenderId") + .IsRequired() + .HasColumnType("bytea"); + + b.HasKey("MessageId"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("Epsilon.Models.User", b => + { + b.Property("Id") + .HasColumnType("bytea"); + + b.Property("PublicKey") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Epsilon/Models/Message.cs b/Epsilon/Models/Message.cs new file mode 100644 index 0000000..e128429 --- /dev/null +++ b/Epsilon/Models/Message.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Epsilon.Models; +public class Message +{ + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int? MessageId { get; set; } + + // Hash of senders's public key + [Required] + [ForeignKey("User")] + public byte[]? SenderId { get; set; } + + // Hash of recipient's public key + [Required] + [ForeignKey("User")] + public byte[]? RecipientId { get; set; } + + [Required] + public byte[]? EncryptedMessage { get; set; } + + [Required] + public DateTime? CreatedAt { get; set; } +} \ No newline at end of file diff --git a/Epsilon/Models/User.cs b/Epsilon/Models/User.cs new file mode 100644 index 0000000..a890af3 --- /dev/null +++ b/Epsilon/Models/User.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace Epsilon.Models; +public class User +{ + // Hash of user's public key + [Key] + public byte[]? Id { get; set; } + + [Required] + public byte[]? PublicKey { get; set; } + + // Can we possibly encrypt this such that only the sender and receiver can view this? + [Required] + [StringLength(16)] + public string? Username { get; set; } +} \ No newline at end of file diff --git a/Epsilon/Program.cs b/Epsilon/Program.cs index 50742c2..a633c88 100644 --- a/Epsilon/Program.cs +++ b/Epsilon/Program.cs @@ -1,4 +1,6 @@ using Epsilon; +using Epsilon.Data; +using Microsoft.EntityFrameworkCore; using Epsilon.Handler.WebsocketMessageHandler; using Epsilon.Models; using Epsilon.Services.WebsocketStateService; @@ -14,6 +16,11 @@ builder.Services.AddHealthChecks(); builder.Services.AddControllers(); +builder.Services.AddTransient(); +builder.Services.AddTransient(); +builder.Services.AddDbContext(options => + options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")) +); builder.Services.AddTransient, WebsocketMessageHandler>(); builder.Services.AddTransient, LoginRequestMessageHandler>(); builder.Services.AddTransient, MessageRequestMessageHandler>(); @@ -31,4 +38,10 @@ KeepAliveInterval = TimeSpan.FromSeconds(30), }); +// Migrate db +DatabaseInitializer.Migrate(app); + +// Seed database +DatabaseInitializer.Seed(app); + await app.RunAsync(); \ No newline at end of file diff --git a/Epsilon/UserManager.cs b/Epsilon/UserManager.cs new file mode 100644 index 0000000..a3765ac --- /dev/null +++ b/Epsilon/UserManager.cs @@ -0,0 +1,26 @@ +using Epsilon.Models; + +namespace Epsilon; + +public class UserManager : IUserManager +{ + public Task AddUser(User user) + { + throw new NotImplementedException(); + } + + public Task DeleteUser(byte[] userId) + { + throw new NotImplementedException(); + } + + public Task> GetAllUsers() + { + throw new NotImplementedException(); + } + + public Task GetUserById(byte[] userId) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/Epsilon/appsettings.Development.json b/Epsilon/appsettings.Development.json index 8b34174..f6e643d 100644 --- a/Epsilon/appsettings.Development.json +++ b/Epsilon/appsettings.Development.json @@ -3,5 +3,8 @@ "MinimumLevel": { "Default": "Debug" } + }, + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Port=5432;Database=EpsilonDB;Username=root;Password=password" } } \ No newline at end of file diff --git a/Epsilon/appsettings.IntegrationTest.json b/Epsilon/appsettings.IntegrationTest.json index 8b34174..aa30b93 100644 --- a/Epsilon/appsettings.IntegrationTest.json +++ b/Epsilon/appsettings.IntegrationTest.json @@ -3,5 +3,8 @@ "MinimumLevel": { "Default": "Debug" } + }, + "ConnectionStrings": { + "DefaultConnection": "Host=postgres;Port=5432;Database=EpsilonDB;Username=root;Password=password" } } \ No newline at end of file diff --git a/IntegrationTests/PostgresTests.cs b/IntegrationTests/PostgresTests.cs new file mode 100644 index 0000000..a625a23 --- /dev/null +++ b/IntegrationTests/PostgresTests.cs @@ -0,0 +1,24 @@ +using System.Net; +using Epsilon.Models; +using FluentAssertions; +using Newtonsoft.Json; +using Xunit; + +namespace IntegrationTests; + +public class PostgresTests +{ + private readonly HttpClient _client = new(); + + [Fact] + public async Task Epsilon_ShouldReturnListOfUsers() + { + var response = await _client.GetAsync("http://localhost:5172/api/users"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var body = await response.Content.ReadAsStringAsync(); + var users = JsonConvert.DeserializeObject>(body)!; + users.Should().HaveCount(4); + } +} \ No newline at end of file diff --git a/Makefile b/Makefile index 20d9343..f73a888 100644 --- a/Makefile +++ b/Makefile @@ -23,6 +23,9 @@ down: echo "Down" docker-compose -f docker-compose-test.yml down +services: + docker-compose -f docker-compose-postgres.yml up + send_logs: .buildkite/scripts/docker-logs.sh make down diff --git a/docker-compose-postgres.yml b/docker-compose-postgres.yml new file mode 100644 index 0000000..589ce4a --- /dev/null +++ b/docker-compose-postgres.yml @@ -0,0 +1,12 @@ +services: + dbPostgres: + image: postgres + restart: always + environment: + POSTGRES_USER: root + POSTGRES_PASSWORD: password + POSTGRES_DB: EpsilonDB + ports: + - 5432:5432 + volumes: + - ./data:/var/lib/postgresql/data \ No newline at end of file diff --git a/docker-compose-test.yml b/docker-compose-test.yml index 015652a..549e1d8 100644 --- a/docker-compose-test.yml +++ b/docker-compose-test.yml @@ -4,7 +4,7 @@ services: entrypoint: /bin/bash working_dir: "/app" depends_on: - epsilon: + epsilon: condition: service_healthy volumes: - "./:/app:rw" @@ -14,6 +14,8 @@ services: image: mcr.microsoft.com/dotnet/sdk:8.0 entrypoint: /bin/bash working_dir: "/app" + depends_on: + - postgres volumes: - "./:/app:rw" command: ".buildkite/scripts/run.sh Epsilon" @@ -21,4 +23,12 @@ services: test: curl --fail -k http://localhost:5172/health || exit 1 interval: 10s timeout: 5s - retries: 5 \ No newline at end of file + retries: 5 + + postgres: + image: postgres + restart: always + environment: + POSTGRES_USER: root + POSTGRES_PASSWORD: password + POSTGRES_DB: EpsilonDB \ No newline at end of file