diff --git a/ssf/core/src/main/java/org/keycloak/ssf/event/GenericSsfEvent.java b/ssf/core/src/main/java/org/keycloak/ssf/event/GenericSsfEvent.java index b15bac73bf9b..87fd74172a69 100644 --- a/ssf/core/src/main/java/org/keycloak/ssf/event/GenericSsfEvent.java +++ b/ssf/core/src/main/java/org/keycloak/ssf/event/GenericSsfEvent.java @@ -5,23 +5,19 @@ */ public class GenericSsfEvent extends SsfEvent { + /** + * Required by Jackson: {@code SsfEventMapJsonDeserializer} materialises unknown + * event payloads via {@code treeToValue(eventData, GenericSsfEvent.class)} and + * sets the actual event type URI afterwards through {@link #setEventType}. + */ public GenericSsfEvent() { - super(null); + this(null); + } + + public GenericSsfEvent(String eventType) { + super(eventType); // Generic events don't have an alias by default setAlias(null); } - - @Override - public String toString() { - return "GenericSecurityEvent{" + - "subjectId=" + subjectId + - ", eventType='" + eventType + '\'' + - ", eventTimestamp=" + eventTimestamp + - ", initiatingEntity=" + initiatingEntity + - ", reasonAdmin=" + reasonAdmin + - ", reasonUser=" + reasonUser + - ", attributes=" + attributes + - '}'; - } } diff --git a/ssf/core/src/main/java/org/keycloak/ssf/event/SsfEvent.java b/ssf/core/src/main/java/org/keycloak/ssf/event/SsfEvent.java index 7695b8b14951..66afdf461d5d 100644 --- a/ssf/core/src/main/java/org/keycloak/ssf/event/SsfEvent.java +++ b/ssf/core/src/main/java/org/keycloak/ssf/event/SsfEvent.java @@ -3,117 +3,55 @@ import java.lang.reflect.Field; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; +import java.util.StringJoiner; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; -import org.keycloak.ssf.subject.SubjectId; -import org.keycloak.ssf.subject.SubjectIdJsonDeserializer; - import com.fasterxml.jackson.annotation.JsonAnyGetter; import com.fasterxml.jackson.annotation.JsonAnySetter; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; /** * Represents a generic SSF event. - * + *

* See: https://datatracker.ietf.org/doc/html/rfc8417 */ @JsonInclude(JsonInclude.Include.NON_NULL) public abstract class SsfEvent { + private static final ConcurrentMap, Set> DECLARED_JSON_PROPERTIES = new ConcurrentHashMap<>(); + /** * Internal (shorter) alias for the event type. */ @JsonIgnore protected String alias; - @JsonProperty("subject") - @JsonDeserialize(using = SubjectIdJsonDeserializer.class) - protected SubjectId subjectId; - - @JsonIgnore - protected String eventType; - - /** - * The time of the event (UNIX timestamp). Nullable so events that do - * not carry a timestamp — e.g. {@code ssf/event-type/verification} - * (SSF §8.1.4 carries only {@code state}) and other stream-management - * events — are omitted from the wire JSON instead of being serialized - * as {@code "event_timestamp": 0} (the default value of a primitive - * {@code long}, which Jackson always emits). - */ - @JsonProperty("event_timestamp") - protected Long eventTimestamp; - - /** - * The entity that initiated the event - */ - @JsonProperty("initiating_entity") - protected InitiatingEntity initiatingEntity; - /** - * A localized administrative message intended for logging and auditing. - * key is language code, value is message. + * The event type URI */ - @JsonProperty("reason_admin") - protected Map reasonAdmin; + @JsonIgnore + protected String eventType; /** - * A localized message intended for the end user. - * key is language code, value is message. + * Additional unmapped event-specific fields. */ - @JsonProperty("reason_user") - protected Map reasonUser; - @JsonIgnore protected Map attributes = new HashMap<>(); public SsfEvent(String eventType) { - this.eventType = eventType; - - // use the simple class name as the default alias - this.alias = getClass().getSimpleName(); - } - - public SubjectId getSubjectId() { - return subjectId; - } - - public Long getEventTimestamp() { - return eventTimestamp; - } - - public void setEventTimestamp(long eventTimestamp) { - this.eventTimestamp = eventTimestamp; - } - - public InitiatingEntity getInitiatingEntity() { - return initiatingEntity; - } - - public void setInitiatingEntity(InitiatingEntity initiatingEntity) { - this.initiatingEntity = initiatingEntity; - } - - public Map getReasonAdmin() { - return reasonAdmin; + this(eventType, null); } - public void setReasonAdmin(Map reasonAdmin) { - this.reasonAdmin = reasonAdmin; - } - - public Map getReasonUser() { - return reasonUser; - } - - public void setReasonUser(Map reasonUser) { - this.reasonUser = reasonUser; + public SsfEvent(String eventType, String alias) { + this.eventType = eventType; + // use the simple class name as the default alias + this.alias = alias == null ? getClass().getSimpleName() : alias; } public String getEventType() { @@ -141,13 +79,11 @@ public void setAttributeValue(String key, Object value) { if (declaredJsonPropertyNames(getClass()).contains(key)) { throw new IllegalArgumentException( "Custom attribute key '" + key + "' collides with a declared @JsonProperty on " - + getClass().getName()); + + getClass().getName()); } attributes.put(key, value); } - private static final ConcurrentMap, Set> DECLARED_JSON_PROPERTIES = new ConcurrentHashMap<>(); - private static Set declaredJsonPropertyNames(Class type) { return DECLARED_JSON_PROPERTIES.computeIfAbsent(type, t -> { Set names = new HashSet<>(); @@ -167,10 +103,6 @@ public void setEventType(String eventType) { this.eventType = eventType; } - public void setSubjectId(SubjectId subjectId) { - this.subjectId = subjectId; - } - public String getAlias() { return alias; } @@ -202,4 +134,72 @@ public void setAlias(String alias) { public void validate() { // no-op — overridden by event subclasses that have spec-required fields } + + @Override + public String toString() { + Map fields = new LinkedHashMap<>(); + appendFields(fields); + if (attributes != null && !attributes.isEmpty()) { + Map renderedAttributes = new LinkedHashMap<>(); + for (var entry : attributes.entrySet()) { + if (entry.getValue() != null) { + renderedAttributes.put(entry.getKey(), entry.getValue()); + } + } + if (!renderedAttributes.isEmpty()) { + fields.putIfAbsent("attributes", renderedAttributes); + } + } + StringJoiner rendered = new StringJoiner(", "); + for (var entry : fields.entrySet()) { + Object value = entry.getValue(); + if (value == null) { + continue; + } + rendered.add(entry.getKey() + "=" + render(value)); + } + String name = alias != null ? alias : getClass().getSimpleName(); + return rendered.length() == 0 ? name : name + "{" + rendered + "}"; + } + + /** + * Renders a field value for {@link #toString()}, quoting {@link String} + * values at every nesting level so entries inside maps (extension + * {@link #attributes}, {@code reason_admin} / {@code reason_user}) read the + * same as top-level fields. Values originate from JSON payloads, which + * cannot form cycles, so the recursion is bounded. + */ + private static String render(Object value) { + if (value instanceof String) { + return "'" + value + '\''; + } + if (value instanceof Map map) { + StringJoiner joiner = new StringJoiner(", ", "{", "}"); + for (var entry : map.entrySet()) { + joiner.add(entry.getKey() + "=" + render(entry.getValue())); + } + return joiner.toString(); + } + if (value instanceof Iterable iterable) { + StringJoiner joiner = new StringJoiner(", ", "[", "]"); + for (var element : iterable) { + joiner.add(render(element)); + } + return joiner.toString(); + } + return String.valueOf(value); + } + + /** + * Contributes the fields of this level of the event hierarchy to the + * {@link #toString()} output; insertion order is the render order. + * Subclasses override this (calling {@code super.appendFields(fields)} first) + * instead of {@code toString()} itself. Values may be put unconditionally — + * {@code null} entries are filtered and {@link String} values quoted centrally + * at every nesting level when rendering, and the extension {@link #attributes} + * map is appended automatically when non-empty. + */ + protected void appendFields(Map fields) { + fields.put("eventType", eventType); + } } diff --git a/ssf/core/src/main/java/org/keycloak/ssf/event/caep/CaepCredentialChange.java b/ssf/core/src/main/java/org/keycloak/ssf/event/caep/CaepCredentialChange.java index 7174059f222d..60f9b02be8d0 100644 --- a/ssf/core/src/main/java/org/keycloak/ssf/event/caep/CaepCredentialChange.java +++ b/ssf/core/src/main/java/org/keycloak/ssf/event/caep/CaepCredentialChange.java @@ -1,5 +1,7 @@ package org.keycloak.ssf.event.caep; +import java.util.Map; + import org.keycloak.ssf.event.SsfEventValidationException; import com.fasterxml.jackson.annotation.JsonCreator; @@ -15,8 +17,7 @@ public class CaepCredentialChange extends CaepEvent { /** * See: https://openid.github.io/sharedsignals/openid-caep-1_0.html#name-credential-change */ - public static final String TYPE = "https://schemas.openid.net/secevent/caep/event-type/credential-change"; - + public static final String TYPE = CaepEvent.EVENT_TYPE_BASE_URI + "credential-change"; /** * This MUST be one of the following strings, or any other credential type supported mutually by the Transmitter and the Receiver. @@ -205,14 +206,13 @@ public String getType() { } @Override - public String toString() { - return "CredentialChange{" + - "credentialType=" + credentialType + - ", changeType=" + changeType + - ", friendlyName='" + friendlyName + '\'' + - ", x509Issuer='" + x509Issuer + '\'' + - ", x509Serial='" + x509Serial + '\'' + - ", fido2Aaguid='" + fido2Aaguid + '\'' + - '}'; + protected void appendFields(Map fields) { + super.appendFields(fields); + fields.put("credentialType", credentialType); + fields.put("changeType", changeType); + fields.put("friendlyName", friendlyName); + fields.put("x509Issuer", x509Issuer); + fields.put("x509Serial", x509Serial); + fields.put("fido2Aaguid", fido2Aaguid); } } diff --git a/ssf/core/src/main/java/org/keycloak/ssf/event/caep/CaepEvent.java b/ssf/core/src/main/java/org/keycloak/ssf/event/caep/CaepEvent.java index 437f58855cc1..6738196e99ce 100644 --- a/ssf/core/src/main/java/org/keycloak/ssf/event/caep/CaepEvent.java +++ b/ssf/core/src/main/java/org/keycloak/ssf/event/caep/CaepEvent.java @@ -1,6 +1,14 @@ package org.keycloak.ssf.event.caep; +import java.util.Map; + +import org.keycloak.ssf.event.InitiatingEntity; import org.keycloak.ssf.event.SsfEvent; +import org.keycloak.ssf.subject.SubjectId; +import org.keycloak.ssf.subject.SubjectIdJsonDeserializer; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; /** * Generic CaepEvent. @@ -9,7 +17,102 @@ */ public abstract class CaepEvent extends SsfEvent { + /** + * See: https://openid.net/specs/openid-caep-1_0-final.html#section-3 + */ + public static final String EVENT_TYPE_BASE_URI = "https://schemas.openid.net/secevent/caep/event-type/"; + + /** + * The legacy CAEP SSE subject attribute for backwards compatibility. In the OpenID Shared Signals and Events Framework Specification 1.0 - draft 01 + * the subject attribute was used to identify the subject of the event. + * See: https://openid.net/specs/openid-sse-framework-1_0-ID1.html#rfc.section.3.3 + * + * In SSF 1.0 the subject is encoded as the "sub_id" claim in the Security Event Token (SET). + * See: https://openid.github.io/sharedsignals/openid-sharedsignals-framework-1_0.html#section-3.1 + */ + @JsonProperty("subject") + @JsonDeserialize(using = SubjectIdJsonDeserializer.class) + protected SubjectId subjectId; + + /** + * The time of the event (UNIX timestamp). Nullable so an absent timestamp + * is omitted from wire JSON rather than serialized as + * {@code "event_timestamp": 0}. + */ + @JsonProperty("event_timestamp") + protected Long eventTimestamp; + + /** + * The entity that initiated the event + */ + @JsonProperty("initiating_entity") + protected InitiatingEntity initiatingEntity; + + /** + * A localized administrative message intended for logging and auditing. + * key is language code, value is message. + */ + @JsonProperty("reason_admin") + protected Map reasonAdmin; + + /** + * A localized message intended for the end user. + * key is language code, value is message. + */ + @JsonProperty("reason_user") + protected Map reasonUser; + public CaepEvent(String type) { super(type); } + + public SubjectId getSubjectId() { + return subjectId; + } + + public void setSubjectId(SubjectId subjectId) { + this.subjectId = subjectId; + } + + public Long getEventTimestamp() { + return eventTimestamp; + } + + public void setEventTimestamp(long eventTimestamp) { + this.eventTimestamp = eventTimestamp; + } + + public InitiatingEntity getInitiatingEntity() { + return initiatingEntity; + } + + public void setInitiatingEntity(InitiatingEntity initiatingEntity) { + this.initiatingEntity = initiatingEntity; + } + + public Map getReasonAdmin() { + return reasonAdmin; + } + + public void setReasonAdmin(Map reasonAdmin) { + this.reasonAdmin = reasonAdmin; + } + + public Map getReasonUser() { + return reasonUser; + } + + public void setReasonUser(Map reasonUser) { + this.reasonUser = reasonUser; + } + + @Override + protected void appendFields(Map fields) { + super.appendFields(fields); + fields.put("subjectId", subjectId); + fields.put("eventTimestamp", eventTimestamp); + fields.put("initiatingEntity", initiatingEntity); + fields.put("reasonAdmin", reasonAdmin); + fields.put("reasonUser", reasonUser); + } } diff --git a/ssf/core/src/main/java/org/keycloak/ssf/event/caep/CaepSessionRevoked.java b/ssf/core/src/main/java/org/keycloak/ssf/event/caep/CaepSessionRevoked.java index 9f59bf3c45ba..68a36aa12778 100644 --- a/ssf/core/src/main/java/org/keycloak/ssf/event/caep/CaepSessionRevoked.java +++ b/ssf/core/src/main/java/org/keycloak/ssf/event/caep/CaepSessionRevoked.java @@ -8,14 +8,9 @@ public class CaepSessionRevoked extends CaepEvent { /** * See: https://openid.github.io/sharedsignals/openid-caep-1_0.html#name-session-revoked */ - public static final String TYPE = "https://schemas.openid.net/secevent/caep/event-type/session-revoked"; + public static final String TYPE = CaepEvent.EVENT_TYPE_BASE_URI + "session-revoked"; public CaepSessionRevoked() { super(TYPE); } - - @Override - public String toString() { - return "SessionRevoked{}"; - } } diff --git a/ssf/core/src/main/java/org/keycloak/ssf/event/stream/SsfStreamEvent.java b/ssf/core/src/main/java/org/keycloak/ssf/event/stream/SsfStreamEvent.java index 621ecb665ea0..215032acd7a1 100644 --- a/ssf/core/src/main/java/org/keycloak/ssf/event/stream/SsfStreamEvent.java +++ b/ssf/core/src/main/java/org/keycloak/ssf/event/stream/SsfStreamEvent.java @@ -7,6 +7,8 @@ */ public abstract class SsfStreamEvent extends SsfEvent { + public static final String EVENT_TYPE_BASE_URI = "https://schemas.openid.net/secevent/ssf/event-type/"; + public SsfStreamEvent(String eventType) { super(eventType); } diff --git a/ssf/core/src/main/java/org/keycloak/ssf/event/stream/SsfStreamUpdatedEvent.java b/ssf/core/src/main/java/org/keycloak/ssf/event/stream/SsfStreamUpdatedEvent.java index becb070af158..ad5800004d9e 100644 --- a/ssf/core/src/main/java/org/keycloak/ssf/event/stream/SsfStreamUpdatedEvent.java +++ b/ssf/core/src/main/java/org/keycloak/ssf/event/stream/SsfStreamUpdatedEvent.java @@ -1,17 +1,19 @@ package org.keycloak.ssf.event.stream; +import java.util.Map; + import org.keycloak.ssf.stream.StreamStatus; import com.fasterxml.jackson.annotation.JsonProperty; /** * SSF Stream status updated event. - * + *

* See: https://openid.net/specs/openid-sharedsignals-framework-1_0-final.html#name-stream-updated-event */ public class SsfStreamUpdatedEvent extends SsfStreamEvent { - public static final String TYPE = "https://schemas.openid.net/secevent/ssf/event-type/stream-updated"; + public static final String TYPE = SsfStreamEvent.EVENT_TYPE_BASE_URI + "stream-updated"; /** * REQUIRED. Defines the new status of the stream. @@ -44,4 +46,11 @@ public String getReason() { public void setReason(String reason) { this.reason = reason; } + + @Override + protected void appendFields(Map fields) { + super.appendFields(fields); + fields.put("status", status); + fields.put("reason", reason); + } } diff --git a/ssf/core/src/main/java/org/keycloak/ssf/event/stream/SsfStreamVerificationEvent.java b/ssf/core/src/main/java/org/keycloak/ssf/event/stream/SsfStreamVerificationEvent.java index 8cce81fc14ef..ec31635e2d1a 100644 --- a/ssf/core/src/main/java/org/keycloak/ssf/event/stream/SsfStreamVerificationEvent.java +++ b/ssf/core/src/main/java/org/keycloak/ssf/event/stream/SsfStreamVerificationEvent.java @@ -1,15 +1,17 @@ package org.keycloak.ssf.event.stream; +import java.util.Map; + import com.fasterxml.jackson.annotation.JsonProperty; /** * SSF Verification event. - * + *

* See: https://openid.net/specs/openid-sharedsignals-framework-1_0-final.html#name-verification */ public class SsfStreamVerificationEvent extends SsfStreamEvent { - public static final String TYPE = "https://schemas.openid.net/secevent/ssf/event-type/verification"; + public static final String TYPE = SsfStreamEvent.EVENT_TYPE_BASE_URI + "verification"; @JsonProperty("state") protected String state; @@ -27,12 +29,8 @@ public void setState(String state) { } @Override - public String toString() { - // Render absent state as an empty object rather than "state='null'" - // so the log representation matches what Jackson actually puts on - // the wire (omitted thanks to @JsonInclude(NON_NULL)). - return state == null - ? "VerificationEvent{}" - : "VerificationEvent{state='" + state + "'}"; + protected void appendFields(Map fields) { + super.appendFields(fields); + fields.put("state", state); } } diff --git a/ssf/core/src/test/java/org/keycloak/ssf/event/SsfEventsTest.java b/ssf/core/src/test/java/org/keycloak/ssf/event/SsfEventsTest.java new file mode 100644 index 000000000000..81aa15d5650d --- /dev/null +++ b/ssf/core/src/test/java/org/keycloak/ssf/event/SsfEventsTest.java @@ -0,0 +1,156 @@ +package org.keycloak.ssf.event; + +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +import org.keycloak.ssf.event.caep.CaepCredentialChange; +import org.keycloak.ssf.event.token.SsfEventMapJsonDeserializer; +import org.keycloak.ssf.event.token.SsfSecurityEventToken; +import org.keycloak.util.JsonSerialization; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SsfEventsTest { + + @Test + void builtInEvents_toStringOnFreshInstanceDoesNotThrow() { + Map> factories = + new DefaultSsfEventProviderFactory().getContributedEventFactories(); + assertFalse(factories.isEmpty(), "factory should contribute the built-in events"); + + for (Map.Entry> entry : factories.entrySet()) { + SsfEvent event = entry.getValue().get(); + String rendered = assertDoesNotThrow(event::toString, + () -> "toString() must not throw for fresh " + entry.getKey()); + assertNotNull(rendered, () -> "toString() must not return null for " + entry.getKey()); + assertFalse(rendered.isBlank(), () -> "toString() must not be blank for " + entry.getKey()); + assertFalse(rendered.contains("=null"), + () -> "unset fields must be omitted, not rendered as null, for " + + entry.getKey() + ": " + rendered); + } + } + + @Test + void withoutBoundSession_setParsingDegradesToGenericSsfEvent() throws Exception { + // Outside a request scope no KeycloakSession is bound, so + // SsfEventMapJsonDeserializer.resolveRegistry() returns null and every + // event type degrades to GenericSsfEvent (documented contract of + // resolveRegistry): the full payload lands in the @JsonAnySetter + // extension attributes and the real type URI is set after + // materialisation. The registry-bound unknown-type fallback is covered + // separately below. + String unknownType = "https://example.com/secevent/custom/unknown-event"; + String setJson = """ + { + "jti": "jti-123", + "iss": "https://issuer.example.test", + "events": { + "%s": { + "foo": "bar", + "count": 3, + "nested": { "a": 1 } + } + } + } + """.formatted(unknownType); + + SsfSecurityEventToken token = JsonSerialization.readValue(setJson, SsfSecurityEventToken.class); + + Object event = token.getEvents().get(unknownType); + GenericSsfEvent generic = assertInstanceOf(GenericSsfEvent.class, event, + "unknown event types must degrade to GenericSsfEvent, not fail parsing"); + assertEquals(unknownType, generic.getEventType(), + "the deserializer must preserve the original event type URI"); + assertEquals("bar", generic.getAttributes().get("foo"), + "payload fields must be preserved in the extension attributes"); + assertEquals(3, generic.getAttributes().get("count")); + assertEquals(Map.of("a", 1), generic.getAttributes().get("nested"), + "nested payload objects must be preserved as maps"); + + generic.setAttributeValue("unset", null); + + String rendered = assertDoesNotThrow(generic::toString); + assertTrue(rendered.contains(unknownType), + "toString should render the preserved event type URI: " + rendered); + assertTrue(rendered.contains("foo='bar'"), + "toString should render attribute Strings quoted like top-level fields: " + rendered); + assertTrue(rendered.contains("nested={a=1}"), + "toString should render nested maps recursively: " + rendered); + assertFalse(rendered.contains("unset="), + "toString should omit null-valued attributes: " + rendered); + } + + @Test + void registryBound_unknownTypeFallsBackToGenericWhileKnownTypeResolvesTyped() throws Exception { + // With a registry bound (stubbed resolveRegistry, same hook the + // per-session provider uses), a contributed type must resolve to its + // typed event class and only genuinely unknown types may fall back to + // GenericSsfEvent — the branch the no-session test above cannot cover. + SsfEventRegistry registry = SsfEventRegistry.from(List.of(new DefaultSsfEventProviderFactory())); + SsfEventMapJsonDeserializer deserializer = new SsfEventMapJsonDeserializer() { + @Override + protected SsfEventRegistry resolveRegistry() { + return registry; + } + }; + + String unknownType = "https://example.com/secevent/custom/unknown-event"; + String eventsJson = """ + { + "%s": { "credential_type": "password" }, + "%s": { "foo": "bar" } + } + """.formatted(CaepCredentialChange.TYPE, unknownType); + + ObjectMapper mapper = new ObjectMapper(); + Map events; + try (JsonParser parser = mapper.createParser(eventsJson)) { + events = deserializer.deserialize(parser, null); + } + + CaepCredentialChange known = assertInstanceOf(CaepCredentialChange.class, + events.get(CaepCredentialChange.TYPE), + "registry-contributed types must resolve to their typed event class"); + assertEquals("password", known.getCredentialType()); + + GenericSsfEvent generic = assertInstanceOf(GenericSsfEvent.class, events.get(unknownType), + "only unknown event types may fall back to GenericSsfEvent"); + assertEquals(unknownType, generic.getEventType()); + assertEquals("bar", generic.getAttributes().get("foo")); + } + + @Test + void genericEvent_toStringOnFreshInstanceDoesNotThrow() { + // GenericSsfEvent is the unknown-type fallback and is not part of the + // factory contributions, so it is covered explicitly. + GenericSsfEvent event = new GenericSsfEvent(); + String rendered = assertDoesNotThrow(event::toString); + assertNotNull(rendered); + assertFalse(rendered.isBlank()); + } + + @Test + void caepEvent_toStringRendersOnlySetFields() { + CaepCredentialChange event = new CaepCredentialChange(); + event.setCredentialType("password"); + event.setChangeType(CaepCredentialChange.ChangeType.UPDATE); + + String rendered = event.toString(); + assertTrue(rendered.contains("credentialType='password'"), + "set String fields must be rendered quoted: " + rendered); + assertTrue(rendered.contains("changeType="), + "set fields must be rendered: " + rendered); + assertFalse(rendered.contains("=null"), + "unset optional fields (friendlyName, x509*, …) must be omitted: " + rendered); + } +}