From 12d79f9a99d5f46530e4c8d574810bbf0399ca55 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Mon, 12 Dec 2022 17:11:38 -0500 Subject: [PATCH 01/17] Add state to ArchivedChallenge entity. --- .../Data/Entities/ArchivedChallenge.cs | 1 + ...0843_AddArchivedChallengeState.Designer.cs | 952 ++++++++++++++++++ ...0221212220843_AddArchivedChallengeState.cs | 25 + ...meboardDbContextPostgreSQLModelSnapshot.cs | 20 +- ...0838_AddArchivedChallengeState.Designer.cs | 951 +++++++++++++++++ ...0221212220838_AddArchivedChallengeState.cs | 25 + ...ameboardDbContextSqlServerModelSnapshot.cs | 12 +- 7 files changed, 1975 insertions(+), 11 deletions(-) create mode 100644 src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20221212220843_AddArchivedChallengeState.Designer.cs create mode 100644 src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20221212220843_AddArchivedChallengeState.cs create mode 100644 src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20221212220838_AddArchivedChallengeState.Designer.cs create mode 100644 src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20221212220838_AddArchivedChallengeState.cs diff --git a/src/Gameboard.Api/Data/Entities/ArchivedChallenge.cs b/src/Gameboard.Api/Data/Entities/ArchivedChallenge.cs index e520a3d2..2ad839e0 100644 --- a/src/Gameboard.Api/Data/Entities/ArchivedChallenge.cs +++ b/src/Gameboard.Api/Data/Entities/ArchivedChallenge.cs @@ -18,6 +18,7 @@ public class ArchivedChallenge : IEntity public DateTimeOffset LastScoreTime { get; set; } public DateTimeOffset LastSyncTime { get; set; } public bool HasGamespaceDeployed { get; set; } + public string State { get; set; } public int Points { get; set; } public int Score { get; set; } public long Duration { get; set; } diff --git a/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20221212220843_AddArchivedChallengeState.Designer.cs b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20221212220843_AddArchivedChallengeState.Designer.cs new file mode 100644 index 00000000..6e9e5295 --- /dev/null +++ b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20221212220843_AddArchivedChallengeState.Designer.cs @@ -0,0 +1,952 @@ +// +using System; +using Gameboard.Api.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 Gameboard.Api.Data.Migrations.PostgreSQL.GameboardDb +{ + [DbContext(typeof(GameboardDbContextPostgreSQL))] + [Migration("20221212220843_AddArchivedChallengeState")] + partial class AddArchivedChallengeState + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Gameboard.Api.Data.ArchivedChallenge", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Duration") + .HasColumnType("bigint"); + + b.Property("EndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Events") + .HasColumnType("text"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("GameName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("HasGamespaceDeployed") + .HasColumnType("boolean"); + + b.Property("LastScoreTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSyncTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("PlayerId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("PlayerName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Points") + .HasColumnType("integer"); + + b.Property("Result") + .HasColumnType("integer"); + + b.Property("Score") + .HasColumnType("integer"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("State") + .HasColumnType("text"); + + b.Property("Submissions") + .HasColumnType("text"); + + b.Property("Tag") + .HasColumnType("text"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TeamMembers") + .HasColumnType("text"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("PlayerId"); + + b.HasIndex("TeamId"); + + b.HasIndex("UserId"); + + b.ToTable("ArchivedChallenges"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Challenge", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("EndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("GraderKey") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("HasDeployedGamespace") + .HasColumnType("boolean"); + + b.Property("LastScoreTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSyncTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("PlayerId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Points") + .HasColumnType("integer"); + + b.Property("Score") + .HasColumnType("double precision"); + + b.Property("SpecId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone"); + + b.Property("State") + .HasColumnType("text"); + + b.Property("Tag") + .HasColumnType("text"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("WhenCreated") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("PlayerId"); + + b.HasIndex("TeamId"); + + b.ToTable("Challenges"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeEvent", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ChallengeId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Text") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.ToTable("ChallengeEvents"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeGate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("RequiredId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("RequiredScore") + .HasColumnType("double precision"); + + b.Property("TargetId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.ToTable("ChallengeGates"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeSpec", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("AverageDeploySeconds") + .HasColumnType("integer"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Disabled") + .HasColumnType("boolean"); + + b.Property("ExternalId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Points") + .HasColumnType("integer"); + + b.Property("R") + .HasColumnType("real"); + + b.Property("Tag") + .HasColumnType("text"); + + b.Property("X") + .HasColumnType("real"); + + b.Property("Y") + .HasColumnType("real"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.ToTable("ChallengeSpecs"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Feedback", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Answers") + .HasColumnType("text"); + + b.Property("ChallengeId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ChallengeSpecId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("PlayerId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Submitted") + .HasColumnType("boolean"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.HasIndex("ChallengeSpecId"); + + b.HasIndex("GameId"); + + b.HasIndex("PlayerId"); + + b.HasIndex("UserId"); + + b.ToTable("Feedback"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Game", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("AllowPreview") + .HasColumnType("boolean"); + + b.Property("AllowReset") + .HasColumnType("boolean"); + + b.Property("Background") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CardText1") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CardText2") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CardText3") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CertificateTemplate") + .HasColumnType("text"); + + b.Property("Competition") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Division") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("FeedbackConfig") + .HasColumnType("text"); + + b.Property("GameEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("GameMarkdown") + .HasColumnType("text"); + + b.Property("GameStart") + .HasColumnType("timestamp with time zone"); + + b.Property("GamespaceLimitPerSession") + .HasColumnType("integer"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("Key") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Logo") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MaxAttempts") + .HasColumnType("integer"); + + b.Property("MaxTeamSize") + .HasColumnType("integer"); + + b.Property("MinTeamSize") + .HasColumnType("integer"); + + b.Property("Mode") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RegistrationClose") + .HasColumnType("timestamp with time zone"); + + b.Property("RegistrationConstraint") + .HasColumnType("text"); + + b.Property("RegistrationMarkdown") + .HasColumnType("text"); + + b.Property("RegistrationOpen") + .HasColumnType("timestamp with time zone"); + + b.Property("RegistrationType") + .HasColumnType("integer"); + + b.Property("RequireSponsoredTeam") + .HasColumnType("boolean"); + + b.Property("Season") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SessionLimit") + .HasColumnType("integer"); + + b.Property("SessionMinutes") + .HasColumnType("integer"); + + b.Property("Sponsor") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TestCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Track") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.ToTable("Games"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Advanced") + .HasColumnType("boolean"); + + b.Property("ApprovedName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CorrectCount") + .HasColumnType("integer"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("InviteCode") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NameStatus") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("PartialCount") + .HasColumnType("integer"); + + b.Property("Rank") + .HasColumnType("integer"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("Score") + .HasColumnType("integer"); + + b.Property("SessionBegin") + .HasColumnType("timestamp with time zone"); + + b.Property("SessionEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("SessionMinutes") + .HasColumnType("integer"); + + b.Property("Sponsor") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TeamSponsors") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Time") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("TeamId"); + + b.HasIndex("UserId"); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Sponsor", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Approved") + .HasColumnType("boolean"); + + b.Property("Logo") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.ToTable("Sponsors"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Ticket", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("AssigneeId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Attachments") + .HasColumnType("text"); + + b.Property("ChallengeId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Key") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseSerialColumn(b.Property("Key")); + + b.Property("Label") + .HasColumnType("text"); + + b.Property("LastUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayerId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("RequesterId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("StaffCreated") + .HasColumnType("boolean"); + + b.Property("Status") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.HasKey("Id"); + + b.HasIndex("AssigneeId"); + + b.HasIndex("ChallengeId"); + + b.HasIndex("CreatorId"); + + b.HasIndex("Key") + .IsUnique(); + + b.HasIndex("PlayerId"); + + b.HasIndex("RequesterId"); + + b.ToTable("Tickets"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.TicketActivity", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AssigneeId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Attachments") + .HasColumnType("text"); + + b.Property("Message") + .HasColumnType("text"); + + b.Property("Status") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TicketId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.HasKey("Id"); + + b.HasIndex("AssigneeId"); + + b.HasIndex("TicketId"); + + b.HasIndex("UserId"); + + b.ToTable("TicketActivity"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.User", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("ApprovedName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Email") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NameStatus") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("Sponsor") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Username") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Challenge", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("Challenges") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Gameboard.Api.Data.Player", "Player") + .WithMany("Challenges") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Game"); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeEvent", b => + { + b.HasOne("Gameboard.Api.Data.Challenge", "Challenge") + .WithMany("Events") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Challenge"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeGate", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("Prerequisites") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Game"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeSpec", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("Specs") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Game"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Feedback", b => + { + b.HasOne("Gameboard.Api.Data.Challenge", "Challenge") + .WithMany("Feedback") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Gameboard.Api.Data.ChallengeSpec", "ChallengeSpec") + .WithMany("Feedback") + .HasForeignKey("ChallengeSpecId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("Feedback") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Gameboard.Api.Data.Player", "Player") + .WithMany("Feedback") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Gameboard.Api.Data.User", "User") + .WithMany("Feedback") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Challenge"); + + b.Navigation("ChallengeSpec"); + + b.Navigation("Game"); + + b.Navigation("Player"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("Players") + .HasForeignKey("GameId"); + + b.HasOne("Gameboard.Api.Data.User", "User") + .WithMany("Enrollments") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Game"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Ticket", b => + { + b.HasOne("Gameboard.Api.Data.User", "Assignee") + .WithMany() + .HasForeignKey("AssigneeId"); + + b.HasOne("Gameboard.Api.Data.Challenge", "Challenge") + .WithMany("Tickets") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Gameboard.Api.Data.User", "Creator") + .WithMany() + .HasForeignKey("CreatorId"); + + b.HasOne("Gameboard.Api.Data.Player", "Player") + .WithMany("Tickets") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Gameboard.Api.Data.User", "Requester") + .WithMany() + .HasForeignKey("RequesterId"); + + b.Navigation("Assignee"); + + b.Navigation("Challenge"); + + b.Navigation("Creator"); + + b.Navigation("Player"); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.TicketActivity", b => + { + b.HasOne("Gameboard.Api.Data.User", "Assignee") + .WithMany() + .HasForeignKey("AssigneeId"); + + b.HasOne("Gameboard.Api.Data.Ticket", "Ticket") + .WithMany("Activity") + .HasForeignKey("TicketId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Gameboard.Api.Data.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Assignee"); + + b.Navigation("Ticket"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Challenge", b => + { + b.Navigation("Events"); + + b.Navigation("Feedback"); + + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeSpec", b => + { + b.Navigation("Feedback"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Game", b => + { + b.Navigation("Challenges"); + + b.Navigation("Feedback"); + + b.Navigation("Players"); + + b.Navigation("Prerequisites"); + + b.Navigation("Specs"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.Navigation("Challenges"); + + b.Navigation("Feedback"); + + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Ticket", b => + { + b.Navigation("Activity"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.User", b => + { + b.Navigation("Enrollments"); + + b.Navigation("Feedback"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20221212220843_AddArchivedChallengeState.cs b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20221212220843_AddArchivedChallengeState.cs new file mode 100644 index 00000000..16a4e15d --- /dev/null +++ b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/20221212220843_AddArchivedChallengeState.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Gameboard.Api.Data.Migrations.PostgreSQL.GameboardDb +{ + public partial class AddArchivedChallengeState : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "State", + table: "ArchivedChallenges", + type: "text", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "State", + table: "ArchivedChallenges"); + } + } +} diff --git a/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/GameboardDbContextPostgreSQLModelSnapshot.cs b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/GameboardDbContextPostgreSQLModelSnapshot.cs index 06bbaa68..87adff08 100644 --- a/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/GameboardDbContextPostgreSQLModelSnapshot.cs +++ b/src/Gameboard.Api/Data/Migrations/PostgreSQL/GameboardDb/GameboardDbContextPostgreSQLModelSnapshot.cs @@ -1,10 +1,9 @@ // using System; -using Gameboard.Api.Data; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable namespace Gameboard.Api.Data.Migrations.PostgreSQL.GameboardDb { @@ -15,9 +14,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .UseIdentityByDefaultColumns() - .HasAnnotation("Relational:MaxIdentifierLength", 63) - .HasAnnotation("ProductVersion", "5.0.0"); + .HasAnnotation("ProductVersion", "6.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); modelBuilder.Entity("Gameboard.Api.Data.ArchivedChallenge", b => { @@ -74,6 +74,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("StartTime") .HasColumnType("timestamp with time zone"); + b.Property("State") + .HasColumnType("text"); + b.Property("Submissions") .HasColumnType("text"); @@ -599,8 +602,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Key") .ValueGeneratedOnAdd() - .HasColumnType("integer") - .UseSerialColumn(); + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseSerialColumn(b.Property("Key")); b.Property("Label") .HasColumnType("text"); diff --git a/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20221212220838_AddArchivedChallengeState.Designer.cs b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20221212220838_AddArchivedChallengeState.Designer.cs new file mode 100644 index 00000000..05905ac2 --- /dev/null +++ b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20221212220838_AddArchivedChallengeState.Designer.cs @@ -0,0 +1,951 @@ +// +using System; +using Gameboard.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Gameboard.Api.Data.Migrations.SqlServer.GameboardDb +{ + [DbContext(typeof(GameboardDbContextSqlServer))] + [Migration("20221212220838_AddArchivedChallengeState")] + partial class AddArchivedChallengeState + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder, 1L, 1); + + modelBuilder.Entity("Gameboard.Api.Data.ArchivedChallenge", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Duration") + .HasColumnType("bigint"); + + b.Property("EndTime") + .HasColumnType("datetimeoffset"); + + b.Property("Events") + .HasColumnType("nvarchar(max)"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("GameName") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("HasGamespaceDeployed") + .HasColumnType("bit"); + + b.Property("LastScoreTime") + .HasColumnType("datetimeoffset"); + + b.Property("LastSyncTime") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("PlayerId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("PlayerName") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Points") + .HasColumnType("int"); + + b.Property("Result") + .HasColumnType("int"); + + b.Property("Score") + .HasColumnType("int"); + + b.Property("StartTime") + .HasColumnType("datetimeoffset"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Submissions") + .HasColumnType("nvarchar(max)"); + + b.Property("Tag") + .HasColumnType("nvarchar(max)"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TeamMembers") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("PlayerId"); + + b.HasIndex("TeamId"); + + b.HasIndex("UserId"); + + b.ToTable("ArchivedChallenges"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Challenge", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("EndTime") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("GraderKey") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("HasDeployedGamespace") + .HasColumnType("bit"); + + b.Property("LastScoreTime") + .HasColumnType("datetimeoffset"); + + b.Property("LastSyncTime") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("PlayerId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Points") + .HasColumnType("int"); + + b.Property("Score") + .HasColumnType("float"); + + b.Property("SpecId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("StartTime") + .HasColumnType("datetimeoffset"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Tag") + .HasColumnType("nvarchar(max)"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("WhenCreated") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("PlayerId"); + + b.HasIndex("TeamId"); + + b.ToTable("Challenges"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeEvent", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ChallengeId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Text") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.ToTable("ChallengeEvents"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeGate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("RequiredId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("RequiredScore") + .HasColumnType("float"); + + b.Property("TargetId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.ToTable("ChallengeGates"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeSpec", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("AverageDeploySeconds") + .HasColumnType("int"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("Disabled") + .HasColumnType("bit"); + + b.Property("ExternalId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Points") + .HasColumnType("int"); + + b.Property("R") + .HasColumnType("real"); + + b.Property("Tag") + .HasColumnType("nvarchar(max)"); + + b.Property("X") + .HasColumnType("real"); + + b.Property("Y") + .HasColumnType("real"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.ToTable("ChallengeSpecs"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Feedback", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Answers") + .HasColumnType("nvarchar(max)"); + + b.Property("ChallengeId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ChallengeSpecId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("PlayerId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Submitted") + .HasColumnType("bit"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.HasIndex("ChallengeSpecId"); + + b.HasIndex("GameId"); + + b.HasIndex("PlayerId"); + + b.HasIndex("UserId"); + + b.ToTable("Feedback"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Game", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("AllowPreview") + .HasColumnType("bit"); + + b.Property("AllowReset") + .HasColumnType("bit"); + + b.Property("Background") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CardText1") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CardText2") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CardText3") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CertificateTemplate") + .HasColumnType("nvarchar(max)"); + + b.Property("Competition") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Division") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FeedbackConfig") + .HasColumnType("nvarchar(max)"); + + b.Property("GameEnd") + .HasColumnType("datetimeoffset"); + + b.Property("GameMarkdown") + .HasColumnType("nvarchar(max)"); + + b.Property("GameStart") + .HasColumnType("datetimeoffset"); + + b.Property("GamespaceLimitPerSession") + .HasColumnType("int"); + + b.Property("IsPublished") + .HasColumnType("bit"); + + b.Property("Key") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Logo") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("MaxAttempts") + .HasColumnType("int"); + + b.Property("MaxTeamSize") + .HasColumnType("int"); + + b.Property("MinTeamSize") + .HasColumnType("int"); + + b.Property("Mode") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RegistrationClose") + .HasColumnType("datetimeoffset"); + + b.Property("RegistrationConstraint") + .HasColumnType("nvarchar(max)"); + + b.Property("RegistrationMarkdown") + .HasColumnType("nvarchar(max)"); + + b.Property("RegistrationOpen") + .HasColumnType("datetimeoffset"); + + b.Property("RegistrationType") + .HasColumnType("int"); + + b.Property("RequireSponsoredTeam") + .HasColumnType("bit"); + + b.Property("Season") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SessionLimit") + .HasColumnType("int"); + + b.Property("SessionMinutes") + .HasColumnType("int"); + + b.Property("Sponsor") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TestCode") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Track") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.ToTable("Games"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Advanced") + .HasColumnType("bit"); + + b.Property("ApprovedName") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CorrectCount") + .HasColumnType("int"); + + b.Property("GameId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("InviteCode") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("NameStatus") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("PartialCount") + .HasColumnType("int"); + + b.Property("Rank") + .HasColumnType("int"); + + b.Property("Role") + .HasColumnType("int"); + + b.Property("Score") + .HasColumnType("int"); + + b.Property("SessionBegin") + .HasColumnType("datetimeoffset"); + + b.Property("SessionEnd") + .HasColumnType("datetimeoffset"); + + b.Property("SessionMinutes") + .HasColumnType("int"); + + b.Property("Sponsor") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("TeamSponsors") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Time") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("TeamId"); + + b.HasIndex("UserId"); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Sponsor", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Approved") + .HasColumnType("bit"); + + b.Property("Logo") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("Id"); + + b.ToTable("Sponsors"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Ticket", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("AssigneeId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("ChallengeId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Created") + .HasColumnType("datetimeoffset"); + + b.Property("CreatorId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("Key") + .HasColumnType("int") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.SerialColumn); + + b.Property("Label") + .HasColumnType("nvarchar(max)"); + + b.Property("LastUpdated") + .HasColumnType("datetimeoffset"); + + b.Property("PlayerId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("RequesterId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("StaffCreated") + .HasColumnType("bit"); + + b.Property("Status") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Summary") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("TeamId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("AssigneeId"); + + b.HasIndex("ChallengeId"); + + b.HasIndex("CreatorId"); + + b.HasIndex("Key") + .IsUnique(); + + b.HasIndex("PlayerId"); + + b.HasIndex("RequesterId"); + + b.ToTable("Tickets"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.TicketActivity", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AssigneeId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("Message") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TicketId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UserId") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.HasKey("Id"); + + b.HasIndex("AssigneeId"); + + b.HasIndex("TicketId"); + + b.HasIndex("UserId"); + + b.ToTable("TicketActivity"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.User", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ApprovedName") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("NameStatus") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Role") + .HasColumnType("int"); + + b.Property("Sponsor") + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Username") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Challenge", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("Challenges") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Gameboard.Api.Data.Player", "Player") + .WithMany("Challenges") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Game"); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeEvent", b => + { + b.HasOne("Gameboard.Api.Data.Challenge", "Challenge") + .WithMany("Events") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Challenge"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeGate", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("Prerequisites") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Game"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeSpec", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("Specs") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Game"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Feedback", b => + { + b.HasOne("Gameboard.Api.Data.Challenge", "Challenge") + .WithMany("Feedback") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Gameboard.Api.Data.ChallengeSpec", "ChallengeSpec") + .WithMany("Feedback") + .HasForeignKey("ChallengeSpecId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("Feedback") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Gameboard.Api.Data.Player", "Player") + .WithMany("Feedback") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Gameboard.Api.Data.User", "User") + .WithMany("Feedback") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Challenge"); + + b.Navigation("ChallengeSpec"); + + b.Navigation("Game"); + + b.Navigation("Player"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.HasOne("Gameboard.Api.Data.Game", "Game") + .WithMany("Players") + .HasForeignKey("GameId"); + + b.HasOne("Gameboard.Api.Data.User", "User") + .WithMany("Enrollments") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Game"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Ticket", b => + { + b.HasOne("Gameboard.Api.Data.User", "Assignee") + .WithMany() + .HasForeignKey("AssigneeId"); + + b.HasOne("Gameboard.Api.Data.Challenge", "Challenge") + .WithMany("Tickets") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Gameboard.Api.Data.User", "Creator") + .WithMany() + .HasForeignKey("CreatorId"); + + b.HasOne("Gameboard.Api.Data.Player", "Player") + .WithMany("Tickets") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Gameboard.Api.Data.User", "Requester") + .WithMany() + .HasForeignKey("RequesterId"); + + b.Navigation("Assignee"); + + b.Navigation("Challenge"); + + b.Navigation("Creator"); + + b.Navigation("Player"); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.TicketActivity", b => + { + b.HasOne("Gameboard.Api.Data.User", "Assignee") + .WithMany() + .HasForeignKey("AssigneeId"); + + b.HasOne("Gameboard.Api.Data.Ticket", "Ticket") + .WithMany("Activity") + .HasForeignKey("TicketId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Gameboard.Api.Data.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Assignee"); + + b.Navigation("Ticket"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Challenge", b => + { + b.Navigation("Events"); + + b.Navigation("Feedback"); + + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.ChallengeSpec", b => + { + b.Navigation("Feedback"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Game", b => + { + b.Navigation("Challenges"); + + b.Navigation("Feedback"); + + b.Navigation("Players"); + + b.Navigation("Prerequisites"); + + b.Navigation("Specs"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Player", b => + { + b.Navigation("Challenges"); + + b.Navigation("Feedback"); + + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.Ticket", b => + { + b.Navigation("Activity"); + }); + + modelBuilder.Entity("Gameboard.Api.Data.User", b => + { + b.Navigation("Enrollments"); + + b.Navigation("Feedback"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20221212220838_AddArchivedChallengeState.cs b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20221212220838_AddArchivedChallengeState.cs new file mode 100644 index 00000000..381449fd --- /dev/null +++ b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/20221212220838_AddArchivedChallengeState.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Gameboard.Api.Data.Migrations.SqlServer.GameboardDb +{ + public partial class AddArchivedChallengeState : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "State", + table: "ArchivedChallenges", + type: "nvarchar(max)", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "State", + table: "ArchivedChallenges"); + } + } +} diff --git a/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/GameboardDbContextSqlServerModelSnapshot.cs b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/GameboardDbContextSqlServerModelSnapshot.cs index 4022d854..d2ad1888 100644 --- a/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/GameboardDbContextSqlServerModelSnapshot.cs +++ b/src/Gameboard.Api/Data/Migrations/SqlServer/GameboardDb/GameboardDbContextSqlServerModelSnapshot.cs @@ -7,6 +7,8 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +#nullable disable + namespace Gameboard.Api.Data.Migrations.SqlServer.GameboardDb { [DbContext(typeof(GameboardDbContextSqlServer))] @@ -16,9 +18,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .UseIdentityColumns() - .HasAnnotation("Relational:MaxIdentifierLength", 128) - .HasAnnotation("ProductVersion", "5.0.0"); + .HasAnnotation("ProductVersion", "6.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder, 1L, 1); modelBuilder.Entity("Gameboard.Api.Data.ArchivedChallenge", b => { @@ -75,6 +78,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("StartTime") .HasColumnType("datetimeoffset"); + b.Property("State") + .HasColumnType("nvarchar(max)"); + b.Property("Submissions") .HasColumnType("nvarchar(max)"); From 788d17f4106beab494e1e1e7b34324e63e49fb26 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Thu, 15 Dec 2022 14:49:39 -0500 Subject: [PATCH 02/17] Corrected an issue that could cause incorrect loading of data from the ticket details endpoint. --- .../Features/Ticket/TicketController.cs | 42 +++++++++---------- .../Features/Ticket/TicketStore.cs | 14 +++---- 2 files changed, 27 insertions(+), 29 deletions(-) diff --git a/src/Gameboard.Api/Features/Ticket/TicketController.cs b/src/Gameboard.Api/Features/Ticket/TicketController.cs index d700f6d9..c6ed5875 100644 --- a/src/Gameboard.Api/Features/Ticket/TicketController.cs +++ b/src/Gameboard.Api/Features/Ticket/TicketController.cs @@ -1,22 +1,21 @@ // Copyright 2021 Carnegie Mellon University. All Rights Reserved. // Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; using System.Threading.Tasks; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; - +using AutoMapper; +using Gameboard.Api.Hubs; using Gameboard.Api.Services; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.AspNetCore.Authorization; using Gameboard.Api.Validators; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; -using System; -using System.IO; -using System.Linq; -using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; -using Gameboard.Api.Hubs; -using AutoMapper; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Logging; namespace Gameboard.Api.Controllers { @@ -36,7 +35,7 @@ public TicketController( TicketService ticketService, IHubContext hub, IMapper mapper - ): base(logger, cache, validator) + ) : base(logger, cache, validator) { TicketService = ticketService; Options = options; @@ -58,13 +57,11 @@ public async Task Retrieve([FromRoute] int id) () => TicketService.IsOwnerOrTeamMember(id, Actor.Id).Result ); - // await Validate(new Entity { Id = id }); - - // Once authenticated, authorized, and validated, cache a file permit for this user id & ticket id await Cache.SetStringAsync( $"{"file-permit:"}{Actor.Id}:{id}", "true", - new DistributedCacheEntryOptions { + new DistributedCacheEntryOptions + { AbsoluteExpirationRelativeToNow = new TimeSpan(0, 15, 0) } ); @@ -80,7 +77,7 @@ await Cache.SetStringAsync( /// [HttpPost("/api/ticket")] [Authorize] - public async Task Create([FromForm]NewTicket model) + public async Task Create([FromForm] NewTicket model) { await Validate(model); @@ -107,7 +104,7 @@ public async Task Create([FromForm]NewTicket model) /// [HttpPut("/api/ticket")] [Authorize] - public async Task Update([FromBody]ChangedTicket model) + public async Task Update([FromBody] ChangedTicket model) { AuthorizeAny( () => Actor.IsSupport, @@ -122,7 +119,8 @@ public async Task Update([FromBody]ChangedTicket model) // Ignore labels being different if (result.Label != prevTicket.Label) prevTicket.LastUpdated = result.LastUpdated; // If the ticket hasn't been meaningfully updated, don't send a notification - if (prevTicket.LastUpdated != result.LastUpdated) { + if (prevTicket.LastUpdated != result.LastUpdated) + { await Notify(Mapper.Map(result), EventAction.Updated); } @@ -148,7 +146,7 @@ public async Task List([FromQuery] TicketSearchFilter model) /// [HttpPost("/api/ticket/comment")] [Authorize] - public async Task AddComment([FromForm]NewTicketComment model) + public async Task AddComment([FromForm] NewTicketComment model) { AuthorizeAny( () => Actor.IsObserver, @@ -203,7 +201,7 @@ private List GetUploadFiles(List uploads) string extension = Path.GetExtension(upload.FileName); string filename = $"{nameOnly}_{fileNum}{extension}"; var sanitized = filename.SanitizeFilename().ToLower(); - result.Add(new UploadFile{ FileName = sanitized, File = upload}); + result.Add(new UploadFile { FileName = sanitized, File = upload }); fileNum += 1; } } @@ -236,7 +234,7 @@ private string BuildPath(params string[] segments) } private Task Notify(TicketNotification notification, EventAction action) - { + { var ev = new HubEvent(notification, action); var tasks = new List(); diff --git a/src/Gameboard.Api/Features/Ticket/TicketStore.cs b/src/Gameboard.Api/Features/Ticket/TicketStore.cs index 77ce8f5e..3c090090 100644 --- a/src/Gameboard.Api/Features/Ticket/TicketStore.cs +++ b/src/Gameboard.Api/Features/Ticket/TicketStore.cs @@ -3,18 +3,18 @@ using System.Linq; using System.Threading.Tasks; -using Microsoft.EntityFrameworkCore; using Gameboard.Api.Data.Abstractions; +using Microsoft.EntityFrameworkCore; namespace Gameboard.Api.Data { - public class TicketStore: Store, ITicketStore + public class TicketStore : Store, ITicketStore { public CoreOptions Options { get; } public TicketStore(GameboardDbContext dbContext, CoreOptions options) - :base(dbContext) + : base(dbContext) { Options = options; } @@ -29,7 +29,7 @@ public async Task Load(string id) public async Task Load(int id) { return await DbSet - .FirstOrDefaultAsync(c => c.Key== id) + .FirstOrDefaultAsync(c => c.Key == id) ; } @@ -54,7 +54,7 @@ public async Task LoadDetails(string id) .ThenInclude(a => a.Assignee) .Include(c => c.Challenge) .Include(c => c.Player) - .Include(c => c.Player.Game) + .ThenInclude(p => p.Game) .FirstOrDefaultAsync(c => c.Id == id) ; } @@ -71,7 +71,7 @@ public async Task LoadDetails(int id) .ThenInclude(a => a.Assignee) .Include(c => c.Challenge) .Include(c => c.Player) - .Include(c => c.Player.Game) + .ThenInclude(p => p.Game) .FirstOrDefaultAsync(c => c.Key == id) ; } @@ -96,7 +96,7 @@ public override IQueryable List(string term) t.Challenge.Tag.ToLower().Contains(term) ); } - + return q; } From 6f2e7156eef8530f78f58accc5b3bdd6e9447820 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Tue, 13 Dec 2022 14:56:15 -0500 Subject: [PATCH 03/17] Upgrade to net 7 - .NET Core to 7 (from 6) - Automapper to 12.0.0 (from 9.0) for compatibility --- Dockerfile | 4 ++-- src/Gameboard.Api/Gameboard.Api.csproj | 14 +++++--------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/Dockerfile b/Dockerfile index fc90edd8..5e0bd86e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # #multi-stage target: dev # -FROM mcr.microsoft.com/dotnet/sdk:6.0 AS dev +FROM mcr.microsoft.com/dotnet/sdk:7.0 AS dev ENV ASPNETCORE_URLS=http://*:5000 \ ASPNETCORE_ENVIRONMENT=DEVELOPMENT @@ -15,7 +15,7 @@ CMD ["dotnet", "run"] # #multi-stage target: prod # -FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS prod +FROM mcr.microsoft.com/dotnet/runtime:7.0.1 AS prod ARG commit ENV COMMIT=$commit COPY --from=dev /app/dist /app diff --git a/src/Gameboard.Api/Gameboard.Api.csproj b/src/Gameboard.Api/Gameboard.Api.csproj index c9590aaf..1699f2d5 100644 --- a/src/Gameboard.Api/Gameboard.Api.csproj +++ b/src/Gameboard.Api/Gameboard.Api.csproj @@ -1,9 +1,10 @@ - + + net7.0 + - + - @@ -19,15 +20,10 @@ + - - - net6.0 - - true $(NoWarn);1591 - From af59548c94a7b856240f93f36be77dac6405de38 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Mon, 19 Dec 2022 13:19:47 -0500 Subject: [PATCH 04/17] Rebase to .net 7 branch --- .github/workflows/main.yml | 17 ++++ Gameboard.sln | 15 ++++ .../Features/UnityGames/GamebrainService.cs | 79 ++++++++++++++++++ .../HttpContextAccessTokenProvider.cs | 20 +++++ .../UnityGames/IAccessTokenProvider.cs | 8 ++ .../Features/UnityGames/IGamebrainService.cs | 12 +++ .../Features/UnityGames/IUnityGameService.cs | 4 +- .../Features/UnityGames/IUnityStore.cs | 3 +- .../UnityGames/UnityGameController.cs | 80 ++++--------------- .../UnityGames/UnityGameExceptions.cs | 14 +++- .../Features/UnityGames/UnityGameService.cs | 50 ++++++------ .../Features/UnityGames/UnityStore.cs | 12 ++- src/Gameboard.Api/Gameboard.Api.csproj | 10 +++ src/Gameboard.Test/.gitignore | 3 + .../UnityGames/UnityGameServiceTests.cs | 50 ++++++++++++ src/Gameboard.Test/Gameboard.Test.csproj | 30 +++++++ src/Gameboard.Test/Stubbing/StubDefinition.cs | 0 src/Gameboard.Test/Stubbing/StubFactory.cs | 22 +++++ .../Stubbing/StubbingException.cs | 10 +++ src/Gameboard.Test/Usings.cs | 3 + src/Gameboard.Tests.Integration/.gitignore | 3 + src/Gameboard.Tests.Unit/.gitignore | 3 + 22 files changed, 353 insertions(+), 95 deletions(-) create mode 100644 src/Gameboard.Api/Features/UnityGames/GamebrainService.cs create mode 100644 src/Gameboard.Api/Features/UnityGames/HttpContextAccessTokenProvider.cs create mode 100644 src/Gameboard.Api/Features/UnityGames/IAccessTokenProvider.cs create mode 100644 src/Gameboard.Api/Features/UnityGames/IGamebrainService.cs create mode 100644 src/Gameboard.Test/.gitignore create mode 100644 src/Gameboard.Test/Features/UnityGames/UnityGameServiceTests.cs create mode 100644 src/Gameboard.Test/Gameboard.Test.csproj create mode 100644 src/Gameboard.Test/Stubbing/StubDefinition.cs create mode 100644 src/Gameboard.Test/Stubbing/StubFactory.cs create mode 100644 src/Gameboard.Test/Stubbing/StubbingException.cs create mode 100644 src/Gameboard.Test/Usings.cs create mode 100644 src/Gameboard.Tests.Integration/.gitignore create mode 100644 src/Gameboard.Tests.Unit/.gitignore diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c54dfb11..18f39d58 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -10,8 +10,25 @@ on: - test jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Use .NET Core SDK + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 7.0 + - name: Install dependencies + run: dotnet restore + - name: Build + run: dotnet build -c Release --no-restore + - name: Run tests + run: dotnet test --no-restore + build: runs-on: ubuntu-latest + needs: test steps: - name: Checkout uses: actions/checkout@v2 diff --git a/Gameboard.sln b/Gameboard.sln index 75d200cd..9b84439d 100644 --- a/Gameboard.sln +++ b/Gameboard.sln @@ -7,6 +7,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{C148F187-E60 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gameboard", "src\Gameboard.Api\Gameboard.Api.csproj", "{A2715FCD-D078-4E96-81C3-B833640921C5}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gameboard.test", "src\Gameboard.Test\Gameboard.Test.csproj", "{BAEF960F-857E-4CDA-9D9D-D2F6F1C40E0C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -32,8 +34,21 @@ Global {A2715FCD-D078-4E96-81C3-B833640921C5}.Release|x64.Build.0 = Release|Any CPU {A2715FCD-D078-4E96-81C3-B833640921C5}.Release|x86.ActiveCfg = Release|Any CPU {A2715FCD-D078-4E96-81C3-B833640921C5}.Release|x86.Build.0 = Release|Any CPU + {BAEF960F-857E-4CDA-9D9D-D2F6F1C40E0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BAEF960F-857E-4CDA-9D9D-D2F6F1C40E0C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BAEF960F-857E-4CDA-9D9D-D2F6F1C40E0C}.Debug|x64.ActiveCfg = Debug|Any CPU + {BAEF960F-857E-4CDA-9D9D-D2F6F1C40E0C}.Debug|x64.Build.0 = Debug|Any CPU + {BAEF960F-857E-4CDA-9D9D-D2F6F1C40E0C}.Debug|x86.ActiveCfg = Debug|Any CPU + {BAEF960F-857E-4CDA-9D9D-D2F6F1C40E0C}.Debug|x86.Build.0 = Debug|Any CPU + {BAEF960F-857E-4CDA-9D9D-D2F6F1C40E0C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BAEF960F-857E-4CDA-9D9D-D2F6F1C40E0C}.Release|Any CPU.Build.0 = Release|Any CPU + {BAEF960F-857E-4CDA-9D9D-D2F6F1C40E0C}.Release|x64.ActiveCfg = Release|Any CPU + {BAEF960F-857E-4CDA-9D9D-D2F6F1C40E0C}.Release|x64.Build.0 = Release|Any CPU + {BAEF960F-857E-4CDA-9D9D-D2F6F1C40E0C}.Release|x86.ActiveCfg = Release|Any CPU + {BAEF960F-857E-4CDA-9D9D-D2F6F1C40E0C}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {A2715FCD-D078-4E96-81C3-B833640921C5} = {C148F187-E600-4044-8944-2E9E52575B07} + {BAEF960F-857E-4CDA-9D9D-D2F6F1C40E0C} = {C148F187-E600-4044-8944-2E9E52575B07} EndGlobalSection EndGlobal diff --git a/src/Gameboard.Api/Features/UnityGames/GamebrainService.cs b/src/Gameboard.Api/Features/UnityGames/GamebrainService.cs new file mode 100644 index 00000000..431f4dd7 --- /dev/null +++ b/src/Gameboard.Api/Features/UnityGames/GamebrainService.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; + +namespace Gameboard.Api.Features.UnityGames; + +internal class GamebrainService : IGamebrainService +{ + private readonly IAccessTokenProvider _accessTokenProvider; + private readonly IHttpClientFactory _httpClientFactory; + + public GamebrainService( + IAccessTokenProvider accessTokenProvider, + IHttpClientFactory httpClientFactory) + { + _accessTokenProvider = accessTokenProvider; + _httpClientFactory = httpClientFactory; + } + + public async Task DeployUnitySpace(string gameId, string teamId) + { + var client = await CreateGamebrain(); + var m = await client.PostAsync($"admin/deploy/{gameId}/{teamId}", null); + return await m.Content.ReadAsStringAsync(); + } + + public async Task GetGameState(string gameId, string teamId) + { + var client = await CreateGamebrain(); + var gamebrainEndpoint = $"admin/deploy/{gameId}/{teamId}"; + var m = await client.GetAsync(gamebrainEndpoint); + + if (m.IsSuccessStatusCode) + { + var stringContent = await m.Content.ReadAsStringAsync(); + + if (!stringContent.IsEmpty()) + { + return stringContent; + } + + throw new GamebrainEmptyResponseException(HttpMethod.Get, gamebrainEndpoint); + } + + throw new GamebrainException(HttpMethod.Get, gamebrainEndpoint, m.StatusCode, await m.Content.ReadAsStringAsync()); + } + + public async Task UpdateConsoleUrls(string gameId, string teamId, IEnumerable vms) + { + var client = await CreateGamebrain(); + await client.PostAsync($"admin/update_console_urls/{teamId}", JsonContent.Create(vms.ToArray(), mediaType: MediaTypeHeaderValue.Parse("application/json"))); + } + + public async Task UndeployUnitySpace(string gameId, string teamId) + { + var client = await CreateGamebrain(); + + var m = await client.GetAsync($"admin/undeploy/{teamId}"); + return await m.Content.ReadAsStringAsync(); + } + + private ActionResult BuildError(HttpResponseMessage response, string message) + { + var result = new ObjectResult(message); + result.StatusCode = (int)response.StatusCode; + return result; + } + + private async Task CreateGamebrain() + { + var gb = _httpClientFactory.CreateClient("Gamebrain"); + gb.DefaultRequestHeaders.Add("Authorization", $"Bearer {await _accessTokenProvider.GetToken()}"); + return gb; + } +} diff --git a/src/Gameboard.Api/Features/UnityGames/HttpContextAccessTokenProvider.cs b/src/Gameboard.Api/Features/UnityGames/HttpContextAccessTokenProvider.cs new file mode 100644 index 00000000..eebe7e4c --- /dev/null +++ b/src/Gameboard.Api/Features/UnityGames/HttpContextAccessTokenProvider.cs @@ -0,0 +1,20 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; + +namespace Gameboard.Api.Features.UnityGames; + +internal class HttpContextAccessTokenProvider : IAccessTokenProvider +{ + private readonly HttpContext _httpContext; + + public HttpContextAccessTokenProvider(HttpContext httpContext) + { + _httpContext = httpContext; + } + + public Task GetToken() + { + return _httpContext.GetTokenAsync("access_token"); + } +} diff --git a/src/Gameboard.Api/Features/UnityGames/IAccessTokenProvider.cs b/src/Gameboard.Api/Features/UnityGames/IAccessTokenProvider.cs new file mode 100644 index 00000000..5a34d426 --- /dev/null +++ b/src/Gameboard.Api/Features/UnityGames/IAccessTokenProvider.cs @@ -0,0 +1,8 @@ +using System.Threading.Tasks; + +namespace Gameboard.Api.Features.UnityGames; + +internal interface IAccessTokenProvider +{ + Task GetToken(); +} diff --git a/src/Gameboard.Api/Features/UnityGames/IGamebrainService.cs b/src/Gameboard.Api/Features/UnityGames/IGamebrainService.cs new file mode 100644 index 00000000..39a5b4d2 --- /dev/null +++ b/src/Gameboard.Api/Features/UnityGames/IGamebrainService.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Gameboard.Api.Features.UnityGames; + +public interface IGamebrainService +{ + Task DeployUnitySpace(string gameId, string teamId); + Task GetGameState(string gameId, string teamId); + Task UndeployUnitySpace(string gameId, string teamId); + Task UpdateConsoleUrls(string gameId, string teamId, IEnumerable vms); +} diff --git a/src/Gameboard.Api/Features/UnityGames/IUnityGameService.cs b/src/Gameboard.Api/Features/UnityGames/IUnityGameService.cs index 35b8d872..d46eed89 100644 --- a/src/Gameboard.Api/Features/UnityGames/IUnityGameService.cs +++ b/src/Gameboard.Api/Features/UnityGames/IUnityGameService.cs @@ -7,10 +7,10 @@ public interface IUnityGameService { Task AddChallenge(NewUnityChallenge newChallenge, User actor); Task CreateMissionEvent(UnityMissionUpdate model, Api.User actor); - Task HasChallengeData(NewUnityChallenge newUnityChallenge); + Task HasChallengeData(string gamespaceId); Task DeleteChallengeData(string gameId); bool IsUnityGame(Game game); bool IsUnityGame(Data.Game game); Regex GetMissionCompleteEventRegex(); string GetUnityModeString(); -} \ No newline at end of file +} diff --git a/src/Gameboard.Api/Features/UnityGames/IUnityStore.cs b/src/Gameboard.Api/Features/UnityGames/IUnityStore.cs index 825e1a1f..a988f140 100644 --- a/src/Gameboard.Api/Features/UnityGames/IUnityStore.cs +++ b/src/Gameboard.Api/Features/UnityGames/IUnityStore.cs @@ -6,4 +6,5 @@ namespace Gameboard.Api.Features.UnityGames; public interface IUnityStore : IStore { Task AddUnityChallengeEvent(Data.ChallengeEvent challengeEvent); -} \ No newline at end of file + Task HasChallengeData(string gamespaceId); +} diff --git a/src/Gameboard.Api/Features/UnityGames/UnityGameController.cs b/src/Gameboard.Api/Features/UnityGames/UnityGameController.cs index 88eed4e0..d7f49b46 100644 --- a/src/Gameboard.Api/Features/UnityGames/UnityGameController.cs +++ b/src/Gameboard.Api/Features/UnityGames/UnityGameController.cs @@ -29,6 +29,7 @@ public class UnityGameController : _Controller private static SemaphoreSlim SP_CHALLENGE_DATA = new SemaphoreSlim(1, 1); private readonly IChallengeStore _challengeStore; private readonly ConsoleActorMap _actorMap; + private readonly IGamebrainService _gamebrainService; private readonly GameService _gameService; private readonly IHttpClientFactory _httpClientFactory; private readonly IHubContext _hub; @@ -45,6 +46,7 @@ public UnityGameController( GameService gameService, PlayerService playerService, IChallengeStore challengeStore, + IGamebrainService gamebrainService, IHttpClientFactory httpClientFactory, IUnityGameService unityGameService, IHubContext hub, @@ -53,6 +55,7 @@ IMapper mapper { _actorMap = actorMap; _challengeStore = challengeStore; + _gamebrainService = gamebrainService; _gameService = gameService; _httpClientFactory = httpClientFactory; _hub = hub; @@ -68,22 +71,8 @@ public async Task GetGamespace([FromRoute] string gid, [FromRoute () => _gameService.UserIsTeamPlayer(Actor.Id, gid, tid).Result ); - var gb = await CreateGamebrain(); - var m = await gb.GetAsync($"admin/deploy/{gid}/{tid}"); - - if (m.IsSuccessStatusCode) - { - var stringContent = await m.Content.ReadAsStringAsync(); - - if (!stringContent.IsEmpty()) - { - return new JsonResult(stringContent); - } - - return Ok(); - } - - return BuildError(m, $"Bad response from Gamebrain: {m.Content} : {m.ReasonPhrase}"); + var content = await _gamebrainService.GetGameState(gid, tid); + return new JsonResult(content); } [HttpPost("/api/unity/deploy/{gid}/{tid}")] @@ -94,9 +83,7 @@ public async Task DeployUnitySpace([FromRoute] string gid, [FromRoute] s () => _gameService.UserIsTeamPlayer(Actor.Id, gid, tid).Result ); - var gb = await CreateGamebrain(); - var m = await gb.PostAsync($"admin/deploy/{gid}/{tid}", null); - return await m.Content.ReadAsStringAsync(); + return await _gamebrainService.DeployUnitySpace(gid, tid); } [HttpPost("/api/unity/undeploy/{gid}/{tid}")] @@ -109,11 +96,7 @@ public async Task UndeployUnitySpace([FromQuery] string gid, [FromRoute] () => _gameService.UserIsTeamPlayer(Actor.Id, gid, tid).Result ); - var accessToken = await HttpContext.GetTokenAsync("access_token"); - var gb = await CreateGamebrain(); - - var m = await gb.GetAsync($"admin/undeploy/{tid}"); - return await m.Content.ReadAsStringAsync(); + return await _gamebrainService.UndeployUnitySpace(gid, tid); } /// @@ -123,7 +106,7 @@ public async Task UndeployUnitySpace([FromQuery] string gid, [FromRoute] /// ChallengeEvent [Authorize] [HttpPost("api/unity/challenge")] - public async Task CreateChallenge([FromBody] NewUnityChallenge model) + public async Task CreateChallenge([FromBody] NewUnityChallenge model) { AuthorizeAny( () => _gameService.UserIsTeamPlayer(Actor.Id, model.GameId, model.TeamId).Result @@ -133,7 +116,7 @@ public async Task CreateChallenge([FromBody] NewUnityChallenge mo // each _team_ will only get one copy of the challenge, and by rule, that challenge must have the id // of the topo gamespace ID. If it's already in the DB, send them on their way with the challenge we've already got - // + // // semaphore locking because, if i don't, may not sleep during the competition Data.Challenge challengeData = null; try @@ -141,10 +124,11 @@ public async Task CreateChallenge([FromBody] NewUnityChallenge mo Console.Write("Entering the Unity challenge data semaphore"); await SP_CHALLENGE_DATA.WaitAsync(); - challengeData = await _unityGameService.HasChallengeData(model); + challengeData = await _unityGameService.HasChallengeData(model.GamespaceId); if (challengeData != null) { - return Accepted(); + return challengeData; + // return Accepted(); } // otherwise, add new challenge data and send gamebrain the ids of the consoles (which are based on the challenge id) @@ -161,8 +145,6 @@ public async Task CreateChallenge([FromBody] NewUnityChallenge mo } // now that we have challenge IDs, we can update gamebrain's console urls - var gamebrainClient = await CreateGamebrain(); - var vmData = model.Vms.Select(vm => { var consoleHost = new UriBuilder(Request.Scheme, Request.Host.Host, Request.Host.Port ?? -1, $"{Request.PathBase}/mks"); @@ -174,24 +156,17 @@ public async Task CreateChallenge([FromBody] NewUnityChallenge mo Url = consoleHost.Uri.ToString(), Name = vm.Name, }; - }).ToArray(); + }); - try - { - await gamebrainClient.PostAsync($"admin/update_console_urls/{model.TeamId}", JsonContent.Create(vmData, mediaType: MediaTypeHeaderValue.Parse("application/json"))); - } - catch (Exception ex) - { - Console.Write("Calling gamebrain failed with", ex); - throw; - } + await _gamebrainService.UpdateConsoleUrls(model.GameId, model.TeamId, vmData); // notify the hub (if there is one) await _hub.Clients .Group(model.TeamId) .ChallengeEvent(new HubEvent(_mapper.Map(challengeData), EventAction.Updated)); - - return Ok(); + + return challengeData; + // return Ok(); } [HttpPost("api/unity/mission-update")] @@ -231,25 +206,4 @@ public async Task DeleteChallengeData(string gameId) await _unityGameService.DeleteChallengeData(gameId); return Ok(); } - - private ActionResult BuildError(HttpResponse response, string message = null) - { - var result = new ObjectResult(message); - result.StatusCode = response.StatusCode; - return result; - } - - private ActionResult BuildError(HttpResponseMessage response, string message) - { - var result = new ObjectResult(message); - result.StatusCode = (int)response.StatusCode; - return result; - } - - private async Task CreateGamebrain() - { - var gb = _httpClientFactory.CreateClient("Gamebrain"); - gb.DefaultRequestHeaders.Add("Authorization", $"Bearer {await HttpContext.GetTokenAsync("access_token")}"); - return gb; - } } diff --git a/src/Gameboard.Api/Features/UnityGames/UnityGameExceptions.cs b/src/Gameboard.Api/Features/UnityGames/UnityGameExceptions.cs index 17d6cf99..234be0cd 100644 --- a/src/Gameboard.Api/Features/UnityGames/UnityGameExceptions.cs +++ b/src/Gameboard.Api/Features/UnityGames/UnityGameExceptions.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net; +using System.Net.Http; namespace Gameboard.Api.Features.UnityGames; @@ -12,6 +14,16 @@ internal class ChallengeResolutionFailure : GameboardException public ChallengeResolutionFailure(string teamId, IEnumerable challengeIds) : base($"Couldn't resolve a Unity challenge for team {teamId}. They have {challengeIds.Count()} challenges ({String.Join(" | ", challengeIds)})") { } } +internal class GamebrainException : GameboardException +{ + public GamebrainException(HttpMethod method, string endpoint, HttpStatusCode statusCode, string error) : base($"Gamebrain threw a {statusCode} in response to a {method} request to {endpoint}. Error detail: '{error}'") { } +} + +internal class GamebrainEmptyResponseException : GameboardException +{ + public GamebrainEmptyResponseException(HttpMethod method, string url) : base($"Gamebrain didn't respond to a {method} request to {url}.") { } +} + internal class SemaphoreLockFailure : GameboardException { public SemaphoreLockFailure(Exception ex) : base($"An operation inside a semaphore lock failed.", ex) { } @@ -20,4 +32,4 @@ internal class SemaphoreLockFailure : GameboardException internal class SpecNotFound : GameboardException { public SpecNotFound(string gameId) : base($"Couldn't resolve a challenge spec for gameId {gameId}.") { } -} \ No newline at end of file +} diff --git a/src/Gameboard.Api/Features/UnityGames/UnityGameService.cs b/src/Gameboard.Api/Features/UnityGames/UnityGameService.cs index 214a33ca..f9050b13 100644 --- a/src/Gameboard.Api/Features/UnityGames/UnityGameService.cs +++ b/src/Gameboard.Api/Features/UnityGames/UnityGameService.cs @@ -17,7 +17,7 @@ namespace Gameboard.Api.Features.UnityGames; internal class UnityGameService : _Service, IUnityGameService { private readonly IChallengeStore _challengeStore; - IUnityStore Store { get; } + private readonly IUnityStore _store; ITopoMojoApiClient Mojo { get; } private readonly ConsoleActorMap _actorMap; @@ -32,17 +32,17 @@ public UnityGameService( ConsoleActorMap actorMap ) : base(logger, mapper, options) { - Store = store; Mojo = mojo; _actorMap = actorMap; _challengeStore = challengeStore; + _store = store; } public async Task AddChallenge(NewUnityChallenge newChallenge, User actor) { // each _team_ should only get one copy of the challenge, and by rule, that challenge must have the id // of the topo gamespace ID. If it's already in the DB, send them on their way with the challenge we've already got - var existingChallenge = await Store.DbContext + var existingChallenge = await _store.DbContext .Challenges .AsNoTracking() .Include(c => c.Events) @@ -55,7 +55,7 @@ ConsoleActorMap actorMap // otherwise, let's make some challenges // find the team's players - var teamPlayers = await Store.DbContext + var teamPlayers = await _store.DbContext .Players .Where(p => p.TeamId == newChallenge.TeamId) .ToListAsync(); @@ -64,14 +64,14 @@ ConsoleActorMap actorMap var challengeName = $"{teamCaptain.ApprovedName} vs. Cubespace"; // load the spec associated with the game - var challengeSpec = await Store.DbContext.ChallengeSpecs.FirstOrDefaultAsync(c => c.GameId == newChallenge.GameId); + var challengeSpec = await _store.DbContext.ChallengeSpecs.FirstOrDefaultAsync(c => c.GameId == newChallenge.GameId); if (challengeSpec == null) { throw new SpecNotFound(newChallenge.GameId); } // we have to spoof topomojo data here. load the game and related data. - var game = await this.Store + var game = await this._store .DbContext .Games .FirstOrDefaultAsync(g => g.Id == newChallenge.GameId); @@ -133,7 +133,7 @@ ConsoleActorMap actorMap Points = newChallenge.MaxPoints, Score = 0, Events = new List - { + { // an initial event to start the party new ChallengeEvent { @@ -148,24 +148,15 @@ ConsoleActorMap actorMap WhenCreated = DateTimeOffset.UtcNow, }; - await Store.DbContext.Challenges.AddAsync(newChallengeEntity); - await Store.DbContext.SaveChangesAsync(); + await _store.DbContext.Challenges.AddAsync(newChallengeEntity); + await _store.DbContext.SaveChangesAsync(); return newChallengeEntity; } - public async Task HasChallengeData(NewUnityChallenge model) - { - return await Store.DbContext - .Challenges - .AsNoTracking() - .Include(c => c.Events) - .FirstOrDefaultAsync(c => c.Id == model.GamespaceId); - } - public async Task DeleteChallengeData(string gameId) { - var challenges = await Store + var challenges = await _store .DbContext .Challenges .Include(c => c.Events) @@ -177,16 +168,21 @@ public async Task DeleteChallengeData(string gameId) return; } - Store.DbContext.Challenges.RemoveRange(challenges); - Store.DbContext.ChallengeEvents.RemoveRange(challenges.SelectMany(c => c.Events)); + _store.DbContext.Challenges.RemoveRange(challenges); + _store.DbContext.ChallengeEvents.RemoveRange(challenges.SelectMany(c => c.Events)); - await Store.DbContext.SaveChangesAsync(); + await _store.DbContext.SaveChangesAsync(); + } + + public async Task HasChallengeData(string gamespaceId) + { + return await _store.HasChallengeData(gamespaceId); } public async Task CreateMissionEvent(UnityMissionUpdate model, Api.User actor) { var unityMode = GetUnityModeString(); - var challengeCandidates = await Store.DbContext + var challengeCandidates = await _store.DbContext .Challenges .Include(c => c.Game) .Include(c => c.Events) @@ -225,7 +221,7 @@ public async Task DeleteChallengeData(string gameId) challenge.Score += model.PointsScored; // save it up - await Store.DbContext.SaveChangesAsync(); + await _store.DbContext.SaveChangesAsync(); // return (used to determine HTTP status code in an above controller) return challengeEvent; @@ -241,10 +237,10 @@ public Regex GetMissionCompleteEventRegex() } // if you change this, change `GetMissionCompleteEventRegex` above - private string GetMissionCompleteDefinitionString(string missionId) + internal string GetMissionCompleteDefinitionString(string missionId) => $"[complete:{missionId}]"; - private bool IsMissionComplete(IEnumerable events, string missionId) + internal bool IsMissionComplete(IEnumerable events, string missionId) => events.Any(e => e.Text.Contains(GetMissionCompleteDefinitionString(missionId))); private Data.Player ResolveTeamCaptain(IEnumerable players, NewUnityChallenge newChallenge) @@ -272,4 +268,4 @@ private Data.Player ResolveTeamCaptain(IEnumerable players, NewUnit return actingPlayer; } -} \ No newline at end of file +} diff --git a/src/Gameboard.Api/Features/UnityGames/UnityStore.cs b/src/Gameboard.Api/Features/UnityGames/UnityStore.cs index d9adb56a..7c40c10e 100644 --- a/src/Gameboard.Api/Features/UnityGames/UnityStore.cs +++ b/src/Gameboard.Api/Features/UnityGames/UnityStore.cs @@ -1,5 +1,6 @@ using System.Threading.Tasks; using Gameboard.Api.Data; +using Microsoft.EntityFrameworkCore; namespace Gameboard.Api.Features.UnityGames; @@ -17,6 +18,15 @@ public UnityStore(GameboardDbContext dbContext) return challengeEvent; } + public async Task HasChallengeData(string gamespaceId) + { + return await DbContext + .Challenges + .AsNoTracking() + .Include(c => c.Events) + .FirstOrDefaultAsync(c => c.Id == gamespaceId); + } + // public async Task UpdateAvgDeployTime(string gameId) // { // var stats = await DbContext.Challenges @@ -38,4 +48,4 @@ public UnityStore(GameboardDbContext dbContext) // await DbContext.SaveChangesAsync(); // } -} \ No newline at end of file +} diff --git a/src/Gameboard.Api/Gameboard.Api.csproj b/src/Gameboard.Api/Gameboard.Api.csproj index 1699f2d5..14619d98 100644 --- a/src/Gameboard.Api/Gameboard.Api.csproj +++ b/src/Gameboard.Api/Gameboard.Api.csproj @@ -22,8 +22,18 @@ + + + + + + + net6.0 + + true $(NoWarn);1591 + Gameboard API diff --git a/src/Gameboard.Test/.gitignore b/src/Gameboard.Test/.gitignore new file mode 100644 index 00000000..c525c856 --- /dev/null +++ b/src/Gameboard.Test/.gitignore @@ -0,0 +1,3 @@ +# compiled output +bin +obj diff --git a/src/Gameboard.Test/Features/UnityGames/UnityGameServiceTests.cs b/src/Gameboard.Test/Features/UnityGames/UnityGameServiceTests.cs new file mode 100644 index 00000000..e6ee57d7 --- /dev/null +++ b/src/Gameboard.Test/Features/UnityGames/UnityGameServiceTests.cs @@ -0,0 +1,50 @@ +using AutoMapper; +using Gameboard.Api; +using Gameboard.Api.Data.Abstractions; +using Gameboard.Api.Features.UnityGames; +using Gameboard.Api.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using TopoMojo.Api.Client; + +namespace Gameboard.Test; + +public class UnityGameServiceTests +{ + [Fact] + public void ThisTestShouldFailAndStopCI() + { + Assert.True(false); + } + + [Fact] + public void GetMissionCompleteDefinitionString_Matches_IsMissionComplete() + { + // arrange + var serviceProvider = new ServiceCollection() + .AddLogging() + .BuildServiceProvider(); + + var factory = serviceProvider.GetService(); + var logger = factory?.CreateLogger(); + + var service = new UnityGameService( + logger, + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For() + ); + var regex = service.GetMissionCompleteEventRegex(); + + + // act + var missionCompleteString = service.GetMissionCompleteDefinitionString("secret-mission"); + var match = regex.IsMatch(missionCompleteString); + + // assert + Assert.True(match); + } +} diff --git a/src/Gameboard.Test/Gameboard.Test.csproj b/src/Gameboard.Test/Gameboard.Test.csproj new file mode 100644 index 00000000..f91746c6 --- /dev/null +++ b/src/Gameboard.Test/Gameboard.Test.csproj @@ -0,0 +1,30 @@ + + + + net7.0 + enable + enable + Gameboard.Test + false + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/src/Gameboard.Test/Stubbing/StubDefinition.cs b/src/Gameboard.Test/Stubbing/StubDefinition.cs new file mode 100644 index 00000000..e69de29b diff --git a/src/Gameboard.Test/Stubbing/StubFactory.cs b/src/Gameboard.Test/Stubbing/StubFactory.cs new file mode 100644 index 00000000..e43e6302 --- /dev/null +++ b/src/Gameboard.Test/Stubbing/StubFactory.cs @@ -0,0 +1,22 @@ +using System.Reflection; +using Gameboard.Api.Controllers; + +namespace Gameboard.Test.Stubbing; + +internal static class StubFactory +{ + // public static T? Stub() where T : class + // { + // var constructors = typeof(T).GetConstructors(BindingFlags.CreateInstance | BindingFlags.NonPublic | BindingFlags.Public); + + // if (constructors.Count() == 0) + // throw new ConstructorMatchException(); + + // return default(T); + // } + + // public static void Thing() + // { + // typeof(UnityGameController).COnstructo + // } +} diff --git a/src/Gameboard.Test/Stubbing/StubbingException.cs b/src/Gameboard.Test/Stubbing/StubbingException.cs new file mode 100644 index 00000000..fbc99369 --- /dev/null +++ b/src/Gameboard.Test/Stubbing/StubbingException.cs @@ -0,0 +1,10 @@ +using System.Reflection; + +namespace Gameboard.Test.Stubbing; + +// internal class FailedConstructorResolution : Exception where T : class +// { +// public FailedConstructorResolution(IEnumerable candidateConstructors) { } +// } + +// internal class AmbiguousConstructorResolution diff --git a/src/Gameboard.Test/Usings.cs b/src/Gameboard.Test/Usings.cs new file mode 100644 index 00000000..1946184b --- /dev/null +++ b/src/Gameboard.Test/Usings.cs @@ -0,0 +1,3 @@ +global using NSubstitute; +global using NSubstitute.Extensions; +global using Xunit; diff --git a/src/Gameboard.Tests.Integration/.gitignore b/src/Gameboard.Tests.Integration/.gitignore new file mode 100644 index 00000000..bdb1e07e --- /dev/null +++ b/src/Gameboard.Tests.Integration/.gitignore @@ -0,0 +1,3 @@ +# compiled artifacts +bin/ +obj/ diff --git a/src/Gameboard.Tests.Unit/.gitignore b/src/Gameboard.Tests.Unit/.gitignore new file mode 100644 index 00000000..bdb1e07e --- /dev/null +++ b/src/Gameboard.Tests.Unit/.gitignore @@ -0,0 +1,3 @@ +# compiled artifacts +bin/ +obj/ From 788f3bb0bc0f6c60b14f16f52802c815ad16ae2e Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Wed, 14 Dec 2022 09:59:46 -0500 Subject: [PATCH 05/17] CI finalize Remove intentionally failing test for CI, add service bindings. --- src/Gameboard.Api/Features/FeatureStartupExtensions.cs | 3 +++ .../Features/UnityGames/HttpContextAccessTokenProvider.cs | 8 ++++---- .../Features/UnityGames/UnityGameServiceTests.cs | 6 ------ 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/Gameboard.Api/Features/FeatureStartupExtensions.cs b/src/Gameboard.Api/Features/FeatureStartupExtensions.cs index 1b0f752c..f67ecb0e 100644 --- a/src/Gameboard.Api/Features/FeatureStartupExtensions.cs +++ b/src/Gameboard.Api/Features/FeatureStartupExtensions.cs @@ -35,9 +35,12 @@ public static IServiceCollection AddGameboardServices(this IServiceCollection se } // TODO: Ben -> fix this + services.AddHttpContextAccessor(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); foreach (var t in Assembly .GetExecutingAssembly() diff --git a/src/Gameboard.Api/Features/UnityGames/HttpContextAccessTokenProvider.cs b/src/Gameboard.Api/Features/UnityGames/HttpContextAccessTokenProvider.cs index eebe7e4c..82ff0d9d 100644 --- a/src/Gameboard.Api/Features/UnityGames/HttpContextAccessTokenProvider.cs +++ b/src/Gameboard.Api/Features/UnityGames/HttpContextAccessTokenProvider.cs @@ -6,15 +6,15 @@ namespace Gameboard.Api.Features.UnityGames; internal class HttpContextAccessTokenProvider : IAccessTokenProvider { - private readonly HttpContext _httpContext; + private readonly IHttpContextAccessor _httpContextAccessor; - public HttpContextAccessTokenProvider(HttpContext httpContext) + public HttpContextAccessTokenProvider(IHttpContextAccessor httpContextAccessor) { - _httpContext = httpContext; + _httpContextAccessor = httpContextAccessor; } public Task GetToken() { - return _httpContext.GetTokenAsync("access_token"); + return _httpContextAccessor.HttpContext.GetTokenAsync("access_token"); } } diff --git a/src/Gameboard.Test/Features/UnityGames/UnityGameServiceTests.cs b/src/Gameboard.Test/Features/UnityGames/UnityGameServiceTests.cs index e6ee57d7..c703b0f7 100644 --- a/src/Gameboard.Test/Features/UnityGames/UnityGameServiceTests.cs +++ b/src/Gameboard.Test/Features/UnityGames/UnityGameServiceTests.cs @@ -11,12 +11,6 @@ namespace Gameboard.Test; public class UnityGameServiceTests { - [Fact] - public void ThisTestShouldFailAndStopCI() - { - Assert.True(false); - } - [Fact] public void GetMissionCompleteDefinitionString_Matches_IsMissionComplete() { From 45f51fc288ae65a6458dd2f49d74d5c087528e19 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Wed, 14 Dec 2022 13:50:01 -0500 Subject: [PATCH 06/17] Rename test project - we'll like want separate functional (API level) tests. --- Gameboard.sln | 2 +- src/Gameboard.Api/Gameboard.Api.csproj | 2 +- src/Gameboard.Test/.gitignore | 3 --- .../Features/UnityGames/UnityGameServiceTests.cs | 0 .../Gameboard.Tests.Unit.csproj} | 2 +- .../Stubbing/StubDefinition.cs | 0 .../Stubbing/StubFactory.cs | 0 .../Stubbing/StubbingException.cs | 0 src/{Gameboard.Test => Gameboard.Tests.Unit}/Usings.cs | 0 9 files changed, 3 insertions(+), 6 deletions(-) delete mode 100644 src/Gameboard.Test/.gitignore rename src/{Gameboard.Test => Gameboard.Tests.Unit}/Features/UnityGames/UnityGameServiceTests.cs (100%) rename src/{Gameboard.Test/Gameboard.Test.csproj => Gameboard.Tests.Unit/Gameboard.Tests.Unit.csproj} (95%) rename src/{Gameboard.Test => Gameboard.Tests.Unit}/Stubbing/StubDefinition.cs (100%) rename src/{Gameboard.Test => Gameboard.Tests.Unit}/Stubbing/StubFactory.cs (100%) rename src/{Gameboard.Test => Gameboard.Tests.Unit}/Stubbing/StubbingException.cs (100%) rename src/{Gameboard.Test => Gameboard.Tests.Unit}/Usings.cs (100%) diff --git a/Gameboard.sln b/Gameboard.sln index 9b84439d..c8dd9395 100644 --- a/Gameboard.sln +++ b/Gameboard.sln @@ -7,7 +7,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{C148F187-E60 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gameboard", "src\Gameboard.Api\Gameboard.Api.csproj", "{A2715FCD-D078-4E96-81C3-B833640921C5}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gameboard.test", "src\Gameboard.Test\Gameboard.Test.csproj", "{BAEF960F-857E-4CDA-9D9D-D2F6F1C40E0C}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gameboard.Tests.Unit", "src\Gameboard.Tests.Unit\Gameboard.Tests.Unit.csproj", "{BAEF960F-857E-4CDA-9D9D-D2F6F1C40E0C}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/src/Gameboard.Api/Gameboard.Api.csproj b/src/Gameboard.Api/Gameboard.Api.csproj index 14619d98..e8f63a19 100644 --- a/src/Gameboard.Api/Gameboard.Api.csproj +++ b/src/Gameboard.Api/Gameboard.Api.csproj @@ -24,7 +24,7 @@ - + diff --git a/src/Gameboard.Test/.gitignore b/src/Gameboard.Test/.gitignore deleted file mode 100644 index c525c856..00000000 --- a/src/Gameboard.Test/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# compiled output -bin -obj diff --git a/src/Gameboard.Test/Features/UnityGames/UnityGameServiceTests.cs b/src/Gameboard.Tests.Unit/Features/UnityGames/UnityGameServiceTests.cs similarity index 100% rename from src/Gameboard.Test/Features/UnityGames/UnityGameServiceTests.cs rename to src/Gameboard.Tests.Unit/Features/UnityGames/UnityGameServiceTests.cs diff --git a/src/Gameboard.Test/Gameboard.Test.csproj b/src/Gameboard.Tests.Unit/Gameboard.Tests.Unit.csproj similarity index 95% rename from src/Gameboard.Test/Gameboard.Test.csproj rename to src/Gameboard.Tests.Unit/Gameboard.Tests.Unit.csproj index f91746c6..544b48c0 100644 --- a/src/Gameboard.Test/Gameboard.Test.csproj +++ b/src/Gameboard.Tests.Unit/Gameboard.Tests.Unit.csproj @@ -4,7 +4,7 @@ net7.0 enable enable - Gameboard.Test + Gameboard.Test.Unit false diff --git a/src/Gameboard.Test/Stubbing/StubDefinition.cs b/src/Gameboard.Tests.Unit/Stubbing/StubDefinition.cs similarity index 100% rename from src/Gameboard.Test/Stubbing/StubDefinition.cs rename to src/Gameboard.Tests.Unit/Stubbing/StubDefinition.cs diff --git a/src/Gameboard.Test/Stubbing/StubFactory.cs b/src/Gameboard.Tests.Unit/Stubbing/StubFactory.cs similarity index 100% rename from src/Gameboard.Test/Stubbing/StubFactory.cs rename to src/Gameboard.Tests.Unit/Stubbing/StubFactory.cs diff --git a/src/Gameboard.Test/Stubbing/StubbingException.cs b/src/Gameboard.Tests.Unit/Stubbing/StubbingException.cs similarity index 100% rename from src/Gameboard.Test/Stubbing/StubbingException.cs rename to src/Gameboard.Tests.Unit/Stubbing/StubbingException.cs diff --git a/src/Gameboard.Test/Usings.cs b/src/Gameboard.Tests.Unit/Usings.cs similarity index 100% rename from src/Gameboard.Test/Usings.cs rename to src/Gameboard.Tests.Unit/Usings.cs From c44286e577b7674016c67481ebab3877870b7d5c Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Mon, 19 Dec 2022 13:11:00 -0500 Subject: [PATCH 07/17] Add integration testing project --- Gameboard.sln | 15 ++++++ .../Features/Games/GameControllerTests.cs | 47 +++++++++++++++++++ .../Fixtures/TestWebApplicationFactory.cs | 44 +++++++++++++++++ .../Gameboard.Tests.Integration.csproj | 32 +++++++++++++ src/Gameboard.Tests.Integration/Usings.cs | 2 + 5 files changed, 140 insertions(+) create mode 100644 src/Gameboard.Tests.Integration/Features/Games/GameControllerTests.cs create mode 100644 src/Gameboard.Tests.Integration/Fixtures/TestWebApplicationFactory.cs create mode 100644 src/Gameboard.Tests.Integration/Gameboard.Tests.Integration.csproj create mode 100644 src/Gameboard.Tests.Integration/Usings.cs diff --git a/Gameboard.sln b/Gameboard.sln index c8dd9395..0872a872 100644 --- a/Gameboard.sln +++ b/Gameboard.sln @@ -9,6 +9,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gameboard", "src\Gameboard. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gameboard.Tests.Unit", "src\Gameboard.Tests.Unit\Gameboard.Tests.Unit.csproj", "{BAEF960F-857E-4CDA-9D9D-D2F6F1C40E0C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gameboard.Tests.Integration", "src\Gameboard.Tests.Integration\Gameboard.Tests.Integration.csproj", "{7598696F-417B-4062-A69A-85CD50D067FE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -46,9 +48,22 @@ Global {BAEF960F-857E-4CDA-9D9D-D2F6F1C40E0C}.Release|x64.Build.0 = Release|Any CPU {BAEF960F-857E-4CDA-9D9D-D2F6F1C40E0C}.Release|x86.ActiveCfg = Release|Any CPU {BAEF960F-857E-4CDA-9D9D-D2F6F1C40E0C}.Release|x86.Build.0 = Release|Any CPU + {7598696F-417B-4062-A69A-85CD50D067FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7598696F-417B-4062-A69A-85CD50D067FE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7598696F-417B-4062-A69A-85CD50D067FE}.Debug|x64.ActiveCfg = Debug|Any CPU + {7598696F-417B-4062-A69A-85CD50D067FE}.Debug|x64.Build.0 = Debug|Any CPU + {7598696F-417B-4062-A69A-85CD50D067FE}.Debug|x86.ActiveCfg = Debug|Any CPU + {7598696F-417B-4062-A69A-85CD50D067FE}.Debug|x86.Build.0 = Debug|Any CPU + {7598696F-417B-4062-A69A-85CD50D067FE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7598696F-417B-4062-A69A-85CD50D067FE}.Release|Any CPU.Build.0 = Release|Any CPU + {7598696F-417B-4062-A69A-85CD50D067FE}.Release|x64.ActiveCfg = Release|Any CPU + {7598696F-417B-4062-A69A-85CD50D067FE}.Release|x64.Build.0 = Release|Any CPU + {7598696F-417B-4062-A69A-85CD50D067FE}.Release|x86.ActiveCfg = Release|Any CPU + {7598696F-417B-4062-A69A-85CD50D067FE}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {A2715FCD-D078-4E96-81C3-B833640921C5} = {C148F187-E600-4044-8944-2E9E52575B07} {BAEF960F-857E-4CDA-9D9D-D2F6F1C40E0C} = {C148F187-E600-4044-8944-2E9E52575B07} + {7598696F-417B-4062-A69A-85CD50D067FE} = {C148F187-E600-4044-8944-2E9E52575B07} EndGlobalSection EndGlobal diff --git a/src/Gameboard.Tests.Integration/Features/Games/GameControllerTests.cs b/src/Gameboard.Tests.Integration/Features/Games/GameControllerTests.cs new file mode 100644 index 00000000..93516c66 --- /dev/null +++ b/src/Gameboard.Tests.Integration/Features/Games/GameControllerTests.cs @@ -0,0 +1,47 @@ +using System.Net.Http.Json; +using Gameboard.Api; +using Gameboard.Tests.Integration.Fixtures; + +namespace Gameboard.Tests.Integration; + +public class GameControllerTests : IClassFixture> +{ + private readonly HttpClient _http; + private readonly TestWebApplicationFactory _appFactory; + + public GameControllerTests(TestWebApplicationFactory appFactory) + { + _appFactory = appFactory; + _http = appFactory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + } + + [Fact] + public async Task GameController_Create_ReturnsGame() + { + // arrange + var game = new NewGame() + { + Name = "Test game", + Competition = "Test competition", + Season = "1", + Track = "Individual", + Sponsor = "Test Sponsor", + GameStart = DateTimeOffset.UtcNow, + GameEnd = DateTime.UtcNow + TimeSpan.FromDays(30), + RegistrationOpen = DateTimeOffset.UtcNow, + RegistrationClose = DateTime.UtcNow + TimeSpan.FromDays(30) + }; + + // act + var response = await _http.PostAsync("/api/game", JsonContent.Create(game)); + + // assert + response.EnsureSuccessStatusCode(); + + var responseGame = await response.Content.ReadFromJsonAsync(); + Assert.Equal(game.Name, responseGame?.Name); + } +} diff --git a/src/Gameboard.Tests.Integration/Fixtures/TestWebApplicationFactory.cs b/src/Gameboard.Tests.Integration/Fixtures/TestWebApplicationFactory.cs new file mode 100644 index 00000000..376491f6 --- /dev/null +++ b/src/Gameboard.Tests.Integration/Fixtures/TestWebApplicationFactory.cs @@ -0,0 +1,44 @@ +using System.Data.Common; +using Gameboard.Api.Data; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Gameboard.Tests.Integration.Fixtures; + +public class TestWebApplicationFactory : WebApplicationFactory where TProgram : class +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("test"); + builder.ConfigureServices(services => + { + // remove configured db context + var dbContextDescriptor = services.SingleOrDefault(s => s.ServiceType == typeof(DbContextOptions)); + if (dbContextDescriptor != null) + services.Remove(dbContextDescriptor); + + // remove configured connection string + var dbConnectionString = services.SingleOrDefault(s => s.ServiceType == typeof(DbConnection)); + if (dbConnectionString != null) + services.Remove(dbConnectionString); + + // create in-memory db provider/connection + services.AddSingleton(container => + { + var connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + + return connection; + }); + + services.AddDbContext((container, options) => + { + var connection = container.GetRequiredService(); + options.UseSqlite(connection); + }); + }); + } +} diff --git a/src/Gameboard.Tests.Integration/Gameboard.Tests.Integration.csproj b/src/Gameboard.Tests.Integration/Gameboard.Tests.Integration.csproj new file mode 100644 index 00000000..82fb66ac --- /dev/null +++ b/src/Gameboard.Tests.Integration/Gameboard.Tests.Integration.csproj @@ -0,0 +1,32 @@ + + + + net7.0 + enable + enable + + false + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/src/Gameboard.Tests.Integration/Usings.cs b/src/Gameboard.Tests.Integration/Usings.cs new file mode 100644 index 00000000..5fc7fcaa --- /dev/null +++ b/src/Gameboard.Tests.Integration/Usings.cs @@ -0,0 +1,2 @@ +global using Microsoft.Extensions.DependencyInjection; +global using Xunit; From cb4adaebda2a07df87ccbbbcbaea03c8a5e00762 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Mon, 19 Dec 2022 15:05:11 -0500 Subject: [PATCH 08/17] Additional configuration for integration testing (still in progress) --- .dockerignore | 9 ++++ .../Extensions/DatabaseStartupExtensions.cs | 14 +++---- src/Gameboard.Api/appsettings.conf | 3 +- .../Fixtures/TestContainersPostgresConfig.cs | 41 +++++++++++++++++++ .../Fixtures/TestWebApplicationFactory.cs | 35 ++++++---------- .../Gameboard.Tests.Integration.csproj | 12 +++++- tests.integration.dockerfile | 12 ++++++ tests.unit.dockerfile | 13 ++++++ 8 files changed, 105 insertions(+), 34 deletions(-) create mode 100644 .dockerignore create mode 100644 src/Gameboard.Tests.Integration/Fixtures/TestContainersPostgresConfig.cs create mode 100644 tests.integration.dockerfile create mode 100644 tests.unit.dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..5c723985 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +# app settings +.git +.github +.vs +.vscode + +# compiled +bin +obj diff --git a/src/Gameboard.Api/Extensions/DatabaseStartupExtensions.cs b/src/Gameboard.Api/Extensions/DatabaseStartupExtensions.cs index 4d8720a6..a9496911 100644 --- a/src/Gameboard.Api/Extensions/DatabaseStartupExtensions.cs +++ b/src/Gameboard.Api/Extensions/DatabaseStartupExtensions.cs @@ -3,14 +3,14 @@ using System; using System.IO; +using System.Linq; using System.Text.Json; +using Gameboard.Api.Data; using Microsoft.AspNetCore.Hosting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using Gameboard.Api.Data; -using System.Linq; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; @@ -19,9 +19,7 @@ namespace Gameboard.Api.Extensions public static class DatabaseStartupExtensions { - public static IHost InitializeDatabase( - this IHost host - ) + public static IHost InitializeDatabase(this IHost host) { using (var scope = host.Services.CreateScope()) { @@ -41,9 +39,9 @@ this IHost host ); var YamlDeserializer = new DeserializerBuilder() - .WithNamingConvention(CamelCaseNamingConvention.Instance) - .IgnoreUnmatchedProperties() - .Build(); + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .IgnoreUnmatchedProperties() + .Build(); if (File.Exists(seedFile)) { diff --git a/src/Gameboard.Api/appsettings.conf b/src/Gameboard.Api/appsettings.conf index e1260315..9e3e4223 100644 --- a/src/Gameboard.Api/appsettings.conf +++ b/src/Gameboard.Api/appsettings.conf @@ -25,7 +25,6 @@ ## File containing any seed data. # Database__SeedFile = seed-data.json - #################### ## Caching #################### @@ -132,4 +131,4 @@ # Headers__Cors__Origins__0 = http://localhost:4200 # Headers__Cors__Methods__0 = * # Headers__Cors__Headers__0 = * -# Headers__Cors__AllowCredentials = true \ No newline at end of file +# Headers__Cors__AllowCredentials = true diff --git a/src/Gameboard.Tests.Integration/Fixtures/TestContainersPostgresConfig.cs b/src/Gameboard.Tests.Integration/Fixtures/TestContainersPostgresConfig.cs new file mode 100644 index 00000000..d82a3615 --- /dev/null +++ b/src/Gameboard.Tests.Integration/Fixtures/TestContainersPostgresConfig.cs @@ -0,0 +1,41 @@ +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Configurations; +using DotNet.Testcontainers.Containers; +using Npgsql; + +public sealed class TestContainersPostgresConfig : IAsyncLifetime +{ + private readonly TestcontainerDatabase testcontainers = new TestcontainersBuilder() + .WithDatabase(new PostgreSqlTestcontainerConfiguration + { + Database = "db", + Username = "postgres", + Password = "postgres", + }) + .Build(); + + [Fact] + public void ExecuteCommand() + { + using (var connection = new NpgsqlConnection(this.testcontainers.ConnectionString)) + { + using (var command = new NpgsqlCommand()) + { + connection.Open(); + command.Connection = connection; + command.CommandText = "SELECT 1"; + command.ExecuteReader(); + } + } + } + + public Task InitializeAsync() + { + return this.testcontainers.StartAsync(); + } + + public Task DisposeAsync() + { + return this.testcontainers.DisposeAsync().AsTask(); + } +} diff --git a/src/Gameboard.Tests.Integration/Fixtures/TestWebApplicationFactory.cs b/src/Gameboard.Tests.Integration/Fixtures/TestWebApplicationFactory.cs index 376491f6..de4cbf07 100644 --- a/src/Gameboard.Tests.Integration/Fixtures/TestWebApplicationFactory.cs +++ b/src/Gameboard.Tests.Integration/Fixtures/TestWebApplicationFactory.cs @@ -1,10 +1,7 @@ -using System.Data.Common; using Gameboard.Api.Data; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; namespace Gameboard.Tests.Integration.Fixtures; @@ -16,29 +13,21 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) builder.ConfigureServices(services => { // remove configured db context - var dbContextDescriptor = services.SingleOrDefault(s => s.ServiceType == typeof(DbContextOptions)); - if (dbContextDescriptor != null) - services.Remove(dbContextDescriptor); + // Remove AppDbContext + var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions)); + if (descriptor != null) services.Remove(descriptor); - // remove configured connection string - var dbConnectionString = services.SingleOrDefault(s => s.ServiceType == typeof(DbConnection)); - if (dbConnectionString != null) - services.Remove(dbConnectionString); + // Add DB context pointing to test container + services.AddDbContext(options => options.UseNpgsql("Username=postgres;Password=testing;Database=Gameboard_db_TEST);")); - // create in-memory db provider/connection - services.AddSingleton(container => + // Ensure schema gets created + var serviceProvider = services.BuildServiceProvider(); + using (var scope = serviceProvider.CreateScope()) { - var connection = new SqliteConnection("DataSource=:memory:"); - connection.Open(); - - return connection; - }); - - services.AddDbContext((container, options) => - { - var connection = container.GetRequiredService(); - options.UseSqlite(connection); - }); + var scopedServices = scope.ServiceProvider; + var context = scopedServices.GetRequiredService(); + context.Database.EnsureCreated(); + } }); } } diff --git a/src/Gameboard.Tests.Integration/Gameboard.Tests.Integration.csproj b/src/Gameboard.Tests.Integration/Gameboard.Tests.Integration.csproj index 82fb66ac..d55b8b9b 100644 --- a/src/Gameboard.Tests.Integration/Gameboard.Tests.Integration.csproj +++ b/src/Gameboard.Tests.Integration/Gameboard.Tests.Integration.csproj @@ -10,10 +10,20 @@ - + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests.integration.dockerfile b/tests.integration.dockerfile new file mode 100644 index 00000000..aaf91fb3 --- /dev/null +++ b/tests.integration.dockerfile @@ -0,0 +1,12 @@ +FROM mcr.microsoft.com/dotnet/sdk:7.0 AS dev + +ENV ASPNETCORE_URLS=http://*:5000 \ + ASPNETCORE_ENVIRONMENT=TEST + +COPY . /app + +WORKDIR /app/src/Gameboard.Tests.Integration + +RUN dotnet restore + +CMD ["dotnet", "test"] diff --git a/tests.unit.dockerfile b/tests.unit.dockerfile new file mode 100644 index 00000000..2ea16e72 --- /dev/null +++ b/tests.unit.dockerfile @@ -0,0 +1,13 @@ +# Use SDK to build and run +FROM mcr.microsoft.com/dotnet/sdk:7.0 AS dev + +ENV ASPNETCORE_URLS=http://*:5000 \ + ASPNETCORE_ENVIRONMENT=TEST + +COPY . /app + +WORKDIR /app/src/Gameboard.Tests.Unit + +RUN dotnet restore + +CMD ["dotnet", "test"] From 9ba5030d9b3c0bac83faaba1609f2001720d0a7e Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Mon, 19 Dec 2022 15:13:46 -0500 Subject: [PATCH 09/17] Update packages to most-recent netcore7 compatible versions and tested. --- src/Gameboard.Api/Gameboard.Api.csproj | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/Gameboard.Api/Gameboard.Api.csproj b/src/Gameboard.Api/Gameboard.Api.csproj index 1699f2d5..ed34a887 100644 --- a/src/Gameboard.Api/Gameboard.Api.csproj +++ b/src/Gameboard.Api/Gameboard.Api.csproj @@ -4,23 +4,23 @@ - - - - - - + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - - + + - - + + - - + + true From 336e7fb9c1f8fae4a8297fc84d8004f857aa14d0 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Mon, 19 Dec 2022 15:51:42 -0500 Subject: [PATCH 10/17] More plumbing for integration tests which require authentication. --- src/Gameboard.Api/Gameboard.Api.csproj | 2 +- src/Gameboard.Api/Startup.cs | 2 +- .../GameboardIntegrationTestException.cs | 11 ++++ .../Fixtures/TestAuthenticationHandler.cs | 52 +++++++++++++++++++ .../TestAuthenticationHandlerOptions.cs | 10 ++++ .../Fixtures/TestWebApplicationFactory.cs | 15 ++++++ 6 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 src/Gameboard.Tests.Integration/Exceptions/GameboardIntegrationTestException.cs create mode 100644 src/Gameboard.Tests.Integration/Fixtures/TestAuthenticationHandler.cs create mode 100644 src/Gameboard.Tests.Integration/Fixtures/TestAuthenticationHandlerOptions.cs diff --git a/src/Gameboard.Api/Gameboard.Api.csproj b/src/Gameboard.Api/Gameboard.Api.csproj index 22050756..286ad6ba 100644 --- a/src/Gameboard.Api/Gameboard.Api.csproj +++ b/src/Gameboard.Api/Gameboard.Api.csproj @@ -28,7 +28,7 @@ - net6.0 + net7.0 diff --git a/src/Gameboard.Api/Startup.cs b/src/Gameboard.Api/Startup.cs index 9d8258ae..b4c3d5fc 100644 --- a/src/Gameboard.Api/Startup.cs +++ b/src/Gameboard.Api/Startup.cs @@ -48,7 +48,7 @@ public Startup(IConfiguration configuration, IHostEnvironment env) CsvConfig.OmitHeaders = true; CsvConfig.OmitHeaders = true; - if (env.IsDevelopment()) + if (env.IsDevelopment() || env.IsEnvironment("test")) Settings.Oidc.RequireHttpsMetadata = false; } diff --git a/src/Gameboard.Tests.Integration/Exceptions/GameboardIntegrationTestException.cs b/src/Gameboard.Tests.Integration/Exceptions/GameboardIntegrationTestException.cs new file mode 100644 index 00000000..6af32bad --- /dev/null +++ b/src/Gameboard.Tests.Integration/Exceptions/GameboardIntegrationTestException.cs @@ -0,0 +1,11 @@ +using Microsoft.Extensions.Primitives; + +internal class GameboardIntegrationTestException : Exception +{ + public GameboardIntegrationTestException(string message) : base(message) { } +} + +internal class CantResolveAuthUserIdException : GameboardIntegrationTestException +{ + public CantResolveAuthUserIdException(StringValues userIds) : base($"Couldn't resolve the authenticated user ID. Found: {userIds}") { } +} diff --git a/src/Gameboard.Tests.Integration/Fixtures/TestAuthenticationHandler.cs b/src/Gameboard.Tests.Integration/Fixtures/TestAuthenticationHandler.cs new file mode 100644 index 00000000..c1231da7 --- /dev/null +++ b/src/Gameboard.Tests.Integration/Fixtures/TestAuthenticationHandler.cs @@ -0,0 +1,52 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using Gameboard.Api; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Gameboard.Tests.Integration.Fixtures; + +internal class TestAuthenticationHandler : AuthenticationHandler +{ + private readonly string _defaultUserId; + + public TestAuthenticationHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock) : + base(options, logger, encoder, clock) + { + _defaultUserId = options.CurrentValue.DefaultUserId; + } + + protected override Task HandleAuthenticateAsync() + { + var claims = new List { new Claim(ClaimTypes.Name, "Test user") }; + + // Extract User ID from the request headers if it exists, + // otherwise use the default User ID from the options. + if (Context.Request.Headers.TryGetValue("UserId", out var userIds)) + { + if (userIds.Count() == 1 && userIds[0] != null && userIds[0] != string.Empty) + { + claims.Add(new Claim(ClaimTypes.NameIdentifier, userIds[0]!)); + } + else + { + throw new CantResolveAuthUserIdException(userIds); + } + } + else + { + claims.Add(new Claim(ClaimTypes.NameIdentifier, _defaultUserId)); + claims.Add(new Claim(AppConstants.SubjectClaimName, _defaultUserId)); + } + + // TODO: Add as many claims as you need here + var identity = new ClaimsIdentity(claims, Options.AuthScheme.Name); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, Options.AuthScheme.Name); + + var result = AuthenticateResult.Success(ticket); + + return Task.FromResult(result); + } +} diff --git a/src/Gameboard.Tests.Integration/Fixtures/TestAuthenticationHandlerOptions.cs b/src/Gameboard.Tests.Integration/Fixtures/TestAuthenticationHandlerOptions.cs new file mode 100644 index 00000000..c1ca5352 --- /dev/null +++ b/src/Gameboard.Tests.Integration/Fixtures/TestAuthenticationHandlerOptions.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Authentication; + +namespace Gameboard.Tests.Integration.Fixtures; + +internal class TestAuthenticationHandlerOptions : AuthenticationSchemeOptions +{ + public const string AuthenticationSchemeName = "Test"; + public AuthenticationScheme AuthScheme { get; } = new AuthenticationScheme(name: AuthenticationSchemeName, displayName: AuthenticationSchemeName, handlerType: typeof(TestAuthenticationHandler)); + public string DefaultUserId { get; set; } = null!; +} diff --git a/src/Gameboard.Tests.Integration/Fixtures/TestWebApplicationFactory.cs b/src/Gameboard.Tests.Integration/Fixtures/TestWebApplicationFactory.cs index de4cbf07..798e60e3 100644 --- a/src/Gameboard.Tests.Integration/Fixtures/TestWebApplicationFactory.cs +++ b/src/Gameboard.Tests.Integration/Fixtures/TestWebApplicationFactory.cs @@ -7,6 +7,8 @@ namespace Gameboard.Tests.Integration.Fixtures; public class TestWebApplicationFactory : WebApplicationFactory where TProgram : class { + private readonly string _DefaultAuthenticationUserId = "679b1757-8ca7-4816-ad1b-ae90dd1b3941"; + protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.UseEnvironment("test"); @@ -20,6 +22,19 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) // Add DB context pointing to test container services.AddDbContext(options => options.UseNpgsql("Username=postgres;Password=testing;Database=Gameboard_db_TEST);")); + // configure authorization spoof + // services.AddAuthorization(_ => + // { + // _.AddPolicy("AutomatedTest", new AuthorizationPolicyBuilder() + // .RequireAssertion(context => context.Succeed()) + // ); + // }); + + services.Configure(options => options.DefaultUserId = _DefaultAuthenticationUserId); + + services.AddAuthentication(TestAuthenticationHandlerOptions.AuthenticationSchemeName) + .AddScheme(TestAuthenticationHandlerOptions.AuthenticationSchemeName, options => { }); + // Ensure schema gets created var serviceProvider = services.BuildServiceProvider(); using (var scope = serviceProvider.CreateScope()) From c3e1596c6763c0517db2f3462767a8d969c6a052 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Tue, 20 Dec 2022 16:00:25 -0500 Subject: [PATCH 11/17] Refactor Gameboard launch to support integration testing. Added integration test plumbing. --- .../Data/DataStartupExtensions.cs | 37 ++-- .../AuthenticationStartupExtensions.cs | 13 +- .../Extensions/DatabaseStartupExtensions.cs | 13 +- .../Extensions/IWebHostEnvironment.cs | 19 ++ .../Extensions/SwaggerStartupExtensions.cs | 17 +- .../WebApplicationBuilderExtensions.cs | 115 ++++++++++++ .../Extensions/WebApplicationExtensions.cs | 46 +++++ src/Gameboard.Api/Gameboard.Api.csproj | 9 +- src/Gameboard.Api/Program.cs | 126 ++++---------- src/Gameboard.Api/Startup.cs | 164 ------------------ src/Gameboard.Api/Structure/AppSettings.cs | 31 ++-- src/Gameboard.Api/Structure/ConfToEnv.cs | 36 ++++ .../Extensions/HttpContentExtensions.cs | 11 ++ .../Extensions/ObjectExtensions.cs | 11 ++ .../Extensions/ServiceCollectionExtensions.cs | 27 +++ .../Features/Games/GameControllerTests.cs | 18 +- .../JsonSerializationOptionsFixture.cs | 8 + .../Fixtures/TestAuthenticationHandler.cs | 9 +- .../TestAuthenticationHandlerOptions.cs | 2 - .../Fixtures/TestAuthorizationService.cs | 17 ++ .../Fixtures/TestContainersPostgresConfig.cs | 66 +++---- .../Fixtures/TestWebApplicationFactory.cs | 27 +-- src/Gameboard.Tests.Integration/Usings.cs | 1 + tests.integration.dockerfile | 2 +- 24 files changed, 456 insertions(+), 369 deletions(-) create mode 100644 src/Gameboard.Api/Extensions/IWebHostEnvironment.cs create mode 100644 src/Gameboard.Api/Extensions/WebApplicationBuilderExtensions.cs create mode 100644 src/Gameboard.Api/Extensions/WebApplicationExtensions.cs delete mode 100644 src/Gameboard.Api/Startup.cs create mode 100644 src/Gameboard.Api/Structure/ConfToEnv.cs create mode 100644 src/Gameboard.Tests.Integration/Extensions/HttpContentExtensions.cs create mode 100644 src/Gameboard.Tests.Integration/Extensions/ObjectExtensions.cs create mode 100644 src/Gameboard.Tests.Integration/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/Gameboard.Tests.Integration/Fixtures/JsonSerializationOptionsFixture.cs create mode 100644 src/Gameboard.Tests.Integration/Fixtures/TestAuthorizationService.cs diff --git a/src/Gameboard.Api/Data/DataStartupExtensions.cs b/src/Gameboard.Api/Data/DataStartupExtensions.cs index 8ebd7b6e..589672a6 100644 --- a/src/Gameboard.Api/Data/DataStartupExtensions.cs +++ b/src/Gameboard.Api/Data/DataStartupExtensions.cs @@ -2,11 +2,10 @@ // Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. using System; -using Microsoft.EntityFrameworkCore; -using Gameboard.Api.Data; -using Gameboard.Api.Data.Abstractions; -using System.Reflection; using System.Linq; +using System.Reflection; +using Gameboard.Api.Data; +using Microsoft.EntityFrameworkCore; namespace Microsoft.Extensions.DependencyInjection { @@ -22,25 +21,25 @@ string connstr { case "sqlserver": - // services.AddEntityFrameworkSqlServer(); - services.AddDbContext( - builder => builder.UseSqlServer(connstr) - ); - break; + // services.AddEntityFrameworkSqlServer(); + services.AddDbContext( + builder => builder.UseSqlServer(connstr) + ); + break; case "postgresql": - // services.AddEntityFrameworkNpgsql(); - services.AddDbContext( - builder => builder.UseNpgsql(connstr) - ); - break; + // services.AddEntityFrameworkNpgsql(); + services.AddDbContext( + builder => builder.UseNpgsql(connstr) + ); + break; default: - // services.AddEntityFrameworkInMemoryDatabase(); - services.AddDbContext( - builder => builder.UseInMemoryDatabase("Gameboard_Db") - ); - break; + // services.AddEntityFrameworkInMemoryDatabase(); + services.AddDbContext( + builder => builder.UseInMemoryDatabase("Gameboard_Db") + ); + break; } // Auto-discover from EntityStore and IEntityStore pattern diff --git a/src/Gameboard.Api/Extensions/AuthenticationStartupExtensions.cs b/src/Gameboard.Api/Extensions/AuthenticationStartupExtensions.cs index 079a3575..93207cde 100644 --- a/src/Gameboard.Api/Extensions/AuthenticationStartupExtensions.cs +++ b/src/Gameboard.Api/Extensions/AuthenticationStartupExtensions.cs @@ -16,7 +16,8 @@ public static class AuthenticationStartupExtensions public static IServiceCollection AddConfiguredAuthentication( this IServiceCollection services, OidcOptions options - ) { + ) + { JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); services @@ -35,19 +36,21 @@ OidcOptions options }; jwt.SaveToken = true; - }) - .AddCookie(AppConstants.MksCookie, opt => { + .AddCookie(AppConstants.MksCookie, opt => + { opt.ExpireTimeSpan = new System.TimeSpan(0, options.MksCookieMinutes, 0); opt.Cookie = new CookieBuilder { Name = AppConstants.MksCookie, }; - opt.Events.OnRedirectToAccessDenied = ctx => { + opt.Events.OnRedirectToAccessDenied = ctx => + { ctx.HttpContext.Response.StatusCode = StatusCodes.Status403Forbidden; return System.Threading.Tasks.Task.CompletedTask; }; - opt.Events.OnRedirectToLogin = ctx => { + opt.Events.OnRedirectToLogin = ctx => + { ctx.HttpContext.Response.StatusCode = StatusCodes.Status401Unauthorized; return System.Threading.Tasks.Task.CompletedTask; }; diff --git a/src/Gameboard.Api/Extensions/DatabaseStartupExtensions.cs b/src/Gameboard.Api/Extensions/DatabaseStartupExtensions.cs index a9496911..8643a45d 100644 --- a/src/Gameboard.Api/Extensions/DatabaseStartupExtensions.cs +++ b/src/Gameboard.Api/Extensions/DatabaseStartupExtensions.cs @@ -1,16 +1,15 @@ // Copyright 2021 Carnegie Mellon University. All Rights Reserved. // Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. -using System; using System.IO; using System.Linq; using System.Text.Json; using Gameboard.Api.Data; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; @@ -19,13 +18,13 @@ namespace Gameboard.Api.Extensions public static class DatabaseStartupExtensions { - public static IHost InitializeDatabase(this IHost host) + public static WebApplication InitializeDatabase(this WebApplication app) { - using (var scope = host.Services.CreateScope()) + using (var scope = app.Services.CreateScope()) { var services = scope.ServiceProvider; - IConfiguration config = services.GetRequiredService(); - IWebHostEnvironment env = services.GetService(); + var config = services.GetRequiredService(); + var env = services.GetService(); var db = services.GetService(); if (!db.Database.IsInMemory()) @@ -114,7 +113,7 @@ public static IHost InitializeDatabase(this IHost host) db.SaveChanges(); } - return host; + return app; } } } diff --git a/src/Gameboard.Api/Extensions/IWebHostEnvironment.cs b/src/Gameboard.Api/Extensions/IWebHostEnvironment.cs new file mode 100644 index 00000000..90401f20 --- /dev/null +++ b/src/Gameboard.Api/Extensions/IWebHostEnvironment.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Hosting; + +namespace Gameboard.Api; + +internal static class IWebHostEnvironmentExtensions +{ + private static string _envDev = "Development"; + private static string _envTest = "Test"; + + public static bool IsDevOrTest(this IWebHostEnvironment env) + { + return env.EnvironmentName == _envDev || env.EnvironmentName == _envTest; + } + + public static bool IsDev(this IWebHostEnvironment env) + { + return env.EnvironmentName == _envDev; + } +} diff --git a/src/Gameboard.Api/Extensions/SwaggerStartupExtensions.cs b/src/Gameboard.Api/Extensions/SwaggerStartupExtensions.cs index 17dcc4ff..74055177 100644 --- a/src/Gameboard.Api/Extensions/SwaggerStartupExtensions.cs +++ b/src/Gameboard.Api/Extensions/SwaggerStartupExtensions.cs @@ -34,15 +34,18 @@ OpenApiOptions openapi options.EnableAnnotations(); options.CustomSchemaIds(i => i.FullName); -#if DEBUG - string[] files = Directory.GetFiles("bin", xmlDoc, SearchOption.AllDirectories); - - if (files.Length > 0) - options.IncludeXmlComments(files[0]); -#else if (File.Exists(xmlDoc)) options.IncludeXmlComments(xmlDoc); -#endif + + // #if DEBUG + // string[] files = Directory.GetFiles("bin", xmlDoc, SearchOption.AllDirectories); + + // if (files.Length > 0) + // options.IncludeXmlComments(files[0]); + // #else + // if (File.Exists(xmlDoc)) + // options.IncludeXmlComments(xmlDoc); + // #endif // options.CustomSchemaIds(type => type.FullName.StartsWith("Gameboard") // ? type.Name diff --git a/src/Gameboard.Api/Extensions/WebApplicationBuilderExtensions.cs b/src/Gameboard.Api/Extensions/WebApplicationBuilderExtensions.cs new file mode 100644 index 00000000..6d3b339f --- /dev/null +++ b/src/Gameboard.Api/Extensions/WebApplicationBuilderExtensions.cs @@ -0,0 +1,115 @@ +using System; +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using ServiceStack.Text; + +namespace Gameboard.Api.Extensions; + +internal static class WebApplicationBuilderExtensions +{ + // exposed internally to support integration testing + public static Action ConfigureJsonOptions { get; } = (options => + { + options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles; + options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); + options.JsonSerializerOptions.Converters.Add(new JsonDateTimeConverter()); + }); + + public static AppSettings BuildAppSettings(this WebApplicationBuilder builder) + { + var settings = builder.Configuration.Get() ?? new AppSettings(); + + settings.Cache.SharedFolder = Path.Combine( + builder.Environment.ContentRootPath, + settings.Cache.SharedFolder ?? "" + ); + + settings.Core.ImageFolder = Path.Combine( + builder.Environment.ContentRootPath, + settings.Core.ImageFolder ?? "" + ); + + if (settings.Core.ChallengeDocUrl.IsEmpty()) + settings.Core.ChallengeDocUrl = settings.PathBase; + + if (!settings.Core.ChallengeDocUrl?.EndsWith("/") ?? true) + settings.Core.ChallengeDocUrl += "/"; + + Directory.CreateDirectory(settings.Core.ImageFolder); + + CsvConfig>.OmitHeaders = true; + CsvConfig>.OmitHeaders = true; + CsvConfig.OmitHeaders = true; + CsvConfig.OmitHeaders = true; + + if (builder.Environment.IsDevOrTest()) + settings.Oidc.RequireHttpsMetadata = false; + + return settings; + } + + public static void ConfigureServices(this WebApplicationBuilder builder, AppSettings settings) + { + var services = builder.Services; + + services.AddMvc() + .AddJsonOptions(ConfigureJsonOptions); + + services.ConfigureForwarding(settings.Headers.Forwarding); + + services.AddCors( + opt => opt.AddPolicy( + settings.Headers.Cors.Name, + settings.Headers.Cors.Build() + ) + ); + + if (settings.OpenApi.Enabled) + services.AddSwagger(settings.Oidc, settings.OpenApi); + + services.AddCache(() => settings.Cache); + + services.AddDataProtection() + .SetApplicationName(AppConstants.DataProtectionPurpose) + .PersistKeys(() => settings.Cache) + ; + + services.AddSignalR() + .AddJsonProtocol(options => + { + options.PayloadSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles; + options.PayloadSerializerOptions.Converters.Add(new JsonStringEnumConverter( + JsonNamingPolicy.CamelCase + )); + }) + ; + services.AddSignalRHub(); + + services + .AddSingleton(_ => settings.Core) + .AddSingleton() + .AddGameboardData(settings.Database.Provider, settings.Database.ConnectionString) + .AddGameboardServices() + .AddConfiguredHttpClients(settings.Core) + .AddHostedService() + .AddDefaults(settings.Defaults, builder.Environment.ContentRootPath) + ; + + services.AddSingleton( + new AutoMapper.MapperConfiguration(cfg => + { + cfg.AddGameboardMaps(); + }).CreateMapper() + ); + + // Configure Auth + services.AddConfiguredAuthentication(settings.Oidc); + services.AddConfiguredAuthorization(); + } +} diff --git a/src/Gameboard.Api/Extensions/WebApplicationExtensions.cs b/src/Gameboard.Api/Extensions/WebApplicationExtensions.cs new file mode 100644 index 00000000..9feb53a2 --- /dev/null +++ b/src/Gameboard.Api/Extensions/WebApplicationExtensions.cs @@ -0,0 +1,46 @@ +using Gameboard.Api.Hubs; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; + +namespace Gameboard.Api.Extensions; + +internal static class WebApplicationExtensions +{ + public static WebApplication ConfigureGameboard(this WebApplication app, AppSettings settings) + { + app.UseJsonExceptions(); + + if (!string.IsNullOrEmpty(settings.PathBase)) + app.UsePathBase(settings.PathBase); + + if (settings.Headers.LogHeaders) + app.UseHeaderInspection(); + + if (!string.IsNullOrEmpty(settings.Headers.Forwarding.TargetHeaders)) + app.UseForwardedHeaders(); + + if (settings.Headers.UseHsts) + app.UseHsts(); + + if (app.Environment.IsDev()) + { + app.UseDeveloperExceptionPage(); + } + + app.UseRouting(); + app.UseCors(settings.Headers.Cors.Name); + app.UseAuthentication(); + app.UseAuthorization(); + app.UseFileProtection(); + app.UseStaticFiles(); + + if (settings.OpenApi.Enabled) + app.UseConfiguredSwagger(settings.OpenApi, settings.Oidc.Audience, settings.PathBase); + + // map endpoints directly on app (warning ASP0014) + app.MapHub("/hub").RequireAuthorization(); + app.MapControllers().RequireAuthorization(); + + return app; + } +} diff --git a/src/Gameboard.Api/Gameboard.Api.csproj b/src/Gameboard.Api/Gameboard.Api.csproj index 286ad6ba..823d68ff 100644 --- a/src/Gameboard.Api/Gameboard.Api.csproj +++ b/src/Gameboard.Api/Gameboard.Api.csproj @@ -1,7 +1,4 @@ - - net7.0 - @@ -31,9 +28,13 @@ net7.0 + + net7.0 + + true - $(NoWarn);1591 + $(NoWarn);1591;ASP0014 Gameboard API diff --git a/src/Gameboard.Api/Program.cs b/src/Gameboard.Api/Program.cs index 4fa43d53..0abf7ac2 100644 --- a/src/Gameboard.Api/Program.cs +++ b/src/Gameboard.Api/Program.cs @@ -1,102 +1,52 @@ -// Copyright 2021 Carnegie Mellon University. All Rights Reserved. +// Copyright 2022 Carnegie Mellon University. All Rights Reserved. // Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. using System; -using System.IO; using System.Linq; using Gameboard.Api.Extensions; -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Hosting; +using Gameboard.Api.Structure; +using Microsoft.AspNetCore.Builder; -namespace Gameboard.Api -{ - public class Program - { - public static void Main(string[] args) - { - Console.Title = "Gameboard"; - - LoadSettings(); - - var hostBuilder = CreateHostBuilder(args) - .Build() - .InitializeDatabase(); - - bool dbonly = args.ToList().Contains("--dbonly") - || Environment.GetEnvironmentVariable("GAMEBOARD_DBONLY")?.ToLower() == "true"; +// set logging properties +Console.Title = "Gameboard"; - if (!dbonly) - { - try - { - // Log.Information("Starting Gameboard..."); - System.Diagnostics.Debug.WriteLine("Starting Gameboard..."); - hostBuilder.Run(); - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"Gameboard terminated unexpectedly: {ex.GetType().Name} - {ex.Message}"); - // Log.Fatal($"Gameboard terminated unexpectedly: {ex.GetType().Name} - {ex.Message}"); - } - finally - { - // Log.CloseAndFlush(); - } - } - } +// load and resolve settings +var envname = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); +var path = Environment.GetEnvironmentVariable("APPSETTINGS_PATH") ?? "./conf/appsettings.conf"; +ConfToEnv.Load("appsettings.conf"); +ConfToEnv.Load($"appsettings.{envname}.conf"); +ConfToEnv.Load(path); - public static IHostBuilder CreateHostBuilder(string[] args) => - Host.CreateDefaultBuilder(args) - // .UseSerilog((ctx, cfg) => - // { - // cfg - // .MinimumLevel.Override("Microsoft", Serilog.Events.LogEventLevel.Warning) - // .Enrich.FromLogContext() - // .Enrich.WithProperty("Application", ctx.HostingEnvironment.ApplicationName) - // .Enrich.WithProperty("Environment", ctx.HostingEnvironment.EnvironmentName) - // .WriteTo.Console(new RenderedCompactJsonFormatter()); - // }) - .ConfigureWebHostDefaults(webBuilder => - { - webBuilder.UseStartup(); - }); +// create an application builder +var builder = WebApplication.CreateBuilder(args); - public static void LoadSettings() - { - string envname = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); - string path = Environment.GetEnvironmentVariable("APPSETTINGS_PATH") ?? "./conf/appsettings.conf"; - ConfToEnv("appsettings.conf"); - ConfToEnv($"appsettings.{envname}.conf"); - ConfToEnv(path); - } +// launch db if db only +var dbOnly = args.ToList().Contains("--dbonly") + || Environment.GetEnvironmentVariable("GAMEBOARD_DBONLY")?.ToLower() == "true"; - public static void ConfToEnv(string conf) - { - if (!File.Exists(conf)) - return; +if (dbOnly) +{ + builder + .Build() + .InitializeDatabase(); +} +else +{ + Console.WriteLine("Configuring Gameboard app..."); - try - { - foreach (string line in File.ReadAllLines(conf)) - { - if ( - line.Equals(string.Empty) - || line.Trim().StartsWith("#") - || !line.Contains("=") - ) - { - continue; - } + // load settings and configure services + var settings = builder.BuildAppSettings(); + builder.ConfigureServices(settings); - int x = line.IndexOf("="); + // build and configure app + var app = builder + .Build() + .InitializeDatabase() + .ConfigureGameboard(settings); - Environment.SetEnvironmentVariable( - line.Substring(0, x).Trim(), - line.Substring(x + 1).Trim() - ); - } - } - catch { } - } - } + // start! + app.Run(); } + +// required for integration tests +public partial class Program { } diff --git a/src/Gameboard.Api/Startup.cs b/src/Gameboard.Api/Startup.cs deleted file mode 100644 index b4c3d5fc..00000000 --- a/src/Gameboard.Api/Startup.cs +++ /dev/null @@ -1,164 +0,0 @@ -// Copyright 2021 Carnegie Mellon University. All Rights Reserved. -// Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. - -using System; -using System.IO; -using System.Text.Json; -using System.Text.Json.Serialization; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.DataProtection; -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using ServiceStack.Text; - -namespace Gameboard.Api -{ - public class Startup - { - public Startup(IConfiguration configuration, IHostEnvironment env) - { - Configuration = configuration; - - Environment = env; - - Settings = Configuration.Get() ?? new AppSettings(); - - Settings.Cache.SharedFolder = Path.Combine( - env.ContentRootPath, - Settings.Cache.SharedFolder ?? "" - ); - - Settings.Core.ImageFolder = Path.Combine( - env.ContentRootPath, - Settings.Core.ImageFolder ?? "" - ); - - if (Settings.Core.ChallengeDocUrl.IsEmpty()) - Settings.Core.ChallengeDocUrl = Settings.PathBase; - - if (!Settings.Core.ChallengeDocUrl?.EndsWith("/") ?? true) - Settings.Core.ChallengeDocUrl += "/"; - - Directory.CreateDirectory(Settings.Core.ImageFolder); - - CsvConfig>.OmitHeaders = true; - CsvConfig>.OmitHeaders = true; - CsvConfig.OmitHeaders = true; - CsvConfig.OmitHeaders = true; - - if (env.IsDevelopment() || env.IsEnvironment("test")) - Settings.Oidc.RequireHttpsMetadata = false; - } - - public IHostEnvironment Environment { get; } - public IConfiguration Configuration { get; } - AppSettings Settings { get; } - - public void ConfigureServices(IServiceCollection services) - { - services.AddMvc() - .AddJsonOptions(options => - { - options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles; - - options.JsonSerializerOptions.Converters - .Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); - - options.JsonSerializerOptions.Converters - .Add(new JsonDateTimeConverter()); - }); - - services.ConfigureForwarding(Settings.Headers.Forwarding); - - services.AddCors( - opt => opt.AddPolicy( - Settings.Headers.Cors.Name, - Settings.Headers.Cors.Build() - ) - ); - - if (Settings.OpenApi.Enabled) - services.AddSwagger(Settings.Oidc, Settings.OpenApi); - - services.AddCache(() => Settings.Cache); - - services.AddDataProtection() - .SetApplicationName(AppConstants.DataProtectionPurpose) - .PersistKeys(() => Settings.Cache) - ; - - services.AddSignalR() - .AddJsonProtocol(options => - { - options.PayloadSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles; - options.PayloadSerializerOptions.Converters.Add(new JsonStringEnumConverter( - JsonNamingPolicy.CamelCase - )); - }) - ; - services.AddSignalRHub(); - - services - .AddSingleton(_ => Settings.Core) - .AddSingleton() - .AddGameboardData(Settings.Database.Provider, Settings.Database.ConnectionString) - .AddGameboardServices() - .AddConfiguredHttpClients(Settings.Core) - .AddHostedService() - .AddDefaults(Settings.Defaults, Environment.ContentRootPath) - ; - - services.AddSingleton( - new AutoMapper.MapperConfiguration(cfg => - { - cfg.AddGameboardMaps(); - }).CreateMapper() - ); - - // Configure Auth - services.AddConfiguredAuthentication(Settings.Oidc); - services.AddConfiguredAuthorization(); - } - - public void Configure(IApplicationBuilder app, IWebHostEnvironment env) - { - app.UseJsonExceptions(); - - if (!string.IsNullOrEmpty(Settings.PathBase)) - app.UsePathBase(Settings.PathBase); - - if (Settings.Headers.LogHeaders) - app.UseHeaderInspection(); - - if (!string.IsNullOrEmpty(Settings.Headers.Forwarding.TargetHeaders)) - app.UseForwardedHeaders(); - - if (Settings.Headers.UseHsts) - app.UseHsts(); - - if (env.IsDevelopment()) - { - app.UseDeveloperExceptionPage(); - } - - app.UseRouting(); - app.UseCors(Settings.Headers.Cors.Name); - app.UseAuthentication(); - app.UseAuthorization(); - app.UseFileProtection(); - app.UseStaticFiles(); - - if (Settings.OpenApi.Enabled) - app.UseConfiguredSwagger(Settings.OpenApi, Settings.Oidc.Audience, Settings.PathBase); - - app.UseEndpoints(ep => - { - ep.MapHub("/hub").RequireAuthorization(); - - ep.MapControllers().RequireAuthorization(); - }); - } - } -} diff --git a/src/Gameboard.Api/Structure/AppSettings.cs b/src/Gameboard.Api/Structure/AppSettings.cs index 7dcc10da..a5916adb 100644 --- a/src/Gameboard.Api/Structure/AppSettings.cs +++ b/src/Gameboard.Api/Structure/AppSettings.cs @@ -1,10 +1,9 @@ // Copyright 2021 Carnegie Mellon University. All Rights Reserved. // Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. -using System.Collections.Generic; +using System; using System.Linq; using Microsoft.AspNetCore.Cors.Infrastructure; -using System; namespace Gameboard.Api { @@ -18,7 +17,6 @@ public class AppSettings public HeaderOptions Headers { get; set; } = new HeaderOptions(); public OpenApiOptions OpenApi { get; set; } = new OpenApiOptions(); public Defaults Defaults { get; set; } = new Defaults(); - } public class OidcOptions @@ -34,7 +32,6 @@ public class OpenIdClient public string ClientId { get; set; } public string ClientName { get; set; } public string ClientSecret { get; set; } - } public class OAuth2Client @@ -98,9 +95,9 @@ public class SecurityHeaderOptions public class CorsPolicyOptions { public string Name { get; set; } = "default"; - public string[] Origins { get; set; } = new string[]{}; - public string[] Methods { get; set; } = new string[]{}; - public string[] Headers { get; set; } = new string[]{}; + public string[] Origins { get; set; } = new string[] { }; + public string[] Methods { get; set; } = new string[] { }; + public string[] Headers { get; set; } = new string[] { }; public bool AllowCredentials { get; set; } public CorsPolicy Build() @@ -108,18 +105,21 @@ public CorsPolicy Build() CorsPolicyBuilder policy = new CorsPolicyBuilder(); var origins = Origins.Where(x => !string.IsNullOrWhiteSpace(x)).ToArray(); - if (origins.Any()) { + if (origins.Any()) + { if (origins.First() == "*") policy.AllowAnyOrigin(); else policy.WithOrigins(origins); if (AllowCredentials && origins.First() != "*") policy.AllowCredentials(); else policy.DisallowCredentials(); } var methods = Methods.Where(x => !string.IsNullOrWhiteSpace(x)).ToArray(); - if (methods.Any()) { + if (methods.Any()) + { if (methods.First() == "*") policy.AllowAnyMethod(); else policy.WithMethods(methods); } var headers = Headers.Where(x => !string.IsNullOrWhiteSpace(x)).ToArray(); - if (headers.Any()) { + if (headers.Any()) + { if (headers.First() == "*") policy.AllowAnyHeader(); else policy.WithHeaders(headers); } @@ -167,22 +167,23 @@ public class Defaults public static DateTimeOffset[][] ShiftsFallback { get; set; } = GetShifts(ShiftStringsFallback); // Helper method to format shifts as DateTimeOffset objects - public static DateTimeOffset[][] GetShifts(string[][] shiftStrings) { + public static DateTimeOffset[][] GetShifts(string[][] shiftStrings) + { DateTimeOffset[][] offsets = new DateTimeOffset[shiftStrings.Length][]; // Create a new DateTimeOffset representation for every string time given for (int i = 0; i < shiftStrings.Length; i++) { - offsets[i] = new DateTimeOffset[] { - ConvertTime(shiftStrings[i][0], ShiftTimezoneFallback), + offsets[i] = new DateTimeOffset[] { + ConvertTime(shiftStrings[i][0], ShiftTimezoneFallback), ConvertTime(shiftStrings[i][1], ShiftTimezoneFallback) }; } return offsets; } // Helper method to convert a given string time into a DateTimeOffset representation - public static DateTimeOffset ConvertTime(string time, string shiftTimezone) { + public static DateTimeOffset ConvertTime(string time, string shiftTimezone) + { return TimeZoneInfo.ConvertTime(DateTimeOffset.Parse(time), TimeZoneInfo.FindSystemTimeZoneById(shiftTimezone)); } } - } diff --git a/src/Gameboard.Api/Structure/ConfToEnv.cs b/src/Gameboard.Api/Structure/ConfToEnv.cs new file mode 100644 index 00000000..598ee30a --- /dev/null +++ b/src/Gameboard.Api/Structure/ConfToEnv.cs @@ -0,0 +1,36 @@ +using System; +using System.IO; + +namespace Gameboard.Api.Structure; + +internal static class ConfToEnv +{ + public static void Load(string confFileName) + { + if (!File.Exists(confFileName)) + return; + + try + { + foreach (string line in File.ReadAllLines(confFileName)) + { + if ( + line.Equals(string.Empty) + || line.Trim().StartsWith("#") + || !line.Contains("=") + ) + { + continue; + } + + int x = line.IndexOf("="); + + Environment.SetEnvironmentVariable( + line.Substring(0, x).Trim(), + line.Substring(x + 1).Trim() + ); + } + } + catch { } + } +} diff --git a/src/Gameboard.Tests.Integration/Extensions/HttpContentExtensions.cs b/src/Gameboard.Tests.Integration/Extensions/HttpContentExtensions.cs new file mode 100644 index 00000000..cf803f42 --- /dev/null +++ b/src/Gameboard.Tests.Integration/Extensions/HttpContentExtensions.cs @@ -0,0 +1,11 @@ +using System.Net.Http.Json; + +namespace Gameboard.Tests.Integration.Extensions; + +internal static class HttpContentExtensions +{ + // public static async T DeserializeAsync(this HttpContent content) where T : class + // { + // return content.ReadFromJsonAsync(content, ) + // } +} diff --git a/src/Gameboard.Tests.Integration/Extensions/ObjectExtensions.cs b/src/Gameboard.Tests.Integration/Extensions/ObjectExtensions.cs new file mode 100644 index 00000000..a3e51afe --- /dev/null +++ b/src/Gameboard.Tests.Integration/Extensions/ObjectExtensions.cs @@ -0,0 +1,11 @@ +using System.Net.Mime; +using System.Text; +using System.Text.Json; + +namespace Gameboard.Tests.Integration.Extensions; + +internal static class ObjectExtensions +{ + public static StringContent ToStringContent(this object obj) + => new StringContent(JsonSerializer.Serialize(obj), Encoding.UTF8, MediaTypeNames.Application.Json); +} diff --git a/src/Gameboard.Tests.Integration/Extensions/ServiceCollectionExtensions.cs b/src/Gameboard.Tests.Integration/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..626addcf --- /dev/null +++ b/src/Gameboard.Tests.Integration/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,27 @@ +namespace Gameboard.Tests.Integration.Extensions; + +internal static class ServiceCollectionExtensions +{ + public static void RemoveService(this IServiceCollection services) where I : class + { + var existingService = FindService(services); + if (existingService != null) services.Remove(existingService); + } + + public static void ReplaceService(this IServiceCollection services) where I : class where C : class, I + { + RemoveService(services); + services.AddSingleton(); + } + + public static void ReplaceService(this IServiceCollection services, C replacement) where I : class where C : class, I + { + RemoveService(services); + services.AddSingleton(replacement); + } + + private static ServiceDescriptor? FindService(IServiceCollection services) where T : class + { + return services.SingleOrDefault(d => d.ServiceType == typeof(T)); + } +} diff --git a/src/Gameboard.Tests.Integration/Features/Games/GameControllerTests.cs b/src/Gameboard.Tests.Integration/Features/Games/GameControllerTests.cs index 93516c66..b5ee214a 100644 --- a/src/Gameboard.Tests.Integration/Features/Games/GameControllerTests.cs +++ b/src/Gameboard.Tests.Integration/Features/Games/GameControllerTests.cs @@ -1,18 +1,27 @@ using System.Net.Http.Json; +using System.Net.Mime; +using System.Text; +using System.Text.Json; using Gameboard.Api; +using Gameboard.Tests.Integration.Extensions; using Gameboard.Tests.Integration.Fixtures; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Options; namespace Gameboard.Tests.Integration; public class GameControllerTests : IClassFixture> { private readonly HttpClient _http; + private readonly IOptions _jsonOptions; private readonly TestWebApplicationFactory _appFactory; public GameControllerTests(TestWebApplicationFactory appFactory) { _appFactory = appFactory; - _http = appFactory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions + _jsonOptions = appFactory.Services.GetRequiredService>(); + _http = appFactory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); @@ -32,16 +41,17 @@ public async Task GameController_Create_ReturnsGame() GameStart = DateTimeOffset.UtcNow, GameEnd = DateTime.UtcNow + TimeSpan.FromDays(30), RegistrationOpen = DateTimeOffset.UtcNow, - RegistrationClose = DateTime.UtcNow + TimeSpan.FromDays(30) + RegistrationClose = DateTime.UtcNow + TimeSpan.FromDays(30), + RegistrationType = GameRegistrationType.Open }; // act - var response = await _http.PostAsync("/api/game", JsonContent.Create(game)); + var response = await _http.PostAsync("/api/game", game.ToStringContent()); // assert response.EnsureSuccessStatusCode(); - var responseGame = await response.Content.ReadFromJsonAsync(); + var responseGame = await response.Content.ReadFromJsonAsync(options: _jsonOptions.Value.JsonSerializerOptions); Assert.Equal(game.Name, responseGame?.Name); } } diff --git a/src/Gameboard.Tests.Integration/Fixtures/JsonSerializationOptionsFixture.cs b/src/Gameboard.Tests.Integration/Fixtures/JsonSerializationOptionsFixture.cs new file mode 100644 index 00000000..cae65597 --- /dev/null +++ b/src/Gameboard.Tests.Integration/Fixtures/JsonSerializationOptionsFixture.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Gameboard.Tests.Integration.Fixtures; + +// internal class JsonSerializationOptionsFixture +// { +// public Action JsonOptions { get; } = Gameboard.Api.Extensions.WebApplicationBuilderExtensions.ConfigureJsonOptions; +// } diff --git a/src/Gameboard.Tests.Integration/Fixtures/TestAuthenticationHandler.cs b/src/Gameboard.Tests.Integration/Fixtures/TestAuthenticationHandler.cs index c1231da7..c84dd785 100644 --- a/src/Gameboard.Tests.Integration/Fixtures/TestAuthenticationHandler.cs +++ b/src/Gameboard.Tests.Integration/Fixtures/TestAuthenticationHandler.cs @@ -11,6 +11,9 @@ internal class TestAuthenticationHandler : AuthenticationHandler options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock) : base(options, logger, encoder, clock) { @@ -38,12 +41,14 @@ protected override Task HandleAuthenticateAsync() { claims.Add(new Claim(ClaimTypes.NameIdentifier, _defaultUserId)); claims.Add(new Claim(AppConstants.SubjectClaimName, _defaultUserId)); + claims.Add(new Claim(AppConstants.ApprovedNameClaimName, "Integration Tester")); + claims.Add(new Claim(AppConstants.RoleListClaimName, "Integration Tester")); } // TODO: Add as many claims as you need here - var identity = new ClaimsIdentity(claims, Options.AuthScheme.Name); + var identity = new ClaimsIdentity(claims, AuthScheme.Name); var principal = new ClaimsPrincipal(identity); - var ticket = new AuthenticationTicket(principal, Options.AuthScheme.Name); + var ticket = new AuthenticationTicket(principal, AuthScheme.Name); var result = AuthenticateResult.Success(ticket); diff --git a/src/Gameboard.Tests.Integration/Fixtures/TestAuthenticationHandlerOptions.cs b/src/Gameboard.Tests.Integration/Fixtures/TestAuthenticationHandlerOptions.cs index c1ca5352..041fbd09 100644 --- a/src/Gameboard.Tests.Integration/Fixtures/TestAuthenticationHandlerOptions.cs +++ b/src/Gameboard.Tests.Integration/Fixtures/TestAuthenticationHandlerOptions.cs @@ -4,7 +4,5 @@ namespace Gameboard.Tests.Integration.Fixtures; internal class TestAuthenticationHandlerOptions : AuthenticationSchemeOptions { - public const string AuthenticationSchemeName = "Test"; - public AuthenticationScheme AuthScheme { get; } = new AuthenticationScheme(name: AuthenticationSchemeName, displayName: AuthenticationSchemeName, handlerType: typeof(TestAuthenticationHandler)); public string DefaultUserId { get; set; } = null!; } diff --git a/src/Gameboard.Tests.Integration/Fixtures/TestAuthorizationService.cs b/src/Gameboard.Tests.Integration/Fixtures/TestAuthorizationService.cs new file mode 100644 index 00000000..ceca6951 --- /dev/null +++ b/src/Gameboard.Tests.Integration/Fixtures/TestAuthorizationService.cs @@ -0,0 +1,17 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; + +namespace Gameboard.Tests.Integration.Fixtures; + +internal class TestAuthorizationService : IAuthorizationService +{ + public async Task AuthorizeAsync(ClaimsPrincipal user, object? resource, IEnumerable requirements) + { + return await Task.FromResult(AuthorizationResult.Success()); + } + + public async Task AuthorizeAsync(ClaimsPrincipal user, object? resource, string policyName) + { + return await Task.FromResult(AuthorizationResult.Success()); + } +} diff --git a/src/Gameboard.Tests.Integration/Fixtures/TestContainersPostgresConfig.cs b/src/Gameboard.Tests.Integration/Fixtures/TestContainersPostgresConfig.cs index d82a3615..0d23b962 100644 --- a/src/Gameboard.Tests.Integration/Fixtures/TestContainersPostgresConfig.cs +++ b/src/Gameboard.Tests.Integration/Fixtures/TestContainersPostgresConfig.cs @@ -3,39 +3,39 @@ using DotNet.Testcontainers.Containers; using Npgsql; -public sealed class TestContainersPostgresConfig : IAsyncLifetime -{ - private readonly TestcontainerDatabase testcontainers = new TestcontainersBuilder() - .WithDatabase(new PostgreSqlTestcontainerConfiguration - { - Database = "db", - Username = "postgres", - Password = "postgres", - }) - .Build(); +// public sealed class TestContainersPostgresConfig : IAsyncLifetime +// { +// private readonly TestcontainerDatabase testcontainers = new TestcontainersBuilder() +// .WithDatabase(new PostgreSqlTestcontainerConfiguration +// { +// Database = "db", +// Username = "postgres", +// Password = "postgres", +// }) +// .Build(); - [Fact] - public void ExecuteCommand() - { - using (var connection = new NpgsqlConnection(this.testcontainers.ConnectionString)) - { - using (var command = new NpgsqlCommand()) - { - connection.Open(); - command.Connection = connection; - command.CommandText = "SELECT 1"; - command.ExecuteReader(); - } - } - } +// [Fact] +// public void ExecuteCommand() +// { +// using (var connection = new NpgsqlConnection(this.testcontainers.ConnectionString)) +// { +// using (var command = new NpgsqlCommand()) +// { +// connection.Open(); +// command.Connection = connection; +// command.CommandText = "SELECT 1"; +// command.ExecuteReader(); +// } +// } +// } - public Task InitializeAsync() - { - return this.testcontainers.StartAsync(); - } +// public Task InitializeAsync() +// { +// return this.testcontainers.StartAsync(); +// } - public Task DisposeAsync() - { - return this.testcontainers.DisposeAsync().AsTask(); - } -} +// public Task DisposeAsync() +// { +// return this.testcontainers.DisposeAsync().AsTask(); +// } +// } diff --git a/src/Gameboard.Tests.Integration/Fixtures/TestWebApplicationFactory.cs b/src/Gameboard.Tests.Integration/Fixtures/TestWebApplicationFactory.cs index 798e60e3..576e0661 100644 --- a/src/Gameboard.Tests.Integration/Fixtures/TestWebApplicationFactory.cs +++ b/src/Gameboard.Tests.Integration/Fixtures/TestWebApplicationFactory.cs @@ -1,4 +1,5 @@ using Gameboard.Api.Data; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.EntityFrameworkCore; @@ -11,29 +12,19 @@ public class TestWebApplicationFactory : WebApplicationFactory { - // remove configured db context - // Remove AppDbContext - var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions)); - if (descriptor != null) services.Remove(descriptor); - - // Add DB context pointing to test container + // Add DB context (will ultimately be replaced by a testcontainer) + services.RemoveService(); services.AddDbContext(options => options.UseNpgsql("Username=postgres;Password=testing;Database=Gameboard_db_TEST);")); - // configure authorization spoof - // services.AddAuthorization(_ => - // { - // _.AddPolicy("AutomatedTest", new AuthorizationPolicyBuilder() - // .RequireAssertion(context => context.Succeed()) - // ); - // }); - + // override authentication/authorization with dummies services.Configure(options => options.DefaultUserId = _DefaultAuthenticationUserId); - - services.AddAuthentication(TestAuthenticationHandlerOptions.AuthenticationSchemeName) - .AddScheme(TestAuthenticationHandlerOptions.AuthenticationSchemeName, options => { }); + services + .AddAuthentication(defaultScheme: TestAuthenticationHandler.AuthenticationSchemeName) + .AddScheme(TestAuthenticationHandler.AuthenticationSchemeName, options => { }); + services.ReplaceService(); // Ensure schema gets created var serviceProvider = services.BuildServiceProvider(); diff --git a/src/Gameboard.Tests.Integration/Usings.cs b/src/Gameboard.Tests.Integration/Usings.cs index 5fc7fcaa..1c8d48d0 100644 --- a/src/Gameboard.Tests.Integration/Usings.cs +++ b/src/Gameboard.Tests.Integration/Usings.cs @@ -1,2 +1,3 @@ +global using Gameboard.Tests.Integration.Extensions; global using Microsoft.Extensions.DependencyInjection; global using Xunit; diff --git a/tests.integration.dockerfile b/tests.integration.dockerfile index aaf91fb3..9a760383 100644 --- a/tests.integration.dockerfile +++ b/tests.integration.dockerfile @@ -1,7 +1,7 @@ FROM mcr.microsoft.com/dotnet/sdk:7.0 AS dev ENV ASPNETCORE_URLS=http://*:5000 \ - ASPNETCORE_ENVIRONMENT=TEST + ASPNETCORE_ENVIRONMENT=Test COPY . /app From 7d69fd7e2e65d97f7fd1519ea47c299dd56cef58 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Wed, 21 Dec 2022 16:29:19 -0500 Subject: [PATCH 12/17] Fully containerized integration tests. --- .../Extensions/IMvcBuilderExtensions.cs | 27 ++++++ .../WebApplicationBuilderExtensions.cs | 31 ++----- .../UnityGames/UnityGameController.cs | 8 +- .../GameboardTestContextExtensions.cs | 29 ++++++ .../Extensions/HttpContentExtensions.cs | 11 +-- .../Features/Games/GameControllerTests.cs | 28 ++---- .../UnityGames/UnityGameControllerTests.cs | 52 +++++++++++ .../Fixtures/GameboardTestContext.cs | 90 +++++++++++++++++++ .../JsonSerializationOptionsFixture.cs | 8 -- .../Fixtures/TestContainersPostgresConfig.cs | 41 --------- .../Fixtures/TestWebApplicationFactory.cs | 39 -------- .../Gameboard.Tests.Integration.csproj | 1 + src/Gameboard.Tests.Integration/Usings.cs | 2 + .../tests.integration.dockerfile | 0 .../UnityGames/UnityGameServiceTests.cs | 2 +- .../Gameboard.Tests.Unit.csproj | 1 + src/Gameboard.Tests.Unit/Usings.cs | 1 + .../tests.unit.dockerfile | 0 18 files changed, 227 insertions(+), 144 deletions(-) create mode 100644 src/Gameboard.Api/Extensions/IMvcBuilderExtensions.cs create mode 100644 src/Gameboard.Tests.Integration/Extensions/GameboardTestContextExtensions.cs create mode 100644 src/Gameboard.Tests.Integration/Features/UnityGames/UnityGameControllerTests.cs create mode 100644 src/Gameboard.Tests.Integration/Fixtures/GameboardTestContext.cs delete mode 100644 src/Gameboard.Tests.Integration/Fixtures/JsonSerializationOptionsFixture.cs delete mode 100644 src/Gameboard.Tests.Integration/Fixtures/TestContainersPostgresConfig.cs delete mode 100644 src/Gameboard.Tests.Integration/Fixtures/TestWebApplicationFactory.cs rename tests.integration.dockerfile => src/Gameboard.Tests.Integration/tests.integration.dockerfile (100%) rename tests.unit.dockerfile => src/Gameboard.Tests.Unit/tests.unit.dockerfile (100%) diff --git a/src/Gameboard.Api/Extensions/IMvcBuilderExtensions.cs b/src/Gameboard.Api/Extensions/IMvcBuilderExtensions.cs new file mode 100644 index 00000000..bda11336 --- /dev/null +++ b/src/Gameboard.Api/Extensions/IMvcBuilderExtensions.cs @@ -0,0 +1,27 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; + +namespace Gameboard.Api.Extensions; + +public static class IMvcBuilderExtensions +{ + // this is here because i'm having a weird problem where despite calling AddGameboardJsonOptions on the test app, + // these options are not registered in services. exposing them statically until i figure that out. + public static Action BuildJsonOptions() + { + return options => + { + options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles; + options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); + options.JsonSerializerOptions.Converters.Add(new JsonDateTimeConverter()); + }; + } + + public static IMvcBuilder AddGameboardJsonOptions(this IMvcBuilder builder) + { + return builder.AddJsonOptions(BuildJsonOptions()); + } +} diff --git a/src/Gameboard.Api/Extensions/WebApplicationBuilderExtensions.cs b/src/Gameboard.Api/Extensions/WebApplicationBuilderExtensions.cs index 6d3b339f..7a91faa5 100644 --- a/src/Gameboard.Api/Extensions/WebApplicationBuilderExtensions.cs +++ b/src/Gameboard.Api/Extensions/WebApplicationBuilderExtensions.cs @@ -4,7 +4,6 @@ using System.Text.Json.Serialization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.DataProtection; -using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using ServiceStack.Text; @@ -13,14 +12,6 @@ namespace Gameboard.Api.Extensions; internal static class WebApplicationBuilderExtensions { - // exposed internally to support integration testing - public static Action ConfigureJsonOptions { get; } = (options => - { - options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles; - options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); - options.JsonSerializerOptions.Converters.Add(new JsonDateTimeConverter()); - }); - public static AppSettings BuildAppSettings(this WebApplicationBuilder builder) { var settings = builder.Configuration.Get() ?? new AppSettings(); @@ -58,27 +49,21 @@ public static void ConfigureServices(this WebApplicationBuilder builder, AppSett { var services = builder.Services; - services.AddMvc() - .AddJsonOptions(ConfigureJsonOptions); - - services.ConfigureForwarding(settings.Headers.Forwarding); + services + .AddMvc() + .AddGameboardJsonOptions(); - services.AddCors( - opt => opt.AddPolicy( - settings.Headers.Cors.Name, - settings.Headers.Cors.Build() - ) - ); + services + .ConfigureForwarding(settings.Headers.Forwarding) + .AddCors(opt => opt.AddPolicy(settings.Headers.Cors.Name, settings.Headers.Cors.Build())) + .AddCache(() => settings.Cache); if (settings.OpenApi.Enabled) services.AddSwagger(settings.Oidc, settings.OpenApi); - services.AddCache(() => settings.Cache); - services.AddDataProtection() .SetApplicationName(AppConstants.DataProtectionPurpose) - .PersistKeys(() => settings.Cache) - ; + .PersistKeys(() => settings.Cache); services.AddSignalR() .AddJsonProtocol(options => diff --git a/src/Gameboard.Api/Features/UnityGames/UnityGameController.cs b/src/Gameboard.Api/Features/UnityGames/UnityGameController.cs index d7f49b46..db0cb56f 100644 --- a/src/Gameboard.Api/Features/UnityGames/UnityGameController.cs +++ b/src/Gameboard.Api/Features/UnityGames/UnityGameController.cs @@ -4,8 +4,6 @@ using System; using System.Linq; using System.Net.Http; -using System.Net.Http.Headers; -using System.Net.Http.Json; using System.Threading; using System.Threading.Tasks; using AutoMapper; @@ -13,9 +11,7 @@ using Gameboard.Api.Features.UnityGames; using Gameboard.Api.Hubs; using Gameboard.Api.Services; -using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Caching.Distributed; @@ -26,7 +22,7 @@ namespace Gameboard.Api.Controllers; [Authorize] public class UnityGameController : _Controller { - private static SemaphoreSlim SP_CHALLENGE_DATA = new SemaphoreSlim(1, 1); + private static readonly SemaphoreSlim SP_CHALLENGE_DATA = new SemaphoreSlim(1, 1); private readonly IChallengeStore _challengeStore; private readonly ConsoleActorMap _actorMap; private readonly IGamebrainService _gamebrainService; @@ -183,7 +179,7 @@ public async Task CreateMissionEvent([FromBody] UnityMissionUpdat if (challengeEvent == null) { // this means that everything went fine, but that we've already been told the team completed this challenge - return Accepted(); + return Accepted(challengeEvent); } // this means we actually created an event, so also update player scores diff --git a/src/Gameboard.Tests.Integration/Extensions/GameboardTestContextExtensions.cs b/src/Gameboard.Tests.Integration/Extensions/GameboardTestContextExtensions.cs new file mode 100644 index 00000000..475d9da6 --- /dev/null +++ b/src/Gameboard.Tests.Integration/Extensions/GameboardTestContextExtensions.cs @@ -0,0 +1,29 @@ +using Gameboard.Api; +using Gameboard.Api.Data; + +namespace Gameboard.Tests.Integration.Extensions; + +internal static class GameboardTestContextExtensions +{ + public static async Task CreateUser(this GameboardTestContext testContext, UserRole role, string username = "integrationtester", string id = "integrationtester") + { + var user = new Api.Data.User() + { + Id = id, + Username = username, + Email = "integration@test.com", + Name = username, + ApprovedName = username, + Sponsor = "SEI", + Role = role + }; + + using (var dbContext = testContext.GetDbContext()) + { + dbContext.Users.Add(user); + await dbContext.SaveChangesAsync(); + } + + return user; + } +} diff --git a/src/Gameboard.Tests.Integration/Extensions/HttpContentExtensions.cs b/src/Gameboard.Tests.Integration/Extensions/HttpContentExtensions.cs index cf803f42..e8ff74ad 100644 --- a/src/Gameboard.Tests.Integration/Extensions/HttpContentExtensions.cs +++ b/src/Gameboard.Tests.Integration/Extensions/HttpContentExtensions.cs @@ -1,11 +1,12 @@ -using System.Net.Http.Json; +using System.Text.Json; namespace Gameboard.Tests.Integration.Extensions; internal static class HttpContentExtensions { - // public static async T DeserializeAsync(this HttpContent content) where T : class - // { - // return content.ReadFromJsonAsync(content, ) - // } + public static async Task JsonDeserializeAsync(this HttpContent content, JsonSerializerOptions opts) where T : class + { + var json = await content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(json, opts); + } } diff --git a/src/Gameboard.Tests.Integration/Features/Games/GameControllerTests.cs b/src/Gameboard.Tests.Integration/Features/Games/GameControllerTests.cs index b5ee214a..34fa1cbc 100644 --- a/src/Gameboard.Tests.Integration/Features/Games/GameControllerTests.cs +++ b/src/Gameboard.Tests.Integration/Features/Games/GameControllerTests.cs @@ -1,30 +1,16 @@ -using System.Net.Http.Json; -using System.Net.Mime; -using System.Text; -using System.Text.Json; using Gameboard.Api; -using Gameboard.Tests.Integration.Extensions; +using Gameboard.Api.Data; using Gameboard.Tests.Integration.Fixtures; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.Extensions.Options; namespace Gameboard.Tests.Integration; -public class GameControllerTests : IClassFixture> +public class GameControllerTests : IClassFixture> { - private readonly HttpClient _http; - private readonly IOptions _jsonOptions; - private readonly TestWebApplicationFactory _appFactory; + private readonly GameboardTestContext _testContext; - public GameControllerTests(TestWebApplicationFactory appFactory) + public GameControllerTests(GameboardTestContext testContext) { - _appFactory = appFactory; - _jsonOptions = appFactory.Services.GetRequiredService>(); - _http = appFactory.CreateClient(new WebApplicationFactoryClientOptions - { - AllowAutoRedirect = false - }); + _testContext = testContext; } [Fact] @@ -46,12 +32,12 @@ public async Task GameController_Create_ReturnsGame() }; // act - var response = await _http.PostAsync("/api/game", game.ToStringContent()); + var response = await _testContext.Http.PostAsync("/api/game", game.ToStringContent()); // assert response.EnsureSuccessStatusCode(); - var responseGame = await response.Content.ReadFromJsonAsync(options: _jsonOptions.Value.JsonSerializerOptions); + var responseGame = await response.Content.JsonDeserializeAsync(_testContext.GetJsonSerializerOptions()); Assert.Equal(game.Name, responseGame?.Name); } } diff --git a/src/Gameboard.Tests.Integration/Features/UnityGames/UnityGameControllerTests.cs b/src/Gameboard.Tests.Integration/Features/UnityGames/UnityGameControllerTests.cs new file mode 100644 index 00000000..642d4764 --- /dev/null +++ b/src/Gameboard.Tests.Integration/Features/UnityGames/UnityGameControllerTests.cs @@ -0,0 +1,52 @@ +using Gameboard.Api; +using Gameboard.Api.Data; +using Gameboard.Api.Features.UnityGames; + +namespace Gameboard.Tests.Integration.Features.UnityGames; + +public class UnityGameControllerTests : IClassFixture> +{ + private readonly GameboardTestContext _testContext; + + public UnityGameControllerTests(GameboardTestContext testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task UnityGameController_CreateChallenge_DoesntReturnGraderKey() + { + // arrange + var user = await _testContext.CreateUser(UserRole.Admin); + var gameId = "game"; + var playerId = "player"; + var teamId = "team"; + + var newChallenge = new NewUnityChallenge() + { + GameId = gameId, + PlayerId = playerId, + TeamId = teamId, + MaxPoints = 50, + GamespaceId = "gamespace", + Vms = new UnityGameVm[] + { + new UnityGameVm + { + Id = "vm", + Url = "google.com", + Name = "vm1" + } + } + }; + + // act + var response = await _testContext.Http.PostAsync("/api/unity/challenge", newChallenge.ToStringContent()); + response.EnsureSuccessStatusCode(); + var challenge = await response.Content.JsonDeserializeAsync(_testContext.GetJsonSerializerOptions()); + + // assert + challenge.ShouldNotBeNull(); + challenge.GraderKey.ShouldBeNull(); + } +} diff --git a/src/Gameboard.Tests.Integration/Fixtures/GameboardTestContext.cs b/src/Gameboard.Tests.Integration/Fixtures/GameboardTestContext.cs new file mode 100644 index 00000000..dfd60014 --- /dev/null +++ b/src/Gameboard.Tests.Integration/Fixtures/GameboardTestContext.cs @@ -0,0 +1,90 @@ +using System.Text.Json; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Configurations; +using DotNet.Testcontainers.Containers; +using Gameboard.Api.Extensions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; + +namespace Gameboard.Tests.Integration.Fixtures; + +public class GameboardTestContext : WebApplicationFactory, IAsyncLifetime where TProgram : class where TDbContext : DbContext +{ + private readonly string _DefaultAuthenticationUserId = "679b1757-8ca7-4816-ad1b-ae90dd1b3941"; + private readonly TestcontainerDatabase _dbContainer; + + public HttpClient Http { get; } + + public GameboardTestContext() + { + _dbContainer = new TestcontainersBuilder() + .WithDatabase(new PostgreSqlTestcontainerConfiguration + { + Database = "GameboardTestDb", + Username = "gameboard", + Password = "gameboard", + }) + .WithCleanUp(true) + .Build(); + + // start the container (see explanation below in InitializeAsync) + _dbContainer.StartAsync().Wait(); + + // create an HttpClient with the desired defaults + Http = CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Test"); + builder.ConfigureServices(services => + { + // Add DB context with connection to the container + services.RemoveService(); + services.AddDbContext(options => options.UseNpgsql(_dbContainer.ConnectionString)); + + // override authentication/authorization with dummies + services.Configure(options => options.DefaultUserId = _DefaultAuthenticationUserId); + services + .AddAuthentication(defaultScheme: TestAuthenticationHandler.AuthenticationSchemeName) + .AddScheme(TestAuthenticationHandler.AuthenticationSchemeName, options => { }); + services.ReplaceService(); + + // TODO: figure out why the json options registered in the main app's ConfigureServices aren't here + // services.AddMvc().AddGameboardJsonOptions(); + }); + } + + public TDbContext GetDbContext() + { + return Services.GetRequiredService(); + } + + public JsonSerializerOptions GetJsonSerializerOptions() + { + var defaultOptions = new Microsoft.AspNetCore.Mvc.JsonOptions(); + IMvcBuilderExtensions.BuildJsonOptions()(defaultOptions); + + return defaultOptions.JsonSerializerOptions; + } + + public async Task InitializeAsync() + { + // Would really like to do this here, but this seems to happen after ConfigureWebhost, and I need the + // connection string before that + // await _dbContainer.StartAsync(); + + // ensure database migration + await Services.GetService()!.Database.MigrateAsync(); + } + + public new async Task DisposeAsync() + { + await _dbContainer.DisposeAsync(); + } +} diff --git a/src/Gameboard.Tests.Integration/Fixtures/JsonSerializationOptionsFixture.cs b/src/Gameboard.Tests.Integration/Fixtures/JsonSerializationOptionsFixture.cs deleted file mode 100644 index cae65597..00000000 --- a/src/Gameboard.Tests.Integration/Fixtures/JsonSerializationOptionsFixture.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Microsoft.AspNetCore.Mvc; - -namespace Gameboard.Tests.Integration.Fixtures; - -// internal class JsonSerializationOptionsFixture -// { -// public Action JsonOptions { get; } = Gameboard.Api.Extensions.WebApplicationBuilderExtensions.ConfigureJsonOptions; -// } diff --git a/src/Gameboard.Tests.Integration/Fixtures/TestContainersPostgresConfig.cs b/src/Gameboard.Tests.Integration/Fixtures/TestContainersPostgresConfig.cs deleted file mode 100644 index 0d23b962..00000000 --- a/src/Gameboard.Tests.Integration/Fixtures/TestContainersPostgresConfig.cs +++ /dev/null @@ -1,41 +0,0 @@ -using DotNet.Testcontainers.Builders; -using DotNet.Testcontainers.Configurations; -using DotNet.Testcontainers.Containers; -using Npgsql; - -// public sealed class TestContainersPostgresConfig : IAsyncLifetime -// { -// private readonly TestcontainerDatabase testcontainers = new TestcontainersBuilder() -// .WithDatabase(new PostgreSqlTestcontainerConfiguration -// { -// Database = "db", -// Username = "postgres", -// Password = "postgres", -// }) -// .Build(); - -// [Fact] -// public void ExecuteCommand() -// { -// using (var connection = new NpgsqlConnection(this.testcontainers.ConnectionString)) -// { -// using (var command = new NpgsqlCommand()) -// { -// connection.Open(); -// command.Connection = connection; -// command.CommandText = "SELECT 1"; -// command.ExecuteReader(); -// } -// } -// } - -// public Task InitializeAsync() -// { -// return this.testcontainers.StartAsync(); -// } - -// public Task DisposeAsync() -// { -// return this.testcontainers.DisposeAsync().AsTask(); -// } -// } diff --git a/src/Gameboard.Tests.Integration/Fixtures/TestWebApplicationFactory.cs b/src/Gameboard.Tests.Integration/Fixtures/TestWebApplicationFactory.cs deleted file mode 100644 index 576e0661..00000000 --- a/src/Gameboard.Tests.Integration/Fixtures/TestWebApplicationFactory.cs +++ /dev/null @@ -1,39 +0,0 @@ -using Gameboard.Api.Data; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.EntityFrameworkCore; - -namespace Gameboard.Tests.Integration.Fixtures; - -public class TestWebApplicationFactory : WebApplicationFactory where TProgram : class -{ - private readonly string _DefaultAuthenticationUserId = "679b1757-8ca7-4816-ad1b-ae90dd1b3941"; - - protected override void ConfigureWebHost(IWebHostBuilder builder) - { - builder.UseEnvironment("Test"); - builder.ConfigureServices(services => - { - // Add DB context (will ultimately be replaced by a testcontainer) - services.RemoveService(); - services.AddDbContext(options => options.UseNpgsql("Username=postgres;Password=testing;Database=Gameboard_db_TEST);")); - - // override authentication/authorization with dummies - services.Configure(options => options.DefaultUserId = _DefaultAuthenticationUserId); - services - .AddAuthentication(defaultScheme: TestAuthenticationHandler.AuthenticationSchemeName) - .AddScheme(TestAuthenticationHandler.AuthenticationSchemeName, options => { }); - services.ReplaceService(); - - // Ensure schema gets created - var serviceProvider = services.BuildServiceProvider(); - using (var scope = serviceProvider.CreateScope()) - { - var scopedServices = scope.ServiceProvider; - var context = scopedServices.GetRequiredService(); - context.Database.EnsureCreated(); - } - }); - } -} diff --git a/src/Gameboard.Tests.Integration/Gameboard.Tests.Integration.csproj b/src/Gameboard.Tests.Integration/Gameboard.Tests.Integration.csproj index d55b8b9b..931d058b 100644 --- a/src/Gameboard.Tests.Integration/Gameboard.Tests.Integration.csproj +++ b/src/Gameboard.Tests.Integration/Gameboard.Tests.Integration.csproj @@ -23,6 +23,7 @@ + diff --git a/src/Gameboard.Tests.Integration/Usings.cs b/src/Gameboard.Tests.Integration/Usings.cs index 1c8d48d0..21b635ae 100644 --- a/src/Gameboard.Tests.Integration/Usings.cs +++ b/src/Gameboard.Tests.Integration/Usings.cs @@ -1,3 +1,5 @@ global using Gameboard.Tests.Integration.Extensions; +global using Gameboard.Tests.Integration.Fixtures; global using Microsoft.Extensions.DependencyInjection; +global using Shouldly; global using Xunit; diff --git a/tests.integration.dockerfile b/src/Gameboard.Tests.Integration/tests.integration.dockerfile similarity index 100% rename from tests.integration.dockerfile rename to src/Gameboard.Tests.Integration/tests.integration.dockerfile diff --git a/src/Gameboard.Tests.Unit/Features/UnityGames/UnityGameServiceTests.cs b/src/Gameboard.Tests.Unit/Features/UnityGames/UnityGameServiceTests.cs index c703b0f7..28e60d84 100644 --- a/src/Gameboard.Tests.Unit/Features/UnityGames/UnityGameServiceTests.cs +++ b/src/Gameboard.Tests.Unit/Features/UnityGames/UnityGameServiceTests.cs @@ -39,6 +39,6 @@ public void GetMissionCompleteDefinitionString_Matches_IsMissionComplete() var match = regex.IsMatch(missionCompleteString); // assert - Assert.True(match); + match.ShouldBe(true); } } diff --git a/src/Gameboard.Tests.Unit/Gameboard.Tests.Unit.csproj b/src/Gameboard.Tests.Unit/Gameboard.Tests.Unit.csproj index 544b48c0..8f737ca6 100644 --- a/src/Gameboard.Tests.Unit/Gameboard.Tests.Unit.csproj +++ b/src/Gameboard.Tests.Unit/Gameboard.Tests.Unit.csproj @@ -12,6 +12,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/Gameboard.Tests.Unit/Usings.cs b/src/Gameboard.Tests.Unit/Usings.cs index 1946184b..27c49838 100644 --- a/src/Gameboard.Tests.Unit/Usings.cs +++ b/src/Gameboard.Tests.Unit/Usings.cs @@ -1,3 +1,4 @@ global using NSubstitute; global using NSubstitute.Extensions; +global using Shouldly; global using Xunit; diff --git a/tests.unit.dockerfile b/src/Gameboard.Tests.Unit/tests.unit.dockerfile similarity index 100% rename from tests.unit.dockerfile rename to src/Gameboard.Tests.Unit/tests.unit.dockerfile From 1c2018c2a654cc41c89b490439ac0d529e4bc4f1 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Thu, 22 Dec 2022 12:08:16 -0500 Subject: [PATCH 13/17] Additional configuration for containerized integration tests and mocking of auth --- .../Data/Store/Store[TEntity].cs | 10 +-- .../WebApplicationBuilderExtensions.cs | 1 + .../Features/FeatureStartupExtensions.cs | 1 + src/Gameboard.Api/Gameboard.Api.csproj.user | 7 ++ src/Gameboard.Api/Services/GuidService.cs | 16 ++++ .../{Structure => Services}/NameService.cs | 4 +- .../GameboardDbContextExtensions.cs | 61 +++++++++++++++ .../GameboardTestContextExtensions.cs | 56 +++++++++----- .../Extensions/ServiceCollectionExtensions.cs | 36 ++++++++- .../Features/Games/GameControllerTests.cs | 11 +-- .../UnityGames/UnityGameControllerTests.cs | 75 ++++++++++--------- .../Fixtures/GameboardTestContext.cs | 31 ++++---- .../Fixtures/TestAuthenticationHandler.cs | 33 ++------ .../Fixtures/TestClaimsTransformation.cs | 13 ++++ 14 files changed, 243 insertions(+), 112 deletions(-) create mode 100644 src/Gameboard.Api/Gameboard.Api.csproj.user create mode 100644 src/Gameboard.Api/Services/GuidService.cs rename src/Gameboard.Api/{Structure => Services}/NameService.cs (96%) create mode 100644 src/Gameboard.Tests.Integration/Extensions/GameboardDbContextExtensions.cs create mode 100644 src/Gameboard.Tests.Integration/Fixtures/TestClaimsTransformation.cs diff --git a/src/Gameboard.Api/Data/Store/Store[TEntity].cs b/src/Gameboard.Api/Data/Store/Store[TEntity].cs index 7573d7c5..6ec43215 100644 --- a/src/Gameboard.Api/Data/Store/Store[TEntity].cs +++ b/src/Gameboard.Api/Data/Store/Store[TEntity].cs @@ -1,21 +1,19 @@ // Copyright 2021 Carnegie Mellon University. All Rights Reserved. // Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. +using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using Microsoft.EntityFrameworkCore; using Gameboard.Api.Data.Abstractions; -using System; -using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; namespace Gameboard.Api.Data { public class Store : IStore where TEntity : class, IEntity { - public Store( - GameboardDbContext dbContext - ) + public Store(GameboardDbContext dbContext) { DbContext = dbContext; DbSet = dbContext.Set(); diff --git a/src/Gameboard.Api/Extensions/WebApplicationBuilderExtensions.cs b/src/Gameboard.Api/Extensions/WebApplicationBuilderExtensions.cs index 7a91faa5..20aec3dc 100644 --- a/src/Gameboard.Api/Extensions/WebApplicationBuilderExtensions.cs +++ b/src/Gameboard.Api/Extensions/WebApplicationBuilderExtensions.cs @@ -2,6 +2,7 @@ using System.IO; using System.Text.Json; using System.Text.Json.Serialization; +using Gameboard.Api.Services; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.Configuration; diff --git a/src/Gameboard.Api/Features/FeatureStartupExtensions.cs b/src/Gameboard.Api/Features/FeatureStartupExtensions.cs index f67ecb0e..0e5ff5ad 100644 --- a/src/Gameboard.Api/Features/FeatureStartupExtensions.cs +++ b/src/Gameboard.Api/Features/FeatureStartupExtensions.cs @@ -41,6 +41,7 @@ public static IServiceCollection AddGameboardServices(this IServiceCollection se services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddSingleton(); foreach (var t in Assembly .GetExecutingAssembly() diff --git a/src/Gameboard.Api/Gameboard.Api.csproj.user b/src/Gameboard.Api/Gameboard.Api.csproj.user new file mode 100644 index 00000000..e4f6e716 --- /dev/null +++ b/src/Gameboard.Api/Gameboard.Api.csproj.user @@ -0,0 +1,7 @@ + + + + MvcControllerEmptyScaffolder + root/Common/MVC/Controller + + \ No newline at end of file diff --git a/src/Gameboard.Api/Services/GuidService.cs b/src/Gameboard.Api/Services/GuidService.cs new file mode 100644 index 00000000..b6fc1ec7 --- /dev/null +++ b/src/Gameboard.Api/Services/GuidService.cs @@ -0,0 +1,16 @@ +using System; + +namespace Gameboard.Api.Services; + +internal interface IGuidService +{ + string GetGuid(); +} + +internal class GuidService : IGuidService +{ + public string GetGuid() + { + return Guid.NewGuid().ToString("n"); + } +} diff --git a/src/Gameboard.Api/Structure/NameService.cs b/src/Gameboard.Api/Services/NameService.cs similarity index 96% rename from src/Gameboard.Api/Structure/NameService.cs rename to src/Gameboard.Api/Services/NameService.cs index c296187d..192d7f14 100644 --- a/src/Gameboard.Api/Structure/NameService.cs +++ b/src/Gameboard.Api/Services/NameService.cs @@ -9,7 +9,7 @@ using AutoMapper; using Microsoft.Extensions.Logging; -namespace Gameboard.Api +namespace Gameboard.Api.Services { public interface INameService { @@ -18,7 +18,7 @@ public interface INameService void AppendList(List list); } - public class NameService: INameService + public class NameService : INameService { private readonly ILogger _logger; private readonly CoreOptions _options; diff --git a/src/Gameboard.Tests.Integration/Extensions/GameboardDbContextExtensions.cs b/src/Gameboard.Tests.Integration/Extensions/GameboardDbContextExtensions.cs new file mode 100644 index 00000000..bb84691f --- /dev/null +++ b/src/Gameboard.Tests.Integration/Extensions/GameboardDbContextExtensions.cs @@ -0,0 +1,61 @@ +using Gameboard.Api; +using Gameboard.Api.Data; + +namespace Gameboard.Tests.Integration.Extensions; + +public static class GameboardDbContextExtensions +{ + public static async Task CreateGame(this GameboardDbContext dbContext, Action? gameBuilder = null) + { + var game = new Api.Data.Game() + { + Id = Guid.NewGuid().ToString("n"), + Name = "Test game", + Competition = "Test competition", + Season = "1", + Track = "Individual", + Sponsor = "Test Sponsor", + GameStart = DateTimeOffset.UtcNow, + GameEnd = DateTime.UtcNow + TimeSpan.FromDays(30), + RegistrationOpen = DateTimeOffset.UtcNow, + RegistrationClose = DateTime.UtcNow + TimeSpan.FromDays(30), + RegistrationType = GameRegistrationType.Open + }; + + if (gameBuilder != null) + gameBuilder(game); + + dbContext.Games.Add(game); + await dbContext.SaveChangesAsync(); + + return game; + } + + //public static async Task CreatePlayer(this GameboardDbContext dbContext, Action? playerBuilder = null) + //{ + // var player = new Api.Data.Player + // { + + // } + //} + + public static async Task CreateUser(this GameboardDbContext dbContext, UserRole role) + { + var user = new Api.Data.User() + { + Id = Guid.NewGuid().ToString("n"), + Username = "integrationtester", + Email = "integration@test.com", + Name = "integrationtester", + ApprovedName = "integrationtester", + Sponsor = "SEI", + Role = role + }; + + dbContext.Users.Add(user); + await dbContext.SaveChangesAsync(); + + return user; + } +} + diff --git a/src/Gameboard.Tests.Integration/Extensions/GameboardTestContextExtensions.cs b/src/Gameboard.Tests.Integration/Extensions/GameboardTestContextExtensions.cs index 475d9da6..18b4ce7b 100644 --- a/src/Gameboard.Tests.Integration/Extensions/GameboardTestContextExtensions.cs +++ b/src/Gameboard.Tests.Integration/Extensions/GameboardTestContextExtensions.cs @@ -1,29 +1,47 @@ -using Gameboard.Api; +using System.Net.Http.Headers; using Gameboard.Api.Data; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; namespace Gameboard.Tests.Integration.Extensions; internal static class GameboardTestContextExtensions { - public static async Task CreateUser(this GameboardTestContext testContext, UserRole role, string username = "integrationtester", string id = "integrationtester") + public static WebApplicationFactory WithAuthentication(this GameboardTestContext testContext, string userId = "integrationtester") { - var user = new Api.Data.User() - { - Id = id, - Username = username, - Email = "integration@test.com", - Name = username, - ApprovedName = username, - Sponsor = "SEI", - Role = role - }; + return testContext + .WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + // spoof authentication + services + .Configure(options => options.DefaultUserId = userId) + .AddAuthentication(TestAuthenticationHandler.AuthenticationSchemeName) + .AddScheme(TestAuthenticationHandler.AuthenticationSchemeName, options => { }); - using (var dbContext = testContext.GetDbContext()) - { - dbContext.Users.Add(user); - await dbContext.SaveChangesAsync(); - } + // and authorization + services.AddAuthorization(config => + { + config.DefaultPolicy = new AuthorizationPolicyBuilder(config.DefaultPolicy) + .AddAuthenticationSchemes(TestAuthenticationHandler.AuthenticationSchemeName) + .Build(); + }); + }); + }); + } + + public static HttpClient CreateHttpClientWithAuth(this GameboardTestContext testContext) + { + var client = testContext + .WithAuthentication() + .CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); - return user; + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationHandler.AuthenticationSchemeName); + return client; } -} +} \ No newline at end of file diff --git a/src/Gameboard.Tests.Integration/Extensions/ServiceCollectionExtensions.cs b/src/Gameboard.Tests.Integration/Extensions/ServiceCollectionExtensions.cs index 626addcf..2f673ad8 100644 --- a/src/Gameboard.Tests.Integration/Extensions/ServiceCollectionExtensions.cs +++ b/src/Gameboard.Tests.Integration/Extensions/ServiceCollectionExtensions.cs @@ -8,9 +8,31 @@ public static void RemoveService(this IServiceCollection services) where I : if (existingService != null) services.Remove(existingService); } - public static void ReplaceService(this IServiceCollection services) where I : class where C : class, I + public static void RemoveServices(this IServiceCollection services) where I : class { - RemoveService(services); + var existingServices = FindServices(services); + + foreach (var service in existingServices) + services.Remove(service); + } + + public static void RemoveService(this IServiceCollection services) where I : class where C : class + { + var existingService = FindService(services); + if (existingService != null) services.Remove(existingService); + } + + public static void ReplaceService(this IServiceCollection services, bool allowMultipleReplace = false) where I : class where C : class, I + { + if (allowMultipleReplace) + { + RemoveServices(services); + } + else + { + RemoveService(services); + } + services.AddSingleton(); } @@ -24,4 +46,14 @@ public static void ReplaceService(this IServiceCollection services, C repl { return services.SingleOrDefault(d => d.ServiceType == typeof(T)); } + + private static ServiceDescriptor? FindService(IServiceCollection services) where I : class where C : class + { + return services.SingleOrDefault(d => d.ServiceType == typeof(I) && d.ImplementationType == typeof(C)); + } + + private static ServiceDescriptor[] FindServices(IServiceCollection services) where I : class + { + return services.Where(d => d.ServiceType == typeof(I)).ToArray(); + } } diff --git a/src/Gameboard.Tests.Integration/Features/Games/GameControllerTests.cs b/src/Gameboard.Tests.Integration/Features/Games/GameControllerTests.cs index 34fa1cbc..9db9cc1d 100644 --- a/src/Gameboard.Tests.Integration/Features/Games/GameControllerTests.cs +++ b/src/Gameboard.Tests.Integration/Features/Games/GameControllerTests.cs @@ -1,14 +1,13 @@ using Gameboard.Api; using Gameboard.Api.Data; -using Gameboard.Tests.Integration.Fixtures; namespace Gameboard.Tests.Integration; -public class GameControllerTests : IClassFixture> +public class GameControllerTests : IClassFixture> { - private readonly GameboardTestContext _testContext; + private readonly GameboardTestContext _testContext; - public GameControllerTests(GameboardTestContext testContext) + public GameControllerTests(GameboardTestContext testContext) { _testContext = testContext; } @@ -31,8 +30,10 @@ public async Task GameController_Create_ReturnsGame() RegistrationType = GameRegistrationType.Open }; + var client = _testContext.CreateClient(); + // act - var response = await _testContext.Http.PostAsync("/api/game", game.ToStringContent()); + var response = await client.PostAsync("/api/game", game.ToStringContent()); // assert response.EnsureSuccessStatusCode(); diff --git a/src/Gameboard.Tests.Integration/Features/UnityGames/UnityGameControllerTests.cs b/src/Gameboard.Tests.Integration/Features/UnityGames/UnityGameControllerTests.cs index 642d4764..50965d04 100644 --- a/src/Gameboard.Tests.Integration/Features/UnityGames/UnityGameControllerTests.cs +++ b/src/Gameboard.Tests.Integration/Features/UnityGames/UnityGameControllerTests.cs @@ -1,14 +1,12 @@ -using Gameboard.Api; using Gameboard.Api.Data; -using Gameboard.Api.Features.UnityGames; namespace Gameboard.Tests.Integration.Features.UnityGames; -public class UnityGameControllerTests : IClassFixture> +public class UnityGameControllerTests : IClassFixture> { - private readonly GameboardTestContext _testContext; + private readonly GameboardTestContext _testContext; - public UnityGameControllerTests(GameboardTestContext testContext) + public UnityGameControllerTests(GameboardTestContext testContext) { _testContext = testContext; } @@ -16,37 +14,40 @@ public UnityGameControllerTests(GameboardTestContext(_testContext.GetJsonSerializerOptions()); - - // assert - challenge.ShouldNotBeNull(); - challenge.GraderKey.ShouldBeNull(); + //// arrange + //var game = await _testContext.GetDbContext().CreateGame(); + //var user = await _testContext.CreateUser(UserRole.Admin); + //var playerId = "player"; + //var teamId = "team"; + + //var newChallenge = new NewUnityChallenge() + //{ + // GameId = game.Id, + // PlayerId = playerId, + // TeamId = teamId, + // MaxPoints = 50, + // GamespaceId = "gamespace", + // Vms = new UnityGameVm[] + // { + // new UnityGameVm + // { + // Id = "vm", + // Url = "google.com", + // Name = "vm1" + // } + // } + //}; + + //var httpClient = _testContext.WithAuthentication().CreateClient(); + + //// act + //var response = await httpClient.PostAsync("/api/unity/challenge", newChallenge.ToStringContent()); + //response.EnsureSuccessStatusCode(); + //var challenge = await response.Content.JsonDeserializeAsync(_testContext.GetJsonSerializerOptions()); + + //// assert + //challenge.ShouldNotBeNull(); + //challenge.GraderKey.ShouldBeNull(); + true.ShouldBeTrue(); } } diff --git a/src/Gameboard.Tests.Integration/Fixtures/GameboardTestContext.cs b/src/Gameboard.Tests.Integration/Fixtures/GameboardTestContext.cs index dfd60014..f55dc834 100644 --- a/src/Gameboard.Tests.Integration/Fixtures/GameboardTestContext.cs +++ b/src/Gameboard.Tests.Integration/Fixtures/GameboardTestContext.cs @@ -2,21 +2,22 @@ using DotNet.Testcontainers.Builders; using DotNet.Testcontainers.Configurations; using DotNet.Testcontainers.Containers; +using Gameboard.Api.Data; using Gameboard.Api.Extensions; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; using Microsoft.EntityFrameworkCore; namespace Gameboard.Tests.Integration.Fixtures; -public class GameboardTestContext : WebApplicationFactory, IAsyncLifetime where TProgram : class where TDbContext : DbContext +public class GameboardTestContext : WebApplicationFactory, IAsyncLifetime where TDbContext : GameboardDbContext { - private readonly string _DefaultAuthenticationUserId = "679b1757-8ca7-4816-ad1b-ae90dd1b3941"; + private readonly string _DefaultAuthenticationUserId = "admin"; private readonly TestcontainerDatabase _dbContainer; - public HttpClient Http { get; } - public GameboardTestContext() { _dbContainer = new TestcontainersBuilder() @@ -31,30 +32,28 @@ public GameboardTestContext() // start the container (see explanation below in InitializeAsync) _dbContainer.StartAsync().Wait(); - - // create an HttpClient with the desired defaults - Http = CreateClient(new WebApplicationFactoryClientOptions - { - AllowAutoRedirect = false - }); } protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.UseEnvironment("Test"); - builder.ConfigureServices(services => + builder.ConfigureTestServices(services => { // Add DB context with connection to the container services.RemoveService(); services.AddDbContext(options => options.UseNpgsql(_dbContainer.ConnectionString)); - // override authentication/authorization with dummies - services.Configure(options => options.DefaultUserId = _DefaultAuthenticationUserId); - services - .AddAuthentication(defaultScheme: TestAuthenticationHandler.AuthenticationSchemeName) - .AddScheme(TestAuthenticationHandler.AuthenticationSchemeName, options => { }); + // Some services (like the stores) in Gameboard inject with GameboardDbContext rather than DbContext, + // so we need to add an additional binding for them + services.AddTransient(); + + // add user claims transformation that lets them all through + services.ReplaceService(allowMultipleReplace: true); + + // dummy authorization service that lets everything through services.ReplaceService(); + // TODO: figure out why the json options registered in the main app's ConfigureServices aren't here // services.AddMvc().AddGameboardJsonOptions(); }); diff --git a/src/Gameboard.Tests.Integration/Fixtures/TestAuthenticationHandler.cs b/src/Gameboard.Tests.Integration/Fixtures/TestAuthenticationHandler.cs index c84dd785..9d10deff 100644 --- a/src/Gameboard.Tests.Integration/Fixtures/TestAuthenticationHandler.cs +++ b/src/Gameboard.Tests.Integration/Fixtures/TestAuthenticationHandler.cs @@ -22,36 +22,19 @@ public TestAuthenticationHandler(IOptionsMonitor HandleAuthenticateAsync() { - var claims = new List { new Claim(ClaimTypes.Name, "Test user") }; - - // Extract User ID from the request headers if it exists, - // otherwise use the default User ID from the options. - if (Context.Request.Headers.TryGetValue("UserId", out var userIds)) - { - if (userIds.Count() == 1 && userIds[0] != null && userIds[0] != string.Empty) - { - claims.Add(new Claim(ClaimTypes.NameIdentifier, userIds[0]!)); - } - else - { - throw new CantResolveAuthUserIdException(userIds); - } - } - else + var claims = new List { - claims.Add(new Claim(ClaimTypes.NameIdentifier, _defaultUserId)); - claims.Add(new Claim(AppConstants.SubjectClaimName, _defaultUserId)); - claims.Add(new Claim(AppConstants.ApprovedNameClaimName, "Integration Tester")); - claims.Add(new Claim(AppConstants.RoleListClaimName, "Integration Tester")); - } + new Claim(ClaimTypes.Name, "Integration tester"), + new Claim(ClaimTypes.NameIdentifier, _defaultUserId), + new Claim(AppConstants.SubjectClaimName, _defaultUserId), + new Claim(AppConstants.ApprovedNameClaimName, "Integration Tester"), + new Claim(AppConstants.RoleListClaimName, UserRole.Admin.ToString()) + }; - // TODO: Add as many claims as you need here var identity = new ClaimsIdentity(claims, AuthScheme.Name); var principal = new ClaimsPrincipal(identity); var ticket = new AuthenticationTicket(principal, AuthScheme.Name); - var result = AuthenticateResult.Success(ticket); - - return Task.FromResult(result); + return Task.FromResult(AuthenticateResult.Success(ticket)); } } diff --git a/src/Gameboard.Tests.Integration/Fixtures/TestClaimsTransformation.cs b/src/Gameboard.Tests.Integration/Fixtures/TestClaimsTransformation.cs new file mode 100644 index 00000000..64cc9ae6 --- /dev/null +++ b/src/Gameboard.Tests.Integration/Fixtures/TestClaimsTransformation.cs @@ -0,0 +1,13 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; + +namespace Gameboard.Tests.Integration.Fixtures +{ + internal class TestClaimsTransformation : IClaimsTransformation + { + public Task TransformAsync(ClaimsPrincipal principal) + { + return Task.FromResult(principal); + } + } +} From c4136dc8184887289b720e0926b0866bc2920260 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Wed, 4 Jan 2023 12:49:53 -0500 Subject: [PATCH 14/17] Finalized initial test infrastructure / add additional test cases. --- .github/workflows/main.yml | 6 +- src/Gameboard.Api/Data/Entities/Challenge.cs | 1 + src/Gameboard.Api/Data/Entities/Player.cs | 6 +- .../Features/Player/PlayerController.cs | 6 +- .../Features/Player/PlayerMapper.cs | 5 + .../Features/Player/PlayerValidator.cs | 1 - .../ViewModels/PlayerUpdatedViewModel.cs | 12 ++ .../Features/UnityGames/IUnityGameService.cs | 1 + .../UnityGames/UnityGameController.cs | 6 +- .../Features/UnityGames/UnityGameMaps.cs | 13 ++ .../Features/UnityGames/UnityGameService.cs | 2 +- .../UnityGames/UnityGameViewModels.cs | 6 + src/Gameboard.Api/Features/_Controller.cs | 8 - src/Gameboard.Api/Program.cs | 4 + .../GameboardIntegrationTestException.cs | 11 -- .../GameboardDbContextExtensions.cs | 61 -------- .../Extensions/HttpContentExtensions.cs | 12 -- .../UnityGames/UnityGameControllerTests.cs | 53 ------- .../Fixtures/Builders/GameBuilder.cs | 15 ++ .../GameboardIntegrationTestExceptions.cs | 17 ++ .../GameboardDbContextExtensions.cs | 120 +++++++++++++++ .../GameboardTestContextExtensions.cs | 2 +- .../HttpResponseMessageExtensions.cs | 22 +++ .../Extensions/ObjectExtensions.cs | 4 +- .../Extensions/ServiceCollectionExtensions.cs | 2 +- .../Fixtures/GameboardTestContext.cs | 14 +- .../Fixtures/TestIds.cs | 7 + .../Gameboard.Tests.Integration.csproj | 1 - .../Features/Games/GameControllerTests.cs | 5 +- .../Features/Players/PlayerControllerTests.cs | 59 +++++++ .../UnityGames/UnityGameControllerTests.cs | 53 +++++++ src/Gameboard.Tests.Integration/Usings.cs | 5 +- .../UnityGames/UnityGameServiceTests.cs | 44 ------ .../Fixtures/GameboardAutoDataAttribute.cs | 9 ++ .../Gameboard.Tests.Unit.csproj | 7 +- .../Stubbing/StubDefinition.cs | 0 .../Stubbing/StubFactory.cs | 22 --- .../Stubbing/StubbingException.cs | 10 -- .../Features/Player/PlayerServiceTests.cs | 21 +++ .../UnityGames/UnityGameServiceTests.cs | 21 +++ .../Tests/Features/_ControllerTests.cs | 145 ++++++++++++++++++ .../Tests/Structure/CorsPolicyOptionsTests.cs | 24 +++ src/Gameboard.Tests.Unit/Usings.cs | 8 +- 43 files changed, 595 insertions(+), 256 deletions(-) create mode 100644 src/Gameboard.Api/Features/Player/ViewModels/PlayerUpdatedViewModel.cs create mode 100644 src/Gameboard.Api/Features/UnityGames/UnityGameMaps.cs create mode 100644 src/Gameboard.Api/Features/UnityGames/UnityGameViewModels.cs delete mode 100644 src/Gameboard.Tests.Integration/Exceptions/GameboardIntegrationTestException.cs delete mode 100644 src/Gameboard.Tests.Integration/Extensions/GameboardDbContextExtensions.cs delete mode 100644 src/Gameboard.Tests.Integration/Extensions/HttpContentExtensions.cs delete mode 100644 src/Gameboard.Tests.Integration/Features/UnityGames/UnityGameControllerTests.cs create mode 100644 src/Gameboard.Tests.Integration/Fixtures/Builders/GameBuilder.cs create mode 100644 src/Gameboard.Tests.Integration/Fixtures/Exceptions.cs/GameboardIntegrationTestExceptions.cs create mode 100644 src/Gameboard.Tests.Integration/Fixtures/Extensions/GameboardDbContextExtensions.cs rename src/Gameboard.Tests.Integration/{ => Fixtures}/Extensions/GameboardTestContextExtensions.cs (97%) create mode 100644 src/Gameboard.Tests.Integration/Fixtures/Extensions/HttpResponseMessageExtensions.cs rename src/Gameboard.Tests.Integration/{ => Fixtures}/Extensions/ObjectExtensions.cs (65%) rename src/Gameboard.Tests.Integration/{ => Fixtures}/Extensions/ServiceCollectionExtensions.cs (97%) create mode 100644 src/Gameboard.Tests.Integration/Fixtures/TestIds.cs rename src/Gameboard.Tests.Integration/{ => Tests}/Features/Games/GameControllerTests.cs (87%) create mode 100644 src/Gameboard.Tests.Integration/Tests/Features/Players/PlayerControllerTests.cs create mode 100644 src/Gameboard.Tests.Integration/Tests/Features/UnityGames/UnityGameControllerTests.cs delete mode 100644 src/Gameboard.Tests.Unit/Features/UnityGames/UnityGameServiceTests.cs create mode 100644 src/Gameboard.Tests.Unit/Fixtures/GameboardAutoDataAttribute.cs delete mode 100644 src/Gameboard.Tests.Unit/Stubbing/StubDefinition.cs delete mode 100644 src/Gameboard.Tests.Unit/Stubbing/StubFactory.cs delete mode 100644 src/Gameboard.Tests.Unit/Stubbing/StubbingException.cs create mode 100644 src/Gameboard.Tests.Unit/Tests/Features/Player/PlayerServiceTests.cs create mode 100644 src/Gameboard.Tests.Unit/Tests/Features/UnityGames/UnityGameServiceTests.cs create mode 100644 src/Gameboard.Tests.Unit/Tests/Features/_ControllerTests.cs create mode 100644 src/Gameboard.Tests.Unit/Tests/Structure/CorsPolicyOptionsTests.cs diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 18f39d58..d0db2a3f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -23,8 +23,10 @@ jobs: run: dotnet restore - name: Build run: dotnet build -c Release --no-restore - - name: Run tests - run: dotnet test --no-restore + - name: Run unit tests + run: dotnet test src/Gameboard.Tests.Unit --no-restore + - name: Run integration tests + run: dotnet test src/Gameboard.Tests.Integration --no-restore build: runs-on: ubuntu-latest diff --git a/src/Gameboard.Api/Data/Entities/Challenge.cs b/src/Gameboard.Api/Data/Entities/Challenge.cs index 19c30eba..d960294e 100644 --- a/src/Gameboard.Api/Data/Entities/Challenge.cs +++ b/src/Gameboard.Api/Data/Entities/Challenge.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json.Serialization; namespace Gameboard.Api.Data { diff --git a/src/Gameboard.Api/Data/Entities/Player.cs b/src/Gameboard.Api/Data/Entities/Player.cs index 93ad96eb..385b6545 100644 --- a/src/Gameboard.Api/Data/Entities/Player.cs +++ b/src/Gameboard.Api/Data/Entities/Player.cs @@ -34,11 +34,11 @@ public class Player : IEntity public Game Game { get; set; } public ICollection Challenges { get; set; } = new List(); [NotMapped] public bool IsManager => Role == PlayerRole.Manager; - [NotMapped] public bool IsLive => + [NotMapped] + public bool IsLive => SessionBegin > DateTimeOffset.MinValue && SessionBegin < DateTimeOffset.UtcNow && - SessionEnd > DateTimeOffset.UtcNow - ; + SessionEnd > DateTimeOffset.UtcNow; // Control delete behavior with relationships public ICollection Feedback { get; set; } = new List(); diff --git a/src/Gameboard.Api/Features/Player/PlayerController.cs b/src/Gameboard.Api/Features/Player/PlayerController.cs index d300b4a0..fdf9e496 100644 --- a/src/Gameboard.Api/Features/Player/PlayerController.cs +++ b/src/Gameboard.Api/Features/Player/PlayerController.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using AutoMapper; +using Gameboard.Api.Features.Player; using Gameboard.Api.Hubs; using Gameboard.Api.Services; using Gameboard.Api.Validators; @@ -79,7 +80,7 @@ public async Task Retrieve([FromRoute] string id) /// [HttpPut("api/player")] [Authorize] - public async Task Update([FromBody] ChangedPlayer model) + public async Task Update([FromBody] ChangedPlayer model) { await Validate(model); @@ -93,6 +94,8 @@ public async Task Update([FromBody] ChangedPlayer model) await Hub.Clients.Group(result.TeamId).TeamEvent( new HubEvent(Mapper.Map(result), EventAction.Updated) ); + + return Mapper.Map(result); } /// @@ -349,6 +352,5 @@ private async Task IsSelf(string playerId) { return await PlayerService.MapId(playerId) == Actor.Id; } - } } diff --git a/src/Gameboard.Api/Features/Player/PlayerMapper.cs b/src/Gameboard.Api/Features/Player/PlayerMapper.cs index 0e797b7c..75fbab79 100644 --- a/src/Gameboard.Api/Features/Player/PlayerMapper.cs +++ b/src/Gameboard.Api/Features/Player/PlayerMapper.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using AutoMapper; +using Gameboard.Api.Features.Player; namespace Gameboard.Api.Services { @@ -51,6 +52,10 @@ public PlayerMapper() CreateMap(); CreateMap(); + + CreateMap() + .ForMember(vm => vm.PreUpdateName, opts => opts.MapFrom(p => p.ApprovedName)) + .ForMember(vm => vm.PreUpdateName, opts => opts.Ignore()); } } } diff --git a/src/Gameboard.Api/Features/Player/PlayerValidator.cs b/src/Gameboard.Api/Features/Player/PlayerValidator.cs index 834e8165..5b638bb8 100644 --- a/src/Gameboard.Api/Features/Player/PlayerValidator.cs +++ b/src/Gameboard.Api/Features/Player/PlayerValidator.cs @@ -3,7 +3,6 @@ using System.Threading.Tasks; using Gameboard.Api.Data.Abstractions; -using Microsoft.EntityFrameworkCore; namespace Gameboard.Api.Validators { diff --git a/src/Gameboard.Api/Features/Player/ViewModels/PlayerUpdatedViewModel.cs b/src/Gameboard.Api/Features/Player/ViewModels/PlayerUpdatedViewModel.cs new file mode 100644 index 00000000..7eaf9aa0 --- /dev/null +++ b/src/Gameboard.Api/Features/Player/ViewModels/PlayerUpdatedViewModel.cs @@ -0,0 +1,12 @@ +using System; + +namespace Gameboard.Api.Features.Player; + +public class PlayerUpdatedViewModel +{ + public string Id { get; set; } + public string ApprovedName { get; set; } + public string PreUpdateName { get; set; } + public string Name { get; set; } + public string NameStatus { get; set; } +} \ No newline at end of file diff --git a/src/Gameboard.Api/Features/UnityGames/IUnityGameService.cs b/src/Gameboard.Api/Features/UnityGames/IUnityGameService.cs index d46eed89..a8a31b5f 100644 --- a/src/Gameboard.Api/Features/UnityGames/IUnityGameService.cs +++ b/src/Gameboard.Api/Features/UnityGames/IUnityGameService.cs @@ -12,5 +12,6 @@ public interface IUnityGameService bool IsUnityGame(Game game); bool IsUnityGame(Data.Game game); Regex GetMissionCompleteEventRegex(); + string GetMissionCompleteDefinitionString(string missionId); string GetUnityModeString(); } diff --git a/src/Gameboard.Api/Features/UnityGames/UnityGameController.cs b/src/Gameboard.Api/Features/UnityGames/UnityGameController.cs index db0cb56f..84821b9d 100644 --- a/src/Gameboard.Api/Features/UnityGames/UnityGameController.cs +++ b/src/Gameboard.Api/Features/UnityGames/UnityGameController.cs @@ -9,6 +9,7 @@ using AutoMapper; using Gameboard.Api.Data.Abstractions; using Gameboard.Api.Features.UnityGames; +using Gameboard.Api.Features.UnityGames.ViewModels; using Gameboard.Api.Hubs; using Gameboard.Api.Services; using Microsoft.AspNetCore.Authorization; @@ -102,7 +103,7 @@ public async Task UndeployUnitySpace([FromQuery] string gid, [FromRoute] /// ChallengeEvent [Authorize] [HttpPost("api/unity/challenge")] - public async Task CreateChallenge([FromBody] NewUnityChallenge model) + public async Task> CreateChallenge([FromBody] NewUnityChallenge model) { AuthorizeAny( () => _gameService.UserIsTeamPlayer(Actor.Id, model.GameId, model.TeamId).Result @@ -161,8 +162,7 @@ await _hub.Clients .Group(model.TeamId) .ChallengeEvent(new HubEvent(_mapper.Map(challengeData), EventAction.Updated)); - return challengeData; - // return Ok(); + return Ok(_mapper.Map(challengeData)); } [HttpPost("api/unity/mission-update")] diff --git a/src/Gameboard.Api/Features/UnityGames/UnityGameMaps.cs b/src/Gameboard.Api/Features/UnityGames/UnityGameMaps.cs new file mode 100644 index 00000000..faa4f7be --- /dev/null +++ b/src/Gameboard.Api/Features/UnityGames/UnityGameMaps.cs @@ -0,0 +1,13 @@ +using AutoMapper; +using Gameboard.Api.Features.UnityGames.ViewModels; + +namespace Gameboard.Api.Features.UnityGames; + +public class UnityGameMaps : Profile +{ + public UnityGameMaps() + { + CreateMap() + .ForMember(vm => vm.GraderKey, opt => opt.Ignore()); + } +} \ No newline at end of file diff --git a/src/Gameboard.Api/Features/UnityGames/UnityGameService.cs b/src/Gameboard.Api/Features/UnityGames/UnityGameService.cs index f9050b13..1dd3a557 100644 --- a/src/Gameboard.Api/Features/UnityGames/UnityGameService.cs +++ b/src/Gameboard.Api/Features/UnityGames/UnityGameService.cs @@ -237,7 +237,7 @@ public Regex GetMissionCompleteEventRegex() } // if you change this, change `GetMissionCompleteEventRegex` above - internal string GetMissionCompleteDefinitionString(string missionId) + public string GetMissionCompleteDefinitionString(string missionId) => $"[complete:{missionId}]"; internal bool IsMissionComplete(IEnumerable events, string missionId) diff --git a/src/Gameboard.Api/Features/UnityGames/UnityGameViewModels.cs b/src/Gameboard.Api/Features/UnityGames/UnityGameViewModels.cs new file mode 100644 index 00000000..f6a821ff --- /dev/null +++ b/src/Gameboard.Api/Features/UnityGames/UnityGameViewModels.cs @@ -0,0 +1,6 @@ +namespace Gameboard.Api.Features.UnityGames.ViewModels; + +internal class UnityGameChallengeViewModel : Gameboard.Api.Data.Challenge +{ + +} \ No newline at end of file diff --git a/src/Gameboard.Api/Features/_Controller.cs b/src/Gameboard.Api/Features/_Controller.cs index 0a339a71..dcc3a5b8 100644 --- a/src/Gameboard.Api/Features/_Controller.cs +++ b/src/Gameboard.Api/Features/_Controller.cs @@ -86,12 +86,4 @@ protected void AuthorizeAny(params Func[] requirements) } } - - public enum AuthRequirement - { - None, - Self, - RegistrarOrSelf, - DirectorOrSelf - } } diff --git a/src/Gameboard.Api/Program.cs b/src/Gameboard.Api/Program.cs index 0abf7ac2..3682a8b0 100644 --- a/src/Gameboard.Api/Program.cs +++ b/src/Gameboard.Api/Program.cs @@ -3,10 +3,14 @@ using System; using System.Linq; +using System.Runtime.CompilerServices; using Gameboard.Api.Extensions; using Gameboard.Api.Structure; using Microsoft.AspNetCore.Builder; +// expose internals for unit test mocking +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")] + // set logging properties Console.Title = "Gameboard"; diff --git a/src/Gameboard.Tests.Integration/Exceptions/GameboardIntegrationTestException.cs b/src/Gameboard.Tests.Integration/Exceptions/GameboardIntegrationTestException.cs deleted file mode 100644 index 6af32bad..00000000 --- a/src/Gameboard.Tests.Integration/Exceptions/GameboardIntegrationTestException.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Microsoft.Extensions.Primitives; - -internal class GameboardIntegrationTestException : Exception -{ - public GameboardIntegrationTestException(string message) : base(message) { } -} - -internal class CantResolveAuthUserIdException : GameboardIntegrationTestException -{ - public CantResolveAuthUserIdException(StringValues userIds) : base($"Couldn't resolve the authenticated user ID. Found: {userIds}") { } -} diff --git a/src/Gameboard.Tests.Integration/Extensions/GameboardDbContextExtensions.cs b/src/Gameboard.Tests.Integration/Extensions/GameboardDbContextExtensions.cs deleted file mode 100644 index bb84691f..00000000 --- a/src/Gameboard.Tests.Integration/Extensions/GameboardDbContextExtensions.cs +++ /dev/null @@ -1,61 +0,0 @@ -using Gameboard.Api; -using Gameboard.Api.Data; - -namespace Gameboard.Tests.Integration.Extensions; - -public static class GameboardDbContextExtensions -{ - public static async Task CreateGame(this GameboardDbContext dbContext, Action? gameBuilder = null) - { - var game = new Api.Data.Game() - { - Id = Guid.NewGuid().ToString("n"), - Name = "Test game", - Competition = "Test competition", - Season = "1", - Track = "Individual", - Sponsor = "Test Sponsor", - GameStart = DateTimeOffset.UtcNow, - GameEnd = DateTime.UtcNow + TimeSpan.FromDays(30), - RegistrationOpen = DateTimeOffset.UtcNow, - RegistrationClose = DateTime.UtcNow + TimeSpan.FromDays(30), - RegistrationType = GameRegistrationType.Open - }; - - if (gameBuilder != null) - gameBuilder(game); - - dbContext.Games.Add(game); - await dbContext.SaveChangesAsync(); - - return game; - } - - //public static async Task CreatePlayer(this GameboardDbContext dbContext, Action? playerBuilder = null) - //{ - // var player = new Api.Data.Player - // { - - // } - //} - - public static async Task CreateUser(this GameboardDbContext dbContext, UserRole role) - { - var user = new Api.Data.User() - { - Id = Guid.NewGuid().ToString("n"), - Username = "integrationtester", - Email = "integration@test.com", - Name = "integrationtester", - ApprovedName = "integrationtester", - Sponsor = "SEI", - Role = role - }; - - dbContext.Users.Add(user); - await dbContext.SaveChangesAsync(); - - return user; - } -} - diff --git a/src/Gameboard.Tests.Integration/Extensions/HttpContentExtensions.cs b/src/Gameboard.Tests.Integration/Extensions/HttpContentExtensions.cs deleted file mode 100644 index e8ff74ad..00000000 --- a/src/Gameboard.Tests.Integration/Extensions/HttpContentExtensions.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Text.Json; - -namespace Gameboard.Tests.Integration.Extensions; - -internal static class HttpContentExtensions -{ - public static async Task JsonDeserializeAsync(this HttpContent content, JsonSerializerOptions opts) where T : class - { - var json = await content.ReadAsStringAsync(); - return JsonSerializer.Deserialize(json, opts); - } -} diff --git a/src/Gameboard.Tests.Integration/Features/UnityGames/UnityGameControllerTests.cs b/src/Gameboard.Tests.Integration/Features/UnityGames/UnityGameControllerTests.cs deleted file mode 100644 index 50965d04..00000000 --- a/src/Gameboard.Tests.Integration/Features/UnityGames/UnityGameControllerTests.cs +++ /dev/null @@ -1,53 +0,0 @@ -using Gameboard.Api.Data; - -namespace Gameboard.Tests.Integration.Features.UnityGames; - -public class UnityGameControllerTests : IClassFixture> -{ - private readonly GameboardTestContext _testContext; - - public UnityGameControllerTests(GameboardTestContext testContext) - { - _testContext = testContext; - } - - [Fact] - public async Task UnityGameController_CreateChallenge_DoesntReturnGraderKey() - { - //// arrange - //var game = await _testContext.GetDbContext().CreateGame(); - //var user = await _testContext.CreateUser(UserRole.Admin); - //var playerId = "player"; - //var teamId = "team"; - - //var newChallenge = new NewUnityChallenge() - //{ - // GameId = game.Id, - // PlayerId = playerId, - // TeamId = teamId, - // MaxPoints = 50, - // GamespaceId = "gamespace", - // Vms = new UnityGameVm[] - // { - // new UnityGameVm - // { - // Id = "vm", - // Url = "google.com", - // Name = "vm1" - // } - // } - //}; - - //var httpClient = _testContext.WithAuthentication().CreateClient(); - - //// act - //var response = await httpClient.PostAsync("/api/unity/challenge", newChallenge.ToStringContent()); - //response.EnsureSuccessStatusCode(); - //var challenge = await response.Content.JsonDeserializeAsync(_testContext.GetJsonSerializerOptions()); - - //// assert - //challenge.ShouldNotBeNull(); - //challenge.GraderKey.ShouldBeNull(); - true.ShouldBeTrue(); - } -} diff --git a/src/Gameboard.Tests.Integration/Fixtures/Builders/GameBuilder.cs b/src/Gameboard.Tests.Integration/Fixtures/Builders/GameBuilder.cs new file mode 100644 index 00000000..2681b908 --- /dev/null +++ b/src/Gameboard.Tests.Integration/Fixtures/Builders/GameBuilder.cs @@ -0,0 +1,15 @@ +using Gameboard.Api.Data; + +namespace Gameboard.Tests.Integration.Fixtures; + +public class GameBuilder +{ + public Action? Configure { get; set; } + public bool WithChallengeSpec { get; set; } = false; + public Action? ConfigureChallengeSpec { get; set; } + + public static GameBuilder WithConfig(Action configure) + { + return new GameBuilder { Configure = configure }; + } +} \ No newline at end of file diff --git a/src/Gameboard.Tests.Integration/Fixtures/Exceptions.cs/GameboardIntegrationTestExceptions.cs b/src/Gameboard.Tests.Integration/Fixtures/Exceptions.cs/GameboardIntegrationTestExceptions.cs new file mode 100644 index 00000000..e0408741 --- /dev/null +++ b/src/Gameboard.Tests.Integration/Fixtures/Exceptions.cs/GameboardIntegrationTestExceptions.cs @@ -0,0 +1,17 @@ +namespace Gameboard.Tests.Integration.Fixtures; + +internal class GameboardIntegrationTestException : Exception +{ + public GameboardIntegrationTestException(string message, Exception? innerException = null) : base(message, innerException) { } +} + +internal class ResponseContentDeserializationTypeFailure : GameboardIntegrationTestException +{ + public ResponseContentDeserializationTypeFailure(string responseContent) + : base($"Attempted to deserialize a response body to type {typeof(T).Name} but failed. Response body: \"{responseContent}\"") { } +} + +internal class ResponseContentEmpty : GameboardIntegrationTestException +{ + public ResponseContentEmpty() : base("Deserialization failed because the body of the response is empty.") { } +} \ No newline at end of file diff --git a/src/Gameboard.Tests.Integration/Fixtures/Extensions/GameboardDbContextExtensions.cs b/src/Gameboard.Tests.Integration/Fixtures/Extensions/GameboardDbContextExtensions.cs new file mode 100644 index 00000000..9817103d --- /dev/null +++ b/src/Gameboard.Tests.Integration/Fixtures/Extensions/GameboardDbContextExtensions.cs @@ -0,0 +1,120 @@ +using Gameboard.Api.Data; + +namespace Gameboard.Tests.Integration.Extensions; + +public static class GameboardDbContextExtensions +{ + public static async Task CreateGame(this GameboardDbContext dbContext, GameBuilder? gameBuilder = null) + { + var game = GenerateGame(gameBuilder?.Configure); + + if (gameBuilder?.WithChallengeSpec == true) + { + var challengeSpec = GenerateChallengeSpec(game.Id, gameBuilder.ConfigureChallengeSpec); + dbContext.ChallengeSpecs.Add(challengeSpec); + } + + dbContext.Games.Add(game); + await dbContext.SaveChangesAsync(); + + return game; + } + + public static async Task CreatePlayer(this GameboardDbContext dbContext, Action? playerBuilder = null, GameBuilder? gameBuilder = null, Action? userBuilder = null) + { + var player = new Api.Data.Player + { + Id = TestIds.Generate(), + TeamId = TestIds.Generate(), + ApprovedName = "Integration Test Player", + Sponsor = "Integration Test Sponsor", + Role = Gameboard.Api.PlayerRole.Manager, + User = GenerateUser(Gameboard.Api.UserRole.Member, userBuilder) + }; + + playerBuilder?.Invoke(player); + + // TODO: yuck + if (string.IsNullOrWhiteSpace(player.GameId) && player.Game == null) + { + player.Game = await CreateGame(dbContext, gameBuilder); + } + + dbContext.Players.Add(player); + await dbContext.SaveChangesAsync(); + + return player; + } + + public static async Task CreateUser(this GameboardDbContext dbContext, Gameboard.Api.UserRole role, Action? userBuilder = null) + { + var user = GenerateUser(role, userBuilder); + + dbContext.Users.Add(user); + await dbContext.SaveChangesAsync(); + + return user; + } + + private static Api.Data.ChallengeSpec GenerateChallengeSpec(string gameId, Action? challengeSpecBuilder = null) + { + var spec = new Api.Data.ChallengeSpec + { + Id = TestIds.Generate(), + GameId = gameId, + Name = "Integration Test Challenge Spec", + AverageDeploySeconds = 1, + Points = 50, + X = 0, + Y = 0, + R = 1 + }; + + challengeSpecBuilder?.Invoke(spec); + + return spec; + } + + private static Api.Data.Game GenerateGame(Action? gameBuilder = null) + { + var game = new Api.Data.Game() + { + Id = TestIds.Generate(), + Name = "Test game", + Competition = "Test competition", + Season = "1", + Track = "Individual", + Sponsor = "Test Sponsor", + GameStart = DateTimeOffset.UtcNow, + GameEnd = DateTime.UtcNow + TimeSpan.FromDays(30), + RegistrationOpen = DateTimeOffset.UtcNow, + RegistrationClose = DateTime.UtcNow + TimeSpan.FromDays(30), + RegistrationType = Gameboard.Api.GameRegistrationType.Open, + }; + + gameBuilder?.Invoke(game); + + return game; + } + + private static Api.Data.User GenerateUser(Gameboard.Api.UserRole role, Action? userBuilder = null) + { + var user = new Api.Data.User() + { + Id = TestIds.Generate(), + Username = "integrationtester", + Email = "integration@test.com", + Name = "integrationtester", + ApprovedName = "integrationtester", + Sponsor = "SEI", + Role = role + }; + + userBuilder?.Invoke(user); + + return user; + } + + // private static IEnumerable GeneratePlayers +} + diff --git a/src/Gameboard.Tests.Integration/Extensions/GameboardTestContextExtensions.cs b/src/Gameboard.Tests.Integration/Fixtures/Extensions/GameboardTestContextExtensions.cs similarity index 97% rename from src/Gameboard.Tests.Integration/Extensions/GameboardTestContextExtensions.cs rename to src/Gameboard.Tests.Integration/Fixtures/Extensions/GameboardTestContextExtensions.cs index 18b4ce7b..fca9ade8 100644 --- a/src/Gameboard.Tests.Integration/Extensions/GameboardTestContextExtensions.cs +++ b/src/Gameboard.Tests.Integration/Fixtures/Extensions/GameboardTestContextExtensions.cs @@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; -namespace Gameboard.Tests.Integration.Extensions; +namespace Gameboard.Tests.Integration.Fixtures; internal static class GameboardTestContextExtensions { diff --git a/src/Gameboard.Tests.Integration/Fixtures/Extensions/HttpResponseMessageExtensions.cs b/src/Gameboard.Tests.Integration/Fixtures/Extensions/HttpResponseMessageExtensions.cs new file mode 100644 index 00000000..aefd5430 --- /dev/null +++ b/src/Gameboard.Tests.Integration/Fixtures/Extensions/HttpResponseMessageExtensions.cs @@ -0,0 +1,22 @@ +using System.Text.Json; + +namespace Gameboard.Tests.Integration.Fixtures; + +public static class HttpResponseMessageExtensions +{ + public static async Task WithContentDeserializedAs(this Task responseTask, JsonSerializerOptions jsonSerializerOptions) where T : class + { + var response = await responseTask; + response.EnsureSuccessStatusCode(); + + var rawResponse = await response.Content.ReadAsStringAsync(); + var deserialized = JsonSerializer.Deserialize(rawResponse, jsonSerializerOptions); + + if (deserialized != null) + { + return deserialized; + } + + throw new ResponseContentDeserializationTypeFailure(rawResponse); + } +} \ No newline at end of file diff --git a/src/Gameboard.Tests.Integration/Extensions/ObjectExtensions.cs b/src/Gameboard.Tests.Integration/Fixtures/Extensions/ObjectExtensions.cs similarity index 65% rename from src/Gameboard.Tests.Integration/Extensions/ObjectExtensions.cs rename to src/Gameboard.Tests.Integration/Fixtures/Extensions/ObjectExtensions.cs index a3e51afe..52b43a5b 100644 --- a/src/Gameboard.Tests.Integration/Extensions/ObjectExtensions.cs +++ b/src/Gameboard.Tests.Integration/Fixtures/Extensions/ObjectExtensions.cs @@ -2,10 +2,10 @@ using System.Text; using System.Text.Json; -namespace Gameboard.Tests.Integration.Extensions; +namespace Gameboard.Tests.Integration.Fixtures; internal static class ObjectExtensions { - public static StringContent ToStringContent(this object obj) + public static StringContent ToJsonBody(this object obj) => new StringContent(JsonSerializer.Serialize(obj), Encoding.UTF8, MediaTypeNames.Application.Json); } diff --git a/src/Gameboard.Tests.Integration/Extensions/ServiceCollectionExtensions.cs b/src/Gameboard.Tests.Integration/Fixtures/Extensions/ServiceCollectionExtensions.cs similarity index 97% rename from src/Gameboard.Tests.Integration/Extensions/ServiceCollectionExtensions.cs rename to src/Gameboard.Tests.Integration/Fixtures/Extensions/ServiceCollectionExtensions.cs index 2f673ad8..93e6e95f 100644 --- a/src/Gameboard.Tests.Integration/Extensions/ServiceCollectionExtensions.cs +++ b/src/Gameboard.Tests.Integration/Fixtures/Extensions/ServiceCollectionExtensions.cs @@ -1,4 +1,4 @@ -namespace Gameboard.Tests.Integration.Extensions; +namespace Gameboard.Tests.Integration.Fixtures; internal static class ServiceCollectionExtensions { diff --git a/src/Gameboard.Tests.Integration/Fixtures/GameboardTestContext.cs b/src/Gameboard.Tests.Integration/Fixtures/GameboardTestContext.cs index f55dc834..cd1f94a4 100644 --- a/src/Gameboard.Tests.Integration/Fixtures/GameboardTestContext.cs +++ b/src/Gameboard.Tests.Integration/Fixtures/GameboardTestContext.cs @@ -15,7 +15,6 @@ namespace Gameboard.Tests.Integration.Fixtures; public class GameboardTestContext : WebApplicationFactory, IAsyncLifetime where TDbContext : GameboardDbContext { - private readonly string _DefaultAuthenticationUserId = "admin"; private readonly TestcontainerDatabase _dbContainer; public GameboardTestContext() @@ -23,15 +22,13 @@ public GameboardTestContext() _dbContainer = new TestcontainersBuilder() .WithDatabase(new PostgreSqlTestcontainerConfiguration { - Database = "GameboardTestDb", + Database = "GameboardIntegrationTestDb", Username = "gameboard", Password = "gameboard", }) + .WithImage("postgres:latest") .WithCleanUp(true) .Build(); - - // start the container (see explanation below in InitializeAsync) - _dbContainer.StartAsync().Wait(); } protected override void ConfigureWebHost(IWebHostBuilder builder) @@ -51,9 +48,9 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) services.ReplaceService(allowMultipleReplace: true); // dummy authorization service that lets everything through + // TODO: we may need to make an easy way to configure this to enable tests which rely on authorization services.ReplaceService(); - // TODO: figure out why the json options registered in the main app's ConfigureServices aren't here // services.AddMvc().AddGameboardJsonOptions(); }); @@ -74,9 +71,8 @@ public JsonSerializerOptions GetJsonSerializerOptions() public async Task InitializeAsync() { - // Would really like to do this here, but this seems to happen after ConfigureWebhost, and I need the - // connection string before that - // await _dbContainer.StartAsync(); + // start up our testcontainer with the db + await _dbContainer.StartAsync(); // ensure database migration await Services.GetService()!.Database.MigrateAsync(); diff --git a/src/Gameboard.Tests.Integration/Fixtures/TestIds.cs b/src/Gameboard.Tests.Integration/Fixtures/TestIds.cs new file mode 100644 index 00000000..48136110 --- /dev/null +++ b/src/Gameboard.Tests.Integration/Fixtures/TestIds.cs @@ -0,0 +1,7 @@ +namespace Gameboard.Tests.Integration.Fixtures; + +internal static class TestIds +{ + public static string Generate() + => Guid.NewGuid().ToString("n"); +} \ No newline at end of file diff --git a/src/Gameboard.Tests.Integration/Gameboard.Tests.Integration.csproj b/src/Gameboard.Tests.Integration/Gameboard.Tests.Integration.csproj index 931d058b..36f83548 100644 --- a/src/Gameboard.Tests.Integration/Gameboard.Tests.Integration.csproj +++ b/src/Gameboard.Tests.Integration/Gameboard.Tests.Integration.csproj @@ -16,7 +16,6 @@ all - runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/Gameboard.Tests.Integration/Features/Games/GameControllerTests.cs b/src/Gameboard.Tests.Integration/Tests/Features/Games/GameControllerTests.cs similarity index 87% rename from src/Gameboard.Tests.Integration/Features/Games/GameControllerTests.cs rename to src/Gameboard.Tests.Integration/Tests/Features/Games/GameControllerTests.cs index 9db9cc1d..616b2c2e 100644 --- a/src/Gameboard.Tests.Integration/Features/Games/GameControllerTests.cs +++ b/src/Gameboard.Tests.Integration/Tests/Features/Games/GameControllerTests.cs @@ -33,12 +33,11 @@ public async Task GameController_Create_ReturnsGame() var client = _testContext.CreateClient(); // act - var response = await client.PostAsync("/api/game", game.ToStringContent()); + var response = await client.PostAsync("/api/game", game.ToJsonBody()); + var responseGame = await client.PostAsync("/api/game", game.ToJsonBody()).WithContentDeserializedAs(_testContext.GetJsonSerializerOptions()); // assert response.EnsureSuccessStatusCode(); - - var responseGame = await response.Content.JsonDeserializeAsync(_testContext.GetJsonSerializerOptions()); Assert.Equal(game.Name, responseGame?.Name); } } diff --git a/src/Gameboard.Tests.Integration/Tests/Features/Players/PlayerControllerTests.cs b/src/Gameboard.Tests.Integration/Tests/Features/Players/PlayerControllerTests.cs new file mode 100644 index 00000000..e0feaa42 --- /dev/null +++ b/src/Gameboard.Tests.Integration/Tests/Features/Players/PlayerControllerTests.cs @@ -0,0 +1,59 @@ +using Gameboard.Api; +using Gameboard.Api.Data; + +namespace Gameboard.Tests.Integration; + +public class PlayerControllerTests : IClassFixture> +{ + private readonly GameboardTestContext _testContext; + + public PlayerControllerTests(GameboardTestContext testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Update_WhenNameNotUniqueInGame_SetsNameNotUnique() + { + // given + var game = await _testContext.GetDbContext().CreateGame(GameBuilder.WithConfig(g => g.Id = "same game")); + var playerA = await _testContext.GetDbContext().CreatePlayer + ( + p => + { + p.Name = "A"; + p.GameId = game.Id; + p.TeamId = "team A"; + } + ); + + var playerB = await _testContext.GetDbContext().CreatePlayer + ( + p => + { + p.Name = "B"; + p.GameId = game.Id; + p.TeamId = "team B"; + } + ); + + var httpClient = _testContext.WithAuthentication().CreateClient(); + var sutParams = new ChangedPlayer + { + Id = playerB.Id, + // tries to update `playerB` to have the same name as `playerA` + Name = playerA.Name, + ApprovedName = playerB.ApprovedName, + Sponsor = "sponsor", + Role = PlayerRole.Member + }; + + // when + var updatedPlayer = await httpClient + .PutAsync("/api/player", sutParams.ToJsonBody()) + .WithContentDeserializedAs(_testContext.GetJsonSerializerOptions()); + + // assert + updatedPlayer.NameStatus.ShouldBe(AppConstants.NameStatusNotUnique); + } +} \ No newline at end of file diff --git a/src/Gameboard.Tests.Integration/Tests/Features/UnityGames/UnityGameControllerTests.cs b/src/Gameboard.Tests.Integration/Tests/Features/UnityGames/UnityGameControllerTests.cs new file mode 100644 index 00000000..b3e0dda8 --- /dev/null +++ b/src/Gameboard.Tests.Integration/Tests/Features/UnityGames/UnityGameControllerTests.cs @@ -0,0 +1,53 @@ +using Gameboard.Api.Data; +using Gameboard.Api.Features.UnityGames; + +namespace Gameboard.Tests.Integration; + +public class UnityGameControllerTests : IClassFixture> +{ + private readonly GameboardTestContext _testContext; + + public UnityGameControllerTests(GameboardTestContext testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task UnityGameController_CreateChallenge_DoesntReturnGraderKey() + { + // arrange + var player = await _testContext.GetDbContext().CreatePlayer + ( + gameBuilder: new GameBuilder { WithChallengeSpec = true } + ); + + var newChallenge = new NewUnityChallenge() + { + GameId = player.Game.Id, + PlayerId = player.Id, + TeamId = player.TeamId, + MaxPoints = 50, + GamespaceId = "gamespace", + Vms = new UnityGameVm[] + { + new UnityGameVm + { + Id = "vm", + Url = "google.com", + Name = "vm1" + } + } + }; + + var httpClient = _testContext.WithAuthentication().CreateClient(); + + // act + var challenge = await httpClient + .PostAsync("/api/unity/challenge", newChallenge.ToJsonBody()) + .WithContentDeserializedAs(_testContext.GetJsonSerializerOptions()); + + // assert + challenge.ShouldNotBeNull(); + challenge.GraderKey.ShouldBeNull(); + } +} diff --git a/src/Gameboard.Tests.Integration/Usings.cs b/src/Gameboard.Tests.Integration/Usings.cs index 21b635ae..c27f3461 100644 --- a/src/Gameboard.Tests.Integration/Usings.cs +++ b/src/Gameboard.Tests.Integration/Usings.cs @@ -1,5 +1,6 @@ -global using Gameboard.Tests.Integration.Extensions; -global using Gameboard.Tests.Integration.Fixtures; global using Microsoft.Extensions.DependencyInjection; global using Shouldly; global using Xunit; + +global using Gameboard.Tests.Integration.Extensions; +global using Gameboard.Tests.Integration.Fixtures; \ No newline at end of file diff --git a/src/Gameboard.Tests.Unit/Features/UnityGames/UnityGameServiceTests.cs b/src/Gameboard.Tests.Unit/Features/UnityGames/UnityGameServiceTests.cs deleted file mode 100644 index 28e60d84..00000000 --- a/src/Gameboard.Tests.Unit/Features/UnityGames/UnityGameServiceTests.cs +++ /dev/null @@ -1,44 +0,0 @@ -using AutoMapper; -using Gameboard.Api; -using Gameboard.Api.Data.Abstractions; -using Gameboard.Api.Features.UnityGames; -using Gameboard.Api.Services; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using TopoMojo.Api.Client; - -namespace Gameboard.Test; - -public class UnityGameServiceTests -{ - [Fact] - public void GetMissionCompleteDefinitionString_Matches_IsMissionComplete() - { - // arrange - var serviceProvider = new ServiceCollection() - .AddLogging() - .BuildServiceProvider(); - - var factory = serviceProvider.GetService(); - var logger = factory?.CreateLogger(); - - var service = new UnityGameService( - logger, - Substitute.For(), - Substitute.For(), - Substitute.For(), - Substitute.For(), - Substitute.For(), - Substitute.For() - ); - var regex = service.GetMissionCompleteEventRegex(); - - - // act - var missionCompleteString = service.GetMissionCompleteDefinitionString("secret-mission"); - var match = regex.IsMatch(missionCompleteString); - - // assert - match.ShouldBe(true); - } -} diff --git a/src/Gameboard.Tests.Unit/Fixtures/GameboardAutoDataAttribute.cs b/src/Gameboard.Tests.Unit/Fixtures/GameboardAutoDataAttribute.cs new file mode 100644 index 00000000..a6add9bb --- /dev/null +++ b/src/Gameboard.Tests.Unit/Fixtures/GameboardAutoDataAttribute.cs @@ -0,0 +1,9 @@ +namespace Gameboard.Tests.Unit.Fixtures; + +public class GameboardAutoDataAttribute : AutoDataAttribute +{ + private static IFixture FIXTURE = new Fixture() + .Customize(new AutoFakeItEasyCustomization()); + + public GameboardAutoDataAttribute() : base(() => FIXTURE) { } +} \ No newline at end of file diff --git a/src/Gameboard.Tests.Unit/Gameboard.Tests.Unit.csproj b/src/Gameboard.Tests.Unit/Gameboard.Tests.Unit.csproj index 8f737ca6..defd46d7 100644 --- a/src/Gameboard.Tests.Unit/Gameboard.Tests.Unit.csproj +++ b/src/Gameboard.Tests.Unit/Gameboard.Tests.Unit.csproj @@ -4,14 +4,17 @@ net7.0 enable enable - Gameboard.Test.Unit + Gameboard.Tests.Unit false + + + + - diff --git a/src/Gameboard.Tests.Unit/Stubbing/StubDefinition.cs b/src/Gameboard.Tests.Unit/Stubbing/StubDefinition.cs deleted file mode 100644 index e69de29b..00000000 diff --git a/src/Gameboard.Tests.Unit/Stubbing/StubFactory.cs b/src/Gameboard.Tests.Unit/Stubbing/StubFactory.cs deleted file mode 100644 index e43e6302..00000000 --- a/src/Gameboard.Tests.Unit/Stubbing/StubFactory.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Reflection; -using Gameboard.Api.Controllers; - -namespace Gameboard.Test.Stubbing; - -internal static class StubFactory -{ - // public static T? Stub() where T : class - // { - // var constructors = typeof(T).GetConstructors(BindingFlags.CreateInstance | BindingFlags.NonPublic | BindingFlags.Public); - - // if (constructors.Count() == 0) - // throw new ConstructorMatchException(); - - // return default(T); - // } - - // public static void Thing() - // { - // typeof(UnityGameController).COnstructo - // } -} diff --git a/src/Gameboard.Tests.Unit/Stubbing/StubbingException.cs b/src/Gameboard.Tests.Unit/Stubbing/StubbingException.cs deleted file mode 100644 index fbc99369..00000000 --- a/src/Gameboard.Tests.Unit/Stubbing/StubbingException.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Reflection; - -namespace Gameboard.Test.Stubbing; - -// internal class FailedConstructorResolution : Exception where T : class -// { -// public FailedConstructorResolution(IEnumerable candidateConstructors) { } -// } - -// internal class AmbiguousConstructorResolution diff --git a/src/Gameboard.Tests.Unit/Tests/Features/Player/PlayerServiceTests.cs b/src/Gameboard.Tests.Unit/Tests/Features/Player/PlayerServiceTests.cs new file mode 100644 index 00000000..cb9e60b6 --- /dev/null +++ b/src/Gameboard.Tests.Unit/Tests/Features/Player/PlayerServiceTests.cs @@ -0,0 +1,21 @@ +using Gameboard.Api; +using Gameboard.Api.Services; + +namespace Gameboard.Tests.Unit; + +public class PlayerServiceTests +{ + [Theory, GameboardAutoData] + public async Task Standings_WhenGameIdIsEmpty_ReturnsEmptyArray(IFixture fixture) + { + // arrange + var sut = fixture.Create(); + var filterParams = A.Fake(); + + // act + var result = await sut.Standings(filterParams); + + // assert + result.ShouldBe(new Standing[] { }); + } +} \ No newline at end of file diff --git a/src/Gameboard.Tests.Unit/Tests/Features/UnityGames/UnityGameServiceTests.cs b/src/Gameboard.Tests.Unit/Tests/Features/UnityGames/UnityGameServiceTests.cs new file mode 100644 index 00000000..95d18a18 --- /dev/null +++ b/src/Gameboard.Tests.Unit/Tests/Features/UnityGames/UnityGameServiceTests.cs @@ -0,0 +1,21 @@ +using Gameboard.Api.Features.UnityGames; +using Gameboard.Tests.Unit.Fixtures; + +namespace Gameboard.Tests.Unit; + +public class UnityGameServiceTests +{ + [Theory, GameboardAutoData] + public void GetMissionCompleteDefinitionString_Matches_MissionCompleteRegex(IFixture fixture) + { + // arrange + var sut = fixture.Create(); + var regex = sut.GetMissionCompleteEventRegex(); + + // act + var missionCompleteString = sut.GetMissionCompleteDefinitionString(fixture.Create()); + + // assert + missionCompleteString.ShouldMatch(regex.ToString()); + } +} diff --git a/src/Gameboard.Tests.Unit/Tests/Features/_ControllerTests.cs b/src/Gameboard.Tests.Unit/Tests/Features/_ControllerTests.cs new file mode 100644 index 00000000..709accfc --- /dev/null +++ b/src/Gameboard.Tests.Unit/Tests/Features/_ControllerTests.cs @@ -0,0 +1,145 @@ +using Gameboard.Api; +using Gameboard.Api.Controllers; +using Gameboard.Api.Validators; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Logging; + +namespace Gameboard.Tests.Unit; + +// _Controller is inherited by every api controller in GameboardApi. To access some of its implementation for testing, we subclass it here. + +public class _ControllerTestable : _Controller +{ + public _ControllerTestable(ILogger logger, IDistributedCache cache, params IModelValidator[] validators) + : base(logger, cache, validators) { } + + public void AuthorizeAllTestable(Func[] requirements) + { + base.AuthorizeAll(requirements); + } + + public void AuthorizeAnyTestable(Func[] requirements) + { + base.AuthorizeAny(requirements); + } + + internal void SetActor(User user) + { + base.Actor = user; + } +} + +public class _ControllerTests +{ + private _ControllerTestable GetControllerTestable(User? withActor = null) + { + var controllerTestable = new _ControllerTestable( + A.Fake(), + A.Fake(), + new IModelValidator[] { } + ); + + if (withActor != null) + { + controllerTestable.SetActor(withActor); + } + else + { + controllerTestable.SetActor(A.Fake()); + } + + return controllerTestable; + } + + [Fact] + public void AuthorizeAll_WhenReqFalse_ThrowsActionForbidden() + { + // arrange + var sut = GetControllerTestable(); + var authReqs = new[] + { + () => false + }; + + // act/assert + Should.Throw(() => sut.AuthorizeAnyTestable(authReqs)); + } + + [Fact] + public void AuthorizeAll_WhenReqsTrueAndFalse_ThrowsActionForbidden() + { + // arrange + var sut = GetControllerTestable(); + var authReqs = new[] + { + () => false, + () => true + }; + + // act/assert + Should.Throw(() => sut.AuthorizeAllTestable(authReqs)); + } + + [Fact] + public void AuthorizeAll_WhenAdmin_EvaluatesRequirements() + { + // arrange + var fakeAdmin = A.Fake(); + fakeAdmin.Role = UserRole.Admin; + + var sut = GetControllerTestable(fakeAdmin); + var authRequirements = new Func[] + { + () => false + }; + + // act/assert + Should.Throw(() => sut.AuthorizeAllTestable(authRequirements)); + } + + [Fact] + public void AuthorizeAny_WhenAdmin_IgnoresOtherRequirements() + { + // arrange + var fakeAdmin = A.Fake(); + fakeAdmin.Role = UserRole.Admin; + var sut = GetControllerTestable(fakeAdmin); + + var authorizationRequirements = new Func[] + { + () => false + }; + + // act/assert + Should.NotThrow(() => sut.AuthorizeAnyTestable(authorizationRequirements)); + } + + [Fact] + public void AuthorizeAny_WhenReqFalse_ThrowsActionForbidden() + { + // arrange + var sut = GetControllerTestable(); + var authReqs = new[] + { + () => false + }; + + // act/assert + Should.Throw(() => sut.AuthorizeAnyTestable(authReqs)); + } + + [Fact] + public void AuthorizeAny_WhenOneReqTrue_Succedes() + { + // arrange + var sut = GetControllerTestable(); + var authReqs = new[] + { + () => false, + () => true + }; + + // act/assert + Should.NotThrow(() => sut.AuthorizeAnyTestable(authReqs)); + } +} \ No newline at end of file diff --git a/src/Gameboard.Tests.Unit/Tests/Structure/CorsPolicyOptionsTests.cs b/src/Gameboard.Tests.Unit/Tests/Structure/CorsPolicyOptionsTests.cs new file mode 100644 index 00000000..5238cbcb --- /dev/null +++ b/src/Gameboard.Tests.Unit/Tests/Structure/CorsPolicyOptionsTests.cs @@ -0,0 +1,24 @@ +using Gameboard.Api; + +namespace Gameboard.Tests.Unit; + +public class CorsPolicyOptionsTests +{ + [Theory, GameboardAutoData] + public void Build_WithStarAndOtherOrigins_AllowsAllOrigins(IFixture fixture) + { + // arrange + var sut = fixture.Create(); + sut.Origins = new string[] + { + "*", + fixture.Create() + }; + + // act + var policy = sut.Build(); + + // assert + policy.AllowAnyOrigin.ShouldBeTrue(); + } +} diff --git a/src/Gameboard.Tests.Unit/Usings.cs b/src/Gameboard.Tests.Unit/Usings.cs index 27c49838..e6d7a0da 100644 --- a/src/Gameboard.Tests.Unit/Usings.cs +++ b/src/Gameboard.Tests.Unit/Usings.cs @@ -1,4 +1,8 @@ -global using NSubstitute; -global using NSubstitute.Extensions; +global using AutoFixture; +global using AutoFixture.Xunit2; +global using AutoFixture.AutoFakeItEasy; +global using FakeItEasy; global using Shouldly; global using Xunit; + +global using Gameboard.Tests.Unit.Fixtures; \ No newline at end of file From 5b00f7c1337f709334cbd689f3ca227a1516bc19 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Wed, 11 Jan 2023 09:04:24 -0500 Subject: [PATCH 15/17] Minor code clean-up --- src/Gameboard.Api/Data/Store/IStore[TEntity].cs | 1 - src/Gameboard.Api/Data/Store/Store[TEntity].cs | 1 - src/Gameboard.Api/Features/User/IUserStore.cs | 4 +--- src/Gameboard.Api/Features/User/UserValidator.cs | 1 - 4 files changed, 1 insertion(+), 6 deletions(-) diff --git a/src/Gameboard.Api/Data/Store/IStore[TEntity].cs b/src/Gameboard.Api/Data/Store/IStore[TEntity].cs index 7a86aec5..c44b770a 100644 --- a/src/Gameboard.Api/Data/Store/IStore[TEntity].cs +++ b/src/Gameboard.Api/Data/Store/IStore[TEntity].cs @@ -23,5 +23,4 @@ public interface IStore Task Update(IEnumerable range); Task Delete(string id); } - } diff --git a/src/Gameboard.Api/Data/Store/Store[TEntity].cs b/src/Gameboard.Api/Data/Store/Store[TEntity].cs index 6ec43215..04688cff 100644 --- a/src/Gameboard.Api/Data/Store/Store[TEntity].cs +++ b/src/Gameboard.Api/Data/Store/Store[TEntity].cs @@ -91,6 +91,5 @@ public virtual async Task Delete(string id) await DbContext.SaveChangesAsync(); } } - } } diff --git a/src/Gameboard.Api/Features/User/IUserStore.cs b/src/Gameboard.Api/Features/User/IUserStore.cs index d07813e1..f1003ba0 100644 --- a/src/Gameboard.Api/Features/User/IUserStore.cs +++ b/src/Gameboard.Api/Features/User/IUserStore.cs @@ -3,7 +3,5 @@ namespace Gameboard.Api.Data.Abstractions { - - public interface IUserStore: IStore { } - + public interface IUserStore : IStore { } } diff --git a/src/Gameboard.Api/Features/User/UserValidator.cs b/src/Gameboard.Api/Features/User/UserValidator.cs index fa7c05de..a9469d89 100644 --- a/src/Gameboard.Api/Features/User/UserValidator.cs +++ b/src/Gameboard.Api/Features/User/UserValidator.cs @@ -1,7 +1,6 @@ // Copyright 2021 Carnegie Mellon University. All Rights Reserved. // Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. -using System; using System.Threading.Tasks; using Gameboard.Api.Data.Abstractions; From e0f9dea729aea38640a931bc278d537aba4884ff Mon Sep 17 00:00:00 2001 From: Matt Kaar <66427159+sei-mkaar@users.noreply.github.com> Date: Thu, 12 Jan 2023 07:11:30 -0500 Subject: [PATCH 16/17] Add next branch to CI --- .github/workflows/main.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d0db2a3f..ae3314d8 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -8,6 +8,7 @@ on: branches: - dev - test + - next jobs: test: From f7f7a383fbe00030327b6cedfa9802b7dadf09a6 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Thu, 12 Jan 2023 09:01:01 -0500 Subject: [PATCH 17/17] Change from dotnet runtime to dotnet/aspnet for hosting container. --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 5e0bd86e..54475957 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,7 +15,7 @@ CMD ["dotnet", "run"] # #multi-stage target: prod # -FROM mcr.microsoft.com/dotnet/runtime:7.0.1 AS prod +FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS prod ARG commit ENV COMMIT=$commit COPY --from=dev /app/dist /app