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
69 changes: 69 additions & 0 deletions backend/Laboratories.Tests/FakeAccessDecisionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using Platform.Api.Modules.Laboratories.Application;
using Platform.Api.Modules.Laboratories.Infrastructure;

namespace Laboratories.Tests;

// Testovi politike pristupa: sve sto nije izricito dozvoljeno -> odbijeno.
public class FakeAccessDecisionTests
{
private readonly FakeAccessDecision Policy = new FakeAccessDecision();

[Test]
public void Decide_WhenUserIsUnknown_Denies()
{
IIdentityContext unknownUser = new AnonymousIdentityContext();

AccessResult result = Policy.Decide(unknownUser, LabActions.List);

Assert.That(result.Allowed, Is.False);
Assert.That(result.Reason, Is.EqualTo(DenyReason.Unauthenticated));
}

[Test]
public void Decide_WhenActionIsUnknown_Denies()
{
IIdentityContext teacher = new TestUser("marko", PlatformRole.Teacher);

AccessResult result = Policy.Decide(teacher, "laboratory.delete-everything");

Assert.That(result.Allowed, Is.False);
Assert.That(result.Reason, Is.EqualTo(DenyReason.UnknownAction));
}

[Test]
public void Decide_WhenStudentCreatesLaboratory_Denies()
{
IIdentityContext student = new TestUser("ana", PlatformRole.Student);

AccessResult result = Policy.Decide(student, LabActions.Create);

Assert.That(result.Allowed, Is.False);
Assert.That(result.Reason, Is.EqualTo(DenyReason.NotPermitted));
}

[Test]
public void Decide_WhenTeacherCreatesLaboratory_Allows()
{
IIdentityContext teacher = new TestUser("marko", PlatformRole.Teacher);

AccessResult result = Policy.Decide(teacher, LabActions.Create);

Assert.That(result.Allowed, Is.True);
}

//lazni korisnik za potrebe testova
private sealed class TestUser : IIdentityContext
{
public TestUser(string userId, PlatformRole role)
{
UserId = userId;
Role = role;
}

public bool IsAuthenticated => true;

public string? UserId { get; }

public PlatformRole? Role { get; }
}
}
28 changes: 28 additions & 0 deletions backend/Laboratories.Tests/Laboratories.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.14" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="NUnit" Version="4.3.2" />
<PackageReference Include="NUnit.Analyzers" Version="4.7.0" />
<PackageReference Include="NUnit3TestAdapter" Version="5.0.0" />
</ItemGroup>

<ItemGroup>
<Using Include="NUnit.Framework" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Platform.Api\Platform.Api.csproj" />
</ItemGroup>

</Project>
103 changes: 103 additions & 0 deletions backend/Laboratories.Tests/LaboratoriesHttpTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
using System.Net;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Logging;

namespace Laboratories.Tests;

[TestFixture]
public sealed class LaboratoriesHttpTests
{
private const string Route = "/api/laboratories/";
private const string TestUserHeader = "X-Test-User";

[TestCase("GET", null, HttpStatusCode.Unauthorized)]
[TestCase("POST", null, HttpStatusCode.Unauthorized)]
[TestCase("GET", "ana:Student", HttpStatusCode.OK)]
[TestCase("POST", "ana:Student", HttpStatusCode.Forbidden)]
[TestCase("POST", "marko:Teacher", HttpStatusCode.NotImplemented)]
[TestCase("GET", "ana:Unknown", HttpStatusCode.Unauthorized)]
[TestCase("GET", "invalid", HttpStatusCode.Unauthorized)]
public async Task Development_EnforcesAccessBeforeEndpoint(
string method, string? testUser, HttpStatusCode expectedStatus)
{
using var factory = new LaboratoriesHostFactory("Development");
using var client = factory.CreateClient();
using var request = CreateRequest(method, testUser);

using var response = await client.SendAsync(request);

Assert.That(response.StatusCode, Is.EqualTo(expectedStatus));
if (expectedStatus == HttpStatusCode.OK)
{
Assert.That(await response.Content.ReadAsStringAsync(), Is.EqualTo("[]"));
}
}

[Test]
public async Task Development_WithMultipleIdentityHeaders_Denies()
{
using var factory = new LaboratoriesHostFactory("Development");
using var client = factory.CreateClient();
using var request = CreateRequest("POST", null);
request.Headers.Add(TestUserHeader, new[] { "ana:Student", "marko:Teacher" });

using var response = await client.SendAsync(request);

Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
}

[Test]
public async Task Development_IdentityDoesNotLeakBetweenRequests()
{
using var factory = new LaboratoriesHostFactory("Development");
using var client = factory.CreateClient();
using var teacherRequest = CreateRequest("POST", "marko:Teacher");
using var teacherResponse = await client.SendAsync(teacherRequest);
Assert.That(teacherResponse.StatusCode, Is.EqualTo(HttpStatusCode.NotImplemented));

using var studentRequest = CreateRequest("POST", "ana:Student");
using var studentResponse = await client.SendAsync(studentRequest);
Assert.That(studentResponse.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden));

using var anonymousRequest = CreateRequest("POST", null);
using var anonymousResponse = await client.SendAsync(anonymousRequest);
Assert.That(anonymousResponse.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
}

[TestCase("Production", "GET")]
[TestCase("Production", "POST")]
[TestCase("Staging", "GET")]
[TestCase("Staging", "POST")]
public async Task OutsideDevelopment_TestTeacherHeaderDoesNotGrantAccess(
string environment, string method)
{
using var factory = new LaboratoriesHostFactory(environment);
using var client = factory.CreateClient();
using var request = CreateRequest(method, "marko:Teacher");

using var response = await client.SendAsync(request);

Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
}

private static HttpRequestMessage CreateRequest(string method, string? testUser)
{
var request = new HttpRequestMessage(new HttpMethod(method), Route);
if (testUser is not null)
{
request.Headers.Add(TestUserHeader, testUser);
}

return request;
}

private sealed class LaboratoriesHostFactory(string environment) : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment(environment);
builder.ConfigureLogging(logging => logging.ClearProviders());
}
}
}
111 changes: 111 additions & 0 deletions backend/Laboratories.Tests/LaboratoryTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
using Platform.Api.Modules.Laboratories.Domain;

namespace Laboratories.Tests;

public class LaboratoryTests
{
[Test]
public void Create_GeneratesDistinctNonEmptyIds()
{
var first = new Laboratory("SQL injection");
var second = new Laboratory("SQL injection");

Assert.That(first.Id, Is.Not.EqualTo(Guid.Empty));
Assert.That(second.Id, Is.Not.EqualTo(Guid.Empty));
Assert.That(second.Id, Is.Not.EqualTo(first.Id));
}

[TestCase(null)]
[TestCase("")]
[TestCase(" ")]
public void Create_WithMissingTitle_Throws(string? title)
{
Assert.Throws<ArgumentException>(() => new Laboratory(title!));
}

[Test]
public void Create_WithOverlongTitle_Throws()
{
Assert.Throws<ArgumentException>(() =>
new Laboratory(new string('a', Laboratory.MaxTitleLength + 1)));
}

[Test]
public void Create_WithMaximumLengthTitle_Accepts()
{
string title = new string('a', Laboratory.MaxTitleLength);

var lab = new Laboratory(title);

Assert.That(lab.Title, Is.EqualTo(title));
}

[Test]
public void Create_WithEmptyExistingId_Throws()
{
Assert.Throws<ArgumentException>(() => new Laboratory(Guid.Empty, "SQL injection"));
}

[Test]
public void Rename_KeepsSameId()
{
Guid id = Guid.NewGuid();
Laboratory lab = new Laboratory(id, "SQL injection");

lab.Rename("SQL injection - napredno");

Assert.That(lab.Id, Is.EqualTo(id));
Assert.That(lab.Title, Is.EqualTo("SQL injection - napredno"));
}

[TestCase(null)]
[TestCase("")]
[TestCase(" ")]
public void Rename_WithMissingTitle_ThrowsAndPreservesState(string? title)
{
var lab = new Laboratory("Original title");
Guid originalId = lab.Id;

Assert.Throws<ArgumentException>(() => lab.Rename(title!));

Assert.That(lab.Id, Is.EqualTo(originalId));
Assert.That(lab.Title, Is.EqualTo("Original title"));
}

[Test]
public void Rename_WithOverlongTitle_ThrowsAndPreservesState()
{
var lab = new Laboratory("Original title");
Guid originalId = lab.Id;

Assert.Throws<ArgumentException>(() =>
lab.Rename(new string('a', Laboratory.MaxTitleLength + 1)));

Assert.That(lab.Id, Is.EqualTo(originalId));
Assert.That(lab.Title, Is.EqualTo("Original title"));
}

[Test]
public void Rename_WithMaximumLengthTitle_AcceptsAndKeepsId()
{
var lab = new Laboratory("Original title");
Guid originalId = lab.Id;
string title = new string('a', Laboratory.MaxTitleLength);

lab.Rename(title);

Assert.That(lab.Id, Is.EqualTo(originalId));
Assert.That(lab.Title, Is.EqualTo(title));
}

[Test]
public void CreateAndRename_TrimSurroundingWhitespace()
{
var lab = new Laboratory(" Original title ");
Assert.That(lab.Title, Is.EqualTo("Original title"));

lab.Rename(" Updated title ");

Assert.That(lab.Title, Is.EqualTo("Updated title"));
}
}
37 changes: 37 additions & 0 deletions backend/Platform.Api/Modules/Laboratories/Api/AccessCheckFilter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using Platform.Api.Modules.Laboratories.Application;

namespace Platform.Api.Modules.Laboratories.Api;

// zahtev -> ko je? (IIdentityContext) -> sme li? (IAccessDecision) -> dozvoli / odbij
public static class AccessCheckFilter
{
public static RouteHandlerBuilder RequireLabAccess(this RouteHandlerBuilder builder, string action)
{
return builder.AddEndpointFilter(async (context, next) =>
{
HttpContext http = context.HttpContext;
IIdentityContext identity = http.RequestServices.GetRequiredService<IIdentityContext>();
IAccessDecision accessDecision = http.RequestServices.GetRequiredService<IAccessDecision>();

AccessResult result = accessDecision.Decide(identity, action);

// Dozvoljeno
if (result.Allowed)
{
return await next(context);
}

// Odbijeno -> zapisi u log i vrati gresku
ILogger logger = http.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("Laboratories.Access");
logger.LogWarning("Pristup odbijen: radnja={Action} korisnik={UserId} razlog={Reason}",
action, identity.UserId ?? "(nepoznat)", result.Reason);

if (result.Reason == DenyReason.Unauthenticated)
{
return Results.Problem(statusCode: 401, title: "Unauthenticated");
}

return Results.Problem(statusCode: 403, title: "Access denied");
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using Platform.Api.Modules.Laboratories.Application;

namespace Platform.Api.Modules.Laboratories.Api;

public static class LaboratoriesEndpoints
{
public static IEndpointRouteBuilder MapLaboratoriesEndpoints(this IEndpointRouteBuilder app)
{
RouteGroupBuilder group = app.MapGroup("/api/laboratories");

group.MapGet("/", () => Results.Ok(Array.Empty<object>()))
.RequireLabAccess(LabActions.List);

group.MapPost("/", () => Results.StatusCode(501))
.RequireLabAccess(LabActions.Create);

return app;
}
}
Loading
Loading