Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Version 0.0.3 - 2026-01-19

### Added

- Auto-populate userInitiatorId from the context

## Version 0.0.2 - 2025-10-30

### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
import com.sap.cds.services.auditlog.KeyValuePair;
import com.sap.cds.services.auditlog.SecurityLog;
import com.sap.cds.services.auditlog.SecurityLogContext;
import com.sap.cds.services.environment.CdsProperties.Security.Mock.User;
import com.sap.cds.services.handler.EventHandler;
import com.sap.cds.services.handler.annotations.On;
import com.sap.cds.services.handler.annotations.ServiceName;
Expand Down Expand Up @@ -105,7 +106,7 @@ private ArrayNode createGeneralEvent(EventContext context) throws JsonProcessing
String eventJson = (String) data.get("event");

ObjectNode eventEnvelope = buildEventEnvelope(OBJECT_MAPPER, eventType, userInfo);
ObjectNode metadata = buildEventMetadata();
ObjectNode metadata = buildEventMetadata(userInfo);
ObjectNode parsedEventNode = (ObjectNode) OBJECT_MAPPER.readTree(eventJson);
ObjectNode wrappedDataNode = OBJECT_MAPPER.createObjectNode();
wrappedDataNode.set(eventType, parsedEventNode);
Expand Down Expand Up @@ -145,7 +146,7 @@ private ArrayNode createSecurityEvent(SecurityLogContext context) {
SecurityLog data = requireNonNull(context.getData(), "SecurityLogContext.getData() is null");
UserInfo userInfo = requireNonNull(context.getUserInfo(), "SecurityLogContext.getUserInfo() is null");
ObjectNode alsEvent = buildEventEnvelope(OBJECT_MAPPER, LEGACY_SECURITY_WRAPPER, userInfo);
ObjectNode metadata = buildEventMetadata();
ObjectNode metadata = buildEventMetadata(userInfo);
ObjectNode origEvent = createLegacySecurityOrigEvent(userInfo, data);
ObjectNode legacySecurityWrapper = OBJECT_MAPPER.createObjectNode();
try {
Expand Down Expand Up @@ -180,7 +181,7 @@ private ObjectNode createLegacySecurityOrigEvent(UserInfo userInfo, SecurityLog
String formattedData = "action: %s, data: %s".formatted(data.getAction(), data.getData());
formattedData = formattedData.replace("\r\n", "\\n").replace("\n", "\\n");
setFieldIfNotNull(envelop, "uuid", UUID.randomUUID().toString());
setFieldIfNotNull(envelop, "user", userInfo != null ? userInfo.getName() : "unknown");
setFieldIfNotNull(envelop, "user", userInfo.getName() != null ? userInfo.getName() : "unknown");
setFieldIfNotNull(envelop, "identityProvider", "$IDP");
setFieldIfNotNull(envelop, "time", Instant.now().toString());
setFieldIfNotNull(envelop, "data", formattedData != null ? formattedData : "");
Expand Down Expand Up @@ -294,7 +295,7 @@ private ArrayNode createAlsConfigChangeEvents(ConfigChangeLogContext context) {
* @return an ObjectNode representing the audit log event for the configuration change
*/
private ObjectNode buildConfigChangeEvent(UserInfo userInfo, ConfigChange configChanges, ChangedAttribute attribute) {
ObjectNode metadata = buildEventMetadata();
ObjectNode metadata = buildEventMetadata(userInfo);
ObjectNode changeNode = OBJECT_MAPPER.createObjectNode();
addValueDetails(changeNode, attribute, "propertyName");
var dataObject = requireNonNull(configChanges.getDataObject(), "ConfigChange.getDataObject() is null");
Expand Down Expand Up @@ -357,7 +358,7 @@ private ArrayNode buildAttributeBasedAlsEvents(UserInfo userInfo, Collection<Dat
*/
private ObjectNode buildDataModificationAlsEvent(UserInfo userInfo, DataModification modification, ChangedAttribute attribute) {
DataObject dataObject = requireNonNull(modification.getDataObject(), "DataModification.getDataObject() is null");
ObjectNode metadata = buildEventMetadata();
ObjectNode metadata = buildEventMetadata(userInfo);
ObjectNode dataModificationNode = buildDataModificationNode(attribute, modification.getDataSubject(), dataObject);
return buildAlsEvent("dppDataModification", userInfo, metadata, "dppDataModification", dataModificationNode);
}
Expand Down Expand Up @@ -405,7 +406,7 @@ private ObjectNode buildDataModificationNode(ChangedAttribute attribute, DataSub
private ObjectNode buildEventEnvelope(ObjectMapper mapper, String type, UserInfo userInfo) {
ObjectNode alsEvent = mapper.createObjectNode();
alsEvent.put("id", UUID.randomUUID().toString());
alsEvent.put("specversion", 1);
alsEvent.put("specversion", "1");
String tenant = (userInfo.getTenant() == null || userInfo.getTenant().isEmpty()) ? tenantService.readProviderTenant() : userInfo.getTenant();
alsEvent.put("source", String.format("/%s/%s/%s", communicator.getRegion(), communicator.getNamespace(), tenant));
alsEvent.put("type", type);
Expand All @@ -420,9 +421,10 @@ private ObjectNode buildEventEnvelope(ObjectMapper mapper, String type, UserInfo
* @param mapper the {@link ObjectMapper} used to create the ObjectNode
* @return an {@link ObjectNode} containing the event metadata
*/
private ObjectNode buildEventMetadata() {
private ObjectNode buildEventMetadata(UserInfo userInfo) {
ObjectNode metadata = OBJECT_MAPPER.createObjectNode();
metadata.put("ts", Instant.now().toString());
metadata.put("userInitiatorId", userInfo.getName() != null ? userInfo.getName() : "anonymous");
ObjectNode infraOther = metadata.putObject("infrastructure").putObject("other");
infraOther.put("runtimeType", "Java");
ObjectNode platformOther = metadata.putObject("platform").putObject("other");
Expand All @@ -441,7 +443,7 @@ private ObjectNode buildEventMetadata() {
* @return an {@link ObjectNode} representing the constructed ALS event for data access
*/
private ObjectNode buildDataAccessAlsEvent(UserInfo userInfo, Access access, String attribute, String attachmentType, String attachmentId) {
ObjectNode metadata = buildEventMetadata();
ObjectNode metadata = buildEventMetadata(userInfo);
ObjectNode dataAccessNode = buildDataAccessNode(access, attribute, attachmentType, attachmentId);
return buildAlsEvent("dppDataAccess", userInfo, metadata, "dppDataAccess", dataAccessNode);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
Expand All @@ -22,10 +21,10 @@
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.networknt.schema.JsonSchema;
import com.networknt.schema.JsonSchemaFactory;
import com.networknt.schema.SpecVersion;
import com.networknt.schema.ValidationMessage;
import com.networknt.schema.Schema;
import com.networknt.schema.SchemaRegistry;
import com.networknt.schema.SpecificationVersion;
import com.networknt.schema.Error;
import com.sap.cds.services.EventContext;
import com.sap.cds.services.auditlog.Access;
import com.sap.cds.services.auditlog.Attachment;
Expand Down Expand Up @@ -67,22 +66,14 @@ public void setUp() {
@FunctionalInterface
private interface ThrowingRunnable { void run() throws Exception; }

private void runAndAssertEvent(String schemaPath, ThrowingRunnable handlerMethod) throws Exception {
ArgumentCaptor<ArrayNode> captor = ArgumentCaptor.forClass(ArrayNode.class);
handlerMethod.run();
verify(communicator).sendBulkRequest(captor.capture());
ArrayNode actualEvents = captor.getValue();
assertJsonMatchesSchema(schemaPath, actualEvents);
}

@Test
public void testHandleSecurityEventSchemaValidation() throws Exception {
SecurityLogContext context = mock(SecurityLogContext.class);
SecurityLog securityLog = mock(SecurityLog.class);
when(context.getUserInfo()).thenReturn(userInfo);
when(context.getData()).thenReturn(securityLog);
when(securityLog.getData()).thenReturn("security event data");
runAndAssertEvent("src/test/resources/legacy-security-wrapper-schema.json", () -> handler.handleSecurityEvent(context));
runAndAssertEvent("src/test/resources/legacy-security-wrapper-schema.json", () -> handler.handleSecurityEvent(context));
}

@Test
Expand Down Expand Up @@ -114,7 +105,7 @@ public void testHandleDataAccessEvent_MultiAttrAttach_MultiAccess() throws Excep
when(dataAccessLog.getAccesses()).thenReturn(List.of(access1, access2));
when(context.getData()).thenReturn(dataAccessLog);
when(context.getUserInfo()).thenReturn(userInfo);
runAndAssertEvent("src/test/resources/dpp-data-access-schema.json", () -> handler.handleDataAccessEvent(context));
runAndAssertEvent("src/test/resources/dpp-data-access-schema.json", () -> handler.handleDataAccessEvent(context));
}

@Test
Expand All @@ -133,11 +124,7 @@ public void testHandleConfigChangeEvent_MultiConfig() throws Exception {
when(configChangeLog.getConfigurations()).thenReturn(List.of(config1, config2));
when(context.getData()).thenReturn(configChangeLog);
when(context.getUserInfo()).thenReturn(userInfo);
handler.handleConfigChangeEvent(context);
ArgumentCaptor<ArrayNode> captor = ArgumentCaptor.forClass(ArrayNode.class);
verify(communicator).sendBulkRequest(captor.capture());
ArrayNode actualEvents = captor.getValue();
assertJsonMatchesSchema("src/test/resources/configuration-change-schema.json", actualEvents);
runAndAssertEvent("src/test/resources/configuration-change-schema.json", () -> handler.handleConfigChangeEvent(context));
}

@Test
Expand All @@ -157,12 +144,7 @@ public void testHandleDataModificationEvent_MultiModification() throws Exception
when(dataModificationLog.getModifications()).thenReturn(List.of(modification1, modification2));
when(context.getData()).thenReturn(dataModificationLog);
when(context.getUserInfo()).thenReturn(userInfo);
handler.handleDataModificationEvent(context);
ArgumentCaptor<ArrayNode> captor = ArgumentCaptor.forClass(ArrayNode.class);
verify(communicator).sendBulkRequest(captor.capture());
ArrayNode actualEvents = captor.getValue();
assertJsonMatchesSchema("src/test/resources/configuration-change-schema.json", actualEvents);

runAndAssertEvent("src/test/resources/dpp-data-modification-schema.json", () -> handler.handleDataModificationEvent(context));
}

@Test
Expand All @@ -185,10 +167,13 @@ public void testObjectIdAndSubjectIdAreAlphabeticallyOrdered() throws Exception
ArgumentCaptor<ArrayNode> captor = ArgumentCaptor.forClass(ArrayNode.class);
handler.handleDataModificationEvent(context);
verify(communicator).sendBulkRequest(captor.capture());
ArrayNode actualEvents = captor.getValue();
// Validate the JSON structure
assertJsonMatchesSchema("src/test/resources/configuration-change-schema.json", actualEvents);
// Further assertions can be done here to check the content of actualEvents
JsonNode events = captor.getValue();
JsonNode event = events.get(0);
JsonNode dppNode = event.get("data").get("data").get("dppDataModification");
String objectId = dppNode.get("objectId").asText();
String dataSubjectId = dppNode.get("dataSubjectId").asText();
assertEquals("aKey:aValue mKey:mValue zKey:zValue", objectId, "objectId should be alphabetically ordered by key");
assertEquals("aKey:aValue mKey:mValue zKey:zValue", dataSubjectId, "dataSubjectId should be alphabetically ordered by key");
}

// --- Additional Tests for Robustness and Coverage ---
Expand All @@ -197,33 +182,26 @@ public void testHandleDataAccessEvent_NullAttributesAndAttachments() throws Exce
DataAccessLogContext context = mock(DataAccessLogContext.class);
DataAccessLog dataAccessLog = mock(DataAccessLog.class);
Access access = mock(Access.class);
KeyValuePair id1 = mockKeyValuePair("userId", "user-111");
DataObject dataObject = mockDataObject("User", List.of(id1));
DataSubject dataSubject = mockDataSubject("Person", List.of(id1));
when(access.getDataObject()).thenReturn(dataObject);
when(access.getDataSubject()).thenReturn(dataSubject);
when(access.getAttributes()).thenReturn(null);
when(access.getAttachments()).thenReturn(null);
when(dataAccessLog.getAccesses()).thenReturn(List.of(access));
when(context.getData()).thenReturn(dataAccessLog);
when(context.getUserInfo()).thenReturn(userInfo);

assertThrows(NullPointerException.class, () -> handler.handleDataAccessEvent(context));
NullPointerException npe = assertThrows(NullPointerException.class, () -> handler.handleDataAccessEvent(context));
assertEquals("Access.getAttributes() is null", npe.getMessage());
}

@Test
public void testHandleConfigChangeEvent_EmptyAttributes() throws Exception {
public void testHandleConfigChangeEvent_NullAttributes() throws Exception {
ConfigChangeLogContext context = mock(ConfigChangeLogContext.class);
ConfigChangeLog configChangeLog = mock(ConfigChangeLog.class);
ConfigChange config = mockConfigChange(List.of(), mockDataObject("AppConfig", List.of()));
ConfigChange config = mockConfigChange(null, mockDataObject("AppConfig", List.of()));
when(configChangeLog.getConfigurations()).thenReturn(List.of(config));
when(context.getData()).thenReturn(configChangeLog);
when(context.getUserInfo()).thenReturn(userInfo);
handler.handleConfigChangeEvent(context);
ArgumentCaptor<ArrayNode> captor = ArgumentCaptor.forClass(ArrayNode.class);
verify(communicator).sendBulkRequest(captor.capture());
ArrayNode actualEvents = captor.getValue();
assertJsonMatchesSchema("src/test/resources/configuration-change-schema.json", actualEvents);

NullPointerException npe = assertThrows(NullPointerException.class, () -> handler.handleConfigChangeEvent(context));
assertEquals("ConfigChange.getAttributes() is null", npe.getMessage());
}

@Test
Expand All @@ -241,11 +219,8 @@ public void testHandleDataModificationEvent_LargeBulk() throws Exception {
when(dataModificationLog.getModifications()).thenReturn(mods);
when(context.getData()).thenReturn(dataModificationLog);
when(context.getUserInfo()).thenReturn(userInfo);
handler.handleDataModificationEvent(context);
ArgumentCaptor<ArrayNode> captor = ArgumentCaptor.forClass(ArrayNode.class);
verify(communicator).sendBulkRequest(captor.capture());
ArrayNode actualEvents = captor.getValue();
assertJsonMatchesSchema("src/test/resources/configuration-change-schema.json", actualEvents);
ArrayNode actualEvents = runAndAssertEvent("src/test/resources/dpp-data-modification-schema.json",
() -> handler.handleDataModificationEvent(context));
Assertions.assertEquals(100, actualEvents.size(), "Should produce 100 events");
}

Expand Down Expand Up @@ -278,11 +253,8 @@ public void testHandleUserInfoWithNullFields() throws Exception {
when(context.getUserInfo()).thenReturn(userInfoNull);
when(context.getData()).thenReturn(securityLog);
when(securityLog.getData()).thenReturn("security event data");
handler.handleSecurityEvent(context);
ArgumentCaptor<ArrayNode> captor = ArgumentCaptor.forClass(ArrayNode.class);
verify(communicator).sendBulkRequest(captor.capture());
ArrayNode actualEvents = captor.getValue();
assertJsonMatchesSchema("src/test/resources/legacy-security-wrapper-schema.json", actualEvents);
runAndAssertEvent("src/test/resources/legacy-security-wrapper-schema.json",
() -> handler.handleSecurityEvent(context));
}

@Test
Expand All @@ -292,7 +264,7 @@ public void testHandleLegacyWrapperEvent() throws Exception {
when(context.getUserInfo()).thenReturn(userInfo);
when(context.getData()).thenReturn(securityLog);
when(securityLog.getData()).thenReturn("{\"legacy\":true}");
runAndAssertEvent("src/test/resources/legacy-security-wrapper-schema.json", () -> handler.handleSecurityEvent(context));
runAndAssertEvent("src/test/resources/legacy-security-wrapper-schema.json", () -> handler.handleSecurityEvent(context));
}

@Test
Expand All @@ -308,13 +280,9 @@ public void testHandleGeneralEvent_DataExportWrapping() throws Exception {
outer.put("event", innerJson);
when(generalContext.get("data")).thenReturn(outer);

// Execute
handler.handleGeneralEvent(generalContext);

// Capture and validate
ArgumentCaptor<ArrayNode> captor = ArgumentCaptor.forClass(ArrayNode.class);
verify(communicator).sendBulkRequest(captor.capture());
ArrayNode events = captor.getValue();
// Execute and schema-validate, capturing events
ArrayNode events = runAndAssertEvent("src/test/resources/general-event-schema.json",
() -> handler.handleGeneralEvent(generalContext));
Assertions.assertEquals(1, events.size(), "Exactly one general event expected");
JsonNode event = events.get(0);
// Basic top-level assertions
Expand All @@ -324,8 +292,6 @@ public void testHandleGeneralEvent_DataExportWrapping() throws Exception {
JsonNode wrapped = dataNode.get("dataExport");
Assertions.assertEquals("UNSPECIFIED", wrapped.get("channelType").asText());
Assertions.assertEquals("string", wrapped.get("channelId").asText());
// Schema validation (generic general event schema)
assertJsonMatchesSchema("src/test/resources/general-event-schema.json", events);
}

private ChangedAttribute mockChangedAttribute(String name, String oldValue, String newValue) {
Expand Down Expand Up @@ -385,18 +351,32 @@ private ConfigChange mockConfigChange(List<ChangedAttribute> attrs, DataObject o
return cc;
}

private ArrayNode runAndAssertEvent(String schemaPath, ThrowingRunnable handlerMethod) throws Exception {
ArgumentCaptor<ArrayNode> captor = ArgumentCaptor.forClass(ArrayNode.class);
handlerMethod.run();
verify(communicator).sendBulkRequest(captor.capture());
ArrayNode actualEvents = captor.getValue();
assertJsonMatchesSchema(schemaPath, actualEvents);
return actualEvents;
}

private void assertJsonMatchesSchema(String schemaPath, JsonNode dataNode) throws Exception {
JsonSchema schema = getTestSchema(schemaPath);
Set<ValidationMessage> errors = schema.validate(dataNode);
Assertions.assertTrue(errors.isEmpty(), "Schema validation errors: " + errors);
Schema schema = buildSchema(schemaPath);
List<Error> errors = new ArrayList<>();
if (dataNode.isArray()) {
for (JsonNode item : dataNode) {
errors.addAll(schema.validate(item, ctx -> ctx.executionConfig(cfg -> cfg.formatAssertionsEnabled(true))));
}
} else {
errors.addAll(schema.validate(dataNode, ctx -> ctx.executionConfig(cfg -> cfg.formatAssertionsEnabled(true))));
}
assertEquals(0, errors.size(), "Schema validation errors: " + errors);
}

private JsonSchema getTestSchema(String schemaPath) throws Exception {
JsonSchemaFactory factory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V4);
private Schema buildSchema(String schemaPath) throws Exception {
SchemaRegistry schemaRegistry = SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_2020_12);
ObjectMapper objectMapper = new ObjectMapper();
JsonNode schemaContent = objectMapper.readTree(new File(schemaPath));
JsonSchema schema = factory.getSchema(schemaContent);
schema.initializeValidators();
return schema;
return schemaRegistry.getSchema(schemaContent);
}
}
Loading
Loading