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.
90 lines
3.3 KiB
C#
90 lines
3.3 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using Novelly.Api.Beats;
|
|
using Novelly.Api.Chapters;
|
|
using Novelly.Api.Characters;
|
|
using Novelly.Api.Common;
|
|
using Novelly.Api.Projects;
|
|
|
|
namespace Novelly.Api.Tests;
|
|
|
|
[TestFixture]
|
|
public class LoggingTests : ServiceTestFixture
|
|
{
|
|
[Test]
|
|
public async Task Fetching_a_missing_chapter_returns_null_and_logs_a_warning()
|
|
{
|
|
var missingId = Guid.NewGuid();
|
|
|
|
var result = await Chapters.GetAsync(missingId);
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(result, Is.Null);
|
|
Assert.That(
|
|
ChapterLogs.Entries,
|
|
Has.Some.Matches<CapturedLogEntry>(e => e.Level == LogLevel.Warning && e.Message.Contains(missingId.ToString())));
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public async Task Creating_a_chapter_logs_the_project_and_title_at_information()
|
|
{
|
|
var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"));
|
|
ChapterLogs.Entries.Clear();
|
|
|
|
await Chapters.CreateAsync(project.Id, new CreateChapterRequest("Landfall"));
|
|
|
|
var info = ChapterLogs.Entries.Single(e => e.Level == LogLevel.Information);
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(info.Message, Does.Contain("Landfall"));
|
|
Assert.That(info.Message, Does.Contain(project.Id.ToString()));
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public async Task Logged_values_never_include_a_chapter_summary_body()
|
|
{
|
|
var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"));
|
|
const string secretSummary = "A very specific plot twist nobody should see in a log line.";
|
|
ChapterLogs.Entries.Clear();
|
|
|
|
await Chapters.CreateAsync(project.Id, new CreateChapterRequest("Landfall", Summary: secretSummary));
|
|
|
|
Assert.That(ChapterLogs.Entries.Select(e => e.Message), Has.None.Contain(secretSummary));
|
|
}
|
|
|
|
[Test]
|
|
public async Task Deleting_a_project_logs_information_before_the_lookup()
|
|
{
|
|
var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"));
|
|
ProjectLogs.Entries.Clear();
|
|
|
|
await Projects.DeleteAsync(project.Id);
|
|
|
|
Assert.That(
|
|
ProjectLogs.Entries,
|
|
Has.Some.Matches<CapturedLogEntry>(e => e.Level == LogLevel.Information && e.Message.Contains(project.Id.ToString())));
|
|
}
|
|
|
|
[Test]
|
|
public async Task Rejecting_a_beat_with_a_foreign_character_logs_a_warning_not_an_error()
|
|
{
|
|
var projectA = await Projects.CreateAsync(new CreateProjectRequest("Project A"));
|
|
var projectB = await Projects.CreateAsync(new CreateProjectRequest("Project B"));
|
|
var chapter = await Chapters.CreateAsync(projectA.Id, new CreateChapterRequest("Landfall"));
|
|
var foreignCharacter = await Characters.CreateAsync(projectB.Id, new CreateCharacterRequest("Ines"));
|
|
BeatLogs.Entries.Clear();
|
|
|
|
Assert.That(
|
|
() => Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Arrival", CharacterIds: [foreignCharacter.Id])),
|
|
Throws.TypeOf<InvalidOperationException>());
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(BeatLogs.Entries.Where(e => e.Level == LogLevel.Error), Is.Empty);
|
|
Assert.That(BeatLogs.Entries.Any(e => e.Level == LogLevel.Warning), Is.True);
|
|
});
|
|
}
|
|
}
|