diff --git a/events/validated_event.go b/events/validated_event.go new file mode 100644 index 0000000..df64bb8 --- /dev/null +++ b/events/validated_event.go @@ -0,0 +1,55 @@ +package events + +// ValidatedEvent is an immutable wrapper around a fully validated event. It +// shares no memory with the original Event struct. +// +// When created with NewValidatedEvent, the wrapped event is guaranteed to: +// - have a valid structure +// - have a valid ID +// - have a valid signature +type ValidatedEvent struct { + event Event +} + +// NewValidatedEvent validates the provided Event. If valid, returns an +// immutable ValidatedEvent. Otherwise returns an error. +func NewValidatedEvent(e Event) (ValidatedEvent, error) { + if err := Validate(e); err != nil { + return ValidatedEvent{}, err + } + e.Tags = deepCopyTags(e.Tags) + return ValidatedEvent{event: e}, nil +} + +func (v ValidatedEvent) ID() string { return v.event.ID } +func (v ValidatedEvent) PubKey() string { return v.event.PubKey } +func (v ValidatedEvent) CreatedAt() int64 { return v.event.CreatedAt } +func (v ValidatedEvent) Kind() int { return v.event.Kind } +func (v ValidatedEvent) Content() string { return v.event.Content } +func (v ValidatedEvent) Sig() string { return v.event.Sig } + +// Tags returns a deep copy of the event's tag list. The returned slice shares +// no memory with the ValidatedEvent. +func (v ValidatedEvent) Tags() []Tag { return deepCopyTags(v.event.Tags) } + +// Event returns a deep copy of the underlying Event. The returned Event shares +// no memory with the ValidatedEvent. +func (v ValidatedEvent) Event() Event { + e := v.event + e.Tags = deepCopyTags(v.event.Tags) + return e +} + +func deepCopyTags(tags []Tag) []Tag { + cp := make([]Tag, len(tags)) + for i, tag := range tags { + tagcp := make(Tag, len(tag)) + copy(tagcp, tag) + cp[i] = tagcp + } + return cp +} + +func (v ValidatedEvent) MarshalJSON() ([]byte, error) { + return v.event.MarshalJSON() +} diff --git a/events/validated_event_test.go b/events/validated_event_test.go new file mode 100644 index 0000000..87e13cd --- /dev/null +++ b/events/validated_event_test.go @@ -0,0 +1,200 @@ +package events + +import ( + "encoding/json" + "github.com/stretchr/testify/assert" + "testing" +) + +var testEventWithTags = NewEvent( + WithID("c9a0f84fcaa889654da8992105eb122eb210c8cbd58210609a5ef7e170b51400"), + WithPubKey(testPK), + WithCreatedAt(1760740551), + WithKind(1), + WithTag(Tag{"a", "value"}), + WithTag(Tag{"b", "value", "optional"}), + WithContent("valid event"), + WithSig("668a715f1eb983172acf230d17bd283daedb2598adf8de4290bcc7eb0b802fdb60669d1e7d1104ac70393f4dbccd07e8abf897152af6ce6c0a75499874e27f14"), +) + +func TestValidatedEventConstruction(t *testing.T) { + t.Run("accepts valid event", func(t *testing.T) { + _, err := NewValidatedEvent(testEvent) + assert.NoError(t, err) + }) + + t.Run("rejects invalid structure", func(t *testing.T) { + bad := NewEvent( + WithID(testEvent.ID), + WithPubKey("notavalidpubkey"), + WithCreatedAt(testEvent.CreatedAt), + WithKind(testEvent.Kind), + WithContent(testEvent.Content), + WithSig(testEvent.Sig), + ) + _, err := NewValidatedEvent(bad) + assert.ErrorContains(t, err, "public key must be 64 lowercase hex characters") + }) + + t.Run("rejects id mismatch", func(t *testing.T) { + bad := NewEvent( + WithID("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + WithPubKey(testEvent.PubKey), + WithCreatedAt(testEvent.CreatedAt), + WithKind(testEvent.Kind), + WithContent(testEvent.Content), + WithSig(testEvent.Sig), + ) + _, err := NewValidatedEvent(bad) + assert.ErrorContains(t, err, "does not match computed id") + }) + + t.Run("rejects invalid signature", func(t *testing.T) { + bad := NewEvent( + WithID(testEvent.ID), + WithPubKey(testEvent.PubKey), + WithCreatedAt(testEvent.CreatedAt), + WithKind(testEvent.Kind), + WithContent(testEvent.Content), + WithSig("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + ) + _, err := NewValidatedEvent(bad) + assert.ErrorContains(t, err, "event signature is invalid") + }) +} + +func TestValidatedEventAccessors(t *testing.T) { + ve, err := NewValidatedEvent(testEventWithTags) + assert.NoError(t, err) + + t.Run("ID", func(t *testing.T) { + assert.Equal(t, testEventWithTags.ID, ve.ID()) + }) + + t.Run("PubKey", func(t *testing.T) { + assert.Equal(t, testEventWithTags.PubKey, ve.PubKey()) + }) + + t.Run("CreatedAt", func(t *testing.T) { + assert.Equal(t, testEventWithTags.CreatedAt, ve.CreatedAt()) + }) + + t.Run("Kind", func(t *testing.T) { + assert.Equal(t, testEventWithTags.Kind, ve.Kind()) + }) + + t.Run("Content", func(t *testing.T) { + assert.Equal(t, testEventWithTags.Content, ve.Content()) + }) + + t.Run("Sig", func(t *testing.T) { + assert.Equal(t, testEventWithTags.Sig, ve.Sig()) + }) + + t.Run("Tags", func(t *testing.T) { + assert.Equal(t, testEventWithTags.Tags, ve.Tags()) + }) +} + +func TestValidatedEventTagsImmutability(t *testing.T) { + t.Run("outer slice mutation is isolated", func(t *testing.T) { + ve, _ := NewValidatedEvent(testEventWithTags) + tags := ve.Tags() + tags[0] = Tag{"z", "injected"} + assert.Equal(t, Tag{"a", "value"}, ve.Tags()[0]) + }) + + t.Run("inner slice mutation is isolated", func(t *testing.T) { + ve, _ := NewValidatedEvent(testEventWithTags) + tags := ve.Tags() + tags[0][1] = "mutated" + assert.Equal(t, "value", ve.Tags()[0][1]) + }) + + t.Run("append to outer slice is isolated", func(t *testing.T) { + ve, _ := NewValidatedEvent(testEventWithTags) + originalLen := len(ve.Tags()) + tags := ve.Tags() + tags = append(tags, Tag{"new", "tag"}) + _ = tags + assert.Equal(t, originalLen, len(ve.Tags())) + }) + + t.Run("append to inner slice is isolated", func(t *testing.T) { + ve, _ := NewValidatedEvent(testEventWithTags) + originalLen := len(ve.Tags()[1]) + tags := ve.Tags() + tags[1] = append(tags[1], "extra") + assert.Equal(t, originalLen, len(ve.Tags()[1])) + }) + + t.Run("successive calls return independent copies", func(t *testing.T) { + ve, _ := NewValidatedEvent(testEventWithTags) + first := ve.Tags() + second := ve.Tags() + first[0][0] = "z" + assert.Equal(t, "a", second[0][0]) + }) +} + +func TestValidatedEventEventImmutability(t *testing.T) { + t.Run("outer tags mutation is isolated", func(t *testing.T) { + ve, _ := NewValidatedEvent(testEventWithTags) + e := ve.Event() + e.Tags[0] = Tag{"z", "injected"} + assert.Equal(t, Tag{"a", "value"}, ve.Tags()[0]) + }) + + t.Run("inner tags mutation is isolated", func(t *testing.T) { + ve, _ := NewValidatedEvent(testEventWithTags) + e := ve.Event() + e.Tags[0][1] = "mutated" + assert.Equal(t, "value", ve.Tags()[0][1]) + }) + + t.Run("scalar field mutation is isolated", func(t *testing.T) { + ve, _ := NewValidatedEvent(testEvent) + e := ve.Event() + e.Content = "tampered" + assert.Equal(t, testEvent.Content, ve.Content()) + }) + + t.Run("successive calls return independent copies", func(t *testing.T) { + ve, _ := NewValidatedEvent(testEventWithTags) + first := ve.Event() + second := ve.Event() + first.Tags[0][0] = "z" + assert.Equal(t, "a", second.Tags[0][0]) + }) + + t.Run("returned event passes validation", func(t *testing.T) { + ve, _ := NewValidatedEvent(testEventWithTags) + assert.NoError(t, Validate(ve.Event())) + }) + + t.Run("source event mutation after construction is isolated", func(t *testing.T) { + source := NewEvent( + WithID(testEventWithTags.ID), + WithPubKey(testEventWithTags.PubKey), + WithCreatedAt(testEventWithTags.CreatedAt), + WithKind(testEventWithTags.Kind), + WithTag(Tag{"a", "value"}), + WithTag(Tag{"b", "value", "optional"}), + WithContent(testEventWithTags.Content), + WithSig(testEventWithTags.Sig), + ) + ve, err := NewValidatedEvent(source) + assert.NoError(t, err) + source.Tags[0][1] = "mutated" + source.Content = "tampered" + assert.Equal(t, "value", ve.Tags()[0][1]) + assert.Equal(t, testEventWithTags.Content, ve.Content()) + }) +} + +func TestValidatedEventMarshalJSON(t *testing.T) { + ve, _ := NewValidatedEvent(testEvent) + jsonBytes, err := json.Marshal(ve) + assert.NoError(t, err) + assert.Equal(t, testEventJSON, string(jsonBytes)) +}