Add users, roles, and per-novel permissions

Introduces accounts (ASP.NET Identity + cookie auth), four global
roles (Admin/Writer/Editor/Reviewer), per-novel ownership and grants
via ProjectMember, and a service-API-key principal for the MCP server
and background import jobs. Enforcement lives in the application
services (not endpoint filters) so the embedded agent and MCP tools,
which call the same services directly, can't bypass it. Web client
gets a login page, session-aware routing, and a People section for
managing per-novel access.

Also includes prior in-flight changes from this branch (CLAUDE.md
compliance pass, dev-deploy docker-compose setup) that were
uncommitted when this feature work started.
This commit is contained in:
James Wampler
2026-08-15 22:29:33 -07:00
parent 7d8dd0c4fd
commit e598c18d67
111 changed files with 6562 additions and 797 deletions
@@ -0,0 +1,81 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Beats;
using Novelly.Api.Chapters;
using Novelly.Api.Common;
using Novelly.Api.Projects;
namespace Novelly.Api.Tests;
[TestFixture]
public class ChapterServiceTests : ServiceTestFixture
{
private Guid _projectId;
protected override void OnSetUp()
{
_projectId = Projects.CreateAsync(new CreateProjectRequest("The Salt Road")).Result.Id;
}
[Test]
public async Task Setting_a_number_that_already_exists_is_still_stored_as_given()
{
var first = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall", Number: 5));
var second = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("The Harbour", Number: 5));
Assert.Multiple(() =>
{
Assert.That(first.Number, Is.EqualTo(5));
Assert.That(second.Number, Is.EqualTo(5));
});
}
[Test]
public async Task Updating_leaves_omitted_fields_alone_and_clears_notes_on_empty_string()
{
var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest(
"Landfall", Summary: "The ship makes shore.", Notes: "Check the tide tables."));
var renamed = (await Chapters.UpdateAsync(chapter.Id, new UpdateChapterRequest(Title: "First Landfall")))!;
Assert.Multiple(() =>
{
Assert.That(renamed.Title, Is.EqualTo("First Landfall"));
Assert.That(renamed.Summary, Is.EqualTo("The ship makes shore."));
Assert.That(renamed.Notes, Is.EqualTo("Check the tide tables."));
});
var cleared = (await Chapters.UpdateAsync(chapter.Id, new UpdateChapterRequest(Notes: "")))!;
Assert.Multiple(() =>
{
Assert.That(cleared.Notes, Is.Null);
Assert.That(cleared.Summary, Is.EqualTo("The ship makes shore."));
});
}
[Test]
public async Task Deleting_a_chapter_takes_its_beats_with_it()
{
var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall"));
await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("She finds the map"));
await Chapters.DeleteAsync(chapter.Id);
using var verification = Db.CreateContext();
Assert.That(await verification.Beats.CountAsync(), Is.EqualTo(0));
}
[Test]
public async Task Creating_a_chapter_under_a_missing_project_returns_null_rather_than_throwing() =>
Assert.That(
await Chapters.CreateAsync(Guid.NewGuid(), new CreateChapterRequest("Landfall")),
Is.Null);
[Test]
public async Task Reading_a_missing_chapter_returns_null_rather_than_throwing() =>
Assert.That(await Chapters.GetAsync(Guid.NewGuid()), Is.Null);
[Test]
public async Task Deleting_a_missing_chapter_returns_false_rather_than_throwing() =>
Assert.That(await Chapters.DeleteAsync(Guid.NewGuid()), Is.False);
}
@@ -0,0 +1,127 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Beats;
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Projects;
namespace Novelly.Api.Tests;
[TestFixture]
public class CharacterServiceTests : ServiceTestFixture
{
private Guid _projectId;
protected override void OnSetUp()
{
_projectId = Projects.CreateAsync(new CreateProjectRequest("The Salt Road")).Result.Id;
}
[Test]
public async Task New_characters_default_to_supporting_role_and_importance()
{
var character = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines"));
Assert.Multiple(() =>
{
Assert.That(character.Role, Is.EqualTo(CharacterRole.Supporting));
Assert.That(character.Importance, Is.EqualTo(CharacterImportance.Supporting));
});
}
[Test]
public async Task Promoting_a_character_to_main_sticks_until_changed_again()
{
var character = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines"));
var promoted = (await Characters.UpdateAsync(
character.Id, new UpdateCharacterRequest(Importance: CharacterImportance.Main)))!;
Assert.That(promoted.Importance, Is.EqualTo(CharacterImportance.Main));
var afterUnrelatedUpdate = (await Characters.UpdateAsync(
character.Id, new UpdateCharacterRequest(Occupation: "Cartographer")))!;
Assert.That(afterUnrelatedUpdate.Importance, Is.EqualTo(CharacterImportance.Main));
}
[Test]
public async Task Updating_leaves_omitted_fields_alone_and_clears_on_empty_string()
{
var character = await Characters.CreateAsync(_projectId, new CreateCharacterRequest(
"Ines", Want: "To find her sister.", Need: "To let go of the guilt."));
var renamed = (await Characters.UpdateAsync(character.Id, new UpdateCharacterRequest(Name: "Ines Vell")))!;
Assert.Multiple(() =>
{
Assert.That(renamed.Name, Is.EqualTo("Ines Vell"));
Assert.That(renamed.Want, Is.EqualTo("To find her sister."));
Assert.That(renamed.Need, Is.EqualTo("To let go of the guilt."));
});
var cleared = (await Characters.UpdateAsync(character.Id, new UpdateCharacterRequest(Need: "")))!;
Assert.Multiple(() =>
{
Assert.That(cleared.Need, Is.Null);
Assert.That(cleared.Want, Is.EqualTo("To find her sister."));
});
}
[Test]
public async Task Relating_characters_across_projects_is_refused()
{
var other = await Projects.CreateAsync(new CreateProjectRequest("Other Book"));
var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines"));
var stranger = await Characters.CreateAsync(other.Id, new CreateCharacterRequest("Stranger"));
Assert.That(
async () => await Characters.AddRelationshipAsync(
ines.Id, new CreateRelationshipRequest(stranger.Id, "sister")),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("same project"));
}
[Test]
public async Task Removing_a_relationship_leaves_both_characters_in_place()
{
var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines"));
var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara"));
var withRelationship = (await Characters.AddRelationshipAsync(
ines.Id, new CreateRelationshipRequest(mara.Id, "sister")))!;
var relationshipId = withRelationship.Relationships[0].Id;
var removed = await Characters.RemoveRelationshipAsync(relationshipId);
var afterRemoval = (await Characters.GetAsync(ines.Id))!;
Assert.Multiple(() =>
{
Assert.That(removed, Is.True);
Assert.That(afterRemoval.Relationships, Is.Empty);
});
}
[Test]
public async Task Deleting_a_character_detaches_it_from_beats_rather_than_deleting_them()
{
var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall"));
var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines"));
var beat = await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("She finds the map", CharacterIds: [ines.Id]));
await Characters.DeleteAsync(ines.Id);
using var verification = Db.CreateContext();
var survivingBeat = await verification.Beats.FirstAsync(b => b.Id == beat.Id);
Assert.That(survivingBeat, Is.Not.Null);
}
[Test]
public async Task Creating_a_character_under_a_missing_project_returns_null_rather_than_throwing() =>
Assert.That(
await Characters.CreateAsync(Guid.NewGuid(), new CreateCharacterRequest("Ines")),
Is.Null);
[Test]
public async Task Reading_a_missing_character_returns_null_rather_than_throwing() =>
Assert.That(await Characters.GetAsync(Guid.NewGuid()), Is.Null);
}
+11 -4
View File
@@ -19,6 +19,7 @@ public class ImportServiceTests : ServiceTestFixture
Db.Context,
Projects,
_queue,
UserContext,
new CapturingLogger<ImportService>(),
new InspectImportRequestValidator(),
new StartImportRequestValidator());
@@ -93,8 +94,11 @@ public class ImportServiceTests : ServiceTestFixture
Assert.That(job.SourceRoot, Is.EqualTo(_root));
});
Assert.That(_queue.Reader.TryRead(out var queued), Is.True);
Assert.That(queued, Is.EqualTo(job.Id));
Assert.Multiple(() =>
{
Assert.That(_queue.Reader.TryRead(out var queued), Is.True);
Assert.That(queued, Is.EqualTo(job.Id));
});
}
[Test]
@@ -105,8 +109,11 @@ public class ImportServiceTests : ServiceTestFixture
Assert.That(second.Id, Is.EqualTo(first.Id));
Assert.That(_queue.Reader.TryRead(out _), Is.True);
Assert.That(_queue.Reader.TryRead(out _), Is.False);
Assert.Multiple(() =>
{
Assert.That(_queue.Reader.TryRead(out _), Is.True);
Assert.That(_queue.Reader.TryRead(out _), Is.False);
});
}
[Test]
+2 -3
View File
@@ -11,7 +11,7 @@ namespace Novelly.Api.Tests;
public class LoggingTests : ServiceTestFixture
{
[Test]
public async Task Fetching_a_missing_chapter_returns_null_and_logs_at_information_not_warning()
public async Task Fetching_a_missing_chapter_returns_null_and_logs_a_warning()
{
var missingId = Guid.NewGuid();
@@ -20,10 +20,9 @@ public class LoggingTests : ServiceTestFixture
Assert.Multiple(() =>
{
Assert.That(result, Is.Null);
Assert.That(ChapterLogs.Entries.Where(e => e.Level == LogLevel.Warning), Is.Empty);
Assert.That(
ChapterLogs.Entries,
Has.Some.Matches<CapturedLogEntry>(e => e.Level == LogLevel.Information && e.Message.Contains(missingId.ToString())));
Has.Some.Matches<CapturedLogEntry>(e => e.Level == LogLevel.Warning && e.Message.Contains(missingId.ToString())));
});
}
@@ -10,6 +10,7 @@
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="coverlet.msbuild" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="NUnit" Version="4.6.1" />
<PackageReference Include="NUnit.Analyzers" Version="4.14.0">
@@ -0,0 +1,135 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Chapters;
using Novelly.Api.Common;
using Novelly.Api.Projects;
using Novelly.Api.Users;
namespace Novelly.Api.Tests;
[TestFixture]
public class ProjectAccessTests : ServiceTestFixture
{
private Guid AsNewUser(GlobalRole globalRole)
{
var user = new NovellyUser
{
Id = Guid.NewGuid(),
UserName = $"{Guid.NewGuid()}@novelly.test",
Email = $"{Guid.NewGuid()}@novelly.test",
DisplayName = "Test User",
GlobalRole = globalRole
};
Db.Context.Users.Add(user);
Db.Context.SaveChanges();
UserContext.UserId = user.Id;
UserContext.GlobalRole = globalRole;
return user.Id;
}
private void GrantProjectRole(Guid projectId, Guid userId, ProjectRole role)
{
Db.Context.ProjectMembers.Add(new ProjectMember { ProjectId = projectId, UserId = userId, ProjectRole = role, GrantedByUserId = userId });
Db.Context.SaveChanges();
}
private void AsAdmin()
{
UserContext.UserId = Db.Context.Users.Single(u => u.GlobalRole == GlobalRole.Admin).Id;
UserContext.GlobalRole = GlobalRole.Admin;
}
[Test]
public async Task A_writer_sees_only_novels_they_own_or_have_been_granted()
{
var writerId = AsNewUser(GlobalRole.Writer);
var ownedProject = await Projects.CreateAsync(new CreateProjectRequest("Owned by writer"));
AsAdmin();
var otherProject = await Projects.CreateAsync(new CreateProjectRequest("Owned by someone else"));
UserContext.UserId = writerId;
UserContext.GlobalRole = GlobalRole.Writer;
var visibleBeforeGrant = await Projects.ListAsync();
Assert.That(visibleBeforeGrant.Select(p => p.Id), Is.EquivalentTo(new[] { ownedProject.Id }));
GrantProjectRole(otherProject.Id, writerId, ProjectRole.Reviewer);
var visibleAfterGrant = await Projects.ListAsync();
Assert.That(visibleAfterGrant.Select(p => p.Id), Is.EquivalentTo(new[] { ownedProject.Id, otherProject.Id }));
}
[Test]
public async Task An_editor_can_rewrite_a_chapter_but_cannot_delete_it()
{
var project = await Projects.CreateAsync(new CreateProjectRequest("Editable Novel"));
var chapter = await Chapters.CreateAsync(project.Id, new CreateChapterRequest("Chapter One"));
var editorId = AsNewUser(GlobalRole.Reviewer);
GrantProjectRole(project.Id, editorId, ProjectRole.Editor);
var updated = await Chapters.UpdateAsync(chapter!.Id, new UpdateChapterRequest(Title: "Renamed"));
Assert.That(updated!.Title, Is.EqualTo("Renamed"));
Assert.That(() => Chapters.DeleteAsync(chapter.Id), Throws.TypeOf<NotAuthorizedException>());
}
[Test]
public void An_editor_cannot_create_a_new_novel()
{
AsNewUser(GlobalRole.Editor);
Assert.That(() => Projects.CreateAsync(new CreateProjectRequest("Should not exist")), Throws.TypeOf<NotAuthorizedException>());
}
[Test]
public async Task A_reviewer_can_read_a_chapter_but_not_change_it()
{
var project = await Projects.CreateAsync(new CreateProjectRequest("Reviewed Novel"));
var chapter = await Chapters.CreateAsync(project.Id, new CreateChapterRequest("Chapter One"));
var reviewerId = AsNewUser(GlobalRole.Reviewer);
GrantProjectRole(project.Id, reviewerId, ProjectRole.Reviewer);
var read = await Chapters.GetAsync(chapter!.Id);
Assert.That(read, Is.Not.Null);
Assert.That(
() => Chapters.UpdateAsync(chapter.Id, new UpdateChapterRequest(Title: "Nope")),
Throws.TypeOf<NotAuthorizedException>());
}
[Test]
public async Task A_writer_granted_access_to_someone_elses_novel_still_cannot_grant_access_to_others()
{
var project = await Projects.CreateAsync(new CreateProjectRequest("Someone Else's Novel"));
var grantedWriterId = AsNewUser(GlobalRole.Writer);
GrantProjectRole(project.Id, grantedWriterId, ProjectRole.Writer);
Assert.That(
() => Access.RequireAsync(project.Id, ProjectPermission.ManageAccess),
Throws.TypeOf<NotAuthorizedException>());
}
[Test]
public async Task An_admin_reaches_every_novel()
{
AsNewUser(GlobalRole.Writer);
await Projects.CreateAsync(new CreateProjectRequest("Writer's Novel"));
AsAdmin();
await Projects.CreateAsync(new CreateProjectRequest("Admin's Novel"));
var visible = await Projects.ListAsync();
Assert.That(visible, Has.Count.EqualTo(2));
}
[Test]
public void Deleting_a_user_does_not_cascade_to_their_novels()
{
var ownerNavigation = Db.Context.Model.FindEntityType(typeof(Project))!.FindNavigation(nameof(Project.Owner))!;
Assert.That(ownerNavigation.ForeignKey.DeleteBehavior, Is.EqualTo(DeleteBehavior.Restrict));
}
}
@@ -0,0 +1,121 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Novelly.Api.Users;
namespace Novelly.Api.Tests;
[TestFixture]
public class ServiceApiKeyTests
{
private const string ConfiguredKey = "s3rvice-key-value";
private TestDatabase _db = null!;
[SetUp]
public void SetUp() => _db = new TestDatabase();
[TearDown]
public void TearDown() => _db.Dispose();
[Test]
public async Task A_request_with_the_configured_service_key_is_admitted_as_the_service_user()
{
await ServiceUser.EnsureSeededAsync(_db.Context, ConfiguredKey, NullLogger.Instance);
var result = await AuthenticateAsync(ConfiguredKey, ConfiguredKey);
Assert.Multiple(() =>
{
Assert.That(result.Succeeded, Is.True);
Assert.That(result.Principal?.FindFirstValue(ClaimTypes.NameIdentifier), Is.EqualTo(ServiceUser.Id.ToString()));
Assert.That(result.Principal?.FindFirstValue(ClaimTypes.Role), Is.EqualTo(nameof(GlobalRole.Admin)));
});
}
[Test]
public async Task A_request_with_a_wrong_key_is_rejected()
{
await ServiceUser.EnsureSeededAsync(_db.Context, ConfiguredKey, NullLogger.Instance);
var result = await AuthenticateAsync(ConfiguredKey, "not-the-key");
Assert.Multiple(() =>
{
Assert.That(result.Succeeded, Is.False);
Assert.That(result.Principal, Is.Null);
});
}
[Test]
public async Task The_api_still_serves_signed_in_users_when_no_service_key_is_configured()
{
var seeded = await ServiceUser.EnsureSeededAsync(_db.Context, null, NullLogger.Instance);
var signedIn = new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString())], "Identity.Application"));
var context = new DefaultHttpContext { User = signedIn };
var result = await AuthenticateAsync(configuredKey: null, presentedKey: null, context);
Assert.Multiple(() =>
{
Assert.That(seeded, Is.Null);
Assert.That(result.None, Is.True);
Assert.That(context.User, Is.SameAs(signedIn));
});
}
[Test]
public async Task A_service_key_presented_when_none_is_configured_is_rejected()
{
var result = await AuthenticateAsync(configuredKey: null, presentedKey: "anything");
Assert.That(result.Succeeded, Is.False);
}
[Test]
public async Task Seeding_the_service_user_twice_leaves_one_row()
{
await ServiceUser.EnsureSeededAsync(_db.Context, ConfiguredKey, NullLogger.Instance);
await ServiceUser.EnsureSeededAsync(_db.Context, ConfiguredKey, NullLogger.Instance);
Assert.That(_db.Context.Users.Count(u => u.Id == ServiceUser.Id), Is.EqualTo(1));
}
private async Task<AuthenticateResult> AuthenticateAsync(string? configuredKey, string? presentedKey, DefaultHttpContext? context = null)
{
var settings = configuredKey is null
? new Dictionary<string, string?>()
: new Dictionary<string, string?> { [ServiceApiKeyAuthenticationHandler.ConfigurationKey] = configuredKey };
var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
var handler = new ServiceApiKeyAuthenticationHandler(
new StaticSchemeOptions(), NullLoggerFactory.Instance, UrlEncoder.Default, configuration, _db.Context);
var httpContext = context ?? new DefaultHttpContext();
if (presentedKey is not null)
{
httpContext.Request.Headers[ServiceApiKeyAuthenticationHandler.HeaderName] = presentedKey;
}
var scheme = new AuthenticationScheme(
ServiceApiKeyAuthenticationHandler.SchemeName, null, typeof(ServiceApiKeyAuthenticationHandler));
await handler.InitializeAsync(scheme, httpContext);
return await handler.AuthenticateAsync();
}
private class StaticSchemeOptions : IOptionsMonitor<AuthenticationSchemeOptions>
{
public AuthenticationSchemeOptions CurrentValue { get; } = new();
public AuthenticationSchemeOptions Get(string? name) => CurrentValue;
public IDisposable? OnChange(Action<AuthenticationSchemeOptions, string?> listener) => null;
}
}
+23 -7
View File
@@ -5,12 +5,15 @@ using Novelly.Api.Genres;
using Novelly.Api.Projects;
using Novelly.Api.Questions;
using Novelly.Api.Tags;
using Novelly.Api.Users;
namespace Novelly.Api.Tests;
public abstract class ServiceTestFixture
{
protected TestDatabase Db { get; private set; } = null!;
protected TestUserContext UserContext { get; private set; } = null!;
protected ProjectAccessService Access { get; private set; } = null!;
protected TagService Tags { get; private set; } = null!;
protected ProjectService Projects { get; private set; } = null!;
protected CharacterService Characters { get; private set; } = null!;
@@ -33,6 +36,18 @@ public abstract class ServiceTestFixture
public void SetUpFixture()
{
Db = new TestDatabase();
UserContext = new TestUserContext();
Access = new ProjectAccessService(Db.Context, UserContext, new CapturingLogger<ProjectAccessService>());
Db.Context.Users.Add(new NovellyUser
{
Id = UserContext.UserId!.Value,
UserName = "admin@novelly.test",
Email = "admin@novelly.test",
DisplayName = "Test Admin",
GlobalRole = GlobalRole.Admin
});
Db.Context.SaveChanges();
TagLogs = new CapturingLogger<TagService>();
ProjectLogs = new CapturingLogger<ProjectService>();
@@ -43,21 +58,22 @@ public abstract class ServiceTestFixture
QuestionLogs = new CapturingLogger<OpenQuestionService>();
GenreLogs = new CapturingLogger<GenreService>();
Tags = new TagService(Db.Context, TagLogs, new CreateTagRequestValidator(), new UpdateTagRequestValidator());
Projects = new ProjectService(Db.Context, ProjectLogs, new CreateProjectRequestValidator(), new UpdateProjectRequestValidator());
Tags = new TagService(Db.Context, Access, TagLogs, new CreateTagRequestValidator(), new UpdateTagRequestValidator());
Projects = new ProjectService(
Db.Context, Access, UserContext, ProjectLogs, new CreateProjectRequestValidator(), new UpdateProjectRequestValidator());
Characters = new CharacterService(
Db.Context, Tags, CharacterLogs,
Db.Context, Access, Tags, CharacterLogs,
new CreateCharacterRequestValidator(), new UpdateCharacterRequestValidator(), new CreateRelationshipRequestValidator());
Chapters = new ChapterService(Db.Context, Tags, ChapterLogs, new CreateChapterRequestValidator(), new UpdateChapterRequestValidator());
Chapters = new ChapterService(Db.Context, Access, Tags, ChapterLogs, new CreateChapterRequestValidator(), new UpdateChapterRequestValidator());
Beats = new BeatService(
Db.Context, Tags, BeatLogs,
Db.Context, Access, Tags, BeatLogs,
new CreateBeatRequestValidator(), new UpdateBeatRequestValidator(), new ReorderBeatsRequestValidator(),
new AssignCharacterToBeatsRequestValidator());
Arcs = new CharacterArcService(
Db.Context, ArcLogs,
Db.Context, Access, ArcLogs,
new CreateArcStageRequestValidator(), new UpdateArcStageRequestValidator(), new ReorderArcStagesRequestValidator());
Questions = new OpenQuestionService(
Db.Context, QuestionLogs,
Db.Context, Access, QuestionLogs,
new CreateOpenQuestionRequestValidator(), new UpdateOpenQuestionRequestValidator(), new ResolveOpenQuestionRequestValidator());
Genres = new GenreService(Db.Context, GenreLogs);
@@ -0,0 +1,10 @@
using Novelly.Api.Users;
namespace Novelly.Api.Tests;
public class TestUserContext : INovelUserContext
{
public bool IsAuthenticated { get; set; } = true;
public Guid? UserId { get; set; } = Guid.NewGuid();
public GlobalRole? GlobalRole { get; set; } = Users.GlobalRole.Admin;
}
+111
View File
@@ -0,0 +1,111 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Novelly.Api.Data;
using Novelly.Api.Users;
namespace Novelly.Api.Tests;
[TestFixture]
public class UserAccountTests
{
private TestDatabase _db = null!;
private ServiceProvider _provider = null!;
private IServiceScope _scope = null!;
private UserAccountService _accounts = null!;
[SetUp]
public void SetUp()
{
_db = new TestDatabase();
var services = new ServiceCollection();
services.AddLogging();
services.AddHttpContextAccessor();
services.AddDataProtection();
services.AddAuthentication(IdentityConstants.ApplicationScheme).AddIdentityCookies();
services.AddScoped(_ => _db.CreateContext());
services.AddScoped<INovelDbContext>(sp => sp.GetRequiredService<NovelDbContext>());
services.AddIdentityCore<NovellyUser>(options => options.User.RequireUniqueEmail = true)
.AddEntityFrameworkStores<NovelDbContext>()
.AddSignInManager();
_provider = services.BuildServiceProvider();
_provider.GetRequiredService<IHttpContextAccessor>().HttpContext = new DefaultHttpContext { RequestServices = _provider };
_scope = _provider.CreateScope();
_accounts = new UserAccountService(
_scope.ServiceProvider.GetRequiredService<UserManager<NovellyUser>>(),
_scope.ServiceProvider.GetRequiredService<SignInManager<NovellyUser>>(),
_scope.ServiceProvider.GetRequiredService<INovelDbContext>(),
new CapturingLogger<UserAccountService>(),
new RegisterRequestValidator(),
new LoginRequestValidator());
}
[TearDown]
public void TearDown()
{
_scope.Dispose();
_provider.Dispose();
_db.Dispose();
}
[Test]
public async Task The_first_account_created_becomes_an_admin()
{
var user = await _accounts.RegisterAsync(new RegisterRequest("first@novelly.test", "Password123!", "First Writer"));
Assert.That(user.GlobalRole, Is.EqualTo(GlobalRole.Admin));
}
[Test]
public async Task The_first_account_created_alongside_a_seeded_service_user_still_becomes_an_admin()
{
await ServiceUser.EnsureSeededAsync(_db.Context, "a-service-key", NullLogger.Instance);
var user = await _accounts.RegisterAsync(new RegisterRequest("first@novelly.test", "Password123!", "First Writer"));
Assert.That(user.GlobalRole, Is.EqualTo(GlobalRole.Admin));
}
[Test]
public async Task Accounts_created_after_the_first_are_reviewers()
{
await _accounts.RegisterAsync(new RegisterRequest("first@novelly.test", "Password123!", "First Writer"));
var second = await _accounts.RegisterAsync(new RegisterRequest("second@novelly.test", "Password123!", "Second Writer"));
Assert.That(second.GlobalRole, Is.EqualTo(GlobalRole.Reviewer));
}
[Test]
public async Task Registering_with_an_email_already_in_use_is_rejected()
{
await _accounts.RegisterAsync(new RegisterRequest("taken@novelly.test", "Password123!", "First Writer"));
Assert.That(
() => _accounts.RegisterAsync(new RegisterRequest("taken@novelly.test", "Password123!", "Someone Else")),
Throws.TypeOf<ArgumentException>());
}
[Test]
public async Task Signing_in_with_the_wrong_password_is_rejected()
{
await _accounts.RegisterAsync(new RegisterRequest("first@novelly.test", "Password123!", "First Writer"));
var result = await _accounts.LoginAsync(new LoginRequest("first@novelly.test", "WrongPassword!"));
Assert.That(result, Is.Null);
}
[Test]
public async Task Signing_in_with_the_right_password_succeeds()
{
await _accounts.RegisterAsync(new RegisterRequest("first@novelly.test", "Password123!", "First Writer"));
var result = await _accounts.LoginAsync(new LoginRequest("first@novelly.test", "Password123!"));
Assert.That(result?.Email, Is.EqualTo("first@novelly.test"));
}
}