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);
+ }
+}