diff --git a/.gitignore b/.gitignore index ba46491..3a7758a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,9 +4,9 @@ /WSIST/WSIST.Web/wwwroot/lib /WSIST/WSIST.Web/bin /WSIST/WSIST.UnitTests/obj -/WSIST/WSIST.UnitTests/obj -WSIST/WSIST.UnitTests/obj/Debug/net9.0/WSIST.UnitTests.csproj.AssemblyReference.cache -/WSIST/WSIST.UnitTests/obj /WSIST/WSIST.UnitTests/bin -WSIST/WSIST.Engine/bin -WSIST/WSIST.Web/appsettings.Development.json +/WSIST/WSIST.Engine/bin +/WSIST/WSIST.Web/appsettings.Development.json +.claude +.env +coderabbit-full-review*.txt diff --git a/WSIST/.config/dotnet-tools.json b/WSIST/.config/dotnet-tools.json index 79512eb..6e2e244 100644 --- a/WSIST/.config/dotnet-tools.json +++ b/WSIST/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "csharpier": { - "version": "1.2.4", + "version": "1.3.0", "commands": [ "csharpier" ], diff --git a/WSIST/.env.example b/WSIST/.env.example new file mode 100644 index 0000000..5718140 --- /dev/null +++ b/WSIST/.env.example @@ -0,0 +1,3 @@ +# Copy to .env and customize. Used by docker-compose.yml for the local MySQL container. +MYSQL_ROOT_PASSWORD=password +MYSQL_DATABASE=wsistdb diff --git a/WSIST/WSIST.Engine/Migrations/20260611100206_SubjectIdAutoIncrement.Designer.cs b/WSIST/WSIST.Engine/Migrations/20260611100206_SubjectIdAutoIncrement.Designer.cs new file mode 100644 index 0000000..67170c9 --- /dev/null +++ b/WSIST/WSIST.Engine/Migrations/20260611100206_SubjectIdAutoIncrement.Designer.cs @@ -0,0 +1,199 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using WSIST.Engine; + +#nullable disable + +namespace WSIST.Engine.Migrations +{ + [DbContext(typeof(WsistContext))] + [Migration("20260611100206_SubjectIdAutoIncrement")] + partial class SubjectIdAutoIncrement + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.17") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("WSIST.Engine.Subject", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("IsSystem") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Subjects", (string)null); + + b.HasData( + new + { + Id = -6, + IsSystem = true, + Name = "Math" + }, + new + { + Id = -5, + IsSystem = true, + Name = "English" + }, + new + { + Id = -4, + IsSystem = true, + Name = "French" + }, + new + { + Id = -3, + IsSystem = true, + Name = "German" + }, + new + { + Id = -2, + IsSystem = true, + Name = "Chemistry" + }, + new + { + Id = -1, + IsSystem = true, + Name = "Other" + }); + }); + + modelBuilder.Entity("WSIST.Engine.Test", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("DueDate") + .HasColumnType("date"); + + b.Property("Grade") + .HasColumnType("double"); + + b.Property("Subject") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Understanding") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("Volume") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Subject"); + + b.HasIndex("UserId"); + + b.ToTable("Tests", (string)null); + }); + + modelBuilder.Entity("WSIST.Engine.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("GoogleId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("WSIST.Engine.Subject", b => + { + b.HasOne("WSIST.Engine.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("User"); + }); + + modelBuilder.Entity("WSIST.Engine.Test", b => + { + b.HasOne("WSIST.Engine.Subject", null) + .WithMany() + .HasForeignKey("Subject") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("WSIST.Engine.User", "User") + .WithMany("Tests") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("WSIST.Engine.User", b => + { + b.Navigation("Tests"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/WSIST/WSIST.Engine/Migrations/20260611100206_SubjectIdAutoIncrement.cs b/WSIST/WSIST.Engine/Migrations/20260611100206_SubjectIdAutoIncrement.cs new file mode 100644 index 0000000..94c2213 --- /dev/null +++ b/WSIST/WSIST.Engine/Migrations/20260611100206_SubjectIdAutoIncrement.cs @@ -0,0 +1,64 @@ +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace WSIST.Engine.Migrations +{ + /// + public partial class SubjectIdAutoIncrement : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Move the seeded system subjects from ids 0..5 to -6..-1 *in place* + // (renumbering instead of delete+insert keeps every Tests.Subject + // reference intact), then turn the column into auto-increment so + // user-created subjects get database-generated ids. Renumbering must + // happen first: MySQL won't accept an auto-increment column that + // still holds a 0. FK checks stay off through the ALTER because the + // column is referenced by FK_Tests_Subjects_Subject. + migrationBuilder.Sql("SET FOREIGN_KEY_CHECKS = 0;"); + migrationBuilder.Sql("UPDATE `Subjects` SET `Id` = `Id` - 6 WHERE `IsSystem` = 1 AND `Id` BETWEEN 0 AND 5;"); + migrationBuilder.Sql("UPDATE `Tests` SET `Subject` = `Subject` - 6 WHERE `Subject` BETWEEN 0 AND 5;"); + + migrationBuilder.AlterColumn( + name: "Id", + table: "Subjects", + type: "int", + nullable: false, + oldClrType: typeof(int), + oldType: "int") + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn); + + migrationBuilder.Sql("SET FOREIGN_KEY_CHECKS = 1;"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // Rollback limitation: if user-created subjects have claimed + // auto-increment ids 1..5 since the Up ran, shifting the system + // subjects back from -6..-1 to 0..5 would collide with them on the + // primary key and this migration would fail. There is no safe, + // automatic resolution — those user subjects would have to be + // renumbered manually first. + migrationBuilder.Sql("SET FOREIGN_KEY_CHECKS = 0;"); + + migrationBuilder.AlterColumn( + name: "Id", + table: "Subjects", + type: "int", + nullable: false, + oldClrType: typeof(int), + oldType: "int") + .OldAnnotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn); + + migrationBuilder.Sql("UPDATE `Subjects` SET `Id` = `Id` + 6 WHERE `IsSystem` = 1 AND `Id` BETWEEN -6 AND -1;"); + migrationBuilder.Sql("UPDATE `Tests` SET `Subject` = `Subject` + 6 WHERE `Subject` BETWEEN -6 AND -1;"); + migrationBuilder.Sql("SET FOREIGN_KEY_CHECKS = 1;"); + } + } +} diff --git a/WSIST/WSIST.Engine/Migrations/WsistContextModelSnapshot.cs b/WSIST/WSIST.Engine/Migrations/WsistContextModelSnapshot.cs index 6116ef2..0a9bf9a 100644 --- a/WSIST/WSIST.Engine/Migrations/WsistContextModelSnapshot.cs +++ b/WSIST/WSIST.Engine/Migrations/WsistContextModelSnapshot.cs @@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "9.0.0") + .HasAnnotation("ProductVersion", "9.0.17") .HasAnnotation("Relational:MaxIdentifierLength", 64); MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); @@ -25,8 +25,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("WSIST.Engine.Subject", b => { b.Property("Id") + .ValueGeneratedOnAdd() .HasColumnType("int"); + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + b.Property("IsSystem") .ValueGeneratedOnAdd() .HasColumnType("tinyint(1)") @@ -49,37 +52,37 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasData( new { - Id = 0, + Id = -6, IsSystem = true, Name = "Math" }, new { - Id = 1, + Id = -5, IsSystem = true, Name = "English" }, new { - Id = 2, + Id = -4, IsSystem = true, Name = "French" }, new { - Id = 3, + Id = -3, IsSystem = true, Name = "German" }, new { - Id = 4, + Id = -2, IsSystem = true, Name = "Chemistry" }, new { - Id = 5, + Id = -1, IsSystem = true, Name = "Other" }); diff --git a/WSIST/WSIST.Engine/Test.cs b/WSIST/WSIST.Engine/Test.cs index 314de0a..216ad13 100644 --- a/WSIST/WSIST.Engine/Test.cs +++ b/WSIST/WSIST.Engine/Test.cs @@ -65,9 +65,9 @@ public static string VolumeHelper(TestVolume volume) return "Please Choose a Setting"; } - public static string UnderstandingHelper(PersonalUnderstanding volume) + public static string UnderstandingHelper(PersonalUnderstanding understanding) { - switch (volume) + switch (understanding) { case PersonalUnderstanding.VeryLow: { diff --git a/WSIST/WSIST.Engine/TestManagement.cs b/WSIST/WSIST.Engine/TestManagement.cs index 72397f4..9feea40 100644 --- a/WSIST/WSIST.Engine/TestManagement.cs +++ b/WSIST/WSIST.Engine/TestManagement.cs @@ -14,6 +14,10 @@ public TestManagement(WsistContext context) public void NewTestMaker(string title, int subjectId, DateOnly dueDate, Test.TestVolume volume, Test.PersonalUnderstanding understanding, double? grade, int userId) { + if (string.IsNullOrWhiteSpace(title)) + throw new ArgumentException("Title cannot be empty.", nameof(title)); + EnsureSubjectAccessible(subjectId, userId); + var test = new Test { Id = Guid.NewGuid(), @@ -30,10 +34,15 @@ public void NewTestMaker(string title, int subjectId, DateOnly dueDate, } public void TestEditor(Guid id, string title, int subjectId, DateOnly dueDate, - Test.TestVolume volume, Test.PersonalUnderstanding understanding, double? grade) + Test.TestVolume volume, Test.PersonalUnderstanding understanding, double? grade, int userId) { + if (string.IsNullOrWhiteSpace(title)) + throw new ArgumentException("Title cannot be empty.", nameof(title)); + EnsureSubjectAccessible(subjectId, userId); + var test = context.Tests.Find(id); - if (test is null) return; + // Only the owner may edit a test. + if (test is null || test.UserId != userId) return; test.Title = title; test.Subject = subjectId; @@ -44,13 +53,24 @@ public void TestEditor(Guid id, string title, int subjectId, DateOnly dueDate, context.SaveChanges(); } - public void TestRemover(Guid id) + public void TestRemover(Guid id, int userId) { var test = context.Tests.Find(id); - if (test is null) return; + // Only the owner may delete a test. + if (test is null || test.UserId != userId) return; context.Tests.Remove(test); context.SaveChanges(); } + private void EnsureSubjectAccessible(int subjectId, int userId) + { + // A test may only reference a system subject or one of the user's own + // custom subjects — never another user's subject. + var accessible = context.Subjects + .Any(s => s.Id == subjectId && (s.IsSystem || s.UserId == userId)); + if (!accessible) + throw new ArgumentException("Subject does not exist or is not accessible.", nameof(subjectId)); + } + public List GetSubjectsForUser(int userId) { return context.Subjects @@ -62,10 +82,12 @@ public List GetSubjectsForUser(int userId) public void AddCustomSubject(string name, int userId) { - var nextId = context.Subjects.Any() ? context.Subjects.Max(s => s.Id) + 1 : 6; + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Subject name cannot be empty.", nameof(name)); + + // Id is database-generated (auto-increment) — see WsistContext. var subject = new Subject { - Id = nextId, Name = name, IsSystem = false, UserId = userId @@ -93,27 +115,47 @@ public bool RemoveCustomSubject(int subjectId, int userId) public void UpdateDisplayName(int userId, string displayName) { + var trimmed = displayName.Trim(); + if (string.IsNullOrEmpty(trimmed)) + throw new ArgumentException("Display name cannot be empty.", nameof(displayName)); + var user = context.Users.Find(userId); if (user is null) return; - user.DisplayName = displayName.Trim(); + user.DisplayName = trimmed; context.SaveChanges(); } public User GetOrCreateUser(string email, string displayName, string googleId) { + if (string.IsNullOrWhiteSpace(email)) + throw new ArgumentException("Email cannot be empty.", nameof(email)); + var user = context.Users.FirstOrDefault(u => u.Email == email); if (user is not null) return user; - user = new User + try { - Email = email, - DisplayName = displayName, - GoogleId = googleId, - CreatedAt = DateTime.UtcNow - }; - context.Users.Add(user); - context.SaveChanges(); - return user; + user = new User + { + Email = email, + DisplayName = displayName, + GoogleId = googleId, + CreatedAt = DateTime.UtcNow + }; + context.Users.Add(user); + context.SaveChanges(); + return user; + } + catch (Microsoft.EntityFrameworkCore.DbUpdateException) + { + // A concurrent request created the same user between our check and + // the insert (unique index on Email) — fetch the winner instead. + // If no row exists, the failure had another cause; surface it. + context.ChangeTracker.Clear(); + var existing = context.Users.FirstOrDefault(u => u.Email == email); + if (existing is null) throw; + return existing; + } } public void DeleteUser(int userId) diff --git a/WSIST/WSIST.Engine/User.cs b/WSIST/WSIST.Engine/User.cs index 1e83a9d..ddf0704 100644 --- a/WSIST/WSIST.Engine/User.cs +++ b/WSIST/WSIST.Engine/User.cs @@ -6,6 +6,8 @@ public class User public string Email { get; set; } = string.Empty; public string DisplayName { get; set; } = string.Empty; public string GoogleId { get; set; } = string.Empty; - public DateTime CreatedAt { get; set; } + // Default so a User created without an explicit timestamp never persists + // DateTime.MinValue (0001-01-01). + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public ICollection Tests { get; set; } = []; } \ No newline at end of file diff --git a/WSIST/WSIST.Engine/WSIST.Engine.csproj b/WSIST/WSIST.Engine/WSIST.Engine.csproj index fd7087f..bb3b50e 100644 --- a/WSIST/WSIST.Engine/WSIST.Engine.csproj +++ b/WSIST/WSIST.Engine/WSIST.Engine.csproj @@ -1,13 +1,16 @@ - + net10.0 enable enable - - - + + + + diff --git a/WSIST/WSIST.Engine/WsistContext.cs b/WSIST/WSIST.Engine/WsistContext.cs index 65accea..35b44ff 100644 --- a/WSIST/WSIST.Engine/WsistContext.cs +++ b/WSIST/WSIST.Engine/WsistContext.cs @@ -37,7 +37,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { entity.ToTable("Subjects"); entity.HasKey(e => e.Id); - entity.Property(e => e.Id).ValueGeneratedNever(); + // User subjects get database-generated (auto-increment) ids; the + // seeded system subjects live on negative ids so the two ranges + // can never collide. + entity.Property(e => e.Id).ValueGeneratedOnAdd(); entity.Property(e => e.Name).HasMaxLength(100).IsRequired(); entity.Property(e => e.IsSystem).HasDefaultValue(false); @@ -48,12 +51,12 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.Cascade); entity.HasData( - new Subject { Id = 0, Name = "Math", IsSystem = true }, - new Subject { Id = 1, Name = "English", IsSystem = true }, - new Subject { Id = 2, Name = "French", IsSystem = true }, - new Subject { Id = 3, Name = "German", IsSystem = true }, - new Subject { Id = 4, Name = "Chemistry", IsSystem = true }, - new Subject { Id = 5, Name = "Other", IsSystem = true } + new Subject { Id = -6, Name = "Math", IsSystem = true }, + new Subject { Id = -5, Name = "English", IsSystem = true }, + new Subject { Id = -4, Name = "French", IsSystem = true }, + new Subject { Id = -3, Name = "German", IsSystem = true }, + new Subject { Id = -2, Name = "Chemistry", IsSystem = true }, + new Subject { Id = -1, Name = "Other", IsSystem = true } ); }); diff --git a/WSIST/WSIST.UnitTests/UnitTests.cs b/WSIST/WSIST.UnitTests/UnitTests.cs index 1e47a8e..681d282 100644 --- a/WSIST/WSIST.UnitTests/UnitTests.cs +++ b/WSIST/WSIST.UnitTests/UnitTests.cs @@ -28,18 +28,36 @@ private static User SeedUser(WsistContext context) return user; } + private static int SeedSystemSubject(WsistContext context, string name = "Math") + { + // HasData seeding doesn't run for the in-memory provider, and the + // engine now validates that a test's subject exists and is accessible, + // so tests must seed a subject explicitly. System subjects live on + // negative ids (see WsistContext). + var subject = new Subject + { + Id = -6, + Name = name, + IsSystem = true, + }; + context.Subjects.Add(subject); + context.SaveChanges(); + return subject.Id; + } + [Test] public void TestIfNewTestGetsMade() { //arrange using var context = CreateContext(); var user = SeedUser(context); + var subjectId = SeedSystemSubject(context); var manager = new TestManagement(context); //act manager.NewTestMaker( "Math Test", - 0, // was Test.Subjects.Math + subjectId, new DateOnly(2026, 12, 01), Test.TestVolume.VeryHigh, Test.PersonalUnderstanding.VeryLow, @@ -57,11 +75,12 @@ public void CheckIfTestWasDeleted() //arrange using var context = CreateContext(); var user = SeedUser(context); + var subjectId = SeedSystemSubject(context, "German"); var manager = new TestManagement(context); manager.NewTestMaker( "Test To Delete", - 3, // was Test.Subjects.German + subjectId, new DateOnly(2026, 12, 01), Test.TestVolume.Low, Test.PersonalUnderstanding.High, @@ -71,14 +90,14 @@ public void CheckIfTestWasDeleted() //act var test = manager.LoadAllTests(user.Id).First(); - manager.TestRemover(test.Id); + manager.TestRemover(test.Id, user.Id); //assert Assert.That(manager.LoadAllTests(user.Id).Any(t => t.Id == test.Id), Is.False); } [Test] - public static void CheckIfGradeIsNotNullIfInThePast() + public void CheckIfGradeIsNotNullIfInThePast() { //arrange DateOnly dueDate = new DateOnly(2025, 06, 07); @@ -92,7 +111,7 @@ public static void CheckIfGradeIsNotNullIfInThePast() } [Test] - public static void CheckIfGradeIsNullIfInTheFuture() + public void CheckIfGradeIsNullIfInTheFuture() { //arrange DateOnly dueDate = new DateOnly(2030, 06, 07); @@ -186,17 +205,19 @@ public void GetSubjectsForUser_ReturnsSystemAndOwnSubjectsOnly() context.Users.Add(otherUser); context.SaveChanges(); - // HasData seeding doesn't run for in-memory DB, so seed manually + // HasData seeding doesn't run for in-memory DB, so seed manually. + // System subjects live on negative ids (see WsistContext) so they can + // never collide with the provider-generated ids of custom subjects. context.Subjects.AddRange( new Subject { - Id = 0, + Id = -6, Name = "Math", IsSystem = true, }, new Subject { - Id = 1, + Id = -5, Name = "English", IsSystem = true, } @@ -218,7 +239,7 @@ public void GetSubjectsForUser_ReturnsSystemAndOwnSubjectsOnly() } [Test] - public static void GradeScoreGivesFullPushBelowAverageOfFour() + public void GradeScoreGivesFullPushBelowAverageOfFour() { var calculator = new PriorityCalculator(); var tests = new List @@ -234,12 +255,12 @@ public static void GradeScoreGivesFullPushBelowAverageOfFour() Assert.That( calculator.CalculateGradeScore(0, tests), Is.EqualTo(6), - "An average below 3 must earn the full +6 push, not 0." + "An average below 4 must earn the full +6 push, not 0." ); } [Test] - public static void GradeScoreGivesSmallPushForStrongAverage() + public void GradeScoreGivesSmallPushForStrongAverage() { var calculator = new PriorityCalculator(); var tests = new List @@ -255,6 +276,139 @@ public static void GradeScoreGivesSmallPushForStrongAverage() Assert.That(calculator.CalculateGradeScore(0, tests), Is.EqualTo(2)); } + [Test] + public void TestRemover_RefusesToDeleteAnotherUsersTest() + { + //arrange + using var context = CreateContext(); + var owner = SeedUser(context); + var attacker = new User + { + Email = "attacker@example.com", + DisplayName = "Attacker", + GoogleId = "google-999", + CreatedAt = DateTime.UtcNow, + }; + context.Users.Add(attacker); + context.SaveChanges(); + var subjectId = SeedSystemSubject(context); + + var manager = new TestManagement(context); + manager.NewTestMaker( + "Owner's Test", + subjectId, + new DateOnly(2026, 12, 01), + Test.TestVolume.Medium, + Test.PersonalUnderstanding.Medium, + null, + owner.Id + ); + var test = manager.LoadAllTests(owner.Id).First(); + + //act — attacker tries to delete the owner's test + manager.TestRemover(test.Id, attacker.Id); + + //assert + Assert.That(manager.LoadAllTests(owner.Id).Any(t => t.Id == test.Id)); + } + + [Test] + public void TestEditor_RefusesToEditAnotherUsersTest() + { + //arrange + using var context = CreateContext(); + var owner = SeedUser(context); + var attacker = new User + { + Email = "attacker@example.com", + DisplayName = "Attacker", + GoogleId = "google-999", + CreatedAt = DateTime.UtcNow, + }; + context.Users.Add(attacker); + context.SaveChanges(); + var subjectId = SeedSystemSubject(context); + + var manager = new TestManagement(context); + manager.NewTestMaker( + "Original Title", + subjectId, + new DateOnly(2026, 12, 01), + Test.TestVolume.Medium, + Test.PersonalUnderstanding.Medium, + null, + owner.Id + ); + var test = manager.LoadAllTests(owner.Id).First(); + + //act — attacker tries to edit the owner's test + manager.TestEditor( + test.Id, + "Hijacked Title", + test.Subject, + test.DueDate, + test.Volume, + test.Understanding, + test.Grade, + attacker.Id + ); + + //assert + Assert.That(manager.LoadAllTests(owner.Id).First().Title, Is.EqualTo("Original Title")); + } + + [Test] + public void NewTestMaker_RejectsEmptyTitle() + { + //arrange + using var context = CreateContext(); + var user = SeedUser(context); + var manager = new TestManagement(context); + + //act + assert + Assert.Throws(() => manager.NewTestMaker( + " ", + 0, + new DateOnly(2026, 12, 01), + Test.TestVolume.Medium, + Test.PersonalUnderstanding.Medium, + null, + user.Id + )); + } + + [Test] + public void NewTestMaker_RejectsAnotherUsersSubject() + { + //arrange + using var context = CreateContext(); + var owner = SeedUser(context); + var attacker = new User + { + Email = "attacker@example.com", + DisplayName = "Attacker", + GoogleId = "google-999", + CreatedAt = DateTime.UtcNow, + }; + context.Users.Add(attacker); + context.SaveChanges(); + + var manager = new TestManagement(context); + manager.AddCustomSubject("Owner's Subject", owner.Id); + var subjectId = manager.GetSubjectsForUser(owner.Id).First(s => !s.IsSystem).Id; + + //act + assert — attacker may not file tests under the owner's subject + Assert.Throws(() => manager.NewTestMaker( + "Sneaky Test", + subjectId, + new DateOnly(2026, 12, 01), + Test.TestVolume.Medium, + Test.PersonalUnderstanding.Medium, + null, + attacker.Id + )); + } + [Test] public void RemoveCustomSubject_RefusesWhenSubjectStillHasTests() { diff --git a/WSIST/WSIST.UnitTests/WSIST.UnitTests.csproj b/WSIST/WSIST.UnitTests/WSIST.UnitTests.csproj index f1ac213..3bb2fbc 100644 --- a/WSIST/WSIST.UnitTests/WSIST.UnitTests.csproj +++ b/WSIST/WSIST.UnitTests/WSIST.UnitTests.csproj @@ -7,10 +7,11 @@ - - - - + + + + + diff --git a/WSIST/WSIST.Web/Components/App.razor b/WSIST/WSIST.Web/Components/App.razor index c78ff7c..c006301 100644 --- a/WSIST/WSIST.Web/Components/App.razor +++ b/WSIST/WSIST.Web/Components/App.razor @@ -8,7 +8,7 @@ - + diff --git a/WSIST/WSIST.Web/Components/Pages/AuthenticatedComponentBase.cs b/WSIST/WSIST.Web/Components/Pages/AuthenticatedComponentBase.cs index 4e81ac6..4fecd9d 100644 --- a/WSIST/WSIST.Web/Components/Pages/AuthenticatedComponentBase.cs +++ b/WSIST/WSIST.Web/Components/Pages/AuthenticatedComponentBase.cs @@ -27,13 +27,37 @@ protected override async Task OnInitializedAsync() var name = user.FindFirst(ClaimTypes.Name)?.Value ?? "Unknown"; var googleId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? ""; - if (email is null) return; + if (email is null) + { + // Without an email we can't resolve a user; treat the session as + // unauthenticated instead of leaving CurrentUserId at 0. + navigation.NavigateTo("/login-page", forceLoad: true); + return; + } - var dbUser = management.GetOrCreateUser(email, name, googleId); - CurrentUserId = dbUser.Id; + try + { + var dbUser = management.GetOrCreateUser(email, name, googleId); + CurrentUserId = dbUser.Id; + } + catch (Exception) + { + // Database unavailable or user resolution failed — send the user + // to the error page instead of crashing the circuit. + navigation.NavigateTo("/Error", forceLoad: true); + return; + } await OnAuthenticatedAsync(); } protected virtual Task OnAuthenticatedAsync() => Task.CompletedTask; + + // Shared grade-to-CSS mapping used by the dashboard and study pages. + protected static string GetGradeClass(double avg) => avg switch + { + >= 5 => "grade-good", + >= 4 => "grade-ok", + _ => "grade-poor" + }; } diff --git a/WSIST/WSIST.Web/Components/Pages/Error.razor b/WSIST/WSIST.Web/Components/Pages/Error.razor index b165e7e..48a6207 100644 --- a/WSIST/WSIST.Web/Components/Pages/Error.razor +++ b/WSIST/WSIST.Web/Components/Pages/Error.razor @@ -5,7 +5,7 @@
- +

Something went wrong

An unexpected error occurred. If this keeps happening, try logging out and back in.

← Back to home diff --git a/WSIST/WSIST.Web/Components/Pages/Home.razor b/WSIST/WSIST.Web/Components/Pages/Home.razor index bf7bea3..e0c662e 100644 --- a/WSIST/WSIST.Web/Components/Pages/Home.razor +++ b/WSIST/WSIST.Web/Components/Pages/Home.razor @@ -12,7 +12,7 @@
Settings - Study → + Study → Logout
@@ -61,7 +61,7 @@ @(subjects.FirstOrDefault(s => s.Id == topRecommendation.Subject)?.Name ?? "Unknown") · in @days day@(days == 1 ? "" : "s") · @score/40 pts - Full plan → + Full plan →
@@ -153,12 +153,8 @@ const int pad = 8; int pts = history.Count; - // Map grades (1–6) to Y coords — higher grade = lower Y value (top of SVG) - string ToY(double g) => ((h - pad) - ((g - 1) / 5.0 * (h - pad * 2))).ToString("F1"); - string ToX(int i) => (pad + i * (double)(w - pad * 2) / (pts - 1)).ToString("F1"); - - var points = string.Join(" ", history.Select((item, i) => $"{ToX(i)},{ToY(item.Grade)}")); - var dotPoints = history.Select((item, i) => (X: ToX(i), Y: ToY(item.Grade), Grade: item.Grade)).ToList(); + var points = string.Join(" ", history.Select((item, i) => $"{MapIndexToX(i, pts, w, pad)},{MapGradeToY(item.Grade, h, pad)}")); + var dotPoints = history.Select((item, i) => (X: MapIndexToX(i, pts, w, pad), Y: MapGradeToY(item.Grade, h, pad), Grade: item.Grade)).ToList();
@@ -167,9 +163,9 @@
- -
- +
diff --git a/WSIST/WSIST.Web/Components/Pages/Home.razor.cs b/WSIST/WSIST.Web/Components/Pages/Home.razor.cs index d93d90a..dc1ee83 100644 --- a/WSIST/WSIST.Web/Components/Pages/Home.razor.cs +++ b/WSIST/WSIST.Web/Components/Pages/Home.razor.cs @@ -68,12 +68,13 @@ private Dictionary GetSubjectAverages() ); } - private string GetGradeClass(double avg) => avg switch - { - >= 5 => "grade-good", - >= 4 => "grade-ok", - _ => "grade-poor" - }; + // Map a grade (1–6) to an SVG Y coordinate — higher grade = lower Y (top of SVG). + private static string MapGradeToY(double grade, int height, int padding) => + ((height - padding) - ((grade - 1) / 5.0 * (height - padding * 2))).ToString("F1"); + + // Map a point index to an SVG X coordinate, spreading points across the width. + private static string MapIndexToX(int index, int totalPoints, int width, int padding) => + (padding + index * (double)(width - padding * 2) / (totalPoints - 1)).ToString("F1"); public enum Modes { @@ -138,7 +139,8 @@ private void ModalSubmit() temporaryTest.DueDate, temporaryTest.Volume, temporaryTest.Understanding, - temporaryTest.Grade + temporaryTest.Grade, + CurrentUserId ); break; } @@ -168,7 +170,7 @@ private void Refresh() private void DeleteTest(Guid id) { - management.TestRemover(id); + management.TestRemover(id, CurrentUserId); Refresh(); } } diff --git a/WSIST/WSIST.Web/Components/Pages/NotFound.razor b/WSIST/WSIST.Web/Components/Pages/NotFound.razor new file mode 100644 index 0000000..1db5c8f --- /dev/null +++ b/WSIST/WSIST.Web/Components/Pages/NotFound.razor @@ -0,0 +1,13 @@ +@page "/not-found" +@layout EmptyLayout +@using WSIST.Web.Components.Layout +Page not found – WSIST + +
+
+ +

Page not found

+

The page you're looking for doesn't exist or has been moved.

+ ← Back to home +
+
diff --git a/WSIST/WSIST.Web/Components/Pages/Study.razor.cs b/WSIST/WSIST.Web/Components/Pages/Study.razor.cs index 97242ef..ea3db36 100644 --- a/WSIST/WSIST.Web/Components/Pages/Study.razor.cs +++ b/WSIST/WSIST.Web/Components/Pages/Study.razor.cs @@ -54,13 +54,6 @@ private static string DayLabel(DateOnly date) return date == today ? "Today" : date.ToString("ddd d MMM"); } - private string GetGradeClass(double avg) => avg switch - { - >= 5 => "grade-good", - >= 4 => "grade-ok", - _ => "grade-poor" - }; - private void OpenStudiedPrompt(Test test) { studiedTestId = test.Id; @@ -81,7 +74,8 @@ private void SaveStudiedUnderstanding(Test test) test.DueDate, test.Volume, updatedUnderstanding, - test.Grade + test.Grade, + CurrentUserId ); allTests = management.LoadAllTests(CurrentUserId); diff --git a/WSIST/WSIST.Web/Components/Routes.razor b/WSIST/WSIST.Web/Components/Routes.razor index ae94e9e..9661f49 100644 --- a/WSIST/WSIST.Web/Components/Routes.razor +++ b/WSIST/WSIST.Web/Components/Routes.razor @@ -1,4 +1,4 @@ - + diff --git a/WSIST/WSIST.Web/Program.cs b/WSIST/WSIST.Web/Program.cs index bdfe426..85ee95a 100644 --- a/WSIST/WSIST.Web/Program.cs +++ b/WSIST/WSIST.Web/Program.cs @@ -13,7 +13,7 @@ builder.Services.Configure(options => { options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; - options.KnownNetworks.Clear(); + options.KnownIPNetworks.Clear(); options.KnownProxies.Clear(); }); @@ -63,7 +63,7 @@ { var isAuthenticated = context.User?.Identity?.IsAuthenticated ?? false; var protectedPaths = new[] { "/", "/study", "/settings" }; - if (protectedPaths.Contains(context.Request.Path.Value) && !isAuthenticated) + if (protectedPaths.Contains(context.Request.Path.Value, StringComparer.OrdinalIgnoreCase) && !isAuthenticated) { context.Response.Redirect("/login-page"); return; @@ -92,6 +92,8 @@ var email = ctx.User.FindFirst(ClaimTypes.Email)?.Value; if (email is null) return Results.Unauthorized(); + // Users are keyed by their (unique) email; GoogleId is informational + // only, so a missing NameIdentifier claim simply stores an empty value. var user = management.GetOrCreateUser( email, ctx.User.FindFirst(ClaimTypes.Name)?.Value ?? "Unknown", diff --git a/WSIST/WSIST.Web/WSIST.Web.csproj b/WSIST/WSIST.Web/WSIST.Web.csproj index eca2ed8..d3ecb2a 100644 --- a/WSIST/WSIST.Web/WSIST.Web.csproj +++ b/WSIST/WSIST.Web/WSIST.Web.csproj @@ -7,6 +7,6 @@ - + diff --git a/WSIST/WSIST.Web/appsettings.json b/WSIST/WSIST.Web/appsettings.json index 4184c87..10f68b8 100644 --- a/WSIST/WSIST.Web/appsettings.json +++ b/WSIST/WSIST.Web/appsettings.json @@ -1,7 +1,4 @@ { - "ConnectionStrings": { - "DatabaseConnection": "Server=localhost; Database=wsistdb; Uid=root; Pwd=root;" - }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/WSIST/WSIST.Web/wwwroot/app.css b/WSIST/WSIST.Web/wwwroot/app.css index eb252b5..89503ec 100644 --- a/WSIST/WSIST.Web/wwwroot/app.css +++ b/WSIST/WSIST.Web/wwwroot/app.css @@ -367,19 +367,15 @@ h1:focus { transform: scale(0.97); } -/* Accent (CTA) button */ -.button-primary.button-accent, -a.button-primary[href="/login"], -a.button-primary[href="/study"] { +/* Accent (CTA) button — opt in with the .button-accent class */ +.button-primary.button-accent { background: var(--accent); color: #0c0b08; border-color: transparent; font-weight: 600; } -.button-primary.button-accent:hover, -a.button-primary[href="/login"]:hover, -a.button-primary[href="/study"]:hover { +.button-primary.button-accent:hover { background: var(--accent-hover); border-color: transparent; } diff --git a/WSIST/WSIST.Web/wwwroot/landing.css b/WSIST/WSIST.Web/wwwroot/landing.css index fedc69f..5450062 100644 --- a/WSIST/WSIST.Web/wwwroot/landing.css +++ b/WSIST/WSIST.Web/wwwroot/landing.css @@ -8,8 +8,15 @@ can leak into the dashboard (app.css). ============================================================ */ -html{ +/* Only smooth-scroll when the landing page is on screen, so the rule + can't leak into the dashboard. :has() is supported by all evergreen + browsers (Chrome 105+, Safari 15.4+, Firefox 121+); older ones simply + fall back to instant scrolling — a harmless degradation. */ +html:has(.landing){ scroll-behavior:smooth; +} + +html{ -webkit-text-size-adjust:100%; } diff --git a/WSIST/WSIST.Web/wwwroot/landing.js b/WSIST/WSIST.Web/wwwroot/landing.js index 63f4d2e..6ae807d 100644 --- a/WSIST/WSIST.Web/wwwroot/landing.js +++ b/WSIST/WSIST.Web/wwwroot/landing.js @@ -72,11 +72,8 @@ return 0; } - /* NOTE: the C# engine currently returns 0 for an average below 3 - (the `_ => 0` branch). That contradicts the product's own rule — - "struggling subjects get a push" — so this demo uses the intended - mapping: anything under 4 earns the full +6. One-line fix in - PriorityCalculator.CalculateGradeScore: change `_ => 0` to `_ => 6`. */ + /* Mirrors PriorityCalculator.CalculateGradeScore in the C# engine: + anything under an average of 4 earns the full +6 push. */ function gradeScore(avg){ if (avg >= 5) return 2; if (avg >= 4) return 4; @@ -175,7 +172,7 @@ setTimeout(function(){ verdict.textContent = vText; verdict.classList.remove("swap"); - }, 160); + }, 250); // match the .25s opacity transition on the verdict element } } } diff --git a/WSIST/docker-compose.yml b/WSIST/docker-compose.yml index 73bcd5e..e2b4a7d 100644 --- a/WSIST/docker-compose.yml +++ b/WSIST/docker-compose.yml @@ -1,17 +1,19 @@ +# Local development database. Defaults are for local use only — never use them +# in shared or production environments. Copy .env.example to .env to override. services: mysql: image: mysql:8.0 container_name: wsist-mysql restart: unless-stopped environment: - MYSQL_ROOT_PASSWORD: password - MYSQL_DATABASE: wsistdb + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-password} + MYSQL_DATABASE: ${MYSQL_DATABASE:-wsistdb} ports: - "3306:3306" volumes: - wsist-mysql-data:/var/lib/mysql healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-ppassword"] + test: ["CMD-SHELL", "mysqladmin ping -h localhost -uroot -p$$MYSQL_ROOT_PASSWORD"] interval: 10s timeout: 5s retries: 10 diff --git a/WSIST/docs/design/wsist-landing.html b/WSIST/docs/design/wsist-landing.html index e756643..16e77ad 100644 --- a/WSIST/docs/design/wsist-landing.html +++ b/WSIST/docs/design/wsist-landing.html @@ -1348,11 +1348,8 @@

Your next test is coming either way.

return 0; } - /* NOTE: the C# engine currently returns 0 for an average below 3 - (the `_ => 0` branch). That contradicts the product's own rule — - "struggling subjects get a push" — so this demo uses the intended - mapping: anything under 4 earns the full +6. One-line fix in - PriorityCalculator.CalculateGradeScore: change `_ => 0` to `_ => 6`. */ + /* Mirrors PriorityCalculator.CalculateGradeScore in the C# engine: + anything under an average of 4 earns the full +6 push. */ function gradeScore(avg){ if (avg >= 5) return 2; if (avg >= 4) return 4; @@ -1451,7 +1448,7 @@

Your next test is coming either way.

setTimeout(function(){ verdict.textContent = vText; verdict.classList.remove("swap"); - }, 160); + }, 250); /* match the .25s opacity transition on the verdict element */ } } } diff --git a/WSIST/nixpacks.toml b/WSIST/nixpacks.toml index 5b5e0c0..a6926d1 100644 --- a/WSIST/nixpacks.toml +++ b/WSIST/nixpacks.toml @@ -1,4 +1,7 @@ -[phases.setup] +# dotnet-sdk_10 on nixos-unstable resolves to whatever 10.x patch is current +# there (typically newer than global.json's 10.0.201) — that's fine: +# global.json sets rollForward=latestMinor, which accepts any newer 10.x SDK. +[phases.setup] nixPkgs = ["dotnet-sdk_10"] nixLibs = [] -nixpkgsArchive = "nixos-unstable" \ No newline at end of file +nixpkgsArchive = "nixos-unstable" diff --git a/docs/landing-implementation.md b/docs/landing-implementation.md index 31972d8..aeb4e77 100644 --- a/docs/landing-implementation.md +++ b/docs/landing-implementation.md @@ -1,5 +1,10 @@ # WSIST — Landing Page & Amber Re-theme Implementation +> **Status: Completed.** The "Design tokens (canonical)" table below is the +> single source of truth for brand colors (accent: `#f5b342`). Older task +> files (`wsist-redesign-tasks.md`, `wsist-phase3-tasks.md`) reference +> superseded values. + Task file for autonomous execution. Work top to bottom; tasks are ordered lowest-risk first. Commit after each task with the given message. Run `dotnet build` after every task and `dotnet test` after T1. diff --git a/docs/wsist-housekeeping-tasks.md b/docs/wsist-housekeeping-tasks.md index 5685dd5..d4e5141 100644 --- a/docs/wsist-housekeeping-tasks.md +++ b/docs/wsist-housekeeping-tasks.md @@ -135,24 +135,25 @@ If for any reason the file is missing, create `WSIST/docker-compose.yml`: ```yaml services: - db: + mysql: image: mysql:8.0 - container_name: wsist-db + container_name: wsist-mysql + restart: unless-stopped environment: - MYSQL_ROOT_PASSWORD: root - MYSQL_DATABASE: wsistdb + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-password} + MYSQL_DATABASE: ${MYSQL_DATABASE:-wsistdb} ports: - "3306:3306" volumes: - - wsist-db-data:/var/lib/mysql + - wsist-mysql-data:/var/lib/mysql healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] - interval: 5s - timeout: 3s + test: ["CMD-SHELL", "mysqladmin ping -h localhost -uroot -p$$MYSQL_ROOT_PASSWORD"] + interval: 10s + timeout: 5s retries: 10 volumes: - wsist-db-data: + wsist-mysql-data: ``` ### Step 2 — Archive completed task files diff --git a/docs/wsist-phase3-tasks.md b/docs/wsist-phase3-tasks.md index affc625..10e5ac6 100644 --- a/docs/wsist-phase3-tasks.md +++ b/docs/wsist-phase3-tasks.md @@ -1,5 +1,10 @@ # WSIST — Phase 3 Task File (Claude Fable 5) +> **Status: Completed (historical).** This spec predates the amber re-theme. +> Color values in this file (e.g. the blue `#5b8cff`) are outdated — the +> canonical design tokens live in `docs/landing-implementation.md` +> (accent: `#f5b342`). + ## Instructions for the model Work through every task in the order listed. Do not stop to ask for clarification — every decision is specified below. Commit after each task using the exact message provided. If a file already exists, edit it in place rather than replacing it. Run `dotnet build` before each commit to confirm no compilation errors. diff --git a/docs/wsist-redesign-tasks.md b/docs/wsist-redesign-tasks.md index 0944fe4..fda6627 100644 --- a/docs/wsist-redesign-tasks.md +++ b/docs/wsist-redesign-tasks.md @@ -1,5 +1,9 @@ # WSIST — Full Redesign Task File +> **Status: Completed (historical). Superseded by `docs/landing-implementation.md`.** +> The amber accent used in this file (`#f59e0b`) was later replaced by the +> canonical `#f5b342` defined in the landing-implementation design tokens. + ## Context for the model WSIST is a Blazor Server app styled with plain CSS in `app.css`. There is no Tailwind, no React, no npm. Launch UI is used as **visual reference only** — translate its design language into hand-crafted CSS. Do not attempt to install any npm packages or import React components.