Files
novelly/tests/Novelly.Api.Tests/ProjectAccessTests.cs
T
Claude e2a2e69631 Surface project ownership/role on the wire and gate the web UI by it
ProjectResponse now carries OwnerId and a server-resolved MyRole so
Editors/Reviewers see read-only fields and no delete/grant-management
affordances instead of only finding out via a 403 after the fact.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PuBH9QSv66DPXSSBERmPs6
2026-08-16 15:37:50 +00:00

183 lines
6.3 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));
}
[Test]
public async Task The_creator_of_a_novel_sees_their_role_as_owner()
{
var writerId = AsNewUser(GlobalRole.Writer);
var project = await Projects.CreateAsync(new CreateProjectRequest("Owned by writer"));
UserContext.UserId = writerId;
var role = await Access.GetMyRoleAsync(project);
Assert.That(role, Is.EqualTo("Owner"));
}
[Test]
public async Task An_admin_sees_their_role_as_admin_even_on_a_novel_they_do_not_own()
{
AsNewUser(GlobalRole.Writer);
var project = await Projects.CreateAsync(new CreateProjectRequest("Owned by someone else"));
AsAdmin();
var role = await Access.GetMyRoleAsync(project);
Assert.That(role, Is.EqualTo("Admin"));
}
[Test]
public async Task A_user_granted_editor_sees_their_role_as_editor()
{
var project = await Projects.CreateAsync(new CreateProjectRequest("Granted Novel"));
var editorId = AsNewUser(GlobalRole.Reviewer);
GrantProjectRole(project.Id, editorId, ProjectRole.Editor);
var role = await Access.GetMyRoleAsync(project);
Assert.That(role, Is.EqualTo("Editor"));
}
[Test]
public async Task A_user_with_no_access_sees_a_null_role()
{
var project = await Projects.CreateAsync(new CreateProjectRequest("Someone Else's Novel"));
AsNewUser(GlobalRole.Writer);
var role = await Access.GetMyRoleAsync(project);
Assert.That(role, Is.Null);
}
}