Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 91 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <http://localhost:5080/api/health>. 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 <http://localhost:5173>. 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/<Project>/<Project>.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.
219 changes: 219 additions & 0 deletions backend/Platform.Authoring.Tests/Application/DraftServiceTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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).
/// </summary>
[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<DomainRuleException>(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));
}
}
Loading
Loading