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
+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"));
}
}