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..912a047156 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,11 +28,15 @@ 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 - public String value() { + public Object value() { if (user.getEmail() == null) { return DEFAULT_VALUE; } diff --git a/src/main/java/org/broadinstitute/consent/http/service/passport/ApprovedUsersVisa.java b/src/main/java/org/broadinstitute/consent/http/service/passport/ApprovedUsersVisa.java new file mode 100644 index 0000000000..003831c3df --- /dev/null +++ b/src/main/java/org/broadinstitute/consent/http/service/passport/ApprovedUsersVisa.java @@ -0,0 +1,35 @@ +package org.broadinstitute.consent.http.service.passport; + +public class ApprovedUsersVisa implements VisaClaimType { + + private final String datasetIdentifier; + + public ApprovedUsersVisa(String datasetIdentifier) { + this.datasetIdentifier = datasetIdentifier; + } + + @Override + public String type() { + return VisaClaimTypes.APPROVED_USERS.type; + } + + @Override + public Long asserted() { + return PassportService.getEpochSeconds(java.time.Instant.now()); + } + + @Override + public Object value() { + return PassportService.getApprovedUsersEndpoint(datasetIdentifier); + } + + @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/ConsentedDataUseTermsVisa.java b/src/main/java/org/broadinstitute/consent/http/service/passport/ConsentedDataUseTermsVisa.java new file mode 100644 index 0000000000..dd456b99e2 --- /dev/null +++ b/src/main/java/org/broadinstitute/consent/http/service/passport/ConsentedDataUseTermsVisa.java @@ -0,0 +1,56 @@ +package org.broadinstitute.consent.http.service.passport; + +import java.time.Instant; +import java.util.List; +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 List value() { + return PassportService.dataUseToTermArray(dataset.getDataUse()); + } + + @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..d2aef0d741 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,12 +32,12 @@ 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 - public String value() { + public Object value() { return String.format( "%s/dataset/%s", PassportService.ISS, approvedDataset.getDatasetIdentifier()); } 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..04b456b7b8 --- /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 Object 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..e0ae32fc19 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,13 @@ 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.DataUse; +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 +28,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 +64,70 @@ 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 GA4GH Data Passports). + * 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<>(); + + // ApprovedUsers - links to the API endpoint describing approved users for the dataset + visas.add(visaFromVisaClaimType(datasetIdentifier, new ApprovedUsersVisa(datasetIdentifier))); + + // 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() @@ -87,4 +158,88 @@ private Visa visaFromVisaClaimType(String userSubjectId, VisaClaimType type) { public static long getEpochSeconds(Instant instant) { return instant.getEpochSecond(); } + + public static String getApprovedUsersEndpoint(String datasetIdentifier) { + return "https://consent.dsde-prod.broadinstitute.org/api/datataset/%s/approvedUsers" + .formatted(datasetIdentifier); + } + + /** + * Converts a {@link DataUse} object to a list of GA4GH DUO ontology term identifiers that + * reflect the active data use conditions. Boolean fields are included when {@code true}; + * String/List fields are included when non-blank/non-empty. Fields that have no standard DUO term + * are omitted. + * + * @param dataUse the DataUse to convert + * @return a list of DUO term identifiers, e.g. {@code ["DUO:0000004", "DUO:0000021"]} + */ + public static List dataUseToTermArray(DataUse dataUse) { + List terms = new ArrayList<>(); + if (dataUse == null) { + return terms; + } + // Note that fields like gender, pediatric, aiLlmUse, illegalBehavior, sexualDiseases, + // stigmatizeDiseases, vulnerablePopulations, psychologicalTraits, notHealth, controls, + // population, other, and secondaryOther are intentionally omitted as they have no standard DUO + // ontology term. + + // ── Primary data use permissions ───────────────────────────────────── + // DUO:0000004 – no restriction (General Research Use, GRU) + if (Boolean.TRUE.equals(dataUse.getGeneralUse())) { + terms.add("DUO:0000004"); + } + // DUO:0000006 – health or medical or biomedical research (HMB) + if (Boolean.TRUE.equals(dataUse.getHmbResearch())) { + terms.add("DUO:0000006"); + } + // DUO:0000007 – disease specific research (DS); the restriction list already contains + // ontology term IDs (e.g. MONDO/HP/DOID), so include both the DUO classifier and each term. + if (dataUse.getDiseaseRestrictions() != null && !dataUse.getDiseaseRestrictions().isEmpty()) { + terms.add("DUO:0000007"); + terms.addAll(dataUse.getDiseaseRestrictions()); + } + // DUO:0000011 – population origins or ancestry research only (POA) + if (Boolean.TRUE.equals(dataUse.getPopulationOriginsAncestry())) { + terms.add("DUO:0000011"); + } + // DUO:0000016 – genetic studies only (GSO) + if (Boolean.TRUE.equals(dataUse.getGeneticStudiesOnly())) { + terms.add("DUO:0000016"); + } + + // ── Secondary / modifier terms ──────────────────────────────────────── + // DUO:0000015 – no general methods research (NMDS) + if (Boolean.TRUE.equals(dataUse.getMethodsResearch())) { + terms.add("DUO:0000015"); + } + // DUO:0000018 – not-for-profit use only (NPU / NCU) + if (Boolean.TRUE.equals(dataUse.getNonProfitUse())) { + terms.add("DUO:0000018"); + } + // DUO:0000019 – publication required (PUB) + if (Boolean.TRUE.equals(dataUse.getPublicationResults())) { + terms.add("DUO:0000019"); + } + // DUO:0000020 – collaboration required (COL) + if (Boolean.TRUE.equals(dataUse.getCollaboratorRequired())) { + terms.add("DUO:0000020"); + } + // DUO:0000021 – ethics approval required (IRB) + if (Boolean.TRUE.equals(dataUse.getEthicsApprovalRequired())) { + terms.add("DUO:0000021"); + } + // DUO:0000022 – geographical restriction (GS) + if (dataUse.getGeographicalRestrictions() != null + && !dataUse.getGeographicalRestrictions().isBlank()) { + terms.add("DUO:0000022"); + } + // DUO:0000024 – publication moratorium (MOR) + if (dataUse.getPublicationMoratorium() != null + && !dataUse.getPublicationMoratorium().isBlank()) { + terms.add("DUO:0000024"); + } + + return terms; + } } 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..24e88db84a --- /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 Object 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..39f901281c 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,11 +27,15 @@ 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 - public String value() { + public Object value() { // See https://broadworkbench.atlassian.net/browse/DT-2863 // This will be replaced with an external profile link. return PassportService.ISS; diff --git a/src/main/java/org/broadinstitute/consent/http/service/passport/VisaClaim.java b/src/main/java/org/broadinstitute/consent/http/service/passport/VisaClaim.java index 7f0831de41..72af09eced 100644 --- a/src/main/java/org/broadinstitute/consent/http/service/passport/VisaClaim.java +++ b/src/main/java/org/broadinstitute/consent/http/service/passport/VisaClaim.java @@ -1,3 +1,3 @@ package org.broadinstitute.consent.http.service.passport; -public record VisaClaim(String type, Long asserted, String value, String source, String by) {} +public record VisaClaim(String type, Long asserted, Object value, String source, String by) {} diff --git a/src/main/java/org/broadinstitute/consent/http/service/passport/VisaClaimType.java b/src/main/java/org/broadinstitute/consent/http/service/passport/VisaClaimType.java index 717c86d3a7..2d12c2f643 100644 --- a/src/main/java/org/broadinstitute/consent/http/service/passport/VisaClaimType.java +++ b/src/main/java/org/broadinstitute/consent/http/service/passport/VisaClaimType.java @@ -5,7 +5,7 @@ public interface VisaClaimType { Long asserted(); - String value(); + Object value(); String source(); 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..230a9c422a 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,17 @@ 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) + APPROVED_USERS("ApprovedUsers"), + 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 4365ea64bf..1511608a8f 100644 --- a/src/main/resources/assets/api-docs.yaml +++ b/src/main/resources/assets/api-docs.yaml @@ -719,6 +719,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..e4a9fee2f2 --- /dev/null +++ b/src/main/resources/assets/paths/passportDataset.yaml @@ -0,0 +1,43 @@ +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: + + - **ApprovedUsers** — links to the dataset's approved user API endpoint + - **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..0272b698ce --- /dev/null +++ b/src/test/java/org/broadinstitute/consent/http/service/passport/DataPassportVisaTest.java @@ -0,0 +1,425 @@ +package org.broadinstitute.consent.http.service.passport; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +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 java.util.List; +import org.broadinstitute.consent.http.models.Dac; +import org.broadinstitute.consent.http.models.DataAccessAgreement; +import org.broadinstitute.consent.http.models.DataUse; +import org.broadinstitute.consent.http.models.DataUseBuilder; +import org.broadinstitute.consent.http.models.Dataset; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for the Data Passport visa types introduced in GA4GH Data Passports: + * 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_isListOfDuoTerms() { + Dataset dataset = datasetWithAlias(42); + ConsentedDataUseTermsVisa visa = new ConsentedDataUseTermsVisa(dataset); + assertInstanceOf(List.class, visa.value()); + } + + @Test + void consentedDataUseTerms_value_emptyListWhenDataUseIsNull() { + Dataset dataset = datasetWithAlias(1); + dataset.setDataUse(null); + List terms = (List) new ConsentedDataUseTermsVisa(dataset).value(); + assertNotNull(terms); + assertTrue(terms.isEmpty()); + } + + @Test + void consentedDataUseTerms_value_generalUse() { + DataUse dataUse = new DataUseBuilder().setGeneralUse(true).build(); + List terms = valueFor(dataUse); + assertTrue(terms.contains("DUO:0000004"), "GRU should map to DUO:0000004"); + } + + @Test + void consentedDataUseTerms_value_hmbResearch() { + DataUse dataUse = new DataUseBuilder().setHmbResearch(true).build(); + List terms = valueFor(dataUse); + assertTrue(terms.contains("DUO:0000006"), "HMB should map to DUO:0000006"); + } + + @Test + void consentedDataUseTerms_value_diseaseRestrictions_includesDuoClassifierAndTermIds() { + DataUse dataUse = + new DataUseBuilder().setDiseaseRestrictions(List.of("MONDO:0005267", "HP:0001250")).build(); + List terms = valueFor(dataUse); + assertTrue(terms.contains("DUO:0000007"), "DS should include DUO:0000007 classifier"); + assertTrue(terms.contains("MONDO:0005267"), "disease term MONDO:0005267 should be included"); + assertTrue(terms.contains("HP:0001250"), "disease term HP:0001250 should be included"); + } + + @Test + void consentedDataUseTerms_value_diseaseRestrictions_emptyListDoesNotAddClassifier() { + DataUse dataUse = new DataUseBuilder().setDiseaseRestrictions(List.of()).build(); + List terms = valueFor(dataUse); + assertTrue( + terms.stream().noneMatch("DUO:0000007"::equals), + "Empty disease list should not add DUO:0000007"); + } + + @Test + void consentedDataUseTerms_value_populationOriginsAncestry() { + DataUse dataUse = new DataUseBuilder().setPopulationOriginsAncestry(true).build(); + List terms = valueFor(dataUse); + assertTrue(terms.contains("DUO:0000011"), "POA should map to DUO:0000011"); + } + + @Test + void consentedDataUseTerms_value_geneticStudiesOnly() { + DataUse dataUse = new DataUseBuilder().setGeneticStudiesOnly(true).build(); + List terms = valueFor(dataUse); + assertTrue(terms.contains("DUO:0000016"), "GSO should map to DUO:0000016"); + } + + @Test + void consentedDataUseTerms_value_methodsResearch() { + DataUse dataUse = new DataUseBuilder().setMethodsResearch(true).build(); + List terms = valueFor(dataUse); + assertTrue(terms.contains("DUO:0000015"), "NMDS should map to DUO:0000015"); + } + + @Test + void consentedDataUseTerms_value_nonProfitUse() { + DataUse dataUse = new DataUseBuilder().setNonProfitUse(true).build(); + List terms = valueFor(dataUse); + assertTrue(terms.contains("DUO:0000018"), "NPU should map to DUO:0000018"); + } + + @Test + void consentedDataUseTerms_value_publicationResults() { + DataUse dataUse = new DataUseBuilder().setPublicationResults(true).build(); + List terms = valueFor(dataUse); + assertTrue(terms.contains("DUO:0000019"), "PUB should map to DUO:0000019"); + } + + @Test + void consentedDataUseTerms_value_collaboratorRequired() { + DataUse dataUse = new DataUseBuilder().setCollaboratorRequired(true).build(); + List terms = valueFor(dataUse); + assertTrue(terms.contains("DUO:0000020"), "COL should map to DUO:0000020"); + } + + @Test + void consentedDataUseTerms_value_ethicsApprovalRequired() { + DataUse dataUse = new DataUseBuilder().setEthicsApprovalRequired(true).build(); + List terms = valueFor(dataUse); + assertTrue(terms.contains("DUO:0000021"), "IRB should map to DUO:0000021"); + } + + @Test + void consentedDataUseTerms_value_geographicalRestrictions() { + DataUse dataUse = new DataUseBuilder().setGeographicalRestrictions("US-only").build(); + List terms = valueFor(dataUse); + assertTrue(terms.contains("DUO:0000022"), "GS should map to DUO:0000022"); + } + + @Test + void consentedDataUseTerms_value_geographicalRestrictions_blankStringNotIncluded() { + DataUse dataUse = new DataUseBuilder().setGeographicalRestrictions(" ").build(); + List terms = valueFor(dataUse); + assertTrue( + terms.stream().noneMatch("DUO:0000022"::equals), + "Blank geographical restriction should not add DUO:0000022"); + } + + @Test + void consentedDataUseTerms_value_publicationMoratorium() { + DataUse dataUse = new DataUseBuilder().setPublicationMoratorium("2027-01-01").build(); + List terms = valueFor(dataUse); + assertTrue(terms.contains("DUO:0000024"), "MOR should map to DUO:0000024"); + } + + @Test + void consentedDataUseTerms_value_multipleCombinedTerms() { + DataUse dataUse = + new DataUseBuilder() + .setHmbResearch(true) + .setEthicsApprovalRequired(true) + .setNonProfitUse(true) + .build(); + List terms = valueFor(dataUse); + assertTrue(terms.contains("DUO:0000006")); + assertTrue(terms.contains("DUO:0000021")); + assertTrue(terms.contains("DUO:0000018")); + } + + @Test + void consentedDataUseTerms_value_falseBooleansNotIncluded() { + DataUse dataUse = + new DataUseBuilder() + .setGeneralUse(false) + .setHmbResearch(false) + .setNonProfitUse(false) + .build(); + List terms = valueFor(dataUse); + assertTrue(terms.isEmpty(), "False boolean fields should not produce any DUO terms"); + } + + // Helper: build a dataset with the given DataUse and return the visa value + @SuppressWarnings("unchecked") + private List valueFor(DataUse dataUse) { + Dataset dataset = datasetWithAlias(1); + dataset.setDataUse(dataUse); + return (List) new ConsentedDataUseTermsVisa(dataset).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().toString()); + } + + @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().toString()); + } + + @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); + } + + // ----------------------------------------------------------------------- + // ApprovedUsersVisa + // ----------------------------------------------------------------------- + + @Test + void approvedUsers_type() { + assertEquals(VisaClaimTypes.APPROVED_USERS.type, new ApprovedUsersVisa("DUOS-000001").type()); + } + + @Test + void approvedUsers_value_containsDatasetIdentifier() { + ApprovedUsersVisa visa = new ApprovedUsersVisa("DUOS-000042"); + assertEquals(PassportService.getApprovedUsersEndpoint("DUOS-000042"), visa.value().toString()); + } + + @Test + void approvedUsers_value_containsDatasetIdentifierInUrl() { + ApprovedUsersVisa visa = new ApprovedUsersVisa("DUOS-000042"); + assertTrue(visa.value().toString().contains("DUOS-000042")); + } + + @Test + void approvedUsers_source_isIss() { + assertEquals(PassportService.ISS, new ApprovedUsersVisa("DUOS-000001").source()); + } + + @Test + void approvedUsers_by_isDac() { + assertEquals(VisaBy.DAC.name().toLowerCase(), new ApprovedUsersVisa("DUOS-000001").by()); + } + + @Test + void approvedUsers_asserted_isEpochSeconds() { + long before = Instant.now().getEpochSecond(); + long asserted = new ApprovedUsersVisa("DUOS-000001").asserted(); + long after = Instant.now().getEpochSecond(); + assertTrue(asserted >= before && asserted <= after); + } + + @Test + void approvedUsers_asserted_isNotMilliseconds() { + long asserted = new ApprovedUsersVisa("DUOS-000001").asserted(); + long nowSeconds = Instant.now().getEpochSecond(); + // If asserted were in milliseconds it would be ~1000x larger than nowSeconds + assertTrue(asserted <= nowSeconds + 5, "asserted should be seconds, not milliseconds"); + } + + // ----------------------------------------------------------------------- + // Common contract across all four visa types + // ----------------------------------------------------------------------- + + @Test + void allVisaTypes_haveNonNullFields() { + VisaClaimType[] visas = { + new ConsentedDataUseTermsVisa(datasetWithAlias(1)), + new OversightBodiesVisa(dac(1)), + new RequiredAgreementsVisa(daa(1)), + new ApprovedUsersVisa("DUOS-000001") + }; + 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..e3e2bfb9f6 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 @@ -140,7 +146,7 @@ void testAffiliationAndRole_nullEmail(String email) { User user = createUser(); user.setEmail(email); AffiliationAndRole affiliationAndRole = new AffiliationAndRole(user); - assertEquals(AffiliationAndRole.DEFAULT_VALUE, affiliationAndRole.value()); + assertEquals(AffiliationAndRole.DEFAULT_VALUE, affiliationAndRole.value().toString()); assertEquals(PassportService.ISS, affiliationAndRole.source()); assertTrue(affiliationAndRole.asserted() > 0); } @@ -151,7 +157,7 @@ void testAffiliationAndRole_withLibraryCard() { LibraryCard card = new LibraryCard(); user.setLibraryCard(card); AffiliationAndRole affiliationAndRole = new AffiliationAndRole(user); - assertTrue(affiliationAndRole.value().contains("faculty@example.org")); + assertTrue(affiliationAndRole.value().toString().contains("faculty@example.org")); assertEquals(PassportService.ISS, affiliationAndRole.source()); assertEquals(VisaBy.SO.name().toLowerCase(), affiliationAndRole.by()); assertTrue(affiliationAndRole.asserted() > 0); @@ -208,6 +214,165 @@ 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_returnsApprovedUsersAndConsentedDataUseTermsVisas() { + Dataset dataset = datasetWithAlias(1); + dataset.setDacId(null); + when(datasetDAO.findDatasetByAlias(1)).thenReturn(dataset); + + PassportClaim claim = service.generateDataPassport("DUOS-000001"); + + assertNotNull(claim); + assertEquals(2, claim.ga4gh_passport_v1().size()); + assertVisaTypePresent(claim, VisaClaimTypes.APPROVED_USERS.type); + assertVisaTypePresent(claim, VisaClaimTypes.CONSENTED_DATA_USE_TERMS.type); + } + + @Test + void generateDataPassport_datasetWithDacAndNoDaa_returnsThreeVisas() { + 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(3, claim.ga4gh_passport_v1().size()); + assertVisaTypePresent(claim, VisaClaimTypes.APPROVED_USERS.type); + assertVisaTypePresent(claim, VisaClaimTypes.CONSENTED_DATA_USE_TERMS.type); + assertVisaTypePresent(claim, VisaClaimTypes.OVERSIGHT_BODIES.type); + } + + @Test + void generateDataPassport_datasetWithDacAndDaa_returnsFourVisas() { + 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(4, claim.ga4gh_passport_v1().size()); + assertVisaTypePresent(claim, VisaClaimTypes.APPROVED_USERS.type); + 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_returnsApprovedUsersAndConsentedDataUseTermsVisas() { + 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(2, claim.ga4gh_passport_v1().size()); + assertVisaTypePresent(claim, VisaClaimTypes.APPROVED_USERS.type); + assertVisaTypePresent(claim, VisaClaimTypes.CONSENTED_DATA_USE_TERMS.type); + } + + @Test + void + generateDataPassport_unsupportedOperationFromDacLookup_returnsApprovedUsersAndConsentedDataUseTermsVisas() { + 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(2, claim.ga4gh_passport_v1().size()); + assertVisaTypePresent(claim, VisaClaimTypes.APPROVED_USERS.type); + 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 +388,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()); + } }