diff --git a/src/Nullinside.Api.Common.AspNetCore/Middleware/BasicAuthorizationRequirement.cs b/src/Nullinside.Api.Common.AspNetCore/Middleware/BasicAuthorizationRequirement.cs
index 0988173..6ad1b74 100644
--- a/src/Nullinside.Api.Common.AspNetCore/Middleware/BasicAuthorizationRequirement.cs
+++ b/src/Nullinside.Api.Common.AspNetCore/Middleware/BasicAuthorizationRequirement.cs
@@ -1,3 +1,5 @@
+using System.Diagnostics.CodeAnalysis;
+
using Microsoft.AspNetCore.Authorization;
namespace Nullinside.Api.Common.AspNetCore.Middleware;
@@ -5,6 +7,7 @@ namespace Nullinside.Api.Common.AspNetCore.Middleware;
///
/// Represents a requirement where a user is expected to have one role.
///
+[ExcludeFromCodeCoverage]
public class BasicAuthorizationRequirement : IAuthorizationRequirement {
///
/// Initializes a new instance of the class.
diff --git a/src/Nullinside.Api.Common/Desktop/GithubLatestReleaseJson.cs b/src/Nullinside.Api.Common/Desktop/GithubLatestReleaseJson.cs
index c4df99c..671c4c0 100644
--- a/src/Nullinside.Api.Common/Desktop/GithubLatestReleaseJson.cs
+++ b/src/Nullinside.Api.Common/Desktop/GithubLatestReleaseJson.cs
@@ -1,8 +1,11 @@
-namespace Nullinside.Api.Common.Desktop;
+using System.Diagnostics.CodeAnalysis;
+
+namespace Nullinside.Api.Common.Desktop;
///
/// The response information from GitHub's API.
///
+[ExcludeFromCodeCoverage]
public class GithubLatestReleaseJson {
///
/// The url of the resource.
diff --git a/src/Nullinside.Api.Common/Docker/Support/DockerComposeLsOutput.cs b/src/Nullinside.Api.Common/Docker/Support/DockerComposeLsOutput.cs
index bef764f..cc17893 100644
--- a/src/Nullinside.Api.Common/Docker/Support/DockerComposeLsOutput.cs
+++ b/src/Nullinside.Api.Common/Docker/Support/DockerComposeLsOutput.cs
@@ -1,8 +1,11 @@
+using System.Diagnostics.CodeAnalysis;
+
namespace Nullinside.Api.Common.Docker.Support;
///
/// The `docker compose ls --format 'json'` output.
///
+[ExcludeFromCodeCoverage]
public class DockerComposeLsOutput {
///
/// The name of the docker compose project.
diff --git a/src/Nullinside.Api.Common/Docker/Support/DockerResource.cs b/src/Nullinside.Api.Common/Docker/Support/DockerResource.cs
index bcfa5b4..7c042c0 100644
--- a/src/Nullinside.Api.Common/Docker/Support/DockerResource.cs
+++ b/src/Nullinside.Api.Common/Docker/Support/DockerResource.cs
@@ -1,9 +1,12 @@
+using System.Diagnostics.CodeAnalysis;
+
namespace Nullinside.Api.Common.Docker.Support;
///
/// A docker resource representing either a docker compose project
/// or a single docker container.
///
+[ExcludeFromCodeCoverage]
public class DockerResource {
///
/// Initializes a new instance of the class.
diff --git a/src/Nullinside.Api.Common/Exceptions/RetryException.cs b/src/Nullinside.Api.Common/Exceptions/RetryException.cs
index 4bfff08..3478e45 100644
--- a/src/Nullinside.Api.Common/Exceptions/RetryException.cs
+++ b/src/Nullinside.Api.Common/Exceptions/RetryException.cs
@@ -1,8 +1,11 @@
-namespace Nullinside.Api.Common.Exceptions;
+using System.Diagnostics.CodeAnalysis;
+
+namespace Nullinside.Api.Common.Exceptions;
///
/// An exception thrown if an action continues to fail after retrying.
///
+[ExcludeFromCodeCoverage]
public class RetryException : Exception {
///
/// Initializes a new instance of the class.
diff --git a/src/Nullinside.Api.Common/Twitch/Json/TwitchModeratedChannelsResponse.cs b/src/Nullinside.Api.Common/Twitch/Json/TwitchModeratedChannelsResponse.cs
index c28d7d4..94c958e 100644
--- a/src/Nullinside.Api.Common/Twitch/Json/TwitchModeratedChannelsResponse.cs
+++ b/src/Nullinside.Api.Common/Twitch/Json/TwitchModeratedChannelsResponse.cs
@@ -1,8 +1,11 @@
-namespace Nullinside.Api.Common.Twitch.Json;
+using System.Diagnostics.CodeAnalysis;
+
+namespace Nullinside.Api.Common.Twitch.Json;
///
/// The response to a query for what channels a user is moderator for.
///
+[ExcludeFromCodeCoverage]
public class TwitchModeratedChannelsResponse {
///
/// The list of channels the user moderates for.
@@ -18,6 +21,7 @@ public class TwitchModeratedChannelsResponse {
///
/// A channel the user moderates.
///
+[ExcludeFromCodeCoverage]
public class TwitchModeratedChannel {
///
/// The twitch id.
@@ -38,6 +42,7 @@ public class TwitchModeratedChannel {
///
/// Pagination information.
///
+[ExcludeFromCodeCoverage]
public class Pagination {
///
/// The cursor to pass to "after" for pagination.
diff --git a/src/Nullinside.Api.Tests/Nullinside.Api.Model/Shared/UserHelpersTests.cs b/src/Nullinside.Api.Tests/Nullinside.Api.Model/Shared/UserHelpersTests.cs
index 7af3690..b464526 100644
--- a/src/Nullinside.Api.Tests/Nullinside.Api.Model/Shared/UserHelpersTests.cs
+++ b/src/Nullinside.Api.Tests/Nullinside.Api.Model/Shared/UserHelpersTests.cs
@@ -1,31 +1,12 @@
-using Microsoft.EntityFrameworkCore;
-using Microsoft.EntityFrameworkCore.Diagnostics;
-
-using Nullinside.Api.Model;
using Nullinside.Api.Model.Ddl;
using Nullinside.Api.Model.Shared;
namespace Nullinside.Api.Tests.Nullinside.Api.Model.Shared;
-public class UserHelpersTests {
- private INullinsideContext _db;
-
- [SetUp]
- public void Setup() {
- // Create an in-memory database to fake the SQL queries. Note that we generate a random GUID for the name
- // here. If you use the same name more than once you'll get collisions between tests.
- DbContextOptions contextOptions = new DbContextOptionsBuilder()
- .UseInMemoryDatabase(Guid.NewGuid().ToString())
- .ConfigureWarnings(b => b.Ignore(InMemoryEventId.TransactionIgnoredWarning))
- .Options;
- _db = new NullinsideContext(contextOptions);
- }
-
- [TearDown]
- public async Task TearDown() {
- await _db.DisposeAsync();
- }
-
+///
+/// Tests for the class.
+///
+public class UserHelpersTests : UnitTestBase {
///
/// The case where a user is generating a new token to replace their existing one.
///
diff --git a/src/Nullinside.Api.Tests/Nullinside.Api.Tests.csproj b/src/Nullinside.Api.Tests/Nullinside.Api.Tests.csproj
index f03613d..7e1deec 100644
--- a/src/Nullinside.Api.Tests/Nullinside.Api.Tests.csproj
+++ b/src/Nullinside.Api.Tests/Nullinside.Api.Tests.csproj
@@ -32,7 +32,6 @@
-
diff --git a/src/Nullinside.Api.Tests/Nullinside.Api/Controllers/DockerControllerTests.cs b/src/Nullinside.Api.Tests/Nullinside.Api/Controllers/DockerControllerTests.cs
new file mode 100644
index 0000000..aa475da
--- /dev/null
+++ b/src/Nullinside.Api.Tests/Nullinside.Api/Controllers/DockerControllerTests.cs
@@ -0,0 +1,170 @@
+using Microsoft.AspNetCore.Mvc;
+
+using Moq;
+
+using Nullinside.Api.Common.Docker;
+using Nullinside.Api.Common.Docker.Support;
+using Nullinside.Api.Controllers;
+using Nullinside.Api.Model.Ddl;
+using Nullinside.Api.Shared.Json;
+
+namespace Nullinside.Api.Tests.Nullinside.Api.Controllers;
+
+///
+/// Tests for the class
+///
+public class DockerControllerTests : UnitTestBase {
+ ///
+ /// The docker proxy.
+ ///
+ private Mock _docker;
+
+ ///
+ /// Add the docker proxy.
+ ///
+ public override void Setup() {
+ base.Setup();
+ _docker = new Mock();
+ }
+
+ ///
+ /// Tests that given a list of docker projects from the database we can match it against the actively running
+ /// projects on the server.
+ ///
+ [Test]
+ public async Task DatabaseMatchesCommandOutputSuccessfully() {
+ // Create three entries in the database for what we allow people to see. All of these should be in the output.
+ _db.DockerDeployments.AddRange(
+ new DockerDeployments {
+ Id = 1, DisplayName = "Good", IsDockerComposeProject = true, Name = "Good", Notes = "Should be in output"
+ },
+ new DockerDeployments {
+ Id = 2, DisplayName = "Good without matching name", IsDockerComposeProject = true, Name = "NonMatchingName",
+ Notes = "Should be in output"
+ },
+ new DockerDeployments {
+ Id = 3, DisplayName = "Good non-compose", IsDockerComposeProject = false, Name = "Stuff",
+ Notes = "Should be in output"
+ }
+ );
+ await _db.SaveChangesAsync();
+
+ // Create two entries "in the server" for what is actually running. We should only match on the "good" one. The bad
+ // one is different enough that it shouldn't match.
+ var compose = new List {
+ new() { Id = 1, IsOnline = true, Name = "Good", Notes = "Should be in output's IsOnline field" },
+ new() { Id = 2, IsOnline = false, Name = "Bad", Notes = "Should not be in output" }
+ };
+ _docker.Setup(d => d.GetDockerComposeProjects(It.IsAny()))
+ .Returns(() => Task.FromResult(compose.AsEnumerable()));
+
+ var containers = new List {
+ new() { Id = 3, IsOnline = true, Name = "doesn't match", Notes = "Should not be in output" }
+ };
+ _docker.Setup(d => d.GetContainers(It.IsAny()))
+ .Returns(() => Task.FromResult(containers.AsEnumerable()));
+
+ // Make the call and ensure it's successful.
+ var controller = new DockerController(_db, _docker.Object);
+ ObjectResult obj = await controller.GetDockerResources();
+ Assert.That(obj.StatusCode, Is.EqualTo(200));
+
+ // There should be three results. One that was actively running "Good" and the others weren't actively running.
+ var deployments = obj.Value as List;
+ Assert.That(deployments, Is.Not.Null);
+ Assert.That(deployments.Count, Is.EqualTo(3));
+ Assert.That(deployments.FirstOrDefault(d => d.Name == "Good")?.IsOnline, Is.True);
+ Assert.That(deployments.FirstOrDefault(d => d.Name == "Good without matching name")?.IsOnline, Is.False);
+ Assert.That(deployments.FirstOrDefault(d => d.Name == "Good non-compose")?.IsOnline, Is.False);
+ }
+
+ ///
+ /// Tests that turning on/off a compose project calls the correct thing.
+ ///
+ [Test]
+ public async Task OnOffComposeProjectsWork() {
+ // Create two entries in the database for what we allow people to adjust.
+ _db.DockerDeployments.AddRange(
+ new DockerDeployments {
+ Id = 1, DisplayName = "Good", IsDockerComposeProject = true, Name = "Good", Notes = "Should be in output"
+ },
+ new DockerDeployments {
+ Id = 2, DisplayName = "Bad", IsDockerComposeProject = true, Name = "Bad", Notes = "Should not be in output"
+ }
+ );
+ await _db.SaveChangesAsync();
+
+ // Only a call with "Good" will work.
+ _docker.Setup(d =>
+ d.TurnOnOffDockerCompose("Good", It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns(() => Task.FromResult(true));
+
+ // Make the call and ensure it's successful.
+ var controller = new DockerController(_db, _docker.Object);
+ ObjectResult obj =
+ await controller.TurnOnOrOffDockerResources(1, new TurnOnOrOffDockerResourcesRequest { TurnOn = true });
+ Assert.That(obj.StatusCode, Is.EqualTo(200));
+
+ // Ensure we called the 3rd party API with a value of "Good" to turn on a compose.
+ bool deployments = (bool)obj.Value!;
+ Assert.That(deployments, Is.True);
+ }
+
+ ///
+ /// Tests that turning on/off a container project calls the correct thing.
+ ///
+ [Test]
+ public async Task OnOffContainerWork() {
+ // Create two entries in the database for what we allow people to adjust.
+ _db.DockerDeployments.AddRange(
+ new DockerDeployments {
+ Id = 1, DisplayName = "Good", IsDockerComposeProject = false, Name = "Good", Notes = "Should be in output"
+ },
+ new DockerDeployments {
+ Id = 2, DisplayName = "Bad", IsDockerComposeProject = false, Name = "Bad", Notes = "Should not be in output"
+ }
+ );
+ await _db.SaveChangesAsync();
+
+ // Only a call with "Good" will work.
+ _docker.Setup(d => d.TurnOnOffDockerContainer("Good", It.IsAny(), It.IsAny()))
+ .Returns(() => Task.FromResult(true));
+
+ // Make the call and ensure it's successful.
+ var controller = new DockerController(_db, _docker.Object);
+ ObjectResult obj =
+ await controller.TurnOnOrOffDockerResources(1, new TurnOnOrOffDockerResourcesRequest { TurnOn = true });
+ Assert.That(obj.StatusCode, Is.EqualTo(200));
+
+ // Ensure we called the 3rd party API with a value of "Good" to turn on a container.
+ bool deployments = (bool)obj.Value!;
+ Assert.That(deployments, Is.True);
+ }
+
+ ///
+ /// Tests providing an invalid id will result in a HTTP bad request.
+ ///
+ [Test]
+ public async Task InvalidIdIsBadRequest() {
+ // Create two entries in the database for what we allow people to adjust.
+ _db.DockerDeployments.Add(
+ new DockerDeployments {
+ Id = 2, DisplayName = "Bad", IsDockerComposeProject = false, Name = "Bad", Notes = "Should not be in output"
+ }
+ );
+ await _db.SaveChangesAsync();
+
+ // Only a call with "Good" will get true.
+ _docker.Setup(d => d.TurnOnOffDockerContainer("Good", It.IsAny(), It.IsAny()))
+ .Returns(() => Task.FromResult(true));
+
+ // Make the call and ensure it's successful.
+ var controller = new DockerController(_db, _docker.Object);
+ ObjectResult obj =
+ await controller.TurnOnOrOffDockerResources(1, new TurnOnOrOffDockerResourcesRequest { TurnOn = true });
+ Assert.That(obj.StatusCode, Is.EqualTo(400));
+
+ // Bad request is returned to user with a generic error message.
+ Assert.That(obj.Value, Is.TypeOf());
+ }
+}
\ No newline at end of file
diff --git a/src/Nullinside.Api.Tests/Nullinside.Api/Controllers/FeatureToggleControllerTests.cs b/src/Nullinside.Api.Tests/Nullinside.Api/Controllers/FeatureToggleControllerTests.cs
new file mode 100644
index 0000000..0347e60
--- /dev/null
+++ b/src/Nullinside.Api.Tests/Nullinside.Api/Controllers/FeatureToggleControllerTests.cs
@@ -0,0 +1,40 @@
+using Microsoft.AspNetCore.Mvc;
+
+using Nullinside.Api.Controllers;
+using Nullinside.Api.Model.Ddl;
+
+namespace Nullinside.Api.Tests.Nullinside.Api.Controllers;
+
+///
+/// Tests for the class
+///
+public class FeatureToggleControllerTests : UnitTestBase {
+ ///
+ /// Tests that we can pull the feature toggles. It's basically a straight through.
+ ///
+ [Test]
+ public async Task GetAllFeatureToggles() {
+ // Creates two feature toggles.
+ _db.FeatureToggle.AddRange(
+ new FeatureToggle {
+ Id = 1, Feature = "hi", IsEnabled = true
+ },
+ new FeatureToggle {
+ Id = 2, Feature = "bye", IsEnabled = false
+ }
+ );
+ await _db.SaveChangesAsync();
+
+ // Make the call and ensure it's successful.
+ var controller = new FeatureToggleController(_db);
+ ObjectResult obj = await controller.GetAll();
+ Assert.That(obj.StatusCode, Is.EqualTo(200));
+
+ // Ensure they passed through cleanly.
+ var featureToggles = obj.Value as IEnumerable;
+ Assert.That(featureToggles, Is.Not.Null);
+ Assert.That(featureToggles.Count, Is.EqualTo(2));
+ Assert.That(featureToggles.FirstOrDefault(f => f.Feature == "hi")?.IsEnabled, Is.True);
+ Assert.That(featureToggles.FirstOrDefault(f => f.Feature == "bye")?.IsEnabled, Is.False);
+ }
+}
\ No newline at end of file
diff --git a/src/Nullinside.Api.Tests/Nullinside.Api/Controllers/UserControllerTests.cs b/src/Nullinside.Api.Tests/Nullinside.Api/Controllers/UserControllerTests.cs
new file mode 100644
index 0000000..7ac5a7d
--- /dev/null
+++ b/src/Nullinside.Api.Tests/Nullinside.Api/Controllers/UserControllerTests.cs
@@ -0,0 +1,337 @@
+using System.Security.Claims;
+
+using Google.Apis.Auth;
+
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Infrastructure;
+using Microsoft.Extensions.Configuration;
+
+using Moq;
+
+using Nullinside.Api.Common.Twitch;
+using Nullinside.Api.Controllers;
+using Nullinside.Api.Model;
+using Nullinside.Api.Model.Ddl;
+using Nullinside.Api.Shared.Json;
+
+namespace Nullinside.Api.Tests.Nullinside.Api.Controllers;
+
+///
+/// Tests for the class
+///
+public class UserControllerTests : UnitTestBase {
+ ///
+ /// The mock configuration.
+ ///
+ private IConfiguration _configuration;
+
+ ///
+ /// The twitch api.
+ ///
+ private Mock _twitchApi;
+
+ ///
+ public override void Setup() {
+ base.Setup();
+
+ // Setup the config
+ var config = new Dictionary() {
+ { "Api:SiteUrl", string.Empty }
+ };
+
+ _configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(config)
+ .Build();
+
+ _twitchApi = new Mock();
+ }
+
+ ///
+ /// Tests that we can login with google for a user that already exists.
+ ///
+ [Test]
+ public async Task PerformGoogleLoginExisting() {
+ // Create an existing user.
+ this._db.Users.Add(new User {
+ Id = 1,
+ Email = "hi"
+ });
+
+ await this._db.SaveChangesAsync();
+
+ // Make the call and ensure it's successful.
+ var controller = new TestableUserController(_configuration, _db);
+ controller.Email = "hi";
+ RedirectResult obj = await controller.Login(new GoogleOpenIdToken { credential = "stuff" });
+
+ // We should have been redirected to the successful route.
+ Assert.That(obj.Url.StartsWith("/user/login?token="), Is.True);
+
+ // No additional users should have been created.
+ Assert.That(this._db.Users.Count(), Is.EqualTo(1));
+
+ // We should have saved the token in the existing user's database.
+ Assert.That(obj.Url.EndsWith(this._db.Users.First().Token!), Is.True);
+ }
+
+ ///
+ /// Tests that we can login with google for a new user.
+ ///
+ [Test]
+ public async Task PerformGoogleLoginNewUser() {
+ // Make the call and ensure it's successful.
+ var controller = new TestableUserController(_configuration, _db);
+ controller.Email = "hi";
+ RedirectResult obj = await controller.Login(new GoogleOpenIdToken { credential = "stuff" });
+
+ // We should have been redirected to the successful route.
+ Assert.That(obj.Url.StartsWith("/user/login?token="), Is.True);
+
+ // No additional users should have been created.
+ Assert.That(this._db.Users.Count(), Is.EqualTo(1));
+
+ // We should have saved the token in the existing user's database.
+ Assert.That(obj.Url.EndsWith(this._db.Users.First().Token!), Is.True);
+ }
+
+ ///
+ /// Tests that we handle DB errors correctly in the google login.
+ ///
+ [Test]
+ public async Task GoToErrorOnDbException() {
+ this._db.Users = null!;
+
+ // Make the call and ensure it's successful.
+ var controller = new TestableUserController(_configuration, _db);
+ controller.Email = "hi";
+ RedirectResult obj = await controller.Login(new GoogleOpenIdToken { credential = "stuff" });
+
+ // We should have been redirected to the error.
+ Assert.That(obj.Url.StartsWith("/user/login?error="), Is.True);
+ }
+
+ ///
+ /// Tests that we handle bad gmail responses.
+ ///
+ [Test]
+ public async Task GoToErrorOnBadGmailResponse() {
+ // Make the call and ensure it's successful.
+ var controller = new TestableUserController(_configuration, _db);
+ controller.Email = null;
+ RedirectResult obj = await controller.Login(new GoogleOpenIdToken { credential = "stuff" });
+
+ // We should have been redirected to the error.
+ Assert.That(obj.Url.StartsWith("/user/login?error="), Is.True);
+ }
+
+ ///
+ /// Tests that we can login with twitch for a user that already exists.
+ ///
+ [Test]
+ public async Task PerformTwitchLoginExisting() {
+ // Tells us twitch parsed the code successfully.
+ _twitchApi.Setup(a => a.CreateAccessToken(It.IsAny(), It.IsAny()))
+ .Returns(() => Task.FromResult(new TwitchAccessToken()));
+
+ // Gets a matching email address from our database
+ _twitchApi.Setup(a => a.GetUserEmail(It.IsAny()))
+ .Returns(() => Task.FromResult("hi"));
+
+ // Create an existing user.
+ this._db.Users.Add(new User {
+ Id = 1,
+ Email = "hi"
+ });
+
+ await this._db.SaveChangesAsync();
+
+ // Make the call and ensure it's successful.
+ var controller = new TestableUserController(_configuration, _db);
+ var obj = await controller.TwitchLogin("things", _twitchApi.Object);
+
+ // We should have been redirected to the successful route.
+ Assert.That(obj.Url.StartsWith("/user/login?token="), Is.True);
+
+ // No additional users should have been created.
+ Assert.That(this._db.Users.Count(), Is.EqualTo(1));
+
+ // We should have saved the token in the existing user's database.
+ Assert.That(obj.Url.EndsWith(this._db.Users.First().Token!), Is.True);
+ }
+
+ ///
+ /// Tests that we can login with twitch for a new user.
+ ///
+ [Test]
+ public async Task PerformTwitchLoginNewUser() {
+ // Tells us twitch parsed the code successfully.
+ _twitchApi.Setup(a => a.CreateAccessToken(It.IsAny(), It.IsAny()))
+ .Returns(() => Task.FromResult(new TwitchAccessToken()));
+
+ // Gets a matching email address from our database
+ _twitchApi.Setup(a => a.GetUserEmail(It.IsAny()))
+ .Returns(() => Task.FromResult("hi"));
+
+ // Make the call and ensure it's successful.
+ var controller = new TestableUserController(_configuration, _db);
+ var obj = await controller.TwitchLogin("things", _twitchApi.Object);
+
+ // We should have been redirected to the successful route.
+ Assert.That(obj.Url.StartsWith("/user/login?token="), Is.True);
+
+ // No additional users should have been created.
+ Assert.That(this._db.Users.Count(), Is.EqualTo(1));
+
+ // We should have saved the token in the existing user's database.
+ Assert.That(obj.Url.EndsWith(this._db.Users.First().Token!), Is.True);
+ }
+
+ ///
+ /// Tests that we handle a bad response from twitch.
+ ///
+ [Test]
+ public async Task PerformTwitchLoginBadTwitchResponse() {
+ // Tells us twitch thinks it was a bad code.
+ _twitchApi.Setup(a => a.CreateAccessToken(It.IsAny(), It.IsAny()))
+ .Returns(() => Task.FromResult(null));
+
+ // Make the call and ensure it's successful.
+ var controller = new TestableUserController(_configuration, _db);
+ var obj = await controller.TwitchLogin("things", _twitchApi.Object);
+
+ // We should have gone down the bad route
+ Assert.That(obj.Url.StartsWith("/user/login?error="), Is.True);
+ }
+
+ ///
+ /// Tests that we can login with twitch but it has no email associated with the account.
+ ///
+ [Test]
+ public async Task PerformTwitchLoginWithNoEmailAccount() {
+ // Tells us twitch parsed the code successfully.
+ _twitchApi.Setup(a => a.CreateAccessToken(It.IsAny(), It.IsAny()))
+ .Returns(() => Task.FromResult(new TwitchAccessToken()));
+
+ // Make the call and ensure it's successful.
+ var controller = new TestableUserController(_configuration, _db);
+ var obj = await controller.TwitchLogin("things", _twitchApi.Object);
+
+ // We should have gone down the bad route because no email was associated with the twitch account.
+ Assert.That(obj.Url.StartsWith("/user/login?error="), Is.True);
+ }
+
+ ///
+ /// Tests that we handle database failures correctly in the twitch login process.
+ ///
+ [Test]
+ public async Task PerformTwitchLoginDbFailure() {
+ _db.Users = null!;
+
+ // Tells us twitch parsed the code successfully.
+ _twitchApi.Setup(a => a.CreateAccessToken(It.IsAny(), It.IsAny()))
+ .Returns(() => Task.FromResult(new TwitchAccessToken()));
+
+ // Gets an email address from twitch
+ _twitchApi.Setup(a => a.GetUserEmail(It.IsAny()))
+ .Returns(() => Task.FromResult("hi"));
+
+ // Make the call and ensure it's successful.
+ var controller = new TestableUserController(_configuration, _db);
+ var obj = await controller.TwitchLogin("things", _twitchApi.Object);
+
+ // We should have been redirected to the error route because of an exception in DB processing.
+ Assert.That(obj.Url.StartsWith("/user/login?error="), Is.True);
+ }
+
+ ///
+ /// Tests that we get the roles assigned to the user.
+ ///
+ [Test]
+ public void GetRoles() {
+ // Setup the logged in user
+ var claims = new List {
+ new(ClaimTypes.Role, "candy")
+ };
+ var identity = new ClaimsIdentity(claims, "icecream");
+
+ // Make the call and ensure it's successful.
+ var controller = new TestableUserController(_configuration, _db);
+ controller.ControllerContext = new ControllerContext();
+ controller.ControllerContext.HttpContext = new DefaultHttpContext();
+ controller.ControllerContext.HttpContext.User = new ClaimsPrincipal(identity);
+
+ var obj = controller.GetRoles();
+ Assert.That(obj.StatusCode, Is.EqualTo(200));
+
+ // Ensure we got the role we put in.
+ var roles = obj.Value!.GetType().GetProperty("roles")!.GetValue(obj.Value) as IEnumerable;
+ Assert.That(roles!.Count(), Is.EqualTo(1));
+ Assert.That(roles!.First(), Is.EqualTo("candy"));
+ }
+
+ ///
+ /// Tests that we can validate a token that exists.
+ ///
+ [Test]
+ public async Task ValidateTokenExists() {
+ this._db.Users.Add(new User { Token = "123" });
+ await this._db.SaveChangesAsync();
+
+ // Make the call and ensure it's successful.
+ var controller = new TestableUserController(_configuration, _db);
+ var obj = await controller.Validate(new AuthToken("123"));
+ Assert.That((obj as IStatusCodeActionResult)?.StatusCode, Is.EqualTo(200));
+
+ // Ensure we returned that the token was correct.
+ Assert.That((obj as ObjectResult)?.Value, Is.True);
+ }
+
+ ///
+ /// Tests that we do not validate tokens that do not exist.
+ ///
+ [Test]
+ public async Task ValidateFailWithoutToken() {
+ // Make the call and ensure it fails.
+ var controller = new TestableUserController(_configuration, _db);
+ var obj = await controller.Validate(new AuthToken("123"));
+ Assert.That((obj as IStatusCodeActionResult)?.StatusCode, Is.EqualTo(401));
+ }
+
+ ///
+ /// Tests that unhandled exceptions are performed appropriately.
+ ///
+ [Test]
+ public async Task ValidateFailOnDbFailure() {
+ this._db.Users = null!;
+
+ // Make the call and ensure it fails.
+ var controller = new TestableUserController(_configuration, _db);
+ var obj = await controller.Validate(new AuthToken("123"));
+ Assert.That((obj as IStatusCodeActionResult)?.StatusCode, Is.EqualTo(500));
+ }
+}
+
+///
+/// A testable version of the user controller that removes 3rd party dependencies.
+///
+public class TestableUserController : UserController {
+ ///
+ /// Gets or sets the email to include in the google payload.
+ ///
+ public string? Email { get; set; }
+
+ ///
+ public TestableUserController(IConfiguration configuration, INullinsideContext dbContext) : base(configuration, dbContext)
+ {
+ }
+
+ ///
+ protected override Task GenerateUserObject(GoogleOpenIdToken creds) {
+ if (null != Email) {
+ return Task.FromResult(new GoogleJsonWebSignature.Payload() { Email = Email });
+ }
+
+ return Task.FromResult(null);
+ }
+}
\ No newline at end of file
diff --git a/src/Nullinside.Api.Tests/UnitTestBase.cs b/src/Nullinside.Api.Tests/UnitTestBase.cs
new file mode 100644
index 0000000..ebc3f16
--- /dev/null
+++ b/src/Nullinside.Api.Tests/UnitTestBase.cs
@@ -0,0 +1,33 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Diagnostics;
+
+using Nullinside.Api.Model;
+
+namespace Nullinside.Api.Tests;
+
+///
+/// A base class for all unit tests.
+///
+public abstract class UnitTestBase {
+ ///
+ /// A fake database.
+ ///
+ protected INullinsideContext _db;
+
+ [SetUp]
+ public virtual void Setup() {
+ // Create an in-memory database to fake the SQL queries. Note that we generate a random GUID for the name
+ // here. If you use the same name more than once you'll get collisions between tests.
+ DbContextOptions contextOptions = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(Guid.NewGuid().ToString())
+ .ConfigureWarnings(b => b.Ignore(InMemoryEventId.TransactionIgnoredWarning))
+ .Options;
+ _db = new NullinsideContext(contextOptions);
+ }
+
+ [TearDown]
+ public virtual async Task TearDown() {
+ // Dispose since it has one.
+ await _db.DisposeAsync();
+ }
+}
\ No newline at end of file
diff --git a/src/Nullinside.Api/Controllers/DatabaseController.cs b/src/Nullinside.Api/Controllers/DatabaseController.cs
index fade3f1..65a2847 100644
--- a/src/Nullinside.Api/Controllers/DatabaseController.cs
+++ b/src/Nullinside.Api/Controllers/DatabaseController.cs
@@ -1,3 +1,5 @@
+using System.Diagnostics.CodeAnalysis;
+
using log4net;
using Microsoft.AspNetCore.Authorization;
@@ -13,6 +15,7 @@ namespace Nullinside.Api.Controllers;
///
[ApiController]
[Route("[controller]")]
+[ExcludeFromCodeCoverage(Justification = "This is only to do database migrations, nothing to test.")]
public class DatabaseController : ControllerBase {
///
/// The nullinside database.
@@ -39,7 +42,7 @@ public DatabaseController(INullinsideContext dbContext) {
[AllowAnonymous]
[HttpGet]
[Route("migration")]
- public async Task Migrate() {
+ public async Task Migrate() {
await _dbContext.Database.MigrateAsync();
return Ok();
}
diff --git a/src/Nullinside.Api/Controllers/DockerController.cs b/src/Nullinside.Api/Controllers/DockerController.cs
index 3b9b380..0393ccb 100644
--- a/src/Nullinside.Api/Controllers/DockerController.cs
+++ b/src/Nullinside.Api/Controllers/DockerController.cs
@@ -52,7 +52,7 @@ public DockerController(INullinsideContext dbContext, IDockerProxy dockerProxy)
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
- public async Task GetDockerResources(CancellationToken token) {
+ public async Task GetDockerResources(CancellationToken token = new()) {
// Get all existing docker containers and docker projects.
Task> containers = _docker.GetContainers(token);
Task> projects = _docker.GetDockerComposeProjects(token);
@@ -93,8 +93,8 @@ public async Task GetDockerResources(CancellationToken token) {
[HttpPost("{id:int}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
- public async Task TurnOnOrOffDockerResources(int id, TurnOnOrOffDockerResourcesRequest request,
- CancellationToken token) {
+ public async Task TurnOnOrOffDockerResources(int id, TurnOnOrOffDockerResourcesRequest request,
+ CancellationToken token = new()) {
DockerDeployments? recognizedProjects = await _dbContext.DockerDeployments
.FirstOrDefaultAsync(d => d.Id == id, token);
if (null == recognizedProjects) {
diff --git a/src/Nullinside.Api/Controllers/FeatureToggleController.cs b/src/Nullinside.Api/Controllers/FeatureToggleController.cs
index b181bcb..a1732b7 100644
--- a/src/Nullinside.Api/Controllers/FeatureToggleController.cs
+++ b/src/Nullinside.Api/Controllers/FeatureToggleController.cs
@@ -39,10 +39,7 @@ public FeatureToggleController(INullinsideContext dbContext) {
/// The collection of feature toggles.
[AllowAnonymous]
[HttpGet]
- public async Task GetAll(CancellationToken token) {
- return Ok((await _dbContext.FeatureToggle
- .ToListAsync(token))
- .Select(r => new { r.Feature, r.IsEnabled })
- );
+ public async Task GetAll(CancellationToken token = new()) {
+ return Ok(await _dbContext.FeatureToggle.ToListAsync(token));
}
}
\ No newline at end of file
diff --git a/src/Nullinside.Api/Controllers/UserController.cs b/src/Nullinside.Api/Controllers/UserController.cs
index 2c08876..a89a2a2 100644
--- a/src/Nullinside.Api/Controllers/UserController.cs
+++ b/src/Nullinside.Api/Controllers/UserController.cs
@@ -57,10 +57,10 @@ public UserController(IConfiguration configuration, INullinsideContext dbContext
[AllowAnonymous]
[HttpPost]
[Route("login")]
- public async Task Login([FromForm] GoogleOpenIdToken creds, CancellationToken token) {
+ public async Task Login([FromForm] GoogleOpenIdToken creds, CancellationToken token = new()) {
string? siteUrl = _configuration.GetValue("Api:SiteUrl");
try {
- GoogleJsonWebSignature.Payload? credentials = await GoogleJsonWebSignature.ValidateAsync(creds.credential);
+ GoogleJsonWebSignature.Payload? credentials = await this.GenerateUserObject(creds);
if (string.IsNullOrWhiteSpace(credentials?.Email)) {
return Redirect($"{siteUrl}/user/login?error=1");
}
@@ -77,7 +77,15 @@ public async Task Login([FromForm] GoogleOpenIdToken creds, Cance
}
}
-
+ ///
+ /// Converts the credential string we get from google to a representation we read information from.
+ ///
+ /// The credentials from Google.
+ /// The user information object.
+ protected async virtual Task GenerateUserObject(GoogleOpenIdToken creds) {
+ return await GoogleJsonWebSignature.ValidateAsync(creds.credential);
+ }
+
///
/// **NOT CALLED BY SITE OR USERS** This endpoint is called by twitch as part of their oauth workflow. It
/// redirects users back to the nullinside website.
@@ -95,7 +103,7 @@ public async Task Login([FromForm] GoogleOpenIdToken creds, Cance
[AllowAnonymous]
[HttpGet]
[Route("twitch-login")]
- public async Task TwitchLogin([FromQuery] string code, [FromServices] ITwitchApiProxy api,
+ public async Task TwitchLogin([FromQuery] string code, [FromServices] ITwitchApiProxy api,
CancellationToken token = new()) {
string? siteUrl = _configuration.GetValue("Api:SiteUrl");
if (null == await api.CreateAccessToken(code, token)) {
@@ -123,13 +131,13 @@ public async Task TwitchLogin([FromQuery] string code, [FromServi
[Route("roles")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
- public IActionResult GetRoles() {
+ public ObjectResult GetRoles() {
return Ok(new {
roles =
(from identify in User.Identities
from claim in identify.Claims
where claim.Type == ClaimTypes.Role
- select claim.Value).Distinct()
+ select claim.Value).Distinct().ToList()
});
}
@@ -137,15 +145,16 @@ from claim in identify.Claims
/// Validates that the provided token is valid.
///
/// The token to validate.
+ /// The cancellation token.
/// 200 if successful, 401 otherwise.
[AllowAnonymous]
[HttpPost]
[Route("token/validate")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
- public async Task Validate(AuthToken token) {
+ public async Task Validate(AuthToken token, CancellationToken cancellationToken = new()) {
try {
- User? existing = await _dbContext.Users.FirstOrDefaultAsync(u => u.Token == token.Token && !u.IsBanned);
+ User? existing = await _dbContext.Users.FirstOrDefaultAsync(u => u.Token == token.Token && !u.IsBanned, cancellationToken);
if (null == existing) {
return Unauthorized();
}
diff --git a/src/Nullinside.Api/Shared/Json/AuthToken.cs b/src/Nullinside.Api/Shared/Json/AuthToken.cs
index bf5acbd..c1f7775 100644
--- a/src/Nullinside.Api/Shared/Json/AuthToken.cs
+++ b/src/Nullinside.Api/Shared/Json/AuthToken.cs
@@ -1,8 +1,11 @@
+using System.Diagnostics.CodeAnalysis;
+
namespace Nullinside.Api.Shared.Json;
///
/// Represents an authentication token provided to the site via a "Bearer" token header.
///
+[ExcludeFromCodeCoverage(Justification = "JSON")]
public class AuthToken {
///
/// Initializes a new instance of the class.
diff --git a/src/Nullinside.Api/Shared/Json/BasicServerFailure.cs b/src/Nullinside.Api/Shared/Json/BasicServerFailure.cs
index 7735dda..046e432 100644
--- a/src/Nullinside.Api/Shared/Json/BasicServerFailure.cs
+++ b/src/Nullinside.Api/Shared/Json/BasicServerFailure.cs
@@ -1,8 +1,11 @@
+using System.Diagnostics.CodeAnalysis;
+
namespace Nullinside.Api.Shared.Json;
///
/// Represents a basic error where you just want to give the caller an error message and nothing more.
///
+[ExcludeFromCodeCoverage(Justification = "JSON")]
public class BasicServerFailure {
///
/// Initializes a new instance of the class.
diff --git a/src/Nullinside.Api/Shared/Json/GoogleOpenIdToken.cs b/src/Nullinside.Api/Shared/Json/GoogleOpenIdToken.cs
index 1eb2c8a..a329e82 100644
--- a/src/Nullinside.Api/Shared/Json/GoogleOpenIdToken.cs
+++ b/src/Nullinside.Api/Shared/Json/GoogleOpenIdToken.cs
@@ -1,8 +1,11 @@
+using System.Diagnostics.CodeAnalysis;
+
namespace Nullinside.Api.Shared.Json;
///
/// Represents the response from google for an OpenId token.
///
+[ExcludeFromCodeCoverage(Justification = "JSON")]
public class GoogleOpenIdToken {
///
/// The cross site scripting check.
diff --git a/src/Nullinside.Api/Shared/Json/TurnOnOrOffDockerResourcesRequest.cs b/src/Nullinside.Api/Shared/Json/TurnOnOrOffDockerResourcesRequest.cs
index b966180..e9b7c3b 100644
--- a/src/Nullinside.Api/Shared/Json/TurnOnOrOffDockerResourcesRequest.cs
+++ b/src/Nullinside.Api/Shared/Json/TurnOnOrOffDockerResourcesRequest.cs
@@ -1,8 +1,11 @@
+using System.Diagnostics.CodeAnalysis;
+
namespace Nullinside.Api.Shared.Json;
///
/// A request to turn on or off a docker resource.
///
+[ExcludeFromCodeCoverage(Justification = "JSON")]
public class TurnOnOrOffDockerResourcesRequest {
///
/// True to turn the resource on, false to turn it off.