diff --git a/backend/Laboratories.Tests/FakeAccessDecisionTests.cs b/backend/Laboratories.Tests/FakeAccessDecisionTests.cs
new file mode 100644
index 0000000..673bc27
--- /dev/null
+++ b/backend/Laboratories.Tests/FakeAccessDecisionTests.cs
@@ -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; }
+ }
+}
\ No newline at end of file
diff --git a/backend/Laboratories.Tests/Laboratories.Tests.csproj b/backend/Laboratories.Tests/Laboratories.Tests.csproj
new file mode 100644
index 0000000..107c9ac
--- /dev/null
+++ b/backend/Laboratories.Tests/Laboratories.Tests.csproj
@@ -0,0 +1,28 @@
+
+
+
+ net10.0
+ latest
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/Laboratories.Tests/LaboratoriesHttpTests.cs b/backend/Laboratories.Tests/LaboratoriesHttpTests.cs
new file mode 100644
index 0000000..e33e85a
--- /dev/null
+++ b/backend/Laboratories.Tests/LaboratoriesHttpTests.cs
@@ -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
+ {
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ builder.UseEnvironment(environment);
+ builder.ConfigureLogging(logging => logging.ClearProviders());
+ }
+ }
+}
diff --git a/backend/Laboratories.Tests/LaboratoryTests.cs b/backend/Laboratories.Tests/LaboratoryTests.cs
new file mode 100644
index 0000000..9340a30
--- /dev/null
+++ b/backend/Laboratories.Tests/LaboratoryTests.cs
@@ -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(() => new Laboratory(title!));
+ }
+
+ [Test]
+ public void Create_WithOverlongTitle_Throws()
+ {
+ Assert.Throws(() =>
+ 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(() => 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(() => 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(() =>
+ 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"));
+ }
+}
diff --git a/backend/Platform.Api/Modules/Laboratories/Api/AccessCheckFilter.cs b/backend/Platform.Api/Modules/Laboratories/Api/AccessCheckFilter.cs
new file mode 100644
index 0000000..4fcda6e
--- /dev/null
+++ b/backend/Platform.Api/Modules/Laboratories/Api/AccessCheckFilter.cs
@@ -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();
+ IAccessDecision accessDecision = http.RequestServices.GetRequiredService();
+
+ 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().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");
+ });
+ }
+}
\ No newline at end of file
diff --git a/backend/Platform.Api/Modules/Laboratories/Api/LaboratoriesEndpoints.cs b/backend/Platform.Api/Modules/Laboratories/Api/LaboratoriesEndpoints.cs
new file mode 100644
index 0000000..2e637cb
--- /dev/null
+++ b/backend/Platform.Api/Modules/Laboratories/Api/LaboratoriesEndpoints.cs
@@ -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