Skip to content

Commit 98fc3df

Browse files
authored
Merge pull request #652 from bettercodepaul/#2236
#2236
2 parents 22fdd63 + e33e71c commit 98fc3df

6 files changed

Lines changed: 182 additions & 1 deletion

File tree

bogenliga/bogenliga-application/src/main/java/de/bogenliga/application/services/v1/match/service/MatchService.java

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,14 @@
3333
import de.bogenliga.application.business.passe.api.PasseComponent;
3434
import de.bogenliga.application.business.passe.api.types.PasseDO;
3535
import de.bogenliga.application.business.passe.impl.business.PasseComponentImpl;
36+
import de.bogenliga.application.business.schusszettel.api.TabletSchusszettelComponent;
3637
import de.bogenliga.application.business.vereine.api.VereinComponent;
3738
import de.bogenliga.application.business.vereine.api.types.VereinDO;
3839
import de.bogenliga.application.business.wettkampf.api.WettkampfComponent;
3940
import de.bogenliga.application.business.wettkampftyp.api.WettkampfTypComponent;
4041
import de.bogenliga.application.business.wettkampftyp.api.types.WettkampfTypDO;
42+
import de.bogenliga.application.common.errorhandling.ErrorCode;
43+
import de.bogenliga.application.common.errorhandling.exception.BusinessException;
4144
import de.bogenliga.application.common.service.ServiceFacade;
4245
import de.bogenliga.application.common.service.UserProvider;
4346
import de.bogenliga.application.common.service.types.DataTransferObject;
@@ -120,6 +123,7 @@ public static Map<String, String> getPasseConditionErrors()
120123
private final VereinComponent vereinComponent;
121124
private final RequiresOnePermissionAspect requiresOnePermissionAspect;
122125
private final VeranstaltungComponent veranstaltungComponent;
126+
private final TabletSchusszettelComponent tabletSchusszettelComponent;
123127

124128
/**
125129
* Constructor with dependency injection
@@ -136,6 +140,7 @@ public MatchService(final MatchComponent matchComponent,
136140
final MannschaftsmitgliedComponent mannschaftsmitgliedComponent,
137141
final WettkampfTypComponent wettkampftypComponent,
138142
final VeranstaltungComponent veranstaltungComponent,
143+
final TabletSchusszettelComponent tabletSchusszettelComponent,
139144
RequiresOnePermissionAspect requiresOnePermissionAspect) {
140145
this.matchComponent = matchComponent;
141146
this.passeComponent = passeComponent;
@@ -146,6 +151,7 @@ public MatchService(final MatchComponent matchComponent,
146151
this.wettkampfTypComponent = wettkampftypComponent;
147152
this.requiresOnePermissionAspect = requiresOnePermissionAspect;
148153
this.veranstaltungComponent = veranstaltungComponent;
154+
this.tabletSchusszettelComponent = tabletSchusszettelComponent;
149155
}
150156

151157
/**
@@ -257,6 +263,55 @@ public List<MatchDTO> findMatchesByIds(@PathVariable("matchId1") Long matchId1,
257263
return matches;
258264
}
259265

266+
/**
267+
* Read-only variant of {@link #findMatchesByIds} for anonymous QR-scanning tablet clients.
268+
* <p>
269+
* Instead of a permission check, this validates the same session token used by the
270+
* public tablet-schusszettel endpoint, and additionally verifies that the requested
271+
* matches actually belong to the token's wettkampf, so a token cannot be used to
272+
* browse matches of other competitions.
273+
*
274+
* @param matchId1 das erste Match auf dem Schusszettel
275+
* @param matchId2 das zweite Match auf dem Schusszettel
276+
* @param token Zugriffs-Token der Tablet-Session
277+
* @param wettkampfid Wettkampf-ID der Tablet-Session
278+
* @param teamid Team-ID der Tablet-Session
279+
*
280+
* @return MatchDTOs zu den IDs (2 Stück)
281+
*/
282+
@GetMapping(value = "schusszettel/tablet/{matchId1}/{matchId2}",
283+
produces = MediaType.APPLICATION_JSON_VALUE)
284+
public List<MatchDTO> findMatchesByIdsForTablet(@PathVariable("matchId1") Long matchId1,
285+
@PathVariable("matchId2") Long matchId2,
286+
@RequestParam String token,
287+
@RequestParam Long wettkampfid,
288+
@RequestParam Long teamid) {
289+
if (!tabletSchusszettelComponent.isValidToken(wettkampfid, teamid, token)) {
290+
throw new BusinessException(ErrorCode.NO_PERMISSION_ERROR, "Invalid or expired token");
291+
}
292+
293+
this.checkMatchId(matchId1);
294+
this.checkMatchId(matchId2);
295+
296+
MatchDTO matchDTO1 = getMatchFromId(matchId1, true);
297+
MatchDTO matchDTO2 = getMatchFromId(matchId2, true);
298+
299+
if (!matchDTO1.getWettkampfId().equals(wettkampfid) || !matchDTO2.getWettkampfId().equals(wettkampfid)) {
300+
throw new BusinessException(ErrorCode.NO_PERMISSION_ERROR, "Matches do not belong to the given Wettkampf");
301+
}
302+
303+
checkPreconditions(matchDTO1, matchConditionErrors);
304+
checkPreconditions(matchDTO2, matchConditionErrors);
305+
306+
List<MatchDTO> matches = new ArrayList<>();
307+
matches.add(matchDTO1);
308+
matches.add(matchDTO2);
309+
this.log(matchDTO1, SERVICE_FIND_MATCHES_BY_IDS);
310+
this.log(matchDTO2, SERVICE_FIND_MATCHES_BY_IDS);
311+
312+
return matches;
313+
}
314+
260315

261316
/**
262317
* I return the match entries of the database with the given mannschaftId.

bogenliga/bogenliga-application/src/main/java/de/bogenliga/application/springconfiguration/security/WebSecurityConfiguration.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,12 @@ public void configure(WebSecurity web) {
7171
.antMatchers(HttpMethod.POST, "/v1/user/signin")
7272
// Kampfrichter-Session read endpoints: protected by QR token, no JWT needed
7373
.antMatchers(HttpMethod.GET, "/v1/kampfrichter-session/matches")
74-
.antMatchers(HttpMethod.PUT, "/v1/kampfrichter-session/strafpunkte");
74+
.antMatchers(HttpMethod.PUT, "/v1/kampfrichter-session/strafpunkte")
75+
// Tablet-Schusszettel endpoints: protected by QR token, no JWT needed
76+
.antMatchers(HttpMethod.GET, "/v1/tablet-schusszettel")
77+
.antMatchers(HttpMethod.POST, "/v1/tablet-schusszettel")
78+
// Read-only match details for the tablet flow: protected by the same QR token
79+
.antMatchers(HttpMethod.GET, "/v1/match/schusszettel/tablet/*/*");
7580
}
7681

7782

bogenliga/bogenliga-application/src/test/java/de/bogenliga/application/services/v1/match/service/MatchServiceTest.java

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
import javax.naming.NoPermissionException;
88

99
import de.bogenliga.application.business.ligamatch.impl.entity.LigamatchBE;
10+
import de.bogenliga.application.business.schusszettel.api.TabletSchusszettelComponent;
1011
import de.bogenliga.application.business.veranstaltung.api.VeranstaltungComponent;
12+
import de.bogenliga.application.common.errorhandling.exception.BusinessException;
1113
import org.junit.Before;
1214
import org.junit.Rule;
1315
import org.junit.Test;
@@ -81,6 +83,9 @@ public class MatchServiceTest {
8183
@Mock
8284
private MannschaftsmitgliedComponent mannschaftsmitgliedComponent;
8385

86+
@Mock
87+
private TabletSchusszettelComponent tabletSchusszettelComponent;
88+
8489

8590
@InjectMocks
8691
private MatchService underTest;
@@ -387,6 +392,64 @@ public void findMatchesByIds_Null() {
387392
}
388393

389394

395+
@Test
396+
public void findMatchesByIdsForTablet_validToken_returnsMatches() {
397+
MatchDO matchDO1 = getMatchDO();
398+
DsbMannschaftDO mannschaftDO = getMannschaftDO(M_ID);
399+
WettkampfTypDO wettkampftypDO = getWettkampfTypDO(W_TYP_ID);
400+
WettkampfDO wettkampfDO = getWettkampfDO(W_ID);
401+
VereinDO vereinDO = getVereinDO(VEREIN_ID);
402+
LigamatchBE ligamatchBE = getLigamatchBE();
403+
when(tabletSchusszettelComponent.isValidToken(MATCH_WETTKAMPF_ID, MATCH_MANNSCHAFT_ID, "valid-token"))
404+
.thenReturn(true);
405+
when(matchComponent.getLigamatchById(anyLong())).thenReturn(ligamatchBE);
406+
when(matchComponent.findById(anyLong())).thenReturn(matchDO1);
407+
when(vereinComponent.findById(anyLong())).thenReturn(vereinDO);
408+
when(mannschaftComponent.findById(anyLong())).thenReturn(mannschaftDO);
409+
when(wettkampfComponent.findById(MATCH_WETTKAMPF_ID)).thenReturn(wettkampfDO);
410+
when(wettkampfTypComponent.findById(W_TYP_ID)).thenReturn(wettkampftypDO);
411+
412+
final List<MatchDTO> actual = underTest.findMatchesByIdsForTablet(
413+
MATCH_ID, MATCH_ID, "valid-token", MATCH_WETTKAMPF_ID, MATCH_MANNSCHAFT_ID);
414+
415+
assertThat(actual).isNotNull().isNotEmpty().hasSize(2);
416+
}
417+
418+
419+
@Test
420+
public void findMatchesByIdsForTablet_invalidToken_throwsBusinessException() {
421+
when(tabletSchusszettelComponent.isValidToken(anyLong(), anyLong(), any()))
422+
.thenReturn(false);
423+
424+
assertThatExceptionOfType(BusinessException.class)
425+
.isThrownBy(() -> underTest.findMatchesByIdsForTablet(
426+
MATCH_ID, MATCH_ID, "invalid-token", MATCH_WETTKAMPF_ID, MATCH_MANNSCHAFT_ID));
427+
428+
verify(matchComponent, never()).findById(anyLong());
429+
}
430+
431+
432+
@Test
433+
public void findMatchesByIdsForTablet_wettkampfMismatch_throwsBusinessException() {
434+
MatchDO matchDO1 = getMatchDO();
435+
DsbMannschaftDO mannschaftDO = getMannschaftDO(M_ID);
436+
VereinDO vereinDO = getVereinDO(VEREIN_ID);
437+
LigamatchBE ligamatchBE = getLigamatchBE();
438+
final Long otherWettkampfId = MATCH_WETTKAMPF_ID + 1;
439+
when(tabletSchusszettelComponent.isValidToken(otherWettkampfId, MATCH_MANNSCHAFT_ID, "valid-token"))
440+
.thenReturn(true);
441+
when(matchComponent.getLigamatchById(anyLong())).thenReturn(ligamatchBE);
442+
when(matchComponent.findById(anyLong())).thenReturn(matchDO1);
443+
when(vereinComponent.findById(anyLong())).thenReturn(vereinDO);
444+
when(mannschaftComponent.findById(anyLong())).thenReturn(mannschaftDO);
445+
446+
// matchDO1 belongs to MATCH_WETTKAMPF_ID, but we pass a different wettkampfid -> must be rejected
447+
assertThatExceptionOfType(BusinessException.class)
448+
.isThrownBy(() -> underTest.findMatchesByIdsForTablet(
449+
MATCH_ID, MATCH_ID, "valid-token", otherWettkampfId, MATCH_MANNSCHAFT_ID));
450+
}
451+
452+
390453
@Test
391454
public void findByMannschaftId() {
392455
final MatchDO matchDO = getMatchDO();

bogenliga/bogenliga-business-logic/src/main/java/de/bogenliga/application/business/schusszettel/api/TabletSchusszettelComponent.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,4 +72,18 @@ public interface TabletSchusszettelComponent {
7272
* @throws BusinessException if validation fails or invalid state
7373
*/
7474
void submitSchuetzen(long wettkampfid, long teamid, String token, SchuetzenMeldungDO doObj);
75+
76+
/**
77+
* Checks whether the given token is a valid, currently active session token
78+
* for the given wettkampf/team combination.
79+
*
80+
* Used to grant read-only access to other endpoints (e.g. match details)
81+
* to anonymous QR-scanning clients, without requiring a login.
82+
*
83+
* @param wettkampfid Competition identifier
84+
* @param teamid Team identifier
85+
* @param token Access token to validate
86+
* @return true if the token belongs to an existing session for this wettkampf/team
87+
*/
88+
boolean isValidToken(long wettkampfid, long teamid, String token);
7589
}

bogenliga/bogenliga-business-logic/src/main/java/de/bogenliga/application/business/schusszettel/impl/business/TabletSchusszettelComponentImpl.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,14 @@ private SessionRuntime validateTokenAndLoadSession(long wettkampfId, long teamId
242242
return SessionRuntime.loadFromDatabase(wettkampfId, teamId, token, sessionDAO, matchComponent, passeComponent, matchAnalysisService, mmComponent, mitgliedComponent, wettkampfComponent, veranstaltungComponent);
243243
}
244244

245+
@Override
246+
public boolean isValidToken(long wettkampfid, long teamid, String token) {
247+
if (token == null || token.isEmpty()) {
248+
return false;
249+
}
250+
return sessionDAO.findByTokenWettkampfUndTeam(wettkampfid, teamid, token).isPresent();
251+
}
252+
245253
/**
246254
* Builds base response with core match data.
247255
*

bogenliga/bogenliga-business-logic/src/test/java/de/bogenliga/application/business/schusszettel/impl/business/TabletSchusszettelComponentImplTest.java

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -659,4 +659,40 @@ public void getStatus_matchNumberConversionSucceeds_convertsLongToInt() {
659659
assertThat(result).isNotNull();
660660
assertThat(result.getEigenesTeamMatchNr()).isEqualTo(42);
661661
}
662+
663+
@Test
664+
public void isValidToken_validSession_returnsTrue() {
665+
when(mockDAO.findByTokenWettkampfUndTeam(50L, 100L, "test-token-123456789012345"))
666+
.thenReturn(Optional.of(testEntity));
667+
668+
boolean result = component.isValidToken(50L, 100L, "test-token-123456789012345");
669+
670+
assertThat(result).isTrue();
671+
}
672+
673+
@Test
674+
public void isValidToken_noMatchingSession_returnsFalse() {
675+
when(mockDAO.findByTokenWettkampfUndTeam(50L, 100L, "wrong-token"))
676+
.thenReturn(Optional.empty());
677+
678+
boolean result = component.isValidToken(50L, 100L, "wrong-token");
679+
680+
assertThat(result).isFalse();
681+
}
682+
683+
@Test
684+
public void isValidToken_nullToken_returnsFalseWithoutCallingDao() {
685+
boolean result = component.isValidToken(50L, 100L, null);
686+
687+
assertThat(result).isFalse();
688+
verify(mockDAO, never()).findByTokenWettkampfUndTeam(anyLong(), anyLong(), any());
689+
}
690+
691+
@Test
692+
public void isValidToken_emptyToken_returnsFalseWithoutCallingDao() {
693+
boolean result = component.isValidToken(50L, 100L, "");
694+
695+
assertThat(result).isFalse();
696+
verify(mockDAO, never()).findByTokenWettkampfUndTeam(anyLong(), anyLong(), any());
697+
}
662698
}

0 commit comments

Comments
 (0)