From 0b7e690ad5beec7791f754ac477ab400e78f8279 Mon Sep 17 00:00:00 2001 From: exstraspace2 Date: Tue, 3 Nov 2026 09:53:57 +0100 Subject: [PATCH 1/2] feat(authoring): add Platform.Authoring module with drafts and templates (AUT-01, AUT-06) --- README.md | 97 +++++++- .../Application/DraftServiceTests.cs | 219 ++++++++++++++++++ .../Domain/LabContentTests.cs | 100 ++++++++ .../Domain/LabDraftTests.cs | 112 +++++++++ .../Domain/LabTemplateTests.cs | 107 +++++++++ .../Fakes/FakeAccess.cs | 29 +++ .../Fakes/InMemoryDraftRepository.cs | 37 +++ .../Platform.Authoring.Tests.csproj | 27 +++ .../Application/DraftService.cs | 60 +++++ .../Application/IAuthoringAccess.cs | 20 ++ .../Application/IDraftRepository.cs | 17 ++ .../Application/ITemplateRepository.cs | 10 + .../Domain/AuthoringErrors.cs | 21 ++ .../Platform.Authoring/Domain/DraftStatus.cs | 13 ++ .../Platform.Authoring/Domain/LabContent.cs | 40 ++++ backend/Platform.Authoring/Domain/LabDraft.cs | 71 ++++++ .../Platform.Authoring/Domain/LabTemplate.cs | 45 ++++ backend/Platform.Authoring/Domain/LabType.cs | 17 ++ .../Infrastructure/BuiltInTemplates.cs | 55 +++++ .../Platform.Authoring.csproj | 9 + backend/SecurityLab.slnx | 2 + docs/PROJECT_COMPONENTS.md | 1 + docs/components/AUT-authoring.md | 86 +++++++ 23 files changed, 1189 insertions(+), 6 deletions(-) create mode 100644 backend/Platform.Authoring.Tests/Application/DraftServiceTests.cs create mode 100644 backend/Platform.Authoring.Tests/Domain/LabContentTests.cs create mode 100644 backend/Platform.Authoring.Tests/Domain/LabDraftTests.cs create mode 100644 backend/Platform.Authoring.Tests/Domain/LabTemplateTests.cs create mode 100644 backend/Platform.Authoring.Tests/Fakes/FakeAccess.cs create mode 100644 backend/Platform.Authoring.Tests/Fakes/InMemoryDraftRepository.cs create mode 100644 backend/Platform.Authoring.Tests/Platform.Authoring.Tests.csproj create mode 100644 backend/Platform.Authoring/Application/DraftService.cs create mode 100644 backend/Platform.Authoring/Application/IAuthoringAccess.cs create mode 100644 backend/Platform.Authoring/Application/IDraftRepository.cs create mode 100644 backend/Platform.Authoring/Application/ITemplateRepository.cs create mode 100644 backend/Platform.Authoring/Domain/AuthoringErrors.cs create mode 100644 backend/Platform.Authoring/Domain/DraftStatus.cs create mode 100644 backend/Platform.Authoring/Domain/LabContent.cs create mode 100644 backend/Platform.Authoring/Domain/LabDraft.cs create mode 100644 backend/Platform.Authoring/Domain/LabTemplate.cs create mode 100644 backend/Platform.Authoring/Domain/LabType.cs create mode 100644 backend/Platform.Authoring/Infrastructure/BuiltInTemplates.cs create mode 100644 backend/Platform.Authoring/Platform.Authoring.csproj create mode 100644 docs/components/AUT-authoring.md diff --git a/README.md b/README.md index 485fde1..46f55a1 100644 --- a/README.md +++ b/README.md @@ -8,23 +8,108 @@ The repository contains a starter application, not a complete implementation: au The reference stack is React and TypeScript for the frontend, ASP.NET Core and C# for the API, PostgreSQL for data storage, and NUnit for tests. Alternative components may be used when justified and compatible with the domain rules and security boundaries. +## Requirements + +| Tool | Version | Check with | +| --- | --- | --- | +| .NET SDK | 10.x | `dotnet --version` | +| Node.js | 22.12 or later (or 24 or later) | `node --version` | +| Git | any recent version | `git --version` | +| Docker Desktop | optional, only for the local PostgreSQL service | `docker --version` | + ## Getting started -Requirements: .NET SDK 10 and Node.js 22.12 or later (or Node.js 24 or later). +Run all commands from the repository root unless stated otherwise. The commands below work in PowerShell, Command Prompt and Bash. + +### 1. Get the code + +``` +git clone https://github.com/owlCoder/security-lab.git +cd security-lab +``` + +### 2. Start the API (terminal 1) + +``` +cd backend/Platform.Api +dotnet run --urls http://localhost:5080 +``` + +Leave this terminal open. When the API is running, open . It should return JSON containing `"status":"ok"`. + +### 3. Start the frontend (terminal 2) + +``` +cd frontend +npm ci +npm run dev +``` + +`npm ci` is only needed the first time and after `package-lock.json` changes. Then open . The page shows **API available** under "Development status" when the frontend can reach the API. The frontend forwards every `/api` request to `http://localhost:5080` (see `frontend/vite.config.ts`), so the API must be running first. + +To stop either program, press `Ctrl+C` in its terminal. + +### 4. Optional: local PostgreSQL -1. Start the API: `cd backend/Platform.Api && dotnet run --urls http://localhost:5080` -2. In another terminal, start the frontend: `cd frontend && npm ci && npm run dev` -3. Open `http://localhost:5173`. The API health endpoint is `http://localhost:5080/api/health`. +No module uses the database yet. When one does, start the local PostgreSQL service like this: -The optional local PostgreSQL service can be started with Docker Compose after copying `.env.example` to `.env` and setting the required credentials. +1. Copy `.env.example` to `.env` (`copy .env.example .env` in PowerShell or Command Prompt, `cp .env.example .env` in Bash). +2. Open `.env` and replace `CHANGE_ME_FOR_LOCAL_DEVELOPMENT` with a password of your choice. Never commit `.env`; it is already in `.gitignore`. +3. Start the service and check that it is healthy: + +``` +docker compose up -d db +docker compose ps +``` + +The database listens on `127.0.0.1:5432` only. Stop it with `docker compose down` (add `-v` to also delete its data). + +## Running the checks + +These are the same checks that run in CI (`.github/workflows/ci.yml`). Run them before opening a pull request. + +``` +dotnet build backend/SecurityLab.slnx --configuration Release +dotnet test backend/SecurityLab.slnx --configuration Release --no-build +cd frontend +npm ci +npm run build +``` + +CI builds and tests the whole backend through the solution file `backend/SecurityLab.slnx`. The first run downloads the NUnit packages, so it needs an internet connection. All tests should pass. To run only the authoring tests, use `dotnet test backend/Platform.Authoring.Tests/Platform.Authoring.Tests.csproj --configuration Release`. + +If you add a backend module or a test project, add it to the solution so that CI picks it up: `dotnet sln backend/SecurityLab.slnx add backend//.csproj`. + +## Troubleshooting + +| Problem | What to do | +| --- | --- | +| The page says "API unavailable (start the backend)" | The API is not running or is not on port 5080. Start it as in step 2 and reload the page. | +| `Address already in use` when starting the API or the frontend | Another program uses port 5080 or 5173. Close it, or stop the earlier run of this project with `Ctrl+C`. | +| `dotnet` reports that the target framework `net10.0` is not found | Your .NET SDK is older than 10. Install .NET SDK 10 and check `dotnet --version`. | +| `npm ci` or `npm run dev` reports an unsupported engine or fails on syntax | Your Node.js is too old. Install Node.js 22.12 or later and check `node --version`. | +| `dotnet test` cannot restore packages | Check your internet connection or proxy, then run the command again. | +| `docker compose` complains about `Define POSTGRES_PASSWORD in local .env` | Create `.env` from `.env.example` as in the optional PostgreSQL step. | ## Repository structure +- `backend/SecurityLab.slnx` — backend solution file used by CI. - `backend/Platform.Api/` — API entry point. +- `backend/Platform.Access/` — access control module (ACC-05, ACC-08). +- `backend/Platform.Audit/` and `backend/Platform.Audit.Tests/` — audit module skeleton and its tests (AUD-01). +- `backend/Platform.Authoring/` — authoring module: laboratory drafts and templates (AUT-01, AUT-06). +- `backend/Platform.Authoring.Tests/` — NUnit tests for the authoring module. +- `backend/Platform.Tests/` — NUnit tests that use the API project (identity tests). - `frontend/` — React application. - `docs/ARCHITECTURE.md` — conceptual architecture and module boundaries. - `docs/DATA_FLOW.md` — platform-level data flow example. +- `docs/DEVELOPMENT.md` — development workflow. - `docs/PROJECT_COMPONENTS.md` — project component assignments. - `docs/templates/PROJECT_COMPONENT.md` — component documentation template. +- `docs/components/` — documentation of individual project components. + +## Contributing + +Use short-lived branches and pull requests for changes (for example `feature/AUT-01-AUT-06-lab-drafts`), and mention the requirement IDs from the specification in commits and pull requests. See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for the full workflow. Keep credentials and other secrets out of the repository. -See the course specification for the full requirements. Use short-lived branches and pull requests for changes; keep credentials and other secrets out of the repository. +See the course specification for the full requirements. diff --git a/backend/Platform.Authoring.Tests/Application/DraftServiceTests.cs b/backend/Platform.Authoring.Tests/Application/DraftServiceTests.cs new file mode 100644 index 0000000..26aa86e --- /dev/null +++ b/backend/Platform.Authoring.Tests/Application/DraftServiceTests.cs @@ -0,0 +1,219 @@ +using Platform.Authoring.Application; +using Platform.Authoring.Domain; +using Platform.Authoring.Infrastructure; +using Platform.Authoring.Tests.Fakes; + +namespace Platform.Authoring.Tests.Application; + +/// +/// Use-case level tests for AUT-01 and AUT-06, including the negative access paths +/// (a student or another teacher must never create, read the existence of, or change a draft). +/// +[TestFixture] +public class DraftServiceTests +{ + private readonly Guid _author = Guid.NewGuid(); + private readonly Guid _otherAuthor = Guid.NewGuid(); + private readonly Guid _student = Guid.NewGuid(); + + private InMemoryDraftRepository _drafts = null!; + private DraftService _service = null!; + + [SetUp] + public void SetUp() + { + _drafts = new InMemoryDraftRepository(); + _service = new DraftService(_drafts, new BuiltInTemplateRepository(), + new FakeAuthoringAccess(_drafts, _author, _otherAuthor)); + } + + private static LabContent Content(string title = "Lab one") => LabContent.Create(title, "Goal", LabType.AccessControl); + + private static string CodeOf(AsyncTestDelegate act) => Assert.ThrowsAsync(act)!.Code; + + // ---------- AUT-01: create and edit ---------- + + [Test] + public async Task Author_can_create_a_draft_and_it_is_stored() + { + var draft = await _service.CreateDraftAsync(_author, Content()); + + var stored = await _drafts.GetAsync(draft.Id); + Assert.That(stored, Is.Not.Null); + Assert.That(stored!.OwnerId, Is.EqualTo(_author)); + Assert.That(stored.Content.Title, Is.EqualTo("Lab one")); + } + + [Test] + public void Actor_without_authoring_rights_cannot_create_and_nothing_is_stored() + { + Assert.That(CodeOf(() => _service.CreateDraftAsync(_student, Content())), Is.EqualTo(AuthoringErrorCodes.Forbidden)); + Assert.That(_drafts.Count, Is.EqualTo(0)); + } + + [Test] + public void Empty_actor_is_always_forbidden_even_when_the_policy_would_allow_everything() + { + var service = new DraftService(_drafts, new BuiltInTemplateRepository(), new AllowAllAccess()); + + Assert.That(CodeOf(() => service.CreateDraftAsync(Guid.Empty, Content())), Is.EqualTo(AuthoringErrorCodes.Forbidden)); + Assert.That(_drafts.Count, Is.EqualTo(0)); + } + + [Test] + public async Task Owner_can_edit_and_the_change_is_persisted_with_a_new_version() + { + var draft = await _service.CreateDraftAsync(_author, Content()); + + var edited = await _service.EditDraftAsync(_author, draft.Id, expectedVersion: 1, Content("Renamed")); + + Assert.That(edited.Version, Is.EqualTo(2)); + var stored = _drafts.Peek(draft.Id)!; + Assert.That(stored.Content.Title, Is.EqualTo("Renamed")); + Assert.That(stored.Version, Is.EqualTo(2)); + } + + [Test] + public async Task Another_author_cannot_edit_the_draft_and_it_stays_unchanged() + { + var draft = await _service.CreateDraftAsync(_author, Content()); + + Assert.That(CodeOf(() => _service.EditDraftAsync(_otherAuthor, draft.Id, 1, Content("Hijacked"))), + Is.EqualTo(AuthoringErrorCodes.Forbidden)); + + var stored = _drafts.Peek(draft.Id)!; + Assert.That(stored.Content.Title, Is.EqualTo("Lab one")); + Assert.That(stored.Version, Is.EqualTo(1)); + } + + [Test] + public async Task A_student_cannot_edit_a_draft() + { + var draft = await _service.CreateDraftAsync(_author, Content()); + + Assert.That(CodeOf(() => _service.EditDraftAsync(_student, draft.Id, 1, Content("Nope"))), + Is.EqualTo(AuthoringErrorCodes.Forbidden)); + } + + [Test] + public async Task Denial_is_identical_for_a_missing_draft_and_someone_elses_draft_so_ids_cannot_be_probed() + { + var existing = await _service.CreateDraftAsync(_author, Content()); + + var foreign = CodeOf(() => _service.EditDraftAsync(_otherAuthor, existing.Id, 1, Content("x"))); + var missing = CodeOf(() => _service.EditDraftAsync(_otherAuthor, Guid.NewGuid(), 1, Content("x"))); + + Assert.That(foreign, Is.EqualTo(AuthoringErrorCodes.Forbidden)); + Assert.That(missing, Is.EqualTo(foreign)); + } + + [Test] + public void Editing_a_missing_draft_is_not_found_once_access_is_granted() + { + var service = new DraftService(_drafts, new BuiltInTemplateRepository(), new AllowAllAccess()); + + Assert.That(CodeOf(() => service.EditDraftAsync(_author, Guid.NewGuid(), 1, Content())), + Is.EqualTo(AuthoringErrorCodes.NotFound)); + } + + [Test] + public async Task Stale_edit_conflicts_and_does_not_overwrite_newer_work() + { + var draft = await _service.CreateDraftAsync(_author, Content()); + await _service.EditDraftAsync(_author, draft.Id, 1, Content("Newer work")); + + Assert.That(CodeOf(() => _service.EditDraftAsync(_author, draft.Id, 1, Content("Stale tab"))), + Is.EqualTo(AuthoringErrorCodes.Conflict)); + + Assert.That(_drafts.Peek(draft.Id)!.Content.Title, Is.EqualTo("Newer work")); + } + + [Test] + public async Task Identical_edit_is_a_no_op_and_does_not_bump_the_stored_version() + { + var draft = await _service.CreateDraftAsync(_author, Content()); + + var result = await _service.EditDraftAsync(_author, draft.Id, 1, Content()); + + Assert.That(result.Version, Is.EqualTo(1)); + Assert.That(_drafts.Peek(draft.Id)!.Version, Is.EqualTo(1)); + } + + [Test] + public async Task Draft_in_review_is_rejected_by_the_service() + { + var inReview = LabDraft.Rehydrate(Guid.NewGuid(), _author, null, DraftStatus.InReview, Content(), version: 2); + await _drafts.AddAsync(inReview); + + Assert.That(CodeOf(() => _service.EditDraftAsync(_author, inReview.Id, 2, Content("Late change"))), + Is.EqualTo(AuthoringErrorCodes.DraftNotEditable)); + Assert.That(_drafts.Peek(inReview.Id)!.Content.Title, Is.EqualTo("Lab one")); + } + + [Test] + public async Task Content_captured_before_an_edit_is_not_changed_by_it() + { + var draft = await _service.CreateDraftAsync(_author, Content("Original")); + var captured = _drafts.Peek(draft.Id)!.Content; // what a published snapshot would hold + + await _service.EditDraftAsync(_author, draft.Id, 1, Content("Edited later")); + + Assert.That(captured.Title, Is.EqualTo("Original")); + } + + // ---------- AUT-06: templates ---------- + + [Test] + public async Task Draft_from_template_gets_a_new_id_and_the_template_content() + { + var template = BuiltInTemplates.All.First(t => t.Id == BuiltInTemplates.AccessControlId); + + var draft = await _service.CreateDraftFromTemplateAsync(_author, template.Id); + + Assert.That(draft.Id, Is.Not.EqualTo(template.Id)); + Assert.That(draft.OwnerId, Is.EqualTo(_author)); + Assert.That(draft.SourceTemplateId, Is.EqualTo(template.Id)); + Assert.That(draft.Content, Is.EqualTo(template.Content)); + Assert.That(_drafts.Peek(draft.Id), Is.Not.Null); + } + + [Test] + public async Task Editing_a_draft_made_from_a_template_leaves_the_template_untouched() + { + var repo = new BuiltInTemplateRepository(); + var before = await repo.GetAsync(BuiltInTemplates.LogAnalysisId); + var draft = await _service.CreateDraftFromTemplateAsync(_author, BuiltInTemplates.LogAnalysisId); + + await _service.EditDraftAsync(_author, draft.Id, 1, Content("Completely my own lab")); + + var after = await repo.GetAsync(BuiltInTemplates.LogAnalysisId); + Assert.That(after!.Content, Is.EqualTo(before!.Content)); + Assert.That(after.Name, Is.EqualTo(before.Name)); + } + + [Test] + public async Task Each_copy_of_a_template_is_a_separate_draft() + { + var first = await _service.CreateDraftFromTemplateAsync(_author, BuiltInTemplates.SecureConfigurationId); + var second = await _service.CreateDraftFromTemplateAsync(_author, BuiltInTemplates.SecureConfigurationId); + + Assert.That(first.Id, Is.Not.EqualTo(second.Id)); + Assert.That(_drafts.Count, Is.EqualTo(2)); + } + + [Test] + public void Unknown_template_is_not_found() + { + Assert.That(CodeOf(() => _service.CreateDraftFromTemplateAsync(_author, Guid.NewGuid())), + Is.EqualTo(AuthoringErrorCodes.NotFound)); + Assert.That(_drafts.Count, Is.EqualTo(0)); + } + + [Test] + public void Actor_without_authoring_rights_cannot_copy_a_template_and_nothing_is_stored() + { + Assert.That(CodeOf(() => _service.CreateDraftFromTemplateAsync(_student, BuiltInTemplates.AccessControlId)), + Is.EqualTo(AuthoringErrorCodes.Forbidden)); + Assert.That(_drafts.Count, Is.EqualTo(0)); + } +} diff --git a/backend/Platform.Authoring.Tests/Domain/LabContentTests.cs b/backend/Platform.Authoring.Tests/Domain/LabContentTests.cs new file mode 100644 index 0000000..202ac6d --- /dev/null +++ b/backend/Platform.Authoring.Tests/Domain/LabContentTests.cs @@ -0,0 +1,100 @@ +using Platform.Authoring.Domain; + +namespace Platform.Authoring.Tests.Domain; + +[TestFixture] +public class LabContentTests +{ + private static string CodeOf(TestDelegate act) => Assert.Throws(act)!.Code; + + [Test] + public void Create_trims_title_and_goal() + { + var content = LabContent.Create(" Access control ", " Decide. ", LabType.AccessControl); + + Assert.That(content.Title, Is.EqualTo("Access control")); + Assert.That(content.Goal, Is.EqualTo("Decide.")); + Assert.That(content.Type, Is.EqualTo(LabType.AccessControl)); + } + + [Test] + public void Contents_with_the_same_values_are_equal() + { + var a = LabContent.Create("Lab", "Goal", LabType.LogAnalysis); + var b = LabContent.Create(" Lab ", "Goal", LabType.LogAnalysis); + + Assert.That(a, Is.EqualTo(b)); + } + + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + [TestCase("bad\ttitle")] + [TestCase("line\nbreak")] + [TestCase("nul\0char")] + public void Title_must_be_present_and_free_of_control_characters(string? title) + { + Assert.That(CodeOf(() => LabContent.Create(title, "goal", LabType.AccessControl)), + Is.EqualTo(AuthoringErrorCodes.InvalidTitle)); + } + + [Test] + public void Title_length_limit_is_inclusive() + { + var ok = LabContent.Create(new string('a', LabContent.MaxTitleLength), "", LabType.AccessControl); + Assert.That(ok.Title.Length, Is.EqualTo(LabContent.MaxTitleLength)); + + Assert.That(CodeOf(() => LabContent.Create(new string('a', LabContent.MaxTitleLength + 1), "", LabType.AccessControl)), + Is.EqualTo(AuthoringErrorCodes.InvalidTitle)); + } + + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + public void A_draft_may_have_an_empty_goal(string? goal) + { + var content = LabContent.Create("Lab", goal, LabType.AccessControl); + + Assert.That(content.Goal, Is.EqualTo("")); + } + + [Test] + public void Goal_may_contain_line_breaks_but_not_other_control_characters() + { + var ok = LabContent.Create("Lab", "line one\r\nline two\tindented", LabType.AccessControl); + Assert.That(ok.Goal, Is.EqualTo("line one\r\nline two\tindented")); + + Assert.That(CodeOf(() => LabContent.Create("Lab", "bad\0goal", LabType.AccessControl)), + Is.EqualTo(AuthoringErrorCodes.InvalidGoal)); + } + + [Test] + public void Goal_length_limit_is_inclusive() + { + var ok = LabContent.Create("Lab", new string('g', LabContent.MaxGoalLength), LabType.AccessControl); + Assert.That(ok.Goal.Length, Is.EqualTo(LabContent.MaxGoalLength)); + + Assert.That(CodeOf(() => LabContent.Create("Lab", new string('g', LabContent.MaxGoalLength + 1), LabType.AccessControl)), + Is.EqualTo(AuthoringErrorCodes.InvalidGoal)); + } + + [TestCase(0)] + [TestCase(99)] + [TestCase(-1)] + public void Undefined_lab_type_is_rejected(int raw) + { + Assert.That(CodeOf(() => LabContent.Create("Lab", "goal", (LabType)raw)), + Is.EqualTo(AuthoringErrorCodes.InvalidLabType)); + } + + [Test] + public void Content_cannot_be_modified_from_outside_the_type() + { + var publicSetters = typeof(LabContent).GetProperties() + .Where(p => p.SetMethod is { IsPublic: true }) + .Select(p => p.Name) + .ToList(); + + Assert.That(publicSetters.Count, Is.EqualTo(0)); + } +} diff --git a/backend/Platform.Authoring.Tests/Domain/LabDraftTests.cs b/backend/Platform.Authoring.Tests/Domain/LabDraftTests.cs new file mode 100644 index 0000000..130aba9 --- /dev/null +++ b/backend/Platform.Authoring.Tests/Domain/LabDraftTests.cs @@ -0,0 +1,112 @@ +using Platform.Authoring.Domain; + +namespace Platform.Authoring.Tests.Domain; + +/// AUT-01: a draft can be created and changed without affecting existing attempts. +[TestFixture] +public class LabDraftTests +{ + private static LabContent Content(string title = "Lab one", string goal = "Goal") => + LabContent.Create(title, goal, LabType.AccessControl); + + private static LabDraft NewDraft() => LabDraft.Create(Guid.NewGuid(), Guid.NewGuid(), Content()); + + private static string CodeOf(TestDelegate act) => Assert.Throws(act)!.Code; + + [Test] + public void New_draft_is_in_draft_state_with_version_one() + { + var owner = Guid.NewGuid(); + var draft = LabDraft.Create(Guid.NewGuid(), owner, Content()); + + Assert.That(draft.Status, Is.EqualTo(DraftStatus.Draft)); + Assert.That(draft.Version, Is.EqualTo(1)); + Assert.That(draft.OwnerId, Is.EqualTo(owner)); + Assert.That(draft.SourceTemplateId, Is.Null); + } + + [Test] + public void Empty_draft_or_owner_id_is_rejected() + { + Assert.That(CodeOf(() => LabDraft.Create(Guid.Empty, Guid.NewGuid(), Content())), Is.EqualTo(AuthoringErrorCodes.InvalidId)); + Assert.That(CodeOf(() => LabDraft.Create(Guid.NewGuid(), Guid.Empty, Content())), Is.EqualTo(AuthoringErrorCodes.InvalidId)); + } + + [Test] + public void Edit_replaces_content_and_bumps_version() + { + var draft = NewDraft(); + + var changed = draft.Edit(Content("Renamed"), expectedVersion: 1); + + Assert.That(changed, Is.True); + Assert.That(draft.Content.Title, Is.EqualTo("Renamed")); + Assert.That(draft.Version, Is.EqualTo(2)); + } + + [Test] + public void Edit_with_identical_content_changes_nothing() + { + var draft = NewDraft(); + + var changed = draft.Edit(Content(), expectedVersion: 1); + + Assert.That(changed, Is.False); + Assert.That(draft.Version, Is.EqualTo(1)); + } + + [Test] + public void Edit_with_a_stale_version_conflicts_and_keeps_current_content() + { + var draft = NewDraft(); + draft.Edit(Content("First"), expectedVersion: 1); + + var code = CodeOf(() => draft.Edit(Content("Stale writer"), expectedVersion: 1)); + + Assert.That(code, Is.EqualTo(AuthoringErrorCodes.Conflict)); + Assert.That(draft.Content.Title, Is.EqualTo("First")); + Assert.That(draft.Version, Is.EqualTo(2)); + } + + [Test] + public void Edit_with_a_version_from_the_future_conflicts_too() + { + var draft = NewDraft(); + + Assert.That(CodeOf(() => draft.Edit(Content("Other"), expectedVersion: 5)), Is.EqualTo(AuthoringErrorCodes.Conflict)); + } + + [Test] + public void Draft_in_review_cannot_be_edited() + { + var draft = LabDraft.Rehydrate(Guid.NewGuid(), Guid.NewGuid(), null, DraftStatus.InReview, Content(), version: 3); + + Assert.That(CodeOf(() => draft.Edit(Content("Sneaky"), expectedVersion: 3)), Is.EqualTo(AuthoringErrorCodes.DraftNotEditable)); + Assert.That(draft.Content.Title, Is.EqualTo("Lab one")); + Assert.That(draft.Version, Is.EqualTo(3)); + } + + [Test] + public void Editing_a_draft_does_not_alter_content_captured_earlier() + { + // A revision snapshot (REV-01) captures the content object. Because LabContent is immutable, + // later draft edits can only replace the draft's reference, never change the captured value. + var draft = NewDraft(); + var captured = draft.Content; + + draft.Edit(Content("Edited afterwards", "New goal"), expectedVersion: 1); + + Assert.That(captured.Title, Is.EqualTo("Lab one")); + Assert.That(captured.Goal, Is.EqualTo("Goal")); + Assert.That(draft.Content.Title, Is.EqualTo("Edited afterwards")); + } + + [Test] + public void Rehydrate_rejects_impossible_stored_state() + { + Assert.That(CodeOf(() => LabDraft.Rehydrate(Guid.NewGuid(), Guid.NewGuid(), null, DraftStatus.Draft, Content(), version: 0)), + Is.EqualTo(AuthoringErrorCodes.InvalidVersion)); + Assert.That(CodeOf(() => LabDraft.Rehydrate(Guid.NewGuid(), Guid.NewGuid(), null, (DraftStatus)42, Content(), version: 1)), + Is.EqualTo(AuthoringErrorCodes.InvalidVersion)); + } +} diff --git a/backend/Platform.Authoring.Tests/Domain/LabTemplateTests.cs b/backend/Platform.Authoring.Tests/Domain/LabTemplateTests.cs new file mode 100644 index 0000000..f1f6acf --- /dev/null +++ b/backend/Platform.Authoring.Tests/Domain/LabTemplateTests.cs @@ -0,0 +1,107 @@ +using Platform.Authoring.Domain; +using Platform.Authoring.Infrastructure; + +namespace Platform.Authoring.Tests.Domain; + +/// AUT-06: a copy of a template has a new ID and does not modify the source template. +[TestFixture] +public class LabTemplateTests +{ + private static LabTemplate NewTemplate() => + LabTemplate.Create(Guid.NewGuid(), "Starter", LabContent.Create("Starter title", "Starter goal", LabType.LogAnalysis)); + + private static string CodeOf(TestDelegate act) => Assert.Throws(act)!.Code; + + [Test] + public void Draft_from_template_has_a_new_id_and_copies_the_content() + { + var template = NewTemplate(); + var draftId = Guid.NewGuid(); + var owner = Guid.NewGuid(); + + var draft = template.CreateDraft(draftId, owner); + + Assert.That(draft.Id, Is.EqualTo(draftId)); + Assert.That(draft.Id, Is.Not.EqualTo(template.Id)); + Assert.That(draft.OwnerId, Is.EqualTo(owner)); + Assert.That(draft.SourceTemplateId, Is.EqualTo(template.Id)); + Assert.That(draft.Content, Is.EqualTo(template.Content)); + Assert.That(draft.Status, Is.EqualTo(DraftStatus.Draft)); + Assert.That(draft.Version, Is.EqualTo(1)); + } + + [Test] + public void Editing_the_copy_does_not_modify_the_source_template() + { + var template = NewTemplate(); + var contentBefore = template.Content; + var draft = template.CreateDraft(Guid.NewGuid(), Guid.NewGuid()); + + draft.Edit(LabContent.Create("Totally different", "Changed", LabType.ThreatModel), expectedVersion: 1); + + Assert.That(template.Content, Is.EqualTo(contentBefore)); + Assert.That(template.Content.Title, Is.EqualTo("Starter title")); + Assert.That(template.Content.Type, Is.EqualTo(LabType.LogAnalysis)); + Assert.That(template.Name, Is.EqualTo("Starter")); + } + + [Test] + public void Two_copies_of_one_template_are_independent() + { + var template = NewTemplate(); + var first = template.CreateDraft(Guid.NewGuid(), Guid.NewGuid()); + var second = template.CreateDraft(Guid.NewGuid(), Guid.NewGuid()); + + first.Edit(LabContent.Create("Only the first changes", "", LabType.LogAnalysis), expectedVersion: 1); + + Assert.That(first.Id, Is.Not.EqualTo(second.Id)); + Assert.That(second.Content.Title, Is.EqualTo("Starter title")); + Assert.That(second.Version, Is.EqualTo(1)); + } + + [Test] + public void An_identifier_is_never_reused_the_template_id_cannot_become_a_draft_id() + { + var template = NewTemplate(); + + Assert.That(CodeOf(() => template.CreateDraft(template.Id, Guid.NewGuid())), Is.EqualTo(AuthoringErrorCodes.InvalidId)); + } + + [Test] + public void Draft_from_template_still_requires_an_owner() + { + var template = NewTemplate(); + + Assert.That(CodeOf(() => template.CreateDraft(Guid.NewGuid(), Guid.Empty)), Is.EqualTo(AuthoringErrorCodes.InvalidId)); + } + + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + [TestCase("bad\nname")] + public void Template_needs_a_clean_name(string? name) + { + var content = LabContent.Create("T", "", LabType.AccessControl); + + Assert.That(CodeOf(() => LabTemplate.Create(Guid.NewGuid(), name, content)), Is.EqualTo(AuthoringErrorCodes.InvalidName)); + } + + [Test] + public void Template_needs_an_id() + { + var content = LabContent.Create("T", "", LabType.AccessControl); + + Assert.That(CodeOf(() => LabTemplate.Create(Guid.Empty, "Name", content)), Is.EqualTo(AuthoringErrorCodes.InvalidId)); + } + + [Test] + public void Built_in_templates_have_unique_ids_and_valid_content() + { + var all = BuiltInTemplates.All; + + Assert.That(all.Count, Is.EqualTo(3)); + Assert.That(all.Select(t => t.Id).Distinct().Count(), Is.EqualTo(all.Count)); + Assert.That(all.Select(t => t.Content.Type).Distinct().Count(), Is.EqualTo(all.Count)); + Assert.That(all.All(t => t.Id != Guid.Empty), Is.True); + } +} diff --git a/backend/Platform.Authoring.Tests/Fakes/FakeAccess.cs b/backend/Platform.Authoring.Tests/Fakes/FakeAccess.cs new file mode 100644 index 0000000..1c8386f --- /dev/null +++ b/backend/Platform.Authoring.Tests/Fakes/FakeAccess.cs @@ -0,0 +1,29 @@ +using Platform.Authoring.Application; + +namespace Platform.Authoring.Tests.Fakes; + +/// +/// Deny-by-default policy: only listed authors may author, and only the owner may edit an existing draft. +/// A missing draft is denied exactly like someone else's draft. +/// +public sealed class FakeAuthoringAccess(InMemoryDraftRepository drafts, params Guid[] authors) : IAuthoringAccess +{ + public bool IsAllowed(Guid actorId, AuthoringAction action, Guid? resourceId) + { + if (actorId == Guid.Empty || !authors.Contains(actorId)) return false; + + return action switch + { + AuthoringAction.CreateDraft => true, + AuthoringAction.CreateDraftFromTemplate => true, + AuthoringAction.EditDraft => resourceId is { } id && drafts.Peek(id)?.OwnerId == actorId, + _ => false, + }; + } +} + +/// Grants everything. Used only to reach service paths that sit behind the access check. +public sealed class AllowAllAccess : IAuthoringAccess +{ + public bool IsAllowed(Guid actorId, AuthoringAction action, Guid? resourceId) => true; +} diff --git a/backend/Platform.Authoring.Tests/Fakes/InMemoryDraftRepository.cs b/backend/Platform.Authoring.Tests/Fakes/InMemoryDraftRepository.cs new file mode 100644 index 0000000..bb9ae61 --- /dev/null +++ b/backend/Platform.Authoring.Tests/Fakes/InMemoryDraftRepository.cs @@ -0,0 +1,37 @@ +using Platform.Authoring.Application; +using Platform.Authoring.Domain; + +namespace Platform.Authoring.Tests.Fakes; + +/// +/// Test double with the same contract as a real store: it keeps and returns copies +/// (never the caller's instance), rejects duplicate ids and rejects stale saves. +/// +public sealed class InMemoryDraftRepository : IDraftRepository +{ + private readonly Dictionary _store = new(); + + public int Count => _store.Count; + + public LabDraft? Peek(Guid id) => _store.TryGetValue(id, out var d) ? Copy(d) : null; + + public Task GetAsync(Guid id, CancellationToken ct = default) => Task.FromResult(Peek(id)); + + public Task AddAsync(LabDraft draft, CancellationToken ct = default) + { + if (_store.ContainsKey(draft.Id)) throw new DomainRuleException(AuthoringErrorCodes.Conflict); + _store[draft.Id] = Copy(draft); + return Task.CompletedTask; + } + + public Task SaveAsync(LabDraft draft, int previousVersion, CancellationToken ct = default) + { + if (!_store.TryGetValue(draft.Id, out var stored)) throw new DomainRuleException(AuthoringErrorCodes.NotFound); + if (stored.Version != previousVersion) throw new DomainRuleException(AuthoringErrorCodes.Conflict); + _store[draft.Id] = Copy(draft); + return Task.CompletedTask; + } + + private static LabDraft Copy(LabDraft d) => + LabDraft.Rehydrate(d.Id, d.OwnerId, d.SourceTemplateId, d.Status, d.Content, d.Version); +} diff --git a/backend/Platform.Authoring.Tests/Platform.Authoring.Tests.csproj b/backend/Platform.Authoring.Tests/Platform.Authoring.Tests.csproj new file mode 100644 index 0000000..3ec603c --- /dev/null +++ b/backend/Platform.Authoring.Tests/Platform.Authoring.Tests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + latest + enable + enable + false + + + + + + + + + + + + + + + + + + + diff --git a/backend/Platform.Authoring/Application/DraftService.cs b/backend/Platform.Authoring/Application/DraftService.cs new file mode 100644 index 0000000..b06e05b --- /dev/null +++ b/backend/Platform.Authoring/Application/DraftService.cs @@ -0,0 +1,60 @@ +using Platform.Authoring.Domain; + +namespace Platform.Authoring.Application; + +/// +/// Use cases for AUT-01 (create and edit drafts) and AUT-06 (create a draft from a template). +/// Every use case authorises first, on the server, before touching any data. +/// +public sealed class DraftService( + IDraftRepository drafts, + ITemplateRepository templates, + IAuthoringAccess access, + Func? newId = null) +{ + private readonly Func _newId = newId ?? (() => Guid.NewGuid()); + + /// FORBIDDEN, or a validation code from the domain. + public async Task CreateDraftAsync(Guid actorId, LabContent content, CancellationToken ct = default) + { + Authorize(actorId, AuthoringAction.CreateDraft, null); + + var draft = LabDraft.Create(_newId(), actorId, content); + await drafts.AddAsync(draft, ct); + return draft; + } + + /// + /// Edits a draft. is the version the caller last saw. + /// A draft the actor may not edit gives FORBIDDEN whether or not it exists. + /// + /// FORBIDDEN, NOT_FOUND, CONFLICT or DRAFT_NOT_EDITABLE. + public async Task EditDraftAsync(Guid actorId, Guid draftId, int expectedVersion, LabContent content, CancellationToken ct = default) + { + Authorize(actorId, AuthoringAction.EditDraft, draftId); + + var draft = await drafts.GetAsync(draftId, ct) ?? throw new DomainRuleException(AuthoringErrorCodes.NotFound); + var previousVersion = draft.Version; + if (draft.Edit(content, expectedVersion)) + await drafts.SaveAsync(draft, previousVersion, ct); + return draft; + } + + /// Creates an independent draft from a template. The template is never modified. + /// FORBIDDEN or NOT_FOUND. + public async Task CreateDraftFromTemplateAsync(Guid actorId, Guid templateId, CancellationToken ct = default) + { + Authorize(actorId, AuthoringAction.CreateDraftFromTemplate, templateId); + + var template = await templates.GetAsync(templateId, ct) ?? throw new DomainRuleException(AuthoringErrorCodes.NotFound); + var draft = template.CreateDraft(_newId(), actorId); + await drafts.AddAsync(draft, ct); + return draft; + } + + private void Authorize(Guid actorId, AuthoringAction action, Guid? resourceId) + { + if (actorId == Guid.Empty || !access.IsAllowed(actorId, action, resourceId)) + throw new DomainRuleException(AuthoringErrorCodes.Forbidden); + } +} diff --git a/backend/Platform.Authoring/Application/IAuthoringAccess.cs b/backend/Platform.Authoring/Application/IAuthoringAccess.cs new file mode 100644 index 0000000..c2104cd --- /dev/null +++ b/backend/Platform.Authoring/Application/IAuthoringAccess.cs @@ -0,0 +1,20 @@ +namespace Platform.Authoring.Application; + +public enum AuthoringAction +{ + CreateDraft = 1, + EditDraft = 2, + CreateDraftFromTemplate = 3, +} + +/// +/// Authoring-side view of the platform's IAccessDecision contract (specification 3.1): +/// subject + action + concrete resource. The decision is made on the server and the +/// implementation must deny by default (ACC-05): an unknown actor, action or resource is denied. +/// For the resource is the draft id, and a draft that +/// does not exist or belongs to someone else is denied the same way, so callers cannot probe for ids. +/// +public interface IAuthoringAccess +{ + bool IsAllowed(Guid actorId, AuthoringAction action, Guid? resourceId); +} diff --git a/backend/Platform.Authoring/Application/IDraftRepository.cs b/backend/Platform.Authoring/Application/IDraftRepository.cs new file mode 100644 index 0000000..08d9c80 --- /dev/null +++ b/backend/Platform.Authoring/Application/IDraftRepository.cs @@ -0,0 +1,17 @@ +using Platform.Authoring.Domain; + +namespace Platform.Authoring.Application; + +public interface IDraftRepository +{ + Task GetAsync(Guid id, CancellationToken ct = default); + + /// Stores a new draft. An existing id is never reassigned: throws CONFLICT. + Task AddAsync(LabDraft draft, CancellationToken ct = default); + + /// + /// Stores a changed draft only if the stored version is still ; + /// otherwise throws CONFLICT so a stale write never overwrites newer work. + /// + Task SaveAsync(LabDraft draft, int previousVersion, CancellationToken ct = default); +} diff --git a/backend/Platform.Authoring/Application/ITemplateRepository.cs b/backend/Platform.Authoring/Application/ITemplateRepository.cs new file mode 100644 index 0000000..f79d349 --- /dev/null +++ b/backend/Platform.Authoring/Application/ITemplateRepository.cs @@ -0,0 +1,10 @@ +using Platform.Authoring.Domain; + +namespace Platform.Authoring.Application; + +/// Read-only access to templates. Authoring code can never modify a template through this port. +public interface ITemplateRepository +{ + Task GetAsync(Guid id, CancellationToken ct = default); + Task> ListAsync(CancellationToken ct = default); +} diff --git a/backend/Platform.Authoring/Domain/AuthoringErrors.cs b/backend/Platform.Authoring/Domain/AuthoringErrors.cs new file mode 100644 index 0000000..83e7a66 --- /dev/null +++ b/backend/Platform.Authoring/Domain/AuthoringErrors.cs @@ -0,0 +1,21 @@ +namespace Platform.Authoring.Domain; + +/// A domain rule was violated. The code is stable and machine-readable. +public sealed class DomainRuleException(string code) : Exception(code) +{ + public string Code { get; } = code; +} + +public static class AuthoringErrorCodes +{ + public const string InvalidId = "INVALID_ID"; + public const string InvalidTitle = "INVALID_TITLE"; + public const string InvalidGoal = "INVALID_GOAL"; + public const string InvalidLabType = "INVALID_LAB_TYPE"; + public const string InvalidName = "INVALID_NAME"; + public const string InvalidVersion = "INVALID_VERSION"; + public const string DraftNotEditable = "DRAFT_NOT_EDITABLE"; + public const string Forbidden = "FORBIDDEN"; + public const string NotFound = "NOT_FOUND"; + public const string Conflict = "CONFLICT"; +} diff --git a/backend/Platform.Authoring/Domain/DraftStatus.cs b/backend/Platform.Authoring/Domain/DraftStatus.cs new file mode 100644 index 0000000..2586b99 --- /dev/null +++ b/backend/Platform.Authoring/Domain/DraftStatus.cs @@ -0,0 +1,13 @@ +namespace Platform.Authoring.Domain; + +/// +/// Working-copy states from the revision life cycle (figure 4.5.1). +/// Only can be edited. A published revision is a separate, +/// immutable LabRevision (REV-01), so Published/Archived do not exist on the working copy. +/// Transitions between these states belong to the REV-* requirements, not to AUT-01. +/// +public enum DraftStatus +{ + Draft = 1, + InReview = 2, +} diff --git a/backend/Platform.Authoring/Domain/LabContent.cs b/backend/Platform.Authoring/Domain/LabContent.cs new file mode 100644 index 0000000..aa7181e --- /dev/null +++ b/backend/Platform.Authoring/Domain/LabContent.cs @@ -0,0 +1,40 @@ +namespace Platform.Authoring.Domain; + +/// +/// The editable content of a laboratory draft. Immutable and always valid: +/// the only way to obtain an instance is , and the setters are private, +/// so neither object initialisers nor with expressions can bypass validation. +/// Because it is immutable, copying it (draft edit, template copy) can never leak a change +/// into another draft, a template, or an earlier snapshot (AUT-01, AUT-06, REV-03). +/// +public sealed record LabContent +{ + public const int MaxTitleLength = 120; + public const int MaxGoalLength = 2000; + + public string Title { get; private init; } = ""; + public string Goal { get; private init; } = ""; + public LabType Type { get; private init; } + + private LabContent() { } + + /// + /// A draft may be incomplete (empty goal); completeness is checked at publication (REV-02, AUT-03). + /// + /// INVALID_TITLE, INVALID_GOAL or INVALID_LAB_TYPE. + public static LabContent Create(string? title, string? goal, LabType type) + { + var cleanTitle = title?.Trim() ?? ""; + if (cleanTitle.Length == 0 || cleanTitle.Length > MaxTitleLength || cleanTitle.Any(char.IsControl)) + throw new DomainRuleException(AuthoringErrorCodes.InvalidTitle); + + var cleanGoal = goal?.Trim() ?? ""; + if (cleanGoal.Length > MaxGoalLength || cleanGoal.Any(c => char.IsControl(c) && c is not ('\n' or '\r' or '\t'))) + throw new DomainRuleException(AuthoringErrorCodes.InvalidGoal); + + if (!Enum.IsDefined(type)) + throw new DomainRuleException(AuthoringErrorCodes.InvalidLabType); + + return new LabContent { Title = cleanTitle, Goal = cleanGoal, Type = type }; + } +} diff --git a/backend/Platform.Authoring/Domain/LabDraft.cs b/backend/Platform.Authoring/Domain/LabDraft.cs new file mode 100644 index 0000000..bd33ff3 --- /dev/null +++ b/backend/Platform.Authoring/Domain/LabDraft.cs @@ -0,0 +1,71 @@ +namespace Platform.Authoring.Domain; + +/// +/// A laboratory working copy (AUT-01). Attempts never point at a draft: they are bound to an +/// immutable published revision (REV-03), so editing a draft cannot affect an attempt. +/// +public sealed class LabDraft +{ + public Guid Id { get; private init; } + public Guid OwnerId { get; private init; } + + /// Set when the draft was copied from a template (AUT-06); the template itself is never touched. + public Guid? SourceTemplateId { get; private init; } + + public DraftStatus Status { get; private set; } + public LabContent Content { get; private set; } = null!; + + /// Starts at 1 and grows on every real change; used to detect concurrent edits. + public int Version { get; private set; } + + private LabDraft() { } + + /// INVALID_ID for an empty draft, owner or source-template id. + public static LabDraft Create(Guid id, Guid ownerId, LabContent content, Guid? sourceTemplateId = null) + { + ArgumentNullException.ThrowIfNull(content); + RequireId(id); + RequireId(ownerId); + if (sourceTemplateId == Guid.Empty) throw new DomainRuleException(AuthoringErrorCodes.InvalidId); + + return new LabDraft + { + Id = id, + OwnerId = ownerId, + SourceTemplateId = sourceTemplateId, + Status = DraftStatus.Draft, + Content = content, + Version = 1, + }; + } + + /// Rebuilds a stored draft. Intended for persistence adapters and tests; applies the same checks as . + /// INVALID_ID or INVALID_VERSION. + public static LabDraft Rehydrate(Guid id, Guid ownerId, Guid? sourceTemplateId, DraftStatus status, LabContent content, int version) + { + var draft = Create(id, ownerId, content, sourceTemplateId); + if (!Enum.IsDefined(status) || version < 1) throw new DomainRuleException(AuthoringErrorCodes.InvalidVersion); + draft.Status = status; + draft.Version = version; + return draft; + } + + /// Replaces the content. Returns false (and changes nothing) when the content is identical. + /// DRAFT_NOT_EDITABLE when not in Draft; CONFLICT when is stale. + public bool Edit(LabContent content, int expectedVersion) + { + ArgumentNullException.ThrowIfNull(content); + if (Status != DraftStatus.Draft) throw new DomainRuleException(AuthoringErrorCodes.DraftNotEditable); + if (expectedVersion != Version) throw new DomainRuleException(AuthoringErrorCodes.Conflict); + if (content == Content) return false; + + Content = content; + Version++; + return true; + } + + private static void RequireId(Guid id) + { + if (id == Guid.Empty) throw new DomainRuleException(AuthoringErrorCodes.InvalidId); + } +} diff --git a/backend/Platform.Authoring/Domain/LabTemplate.cs b/backend/Platform.Authoring/Domain/LabTemplate.cs new file mode 100644 index 0000000..24677bc --- /dev/null +++ b/backend/Platform.Authoring/Domain/LabTemplate.cs @@ -0,0 +1,45 @@ +namespace Platform.Authoring.Domain; + +/// +/// A read-only starting point for a new task (AUT-06). Fully immutable: copying a template +/// produces an independent draft and cannot modify the source template. +/// +public sealed class LabTemplate +{ + public const int MaxNameLength = 80; + + public Guid Id { get; } + public string Name { get; } + public LabContent Content { get; } + + private LabTemplate(Guid id, string name, LabContent content) + { + Id = id; + Name = name; + Content = content; + } + + /// INVALID_ID or INVALID_NAME. + public static LabTemplate Create(Guid id, string? name, LabContent content) + { + ArgumentNullException.ThrowIfNull(content); + if (id == Guid.Empty) throw new DomainRuleException(AuthoringErrorCodes.InvalidId); + + var cleanName = name?.Trim() ?? ""; + if (cleanName.Length == 0 || cleanName.Length > MaxNameLength || cleanName.Any(char.IsControl)) + throw new DomainRuleException(AuthoringErrorCodes.InvalidName); + + return new LabTemplate(id, cleanName, content); + } + + /// + /// Creates a new draft from this template. The draft gets its own id; an identifier is never + /// reused, so the template id is rejected as a draft id. + /// + /// INVALID_ID. + public LabDraft CreateDraft(Guid newDraftId, Guid ownerId) + { + if (newDraftId == Id) throw new DomainRuleException(AuthoringErrorCodes.InvalidId); + return LabDraft.Create(newDraftId, ownerId, Content, sourceTemplateId: Id); + } +} diff --git a/backend/Platform.Authoring/Domain/LabType.cs b/backend/Platform.Authoring/Domain/LabType.cs new file mode 100644 index 0000000..39e2032 --- /dev/null +++ b/backend/Platform.Authoring/Domain/LabType.cs @@ -0,0 +1,17 @@ +namespace Platform.Authoring.Domain; + +/// +/// Laboratory scenario types (specification chapter 5, TYP-01..TYP-08). +/// Values start at 1 so that an uninitialised value (0) is never a valid type. +/// +public enum LabType +{ + AccessControl = 1, + LogAnalysis = 2, + SecureConfiguration = 3, + ThreatModel = 4, + MfaAndSessions = 5, + ContextualAuthorization = 6, + IncidentResponse = 7, + IsolatedEnvironment = 8, +} diff --git a/backend/Platform.Authoring/Infrastructure/BuiltInTemplates.cs b/backend/Platform.Authoring/Infrastructure/BuiltInTemplates.cs new file mode 100644 index 0000000..7969e0d --- /dev/null +++ b/backend/Platform.Authoring/Infrastructure/BuiltInTemplates.cs @@ -0,0 +1,55 @@ +using Platform.Authoring.Application; +using Platform.Authoring.Domain; + +namespace Platform.Authoring.Infrastructure; + +/// +/// Starter templates for the three core laboratory types (TYP-01..TYP-03, scenarios 5.1..5.3). +/// Ids are fixed so that they stay stable across restarts and migrations. +/// +public static class BuiltInTemplates +{ + public static readonly Guid AccessControlId = Guid.Parse("a1000000-0000-4000-8000-000000000001"); + public static readonly Guid LogAnalysisId = Guid.Parse("a1000000-0000-4000-8000-000000000002"); + public static readonly Guid SecureConfigurationId = Guid.Parse("a1000000-0000-4000-8000-000000000003"); + + public static IReadOnlyList All { get; } = + [ + LabTemplate.Create( + AccessControlId, + "Access control and ownership", + LabContent.Create( + "Access control and ownership", + "Given a user, a resource, a requested action and a set of permissions, decide whether access " + + "should be allowed and justify the decision. Watch for reliance on login or role alone, access to " + + "another user's resource, swapped IDs and non-existent membership.", + LabType.AccessControl)), + LabTemplate.Create( + LogAnalysisId, + "Security log analysis", + LabContent.Create( + "Security log analysis", + "Given a fixed set of timestamped security events, select the related records and explain why " + + "they matter. Watch for false alarms, duplicates and misread time zones.", + LabType.LogAnalysis)), + LabTemplate.Create( + SecureConfigurationId, + "Secure configuration review", + LabContent.Create( + "Secure configuration review", + "Given the configuration of a simulated service and the rules for permitted values, find the " + + "insecure settings and propose changes. Watch for allowed exceptions, missing settings and " + + "contradictory policies.", + LabType.SecureConfiguration)), + ]; +} + +/// Read-only repository over . Immutable data, so it is safe to share. +public sealed class BuiltInTemplateRepository : ITemplateRepository +{ + public Task GetAsync(Guid id, CancellationToken ct = default) => + Task.FromResult(BuiltInTemplates.All.FirstOrDefault(t => t.Id == id)); + + public Task> ListAsync(CancellationToken ct = default) => + Task.FromResult(BuiltInTemplates.All); +} diff --git a/backend/Platform.Authoring/Platform.Authoring.csproj b/backend/Platform.Authoring/Platform.Authoring.csproj new file mode 100644 index 0000000..9ed914b --- /dev/null +++ b/backend/Platform.Authoring/Platform.Authoring.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/backend/SecurityLab.slnx b/backend/SecurityLab.slnx index c8a3250..e0cdbad 100644 --- a/backend/SecurityLab.slnx +++ b/backend/SecurityLab.slnx @@ -3,5 +3,7 @@ + + diff --git a/docs/PROJECT_COMPONENTS.md b/docs/PROJECT_COMPONENTS.md index 7ccb638..c3c4bbe 100644 --- a/docs/PROJECT_COMPONENTS.md +++ b/docs/PROJECT_COMPONENTS.md @@ -8,6 +8,7 @@ Components and requirements are defined by the Security Lab project specificatio | Team 2 | IDN-03 Account status | | Igor Cvijić (PR 21/2023): IDN-03; Vladimir Radlovački (PR 23/2023); Nebojša Golušin (PR 17/2023); | | Team 3 | ACC-05 — podrazumevano odbijanje | ACC-08 — kontekstualne politike pristupa | [ACC-05 i ACC-08](components/ACC-05-ACC-08.md) | | … | | | | +| Team 11 | AUT-01 Create and edit drafts | AUT-06 Templates for quick task creation | SopalovicDjordje (PR __/2023): AUT-01, AUT-06; [AUT-01 and AUT-06](components/AUT-authoring.md) | | Team 32 | | | | Each student documents their component using the [component template](templates/PROJECT_COMPONENT.md). Refer to the stable requirement IDs in the specification, such as `IDN-01`, `ACC-04`, `LAB-01`, `ATT-03`, and `EVAL-02`, without changing their meaning. \ No newline at end of file diff --git a/docs/components/AUT-authoring.md b/docs/components/AUT-authoring.md new file mode 100644 index 0000000..8b1bf1d --- /dev/null +++ b/docs/components/AUT-authoring.md @@ -0,0 +1,86 @@ +# Project component: AUT — Authoring tool (drafts and templates) + +**Team:** 11 + +**Student:** SopalovicDjordje (student ID: [fill in]) + +**Specification requirements:** AUT-01 (create and edit drafts), AUT-06 (templates for quick task creation) + +## Responsibilities and boundaries + +- **Processes and user scenarios:** + - A teacher creates a laboratory draft and edits it while it is in the *Draft* state (AUT-01). + - A teacher starts a new draft from a built-in template; the copy gets a new ID and the template is never changed (AUT-06). + - Part of UC-03 (teacher publishes a laboratory): this component owns the editable working copy that the later validation and publication steps read. +- **Contracts with other modules:** + - `IAuthoringAccess` (this module's view of `IAccessDecision`, specification 3.1): subject + action + concrete resource, deny by default. Implemented by the Identity and access module; a fake is used in tests until then. + - Attempts never reference a draft. They are bound to an immutable published revision (REV-03), so draft edits cannot change a started attempt. + - Publication (REV-01/REV-02) will read the draft content through this module and create the immutable `LabRevision` snapshot. Not implemented here. +- **Implementation status:** + - Implemented and unit-tested: `LabContent`, `LabDraft`, `LabTemplate`, `DraftService`, built-in templates, optimistic concurrency by version number. + - Planned, not implemented: PostgreSQL repository, HTTP endpoints, audit events, the Identity-backed `IAuthoringAccess`, course binding (LAB-03), structured steps (AUT-02), parameter validation (AUT-03). + +## Component architecture + +```mermaid +flowchart LR + API[Platform.Api - planned endpoints] --> SVC[DraftService] + SVC -->|authorise first| ACC[[IAuthoringAccess]] + SVC --> DOM[LabDraft / LabContent / LabTemplate] + SVC --> DR[[IDraftRepository]] + SVC --> TR[[ITemplateRepository]] + TR --> BT[BuiltInTemplateRepository - read-only] + DR -. planned .-> DB[(PostgreSQL)] + ACC -. provided by .-> ID[Identity and access module] +``` + +Layers inside `backend/Platform.Authoring`: `Domain` (rules, no dependencies), `Application` (use cases and ports), `Infrastructure` (adapters; currently only the read-only built-in templates). + +## Data flow diagram + +```mermaid +flowchart LR + T[Teacher] -->|create / edit / copy template| API[API - planned] + API --> P1((DraftService: authorise, validate, apply)) + P1 -->|subject, action, resource| P2((Access decision)) + P2 -->|allow / deny| P1 + P1 -->|read| TS[(Built-in templates, read-only)] + P1 -->|add / save with version check| DS[(Drafts)] + P1 -->|result or FORBIDDEN, CONFLICT, ...| API + API --> T + DS -. "published later as immutable revision (REV-01)" .-> RV[(Revisions - other component)] +``` + +Trust boundary: everything left of `API` is untrusted. The authorisation check runs on the server before any data is read or changed. Student accounts never reach this module's use cases (they are denied). + +**Implemented vs planned:** the process, access check, draft store port and template store in the diagram are implemented in code and tests, with in-memory test doubles for the stores. The HTTP API, the PostgreSQL store and the link to revisions are planned. + +## Security requirements + +- **Server-side access checks:** + - Every use case calls `IAuthoringAccess` first; an empty actor id is always denied. + - Editing is allowed only for the draft's owner. A missing draft and someone else's draft are denied identically (`FORBIDDEN`), so draft IDs cannot be probed. + - Templates are exposed through a read-only port, so no authoring path can modify one. +- **Input validation and sensitive data:** + - `LabContent` is immutable and always valid: title 1–120 characters without control characters, goal up to 2000 characters, defined lab type. Setters are private, so `with` expressions cannot bypass validation. + - Drafts hold no secrets. Evaluator keys and correct answers are not part of this component yet (AUT-04 is a separate requirement). +- **Negative scenarios (all covered by tests):** + - Student or non-author creates, edits or copies a template, so access is denied and nothing is stored. + - Another author edits a draft, so it is denied and the draft is unchanged. + - Stale edit (old version number) gives `CONFLICT` and does not overwrite newer work. + - Editing a draft that is *In review* gives `DRAFT_NOT_EDITABLE`. + - Template ID reused as a draft ID is rejected; editing a copy leaves the template unchanged. + +## Acceptance criteria mapping + +| Requirement | Minimum acceptance criterion | Where it is tested | +| --- | --- | --- | +| AUT-01 | An unpublished draft can be changed without affecting existing attempts | `LabDraftTests`, `DraftServiceTests` (edit paths, content captured earlier is unchanged) | +| AUT-06 | A copy of a template has a new ID and does not modify the source template | `LabTemplateTests`, `DraftServiceTests` (template paths) | + +## Evidence of work + +- **Changed files / commit / pull request:** branch `feature/AUT-01-AUT-06-lab-drafts` (`backend/Platform.Authoring`, `backend/Platform.Authoring.Tests`, `backend/SecurityLab.slnx`, `docs/components/AUT-authoring.md`, README, `docs/PROJECT_COMPONENTS.md`). The two projects are part of `backend/SecurityLab.slnx`, so the common CI job builds and tests them. +- **Run instructions:** `dotnet test backend/Platform.Authoring.Tests/Platform.Authoring.Tests.csproj --configuration Release` (or `dotnet test backend/SecurityLab.slnx` for the whole backend). +- **Tests and results:** 55 NUnit test cases (content validation, draft rules, template copy, use cases, negative access paths). Record the actual run result and date here after running locally. +- **Next steps:** `IAuthoringAccess` adapter on top of the Identity module; PostgreSQL repository and migration; HTTP endpoints once authentication exists; audit events; AUT-02/AUT-03 content (steps, parameters); hand-off of draft content to REV-01/REV-02. From ccad0a44c67b76192291aab2da15e1b4ccac6718 Mon Sep 17 00:00:00 2001 From: exstraspace2 Date: Tue, 3 Nov 2026 10:00:38 +0100 Subject: [PATCH 2/2] docs(authoring): fill in student details --- docs/PROJECT_COMPONENTS.md | 2 +- docs/components/AUT-authoring.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/PROJECT_COMPONENTS.md b/docs/PROJECT_COMPONENTS.md index c3c4bbe..dd4f033 100644 --- a/docs/PROJECT_COMPONENTS.md +++ b/docs/PROJECT_COMPONENTS.md @@ -8,7 +8,7 @@ Components and requirements are defined by the Security Lab project specificatio | Team 2 | IDN-03 Account status | | Igor Cvijić (PR 21/2023): IDN-03; Vladimir Radlovački (PR 23/2023); Nebojša Golušin (PR 17/2023); | | Team 3 | ACC-05 — podrazumevano odbijanje | ACC-08 — kontekstualne politike pristupa | [ACC-05 i ACC-08](components/ACC-05-ACC-08.md) | | … | | | | -| Team 11 | AUT-01 Create and edit drafts | AUT-06 Templates for quick task creation | SopalovicDjordje (PR __/2023): AUT-01, AUT-06; [AUT-01 and AUT-06](components/AUT-authoring.md) | +| Team 11 | AUT-01 Create and edit drafts | AUT-06 Templates for quick task creation | SopalovicDjordje (PR 70/2023): AUT-01, AUT-06; [AUT-01 and AUT-06](components/AUT-authoring.md) | | Team 32 | | | | Each student documents their component using the [component template](templates/PROJECT_COMPONENT.md). Refer to the stable requirement IDs in the specification, such as `IDN-01`, `ACC-04`, `LAB-01`, `ATT-03`, and `EVAL-02`, without changing their meaning. \ No newline at end of file diff --git a/docs/components/AUT-authoring.md b/docs/components/AUT-authoring.md index 8b1bf1d..0bd8d43 100644 --- a/docs/components/AUT-authoring.md +++ b/docs/components/AUT-authoring.md @@ -2,7 +2,7 @@ **Team:** 11 -**Student:** SopalovicDjordje (student ID: [fill in]) +**Student:** SopalovicDjordje (student ID: [PR70-2023]) **Specification requirements:** AUT-01 (create and edit drafts), AUT-06 (templates for quick task creation)