Files
novelly/tests/Novelly.Api.Tests/ProjectAccessTests.cs
T
James Wampler e598c18d67 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.
2026-08-15 22:29:33 -07:00

136 lines
4.9 KiB
C#

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