88package org .dspace .app .rest ;
99
1010import static org .hamcrest .Matchers .hasSize ;
11+ import static org .hamcrest .Matchers .is ;
1112import static org .junit .Assert .assertEquals ;
13+ import static org .junit .Assert .assertFalse ;
1214import static org .junit .Assert .assertNotNull ;
1315import static org .junit .Assert .assertTrue ;
1416import static org .springframework .test .web .servlet .request .MockMvcRequestBuilders .get ;
1517import static org .springframework .test .web .servlet .result .MockMvcResultMatchers .jsonPath ;
1618import static org .springframework .test .web .servlet .result .MockMvcResultMatchers .status ;
1719
20+ import java .io .ByteArrayInputStream ;
21+ import java .io .InputStream ;
22+ import java .nio .charset .StandardCharsets ;
23+ import java .util .ArrayList ;
24+ import java .util .HashSet ;
25+ import java .util .List ;
1826import java .util .Map ;
27+ import java .util .Objects ;
1928
2029import org .dspace .app .rest .exception .RepositoryNotFoundException ;
2130import org .dspace .app .rest .model .BitstreamRest ;
2837import org .dspace .app .rest .repository .LinkRestRepository ;
2938import org .dspace .app .rest .test .AbstractControllerIntegrationTest ;
3039import org .dspace .app .rest .utils .Utils ;
40+ import org .dspace .builder .BitstreamBuilder ;
41+ import org .dspace .builder .ClarinLicenseBuilder ;
42+ import org .dspace .builder .ClarinLicenseLabelBuilder ;
3143import org .dspace .builder .ClarinLicenseResourceMappingBuilder ;
3244import org .dspace .builder .ClarinLicenseResourceUserAllowanceBuilder ;
3345import org .dspace .builder .ClarinUserMetadataBuilder ;
3446import org .dspace .builder .ClarinUserRegistrationBuilder ;
47+ import org .dspace .builder .CollectionBuilder ;
48+ import org .dspace .builder .CommunityBuilder ;
3549import org .dspace .builder .EPersonBuilder ;
50+ import org .dspace .builder .ItemBuilder ;
51+ import org .dspace .content .Bitstream ;
52+ import org .dspace .content .Collection ;
53+ import org .dspace .content .Item ;
54+ import org .dspace .content .clarin .ClarinLicense ;
55+ import org .dspace .content .clarin .ClarinLicenseLabel ;
56+ import org .dspace .content .clarin .ClarinLicenseResourceMapping ;
3657import org .dspace .content .clarin .ClarinLicenseResourceUserAllowance ;
3758import org .dspace .content .clarin .ClarinUserRegistration ;
59+ import org .dspace .content .service .clarin .ClarinLicenseLabelService ;
60+ import org .dspace .content .service .clarin .ClarinLicenseResourceMappingService ;
61+ import org .dspace .content .service .clarin .ClarinLicenseService ;
3862import org .dspace .eperson .EPerson ;
3963import org .junit .Test ;
4064import org .springframework .beans .factory .annotation .Autowired ;
4165import org .springframework .context .ApplicationContext ;
66+ import org .springframework .mock .web .MockHttpServletResponse ;
4267
4368/**
4469 * Guards the DSpace 9 link-repository contract for the CLARIN models: the bean naming that decides
@@ -76,14 +101,38 @@ public class ClarinLinkRestRepositoryBeanNameIT extends AbstractControllerIntegr
76101 private static final String ALLOWANCES_URL = "/api/core/clarinlruallowances/" ;
77102 private static final String OTHER_EPERSON_EMAIL = "other-eperson@mail.com" ;
78103
104+ /** An id no fixture can own, used to ask about an entity that does not exist. */
105+ private static final int UNKNOWN_ID = Integer .MAX_VALUE ;
106+
107+ /**
108+ * The CLARIN models that own rels. The cells are read off their {@link LinksRest} annotations rather
109+ * than hardcoded, so a rel added to any of them is covered without touching this test - the same
110+ * reason {@code _sync3/sweeps/rest-matrix.sh} enumerates from the source instead of from a list.
111+ */
112+ private static final List <Class <? extends RestAddressableModel >> CLARIN_MODELS_WITH_RELS = List .of (
113+ ClarinLicenseResourceUserAllowanceRest .class ,
114+ ClarinUserRegistrationRest .class ,
115+ ClarinLicenseResourceMappingRest .class );
116+
79117 @ Autowired
80118 private Utils utils ;
81119
82120 @ Autowired
83121 private ApplicationContext applicationContext ;
84122
123+ @ Autowired
124+ private ClarinLicenseService clarinLicenseService ;
125+
126+ @ Autowired
127+ private ClarinLicenseLabelService clarinLicenseLabelService ;
128+
129+ @ Autowired
130+ private ClarinLicenseResourceMappingService clarinLicenseResourceMappingService ;
131+
85132 private ClarinLicenseResourceUserAllowance allowance ;
86133
134+ private ClarinLicenseResourceMapping publicResourceMapping ;
135+
87136 /**
88137 * The CLARIN builders are not part of the ordered cleanup map, so they are torn down in the order they
89138 * were first used: the user registration would be deleted while the allowance still references it. Drop
@@ -95,6 +144,11 @@ public void destroy() throws Exception {
95144 ClarinLicenseResourceUserAllowanceBuilder .deleteClarinLicenseResourceUserAllowance (allowance .getID ());
96145 allowance = null ;
97146 }
147+ // Same reason: this mapping carries a licence, whose builder would otherwise be torn down first.
148+ if (publicResourceMapping != null ) {
149+ ClarinLicenseResourceMappingBuilder .delete (publicResourceMapping .getID ());
150+ publicResourceMapping = null ;
151+ }
98152 super .destroy ();
99153 }
100154
@@ -350,4 +404,161 @@ public void clruaUserRegistrationAndUserMetadataRelsHonourRoles() throws Excepti
350404 getClient (adminToken ).perform (get (ALLOWANCES_URL + allowance .getID () + "/userRegistration" ))
351405 .andExpect (status ().isOk ());
352406 }
407+ /**
408+ * Enumerates every CLARIN rel cell from the {@link LinksRest} annotations of the models above, the way
409+ * {@code _sync3/sweeps/rest-matrix.sh} does from the source.
410+ *
411+ * @return one {category, typePlural, rel} triple per declared rel
412+ */
413+ private List <String []> clarinRelCells () throws ReflectiveOperationException {
414+ List <String []> cells = new ArrayList <>();
415+ for (Class <? extends RestAddressableModel > modelClass : CLARIN_MODELS_WITH_RELS ) {
416+ RestAddressableModel model = modelClass .getDeclaredConstructor ().newInstance ();
417+ LinksRest linksRest = modelClass .getDeclaredAnnotation (LinksRest .class );
418+ assertNotNull (modelClass .getSimpleName () + " is expected to declare @LinksRest" , linksRest );
419+ for (LinkRest linkRest : linksRest .links ()) {
420+ cells .add (new String [] {model .getCategory (), model .getTypePlural (), linkRest .name ()});
421+ }
422+ }
423+ return cells ;
424+ }
425+
426+ private String parentUrl (String [] cell , Object id ) {
427+ return "/api/" + cell [0 ] + "/" + cell [1 ] + "/" + id ;
428+ }
429+
430+ private int anonymousStatus (String url ) throws Exception {
431+ return getClient ().perform (get (url )).andReturn ().getResponse ().getStatus ();
432+ }
433+
434+ /**
435+ * A rel must answer an anonymous caller exactly as its own parent does, or the status code becomes an
436+ * oracle: {@code ClarinLicenseResourceUserAllowanceService.find} returns null for a missing row before
437+ * {@code authorizeClruaAction} ever runs, so an unguarded link method answered 404 for an unknown id and
438+ * 401 for an existing one, while the parent findOne answers 401 for both. Anonymous callers could
439+ * therefore probe which allowance ids exist.
440+ * <P>
441+ * The cells come from the models' own annotations, so this covers all six CLARIN rels rather than the two
442+ * that leaked, and picks up any rel added later.
443+ */
444+ @ Test
445+ public void anonymousRelStatusMatchesParentForUnknownId () throws Exception {
446+ List <String []> cells = clarinRelCells ();
447+ assertEquals ("Expected the six CLARIN rel cells; the matrix changed shape: " + cells .size (),
448+ 6 , cells .size ());
449+
450+ for (String [] cell : cells ) {
451+ String parent = parentUrl (cell , UNKNOWN_ID );
452+ String rel = parent + "/" + cell [2 ];
453+ assertEquals ("Anonymous " + rel + " must answer the same status as its parent " + parent ,
454+ anonymousStatus (parent ), anonymousStatus (rel ));
455+ }
456+ }
457+
458+ /**
459+ * The other half of the same leak, seen from the entity rather than from the parent: for an anonymous
460+ * caller the answer must not depend on whether the allowance exists.
461+ */
462+ @ Test
463+ public void anonymousClruaRelsRevealNothingAboutExistence () throws Exception {
464+ ClarinLicenseResourceUserAllowance existing = allowanceOwnedByEPerson ();
465+
466+ for (String rel : new String [] {ClarinLicenseResourceUserAllowanceRest .RESOURCE_MAPPING ,
467+ ClarinLicenseResourceUserAllowanceRest .USER_REGISTRATION ,
468+ ClarinLicenseResourceUserAllowanceRest .USER_METADATA }) {
469+ int onExisting = anonymousStatus (ALLOWANCES_URL + existing .getID () + "/" + rel );
470+ int onUnknown = anonymousStatus (ALLOWANCES_URL + UNKNOWN_ID + "/" + rel );
471+ assertEquals ("The anonymous status of the " + rel + " rel tells the caller whether the allowance"
472+ + " exists (existing id vs unknown id)" , onExisting , onUnknown );
473+ }
474+ }
475+
476+ /**
477+ * The not-found message of the userMetadata rel said "for if:" instead of "for id:" on both dtq-dev and
478+ * the v9 base. An authenticated caller is past the guard, so this is the caller who can still see it.
479+ */
480+ @ Test
481+ public void clruaUserMetadataNotFoundMessageUsesId () throws Exception {
482+ String epersonToken = getAuthToken (eperson .getEmail (), password );
483+ MockHttpServletResponse response = getClient (epersonToken )
484+ .perform (get (ALLOWANCES_URL + UNKNOWN_ID + "/"
485+ + ClarinLicenseResourceUserAllowanceRest .USER_METADATA ))
486+ .andExpect (status ().isNotFound ())
487+ .andReturn ().getResponse ();
488+ // MockMvc renders no error page, so the text of a sendError() lands in the error message rather
489+ // than in the body; read both so the assertion holds however the advice reports it.
490+ String message = Objects .toString (response .getErrorMessage (), "" ) + response .getContentAsString ();
491+
492+ assertTrue ("The not-found message should read \" for id: \" , but was: " + message ,
493+ message .contains ("for id: " ));
494+ assertFalse ("The \" for if: \" typo is back: " + message , message .contains ("for if: " ));
495+ }
496+
497+ /**
498+ * Guard against over-fixing. {@code ClarinResourceMappingCLicenseLinkRepository} must stay unguarded:
499+ * its parent {@code ClarinLicenseResourceMappingRestRepository.findOne} is {@code permitAll()} and the
500+ * Angular licence agreement page follows this rel anonymously, so adding {@code @PreAuthorize} here in a
501+ * later "security cleanup" would break the anonymous download flow.
502+ */
503+ @ Test
504+ public void anonymousResourceMappingClarinLicenseRelStaysPublic () throws Exception {
505+ ClarinLicenseResourceMapping mapping = resourceMappingWithLicence ();
506+
507+ getClient ().perform (get ("/api/" + ClarinLicenseResourceMappingRest .CATEGORY + "/"
508+ + ClarinLicenseResourceMappingRest .PLURAL_NAME + "/" + mapping .getID () + "/"
509+ + ClarinLicenseResourceMappingRest .CLARIN_LICENSE ))
510+ .andExpect (status ().isOk ())
511+ .andExpect (jsonPath ("$.id" , is (mapping .getLicense ().getID ())));
512+ }
513+
514+ /**
515+ * Builds a bitstream with a CLARIN licence attached, i.e. the resource mapping the licence agreement page
516+ * reads anonymously.
517+ *
518+ * @return the resource mapping created by attaching the licence
519+ */
520+ private ClarinLicenseResourceMapping resourceMappingWithLicence () throws Exception {
521+ context .turnOffAuthorisationSystem ();
522+ parentCommunity = CommunityBuilder .createCommunity (context )
523+ .withName ("Parent Community" )
524+ .build ();
525+ Collection collection = CollectionBuilder .createCollection (context , parentCommunity )
526+ .withName ("Collection 1" )
527+ .build ();
528+ Item item = ItemBuilder .createItem (context , collection )
529+ .withTitle ("Item with a licensed bitstream" )
530+ .withIssueDate ("2026-09-10" )
531+ .build ();
532+ Bitstream bitstream ;
533+ try (InputStream is = new ByteArrayInputStream ("public" .getBytes (StandardCharsets .UTF_8 ))) {
534+ bitstream = BitstreamBuilder .createBitstream (context , item , is )
535+ .withName ("public.txt" )
536+ .withMimeType ("text/plain" )
537+ .build ();
538+ }
539+
540+ ClarinLicenseLabel label = ClarinLicenseLabelBuilder .createClarinLicenseLabel (context ).build ();
541+ label .setLabel ("PUB" );
542+ label .setTitle ("Public rel label" );
543+ label .setExtended (false );
544+ clarinLicenseLabelService .update (context , label );
545+
546+ ClarinLicense licence = ClarinLicenseBuilder .createClarinLicense (context ).build ();
547+ licence .setName ("Public rel licence" );
548+ licence .setDefinition ("http://example.com/licence" );
549+ licence .setRequiredInfo ("NAME" );
550+ licence .setConfirmation (ClarinLicense .Confirmation .NOT_REQUIRED );
551+ HashSet <ClarinLicenseLabel > labels = new HashSet <>();
552+ labels .add (label );
553+ licence .setLicenseLabels (labels );
554+ clarinLicenseService .update (context , licence );
555+
556+ clarinLicenseResourceMappingService .attachLicense (context , licence , bitstream );
557+ List <ClarinLicenseResourceMapping > mappings =
558+ clarinLicenseResourceMappingService .findByBitstreamUUID (context , bitstream .getID ());
559+ assertEquals ("The licence fixture did not attach exactly one resource mapping" , 1 , mappings .size ());
560+ publicResourceMapping = mappings .get (0 );
561+ context .restoreAuthSystemState ();
562+ return publicResourceMapping ;
563+ }
353564}
0 commit comments