From ed37fee33e51b1b6f1ab7cf8961493812349559a Mon Sep 17 00:00:00 2001 From: ChrisJr404 Date: Tue, 5 May 2026 11:16:25 -0400 Subject: [PATCH] Expose project created date via API Resolves #6094 Adds a CREATED column to the PROJECT table and exposes it on Project JSON responses. Existing rows have no creation timestamp recorded; the column is nullable so backfill is not required. Signed-off-by: ChrisJr404 --- .../org/dependencytrack/model/Project.java | 16 +++++++ .../persistence/ProjectQueryManager.java | 4 ++ .../persistence/ProjectQueryManagerTest.java | 28 ++++++++++++ .../resources/v1/ProjectResourceTest.java | 45 +++++++++++++++---- 4 files changed, 85 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/dependencytrack/model/Project.java b/src/main/java/org/dependencytrack/model/Project.java index 9d4c3a0019..b0b97ec28e 100644 --- a/src/main/java/org/dependencytrack/model/Project.java +++ b/src/main/java/org/dependencytrack/model/Project.java @@ -281,6 +281,14 @@ public enum FetchGroup { @Element(column = "TAG_ID") private Set tags; + /** + * Date when the project was created. + */ + @Persistent + @Column(name = "CREATED", allowsNull = "true") // New column, must allow nulls on existing databases + @Schema(type = "integer", format = "int64", requiredMode = Schema.RequiredMode.NOT_REQUIRED, description = "UNIX epoch timestamp in milliseconds") + private Date created; + /** * Convenience field which will contain the date of the last entry in the {@link Bom} table */ @@ -547,6 +555,14 @@ public void setTags(Set tags) { this.tags = tags; } + public Date getCreated() { + return created; + } + + public void setCreated(Date created) { + this.created = created; + } + public Date getLastBomImport() { return lastBomImport; } diff --git a/src/main/java/org/dependencytrack/persistence/ProjectQueryManager.java b/src/main/java/org/dependencytrack/persistence/ProjectQueryManager.java index aabbaf291a..674e139c5b 100644 --- a/src/main/java/org/dependencytrack/persistence/ProjectQueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/ProjectQueryManager.java @@ -463,6 +463,9 @@ public Project createProject(final Project project, Collection tags, boolea if (project.getParent() != null && !Boolean.TRUE.equals(project.getParent().isActive())){ throw new IllegalArgumentException("An inactive Parent cannot be selected as parent"); } + if (project.getCreated() == null) { + project.setCreated(new Date()); + } final Project oldLatestProject = project.isLatest() ? getLatestProjectVersion(project.getName()) : null; final Project result = callInTransaction(() -> { // Remove isLatest flag from current latest project version, if the new project will be the latest @@ -685,6 +688,7 @@ public Project clone( oldLatestProject.set(null); } Project project = new Project(); + project.setCreated(new Date()); project.setAuthors(source.getAuthors()); project.setManufacturer(source.getManufacturer()); project.setSupplier(source.getSupplier()); diff --git a/src/test/java/org/dependencytrack/persistence/ProjectQueryManagerTest.java b/src/test/java/org/dependencytrack/persistence/ProjectQueryManagerTest.java index 05cf9a51bd..1b39ceb9f7 100644 --- a/src/test/java/org/dependencytrack/persistence/ProjectQueryManagerTest.java +++ b/src/test/java/org/dependencytrack/persistence/ProjectQueryManagerTest.java @@ -33,11 +33,39 @@ import java.util.Date; import java.util.List; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.mockito.Mockito.times; class ProjectQueryManagerTest extends PersistenceCapableTest { + @Test + void testCreateProjectSetsCreatedDate() { + final Date before = new Date(); + Project project = qm.createProject("Example Project", null, "1.0", null, null, null, true, false); + final Date after = new Date(); + assertThat(project.getCreated()) + .isNotNull() + .isBetween(before, after); + } + + @Test + void testCloneProjectSetsCreatedDate() throws Exception { + Project source = qm.createProject("Source", null, "1.0", null, null, null, true, false); + // Force an older created date on the source to verify the clone is independent. + source.setCreated(new Date(1700000000000L)); + qm.persist(source); + + final Date before = new Date(); + Project cloned = qm.clone(source.getUuid(), "1.1.0", false, false, + false, false, false, false, false, false); + final Date after = new Date(); + + assertThat(cloned.getCreated()) + .isNotNull() + .isBetween(before, after); + } + @Test void testCloneProjectPreservesVulnerabilityAttributionDate() throws Exception { Project project = qm.createProject("Example Project 1", "Description 1", "1.0", null, null, null, true, false); diff --git a/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java index 13e900d64a..19dab991bb 100644 --- a/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java @@ -383,6 +383,25 @@ void getProjectByUuidTest() { """); } + @Test + void getProjectByUuidExposesCreatedDateTest() { + // Issue #6094: GET /api/v1/project/ must return the project's creation timestamp. + final long beforeCreate = System.currentTimeMillis(); + final Project project = qm.createProject("acme-app", null, "1.0.0", null, null, null, true, false); + final long afterCreate = System.currentTimeMillis(); + + final Response response = jersey.target(V1_PROJECT + "/" + project.getUuid()) + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + + final JsonObject json = parseJsonObject(response); + assertThat(json.containsKey("created")).isTrue(); + final long created = json.getJsonNumber("created").longValueExact(); + assertThat(created).isBetween(beforeCreate, afterCreate); + } + @Test void getProjectByUuidNotPermittedTest() { enablePortfolioAccessControl(); @@ -643,7 +662,8 @@ void createProjectAsUserWithAclEnabledAndExistingTeamByUuidTest() { "properties": [], "tags": [], "active": true, - "isLatest":false + "isLatest":false, + "created": "${json-unit.any-number}" } """); @@ -690,7 +710,8 @@ void createProjectAsUserWithAclEnabledAndExistingTeamByNameTest() { "properties": [], "tags": [], "active": true, - "isLatest":false + "isLatest":false, + "created": "${json-unit.any-number}" } """); @@ -732,7 +753,8 @@ void createProjectAsUserWithAclEnabledAndWithoutTeamTest() { "properties": [], "tags": [], "active": true, - "isLatest":false + "isLatest":false, + "created": "${json-unit.any-number}" } """); @@ -815,7 +837,8 @@ void createProjectAsUserWithAclEnabledAndNotMemberOfTeamAdminTest() { "properties": [], "tags": [], "active": true, - "isLatest":false + "isLatest":false, + "created": "${json-unit.any-number}" } """); @@ -926,7 +949,8 @@ void createProjectAsApiKeyWithAclEnabledAndWithExistentTeamTest() { "properties": [], "tags": [], "active": true, - "isLatest":false + "isLatest":false, + "created": "${json-unit.any-number}" } """); @@ -1606,7 +1630,8 @@ void patchProjectSuccessfullyPatchedTest() { "active": false, "isLatest":false, "children": [], - "collectionLogic":"NONE" + "collectionLogic":"NONE", + "created": "${json-unit.any-number}" } """); } @@ -1680,7 +1705,8 @@ void patchProjectParentTest() { "tags": [], "active": true, "isLatest":false, - "collectionLogic":"NONE" + "collectionLogic":"NONE", + "created": "${json-unit.any-number}" } """); @@ -2311,7 +2337,8 @@ void issue3883RegressionTest() { "uuid": "${json-unit.any-string}", "active": true, "isLatest":false, - "collectionLogic":"NONE" + "collectionLogic":"NONE", + "created": "${json-unit.any-number}" } ], "properties": [], @@ -2319,6 +2346,7 @@ void issue3883RegressionTest() { "active": true, "isLatest":false, "collectionLogic":"NONE", + "created": "${json-unit.any-number}", "versions": [ { "uuid": "${json-unit.any-string}", @@ -2351,6 +2379,7 @@ void issue3883RegressionTest() { "active": true, "isLatest":false, "collectionLogic":"NONE", + "created": "${json-unit.any-number}", "versions": [ { "uuid": "${json-unit.any-string}",