diff --git a/src/main/java/org/broadinstitute/consent/http/resources/PassportResource.java b/src/main/java/org/broadinstitute/consent/http/resources/PassportResource.java index c6dbc798e7..8b7d215fdb 100644 --- a/src/main/java/org/broadinstitute/consent/http/resources/PassportResource.java +++ b/src/main/java/org/broadinstitute/consent/http/resources/PassportResource.java @@ -5,6 +5,7 @@ import jakarta.annotation.security.RolesAllowed; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; @@ -34,4 +35,27 @@ public Response getPassport(@Auth DuosUser duosUser) { return createExceptionResponse(e); } } + + /** + * Returns a Data Passport for a given dataset identifier, as proposed in the GA4GH Data Passports + * specification. The response uses the same {@code ga4gh_passport_v1} envelope as a Researcher + * Passport but contains dataset-centric visas: {@code ConsentedDataUseTerms}, {@code + * OversightBodies}, and {@code RequiredAgreements} (when a DAA exists). + * + * @param datasetIdentifier the formatted DUOS identifier, e.g. {@code DUOS-000001} + */ + @GET + @Produces(MediaType.APPLICATION_JSON) + @RolesAllowed({ADMIN}) + @Path("dataset/{datasetIdentifier}") + public Response getDataPassport( + @SuppressWarnings("unused") @Auth DuosUser duosUser, + @PathParam("datasetIdentifier") String datasetIdentifier) { + try { + PassportClaim passport = passportService.generateDataPassport(datasetIdentifier); + return Response.ok().entity(passport).build(); + } catch (Exception e) { + return createExceptionResponse(e); + } + } } diff --git a/src/main/java/org/broadinstitute/consent/http/service/passport/AffiliationAndRole.java b/src/main/java/org/broadinstitute/consent/http/service/passport/AffiliationAndRole.java index 05b205a82e..74cb5e2cb5 100644 --- a/src/main/java/org/broadinstitute/consent/http/service/passport/AffiliationAndRole.java +++ b/src/main/java/org/broadinstitute/consent/http/service/passport/AffiliationAndRole.java @@ -28,7 +28,11 @@ public Long asserted() { Optional.ofNullable(user.getLibraryCard()) .map(LibraryCard::getCreateDate) .orElse(user.getCreateDate()); - return PassportService.getEpochSeconds(assertedDate.toInstant()); + if (assertedDate == null) { + return PassportService.getEpochSeconds(java.time.Instant.now()); + } + // java.sql.Date#toInstant throws UnsupportedOperationException; use epoch millis instead. + return PassportService.getEpochSeconds(java.time.Instant.ofEpochMilli(assertedDate.getTime())); } @Override diff --git a/src/main/java/org/broadinstitute/consent/http/service/passport/ConsentedDataUseTermsVisa.java b/src/main/java/org/broadinstitute/consent/http/service/passport/ConsentedDataUseTermsVisa.java new file mode 100644 index 0000000000..92d8018290 --- /dev/null +++ b/src/main/java/org/broadinstitute/consent/http/service/passport/ConsentedDataUseTermsVisa.java @@ -0,0 +1,55 @@ +package org.broadinstitute.consent.http.service.passport; + +import java.time.Instant; +import org.broadinstitute.consent.http.models.Dataset; + +/** + * Data Passport visa encoding the permitted uses of a dataset based on participant consent, + * expressed as a link to the dataset's data use terms. Leverages the Data Use Ontology (DUO) for + * standardization. + * + * @see GA4GH Data Passports + * specification + */ +public class ConsentedDataUseTermsVisa implements VisaClaimType { + + private final Dataset dataset; + + public ConsentedDataUseTermsVisa(Dataset dataset) { + this.dataset = dataset; + } + + @Override + public String type() { + return VisaClaimTypes.CONSENTED_DATA_USE_TERMS.type; + } + + @Override + public Long asserted() { + if (dataset.getCreateDate() != null) { + // java.sql.Date#toInstant throws UnsupportedOperationException; use epoch millis instead. + return PassportService.getEpochSeconds( + Instant.ofEpochMilli(dataset.getCreateDate().getTime())); + } + return PassportService.getEpochSeconds(Instant.now()); + } + + /** + * Returns a stable URL pointing to the dataset identifier that describes the consented terms + * dereference this URL to retrieve the full DUO-coded data use object for the dataset. + */ + @Override + public String value() { + return "%s/dataset/%s".formatted(PassportService.ISS, dataset.getDatasetIdentifier()); + } + + @Override + public String source() { + return PassportService.ISS; + } + + @Override + public String by() { + return VisaBy.DAC.name().toLowerCase(); + } +} diff --git a/src/main/java/org/broadinstitute/consent/http/service/passport/ControlledAccessGrants.java b/src/main/java/org/broadinstitute/consent/http/service/passport/ControlledAccessGrants.java index 11afe011fa..f006b596b1 100644 --- a/src/main/java/org/broadinstitute/consent/http/service/passport/ControlledAccessGrants.java +++ b/src/main/java/org/broadinstitute/consent/http/service/passport/ControlledAccessGrants.java @@ -1,7 +1,7 @@ package org.broadinstitute.consent.http.service.passport; +import java.time.Instant; import java.util.Calendar; -import java.util.Date; import org.broadinstitute.consent.http.models.ApprovedDataset; /** @@ -32,8 +32,8 @@ public Long asserted() { calendar.set(Calendar.YEAR, calendar.get(Calendar.YEAR) - 1); return PassportService.getEpochSeconds(calendar.toInstant()); } - // If there is no expiration date, we will use the current time as the asserted time. - return PassportService.getEpochSeconds(new Date().toInstant()); + // If there is no expiration date, use the current time as the asserted time. + return PassportService.getEpochSeconds(Instant.now()); } @Override diff --git a/src/main/java/org/broadinstitute/consent/http/service/passport/OversightBodiesVisa.java b/src/main/java/org/broadinstitute/consent/http/service/passport/OversightBodiesVisa.java new file mode 100644 index 0000000000..a8c68d24f8 --- /dev/null +++ b/src/main/java/org/broadinstitute/consent/http/service/passport/OversightBodiesVisa.java @@ -0,0 +1,54 @@ +package org.broadinstitute.consent.http.service.passport; + +import java.time.Instant; +import org.broadinstitute.consent.http.models.Dac; + +/** + * Data Passport visa describing the entity responsible for governing access to the dataset. Maps to + * the DAC (Data Access Committee) that oversees the dataset in DUOS. + * + * @see GA4GH Data Passports + * specification + */ +public class OversightBodiesVisa implements VisaClaimType { + + private final Dac dac; + + public OversightBodiesVisa(Dac dac) { + this.dac = dac; + } + + @Override + public String type() { + return VisaClaimTypes.OVERSIGHT_BODIES.type; + } + + @Override + public Long asserted() { + if (dac.getCreateDate() != null) { + // java.sql.Date#toInstant throws UnsupportedOperationException; use epoch millis instead. + return PassportService.getEpochSeconds(Instant.ofEpochMilli(dac.getCreateDate().getTime())); + } + return PassportService.getEpochSeconds(Instant.now()); + } + + /** + * Returns a stable URL identifying the DAC within DUOS. Consumers can dereference this URL to + * retrieve details about the oversight body, including its members and chairpersons. TODO: We + * need a public and stable identifier to point users to for DACs. + */ + @Override + public String value() { + return "%s/dac/%d".formatted(PassportService.ISS, dac.getDacId()); + } + + @Override + public String source() { + return PassportService.ISS; + } + + @Override + public String by() { + return VisaBy.DAC.name().toLowerCase(); + } +} diff --git a/src/main/java/org/broadinstitute/consent/http/service/passport/PassportService.java b/src/main/java/org/broadinstitute/consent/http/service/passport/PassportService.java index 6763b3605a..6499c80a8e 100644 --- a/src/main/java/org/broadinstitute/consent/http/service/passport/PassportService.java +++ b/src/main/java/org/broadinstitute/consent/http/service/passport/PassportService.java @@ -3,6 +3,7 @@ import com.google.inject.Inject; import jakarta.ws.rs.NotFoundException; import java.time.Instant; +import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; @@ -11,9 +12,12 @@ import java.util.stream.Stream; import org.broadinstitute.consent.http.db.DatasetDAO; import org.broadinstitute.consent.http.models.ApprovedDataset; +import org.broadinstitute.consent.http.models.Dac; +import org.broadinstitute.consent.http.models.Dataset; import org.broadinstitute.consent.http.models.DuosUser; import org.broadinstitute.consent.http.models.User; import org.broadinstitute.consent.http.models.sam.UserStatusInfo; +import org.broadinstitute.consent.http.service.DacService; import org.broadinstitute.consent.http.util.ConsentLogger; /** GA4GH Passport */ @@ -23,10 +27,12 @@ public class PassportService implements ConsentLogger { public static final int EXPIRATION_SECONDS = 3600; private final DatasetDAO datasetDAO; + private final DacService dacService; @Inject - public PassportService(DatasetDAO datasetDAO) { + public PassportService(DatasetDAO datasetDAO, DacService dacService) { this.datasetDAO = datasetDAO; + this.dacService = dacService; } public PassportClaim generatePassport(DuosUser duosUser) { @@ -57,6 +63,65 @@ public PassportClaim generatePassport(DuosUser duosUser) { return new PassportClaim(allVisas); } + /** + * Generates a Data Passport for a specific dataset, as proposed in the GA4GH Data Passports + * specification (see https://papers.ssrn.com/sol3/papers.cfm?abstract_id=5372874). The returned + * {@link PassportClaim} uses the same envelope as a Researcher Passport but contains + * dataset-centric visas: + * + * + * + *

The {@code sub} field of each visa is the dataset identifier (e.g. {@code DUOS-000001}) + * rather than a user subject ID, reflecting the dataset-centric nature of the passport. + * + * @param datasetIdentifier the formatted DUOS identifier, e.g. {@code DUOS-000001} + * @return a {@link PassportClaim} containing the Data Passport visas for the dataset + * @throws NotFoundException if the dataset does not exist + */ + public PassportClaim generateDataPassport(String datasetIdentifier) { + Integer alias = Dataset.parseIdentifierToAlias(datasetIdentifier); + Dataset dataset = datasetDAO.findDatasetByAlias(alias); + if (dataset == null) { + throw new NotFoundException("Dataset not found: " + datasetIdentifier); + } + + List visas = new ArrayList<>(); + + // ConsentedDataUseTerms — always present if the dataset exists + visas.add(visaFromVisaClaimType(datasetIdentifier, new ConsentedDataUseTermsVisa(dataset))); + + // OversightBodies + RequiredAgreements — only when the dataset is associated with a DAC + if (dataset.getDacId() != null) { + try { + Dac dac = dacService.findById(dataset.getDacId()); + addDacBackedVisas(datasetIdentifier, visas, dac); + } catch (UnsupportedOperationException e) { + logWarn( + "Unable to build DAC-backed visas for dataset %s; returning consented-data-use visa only" + .formatted(datasetIdentifier), + e); + } + } + + return new PassportClaim(visas); + } + + private void addDacBackedVisas(String datasetIdentifier, List visas, Dac dac) { + if (dac == null) { + return; + } + visas.add(visaFromVisaClaimType(datasetIdentifier, new OversightBodiesVisa(dac))); + if (dac.getAssociatedDaa() != null) { + visas.add( + visaFromVisaClaimType( + datasetIdentifier, new RequiredAgreementsVisa(dac.getAssociatedDaa()))); + } + } + protected List buildControlledAccessGrants( String userSubjectId, List approvedDatasets) { return approvedDatasets.stream() diff --git a/src/main/java/org/broadinstitute/consent/http/service/passport/RequiredAgreementsVisa.java b/src/main/java/org/broadinstitute/consent/http/service/passport/RequiredAgreementsVisa.java new file mode 100644 index 0000000000..d10379d2a2 --- /dev/null +++ b/src/main/java/org/broadinstitute/consent/http/service/passport/RequiredAgreementsVisa.java @@ -0,0 +1,53 @@ +package org.broadinstitute.consent.http.service.passport; + +import java.time.Instant; +import org.broadinstitute.consent.http.models.DataAccessAgreement; + +/** + * Data Passport visa listing the Data Access Agreement (DAA) that users must accept in order to + * access the dataset. References the DAA document managed in DUOS by the DAC. + * + * @see GA4GH Data Passports + * specification + */ +public class RequiredAgreementsVisa implements VisaClaimType { + + private final DataAccessAgreement daa; + + public RequiredAgreementsVisa(DataAccessAgreement daa) { + this.daa = daa; + } + + @Override + public String type() { + return VisaClaimTypes.REQUIRED_AGREEMENTS.type; + } + + @Override + public Long asserted() { + if (daa.getCreateDate() != null) { + return PassportService.getEpochSeconds(daa.getCreateDate()); + } + return PassportService.getEpochSeconds(Instant.now()); + } + + /** + * Returns a stable URL pointing to the DAA within DUOS. Consumers can use this to retrieve the + * full agreement document and verify that a researcher's Library Card includes acceptance of this + * agreement before granting access. + */ + @Override + public String value() { + return "%s/daa/%d".formatted(PassportService.ISS, daa.getDaaId()); + } + + @Override + public String source() { + return PassportService.ISS; + } + + @Override + public String by() { + return VisaBy.SO.name().toLowerCase(); + } +} diff --git a/src/main/java/org/broadinstitute/consent/http/service/passport/ResearcherStatus.java b/src/main/java/org/broadinstitute/consent/http/service/passport/ResearcherStatus.java index 8e3aee686b..8f3f23d042 100644 --- a/src/main/java/org/broadinstitute/consent/http/service/passport/ResearcherStatus.java +++ b/src/main/java/org/broadinstitute/consent/http/service/passport/ResearcherStatus.java @@ -27,7 +27,11 @@ public Long asserted() { Optional.ofNullable(user.getLibraryCard()) .map(LibraryCard::getCreateDate) .orElse(user.getCreateDate()); - return PassportService.getEpochSeconds(assertedDate.toInstant()); + if (assertedDate == null) { + return PassportService.getEpochSeconds(java.time.Instant.now()); + } + // java.sql.Date#toInstant throws UnsupportedOperationException; use epoch millis instead. + return PassportService.getEpochSeconds(java.time.Instant.ofEpochMilli(assertedDate.getTime())); } @Override diff --git a/src/main/java/org/broadinstitute/consent/http/service/passport/VisaClaimTypes.java b/src/main/java/org/broadinstitute/consent/http/service/passport/VisaClaimTypes.java index 25723b08c5..2e4be23ebf 100644 --- a/src/main/java/org/broadinstitute/consent/http/service/passport/VisaClaimTypes.java +++ b/src/main/java/org/broadinstitute/consent/http/service/passport/VisaClaimTypes.java @@ -1,9 +1,16 @@ package org.broadinstitute.consent.http.service.passport; public enum VisaClaimTypes { + // GA4GH Researcher Passport visa types AFFILIATION_AND_ROLE("AffiliationAndRole"), CONTROLLED_ACCESS_GRANTS("ControlledAccessGrants"), - RESEARCHER_STATUS("ResearcherStatus"); + RESEARCHER_STATUS("ResearcherStatus"), + + // GA4GH Data Passport visa types (see + // https://papers.ssrn.com/sol3/papers.cfm?abstract_id=5372874) + CONSENTED_DATA_USE_TERMS("ConsentedDataUseTerms"), + OVERSIGHT_BODIES("OversightBodies"), + REQUIRED_AGREEMENTS("RequiredAgreements"); public final String type; diff --git a/src/main/resources/assets/api-docs.yaml b/src/main/resources/assets/api-docs.yaml index ffc69ba62a..8414d8587a 100644 --- a/src/main/resources/assets/api-docs.yaml +++ b/src/main/resources/assets/api-docs.yaml @@ -715,6 +715,8 @@ paths: $ref: './paths/darSummariesByDatasetId.yaml' /api/passport/userinfo: $ref: './paths/passportUserInfo.yaml' + /api/passport/dataset/{datasetIdentifier}: + $ref: './paths/passportDataset.yaml' /api/user: $ref: './paths/user.yaml' /api/user/me: diff --git a/src/main/resources/assets/paths/passportDataset.yaml b/src/main/resources/assets/paths/passportDataset.yaml new file mode 100644 index 0000000000..3d1ba8a740 --- /dev/null +++ b/src/main/resources/assets/paths/passportDataset.yaml @@ -0,0 +1,42 @@ +get: + summary: Get Data Passport + description: | + Returns a Data Passport for a given dataset, as proposed in the + [GA4GH Data Passports specification](https://papers.ssrn.com/sol3/papers.cfm?abstract_id=5372874). + + The response uses the same `ga4gh_passport_v1` envelope as a Researcher Passport + (see `/api/passport/userinfo`) but contains dataset-centric visas that describe + the governance requirements of the dataset rather than the permissions of a researcher: + + - **ConsentedDataUseTerms** — links to the dataset's DUO-coded data use terms + - **OversightBodies** — identifies the DAC responsible for governing access + - **RequiredAgreements** — references the Data Access Agreement users must accept (when one exists) + + The `sub` field of each visa is the dataset identifier (e.g. `DUOS-000001`) rather + than a user subject ID, reflecting the dataset-centric nature of the passport. + tags: + - Admin + parameters: + - name: datasetIdentifier + in: path + description: The formatted DUOS dataset identifier, e.g. DUOS-000001 + required: true + schema: + type: string + example: DUOS-000001 + responses: + 200: + description: A Passport Object containing dataset-centric visas for the specified dataset + content: + application/json: + schema: + $ref: '../schemas/PassportClaim.yaml' + 400: + description: Bad Request — the dataset identifier could not be parsed + 403: + description: Forbidden — the authenticated user does not have permission to access this resource + 404: + description: Dataset not found + 500: + description: Internal Server Error + diff --git a/src/test/java/org/broadinstitute/consent/http/resources/PassportResourceTest.java b/src/test/java/org/broadinstitute/consent/http/resources/PassportResourceTest.java index 0fe8cc6f99..e80b290dfd 100644 --- a/src/test/java/org/broadinstitute/consent/http/resources/PassportResourceTest.java +++ b/src/test/java/org/broadinstitute/consent/http/resources/PassportResourceTest.java @@ -8,12 +8,16 @@ import jakarta.ws.rs.core.Response.Status; import java.sql.Timestamp; import java.time.Instant; +import java.util.List; import org.broadinstitute.consent.http.AbstractTestHelper; import org.broadinstitute.consent.http.models.AuthUser; import org.broadinstitute.consent.http.models.DuosUser; import org.broadinstitute.consent.http.models.User; import org.broadinstitute.consent.http.models.sam.UserStatusInfo; +import org.broadinstitute.consent.http.service.passport.PassportClaim; import org.broadinstitute.consent.http.service.passport.PassportService; +import org.broadinstitute.consent.http.service.passport.Visa; +import org.broadinstitute.consent.http.service.passport.VisaClaim; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; @@ -34,8 +38,9 @@ void testGetPassportSuccess() { duosUser.setUserStatusInfo(userStatusInfo); PassportResource resource = new PassportResource(passportService); - Response response = resource.getPassport(duosUser); - assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + try (Response response = resource.getPassport(duosUser)) { + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + } } @Test @@ -45,8 +50,9 @@ void testGetPassportFailure() { .thenThrow(new RuntimeException("Passport generation failed")); PassportResource resource = new PassportResource(passportService); - Response response = resource.getPassport(duosUser); - assertEquals(Status.INTERNAL_SERVER_ERROR.getStatusCode(), response.getStatus()); + try (Response response = resource.getPassport(duosUser)) { + assertEquals(Status.INTERNAL_SERVER_ERROR.getStatusCode(), response.getStatus()); + } } @Test @@ -54,8 +60,9 @@ void testGetPassportNotFoundNullDuosUser() { PassportResource resource = new PassportResource(passportService); when(passportService.generatePassport(null)).thenThrow(new NotFoundException("User not found")); - Response response = resource.getPassport(null); - assertEquals(Status.NOT_FOUND.getStatusCode(), response.getStatus()); + try (Response response = resource.getPassport(null)) { + assertEquals(Status.NOT_FOUND.getStatusCode(), response.getStatus()); + } } @Test @@ -65,8 +72,78 @@ void testGetPassportNotFoundNullUser() { when(passportService.generatePassport(duosUser)) .thenThrow(new NotFoundException("User not found")); - Response response = resource.getPassport(duosUser); - assertEquals(Status.NOT_FOUND.getStatusCode(), response.getStatus()); + try (Response response = resource.getPassport(duosUser)) { + assertEquals(Status.NOT_FOUND.getStatusCode(), response.getStatus()); + } + } + + // ----------------------------------------------------------------------- + // getDataPassport + // ----------------------------------------------------------------------- + + @Test + void testGetDataPassportSuccess() { + PassportClaim mockClaim = new PassportClaim(List.of(mockVisa())); + when(passportService.generateDataPassport("DUOS-000001")).thenReturn(mockClaim); + + PassportResource resource = new PassportResource(passportService); + try (Response response = + resource.getDataPassport(new DuosUser(authUser, createUser()), "DUOS-000001")) { + assertEquals(Status.OK.getStatusCode(), response.getStatus()); + assertEquals(mockClaim, response.getEntity()); + } + } + + @Test + void testGetDataPassportNotFound() { + when(passportService.generateDataPassport("DUOS-000001")) + .thenThrow(new NotFoundException("Dataset not found: DUOS-000001")); + + PassportResource resource = new PassportResource(passportService); + try (Response response = + resource.getDataPassport(new DuosUser(authUser, createUser()), "DUOS-000001")) { + assertEquals(Status.NOT_FOUND.getStatusCode(), response.getStatus()); + } + } + + @Test + void testGetDataPassportInvalidIdentifier() { + when(passportService.generateDataPassport("INVALID")) + .thenThrow(new IllegalArgumentException("Could not parse identifier (INVALID)")); + + PassportResource resource = new PassportResource(passportService); + try (Response response = + resource.getDataPassport(new DuosUser(authUser, createUser()), "INVALID")) { + assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatus()); + } + } + + @Test + void testGetDataPassportInternalError() { + when(passportService.generateDataPassport("DUOS-000001")) + .thenThrow(new RuntimeException("Unexpected error")); + + PassportResource resource = new PassportResource(passportService); + try (Response response = + resource.getDataPassport(new DuosUser(authUser, createUser()), "DUOS-000001")) { + assertEquals(Status.INTERNAL_SERVER_ERROR.getStatusCode(), response.getStatus()); + } + } + + private Visa mockVisa() { + VisaClaim claim = + new VisaClaim( + "ConsentedDataUseTerms", + Instant.now().getEpochSecond(), + PassportService.ISS + "/dataset/DUOS-000001/dataUse", + PassportService.ISS, + "dac"); + return new Visa( + PassportService.ISS, + "DUOS-000001", + Instant.now().getEpochSecond(), + Instant.now().getEpochSecond() + PassportService.EXPIRATION_SECONDS, + claim); } private User createUser() { diff --git a/src/test/java/org/broadinstitute/consent/http/service/passport/DataPassportVisaTest.java b/src/test/java/org/broadinstitute/consent/http/service/passport/DataPassportVisaTest.java new file mode 100644 index 0000000000..552bdffff6 --- /dev/null +++ b/src/test/java/org/broadinstitute/consent/http/service/passport/DataPassportVisaTest.java @@ -0,0 +1,224 @@ +package org.broadinstitute.consent.http.service.passport; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Instant; +import java.util.Date; +import org.broadinstitute.consent.http.models.Dac; +import org.broadinstitute.consent.http.models.DataAccessAgreement; +import org.broadinstitute.consent.http.models.Dataset; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for the Data Passport visa types introduced in + * https://papers.ssrn.com/sol3/papers.cfm?abstract_id=5372874: ConsentedDataUseTermsVisa, + * OversightBodiesVisa, and RequiredAgreementsVisa. + */ +class DataPassportVisaTest { + + // ----------------------------------------------------------------------- + // ConsentedDataUseTermsVisa + // ----------------------------------------------------------------------- + + @Test + void consentedDataUseTerms_type() { + ConsentedDataUseTermsVisa visa = new ConsentedDataUseTermsVisa(datasetWithAlias(42)); + assertEquals(VisaClaimTypes.CONSENTED_DATA_USE_TERMS.type, visa.type()); + } + + @Test + void consentedDataUseTerms_value_containsDatasetIdentifier() { + Dataset dataset = datasetWithAlias(42); + ConsentedDataUseTermsVisa visa = new ConsentedDataUseTermsVisa(dataset); + assertEquals(PassportService.ISS + "/dataset/" + dataset.getDatasetIdentifier(), visa.value()); + } + + @Test + void consentedDataUseTerms_source_isIss() { + assertEquals(PassportService.ISS, new ConsentedDataUseTermsVisa(datasetWithAlias(1)).source()); + } + + @Test + void consentedDataUseTerms_by_isDac() { + assertEquals( + VisaBy.DAC.name().toLowerCase(), new ConsentedDataUseTermsVisa(datasetWithAlias(1)).by()); + } + + @Test + void consentedDataUseTerms_asserted_usesDatasetCreateDate() { + Dataset dataset = datasetWithAlias(1); + Date createDate = new Date(1_000_000_000L); + dataset.setCreateDate(createDate); + ConsentedDataUseTermsVisa visa = new ConsentedDataUseTermsVisa(dataset); + assertEquals(PassportService.getEpochSeconds(createDate.toInstant()), visa.asserted()); + } + + @Test + void consentedDataUseTerms_asserted_fallsBackToNowWhenCreateDateNull() { + Dataset dataset = datasetWithAlias(1); + dataset.setCreateDate(null); + long before = Instant.now().getEpochSecond(); + long asserted = new ConsentedDataUseTermsVisa(dataset).asserted(); + long after = Instant.now().getEpochSecond(); + assertTrue(asserted >= before && asserted <= after); + } + + @Test + void consentedDataUseTerms_asserted_handlesSqlDate() { + Dataset dataset = datasetWithAlias(42); + java.sql.Date createDate = new java.sql.Date(2_100_000_000L); + dataset.setCreateDate(createDate); + + ConsentedDataUseTermsVisa visa = new ConsentedDataUseTermsVisa(dataset); + + assertEquals( + PassportService.getEpochSeconds(Instant.ofEpochMilli(createDate.getTime())), + visa.asserted()); + } + + // ----------------------------------------------------------------------- + // OversightBodiesVisa + // ----------------------------------------------------------------------- + + @Test + void oversightBodies_type() { + assertEquals(VisaClaimTypes.OVERSIGHT_BODIES.type, new OversightBodiesVisa(dac(7)).type()); + } + + @Test + void oversightBodies_value_containsDacId() { + Dac dac = dac(7); + assertEquals(PassportService.ISS + "/dac/7", new OversightBodiesVisa(dac).value()); + } + + @Test + void oversightBodies_source_isIss() { + assertEquals(PassportService.ISS, new OversightBodiesVisa(dac(1)).source()); + } + + @Test + void oversightBodies_by_isDac() { + assertEquals(VisaBy.DAC.name().toLowerCase(), new OversightBodiesVisa(dac(1)).by()); + } + + @Test + void oversightBodies_asserted_usesDacCreateDate() { + Dac dac = dac(1); + Date createDate = new Date(2_000_000_000L); + dac.setCreateDate(createDate); + assertEquals( + PassportService.getEpochSeconds(createDate.toInstant()), + new OversightBodiesVisa(dac).asserted()); + } + + @Test + void oversightBodies_asserted_fallsBackToNowWhenCreateDateNull() { + Dac dac = dac(1); + dac.setCreateDate(null); + long before = Instant.now().getEpochSecond(); + long asserted = new OversightBodiesVisa(dac).asserted(); + long after = Instant.now().getEpochSecond(); + assertTrue(asserted >= before && asserted <= after); + } + + @Test + void oversightBodies_asserted_handlesSqlDate() { + Dac dac = dac(1); + java.sql.Date createDate = new java.sql.Date(2_000_000_000L); + dac.setCreateDate(createDate); + assertEquals( + PassportService.getEpochSeconds(Instant.ofEpochMilli(createDate.getTime())), + new OversightBodiesVisa(dac).asserted()); + } + + // ----------------------------------------------------------------------- + // RequiredAgreementsVisa + // ----------------------------------------------------------------------- + + @Test + void requiredAgreements_type() { + assertEquals( + VisaClaimTypes.REQUIRED_AGREEMENTS.type, new RequiredAgreementsVisa(daa(5)).type()); + } + + @Test + void requiredAgreements_value_containsDaaId() { + assertEquals(PassportService.ISS + "/daa/5", new RequiredAgreementsVisa(daa(5)).value()); + } + + @Test + void requiredAgreements_source_isIss() { + assertEquals(PassportService.ISS, new RequiredAgreementsVisa(daa(1)).source()); + } + + @Test + void requiredAgreements_by_isSo() { + assertEquals(VisaBy.SO.name().toLowerCase(), new RequiredAgreementsVisa(daa(1)).by()); + } + + @Test + void requiredAgreements_asserted_usesDaaCreateDate() { + Instant createDate = Instant.ofEpochSecond(3_000_000L); + DataAccessAgreement daa = daa(1); + daa.setCreateDate(createDate); + assertEquals( + PassportService.getEpochSeconds(createDate), new RequiredAgreementsVisa(daa).asserted()); + } + + @Test + void requiredAgreements_asserted_fallsBackToNowWhenCreateDateNull() { + DataAccessAgreement daa = daa(1); + daa.setCreateDate(null); + long before = Instant.now().getEpochSecond(); + long asserted = new RequiredAgreementsVisa(daa).asserted(); + long after = Instant.now().getEpochSecond(); + assertTrue(asserted >= before && asserted <= after); + } + + // ----------------------------------------------------------------------- + // Common contract across all three visa types + // ----------------------------------------------------------------------- + + @Test + void allVisaTypes_haveNonNullFields() { + VisaClaimType[] visas = { + new ConsentedDataUseTermsVisa(datasetWithAlias(1)), + new OversightBodiesVisa(dac(1)), + new RequiredAgreementsVisa(daa(1)) + }; + for (VisaClaimType v : visas) { + assertNotNull(v.type(), "type must not be null for " + v.getClass().getSimpleName()); + assertNotNull(v.value(), "value must not be null for " + v.getClass().getSimpleName()); + assertNotNull(v.source(), "source must not be null for " + v.getClass().getSimpleName()); + assertNotNull(v.by(), "by must not be null for " + v.getClass().getSimpleName()); + assertTrue(v.asserted() > 0, "asserted must be positive for " + v.getClass().getSimpleName()); + } + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private Dataset datasetWithAlias(int alias) { + Dataset d = new Dataset(); + d.setAlias(alias); + d.setCreateDate(new Date()); + return d; + } + + private Dac dac(int dacId) { + Dac dac = new Dac(); + dac.setDacId(dacId); + dac.setCreateDate(new Date()); + return dac; + } + + private DataAccessAgreement daa(int daaId) { + DataAccessAgreement daa = new DataAccessAgreement(); + daa.setDaaId(daaId); + daa.setCreateDate(Instant.now()); + return daa; + } +} diff --git a/src/test/java/org/broadinstitute/consent/http/service/passport/PassportServiceTest.java b/src/test/java/org/broadinstitute/consent/http/service/passport/PassportServiceTest.java index 0e06478f60..2dd39b4865 100644 --- a/src/test/java/org/broadinstitute/consent/http/service/passport/PassportServiceTest.java +++ b/src/test/java/org/broadinstitute/consent/http/service/passport/PassportServiceTest.java @@ -9,14 +9,19 @@ import jakarta.ws.rs.NotFoundException; import java.sql.Timestamp; import java.time.Instant; +import java.util.Date; import java.util.List; import org.broadinstitute.consent.http.AbstractTestHelper; import org.broadinstitute.consent.http.db.DatasetDAO; import org.broadinstitute.consent.http.models.ApprovedDataset; +import org.broadinstitute.consent.http.models.Dac; +import org.broadinstitute.consent.http.models.DataAccessAgreement; +import org.broadinstitute.consent.http.models.Dataset; import org.broadinstitute.consent.http.models.DuosUser; import org.broadinstitute.consent.http.models.LibraryCard; import org.broadinstitute.consent.http.models.User; import org.broadinstitute.consent.http.models.sam.UserStatusInfo; +import org.broadinstitute.consent.http.service.DacService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -29,13 +34,14 @@ class PassportServiceTest extends AbstractTestHelper { @Mock private DatasetDAO datasetDAO; + @Mock private DacService dacService; @Mock private DuosUser duosUser; private PassportService service; @BeforeEach void setUp() { - service = new PassportService(datasetDAO); + service = new PassportService(datasetDAO, dacService); } @Test @@ -208,6 +214,162 @@ private UserStatusInfo createUserStatusInfo(User user) { return info; } + // ----------------------------------------------------------------------- + // generateDataPassport + // ----------------------------------------------------------------------- + + @Test + void generateDataPassport_datasetNotFound_throwsNotFoundException() { + when(datasetDAO.findDatasetByAlias(1)).thenReturn(null); + assertThrows(NotFoundException.class, () -> service.generateDataPassport("DUOS-000001")); + } + + @Test + void generateDataPassport_datasetWithNoDac_returnsOnlyConsentedDataUseTermsVisa() { + Dataset dataset = datasetWithAlias(1); + dataset.setDacId(null); + when(datasetDAO.findDatasetByAlias(1)).thenReturn(dataset); + + PassportClaim claim = service.generateDataPassport("DUOS-000001"); + + assertNotNull(claim); + assertEquals(1, claim.ga4gh_passport_v1().size()); + assertEquals( + VisaClaimTypes.CONSENTED_DATA_USE_TERMS.type, + claim.ga4gh_passport_v1().getFirst().ga4gh_visa_v1().type()); + } + + @Test + void generateDataPassport_datasetWithDacAndNoDaa_returnsTwoVisas() { + Dataset dataset = datasetWithAlias(1); + dataset.setDacId(10); + Dac dac = dacWithId(10); + dac.setAssociatedDaa(null); + + when(datasetDAO.findDatasetByAlias(1)).thenReturn(dataset); + when(dacService.findById(10)).thenReturn(dac); + + PassportClaim claim = service.generateDataPassport("DUOS-000001"); + + assertNotNull(claim); + assertEquals(2, claim.ga4gh_passport_v1().size()); + assertVisaTypePresent(claim, VisaClaimTypes.CONSENTED_DATA_USE_TERMS.type); + assertVisaTypePresent(claim, VisaClaimTypes.OVERSIGHT_BODIES.type); + } + + @Test + void generateDataPassport_datasetWithDacAndDaa_returnsThreeVisas() { + Dataset dataset = datasetWithAlias(1); + dataset.setDacId(10); + Dac dac = dacWithId(10); + DataAccessAgreement daa = new DataAccessAgreement(); + daa.setDaaId(99); + daa.setCreateDate(Instant.now()); + dac.setAssociatedDaa(daa); + + when(datasetDAO.findDatasetByAlias(1)).thenReturn(dataset); + when(dacService.findById(10)).thenReturn(dac); + + PassportClaim claim = service.generateDataPassport("DUOS-000001"); + + assertNotNull(claim); + assertEquals(3, claim.ga4gh_passport_v1().size()); + assertVisaTypePresent(claim, VisaClaimTypes.CONSENTED_DATA_USE_TERMS.type); + assertVisaTypePresent(claim, VisaClaimTypes.OVERSIGHT_BODIES.type); + assertVisaTypePresent(claim, VisaClaimTypes.REQUIRED_AGREEMENTS.type); + } + + @Test + void generateDataPassport_subFieldIsDatasetIdentifier() { + Dataset dataset = datasetWithAlias(1); + dataset.setDacId(null); + when(datasetDAO.findDatasetByAlias(1)).thenReturn(dataset); + + PassportClaim claim = service.generateDataPassport("DUOS-000001"); + + claim + .ga4gh_passport_v1() + .forEach(v -> assertEquals("DUOS-000001", v.sub(), "sub should be the dataset identifier")); + } + + @Test + void generateDataPassport_issFieldIsIss() { + Dataset dataset = datasetWithAlias(1); + dataset.setDacId(null); + when(datasetDAO.findDatasetByAlias(1)).thenReturn(dataset); + + PassportClaim claim = service.generateDataPassport("DUOS-000001"); + + claim.ga4gh_passport_v1().forEach(v -> assertEquals(PassportService.ISS, v.iss())); + } + + @Test + void generateDataPassport_iatAndExpAreEpochSeconds() { + Dataset dataset = datasetWithAlias(1); + dataset.setDacId(null); + when(datasetDAO.findDatasetByAlias(1)).thenReturn(dataset); + + PassportClaim claim = service.generateDataPassport("DUOS-000001"); + + long nowSeconds = Instant.now().getEpochSecond(); + claim + .ga4gh_passport_v1() + .forEach( + v -> { + assertTrue(v.iat() <= nowSeconds + 5, "iat should be seconds, not milliseconds"); + assertTrue(v.exp() > v.iat(), "exp should be after iat"); + assertEquals(PassportService.EXPIRATION_SECONDS, v.exp() - v.iat()); + }); + } + + @Test + void generateDataPassport_dacNotFound_returnsOnlyConsentedDataUseTermsVisa() { + Dataset dataset = datasetWithAlias(1); + dataset.setDacId(10); + when(datasetDAO.findDatasetByAlias(1)).thenReturn(dataset); + when(dacService.findById(10)).thenReturn(null); + + PassportClaim claim = service.generateDataPassport("DUOS-000001"); + + assertEquals(1, claim.ga4gh_passport_v1().size()); + assertVisaTypePresent(claim, VisaClaimTypes.CONSENTED_DATA_USE_TERMS.type); + } + + @Test + void + generateDataPassport_unsupportedOperationFromDacLookup_returnsOnlyConsentedDataUseTermsVisa() { + Dataset dataset = datasetWithAlias(1); + dataset.setDacId(10); + when(datasetDAO.findDatasetByAlias(1)).thenReturn(dataset); + when(dacService.findById(10)).thenThrow(new UnsupportedOperationException("unsupported")); + + PassportClaim claim = service.generateDataPassport("DUOS-000001"); + + assertNotNull(claim); + assertEquals(1, claim.ga4gh_passport_v1().size()); + assertVisaTypePresent(claim, VisaClaimTypes.CONSENTED_DATA_USE_TERMS.type); + } + + private void assertVisaTypePresent(PassportClaim claim, String type) { + assertTrue( + claim.ga4gh_passport_v1().stream().anyMatch(v -> type.equals(v.ga4gh_visa_v1().type())), + "Expected visa type '%s' to be present".formatted(type)); + } + + private Dataset datasetWithAlias(int alias) { + Dataset d = new Dataset(); + d.setAlias(alias); + d.setCreateDate(new Date()); + return d; + } + + private Dac dacWithId(int dacId) { + Dac dac = new Dac(); + dac.setDacId(dacId); + dac.setCreateDate(new Date()); + return dac; + } + private int datasetCounter = 0; private ApprovedDataset createApprovedDataset() { @@ -223,4 +385,60 @@ private ApprovedDataset createApprovedDataset() { d.setDatasetIdentifier(datasetIdentifier); return d; } + + @Test + void testAffiliationAndRole_assertedHandlesSqlDateOnUser() { + User user = createUser(); + java.sql.Date sqlDate = new java.sql.Date(1_700_000_000_000L); + user.setCreateDate(sqlDate); + + AffiliationAndRole affiliationAndRole = new AffiliationAndRole(user); + + assertEquals( + PassportService.getEpochSeconds(Instant.ofEpochMilli(sqlDate.getTime())), + affiliationAndRole.asserted()); + } + + @Test + void testAffiliationAndRole_assertedHandlesSqlDateOnLibraryCard() { + User user = createUser(); + LibraryCard card = new LibraryCard(); + java.sql.Date sqlDate = new java.sql.Date(1_710_000_000_000L); + card.setCreateDate(sqlDate); + user.setLibraryCard(card); + + AffiliationAndRole affiliationAndRole = new AffiliationAndRole(user); + + assertEquals( + PassportService.getEpochSeconds(Instant.ofEpochMilli(sqlDate.getTime())), + affiliationAndRole.asserted()); + } + + @Test + void testResearcherStatus_assertedHandlesSqlDateOnUser() { + User user = createUser(); + java.sql.Date sqlDate = new java.sql.Date(1_720_000_000_000L); + user.setCreateDate(sqlDate); + + ResearcherStatus researcherStatus = new ResearcherStatus(user); + + assertEquals( + PassportService.getEpochSeconds(Instant.ofEpochMilli(sqlDate.getTime())), + researcherStatus.asserted()); + } + + @Test + void testResearcherStatus_assertedHandlesSqlDateOnLibraryCard() { + User user = createUser(); + LibraryCard card = new LibraryCard(); + java.sql.Date sqlDate = new java.sql.Date(1_730_000_000_000L); + card.setCreateDate(sqlDate); + user.setLibraryCard(card); + + ResearcherStatus researcherStatus = new ResearcherStatus(user); + + assertEquals( + PassportService.getEpochSeconds(Instant.ofEpochMilli(sqlDate.getTime())), + researcherStatus.asserted()); + } }