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
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package tum.devoops.eventservice.entity;

import java.io.Serializable;
import java.util.UUID;

import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import jakarta.persistence.EmbeddedId;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.Getter;
import lombok.NoArgsConstructor;

/**
* Read-only shadow of {@code organization.directors}, owned by the organization service.
*/
@Entity
@Table(schema = "organization", name = "directors")
@Getter
@NoArgsConstructor
public class DirectorEntity {

// Composite PK: (sport_id, member_id).
@EmbeddedId
private Id id;

@Embeddable
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class Id implements Serializable {
@Column(name = "sport_id", nullable = false)
private UUID sportId;

@Column(name = "member_id", nullable = false)
private UUID memberId;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,7 @@ public class TeamEntity {

@Column(name = "name", insertable = false, updatable = false)
private String name;

@Column(name = "sport_id", insertable = false, updatable = false)
private UUID sportId;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package tum.devoops.eventservice.entity;

import java.io.Serializable;
import java.util.UUID;

import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import jakarta.persistence.EmbeddedId;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.Getter;
import lombok.NoArgsConstructor;

/**
* Read-only shadow of {@code organization.trainers}, owned by the organization service.
*/
@Entity
@Table(schema = "organization", name = "trainers")
@Getter
@NoArgsConstructor
public class TrainerEntity {

// Composite PK: (team_id, member_id).
@EmbeddedId
private Id id;

@Embeddable
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class Id implements Serializable {
@Column(name = "team_id", nullable = false)
private UUID teamId;

@Column(name = "member_id", nullable = false)
private UUID memberId;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package tum.devoops.eventservice.repository;

import org.springframework.data.jpa.repository.JpaRepository;

import tum.devoops.eventservice.entity.DirectorEntity;

public interface DirectorRepository extends JpaRepository<DirectorEntity, DirectorEntity.Id> {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package tum.devoops.eventservice.repository;

import org.springframework.data.jpa.repository.JpaRepository;

import tum.devoops.eventservice.entity.TrainerEntity;

public interface TrainerRepository extends JpaRepository<TrainerEntity, TrainerEntity.Id> {
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@

import tum.devoops.eventservice.converter.EventConverter;
import tum.devoops.eventservice.entity.AttendanceEntity;
import tum.devoops.eventservice.entity.DirectorEntity;
import tum.devoops.eventservice.entity.EventEntity;
import tum.devoops.eventservice.entity.SportEventEntity;
import tum.devoops.eventservice.entity.TeamEventEntity;
import tum.devoops.eventservice.entity.TrainerEntity;
import tum.devoops.eventservice.exception.BadRequestException;
import tum.devoops.eventservice.exception.ForbiddenException;
import tum.devoops.eventservice.exception.NotFoundException;
Expand All @@ -26,12 +28,14 @@
import tum.devoops.eventservice.model.EventPartialUpdate;
import tum.devoops.eventservice.model.EventSummary;
import tum.devoops.eventservice.repository.AttendanceRepository;
import tum.devoops.eventservice.repository.DirectorRepository;
import tum.devoops.eventservice.repository.EventRepository;
import tum.devoops.eventservice.repository.MemberRepository;
import tum.devoops.eventservice.repository.SportEventRepository;
import tum.devoops.eventservice.repository.SportRepository;
import tum.devoops.eventservice.repository.TeamEventRepository;
import tum.devoops.eventservice.repository.TeamRepository;
import tum.devoops.eventservice.repository.TrainerRepository;

@Service
public class EventService {
Expand All @@ -50,6 +54,10 @@ public class EventService {
private SportRepository sportRepository;
@Autowired
private TeamRepository teamRepository;
@Autowired
private TrainerRepository trainerRepository;
@Autowired
private DirectorRepository directorRepository;

@Transactional(readOnly = true)
public List<EventSummary> getAllEvents(UUID requesterId, boolean isAdmin) {
Expand Down Expand Up @@ -99,6 +107,10 @@ public Event createEvent(EventCreate body, UUID requesterId, boolean isAdmin) {
throw new BadRequestException("Event end time must be after start time");
}

if (!isAdmin && !canCreateEvent(requesterId, body)) {
throw new ForbiddenException("Access denied");
}

EventEntity entity = new EventEntity();
entity.setName(body.getName());
entity.setDescription(body.getDescription());
Expand Down Expand Up @@ -177,6 +189,30 @@ public void deleteEvent(UUID eventId, UUID requesterId, boolean isAdmin) {
eventRepository.delete(entity);
}

// Per the API contract: trainers may only create events for a team they coach; directors
// only for a sport they direct (either linked directly via sports_linked, or via one of the
// linked teams' sport). Admins bypass this entirely (checked by the caller).
private boolean canCreateEvent(UUID requesterId, EventCreate body) {
List<UUID> teamIds = body.getTeamsLinked() == null
? List.of()
: body.getTeamsLinked().stream().map(t -> parseUuid(t, "teams_linked")).collect(Collectors.toList());

boolean isTrainerOfTeam = teamIds.stream()
.anyMatch(teamId -> trainerRepository.existsById(new TrainerEntity.Id(teamId, requesterId)));
if (isTrainerOfTeam) {
return true;
}

Set<UUID> sportIds = new HashSet<>();
if (body.getSportsLinked() != null) {
sportIds.addAll(body.getSportsLinked());
}
teamRepository.findAllById(teamIds).forEach(team -> sportIds.add(team.getSportId()));

return sportIds.stream()
.anyMatch(sportId -> directorRepository.existsById(new DirectorEntity.Id(sportId, requesterId)));
}

Comment on lines +192 to +215

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the service and nearby tests
git ls-files 'services/spring-event/**' | sed -n '1,200p'

echo '--- EventService outline ---'
ast-grep outline services/spring-event/src/main/java/tum/devoops/eventservice/service/EventService.java --view expanded

echo '--- Search for canCreateEvent and create-event tests ---'
rg -n "canCreateEvent|teamsLinked|sportsLinked|create event|EventCreate" services/spring-event/src/test services/spring-event/src/main/java -S

Repository: AET-DevOps26/team-devoops

Length of output: 10170


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- EventService.java (100-220) ---'
sed -n '100,220p' services/spring-event/src/main/java/tum/devoops/eventservice/service/EventService.java | cat -n

echo '--- EventServiceTest.java (190-290) ---'
sed -n '190,290p' services/spring-event/src/test/java/tum/devoops/eventservice/service/EventServiceTest.java | cat -n

echo '--- EventCreate model fields ---'
sed -n '1,220p' services/spring-event/src/generated/java/tum/devoops/eventservice/model/EventCreate.java | cat -n

Repository: AET-DevOps26/team-devoops

Length of output: 18727


Check every linked team/sport before allowing create
services/spring-event/src/main/java/tum/devoops/eventservice/service/EventService.java:195-215 accepts the request if the requester matches any one linked team or any sport in the combined set. That lets a trainer/director mix one authorized link with unauthorized teams/sports and still create the event with the full payload. Add coverage for mixed authorized/unauthorized links and require each linked team/sport to be individually in scope.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@services/spring-event/src/main/java/tum/devoops/eventservice/service/EventService.java`
around lines 192 - 215, The canCreateEvent method in EventService currently
grants access when requesterId matches any single team or sport in the request,
which allows mixed authorized and unauthorized links to slip through. Update the
validation logic so every linked team in teamsLinked and every linked sport in
sportsLinked is checked individually against trainerRepository and
directorRepository, rather than using anyMatch over the whole set. Keep the
existing parsing and repository lookups, but change the authorization flow to
reject the request unless all linked entities are in scope for the requester.

private void persistLinks(UUID eventId, List<String> attendees, List<UUID> sports, List<String> teams) {
if (attendees != null) {
attendanceRepository.saveAll(buildAttendanceEntities(eventId, attendees));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,13 @@
import org.mockito.junit.jupiter.MockitoExtension;

import tum.devoops.eventservice.entity.AttendanceEntity;
import tum.devoops.eventservice.entity.DirectorEntity;
import tum.devoops.eventservice.entity.MemberEntity;
import tum.devoops.eventservice.entity.EventEntity;
import tum.devoops.eventservice.entity.SportEventEntity;
import tum.devoops.eventservice.entity.TeamEntity;
import tum.devoops.eventservice.entity.TeamEventEntity;
import tum.devoops.eventservice.entity.TrainerEntity;
import tum.devoops.eventservice.exception.BadRequestException;
import tum.devoops.eventservice.exception.ForbiddenException;
import tum.devoops.eventservice.exception.NotFoundException;
Expand All @@ -35,12 +38,14 @@
import tum.devoops.eventservice.model.EventSummary;
import tum.devoops.eventservice.model.Reference;
import tum.devoops.eventservice.repository.AttendanceRepository;
import tum.devoops.eventservice.repository.DirectorRepository;
import tum.devoops.eventservice.repository.EventRepository;
import tum.devoops.eventservice.repository.MemberRepository;
import tum.devoops.eventservice.repository.SportEventRepository;
import tum.devoops.eventservice.repository.SportRepository;
import tum.devoops.eventservice.repository.TeamEventRepository;
import tum.devoops.eventservice.repository.TeamRepository;
import tum.devoops.eventservice.repository.TrainerRepository;

@ExtendWith(MockitoExtension.class)
class EventServiceTest {
Expand All @@ -59,6 +64,10 @@ class EventServiceTest {
private SportRepository sportRepository;
@Mock
private TeamRepository teamRepository;
@Mock
private TrainerRepository trainerRepository;
@Mock
private DirectorRepository directorRepository;

@InjectMocks
private EventService service;
Expand Down Expand Up @@ -218,6 +227,65 @@ void createEventWithInvalidAttendeeUuidThrowsBadRequest() {
.isInstanceOf(BadRequestException.class);
}

@Test
void createEventAsPlainMemberThrowsForbidden() {
EventCreate body = validCreate().teamsLinked(List.of(TEAM_ID.toString()));

assertThatThrownBy(() -> service.createEvent(body, REQUESTER_ID, false))
.isInstanceOf(ForbiddenException.class);
verify(eventRepository, never()).save(any());
}

@Test
void createEventAsTrainerOfLinkedTeamSucceeds() {
when(trainerRepository.existsById(new TrainerEntity.Id(TEAM_ID, REQUESTER_ID))).thenReturn(true);
when(eventRepository.save(any())).thenReturn(eventEntity(EVENT_ID, REQUESTER_ID));
EventCreate body = validCreate().teamsLinked(List.of(TEAM_ID.toString()));

Event result = service.createEvent(body, REQUESTER_ID, false);

assertThat(result.getId()).isEqualTo(EVENT_ID);
}

@Test
void createEventAsTrainerOfUnrelatedTeamThrowsForbidden() {
// Trainer of some other team, not the one this event links to.
when(trainerRepository.existsById(new TrainerEntity.Id(TEAM_ID, REQUESTER_ID))).thenReturn(false);
when(teamRepository.findAllById(List.of(TEAM_ID))).thenReturn(List.of());
EventCreate body = validCreate().teamsLinked(List.of(TEAM_ID.toString()));

assertThatThrownBy(() -> service.createEvent(body, REQUESTER_ID, false))
.isInstanceOf(ForbiddenException.class);
verify(eventRepository, never()).save(any());
}

@Test
void createEventAsDirectorOfLinkedTeamsSportSucceeds() {
TeamEntity team = new TeamEntity();
team.setId(TEAM_ID);
team.setSportId(SPORT_ID);
when(trainerRepository.existsById(new TrainerEntity.Id(TEAM_ID, REQUESTER_ID))).thenReturn(false);
when(teamRepository.findAllById(List.of(TEAM_ID))).thenReturn(List.of(team));
when(directorRepository.existsById(new DirectorEntity.Id(SPORT_ID, REQUESTER_ID))).thenReturn(true);
when(eventRepository.save(any())).thenReturn(eventEntity(EVENT_ID, REQUESTER_ID));
EventCreate body = validCreate().teamsLinked(List.of(TEAM_ID.toString()));

Event result = service.createEvent(body, REQUESTER_ID, false);

assertThat(result.getId()).isEqualTo(EVENT_ID);
}

@Test
void createEventAsDirectorOfExplicitSportSucceeds() {
when(directorRepository.existsById(new DirectorEntity.Id(SPORT_ID, REQUESTER_ID))).thenReturn(true);
when(eventRepository.save(any())).thenReturn(eventEntity(EVENT_ID, REQUESTER_ID));
EventCreate body = validCreate().sportsLinked(List.of(SPORT_ID));

Event result = service.createEvent(body, REQUESTER_ID, false);

assertThat(result.getId()).isEqualTo(EVENT_ID);
}

// ─── getEventDetails ───────────────────────────────────────────────────────

@Test
Expand Down
96 changes: 96 additions & 0 deletions web-client/src/__tests__/DashboardPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { MemoryRouter } from 'react-router-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('@/features/auth', async () => {
const { MOCK_PERSONAS } = await import('@/mocks/personas')

return {
useAuth: () => ({ user: MOCK_PERSONAS.coach }),
}
})

vi.mock('@/app/pages/api/dashboardQueries', async () => {
const { dashboardFixtures } = await import('@/mocks/fixtures/dashboard')

return {
useDashboard: () => ({
data: dashboardFixtures.coach,
isLoading: false,
error: null,
}),
}
})

vi.mock('@/features/sport-events/api/queries', async () => {
const { eventSummaryFixtures } = await import('@/mocks/fixtures')
const { MOCK_PERSONAS } = await import('@/mocks/personas')
const { scopeEvents } = await import('@/mocks/scope')

return {
useEventsList: () => ({
data: scopeEvents(eventSummaryFixtures, MOCK_PERSONAS.coach),
isLoading: false,
error: null,
}),
}
})

vi.mock('@/features/organization/api/queries', () => ({
useSportsList: () => ({
data: [],
isLoading: false,
error: null,
}),
useTeamsList: () => ({
data: [],
isLoading: false,
error: null,
}),
}))

const { DashboardPage } = await import('@/app/pages/DashboardPage')
const { dashboardFixtures } = await import('@/mocks/fixtures/dashboard')

describe('DashboardPage', () => {
let container: HTMLDivElement
let root: Root

beforeEach(() => {
vi.clearAllMocks()
document.body.innerHTML = '<div id="root"></div>'
container = document.getElementById('root') as HTMLDivElement
root = createRoot(container)
})

afterEach(async () => {
await act(async () => {
root.unmount()
})
document.body.innerHTML = ''
})

async function render() {
await act(async () => {
root.render(
<MemoryRouter>
<DashboardPage />
</MemoryRouter>,
)
})
}

it('renders the coach team and roster size from the dashboard fixture', async () => {
const dashboard = dashboardFixtures.coach

expect(dashboard.role).toBe('trainer')

await render()

if (dashboard.role === 'trainer') {
expect(container.textContent).toContain(dashboard.team.name)
expect(container.textContent).toContain(`${dashboard.total_members} roster members`)
}
})
})
Loading
Loading