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:
+ *
+ *
+ * - {@link ConsentedDataUseTermsVisa} — links to the dataset's DUO-coded data use terms
+ *
- {@link OversightBodiesVisa} — identifies the DAC governing the dataset
+ *
- {@link RequiredAgreementsVisa} — references the DAA users must accept (if one exists)
+ *
+ *
+ * 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());
+ }
}