From d2180db32f25d791c76d5f947768e1ea8ad63753 Mon Sep 17 00:00:00 2001 From: Henry Stern Date: Sat, 20 Jun 2026 23:15:46 -0300 Subject: [PATCH] add Shared Signals interop fixtures and consumer-flow tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The RFC 8417 golden fixtures prove the library round-trips the spec's own example shapes, but they do not exercise the scenario the library actually exists to serve: a Shared Signals receiver handing decoded claims-set bytes to Parse, then decoding the CAEP/RISC event types it recognizes through the registry while leaving the rest raw. This adds fixtures and tests that pin that end-to-end consumer flow. The fixtures (testdata/caep_session_revoked.json, risc_account_disabled.json, multi_event.json) are Shared-Signals-shaped SETs whose events are keyed by the real public OpenID event-type URIs — schemas.openid.net/secevent/caep/event-type/session-revoked, .../risc/event-type/account-disabled, and a credential-change URI left deliberately unregistered. caep_session_revoked carries a top-level RFC 9493 iss_sub sub_id (how a receiver keys revocation by subject); risc_account_disabled names its subject inside the event payload, the RISC idiom, distinct from a top-level sub_id; multi_event mixes both recognized types with the unregistered one. interop_test.go drives Parse -> Validate -> Typed for each: the registered CAEP/RISC URIs decode to typed values through the registry, the unregistered URI stays raw and reachable via Raw(), and every payload round-trips byte-stably (Parse -> Encode -> Parse is a fixed point). The multi_event test asserts the exact decode-known/leave-rest partition a receiver performs. The library defines no CAEP/RISC payload types itself — those are downstream event vocabularies — so the decoders here are test-local fakes standing in for them, registered once in init to respect the process-wide, panic-on-duplicate registry. These run under the existing test job as ordinary go test cases; no separate CI job is added, since one would only re-run the same go test. The fixtures are hand-authored rather than imported from a transport library: feeding a transport's own bytes in would invert the dependency direction (the SET-envelope layer must not depend on the transport that sits above it and produces the bytes it parses), so the interop boundary is proven with representative shapes instead. Subjects use the RFC 2606 reserved example domains; no real subject data appears. Co-Authored-By: Claude --- interop_test.go | 318 ++++++++++++++++++++++++++++ testdata/README.md | 26 +++ testdata/caep_session_revoked.json | 20 ++ testdata/multi_event.json | 35 +++ testdata/risc_account_disabled.json | 16 ++ 5 files changed, 415 insertions(+) create mode 100644 interop_test.go create mode 100644 testdata/caep_session_revoked.json create mode 100644 testdata/multi_event.json create mode 100644 testdata/risc_account_disabled.json diff --git a/interop_test.go b/interop_test.go new file mode 100644 index 0000000..d3bb8d9 --- /dev/null +++ b/interop_test.go @@ -0,0 +1,318 @@ +// Copyright 2026 The go-secevent Authors +// SPDX-License-Identifier: Apache-2.0 + +package secevent + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/hstern/go-subjectid" +) + +// The interop fixtures are Shared-Signals-shaped SETs: the decoded claims-set +// bytes a JOSE/transport layer would hand to Parse after verifying the JWS. The +// events are keyed by the real public OpenID event-type URIs that CAEP and RISC +// define, so the tests exercise the exact consumer flow a Shared Signals +// receiver runs — Parse the claims set, Validate it, decode the event types it +// recognizes through the registry, and leave the rest raw and byte-stable. The +// library defines no CAEP/RISC payload types itself (those are downstream +// vocabularies); the decoders below are test-local fakes standing in for them. +// +// These URIs are the public CAEP 1.0 / RISC event-type identifiers under +// schemas.openid.net. They are distinct from the schemas.example.com test URIs +// registered elsewhere in this package's test binary, so registering decoders +// for them in init does not collide with the process-wide registry (which has +// no unregister and panics on a duplicate). +// +// These tests scope themselves to the interop scenario — real CAEP/RISC SET +// shapes, the multi-event decode-known/leave-rest partition, and byte-stable +// round-trips. The registry mechanics they build on (unregistered-present, +// absent, and decoder-error paths) are exercised directly in events_test.go and +// are not re-tested here. +const ( + caepSessionRevokedURI = "https://schemas.openid.net/secevent/caep/event-type/session-revoked" + caepCredentialChangeURI = "https://schemas.openid.net/secevent/caep/event-type/credential-change" + riscAccountDisabledURI = "https://schemas.openid.net/secevent/risc/event-type/account-disabled" +) + +// caepSessionRevoked is a test-local stand-in for a CAEP session-revoked event +// payload. It models only the fields the fixtures carry; a real CAEP vocabulary +// would define the full typed event. The event-type URI is fixed per type, as +// the Event contract requires. +type caepSessionRevoked struct { + EventTimestamp int64 `json:"event_timestamp"` + InitiatingEntity string `json:"initiating_entity,omitempty"` + ReasonAdmin json.RawMessage `json:"reason_admin,omitempty"` +} + +var _ Event = caepSessionRevoked{} + +func (caepSessionRevoked) EventTypeURI() string { return caepSessionRevokedURI } + +// riscAccountDisabled is a test-local stand-in for a RISC account-disabled event +// payload. RISC events name their subject inside the event payload (an RFC 9493 +// Subject Identifier object), distinct from a top-level sub_id, so the fake +// decodes the subject through go-subjectid to mirror that real shape. +type riscAccountDisabled struct { + Subject json.RawMessage `json:"subject"` + Reason string `json:"reason,omitempty"` +} + +var _ Event = riscAccountDisabled{} + +func (riscAccountDisabled) EventTypeURI() string { return riscAccountDisabledURI } + +// init registers the CAEP/RISC interop decoders once for the whole package test +// binary. The registry is process-wide with no unregister and panics on a +// duplicate URI, so registration lives in init rather than a test body where +// -count or -shuffle could re-run it. The credential-change URI is deliberately +// left unregistered: the multi_event fixture carries it so the consumer flow can +// demonstrate "decode the events I know, leave the rest raw". +func init() { + RegisterEventType(caepSessionRevokedURI, func(raw json.RawMessage) (Event, error) { + var ev caepSessionRevoked + if err := json.Unmarshal(raw, &ev); err != nil { + return nil, err + } + return ev, nil + }) + RegisterEventType(riscAccountDisabledURI, func(raw json.RawMessage) (Event, error) { + var ev riscAccountDisabled + if err := json.Unmarshal(raw, &ev); err != nil { + return nil, err + } + return ev, nil + }) +} + +// readInteropFixture loads a Shared-Signals-shaped fixture from testdata by +// basename. +func readInteropFixture(t *testing.T, name string) []byte { + t.Helper() + + path := filepath.Join("testdata", name+".json") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read fixture %s: %v", path, err) + } + return data +} + +// TestInteropCAEPSessionRevoked drives the single-CAEP-event consumer flow: +// Parse the claims set, confirm it validates, decode the session-revoked event +// through the registry into the typed value, and confirm the top-level sub_id +// the receiver keys revocation by is the expected RFC 9493 iss_sub pair. +func TestInteropCAEPSessionRevoked(t *testing.T) { + t.Parallel() + + payload := readInteropFixture(t, "caep_session_revoked") + + set, err := Parse(payload) + if err != nil { + t.Fatalf("Parse(caep_session_revoked.json): %v", err) + } + if err := set.Validate(); err != nil { + t.Fatalf("Validate() = %v, want nil for a complete SET", err) + } + + wantSubject := subjectid.IssSubID{Iss: "https://idp.example.com/", Sub: "user-7f3e2a"} + got, isIssSub := set.Subject.(*subjectid.IssSubID) + if !isIssSub { + t.Fatalf("Subject is %T, want *subjectid.IssSubID", set.Subject) + } + if *got != wantSubject { + t.Errorf("Subject = %+v, want %+v", *got, wantSubject) + } + + event, ok, err := set.Events.Typed(caepSessionRevokedURI) + if err != nil { + t.Fatalf("Events.Typed(%q): %v", caepSessionRevokedURI, err) + } + if !ok { + t.Fatalf("Events.Typed(%q) ok = false, want true for the registered CAEP URI", caepSessionRevokedURI) + } + revoked, isRevoked := event.(caepSessionRevoked) + if !isRevoked { + t.Fatalf("decoded event is %T, want caepSessionRevoked", event) + } + if revoked.EventTimestamp != 1615305500 { + t.Errorf("EventTimestamp = %d, want 1615305500", revoked.EventTimestamp) + } + if revoked.InitiatingEntity != "policy" { + t.Errorf("InitiatingEntity = %q, want %q", revoked.InitiatingEntity, "policy") + } +} + +// TestInteropRISCAccountDisabled drives the single-RISC-event consumer flow and +// confirms the RISC-style per-event subject (an RFC 9493 Subject Identifier +// nested inside the event payload, not at the SET top level) decodes through +// go-subjectid. +func TestInteropRISCAccountDisabled(t *testing.T) { + t.Parallel() + + payload := readInteropFixture(t, "risc_account_disabled") + + set, err := Parse(payload) + if err != nil { + t.Fatalf("Parse(risc_account_disabled.json): %v", err) + } + if err := set.Validate(); err != nil { + t.Fatalf("Validate() = %v, want nil for a complete SET", err) + } + + // RISC names the subject inside the event, so the SET carries no top-level + // sub_id. + if set.Subject != nil { + t.Errorf("Subject = %v, want nil (RISC carries the subject inside the event)", set.Subject) + } + + event, ok, err := set.Events.Typed(riscAccountDisabledURI) + if err != nil { + t.Fatalf("Events.Typed(%q): %v", riscAccountDisabledURI, err) + } + if !ok { + t.Fatalf("Events.Typed(%q) ok = false, want true for the registered RISC URI", riscAccountDisabledURI) + } + disabled, isDisabled := event.(riscAccountDisabled) + if !isDisabled { + t.Fatalf("decoded event is %T, want riscAccountDisabled", event) + } + if disabled.Reason != "hijacking" { + t.Errorf("Reason = %q, want %q", disabled.Reason, "hijacking") + } + + subject, err := subjectid.Parse(disabled.Subject) + if err != nil { + t.Fatalf("decode per-event subject: %v", err) + } + issSub, isIssSub := subject.(*subjectid.IssSubID) + if !isIssSub { + t.Fatalf("per-event subject is %T, want *subjectid.IssSubID", subject) + } + want := subjectid.IssSubID{Iss: "https://idp.example.com/", Sub: "user-7f3e2a"} + if *issSub != want { + t.Errorf("per-event subject = %+v, want %+v", *issSub, want) + } +} + +// TestInteropMultiEventDecodeKnownLeaveRest is the headline Shared Signals +// receiver pattern: a SET carrying several events, of which the receiver decodes +// the types it recognizes (CAEP session-revoked, RISC account-disabled) and +// leaves the rest raw and byte-stable (a CAEP credential-change for which this +// build registers no decoder). It walks the events exactly as a receiver would — +// ranging Raw and calling Typed per URI — and asserts the partition. +func TestInteropMultiEventDecodeKnownLeaveRest(t *testing.T) { + t.Parallel() + + payload := readInteropFixture(t, "multi_event") + + set, err := Parse(payload) + if err != nil { + t.Fatalf("Parse(multi_event.json): %v", err) + } + if err := set.Validate(); err != nil { + t.Fatalf("Validate() = %v, want nil for a complete SET", err) + } + if got := set.Events.Len(); got != 3 { + t.Fatalf("Events.Len() = %d, want 3", got) + } + + typed := make(map[string]Event) + raw := make(map[string]json.RawMessage) + for uri := range set.Events.Raw() { + event, ok, err := set.Events.Typed(uri) + switch { + case err != nil: + t.Fatalf("Events.Typed(%q): %v", uri, err) + case ok: + typed[uri] = event + default: + raw[uri] = set.Events.Raw()[uri] + } + } + + // The two registered URIs decode to typed values. + if _, ok := typed[caepSessionRevokedURI].(caepSessionRevoked); !ok { + t.Errorf("CAEP session-revoked did not decode to caepSessionRevoked: %#v", typed[caepSessionRevokedURI]) + } + if _, ok := typed[riscAccountDisabledURI].(riscAccountDisabled); !ok { + t.Errorf("RISC account-disabled did not decode to riscAccountDisabled: %#v", typed[riscAccountDisabledURI]) + } + if len(typed) != 2 { + t.Errorf("typed events = %d, want 2 (the recognized CAEP/RISC types)", len(typed)) + } + + // The unregistered URI stays raw and reachable through Raw. + if len(raw) != 1 { + t.Fatalf("raw events = %d, want 1 (the unrecognized credential-change)", len(raw)) + } + if _, ok := raw[caepCredentialChangeURI]; !ok { + t.Errorf("unregistered URI %q not left raw", caepCredentialChangeURI) + } +} + +// TestInteropFixturesRoundTripByteStable confirms every interop fixture survives +// a Parse → Encode round-trip with each event payload reproduced byte-for-byte — +// including the unregistered credential-change in multi_event. Encode compacts +// each json.RawMessage member to its canonical form, so the comparison is +// against the compacted payload; the bytes (and member order within each +// payload) must be unchanged, and a second Encode of the re-parsed SET must be a +// fixed point. This is the open-extension guarantee CAEP/RISC rely on. +func TestInteropFixturesRoundTripByteStable(t *testing.T) { + t.Parallel() + + for _, name := range []string{"caep_session_revoked", "risc_account_disabled", "multi_event"} { + t.Run(name, func(t *testing.T) { + t.Parallel() + + payload := readInteropFixture(t, name) + + set, err := Parse(payload) + if err != nil { + t.Fatalf("Parse(%s.json): %v", name, err) + } + + encoded, err := set.Encode() + if err != nil { + t.Fatalf("Encode(): %v", err) + } + + for uri, p := range set.Events.Raw() { + want := compactJSON(t, p) + if !bytes.Contains(encoded, want) { + t.Errorf("event %q payload not reproduced verbatim in Encode output\n payload: %s\n encoded: %s", + uri, want, encoded) + } + } + + reparsed, err := Parse(encoded) + if err != nil { + t.Fatalf("Parse(Encode()): %v", err) + } + reEncoded, err := reparsed.Encode() + if err != nil { + t.Fatalf("Encode() of re-parsed SET: %v", err) + } + if !bytes.Equal(reEncoded, encoded) { + t.Errorf("Parse → Encode is not a fixed point\n first: %s\n second: %s", encoded, reEncoded) + } + + for uri, p := range set.Events.Raw() { + want := compactJSON(t, p) + got, present := reparsed.Events.Raw()[uri] + if !present { + t.Errorf("event %q dropped across the round-trip", uri) + continue + } + if !bytes.Equal(got, want) { + t.Errorf("event %q payload changed across the round-trip\n before: %s\n after: %s", + uri, want, got) + } + } + }) + } +} diff --git a/testdata/README.md b/testdata/README.md index f07c3fa..ca9bc65 100644 --- a/testdata/README.md +++ b/testdata/README.md @@ -17,6 +17,32 @@ fixture is labelled as such below. | `txn_toe_subid.json` | Synthetic | `aud` as a two-member array, `txn`, `toe` distinct from `iat`, an RFC 9493 `iss_sub` `sub_id`, one event with a non-empty payload | | `single_aud.json` | Synthetic | `aud` as a bare string (the single-recipient JWT idiom), one event whose payload a registered decoder turns into a typed value | +## Shared Signals interop fixtures (`interop_test.go`) + +These fixtures are Shared-Signals-shaped SETs: their `events` are keyed +by the **real public** CAEP 1.0 / RISC event-type URIs under +`https://schemas.openid.net/secevent/…`, carrying representative +payloads. They model the decoded claims-set bytes a Shared Signals +receiver hands to `Parse`, and drive the consumer flow end to end — Parse +→ Validate → decode the recognized event types through the registry → +leave the rest raw and byte-stable. The library defines no CAEP/RISC +payload types itself (those are downstream event vocabularies); the +interop tests register test-local decoders standing in for them. + +| File | Source | Exercises | +|---|---|---| +| `caep_session_revoked.json` | Synthetic, CAEP `session-revoked` shape | bare-string `aud`, a top-level `iss_sub` `sub_id` a receiver keys revocation by, one CAEP event decoded to a typed value through the registry | +| `risc_account_disabled.json` | Synthetic, RISC `account-disabled` shape | the RISC idiom of naming the subject *inside* the event payload (an RFC 9493 Subject Identifier object) rather than via a top-level `sub_id` | +| `multi_event.json` | Synthetic | the headline receiver pattern: several events of which the recognized CAEP/RISC types decode to typed values while an unregistered CAEP `credential-change` event stays raw and round-trips byte-stably | + +The CAEP/RISC event-type URIs are the real public identifiers under +`schemas.openid.net`, but the payloads are synthetic and the subjects use +the reserved example domains; no real subject data appears. The fixtures +are not imported from any sibling library — feeding a transport library's +own bytes in would invert the dependency direction (the SET-envelope +layer must not depend on the transport above it), so the interop boundary +is proven with hand-authored fixtures instead. + The synthetic fixtures use the `https://schemas.example.com` and `https://issuer.example.com` / `https://receiver.example.com` example domains (RFC 2606 reserves `example.com`); their event-type URIs are diff --git a/testdata/caep_session_revoked.json b/testdata/caep_session_revoked.json new file mode 100644 index 0000000..6f26cf3 --- /dev/null +++ b/testdata/caep_session_revoked.json @@ -0,0 +1,20 @@ +{ + "iss": "https://idp.example.com/", + "iat": 1615305600, + "jti": "caep-session-revoked-0001", + "aud": "https://receiver.example.com/caep", + "sub_id": { + "format": "iss_sub", + "iss": "https://idp.example.com/", + "sub": "user-7f3e2a" + }, + "events": { + "https://schemas.openid.net/secevent/caep/event-type/session-revoked": { + "event_timestamp": 1615305500, + "initiating_entity": "policy", + "reason_admin": { + "en": "Landspeed policy violation: IP login from multiple regions" + } + } + } +} diff --git a/testdata/multi_event.json b/testdata/multi_event.json new file mode 100644 index 0000000..4377f48 --- /dev/null +++ b/testdata/multi_event.json @@ -0,0 +1,35 @@ +{ + "iss": "https://idp.example.com/", + "iat": 1615307000, + "jti": "multi-event-0001", + "aud": [ + "https://receiver.example.com/caep", + "https://receiver.example.com/risc" + ], + "txn": "corr-8a2f19", + "sub_id": { + "format": "iss_sub", + "iss": "https://idp.example.com/", + "sub": "user-7f3e2a" + }, + "events": { + "https://schemas.openid.net/secevent/caep/event-type/session-revoked": { + "event_timestamp": 1615306900, + "initiating_entity": "admin" + }, + "https://schemas.openid.net/secevent/risc/event-type/account-disabled": { + "subject": { + "format": "iss_sub", + "iss": "https://idp.example.com/", + "sub": "user-7f3e2a" + }, + "reason": "account-takeover" + }, + "https://schemas.openid.net/secevent/caep/event-type/credential-change": { + "z_trailing": true, + "event_timestamp": 1615306950, + "credential_type": "password", + "change_type": "update" + } + } +} diff --git a/testdata/risc_account_disabled.json b/testdata/risc_account_disabled.json new file mode 100644 index 0000000..9287148 --- /dev/null +++ b/testdata/risc_account_disabled.json @@ -0,0 +1,16 @@ +{ + "iss": "https://idp.example.com/", + "iat": 1615306200, + "jti": "risc-account-disabled-0001", + "aud": "https://receiver.example.com/risc", + "events": { + "https://schemas.openid.net/secevent/risc/event-type/account-disabled": { + "subject": { + "format": "iss_sub", + "iss": "https://idp.example.com/", + "sub": "user-7f3e2a" + }, + "reason": "hijacking" + } + } +}