Skip to content
This repository was archived by the owner on Jul 6, 2026. It is now read-only.
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
1 change: 1 addition & 0 deletions backend/src/Loopless.Api/Endpoints/ProjectEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ public static IEndpointRouteBuilder MapProjectEndpoints(this IEndpointRouteBuild
.WithName("GetProjectCommits")
.Produces<PagedResult<GitHubCommitDto>>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound)
.ProducesValidationProblem();

Expand Down
3 changes: 3 additions & 0 deletions backend/src/Loopless.Api/Endpoints/StandupEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ public static IEndpointRouteBuilder MapStandupEndpoints(this IEndpointRouteBuild
.WithName("GetStandupFeed")
.Produces<PagedResult<StandupDto>>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound)
.ProducesValidationProblem();

group.MapGet("/analytics", async (
Expand Down Expand Up @@ -84,6 +86,7 @@ public static IEndpointRouteBuilder MapStandupEndpoints(this IEndpointRouteBuild
.WithName("ExportStandupsCsv")
.Produces(StatusCodes.Status200OK, contentType: "text/csv")
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);

return app;
Expand Down
14 changes: 13 additions & 1 deletion backend/src/Loopless.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,19 @@
app.MapGet("/", () => Results.Redirect("/swagger")).ExcludeFromDescription();
}

app.MapMetrics("/metrics");
// Prometheus scrape endpoint. Snippet-based path blocking is disabled on the prod
// ingress, so restrict /metrics by Host instead: the in-cluster scrape reaches the
// pod directly as "loopless-backend:8080" (allowed), while public traffic arrives as
// the ingress host (api.<domain>) which is not in the allowlist and gets 404 — keeping
// metrics off the public internet without breaking scraping. Unset (dev/local) = open.
var metricsEndpoint = app.MapMetrics("/metrics");
var metricsAllowedHosts = app.Configuration
.GetSection("Metrics:AllowedHosts").Get<string[]>();
if (metricsAllowedHosts is { Length: > 0 })
{
metricsEndpoint.RequireHost(metricsAllowedHosts);
}

app.MapHealthChecks("/health");
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,39 @@
using Loopless.Application.Common.Exceptions;
using Loopless.Application.DTOs;
using Loopless.Application.Interfaces;
using Loopless.Domain.Enums;
using MediatR;
using Microsoft.EntityFrameworkCore;

namespace Loopless.Application.Features.Projects.GetCommits;

public sealed class GetProjectCommitsQueryHandler(
IAppDbContext db) : IRequestHandler<GetProjectCommitsQuery, PagedResult<GitHubCommitDto>>
IAppDbContext db,
ICurrentUser currentUser) : IRequestHandler<GetProjectCommitsQuery, PagedResult<GitHubCommitDto>>
{
public async Task<PagedResult<GitHubCommitDto>> Handle(GetProjectCommitsQuery request, CancellationToken cancellationToken)
{
var projectExists = await db.Projects
.AnyAsync(p => p.Id == request.ProjectId, cancellationToken);
var keycloakId = currentUser.KeycloakId
?? throw new UnauthorizedException("Missing subject claim.");

if (!projectExists)
throw new NotFoundException("Project not found.");
var user = await db.Users
.FirstOrDefaultAsync(u => u.KeycloakId == keycloakId, cancellationToken)
?? throw new NotFoundException("User not found.");

var project = await db.Projects
.FirstOrDefaultAsync(p => p.Id == request.ProjectId, cancellationToken)
?? throw new NotFoundException("Project not found.");

var isOwner = project.OwnerId == user.Id;
var isMember = await db.ProjectInvitations
.AnyAsync(
i => i.ProjectId == project.Id
&& (i.EnterpriseId == user.Id || i.FreelancerId == user.Id)
&& i.Status == InvitationStatus.Accepted,
cancellationToken);

if (!isOwner && !isMember)
throw new ForbiddenException("Only project members can view commits.");

var baseQuery = db.GitHubCommits.Where(c => c.ProjectId == request.ProjectId);
var totalCount = await baseQuery.CountAsync(cancellationToken);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,39 @@
using Loopless.Application.Common.Exceptions;
using Loopless.Application.DTOs;
using Loopless.Application.Interfaces;
using Loopless.Domain.Enums;
using MediatR;
using Microsoft.EntityFrameworkCore;

namespace Loopless.Application.Features.Standups.GetAnalytics;

public sealed class ExportStandupsCsvQueryHandler(
IAppDbContext db) : IRequestHandler<ExportStandupsCsvQuery, IReadOnlyList<StandupCsvRowDto>>
IAppDbContext db,
ICurrentUser currentUser) : IRequestHandler<ExportStandupsCsvQuery, IReadOnlyList<StandupCsvRowDto>>
{
public async Task<IReadOnlyList<StandupCsvRowDto>> Handle(ExportStandupsCsvQuery request, CancellationToken cancellationToken)
{
var projectExists = await db.Projects.AnyAsync(p => p.Id == request.ProjectId, cancellationToken);
if (!projectExists) throw new NotFoundException("Project not found.");
var keycloakId = currentUser.KeycloakId
?? throw new UnauthorizedException("Missing subject claim.");

var user = await db.Users
.FirstOrDefaultAsync(u => u.KeycloakId == keycloakId, cancellationToken)
?? throw new NotFoundException("User not found.");

var project = await db.Projects
.FirstOrDefaultAsync(p => p.Id == request.ProjectId, cancellationToken)
?? throw new NotFoundException("Project not found.");

var isOwner = project.OwnerId == user.Id;
var isMember = await db.ProjectInvitations
.AnyAsync(
i => i.ProjectId == project.Id
&& (i.EnterpriseId == user.Id || i.FreelancerId == user.Id)
&& i.Status == InvitationStatus.Accepted,
cancellationToken);

if (!isOwner && !isMember)
throw new ForbiddenException("Only project members can export standups.");

return await db.Standups
.Where(s => s.ProjectId == request.ProjectId)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,40 @@
using Loopless.Application.DTOs;
using Loopless.Application.Common.Exceptions;
using Loopless.Application.DTOs;
using Loopless.Application.Interfaces;
using Loopless.Domain.Enums;
using MediatR;
using Microsoft.EntityFrameworkCore;

namespace Loopless.Application.Features.Standups.GetFeed;

public sealed class GetStandupFeedQueryHandler(
IAppDbContext db) : IRequestHandler<GetStandupFeedQuery, PagedResult<StandupDto>>
IAppDbContext db,
ICurrentUser currentUser) : IRequestHandler<GetStandupFeedQuery, PagedResult<StandupDto>>
{
public async Task<PagedResult<StandupDto>> Handle(GetStandupFeedQuery request, CancellationToken cancellationToken)
{
var keycloakId = currentUser.KeycloakId
?? throw new UnauthorizedException("Missing subject claim.");

var user = await db.Users
.FirstOrDefaultAsync(u => u.KeycloakId == keycloakId, cancellationToken)
?? throw new NotFoundException("User not found.");

var project = await db.Projects
.FirstOrDefaultAsync(p => p.Id == request.ProjectId, cancellationToken)
?? throw new NotFoundException("Project not found.");

var isOwner = project.OwnerId == user.Id;
var isMember = await db.ProjectInvitations
.AnyAsync(
i => i.ProjectId == project.Id
&& (i.EnterpriseId == user.Id || i.FreelancerId == user.Id)
&& i.Status == InvitationStatus.Accepted,
cancellationToken);

if (!isOwner && !isMember)
throw new ForbiddenException("Only project members can view the standup feed.");

var baseQuery = db.Standups.Where(s => s.ProjectId == request.ProjectId);

var totalCount = await baseQuery.CountAsync(cancellationToken);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
using Loopless.Application.Common.Exceptions;
using Loopless.Application.Features.Projects.GetCommits;
using Loopless.Application.Features.Standups.GetAnalytics;
using Loopless.Application.Features.Standups.GetFeed;
using Loopless.Domain.Entities;
using Loopless.Domain.Enums;
using Loopless.UnitTests.Messaging;
using Xunit;

namespace Loopless.UnitTests.Visibility;

// Regression tests for FR-4.7 "project visibility invited-only, enforced backend".
// The standup feed, CSV export, and commits endpoints previously returned another
// project's data to ANY authenticated user (no membership check). These assert the
// owner-or-accepted-member guard now blocks non-members and admits owner + members.
public class ProjectVisibilityGuardTests
{
private static User MakeUser(string keycloakId, UserRole role)
=> new()
{
Email = $"{keycloakId}@test.local",
Name = keycloakId,
Role = role,
KeycloakId = keycloakId,
};

private static Project MakeProject(Guid ownerId)
=> new() { OwnerId = ownerId, Title = "Test Project", Status = ProjectStatus.Active };

private sealed record Fixture(
ProjectVisibilityTestDbContext Db,
Project Project,
User Owner,
User Member,
User Outsider);

// Owner (enterprise) + an accepted freelancer member + an unrelated outsider, with one
// standup and one commit on the project.
private static async Task<Fixture> SeedAsync()
{
var db = ProjectVisibilityTestDbContext.CreateInMemory();

var owner = MakeUser("owner", UserRole.Enterprise);
var member = MakeUser("member", UserRole.Freelancer);
var outsider = MakeUser("outsider", UserRole.Freelancer);
db.Users.AddRange(owner, member, outsider);

var project = MakeProject(owner.Id);
db.Projects.Add(project);

db.ProjectInvitations.Add(new ProjectInvitation
{
ProjectId = project.Id,
EnterpriseId = owner.Id,
FreelancerId = member.Id,
Status = InvitationStatus.Accepted,
});

db.Standups.Add(new Standup
{
ProjectId = project.Id,
FreelancerId = member.Id,
WhatDid = "did",
WhatPlan = "plan",
Blockers = "none",
CreatedAt = DateTimeOffset.UtcNow,
});

db.GitHubCommits.Add(new GitHubCommit
{
ProjectId = project.Id,
Sha = "abc123",
Message = "init",
Author = "member",
CommittedAt = DateTimeOffset.UtcNow,
});

await db.SaveChangesAsync();
return new Fixture(db, project, owner, member, outsider);
}

// ---- Standup feed -------------------------------------------------------

[Fact]
public async Task StandupFeed_Outsider_IsForbidden()
{
var f = await SeedAsync();
var handler = new GetStandupFeedQueryHandler(f.Db, new FakeCurrentUser(f.Outsider.KeycloakId));
await Assert.ThrowsAsync<ForbiddenException>(() =>
handler.Handle(new GetStandupFeedQuery(f.Project.Id, 1, 20), CancellationToken.None));
}

[Fact]
public async Task StandupFeed_Owner_AndMember_SeeEntries()
{
var f = await SeedAsync();

var asOwner = new GetStandupFeedQueryHandler(f.Db, new FakeCurrentUser(f.Owner.KeycloakId));
var ownerResult = await asOwner.Handle(new GetStandupFeedQuery(f.Project.Id, 1, 20), CancellationToken.None);
Assert.Equal(1, ownerResult.TotalCount);

var asMember = new GetStandupFeedQueryHandler(f.Db, new FakeCurrentUser(f.Member.KeycloakId));
var memberResult = await asMember.Handle(new GetStandupFeedQuery(f.Project.Id, 1, 20), CancellationToken.None);
Assert.Equal(1, memberResult.TotalCount);
}

// ---- CSV export ---------------------------------------------------------

[Fact]
public async Task StandupCsvExport_Outsider_IsForbidden()
{
var f = await SeedAsync();
var handler = new ExportStandupsCsvQueryHandler(f.Db, new FakeCurrentUser(f.Outsider.KeycloakId));
await Assert.ThrowsAsync<ForbiddenException>(() =>
handler.Handle(new ExportStandupsCsvQuery(f.Project.Id), CancellationToken.None));
}

[Fact]
public async Task StandupCsvExport_Member_GetsRows()
{
var f = await SeedAsync();
var handler = new ExportStandupsCsvQueryHandler(f.Db, new FakeCurrentUser(f.Member.KeycloakId));
var rows = await handler.Handle(new ExportStandupsCsvQuery(f.Project.Id), CancellationToken.None);
Assert.Single(rows);
}

// ---- Commits ------------------------------------------------------------

[Fact]
public async Task Commits_Outsider_IsForbidden()
{
var f = await SeedAsync();
var handler = new GetProjectCommitsQueryHandler(f.Db, new FakeCurrentUser(f.Outsider.KeycloakId));
await Assert.ThrowsAsync<ForbiddenException>(() =>
handler.Handle(new GetProjectCommitsQuery(f.Project.Id, 1, 20), CancellationToken.None));
}

[Fact]
public async Task Commits_Owner_SeesCommits()
{
var f = await SeedAsync();
var handler = new GetProjectCommitsQueryHandler(f.Db, new FakeCurrentUser(f.Owner.KeycloakId));
var result = await handler.Handle(new GetProjectCommitsQuery(f.Project.Id, 1, 20), CancellationToken.None);
Assert.Equal(1, result.TotalCount);
}
}
Loading
Loading