diff --git a/README.md b/README.md index bf47e0c0..66431de4 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,11 @@ creation), `review` (on transition to `Review`) or `release` (on `AutoPublish`/` preserving the historic behavior). Once assigned, the final tracking ID never changes again, regardless of further workflow transitions. If `CSAF_REFERENCES_BASE_URL` is defined, a JSON reference in `document/references` with the set URL is added when publishing the document. +This self-reference URL includes the document's TLP label (`{baseUrl}/{tlp}/{year}/{trackingId}.json`). To also add an HTML reference (`.html` variant), set `CSAF_WORKFLOW_CREATE_HTML_REFERENCE=true` (default: `false`). +The variable `CSAF_REFERENCES_REGENERATION` controls when this self-reference is (re)created: `always` (recomputed +at every publish, and also on every advisory update, so a later TLP change is kept in sync, `initial` (default, generated once, +at the first publish, and never updated again), or `never` (auto-generation is skipped entirely). See **.env.example** for an example configuration. ### Management of engine data diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/PostConstructActions.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/PostConstructActions.java index 87a0d3a1..92769eae 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/PostConstructActions.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/PostConstructActions.java @@ -62,6 +62,7 @@ private void checkConfiguration() { LOG.info("Is Allowed to Approved Own Documents: {}.", configuration.getWorkflow().isAllowOwnDocumentsApproved()); LOG.info("Creates an Html Reference on Publish: {}.", configuration.getWorkflow().isCreateHtmlReference()); LOG.info("csaf.trackingid.assignment.phase is configured to {}.", advisoryService.getTrackingIdAssignmentPhase()); + LOG.info("csaf.references.regeneration is configured to {}.", advisoryService.getSelfRefRegenerationMode()); LOG.info(CONFIG_LOG_SEPARATOR); } diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/AdvisoryWrapper.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/AdvisoryWrapper.java index 88b4e186..3ded0e4d 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/AdvisoryWrapper.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/json/AdvisoryWrapper.java @@ -870,12 +870,35 @@ public static boolean timestampIsBefore(String timestamp1, String timestamp2) { } /** - * add new node to the document references with category 'sef' + * (Re-)generate the self-reference DocumentReferencesNode(s) from the document's current tracking id and TLP label. + * If a self-reference already exists (matched by summary), its url is updated in place; otherwise it is added. + * @param baseUrl the configured base url + * @param createHtmlReference when {@code true}, an additional DocumentReferencesNode with the HTML url of the tracking id is created/updated + */ + public void generateOrUpdateSelfReference(String baseUrl, boolean createHtmlReference) { + + if (baseUrl == null || baseUrl.isBlank()) { + return; + } + + String trackingId = getDocumentTrackingId(); + String referenceUrl = calculateReferenceUrl(baseUrl, trackingId); + this.addOrUpdateDocumentReferencesNode("JSON URL generated by system", referenceUrl); + if (createHtmlReference) { + String htmlReferenceUrl = calculateHtmlReferenceUrl(baseUrl, trackingId); + this.addOrUpdateDocumentReferencesNode("HTML URL generated by system", htmlReferenceUrl); + } + } + + /** + * Add or update a node in the document references with category 'self'. + * If a 'self' reference entry with the same summary already exists, its url is updated. + * Otherwise, a new entry is appended. * @param summary summary of the node * @param url url of the node * @return this wrapper */ - public AdvisoryWrapper addDocumentReferencesNode(String summary, String url) { + private AdvisoryWrapper addOrUpdateDocumentReferencesNode(String summary, String url) { ObjectNode documentNode = getOrCreateObjectNode(this.advisoryNode, List.of("csaf", "document")); ArrayNode referencesNode = (ArrayNode) documentNode.get("references"); @@ -885,9 +908,21 @@ public AdvisoryWrapper addDocumentReferencesNode(String summary, String url) { documentNode.set("references", referencesNode); } + // either update existing ref node + for (JsonNode existingEntry : referencesNode) { + JsonNode categoryNode = existingEntry.at("/category"); + JsonNode summaryNode = existingEntry.at("/summary"); + if (existingEntry.isObject() + && !categoryNode.isMissingNode() && "self".equals(categoryNode.asString()) + && !summaryNode.isMissingNode() && summary.equals(summaryNode.asString())) { + ((ObjectNode) existingEntry).put("url", url); + return this; + } + } + + // ... or add new ref node ObjectNode entry = referencesNode.addObject(); entry.put("category", "self"); - entry.put("summary", summary); entry.put("url", url); return this; @@ -901,14 +936,12 @@ public void setTemporaryTrackingId(String trackingidCompany, String trackingidDi } /** - * Set the final tracking id in the advisory and a DocumentReferencesNode with the url of the tracking id. - * @param baseUrl the configured base url + * Set the final tracking id in the advisory. * @param trackingIdCompany the configured company for the name of the tracking id * @param trackingIdDigits the count of leading zeros to which the sequentialNumber is filled with * @param sequentialNumber the next sequentialNumber - * @param createHtmlReference when {@code true}, an additional DocumentReferencesNode with the HTML url of the tracking id is created */ - public void setFinalTrackingIdAndUrl(String baseUrl, String trackingIdCompany, String trackingIdDigits, long sequentialNumber, boolean createHtmlReference) { + public void setFinalTrackingId(String trackingIdCompany, String trackingIdDigits, long sequentialNumber) { setTempTrackingIdInMeta(getDocumentTrackingId()); @@ -917,15 +950,6 @@ public void setFinalTrackingIdAndUrl(String baseUrl, String trackingIdCompany, S int year = calculatePublishYear(); String trackingId = companyName + "-" + year + "-" + formatted; setDocumentTrackingId(trackingId); - - if (baseUrl != null && !baseUrl.isBlank()) { - String referenceUrl = calculateReferenceUrl(baseUrl, trackingId); - this.addDocumentReferencesNode("JSON URL generated by system", referenceUrl); - if (createHtmlReference) { - String htmlReferenceUrl = calculateHtmlReferenceUrl(baseUrl, trackingId); - this.addDocumentReferencesNode("HTML URL generated by system", htmlReferenceUrl); - } - } } /** diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/model/SelfRefRegenerationMode.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/model/SelfRefRegenerationMode.java new file mode 100644 index 00000000..5740ea74 --- /dev/null +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/model/SelfRefRegenerationMode.java @@ -0,0 +1,11 @@ +package de.bsi.secvisogram.csaf_cms_backend.model; + +/** + * Controls when the auto-generated self-reference (document/references, category "self") + * is created or updated. + */ +public enum SelfRefRegenerationMode { + ALWAYS, + INITIAL, + NEVER +} diff --git a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryService.java b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryService.java index 9e9f709a..6542404f 100644 --- a/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryService.java +++ b/src/main/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisoryService.java @@ -98,21 +98,18 @@ public class AdvisoryService { private TrackingIdAssignmentPhase trackingIdAssignmentPhase; + @Value("${csaf.references.regeneration}") + private String selfRefRegenerationModeValue; + + private SelfRefRegenerationMode selfRefRegenerationMode; + @Autowired private CsafConfiguration configuration; @PostConstruct void validateTrackingIdAssignmentPhase() { - try { - this.trackingIdAssignmentPhase = TrackingIdAssignmentPhase.valueOf( - this.trackingIdAssignmentPhaseValue.trim().toUpperCase(Locale.ROOT)); - } catch (IllegalArgumentException ex) { - LOG.error("Invalid value '{}' for property csaf.trackingid.assignment.phase. " - + "Allowed values are: {}. Falling back to default value '{}'.", - this.trackingIdAssignmentPhaseValue, Arrays.toString(TrackingIdAssignmentPhase.values()), - TrackingIdAssignmentPhase.RELEASE); - this.trackingIdAssignmentPhase = TrackingIdAssignmentPhase.RELEASE; - } + this.trackingIdAssignmentPhase = parseEnumProperty("csaf.trackingid.assignment.phase", + this.trackingIdAssignmentPhaseValue, TrackingIdAssignmentPhase.class, TrackingIdAssignmentPhase.RELEASE); } /** @@ -124,6 +121,41 @@ public TrackingIdAssignmentPhase getTrackingIdAssignmentPhase() { return this.trackingIdAssignmentPhase; } + @PostConstruct + void validateSelfReferenceRegenerationMode() { + this.selfRefRegenerationMode = parseEnumProperty("csaf.references.regeneration", + this.selfRefRegenerationModeValue, SelfRefRegenerationMode.class, SelfRefRegenerationMode.INITIAL); + } + + /** + * Parse a String-valued configuration property into an enum constant, falling back to a default value if the + * property does not match any constant. + * + * @param propertyName the name of the configuration property, for logging + * @param rawValue the raw, unparsed property value + * @param enumType the enum type to parse into + * @param fallback the value to fall back to + * @return the parsed enum constant, or {@code fallback} if the raw value is invalid + */ + private static > E parseEnumProperty(String propertyName, String rawValue, Class enumType, E fallback) { + try { + return Enum.valueOf(enumType, rawValue.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ex) { + LOG.error("Invalid value '{}' for property {}. Allowed values are: {}. Falling back to default value '{}'.", + rawValue, propertyName, Arrays.toString(enumType.getEnumConstants()), fallback); + return fallback; + } + } + + /** + * get the self-reference regeneration mode + * + * @return the self-reference regeneration mode + */ + public SelfRefRegenerationMode getSelfRefRegenerationMode() { + return this.selfRefRegenerationMode; + } + @Autowired private BuildProperties buildProperties; @@ -253,7 +285,7 @@ IdAndRevision addAdvisoryForCredentials(CreateAdvisoryRequest newCsafJson, Authe } if (this.trackingIdAssignmentPhase == TrackingIdAssignmentPhase.DRAFT) { - setFinalTrackingIdAndUrl(newAdvisoryNode); + setFinalTrackingId(newAdvisoryNode); } else { addTemporaryTrackingId(newAdvisoryNode); } @@ -520,6 +552,12 @@ public String updateAdvisory(String advisoryId, String revision, CreateAdvisoryR newAdvisoryNode.editLastRevisionHistoryElement(changedCsafJson, timestampNow); } + // goal: always but avoid generating for non-published documents. + if (this.selfRefRegenerationMode == SelfRefRegenerationMode.ALWAYS + && oldAdvisoryNode.getLastMajorVersion() >= 1) { + regenerateSelfReference(newAdvisoryNode); + } + String result = this.couchDbService.updateDocument(newAdvisoryNode.advisoryAsString()); AuditTrailWrapper auditTrail = AdvisoryAuditTrailDiffWrapper.createNewFromAdvisories(oldAdvisoryNode, newAdvisoryNode) @@ -534,6 +572,24 @@ public String updateAdvisory(String advisoryId, String revision, CreateAdvisoryR } } + private boolean isCreateHtmlReference() { + return this.configuration.getWorkflow() != null && this.configuration.getWorkflow().isCreateHtmlReference(); + } + + private void regenerateSelfReference(AdvisoryWrapper advisoryNode) { + advisoryNode.generateOrUpdateSelfReference(this.referencesBaseUrl, isCreateHtmlReference()); + } + + private AdvisoryWrapper finalizePublication(AdvisoryWrapper advisoryNode, String proposedTime) throws CsafException, IOException { + AdvisoryWrapper releaseReadyNode = createReleaseReadyAdvisoryAndValidate(advisoryNode, proposedTime); + setFinalTrackingId(releaseReadyNode); + if (this.selfRefRegenerationMode == SelfRefRegenerationMode.ALWAYS + || (this.selfRefRegenerationMode == SelfRefRegenerationMode.INITIAL && releaseReadyNode.getLastMajorVersion() < 1)) { + regenerateSelfReference(releaseReadyNode); + } + return releaseReadyNode; + } + /** * Manually assign the final tracking id for an advisory, if none has been assigned yet. * @@ -564,7 +620,7 @@ public String assignTrackingId(String advisoryId, String revision) throws IOExce } AdvisoryWrapper oldAdvisoryNode = AdvisoryWrapper.createCopy(existingAdvisoryNode); - setFinalTrackingIdAndUrl(existingAdvisoryNode); + setFinalTrackingId(existingAdvisoryNode); existingAdvisoryNode.setRevision(revision); String newRevision = this.couchDbService.updateDocument(existingAdvisoryNode.advisoryAsString()); @@ -739,7 +795,7 @@ public String changeAdvisoryWorkflowState(String advisoryId, String revision, Wo if (newWorkflowState == WorkflowState.Review && this.trackingIdAssignmentPhase == TrackingIdAssignmentPhase.REVIEW) { - setFinalTrackingIdAndUrl(existingAdvisoryNode); + setFinalTrackingId(existingAdvisoryNode); } if (newWorkflowState == WorkflowState.RfPublication) { @@ -765,14 +821,12 @@ public String changeAdvisoryWorkflowState(String advisoryId, String revision, Wo } //TODO: Check, if further checks for upload are needed - existingAdvisoryNode = createReleaseReadyAdvisoryAndValidate(existingAdvisoryNode, proposedTime); - setFinalTrackingIdAndUrl(existingAdvisoryNode); + existingAdvisoryNode = finalizePublication(existingAdvisoryNode, proposedTime); } - + if (newWorkflowState == WorkflowState.Published && (previousWorkflowState != WorkflowState.AutoPublish)) { - - existingAdvisoryNode = createReleaseReadyAdvisoryAndValidate(existingAdvisoryNode, proposedTime); - setFinalTrackingIdAndUrl(existingAdvisoryNode); + + existingAdvisoryNode = finalizePublication(existingAdvisoryNode, proposedTime); } AuditTrailWrapper auditTrail = AdvisoryAuditTrailWorkflowWrapper.createNewFrom(newWorkflowState, previousWorkflowState) @@ -791,21 +845,19 @@ public String changeAdvisoryWorkflowState(String advisoryId, String revision, Wo } /** - * Set the final tracking id in the advisory and a DocumentReferencesNode with the url of the tracking id + * Set the final tracking id in the advisory, unless a final tracking id is already assigned. * * @param advisoryNode the node to set the tracking id * @throws CsafException error creating counter */ - void setFinalTrackingIdAndUrl(AdvisoryWrapper advisoryNode) throws CsafException { + void setFinalTrackingId(AdvisoryWrapper advisoryNode) throws CsafException { if (advisoryNode.isFinalTrackingIdAssigned()) { return; } final long sequentialNumber = getNewTrackingIdCounter(TrackingIdCounter.FINAL_OBJECT_ID); - final boolean createHtmlReference = this.configuration.getWorkflow() != null - && this.configuration.getWorkflow().isCreateHtmlReference(); - advisoryNode.setFinalTrackingIdAndUrl(this.referencesBaseUrl, this.trackingidCompany, this.trackingidDigits, sequentialNumber, createHtmlReference); + advisoryNode.setFinalTrackingId(this.trackingidCompany, this.trackingidDigits, sequentialNumber); } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 2e5706b9..28bc6cfd 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -53,6 +53,11 @@ csaf.trackingid.digits=${CSAF_TRACKINGID_DIGITS:} # Point in the workflow status at which the final tracking id (instead of a temporary -TEMP- id) is assigned. # One of: draft, review, release (default). csaf.trackingid.assignment.phase=${CSAF_TRACKINGID_ASSIGNMENT_PHASE:release} +# Controls when the auto-generated self-reference (document/references, category "self") is (re)created. +# - always: generated at publish time and then being kept updated based on the value of its components (TLP) +# - initial (default): generated once, at the first publish, and never updated again. +# - never: never auto-generated. +csaf.references.regeneration=${CSAF_REFERENCES_REGENERATION:initial} csaf.autoPublish.enabled=${CSAF_AUTOPUBLISH_ENABLED:true} diff --git a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/json/AdvisoryWrapperTest.java b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/json/AdvisoryWrapperTest.java index 63ca1cc9..2f40dfce 100644 --- a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/json/AdvisoryWrapperTest.java +++ b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/json/AdvisoryWrapperTest.java @@ -596,21 +596,57 @@ public void setFinalTrackingIdTest() throws IOException, CsafException { AdvisoryWrapper advisory = AdvisoryWrapper.createNewFromCsaf(csafToRequest(csafJsonWithReleaseDate), "Mustermann", Semantic.name()); advisory.setTemporaryTrackingId("tempExamle", "7", 123L); - advisory.setFinalTrackingIdAndUrl("https://example.com", "example", "5", 158L, true); + advisory.setFinalTrackingId("example", "5", 158L); long year = ZonedDateTime.now().getYear(); assertEquals("example-" + year + "-00158", advisory.getDocumentTrackingId()); + assertEquals("tempExamle-TEMP-0000123", advisory.getTempTrackingIdInFromMeta()); + } + + @Test + @SuppressFBWarnings(value = "CE_CLASS_ENVY", justification = "Only for Test") + public void generateOrUpdateSelfReferenceTest_regeneratesOnTlpChange() throws IOException, CsafException { + + var csafJsonWithReleaseDate = """ + { "document": { + "distribution": { + "tlp": { + "label": "WHITE" + } + }, + "publisher": { + "name": "Red flag company" + } + } + }"""; + + AdvisoryWrapper advisory = AdvisoryWrapper.createNewFromCsaf(csafToRequest(csafJsonWithReleaseDate), "Mustermann", Semantic.name()); + advisory.setTemporaryTrackingId("tempExample", "7", 123L); + advisory.setFinalTrackingId("example", "5", 158L); + advisory.generateOrUpdateSelfReference("https://example.com", true); + long year = ZonedDateTime.now().getYear(); assertEquals("https://example.com/white/" + year + "/example-" + year + "-00158.json", advisory.at("/csaf/document/references/0/url").asString()); + + // calling again unchanged is idempotent: same single entry, same position + advisory.generateOrUpdateSelfReference("https://example.com", true); + assertEquals("https://example.com/white/" + year + "/example-" + year + "-00158.json", advisory.at("/csaf/document/references/0/url").asString()); + assertEquals("https://example.com/white/" + year + "/example-" + year + "-00158.html", advisory.at("/csaf/document/references/1/url").asString()); + assertTrue(advisory.at("/csaf/document/references/2").isMissingNode()); + + // TLP change is picked up in place, without creating a duplicate entry + ((ObjectNode) advisory.at("/csaf/document/distribution/tlp")).put("label", "AMBER"); + advisory.generateOrUpdateSelfReference("https://example.com", true); + assertEquals("https://example.com/amber/" + year + "/example-" + year + "-00158.json", advisory.at("/csaf/document/references/0/url").asString()); assertEquals("JSON URL generated by system", advisory.at("/csaf/document/references/0/summary").asString()); assertEquals("self", advisory.at("/csaf/document/references/0/category").asString()); - assertEquals("https://example.com/white/" + year + "/example-" + year + "-00158.html", advisory.at("/csaf/document/references/1/url").asString()); + assertEquals("https://example.com/amber/" + year + "/example-" + year + "-00158.html", advisory.at("/csaf/document/references/1/url").asString()); assertEquals("HTML URL generated by system", advisory.at("/csaf/document/references/1/summary").asString()); assertEquals("self", advisory.at("/csaf/document/references/1/category").asString()); - assertEquals("tempExamle-TEMP-0000123", advisory.getTempTrackingIdInFromMeta()); + assertTrue(advisory.at("/csaf/document/references/2").isMissingNode()); } @Test @SuppressFBWarnings(value = "CE_CLASS_ENVY", justification = "Only for Test") - public void setFinalTrackingIdTest_noHtmlReference() throws IOException, CsafException { + public void generateOrUpdateSelfReferenceTest_noHtmlReference() throws IOException, CsafException { var csafJsonWithReleaseDate = """ { "document": { @@ -622,7 +658,8 @@ public void setFinalTrackingIdTest_noHtmlReference() throws IOException, CsafExc AdvisoryWrapper advisory = AdvisoryWrapper.createNewFromCsaf(csafToRequest(csafJsonWithReleaseDate), "Mustermann", Semantic.name()); advisory.setTemporaryTrackingId("tempExamle", "7", 123L); - advisory.setFinalTrackingIdAndUrl("https://example.com", "example", "5", 158L, false); + advisory.setFinalTrackingId("example", "5", 158L); + advisory.generateOrUpdateSelfReference("https://example.com", false); long year = ZonedDateTime.now().getYear(); assertEquals("example-" + year + "-00158", advisory.getDocumentTrackingId()); assertEquals("https://example.com/white/" + year + "/example-" + year + "-00158.json", advisory.at("/csaf/document/references/0/url").asString()); @@ -635,7 +672,7 @@ public void setFinalTrackingIdTest_noHtmlReference() throws IOException, CsafExc @Test @SuppressFBWarnings(value = "CE_CLASS_ENVY", justification = "Only for Test") - public void setFinalTrackingIdTest_label_not_null() throws IOException, CsafException { + public void generateOrUpdateSelfReferenceTest_label_not_null() throws IOException, CsafException { final String TLP_LABEL = "AMBER"; @@ -654,7 +691,8 @@ public void setFinalTrackingIdTest_label_not_null() throws IOException, CsafExce AdvisoryWrapper advisory = AdvisoryWrapper.createNewFromCsaf(csafToRequest(csafJsonWithReleaseDate), "Mustermann", Semantic.name()); advisory.setTemporaryTrackingId("tempExamle", "7", 123L); - advisory.setFinalTrackingIdAndUrl("https://example.com", "example", "5", 158L, true); + advisory.setFinalTrackingId("example", "5", 158L); + advisory.generateOrUpdateSelfReference("https://example.com", true); long year = ZonedDateTime.now().getYear(); assertEquals("example-" + year + "-00158", advisory.getDocumentTrackingId()); assertEquals("https://example.com/" + TLP_LABEL.toLowerCase() + "/" + year + "/example-" + year + "-00158.json", advisory.at("/csaf/document/references/0/url").asString()); diff --git a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisorySelfReferenceAlwaysModeTest.java b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisorySelfReferenceAlwaysModeTest.java new file mode 100644 index 00000000..66e55f37 --- /dev/null +++ b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisorySelfReferenceAlwaysModeTest.java @@ -0,0 +1,101 @@ +package de.bsi.secvisogram.csaf_cms_backend.service; + +import static de.bsi.secvisogram.csaf_cms_backend.fixture.CsafDocumentJsonCreator.csafToRequest; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.mockito.ArgumentMatchers.any; + +import de.bsi.secvisogram.csaf_cms_backend.CouchDBExtension; +import de.bsi.secvisogram.csaf_cms_backend.config.CsafRoles; +import de.bsi.secvisogram.csaf_cms_backend.couchdb.DatabaseException; +import de.bsi.secvisogram.csaf_cms_backend.exception.CsafException; +import de.bsi.secvisogram.csaf_cms_backend.model.DocumentTrackingStatus; +import de.bsi.secvisogram.csaf_cms_backend.model.WorkflowState; +import de.bsi.secvisogram.csaf_cms_backend.rest.request.CreateAdvisoryRequest; +import de.bsi.secvisogram.csaf_cms_backend.rest.response.AdvisoryResponse; +import de.bsi.secvisogram.csaf_cms_backend.validator.ValidatorServiceClient; +import java.io.IOException; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import tools.jackson.databind.node.ObjectNode; + +/** + * Verifies that with {@code csaf.references.regeneration=always}, no self-reference exists before an + * advisory is actually published, and that once one exists it is kept in sync with the document's TLP + * label and real publication year on every subsequent update/publish. See issue #230. + */ +@SpringBootTest(properties = { + "csaf.references.baseURL=https://example.com", + "csaf.references.regeneration=always", + "csaf.trackingid.company=Testcase", + "csaf.trackingid.digits=5", + "csaf.trackingid.assignment.phase=draft", + "csaf.workflow.allowOwnDocumentsApproved=true", +}) +@ExtendWith(CouchDBExtension.class) +@DirtiesContext +@SpringJUnitConfig +public class AdvisorySelfReferenceAlwaysModeTest { + + @Autowired + private AdvisoryService advisoryService; + + private static final String csafJsonWhite = """ + { + "document": { + "category": "CSAF_BASE", + "distribution": { + "tlp": { + "label": "WHITE" + } + } + } + }"""; + + @Test + @WithMockUser(username = "editor1", authorities = {CsafRoles.ROLE_AUTHOR, CsafRoles.ROLE_EDITOR, CsafRoles.ROLE_REVIEWER, CsafRoles.ROLE_PUBLISHER}) + public void updateAdvisory_regeneratesSelfReferenceOnTlpChangeAfterPublish() throws IOException, DatabaseException, CsafException { + + try (final MockedStatic validatorMock = Mockito.mockStatic(ValidatorServiceClient.class)) { + validatorMock.when(() -> ValidatorServiceClient.isAdvisoryValid(any(), any())).thenReturn(Boolean.TRUE); + + IdAndRevision idRev = advisoryService.addAdvisory(csafToRequest(csafJsonWhite)); + AdvisoryResponse created = advisoryService.getAdvisory(idRev.getId()); + assertThat(created.getCsaf().at("/document/references/0").isMissingNode(), is(true)); + + String revision = advisoryService.changeAdvisoryWorkflowState(idRev.getId(), idRev.getRevision(), WorkflowState.Review, null, null); + revision = advisoryService.changeAdvisoryWorkflowState(idRev.getId(), revision, WorkflowState.Approved, null, null); + revision = advisoryService.changeAdvisoryWorkflowState(idRev.getId(), revision, WorkflowState.RfPublication, null, null); + revision = advisoryService.changeAdvisoryWorkflowState(idRev.getId(), revision, WorkflowState.AutoPublish, null, DocumentTrackingStatus.Interim); + revision = advisoryService.changeAdvisoryWorkflowState(idRev.getId(), revision, WorkflowState.Published, null, null); + + AdvisoryResponse published = advisoryService.getAdvisory(idRev.getId()); + assertThat(published.getCsaf().at("/document/references/0/url").asString(), containsString("/white/")); + + // start a new draft version of the already-published advisory and change its TLP + revision = advisoryService.createNewCsafDocumentVersion(idRev.getId(), revision); + AdvisoryResponse draftV2 = advisoryService.getAdvisory(idRev.getId()); + ObjectNode changedCsaf = (ObjectNode) draftV2.getCsaf(); + ((ObjectNode) changedCsaf.at("/document/distribution/tlp")).put("label", "AMBER"); + CreateAdvisoryRequest request = new CreateAdvisoryRequest().setSummary("Changed TLP").setCsaf(changedCsaf); + advisoryService.updateAdvisory(idRev.getId(), revision, request); + + AdvisoryResponse updated = advisoryService.getAdvisory(idRev.getId()); + assertThat(updated.getCsaf().at("/document/references/0/url").asString(), containsString("/amber/")); + assertThat(updated.getCsaf().at("/document/references/0/url").asString(), not(containsString("/white/"))); + // regeneration replaces the existing entry in place, it does not duplicate it + assertThat(updated.getCsaf().at("/document/references/1").isMissingNode(), is(true)); + } + } + +} diff --git a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisorySelfReferenceInitialModeTest.java b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisorySelfReferenceInitialModeTest.java new file mode 100644 index 00000000..fa5ede96 --- /dev/null +++ b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisorySelfReferenceInitialModeTest.java @@ -0,0 +1,110 @@ +package de.bsi.secvisogram.csaf_cms_backend.service; + +import static de.bsi.secvisogram.csaf_cms_backend.fixture.CsafDocumentJsonCreator.csafToRequest; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.mockito.ArgumentMatchers.any; + +import de.bsi.secvisogram.csaf_cms_backend.CouchDBExtension; +import de.bsi.secvisogram.csaf_cms_backend.config.CsafRoles; +import de.bsi.secvisogram.csaf_cms_backend.couchdb.DatabaseException; +import de.bsi.secvisogram.csaf_cms_backend.exception.CsafException; +import de.bsi.secvisogram.csaf_cms_backend.model.DocumentTrackingStatus; +import de.bsi.secvisogram.csaf_cms_backend.model.WorkflowState; +import de.bsi.secvisogram.csaf_cms_backend.rest.request.CreateAdvisoryRequest; +import de.bsi.secvisogram.csaf_cms_backend.rest.response.AdvisoryResponse; +import de.bsi.secvisogram.csaf_cms_backend.validator.ValidatorServiceClient; +import java.io.IOException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import tools.jackson.databind.node.ObjectNode; + +/** + * Verifies that with the default {@code csaf.references.regeneration=initial}, the self-reference is + * generated once - at the advisory's first actual publish, never before - and left untouched by any + * later TLP change, preserving the historic behavior. See issue #230. + */ +@SpringBootTest(properties = { + "csaf.references.baseURL=https://example.com", + "csaf.trackingid.company=Testcase", + "csaf.trackingid.digits=5", + "csaf.trackingid.assignment.phase=draft", + "csaf.workflow.allowOwnDocumentsApproved=true", +}) +@ExtendWith(CouchDBExtension.class) +@DirtiesContext +@SpringJUnitConfig +public class AdvisorySelfReferenceInitialModeTest { + + @Autowired + private AdvisoryService advisoryService; + + private static final String csafJsonWhite = """ + { + "document": { + "category": "CSAF_BASE", + "distribution": { + "tlp": { + "label": "WHITE" + } + } + } + }"""; + + @Test + @WithMockUser(username = "editor1", authorities = {CsafRoles.ROLE_AUTHOR, CsafRoles.ROLE_EDITOR, CsafRoles.ROLE_REVIEWER, CsafRoles.ROLE_PUBLISHER}) + public void selfReference_generatedOnceAtFirstPublishAndUnaffectedByLaterTlpChange() throws IOException, DatabaseException, CsafException { + + try (final MockedStatic validatorMock = Mockito.mockStatic(ValidatorServiceClient.class)) { + validatorMock.when(() -> ValidatorServiceClient.isAdvisoryValid(any(), any())).thenReturn(Boolean.TRUE); + + IdAndRevision idRev = advisoryService.addAdvisory(csafToRequest(csafJsonWhite)); + AdvisoryResponse created = advisoryService.getAdvisory(idRev.getId()); + assertThat(created.getCsaf().at("/document/references/0").isMissingNode(), is(true)); + + String revision = advisoryService.changeAdvisoryWorkflowState(idRev.getId(), idRev.getRevision(), WorkflowState.Review, null, null); + revision = advisoryService.changeAdvisoryWorkflowState(idRev.getId(), revision, WorkflowState.Approved, null, null); + revision = advisoryService.changeAdvisoryWorkflowState(idRev.getId(), revision, WorkflowState.RfPublication, null, null); + revision = advisoryService.changeAdvisoryWorkflowState(idRev.getId(), revision, WorkflowState.AutoPublish, null, DocumentTrackingStatus.Interim); + revision = advisoryService.changeAdvisoryWorkflowState(idRev.getId(), revision, WorkflowState.Published, null, null); + + AdvisoryResponse published = advisoryService.getAdvisory(idRev.getId()); + String originalSelfReferenceUrl = published.getCsaf().at("/document/references/0/url").asString(); + assertThat(originalSelfReferenceUrl, containsString("/white/")); + + // start a new draft version of the already-published advisory and change its TLP + revision = advisoryService.createNewCsafDocumentVersion(idRev.getId(), revision); + AdvisoryResponse draftV2 = advisoryService.getAdvisory(idRev.getId()); + ObjectNode changedCsaf = (ObjectNode) draftV2.getCsaf(); + ((ObjectNode) changedCsaf.at("/document/distribution/tlp")).put("label", "AMBER"); + CreateAdvisoryRequest request = new CreateAdvisoryRequest().setSummary("Changed TLP").setCsaf(changedCsaf); + revision = advisoryService.updateAdvisory(idRev.getId(), revision, request); + + AdvisoryResponse updated = advisoryService.getAdvisory(idRev.getId()); + // initial mode: the self-reference was generated once, at the first publish, and is never + // touched again - not even by a later TLP change on a subsequent version. + assertThat(updated.getCsaf().at("/document/references/0/url").asString(), equalTo(originalSelfReferenceUrl)); + + // now actually publish the second version (with the changed TLP) as well + revision = advisoryService.changeAdvisoryWorkflowState(idRev.getId(), revision, WorkflowState.Review, null, null); + revision = advisoryService.changeAdvisoryWorkflowState(idRev.getId(), revision, WorkflowState.Approved, null, null); + revision = advisoryService.changeAdvisoryWorkflowState(idRev.getId(), revision, WorkflowState.RfPublication, null, null); + revision = advisoryService.changeAdvisoryWorkflowState(idRev.getId(), revision, WorkflowState.AutoPublish, null, DocumentTrackingStatus.Interim); + advisoryService.changeAdvisoryWorkflowState(idRev.getId(), revision, WorkflowState.Published, null, null); + + AdvisoryResponse republished = advisoryService.getAdvisory(idRev.getId()); + // initial mode: even a second full publish must not touch the self-reference generated at + // the first publish. + assertThat(republished.getCsaf().at("/document/references/0/url").asString(), equalTo(originalSelfReferenceUrl)); + } + } +} diff --git a/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisorySelfReferenceNeverModeTest.java b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisorySelfReferenceNeverModeTest.java new file mode 100644 index 00000000..f1413132 --- /dev/null +++ b/src/test/java/de/bsi/secvisogram/csaf_cms_backend/service/AdvisorySelfReferenceNeverModeTest.java @@ -0,0 +1,107 @@ +package de.bsi.secvisogram.csaf_cms_backend.service; + +import static de.bsi.secvisogram.csaf_cms_backend.fixture.CsafDocumentJsonCreator.csafToRequest; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.mockito.ArgumentMatchers.any; + +import de.bsi.secvisogram.csaf_cms_backend.CouchDBExtension; +import de.bsi.secvisogram.csaf_cms_backend.config.CsafRoles; +import de.bsi.secvisogram.csaf_cms_backend.couchdb.DatabaseException; +import de.bsi.secvisogram.csaf_cms_backend.exception.CsafException; +import de.bsi.secvisogram.csaf_cms_backend.model.DocumentTrackingStatus; +import de.bsi.secvisogram.csaf_cms_backend.model.WorkflowState; +import de.bsi.secvisogram.csaf_cms_backend.rest.request.CreateAdvisoryRequest; +import de.bsi.secvisogram.csaf_cms_backend.rest.response.AdvisoryResponse; +import de.bsi.secvisogram.csaf_cms_backend.validator.ValidatorServiceClient; +import java.io.IOException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import tools.jackson.databind.node.ObjectNode; + +/** + * Verifies that with {@code csaf.references.regeneration=never}, the self-reference is never + * auto-generated at all - not at publish, and not on any later update - leaving + * {@code document/references} entirely to the user. See issue #230. + */ +@SpringBootTest(properties = { + "csaf.references.baseURL=https://example.com", + "csaf.references.regeneration=never", + "csaf.trackingid.company=Testcase", + "csaf.trackingid.digits=5", + "csaf.trackingid.assignment.phase=draft", + "csaf.workflow.allowOwnDocumentsApproved=true", +}) +@ExtendWith(CouchDBExtension.class) +@DirtiesContext +@SpringJUnitConfig +public class AdvisorySelfReferenceNeverModeTest { + + @Autowired + private AdvisoryService advisoryService; + + private static final String csafJsonWhite = """ + { + "document": { + "category": "CSAF_BASE", + "distribution": { + "tlp": { + "label": "WHITE" + } + } + } + }"""; + + @Test + @WithMockUser(username = "editor1", authorities = {CsafRoles.ROLE_AUTHOR, CsafRoles.ROLE_EDITOR, CsafRoles.ROLE_REVIEWER, CsafRoles.ROLE_PUBLISHER}) + public void selfReference_neverGeneratedAtPublishOrOnLaterUpdate() throws IOException, DatabaseException, CsafException { + + try (final MockedStatic validatorMock = Mockito.mockStatic(ValidatorServiceClient.class)) { + validatorMock.when(() -> ValidatorServiceClient.isAdvisoryValid(any(), any())).thenReturn(Boolean.TRUE); + + IdAndRevision idRev = advisoryService.addAdvisory(csafToRequest(csafJsonWhite)); + AdvisoryResponse created = advisoryService.getAdvisory(idRev.getId()); + assertThat(created.getCsaf().at("/document/references/0").isMissingNode(), is(true)); + + String revision = advisoryService.changeAdvisoryWorkflowState(idRev.getId(), idRev.getRevision(), WorkflowState.Review, null, null); + revision = advisoryService.changeAdvisoryWorkflowState(idRev.getId(), revision, WorkflowState.Approved, null, null); + revision = advisoryService.changeAdvisoryWorkflowState(idRev.getId(), revision, WorkflowState.RfPublication, null, null); + revision = advisoryService.changeAdvisoryWorkflowState(idRev.getId(), revision, WorkflowState.AutoPublish, null, DocumentTrackingStatus.Interim); + revision = advisoryService.changeAdvisoryWorkflowState(idRev.getId(), revision, WorkflowState.Published, null, null); + + AdvisoryResponse published = advisoryService.getAdvisory(idRev.getId()); + // never mode: no self-reference is generated even once the advisory is actually published + assertThat(published.getCsaf().at("/document/references/0").isMissingNode(), is(true)); + + // start a new draft version of the already-published advisory and change its TLP + revision = advisoryService.createNewCsafDocumentVersion(idRev.getId(), revision); + AdvisoryResponse draftV2 = advisoryService.getAdvisory(idRev.getId()); + ObjectNode changedCsaf = (ObjectNode) draftV2.getCsaf(); + ((ObjectNode) changedCsaf.at("/document/distribution/tlp")).put("label", "AMBER"); + CreateAdvisoryRequest request = new CreateAdvisoryRequest().setSummary("Changed TLP").setCsaf(changedCsaf); + revision = advisoryService.updateAdvisory(idRev.getId(), revision, request); + + AdvisoryResponse updated = advisoryService.getAdvisory(idRev.getId()); + // never mode: an update on an already-published advisory still does not generate one + assertThat(updated.getCsaf().at("/document/references/0").isMissingNode(), is(true)); + + // publish the second version too + revision = advisoryService.changeAdvisoryWorkflowState(idRev.getId(), revision, WorkflowState.Review, null, null); + revision = advisoryService.changeAdvisoryWorkflowState(idRev.getId(), revision, WorkflowState.Approved, null, null); + revision = advisoryService.changeAdvisoryWorkflowState(idRev.getId(), revision, WorkflowState.RfPublication, null, null); + revision = advisoryService.changeAdvisoryWorkflowState(idRev.getId(), revision, WorkflowState.AutoPublish, null, DocumentTrackingStatus.Interim); + advisoryService.changeAdvisoryWorkflowState(idRev.getId(), revision, WorkflowState.Published, null, null); + + AdvisoryResponse republished = advisoryService.getAdvisory(idRev.getId()); + // never mode: still nothing, even after a second full publish + assertThat(republished.getCsaf().at("/document/references/0").isMissingNode(), is(true)); + } + } +} diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index 1af9c887..14cac40b 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -46,3 +46,4 @@ csaf.references.baseURL=${CSAF_REFERENCES_BASE_URL:} csaf.trackingid.company=${CSAF_TRACKINGID_COMPANY:} csaf.trackingid.digits=${CSAF_TRACKINGID_DIGITS:} csaf.trackingid.assignment.phase=${CSAF_TRACKINGID_ASSIGNMENT_PHASE:release} +csaf.references.regeneration=${CSAF_REFERENCES_REGENERATION:initial}