From c60a486f686a81dfcc6a4015325319d2467c549d Mon Sep 17 00:00:00 2001 From: arunesh-j Date: Sat, 22 Aug 2026 00:03:26 +0530 Subject: [PATCH 1/3] feat(oci): add Notifications (ONS) topics, subscriptions and publishing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements OCI Notifications against the portable notification driver. providers/oci/notifications: a Mock over memstore.Store guarded by a single RWMutex. Topics carry an ocid1.onstopic OCID, a compartment recorded at create and filtered on every list, a short topic id, lifecycle state, etag and creation time. Subscriptions carry an ocid1.onssubscription OCID and start PENDING with a confirmation token; a publish reaches only ACTIVE ones, so an unconfirmed endpoint receives nothing. Deleting a topic takes its subscriptions with it, as ONS does. server/oci/notifications: the /20181201 wire handler for topics, subscriptions, the two token-authenticated confirmation endpoints, the changeCompartment and resendConfirmation actions, and PublishMessage on the topic's own endpoint. Real ONS splits control and data plane by host rather than by prefix, so a topic reports the requesting origin as its apiEndpoint and a publish lands back on the same listener. DeleteTopic is asynchronous: it records a work request and answers 204 with opc-work-request-id. The OCI-only surface — subscription compartments, tags, metadata, delivery policy, the confirmation handshake and a topic's lifecycle state — is declared consumer-side as an Extras interface in the handler, with its value types in the provider. A driver that does not satisfy it is served 501. Nothing was added to services/notification/driver. Input the emulator cannot honour is refused rather than stored unused: message attributes, defined tags, protocols outside the ONS set, unknown message types and unsupported sort keys all answer 400 naming what is unsupported. --- docs/coverage/README.md | 2 +- docs/coverage/coverage.json | 3 +- docs/coverage/oci/README.md | 1 + docs/coverage/oci/notifications.md | 22 + providers/oci/notifications/notifications.go | 350 ++++++++++ .../oci/notifications/notifications_test.go | 645 ++++++++++++++++++ providers/oci/notifications/publish.go | 147 ++++ providers/oci/notifications/subscriptions.go | 452 ++++++++++++ providers/oci/oci.go | 2 + server/oci/notifications/handler.go | 211 ++++++ server/oci/notifications/handler_test.go | 620 +++++++++++++++++ server/oci/notifications/subscriptions.go | 345 ++++++++++ server/oci/notifications/topics.go | 390 +++++++++++ server/oci/notifications/types.go | 106 +++ server/oci/oci.go | 5 + 15 files changed, 3299 insertions(+), 2 deletions(-) create mode 100644 docs/coverage/oci/notifications.md create mode 100644 providers/oci/notifications/notifications.go create mode 100644 providers/oci/notifications/notifications_test.go create mode 100644 providers/oci/notifications/publish.go create mode 100644 providers/oci/notifications/subscriptions.go create mode 100644 server/oci/notifications/handler.go create mode 100644 server/oci/notifications/handler_test.go create mode 100644 server/oci/notifications/subscriptions.go create mode 100644 server/oci/notifications/topics.go create mode 100644 server/oci/notifications/types.go diff --git a/docs/coverage/README.md b/docs/coverage/README.md index 67243417b..742f5f391 100644 --- a/docs/coverage/README.md +++ b/docs/coverage/README.md @@ -113,7 +113,7 @@ code does not implement. Machine-readable: [`coverage.json`](./coverage.json). | `networkconnectivity` | — | — | [NetworkConnectivity](./gcp/networkconnectivity.md) | — | 11 | | `networkfirewall` | [NetworkFirewall](./aws/networkfirewall.md) | — | — | — | 23 | | `networking` | [VPC](./aws/vpc.md) | [VNet](./azure/vnet.md) | [VPC](./gcp/vpc.md) | [VCN](./oci/vcn.md) | 57 | -| `notification` | [SNS](./aws/sns.md) | [NotificationHubs](./azure/notificationhubs.md) | [FCM](./gcp/fcm.md) | — | 9 | +| `notification` | [SNS](./aws/sns.md) | [NotificationHubs](./azure/notificationhubs.md) | [FCM](./gcp/fcm.md) | [Notifications](./oci/notifications.md) | 9 | | `opensearch` | [OpenSearch](./aws/opensearch.md) | — | — | — | 96 | | `parameterstore` | [SSM](./aws/ssm.md) | — | — | — | 9 | | `postgresflex` | — | [PostgresFlex](./azure/postgresflex.md) | — | — | 21 | diff --git a/docs/coverage/coverage.json b/docs/coverage/coverage.json index 3cf0f52b7..ef7c02b54 100644 --- a/docs/coverage/coverage.json +++ b/docs/coverage/coverage.json @@ -10973,7 +10973,8 @@ "providers": { "aws": "SNS", "azure": "NotificationHubs", - "gcp": "FCM" + "gcp": "FCM", + "oci": "Notifications" } }, { diff --git a/docs/coverage/oci/README.md b/docs/coverage/oci/README.md index a04d3407d..16370719b 100644 --- a/docs/coverage/oci/README.md +++ b/docs/coverage/oci/README.md @@ -7,5 +7,6 @@ Services cloudemu emulates for OCI, by native name. Back to the [cross-provider | --- | --- | --- | | [Identity](./identity.md) | `iam` | 40 | | [Monitoring](./monitoring.md) | `monitoring` | 12 | +| [Notifications](./notifications.md) | `notification` | 9 | | [VCN](./vcn.md) | `networking` | 57 | | [Workrequest](./workrequest.md) | — (provider-native) | 4 | diff --git a/docs/coverage/oci/notifications.md b/docs/coverage/oci/notifications.md new file mode 100644 index 000000000..9367101d2 --- /dev/null +++ b/docs/coverage/oci/notifications.md @@ -0,0 +1,22 @@ + +# Notifications + +OCI's `notification` service · portable interface `driver.Notification` · [OCI index](./README.md) + +## Operations (9) + +| Operation | Description | +| --- | --- | +| `CreateTopic` | | +| `DeleteTopic` | | +| `GetTopic` | | +| `ListSubscriptions` | | +| `ListTopics` | | +| `Publish` | | +| `Subscribe` | | +| `Unsubscribe` | | +| `UpdateTopic` | UpdateTopic replaces the mutable fields (display name, tags) of an | + +## Not in scope + +_Not documented yet. See the [emulator boundary](../../../README.md) for cloudemu-wide non-goals._ diff --git a/providers/oci/notifications/notifications.go b/providers/oci/notifications/notifications.go new file mode 100644 index 000000000..1b95daefb --- /dev/null +++ b/providers/oci/notifications/notifications.go @@ -0,0 +1,350 @@ +// Package notifications provides an in-memory mock implementation of OCI +// Notifications (ONS). It implements the portable notification driver: an ONS +// topic is the topic and an ONS subscription is the subscription, with the +// PENDING-until-confirmed step ONS puts in front of delivery. +package notifications + +import ( + "context" + "maps" + "regexp" + "sync" + "time" + + "github.com/stackshy/cloudemu/v2/config" + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/internal/memstore" + mondriver "github.com/stackshy/cloudemu/v2/services/monitoring/driver" + "github.com/stackshy/cloudemu/v2/services/notification/driver" + "github.com/stackshy/cloudemu/v2/services/scope" +) + +// Compile-time check that Mock implements driver.Notification. The OCI-shaped +// capabilities live in server/oci/notifications and are checked there. +var _ driver.Notification = (*Mock)(nil) + +const timeFormat = time.RFC3339 + +// Lifecycle states ONS reports. +const ( + StateActive = "ACTIVE" + StatePending = "PENDING" + StateDeleted = "DELETED" +) + +// Subscription statuses as driver.SubscriptionInfo spells them. +const ( + StatusPending = "pending" + StatusConfirmed = "confirmed" +) + +// OCID resource type segments. +const ( + typeTopic = "onstopic" + typeSubscription = "onssubscription" +) + +// maxTopicNameLength is the limit ONS puts on a topic name. +const maxTopicNameLength = 256 + +// shortTopicIDLength is how much of the OCID's opaque suffix ONS reports as +// the topic's short id. +const shortTopicIDLength = 8 + +// topicNamePattern is the character set ONS allows in a topic name. +var topicNamePattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + +// metricNamespace is the namespace ONS publishes its metrics under. +const metricNamespace = "oci_notification" + +// TopicDetails is the OCI-only state of a topic; driver.TopicInfo has no room +// for it. +type TopicDetails struct { + ShortTopicID string + LifecycleState string + TimeCreated string + Etag string +} + +type topicData struct { + ID string + Name string + Description string + ShortTopicID string + LifecycleState string + TimeCreated string + Etag string + Scope scope.Scope + FreeformTags map[string]string +} + +// Mock is an in-memory mock implementation of OCI Notifications. +type Mock struct { + // mu guards the stored values and spans the reads and writes a single + // operation makes across stores: a publish walks the subscriptions of a + // topic it has just read, and deleting a topic drops both. + mu sync.RWMutex + + topics *memstore.Store[*topicData] + subs *memstore.Store[*Subscription] + // deliveries records what each subscription received, keyed by its OCID. + // Real ONS pushes to the endpoint; the emulator has nowhere to push. + deliveries *memstore.Store[[]Message] + + opts *config.Options + monitoring mondriver.Monitoring +} + +// New creates a new OCI Notifications mock. +func New(opts *config.Options) *Mock { + return &Mock{ + topics: memstore.New[*topicData](), + subs: memstore.New[*Subscription](), + deliveries: memstore.New[[]Message](), + opts: opts, + } +} + +// SetMonitoring sets the monitoring backend for auto-metric generation. +func (m *Mock) SetMonitoring(mon mondriver.Monitoring) { + m.mu.Lock() + defer m.mu.Unlock() + + m.monitoring = mon +} + +// now returns the current time in OCI's timestamp format. +func (m *Mock) now() string { + return m.opts.Clock.Now().UTC().Format(timeFormat) +} + +// CreateTopic creates an ONS topic. +// +//nolint:gocritic // hugeParam: interface method signature cannot be changed. +func (m *Mock) CreateTopic(_ context.Context, cfg driver.TopicConfig) (*driver.TopicInfo, error) { + if err := validateTopicName(cfg.Name); err != nil { + return nil, err + } + + m.mu.Lock() + defer m.mu.Unlock() + + place := cfg.Scope + if place.Compartment == "" { + place.Compartment = m.opts.CompartmentID + } + + if m.topicByName(place.Compartment, cfg.Name) != nil { + return nil, cerrors.Newf(cerrors.AlreadyExists, "topic %q already exists in compartment %s", + cfg.Name, place.Compartment) + } + + id := idgen.OCID(typeTopic, m.opts.Realm, m.opts.OCIRegion()) + + td := &topicData{ + ID: id, + Name: cfg.Name, + Description: cfg.DisplayName, + ShortTopicID: shortTopicID(id), + LifecycleState: StateActive, + TimeCreated: m.now(), + Etag: idgen.GenerateID("etag-"), + Scope: place, + FreeformTags: maps.Clone(cfg.Tags), + } + + m.topics.Set(id, td) + + return m.topicInfo(td), nil +} + +// GetTopic returns a topic by OCID. +func (m *Mock) GetTopic(_ context.Context, id string) (*driver.TopicInfo, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + td, ok := m.topics.Get(id) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "topic %q not found", id) + } + + return m.topicInfo(td), nil +} + +// ListTopics lists the topics visible under a compartment filter. +func (m *Mock) ListTopics(_ context.Context, filter scope.Scope) ([]driver.TopicInfo, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + all := m.topics.SortedValues() + out := make([]driver.TopicInfo, 0, len(all)) + + for _, td := range all { + if !td.Scope.Matches(filter) { + continue + } + + out = append(out, *m.topicInfo(td)) + } + + return out, nil +} + +// UpdateTopic replaces a topic's mutable fields. cfg.Name identifies the topic +// by OCID or by name; an ONS topic cannot be renamed, so the name is never a +// new value. An empty field leaves the stored one alone. +// +//nolint:gocritic // hugeParam: interface method signature cannot be changed. +func (m *Mock) UpdateTopic(_ context.Context, cfg driver.TopicConfig) (*driver.TopicInfo, error) { + m.mu.Lock() + defer m.mu.Unlock() + + td := m.resolveTopic(cfg.Name, cfg.Scope.Compartment) + if td == nil { + return nil, cerrors.Newf(cerrors.NotFound, "topic %q not found", cfg.Name) + } + + if cfg.DisplayName != "" { + td.Description = cfg.DisplayName + } + + if cfg.Tags != nil { + td.FreeformTags = maps.Clone(cfg.Tags) + } + + if !cfg.Scope.IsZero() { + td.Scope = cfg.Scope + } + + td.Etag = idgen.GenerateID("etag-") + + m.topics.Set(td.ID, td) + + return m.topicInfo(td), nil +} + +// DeleteTopic deletes a topic and every subscription on it, as ONS does. +func (m *Mock) DeleteTopic(_ context.Context, id string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.topics.Has(id) { + return cerrors.Newf(cerrors.NotFound, "topic %q not found", id) + } + + m.topics.Delete(id) + + for subID, sub := range m.subs.All() { + if sub.TopicID == id { + m.subs.Delete(subID) + m.deliveries.Delete(subID) + } + } + + return nil +} + +// TopicDetails returns the OCI-only state of a topic. It is an OPTIONAL +// capability, discovered by type assertion. +func (m *Mock) TopicDetails(id string) (TopicDetails, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + + td, ok := m.topics.Get(id) + if !ok { + return TopicDetails{}, false + } + + return TopicDetails{ + ShortTopicID: td.ShortTopicID, + LifecycleState: td.LifecycleState, + TimeCreated: td.TimeCreated, + Etag: td.Etag, + }, true +} + +// topicInfo projects stored state onto the portable shape. The caller holds mu. +func (m *Mock) topicInfo(td *topicData) *driver.TopicInfo { + count := 0 + + for _, sub := range m.subs.All() { + if sub.TopicID == td.ID { + count++ + } + } + + return &driver.TopicInfo{ + ID: td.ID, + Name: td.Name, + ResourceID: td.ID, + DisplayName: td.Description, + SubscriptionCount: count, + Tags: maps.Clone(td.FreeformTags), + Scope: td.Scope, + } +} + +// topicByName finds a topic by name within a compartment. The caller holds mu. +func (m *Mock) topicByName(compartment, name string) *topicData { + for _, td := range m.topics.SortedValues() { + if td.Name == name && td.Scope.Compartment == compartment { + return td + } + } + + return nil +} + +// resolveTopic finds a topic by OCID, falling back to its name. The caller +// holds mu. +func (m *Mock) resolveTopic(ref, compartment string) *topicData { + if td, ok := m.topics.Get(ref); ok { + return td + } + + if compartment == "" { + compartment = m.opts.CompartmentID + } + + return m.topicByName(compartment, ref) +} + +// emitMetric records an ONS metric. Called with mu released. +func (m *Mock) emitMetric(name string, value float64, dims map[string]string) { + m.mu.RLock() + mon := m.monitoring + m.mu.RUnlock() + + if mon == nil { + return + } + + _ = mon.PutMetricData(context.Background(), []mondriver.MetricDatum{{ + Namespace: metricNamespace, MetricName: name, Value: value, Unit: "Count", + Dimensions: dims, Timestamp: m.opts.Clock.Now(), + }}) +} + +// validateTopicName applies the constraints ONS puts on a topic name. +func validateTopicName(name string) error { + switch { + case name == "": + return cerrors.New(cerrors.InvalidArgument, "topic name is required") + case len(name) > maxTopicNameLength: + return cerrors.Newf(cerrors.InvalidArgument, "topic name must be at most %d characters", maxTopicNameLength) + case !topicNamePattern.MatchString(name): + return cerrors.Newf(cerrors.InvalidArgument, + "topic name %q may contain only letters, numbers, dashes and underscores", name) + } + + return nil +} + +// shortTopicID is the leading run of the OCID's opaque suffix, which ONS +// reports alongside the full OCID. +func shortTopicID(ocid string) string { + suffix := ocid[len(ocid)-min(len(ocid), shortTopicIDLength):] + + return suffix +} diff --git a/providers/oci/notifications/notifications_test.go b/providers/oci/notifications/notifications_test.go new file mode 100644 index 000000000..08f3dc7d5 --- /dev/null +++ b/providers/oci/notifications/notifications_test.go @@ -0,0 +1,645 @@ +package notifications_test + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stackshy/cloudemu/v2/config" + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/providers/oci/notifications" + "github.com/stackshy/cloudemu/v2/services/notification/driver" + "github.com/stackshy/cloudemu/v2/services/scope" +) + +const ( + compartment = "ocid1.compartment.oc1..aaaaaaaatest" + otherCompartment = "ocid1.compartment.oc1..aaaaaaaaother" + region = "us-ashburn-1" +) + +func newMock(t *testing.T) *notifications.Mock { + t.Helper() + + return notifications.New(config.NewOptions( + config.WithRegion(region), + config.WithCompartmentID(compartment), + )) +} + +// newTopic creates a topic in the given compartment and returns its OCID. +func newTopic(t *testing.T, m *notifications.Mock, name, compartmentID string) string { + t.Helper() + + info, err := m.CreateTopic(context.Background(), driver.TopicConfig{ + Name: name, + DisplayName: "topic " + name, + Scope: scope.Scope{Compartment: compartmentID}, + }) + require.NoError(t, err) + + return info.ID +} + +func TestCreateTopic(t *testing.T) { + tests := []struct { + name string + topicName string + twice bool + code cerrors.Code + }{ + {name: "valid name", topicName: "alerts"}, + {name: "dashes and underscores", topicName: "prod-alerts_v2"}, + {name: "empty name", topicName: "", code: cerrors.InvalidArgument}, + {name: "illegal characters", topicName: "prod alerts!", code: cerrors.InvalidArgument}, + {name: "too long", topicName: strings.Repeat("a", 257), code: cerrors.InvalidArgument}, + {name: "duplicate in compartment", topicName: "alerts", twice: true, code: cerrors.AlreadyExists}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + m := newMock(t) + + if tc.twice { + _, err := m.CreateTopic(t.Context(), driver.TopicConfig{Name: tc.topicName}) + require.NoError(t, err) + } + + info, err := m.CreateTopic(t.Context(), driver.TopicConfig{ + Name: tc.topicName, + DisplayName: "the topic", + Tags: map[string]string{"env": "prod"}, + }) + + if tc.code != cerrors.OK { + require.Error(t, err) + assert.Equal(t, tc.code, cerrors.GetCode(err)) + + return + } + + require.NoError(t, err) + assert.Equal(t, tc.topicName, info.Name) + assert.Equal(t, "the topic", info.DisplayName) + assert.Equal(t, compartment, info.Scope.Compartment) + assert.Equal(t, info.ID, info.ResourceID) + assert.Equal(t, map[string]string{"env": "prod"}, info.Tags) + }) + } +} + +func TestTopicOCIDShape(t *testing.T) { + m := newMock(t) + topicID := newTopic(t, m, "alerts", compartment) + + assert.True(t, strings.HasPrefix(topicID, "ocid1.onstopic.oc1.iad."), topicID) + + sub, err := m.CreateSubscription(t.Context(), notifications.SubscriptionSpec{ + TopicID: topicID, Protocol: "EMAIL", Endpoint: "ops@example.com", + }) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(sub.ID, "ocid1.onssubscription.oc1.iad."), sub.ID) +} + +func TestTopicDetails(t *testing.T) { + m := newMock(t) + topicID := newTopic(t, m, "alerts", compartment) + + details, ok := m.TopicDetails(topicID) + require.True(t, ok) + assert.Equal(t, notifications.StateActive, details.LifecycleState) + assert.NotEmpty(t, details.TimeCreated) + assert.NotEmpty(t, details.Etag) + assert.Len(t, details.ShortTopicID, 8) + + _, ok = m.TopicDetails("ocid1.onstopic.oc1.iad.missing") + assert.False(t, ok) +} + +func TestTopicLifecycle(t *testing.T) { + m := newMock(t) + topicID := newTopic(t, m, "alerts", compartment) + + got, err := m.GetTopic(t.Context(), topicID) + require.NoError(t, err) + assert.Equal(t, "alerts", got.Name) + + updated, err := m.UpdateTopic(t.Context(), driver.TopicConfig{ + Name: topicID, + DisplayName: "renamed description", + Tags: map[string]string{"team": "sre"}, + }) + require.NoError(t, err) + assert.Equal(t, "alerts", updated.Name, "ONS does not rename a topic") + assert.Equal(t, "renamed description", updated.DisplayName) + assert.Equal(t, map[string]string{"team": "sre"}, updated.Tags) + + byName, err := m.UpdateTopic(t.Context(), driver.TopicConfig{Name: "alerts", DisplayName: "by name"}) + require.NoError(t, err) + assert.Equal(t, topicID, byName.ID) + + require.NoError(t, m.DeleteTopic(t.Context(), topicID)) + + _, err = m.GetTopic(t.Context(), topicID) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) +} + +func TestTopicNotFound(t *testing.T) { + const missing = "ocid1.onstopic.oc1.iad.missing" + + m := newMock(t) + + tests := []struct { + name string + call func() error + }{ + {name: "get", call: func() error { _, err := m.GetTopic(t.Context(), missing); return err }}, + {name: "delete", call: func() error { return m.DeleteTopic(t.Context(), missing) }}, + { + name: "update", + call: func() error { + _, err := m.UpdateTopic(t.Context(), driver.TopicConfig{Name: missing}) + + return err + }, + }, + { + name: "subscribe", + call: func() error { + _, err := m.Subscribe(t.Context(), driver.SubscriptionConfig{ + TopicID: missing, Protocol: "EMAIL", Endpoint: "a@b.c", + }) + + return err + }, + }, + { + name: "list subscriptions", + call: func() error { _, err := m.ListSubscriptions(t.Context(), missing); return err }, + }, + { + name: "publish", + call: func() error { + _, err := m.Publish(t.Context(), driver.PublishInput{TopicID: missing, Message: "hi"}) + + return err + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(tc.call())) + }) + } +} + +func TestListTopicsFiltersByCompartment(t *testing.T) { + m := newMock(t) + newTopic(t, m, "mine", compartment) + newTopic(t, m, "theirs", otherCompartment) + + tests := []struct { + name string + filter scope.Scope + expect []string + }{ + {name: "own compartment", filter: scope.Scope{Compartment: compartment}, expect: []string{"mine"}}, + {name: "other compartment", filter: scope.Scope{Compartment: otherCompartment}, expect: []string{"theirs"}}, + { + name: "unknown compartment", + filter: scope.Scope{Compartment: "ocid1.compartment.oc1..aaaaaaaanone"}, + expect: []string{}, + }, + {name: "unscoped lists all", filter: scope.Scope{}, expect: []string{"mine", "theirs"}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + topics, err := m.ListTopics(t.Context(), tc.filter) + require.NoError(t, err) + + names := make([]string, 0, len(topics)) + for _, topic := range topics { + names = append(names, topic.Name) + } + + assert.ElementsMatch(t, tc.expect, names) + }) + } +} + +func TestSameTopicNameInAnotherCompartment(t *testing.T) { + m := newMock(t) + first := newTopic(t, m, "alerts", compartment) + second := newTopic(t, m, "alerts", otherCompartment) + + assert.NotEqual(t, first, second) +} + +func TestCreateSubscription(t *testing.T) { + m := newMock(t) + topicID := newTopic(t, m, "alerts", compartment) + + tests := []struct { + name string + protocol string + endpoint string + expect string + code cerrors.Code + }{ + {name: "email", protocol: "EMAIL", endpoint: "ops@example.com", expect: notifications.ProtocolEmail}, + {name: "lowercase alias", protocol: "email", endpoint: "a@example.com", expect: notifications.ProtocolEmail}, + {name: "https alias", protocol: "https", endpoint: "https://hook", expect: notifications.ProtocolHTTPS}, + {name: "sms", protocol: "SMS", endpoint: "+15550100", expect: notifications.ProtocolSMS}, + {name: "unknown protocol", protocol: "sqs", endpoint: "q", code: cerrors.InvalidArgument}, + {name: "missing protocol", protocol: "", endpoint: "q", code: cerrors.InvalidArgument}, + {name: "missing endpoint", protocol: "EMAIL", endpoint: "", code: cerrors.InvalidArgument}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + sub, err := m.CreateSubscription(t.Context(), notifications.SubscriptionSpec{ + TopicID: topicID, Protocol: tc.protocol, Endpoint: tc.endpoint, + }) + + if tc.code != cerrors.OK { + require.Error(t, err) + assert.Equal(t, tc.code, cerrors.GetCode(err)) + + return + } + + require.NoError(t, err) + assert.Equal(t, tc.expect, sub.Protocol) + assert.Equal(t, notifications.StatePending, sub.LifecycleState) + assert.NotEmpty(t, sub.ConfirmationToken) + assert.Equal(t, compartment, sub.CompartmentID) + }) + } +} + +func TestDuplicateSubscription(t *testing.T) { + m := newMock(t) + topicID := newTopic(t, m, "alerts", compartment) + + spec := notifications.SubscriptionSpec{TopicID: topicID, Protocol: "EMAIL", Endpoint: "ops@example.com"} + + _, err := m.CreateSubscription(t.Context(), spec) + require.NoError(t, err) + + _, err = m.CreateSubscription(t.Context(), spec) + assert.Equal(t, cerrors.AlreadyExists, cerrors.GetCode(err)) +} + +// TestConfirmationFlow is the PENDING -> ACTIVE transition ONS puts in front +// of delivery, and the guarantee that nothing reaches an unconfirmed endpoint. +func TestConfirmationFlow(t *testing.T) { + m := newMock(t) + topicID := newTopic(t, m, "alerts", compartment) + + sub, err := m.CreateSubscription(t.Context(), notifications.SubscriptionSpec{ + TopicID: topicID, Protocol: "EMAIL", Endpoint: "ops@example.com", + }) + require.NoError(t, err) + require.Equal(t, notifications.StatePending, sub.LifecycleState) + + _, err = m.PublishMessage(t.Context(), topicID, notifications.MessageSpec{Body: "before confirmation"}) + require.NoError(t, err) + assert.Empty(t, m.Deliveries(sub.ID), "a PENDING subscription must receive nothing") + + subs, err := m.ListSubscriptions(t.Context(), topicID) + require.NoError(t, err) + require.Len(t, subs, 1) + assert.Equal(t, notifications.StatusPending, subs[0].Status) + + result, err := m.ConfirmSubscription(t.Context(), sub.ID, sub.ConfirmationToken, "EMAIL") + require.NoError(t, err) + assert.Equal(t, "alerts", result.TopicName) + assert.Equal(t, sub.ID, result.SubscriptionID) + + confirmed, err := m.GetSubscription(t.Context(), sub.ID) + require.NoError(t, err) + assert.Equal(t, notifications.StateActive, confirmed.LifecycleState) + + subs, err = m.ListSubscriptions(t.Context(), topicID) + require.NoError(t, err) + assert.Equal(t, notifications.StatusConfirmed, subs[0].Status) + + msg, err := m.PublishMessage(t.Context(), topicID, notifications.MessageSpec{ + Title: "disk", Body: "after confirmation", + }) + require.NoError(t, err) + + delivered := m.Deliveries(sub.ID) + require.Len(t, delivered, 1) + assert.Equal(t, msg.ID, delivered[0].ID) + assert.Equal(t, "after confirmation", delivered[0].Body) + assert.Equal(t, notifications.MessageTypeRawText, delivered[0].Type) +} + +func TestConfirmSubscriptionErrors(t *testing.T) { + m := newMock(t) + topicID := newTopic(t, m, "alerts", compartment) + + sub, err := m.CreateSubscription(t.Context(), notifications.SubscriptionSpec{ + TopicID: topicID, Protocol: "EMAIL", Endpoint: "ops@example.com", + }) + require.NoError(t, err) + + tests := []struct { + name string + id string + token string + protocol string + code cerrors.Code + }{ + {name: "wrong token", id: sub.ID, token: "nope", code: cerrors.InvalidArgument}, + {name: "missing token", id: sub.ID, token: "", code: cerrors.InvalidArgument}, + { + name: "mismatched protocol", id: sub.ID, token: sub.ConfirmationToken, + protocol: "SMS", code: cerrors.InvalidArgument, + }, + { + name: "unknown subscription", id: "ocid1.onssubscription.oc1.iad.missing", + token: sub.ConfirmationToken, code: cerrors.NotFound, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := m.ConfirmSubscription(t.Context(), tc.id, tc.token, tc.protocol) + require.Error(t, err) + assert.Equal(t, tc.code, cerrors.GetCode(err)) + }) + } +} + +func TestResendConfirmation(t *testing.T) { + m := newMock(t) + topicID := newTopic(t, m, "alerts", compartment) + + sub, err := m.CreateSubscription(t.Context(), notifications.SubscriptionSpec{ + TopicID: topicID, Protocol: "EMAIL", Endpoint: "ops@example.com", + }) + require.NoError(t, err) + + resent, err := m.ResendSubscriptionConfirmation(t.Context(), sub.ID) + require.NoError(t, err) + assert.NotEqual(t, sub.ConfirmationToken, resent.ConfirmationToken) + + _, err = m.ConfirmSubscription(t.Context(), sub.ID, resent.ConfirmationToken, "") + require.NoError(t, err) + + _, err = m.ResendSubscriptionConfirmation(t.Context(), sub.ID) + assert.Equal(t, cerrors.FailedPrecondition, cerrors.GetCode(err)) +} + +func TestUnsubscribe(t *testing.T) { + m := newMock(t) + topicID := newTopic(t, m, "alerts", compartment) + + sub, err := m.Subscribe(t.Context(), driver.SubscriptionConfig{ + TopicID: topicID, Protocol: "email", Endpoint: "ops@example.com", + }) + require.NoError(t, err) + assert.Equal(t, notifications.StatusPending, sub.Status) + + require.NoError(t, m.Unsubscribe(t.Context(), sub.ID)) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(m.Unsubscribe(t.Context(), sub.ID))) +} + +func TestUnsubscribeByToken(t *testing.T) { + m := newMock(t) + topicID := newTopic(t, m, "alerts", compartment) + + sub, err := m.CreateSubscription(t.Context(), notifications.SubscriptionSpec{ + TopicID: topicID, Protocol: "EMAIL", Endpoint: "ops@example.com", + }) + require.NoError(t, err) + + err = m.UnsubscribeByToken(t.Context(), sub.ID, "wrong", "") + assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(err)) + + require.NoError(t, m.UnsubscribeByToken(t.Context(), sub.ID, sub.ConfirmationToken, "EMAIL")) + + _, err = m.GetSubscription(t.Context(), sub.ID) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) +} + +func TestListOCISubscriptions(t *testing.T) { + m := newMock(t) + mine := newTopic(t, m, "mine", compartment) + theirs := newTopic(t, m, "theirs", otherCompartment) + + _, err := m.CreateSubscription(t.Context(), notifications.SubscriptionSpec{ + TopicID: mine, Protocol: "EMAIL", Endpoint: "a@example.com", + }) + require.NoError(t, err) + + _, err = m.CreateSubscription(t.Context(), notifications.SubscriptionSpec{ + TopicID: theirs, CompartmentID: otherCompartment, Protocol: "EMAIL", Endpoint: "b@example.com", + }) + require.NoError(t, err) + + tests := []struct { + name string + compartment string + topicID string + expect int + }{ + {name: "own compartment", compartment: compartment, expect: 1}, + {name: "other compartment", compartment: otherCompartment, expect: 1}, + {name: "narrowed to topic", compartment: compartment, topicID: mine, expect: 1}, + {name: "topic in another compartment", compartment: compartment, topicID: theirs, expect: 0}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + subs, err := m.ListOCISubscriptions(t.Context(), tc.compartment, tc.topicID) + require.NoError(t, err) + assert.Len(t, subs, tc.expect) + }) + } +} + +func TestUpdateAndMoveSubscription(t *testing.T) { + m := newMock(t) + topicID := newTopic(t, m, "alerts", compartment) + + sub, err := m.CreateSubscription(t.Context(), notifications.SubscriptionSpec{ + TopicID: topicID, Protocol: "https", Endpoint: "https://hook", + }) + require.NoError(t, err) + + updated, err := m.UpdateSubscription(t.Context(), sub.ID, notifications.SubscriptionPatch{ + DeliveryPolicy: ¬ifications.DeliveryPolicy{ + BackoffRetryPolicy: ¬ifications.BackoffRetryPolicy{MaxRetryDuration: 7200, PolicyType: "EXPONENTIAL"}, + }, + FreeformTags: map[string]string{"team": "sre"}, + }) + require.NoError(t, err) + require.NotNil(t, updated.DeliveryPolicy) + assert.Equal(t, 7200, updated.DeliveryPolicy.BackoffRetryPolicy.MaxRetryDuration) + assert.Equal(t, map[string]string{"team": "sre"}, updated.FreeformTags) + + require.NoError(t, m.ChangeSubscriptionCompartment(t.Context(), sub.ID, otherCompartment)) + + moved, err := m.GetSubscription(t.Context(), sub.ID) + require.NoError(t, err) + assert.Equal(t, otherCompartment, moved.CompartmentID) + + _, err = m.UpdateSubscription(t.Context(), "ocid1.onssubscription.oc1.iad.missing", + notifications.SubscriptionPatch{}) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) +} + +func TestDeleteTopicRemovesItsSubscriptions(t *testing.T) { + m := newMock(t) + topicID := newTopic(t, m, "alerts", compartment) + other := newTopic(t, m, "keep", compartment) + + doomed, err := m.CreateSubscription(t.Context(), notifications.SubscriptionSpec{ + TopicID: topicID, Protocol: "EMAIL", Endpoint: "a@example.com", + }) + require.NoError(t, err) + + survivor, err := m.CreateSubscription(t.Context(), notifications.SubscriptionSpec{ + TopicID: other, Protocol: "EMAIL", Endpoint: "b@example.com", + }) + require.NoError(t, err) + + require.NoError(t, m.DeleteTopic(t.Context(), topicID)) + + _, err = m.GetSubscription(t.Context(), doomed.ID) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) + + _, err = m.GetSubscription(t.Context(), survivor.ID) + assert.NoError(t, err) +} + +func TestPublish(t *testing.T) { + m := newMock(t) + topicID := newTopic(t, m, "alerts", compartment) + + tests := []struct { + name string + input driver.PublishInput + code cerrors.Code + }{ + {name: "ok", input: driver.PublishInput{TopicID: topicID, Subject: "s", Message: "hello"}}, + {name: "empty message", input: driver.PublishInput{TopicID: topicID}, code: cerrors.InvalidArgument}, + { + name: "attributes are refused, not dropped", + input: driver.PublishInput{ + TopicID: topicID, Message: "hello", Attributes: map[string]string{"k": "v"}, + }, + code: cerrors.InvalidArgument, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + out, err := m.Publish(t.Context(), tc.input) + + if tc.code != cerrors.OK { + require.Error(t, err) + assert.Equal(t, tc.code, cerrors.GetCode(err)) + + return + } + + require.NoError(t, err) + assert.NotEmpty(t, out.MessageID) + }) + } +} + +func TestPublishMessageType(t *testing.T) { + m := newMock(t) + topicID := newTopic(t, m, "alerts", compartment) + + tests := []struct { + name string + msgType string + expect string + code cerrors.Code + }{ + {name: "default", msgType: "", expect: notifications.MessageTypeRawText}, + {name: "raw text", msgType: "RAW_TEXT", expect: notifications.MessageTypeRawText}, + {name: "json", msgType: "JSON", expect: notifications.MessageTypeJSON}, + {name: "unknown", msgType: "PROTOBUF", code: cerrors.InvalidArgument}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + msg, err := m.PublishMessage(t.Context(), topicID, + notifications.MessageSpec{Body: "hello", Type: tc.msgType}) + + if tc.code != cerrors.OK { + require.Error(t, err) + assert.Equal(t, tc.code, cerrors.GetCode(err)) + + return + } + + require.NoError(t, err) + assert.Equal(t, tc.expect, msg.Type) + }) + } +} + +func TestSubscriptionCountTracksTopic(t *testing.T) { + m := newMock(t) + topicID := newTopic(t, m, "alerts", compartment) + + info, err := m.GetTopic(t.Context(), topicID) + require.NoError(t, err) + assert.Equal(t, 0, info.SubscriptionCount) + + _, err = m.Subscribe(t.Context(), driver.SubscriptionConfig{ + TopicID: topicID, Protocol: "EMAIL", Endpoint: "a@example.com", + }) + require.NoError(t, err) + + info, err = m.GetTopic(t.Context(), topicID) + require.NoError(t, err) + assert.Equal(t, 1, info.SubscriptionCount) +} + +// TestConcurrentUse exercises the mutex under -race: every exported method +// locks, and none of them calls another that locks. +func TestConcurrentUse(t *testing.T) { + m := newMock(t) + topicID := newTopic(t, m, "alerts", compartment) + + done := make(chan struct{}) + + for i := range 8 { + go func(i int) { + defer func() { done <- struct{}{} }() + + ctx := context.Background() + + sub, err := m.CreateSubscription(ctx, notifications.SubscriptionSpec{ + TopicID: topicID, Protocol: "EMAIL", Endpoint: string(rune('a'+i)) + "@example.com", + }) + if err != nil { + return + } + + _, _ = m.ConfirmSubscription(ctx, sub.ID, sub.ConfirmationToken, "EMAIL") + _, _ = m.PublishMessage(ctx, topicID, notifications.MessageSpec{Body: "hello"}) + _, _ = m.ListOCISubscriptions(ctx, compartment, topicID) + _, _ = m.ListTopics(ctx, scope.Scope{Compartment: compartment}) + m.Deliveries(sub.ID) + }(i) + } + + for range 8 { + <-done + } +} diff --git a/providers/oci/notifications/publish.go b/providers/oci/notifications/publish.go new file mode 100644 index 000000000..68251b8b1 --- /dev/null +++ b/providers/oci/notifications/publish.go @@ -0,0 +1,147 @@ +package notifications + +import ( + "context" + "strings" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/services/notification/driver" +) + +// Message body encodings ONS accepts on PublishMessage. +const ( + MessageTypeRawText = "RAW_TEXT" + MessageTypeJSON = "JSON" +) + +// MessageSpec is a message to publish to a topic. +type MessageSpec struct { + Title string + Body string + Type string +} + +// Message is a published message as it was delivered. +type Message struct { + ID string + TopicID string + Title string + Body string + Type string + Timestamp string +} + +// Publish publishes a message to a topic. It is the portable entry point onto +// PublishMessage. +func (m *Mock) Publish(ctx context.Context, input driver.PublishInput) (*driver.PublishOutput, error) { + // ONS carries no per-message attributes, so accepting them would drop + // them silently. + if len(input.Attributes) > 0 { + return nil, cerrors.New(cerrors.InvalidArgument, + "OCI Notifications does not carry message attributes") + } + + msg, err := m.PublishMessage(ctx, input.TopicID, MessageSpec{ + Title: input.Subject, + Body: input.Message, + Type: MessageTypeRawText, + }) + if err != nil { + return nil, err + } + + return &driver.PublishOutput{MessageID: msg.ID}, nil +} + +// PublishMessage publishes a message to a topic, delivering it to every ACTIVE +// subscription. A subscription still PENDING receives nothing. +func (m *Mock) PublishMessage(_ context.Context, topicID string, spec MessageSpec) (*Message, error) { + if spec.Body == "" { + return nil, cerrors.New(cerrors.InvalidArgument, "message body is required") + } + + msgType, err := normalizeMessageType(spec.Type) + if err != nil { + return nil, err + } + + msg, delivered, err := m.deliver(topicID, spec, msgType) + if err != nil { + return nil, err + } + + // Emitted outside the lock: the monitoring backend is another driver, and + // holding mu across it would make the two mocks lock-ordered. + dims := map[string]string{"topicId": topicID} + m.emitMetric("PublishedMessages", 1, dims) + m.emitMetric("DeliveredMessages", float64(delivered), dims) + + return msg, nil +} + +// deliver records a message against every ACTIVE subscription on the topic and +// reports how many received it. +func (m *Mock) deliver(topicID string, spec MessageSpec, msgType string) (*Message, int, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.topics.Has(topicID) { + return nil, 0, cerrors.Newf(cerrors.NotFound, "topic %q not found", topicID) + } + + msg := Message{ + ID: idgen.GenerateID("msg-"), + TopicID: topicID, + Title: spec.Title, + Body: spec.Body, + Type: msgType, + Timestamp: m.now(), + } + + delivered := 0 + + for _, sub := range m.subs.SortedValues() { + if sub.TopicID != topicID || sub.LifecycleState != StateActive { + continue + } + + existing, _ := m.deliveries.Get(sub.ID) + m.deliveries.Set(sub.ID, append(existing, msg)) + + delivered++ + } + + return &msg, delivered, nil +} + +// Deliveries returns the messages a subscription received. Real ONS pushes to +// the endpoint; the emulator records them here instead. +func (m *Mock) Deliveries(subscriptionID string) []Message { + m.mu.RLock() + defer m.mu.RUnlock() + + stored, ok := m.deliveries.Get(subscriptionID) + if !ok { + return nil + } + + out := make([]Message, len(stored)) + copy(out, stored) + + return out +} + +// normalizeMessageType defaults an unset message type to RAW_TEXT and rejects +// an encoding ONS does not define. +func normalizeMessageType(msgType string) (string, error) { + switch strings.ToUpper(msgType) { + case "", MessageTypeRawText: + return MessageTypeRawText, nil + case MessageTypeJSON: + return MessageTypeJSON, nil + } + + return "", cerrors.Newf(cerrors.InvalidArgument, + "messageType %q is not supported; want %s or %s", msgType, MessageTypeRawText, MessageTypeJSON) +} diff --git a/providers/oci/notifications/subscriptions.go b/providers/oci/notifications/subscriptions.go new file mode 100644 index 000000000..56ae1bef8 --- /dev/null +++ b/providers/oci/notifications/subscriptions.go @@ -0,0 +1,452 @@ +package notifications + +import ( + "context" + "maps" + "strings" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/services/notification/driver" +) + +// Protocols ONS delivers over. +const ( + ProtocolEmail = "EMAIL" + ProtocolSMS = "SMS" + ProtocolHTTPS = "CUSTOM_HTTPS" + ProtocolSlack = "SLACK" + ProtocolPagerDuty = "PAGERDUTY" + ProtocolFunctions = "ORACLE_FUNCTIONS" +) + +// protocolAliases maps the lowercase names portable callers use onto the ONS +// protocol they name. "http" and "https" are both CUSTOM_HTTPS, the one +// webhook protocol ONS has. +var protocolAliases = map[string]string{ //nolint:gochecknoglobals // lookup table + "EMAIL": ProtocolEmail, + "SMS": ProtocolSMS, + "HTTP": ProtocolHTTPS, + "HTTPS": ProtocolHTTPS, + "CUSTOM_HTTPS": ProtocolHTTPS, + "SLACK": ProtocolSlack, + "PAGERDUTY": ProtocolPagerDuty, + "ORACLE_FUNCTIONS": ProtocolFunctions, +} + +// BackoffRetryPolicy is the retry schedule a delivery policy applies. +type BackoffRetryPolicy struct { + MaxRetryDuration int + PolicyType string +} + +// DeliveryPolicy is an ONS subscription's delivery configuration. +type DeliveryPolicy struct { + BackoffRetryPolicy *BackoffRetryPolicy +} + +// SubscriptionSpec describes an ONS subscription to create. +type SubscriptionSpec struct { + TopicID string + CompartmentID string + Protocol string + Endpoint string + Metadata string + FreeformTags map[string]string +} + +// SubscriptionPatch carries the mutable fields of a subscription. A nil field +// leaves the stored one alone. +type SubscriptionPatch struct { + DeliveryPolicy *DeliveryPolicy + FreeformTags map[string]string +} + +// Subscription is an ONS subscription in full. +type Subscription struct { + ID string + TopicID string + CompartmentID string + Protocol string + Endpoint string + Metadata string + LifecycleState string + // CreatedTime is epoch milliseconds, as ONS reports it. + CreatedTime int64 + DeliveryPolicy *DeliveryPolicy + Etag string + // ConfirmationToken is mailed to the endpoint by real ONS. The emulator + // has no channel to deliver it on, so it is readable here instead. + ConfirmationToken string + FreeformTags map[string]string +} + +// ConfirmationResult is what ONS returns from ConfirmSubscription. The +// unsubscribe URL is built by the wire layer, which knows its own origin. +type ConfirmationResult struct { + TopicName string + TopicID string + Endpoint string + SubscriptionID string + Token string + Message string +} + +// Subscribe creates a subscription in the default compartment. It is the +// portable entry point onto CreateSubscription. +func (m *Mock) Subscribe(ctx context.Context, cfg driver.SubscriptionConfig) (*driver.SubscriptionInfo, error) { + sub, err := m.CreateSubscription(ctx, SubscriptionSpec{ + TopicID: cfg.TopicID, + Protocol: cfg.Protocol, + Endpoint: cfg.Endpoint, + }) + if err != nil { + return nil, err + } + + return subscriptionInfo(sub), nil +} + +// CreateSubscription creates an ONS subscription. It starts PENDING: ONS +// delivers nothing to it until the endpoint owner confirms with the token. +// +//nolint:gocritic // hugeParam: spec is the subscription's full definition. +func (m *Mock) CreateSubscription(_ context.Context, spec SubscriptionSpec) (*Subscription, error) { + protocol, err := normalizeProtocol(spec.Protocol) + if err != nil { + return nil, err + } + + if spec.Endpoint == "" { + return nil, cerrors.New(cerrors.InvalidArgument, "endpoint is required") + } + + m.mu.Lock() + defer m.mu.Unlock() + + td, ok := m.topics.Get(spec.TopicID) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "topic %q not found", spec.TopicID) + } + + compartment := spec.CompartmentID + if compartment == "" { + compartment = td.Scope.Compartment + } + + for _, existing := range m.subs.SortedValues() { + if existing.TopicID == td.ID && existing.Protocol == protocol && existing.Endpoint == spec.Endpoint { + return nil, cerrors.Newf(cerrors.AlreadyExists, + "subscription to %s endpoint %q on topic %s already exists", protocol, spec.Endpoint, td.ID) + } + } + + sub := &Subscription{ + ID: idgen.OCID(typeSubscription, m.opts.Realm, m.opts.OCIRegion()), + TopicID: td.ID, + CompartmentID: compartment, + Protocol: protocol, + Endpoint: spec.Endpoint, + Metadata: spec.Metadata, + LifecycleState: StatePending, + CreatedTime: m.opts.Clock.Now().UTC().UnixMilli(), + Etag: idgen.GenerateID("etag-"), + ConfirmationToken: idgen.GenerateID("token-"), + FreeformTags: maps.Clone(spec.FreeformTags), + } + + m.subs.Set(sub.ID, sub) + + return cloneSubscription(sub), nil +} + +// GetSubscription returns a subscription by OCID. +func (m *Mock) GetSubscription(_ context.Context, id string) (*Subscription, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + sub, ok := m.subs.Get(id) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "subscription %q not found", id) + } + + return cloneSubscription(sub), nil +} + +// ListSubscriptions lists the subscriptions on a topic, in the portable shape. +func (m *Mock) ListSubscriptions(_ context.Context, topicID string) ([]driver.SubscriptionInfo, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if !m.topics.Has(topicID) { + return nil, cerrors.Newf(cerrors.NotFound, "topic %q not found", topicID) + } + + all := m.subs.SortedValues() + out := make([]driver.SubscriptionInfo, 0, len(all)) + + for _, sub := range all { + if sub.TopicID != topicID { + continue + } + + out = append(out, *subscriptionInfo(sub)) + } + + return out, nil +} + +// ListOCISubscriptions lists the subscriptions in a compartment, narrowed to +// one topic when topicID is given. ONS lists subscriptions by compartment +// rather than by topic, which the portable ListSubscriptions cannot express. +func (m *Mock) ListOCISubscriptions(_ context.Context, compartmentID, topicID string) ([]Subscription, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + all := m.subs.SortedValues() + out := make([]Subscription, 0, len(all)) + + for _, sub := range all { + if compartmentID != "" && sub.CompartmentID != compartmentID { + continue + } + + if topicID != "" && sub.TopicID != topicID { + continue + } + + out = append(out, *cloneSubscription(sub)) + } + + return out, nil +} + +// UpdateSubscription replaces a subscription's delivery policy and tags. +func (m *Mock) UpdateSubscription(_ context.Context, id string, patch SubscriptionPatch) (*Subscription, error) { + m.mu.Lock() + defer m.mu.Unlock() + + sub, ok := m.subs.Get(id) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "subscription %q not found", id) + } + + if patch.DeliveryPolicy != nil { + sub.DeliveryPolicy = cloneDeliveryPolicy(patch.DeliveryPolicy) + } + + if patch.FreeformTags != nil { + sub.FreeformTags = maps.Clone(patch.FreeformTags) + } + + sub.Etag = idgen.GenerateID("etag-") + + m.subs.Set(id, sub) + + return cloneSubscription(sub), nil +} + +// Unsubscribe deletes a subscription by OCID. +func (m *Mock) Unsubscribe(_ context.Context, subscriptionID string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.subs.Delete(subscriptionID) { + return cerrors.Newf(cerrors.NotFound, "subscription %q not found", subscriptionID) + } + + m.deliveries.Delete(subscriptionID) + + return nil +} + +// ConfirmSubscription moves a subscription from PENDING to ACTIVE. Until it +// runs, a publish to the topic delivers nothing to this subscription. +func (m *Mock) ConfirmSubscription(_ context.Context, id, token, protocol string) (*ConfirmationResult, error) { + if token == "" { + return nil, cerrors.New(cerrors.InvalidArgument, "token is required") + } + + m.mu.Lock() + defer m.mu.Unlock() + + sub, ok := m.subs.Get(id) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "subscription %q not found", id) + } + + if err := checkToken(sub, token, protocol); err != nil { + return nil, err + } + + sub.LifecycleState = StateActive + sub.Etag = idgen.GenerateID("etag-") + + m.subs.Set(id, sub) + + name := "" + if td, found := m.topics.Get(sub.TopicID); found { + name = td.Name + } + + return &ConfirmationResult{ + TopicName: name, + TopicID: sub.TopicID, + Endpoint: sub.Endpoint, + SubscriptionID: sub.ID, + Token: sub.ConfirmationToken, + Message: "subscription confirmed", + }, nil +} + +// UnsubscribeByToken deletes a subscription through the unsubscribe link ONS +// puts in every delivery, which authenticates with the confirmation token +// rather than with the caller's credentials. +func (m *Mock) UnsubscribeByToken(_ context.Context, id, token, protocol string) error { + if token == "" { + return cerrors.New(cerrors.InvalidArgument, "token is required") + } + + m.mu.Lock() + defer m.mu.Unlock() + + sub, ok := m.subs.Get(id) + if !ok { + return cerrors.Newf(cerrors.NotFound, "subscription %q not found", id) + } + + if err := checkToken(sub, token, protocol); err != nil { + return err + } + + m.subs.Delete(id) + m.deliveries.Delete(id) + + return nil +} + +// ResendSubscriptionConfirmation re-issues the confirmation token for a +// subscription still waiting on one. +func (m *Mock) ResendSubscriptionConfirmation(_ context.Context, id string) (*Subscription, error) { + m.mu.Lock() + defer m.mu.Unlock() + + sub, ok := m.subs.Get(id) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "subscription %q not found", id) + } + + if sub.LifecycleState != StatePending { + return nil, cerrors.Newf(cerrors.FailedPrecondition, + "subscription %q is %s, not %s", id, sub.LifecycleState, StatePending) + } + + sub.ConfirmationToken = idgen.GenerateID("token-") + sub.Etag = idgen.GenerateID("etag-") + + m.subs.Set(id, sub) + + return cloneSubscription(sub), nil +} + +// ChangeSubscriptionCompartment moves a subscription to another compartment. +func (m *Mock) ChangeSubscriptionCompartment(_ context.Context, id, compartmentID string) error { + if compartmentID == "" { + return cerrors.New(cerrors.InvalidArgument, "compartmentId is required") + } + + m.mu.Lock() + defer m.mu.Unlock() + + sub, ok := m.subs.Get(id) + if !ok { + return cerrors.Newf(cerrors.NotFound, "subscription %q not found", id) + } + + sub.CompartmentID = compartmentID + sub.Etag = idgen.GenerateID("etag-") + + m.subs.Set(id, sub) + + return nil +} + +// checkToken rejects a confirmation token, or a protocol, that does not belong +// to the subscription. The caller holds mu. +func checkToken(sub *Subscription, token, protocol string) error { + if token != sub.ConfirmationToken { + return cerrors.Newf(cerrors.InvalidArgument, "token does not match subscription %q", sub.ID) + } + + if protocol == "" { + return nil + } + + want, err := normalizeProtocol(protocol) + if err != nil { + return err + } + + if want != sub.Protocol { + return cerrors.Newf(cerrors.InvalidArgument, + "protocol %s does not match subscription %q", want, sub.ID) + } + + return nil +} + +// normalizeProtocol maps a caller's protocol onto the ONS one, rejecting a +// protocol ONS does not deliver over rather than storing it unused. +func normalizeProtocol(protocol string) (string, error) { + if protocol == "" { + return "", cerrors.New(cerrors.InvalidArgument, "protocol is required") + } + + resolved, ok := protocolAliases[strings.ToUpper(protocol)] + if !ok { + return "", cerrors.Newf(cerrors.InvalidArgument, + "protocol %q is not supported by OCI Notifications; want one of EMAIL, SMS, CUSTOM_HTTPS, "+ + "SLACK, PAGERDUTY, ORACLE_FUNCTIONS", protocol) + } + + return resolved, nil +} + +// subscriptionInfo projects an ONS subscription onto the portable shape. +func subscriptionInfo(sub *Subscription) *driver.SubscriptionInfo { + status := StatusPending + if sub.LifecycleState == StateActive { + status = StatusConfirmed + } + + return &driver.SubscriptionInfo{ + ID: sub.ID, + TopicID: sub.TopicID, + Protocol: sub.Protocol, + Endpoint: sub.Endpoint, + Status: status, + } +} + +func cloneSubscription(sub *Subscription) *Subscription { + out := *sub + out.FreeformTags = maps.Clone(sub.FreeformTags) + out.DeliveryPolicy = cloneDeliveryPolicy(sub.DeliveryPolicy) + + return &out +} + +func cloneDeliveryPolicy(p *DeliveryPolicy) *DeliveryPolicy { + if p == nil { + return nil + } + + out := DeliveryPolicy{} + + if p.BackoffRetryPolicy != nil { + retry := *p.BackoffRetryPolicy + out.BackoffRetryPolicy = &retry + } + + return &out +} diff --git a/providers/oci/oci.go b/providers/oci/oci.go index 0036a9a58..bca03b148 100644 --- a/providers/oci/oci.go +++ b/providers/oci/oci.go @@ -6,6 +6,7 @@ import ( "github.com/stackshy/cloudemu/v2/internal/snapshot" "github.com/stackshy/cloudemu/v2/providers/oci/identity" "github.com/stackshy/cloudemu/v2/providers/oci/monitoring" + notifprovider "github.com/stackshy/cloudemu/v2/providers/oci/notifications" vcnprovider "github.com/stackshy/cloudemu/v2/providers/oci/vcn" cachedriver "github.com/stackshy/cloudemu/v2/services/cache/driver" computedriver "github.com/stackshy/cloudemu/v2/services/compute/driver" @@ -76,6 +77,7 @@ func New(opts ...config.Option) *Provider { } p.Identity = identity.New(o) p.VCN = vcnprovider.New(o) + p.Notifications = notifprovider.New(o) p.Monitoring = monitoring.New(o) diff --git a/server/oci/notifications/handler.go b/server/oci/notifications/handler.go new file mode 100644 index 000000000..b1ad9a5f7 --- /dev/null +++ b/server/oci/notifications/handler.go @@ -0,0 +1,211 @@ +// Package notifications implements OCI's Notifications (ONS) REST API against +// a CloudEmu notification driver. +// +// Everything sits under the /20181201 prefix: +// +// POST/GET /20181201/topics — create, list +// GET/PUT/DELETE /20181201/topics/{topicId} — get, update, delete +// POST /20181201/topics/{topicId}/messages — PublishMessage +// POST /20181201/topics/{topicId}/actions/changeCompartment +// POST/GET /20181201/subscriptions — create, list +// GET/PUT/DELETE /20181201/subscriptions/{id} — get, update, delete +// GET /20181201/subscriptions/{id}/confirmation — ConfirmSubscription +// GET /20181201/subscriptions/{id}/unsubscription — UnsubscribeSubscription +// POST /20181201/subscriptions/{id}/actions/{changeCompartment,resendConfirmation} +// +// Real ONS splits the control plane from the data plane by host, not by +// prefix: PublishMessage goes to the topic's own apiEndpoint. CloudEmu serves +// both on one listener, so every topic reports the requesting origin as its +// apiEndpoint and a publish lands back here. +// +// A subscription is created PENDING and receives nothing until it is +// confirmed. Real ONS mails the confirmation token to the endpoint; the +// emulator has no channel to mail it on, so a PENDING subscription carries the +// token in its response body. DeleteTopic is asynchronous in real ONS and +// answers 204 with an opc-work-request-id; every other mutation here is +// synchronous. +package notifications + +import ( + "context" + "net/http" + "strings" + + notifprovider "github.com/stackshy/cloudemu/v2/providers/oci/notifications" + "github.com/stackshy/cloudemu/v2/server/oci/workrequest" + "github.com/stackshy/cloudemu/v2/server/wire/ocirest" + notifdriver "github.com/stackshy/cloudemu/v2/services/notification/driver" +) + +// apiVersion is the Notifications API version every ONS path carries. +const apiVersion = "20181201" + +// Collections this handler claims. +const ( + segTopics = "topics" + segSubscriptions = "subscriptions" +) + +// Sub-collections and actions. +const ( + subMessages = "messages" + subConfirmation = "confirmation" + subUnsubscription = "unsubscription" + subActions = "actions" + + actionChangeCompartment = "changeCompartment" + actionResendConfirm = "resendConfirmation" +) + +// Error codes the handler raises itself. +const ( + codeInvalidParameter = "InvalidParameter" + codeMethodNotAllowed = "MethodNotAllowed" + codeNotImplemented = "NotImplemented" + codeNotFound = "NotAuthorizedOrNotFound" +) + +// maxPathSegments is /{version}/{collection}/{id}/{sub}/{action}. +const maxPathSegments = 5 + +// Extras is the OCI-only surface the portable notification driver cannot +// express: a subscription's compartment, tags, metadata and delivery policy, +// the PENDING confirmation handshake, and a topic's lifecycle state, short id +// and etag. *providers/oci/notifications.Mock satisfies it; any driver that +// does not is served 501 for every path this handler claims. +type Extras interface { + TopicDetails(id string) (notifprovider.TopicDetails, bool) + + CreateSubscription( + ctx context.Context, spec notifprovider.SubscriptionSpec, + ) (*notifprovider.Subscription, error) + GetSubscription(ctx context.Context, id string) (*notifprovider.Subscription, error) + ListOCISubscriptions(ctx context.Context, compartmentID, topicID string) ([]notifprovider.Subscription, error) + UpdateSubscription( + ctx context.Context, id string, patch notifprovider.SubscriptionPatch, + ) (*notifprovider.Subscription, error) + ConfirmSubscription(ctx context.Context, id, token, protocol string) (*notifprovider.ConfirmationResult, error) + UnsubscribeByToken(ctx context.Context, id, token, protocol string) error + ResendSubscriptionConfirmation(ctx context.Context, id string) (*notifprovider.Subscription, error) + ChangeSubscriptionCompartment(ctx context.Context, id, compartmentID string) error + + PublishMessage( + ctx context.Context, topicID string, spec notifprovider.MessageSpec, + ) (*notifprovider.Message, error) +} + +// Handler serves OCI Notifications against a notification driver. +type Handler struct { + notif notifdriver.Notification + extras Extras + work *workrequest.Store +} + +// New returns a Notifications handler. work records the asynchronous topic +// delete; a nil store leaves that path unserved. +func New(n notifdriver.Notification, work *workrequest.Store) *Handler { + extras, _ := n.(Extras) + + return &Handler{notif: n, extras: extras, work: work} +} + +// route is a parsed Notifications path. +type route struct { + Collection string + ID string + Sub string + Action string +} + +// Matches claims the two ONS collections under /20181201, and nothing else +// sharing that prefix. +func (*Handler) Matches(r *http.Request) bool { + rt, ok := parsePath(r.URL.Path) + if !ok { + return false + } + + return rt.Collection == segTopics || rt.Collection == segSubscriptions +} + +// ServeHTTP routes on collection, then on path shape and method. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + rt, ok := parsePath(r.URL.Path) + if !ok { + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, "malformed notifications path") + return + } + + if h.extras == nil { + ocirest.WriteError(w, r, http.StatusNotImplemented, codeNotImplemented, + "the wired notification driver does not implement OCI Notifications") + + return + } + + if rt.Collection == segTopics { + h.serveTopics(w, r, rt) + return + } + + h.serveSubscriptions(w, r, rt) +} + +// parsePath splits /20181201/{collection}[/{id}[/{sub}[/{action}]]]. +func parsePath(urlPath string) (route, bool) { + parts := strings.Split(strings.Trim(urlPath, "/"), "/") + if len(parts) < 2 || len(parts) > maxPathSegments || parts[0] != apiVersion { + return route{}, false + } + + rt := route{Collection: parts[1]} + + if len(parts) > 2 { //nolint:mnd // the id follows the collection + rt.ID = parts[2] + } + + if len(parts) > 3 { //nolint:mnd // then the sub-collection + rt.Sub = parts[3] + } + + if len(parts) > 4 { //nolint:mnd // then the action on it + rt.Action = parts[4] + } + + return rt, true +} + +// apiEndpoint is the origin a topic's data plane is reachable at. Real ONS +// hands back a per-cell host; CloudEmu serves the data plane on the listener +// the caller already reached. +func apiEndpoint(r *http.Request) string { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + + return scheme + "://" + r.Host +} + +// refuseDefinedTags rejects a body carrying defined tags, which CloudEmu does +// not model. Echoing them back empty would leave a caller's tags looking +// applied. +func refuseDefinedTags(w http.ResponseWriter, r *http.Request, tags definedTags) bool { + if len(tags) == 0 { + return true + } + + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, + "definedTags are not modeled by this emulator; use freeformTags") + + return false +} + +func notFound(w http.ResponseWriter, r *http.Request) { + ocirest.WriteError(w, r, http.StatusNotFound, codeNotFound, "unknown notifications path "+r.URL.Path) +} + +func methodNotAllowed(w http.ResponseWriter, r *http.Request) { + ocirest.WriteError(w, r, http.StatusMethodNotAllowed, codeMethodNotAllowed, + r.Method+" is not allowed on "+r.URL.Path) +} diff --git a/server/oci/notifications/handler_test.go b/server/oci/notifications/handler_test.go new file mode 100644 index 000000000..531628acb --- /dev/null +++ b/server/oci/notifications/handler_test.go @@ -0,0 +1,620 @@ +package notifications_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stackshy/cloudemu/v2/config" + notifprovider "github.com/stackshy/cloudemu/v2/providers/oci/notifications" + ocinotif "github.com/stackshy/cloudemu/v2/server/oci/notifications" + "github.com/stackshy/cloudemu/v2/server/oci/workrequest" + "github.com/stackshy/cloudemu/v2/server/wire/ocirest" + notifdriver "github.com/stackshy/cloudemu/v2/services/notification/driver" + "github.com/stackshy/cloudemu/v2/services/scope" +) + +const ( + compartment = "ocid1.compartment.oc1..aaaaaaaatest" + otherCompartment = "ocid1.compartment.oc1..aaaaaaaaother" +) + +// Compile-time check that the OCI Notifications mock carries the capabilities +// the handler discovers by type assertion. +var _ ocinotif.Extras = (*notifprovider.Mock)(nil) + +type fixture struct { + t *testing.T + handler *ocinotif.Handler + mock *notifprovider.Mock + work *workrequest.Store +} + +func newFixture(t *testing.T) *fixture { + t.Helper() + + opts := config.NewOptions(config.WithRegion("us-ashburn-1"), config.WithCompartmentID(compartment)) + mock := notifprovider.New(opts) + work := workrequest.New(opts) + + return &fixture{t: t, handler: ocinotif.New(mock, work), mock: mock, work: work} +} + +// do sends a request through the handler and returns the recorder. +func (f *fixture) do(method, target string, body any) *httptest.ResponseRecorder { + f.t.Helper() + + var reader *bytes.Reader + + if body != nil { + raw, err := json.Marshal(body) + require.NoError(f.t, err) + reader = bytes.NewReader(raw) + } else { + reader = bytes.NewReader(nil) + } + + r := httptest.NewRequest(method, target, reader) + w := httptest.NewRecorder() + f.handler.ServeHTTP(w, r) + + return w +} + +func decode(t *testing.T, w *httptest.ResponseRecorder) map[string]any { + t.Helper() + + out := map[string]any{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &out)) + + return out +} + +func decodeList(t *testing.T, w *httptest.ResponseRecorder) []map[string]any { + t.Helper() + + var out []map[string]any + + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &out)) + + return out +} + +// newTopic creates a topic over the wire and returns its OCID. +func (f *fixture) newTopic(name, compartmentID string) string { + f.t.Helper() + + w := f.do(http.MethodPost, "/20181201/topics", map[string]any{ + "name": name, + "compartmentId": compartmentID, + "description": "topic " + name, + }) + require.Equal(f.t, http.StatusOK, w.Code, w.Body.String()) + + id, _ := decode(f.t, w)["topicId"].(string) + + return id +} + +// newSubscription creates a subscription over the wire and returns its OCID +// and confirmation token. +func (f *fixture) newSubscription(topicID, endpoint string) (id, token string) { + f.t.Helper() + + w := f.do(http.MethodPost, "/20181201/subscriptions", map[string]any{ + "topicId": topicID, + "compartmentId": compartment, + "protocol": "EMAIL", + "endpoint": endpoint, + }) + require.Equal(f.t, http.StatusOK, w.Code, w.Body.String()) + + body := decode(f.t, w) + id, _ = body["id"].(string) + token, _ = body["confirmationToken"].(string) + + return id, token +} + +func TestMatches(t *testing.T) { + tests := []struct { + name string + path string + expect bool + }{ + {name: "topic collection", path: "/20181201/topics", expect: true}, + {name: "single topic", path: "/20181201/topics/ocid1.onstopic.oc1.iad.abc", expect: true}, + {name: "publish endpoint", path: "/20181201/topics/ocid1.onstopic.oc1.iad.abc/messages", expect: true}, + { + name: "topic action", + path: "/20181201/topics/ocid1.onstopic.oc1.iad.abc/actions/changeCompartment", + expect: true, + }, + {name: "subscription collection", path: "/20181201/subscriptions", expect: true}, + {name: "single subscription", path: "/20181201/subscriptions/ocid1.onssubscription.oc1.iad.abc", expect: true}, + { + name: "confirmation", + path: "/20181201/subscriptions/ocid1.onssubscription.oc1.iad.abc/confirmation", + expect: true, + }, + { + name: "unsubscription", + path: "/20181201/subscriptions/ocid1.onssubscription.oc1.iad.abc/unsubscription", + expect: true, + }, + + {name: "another service's version prefix", path: "/20160918/vcns", expect: false}, + {name: "monitoring alarms", path: "/20180401/alarms", expect: false}, + {name: "topics under the wrong version", path: "/20180401/topics", expect: false}, + {name: "unknown collection on this version", path: "/20181201/alarms", expect: false}, + {name: "version alone", path: "/20181201", expect: false}, + {name: "work request poll", path: "/20181201/workRequests/ocid1.workrequest.oc1.iad.abc", expect: false}, + {name: "root", path: "/", expect: false}, + { + name: "deeper than any ONS path", + path: "/20181201/topics/ocid1.onstopic.oc1.iad.abc/messages/extra/parts", + expect: false, + }, + } + + f := newFixture(t) + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, tc.path, nil) + assert.Equal(t, tc.expect, f.handler.Matches(r)) + }) + } +} + +func TestCreateTopicWire(t *testing.T) { + f := newFixture(t) + + w := f.do(http.MethodPost, "/20181201/topics", map[string]any{ + "name": "alerts", + "compartmentId": compartment, + "description": "production alerts", + "freeformTags": map[string]string{"env": "prod"}, + }) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + body := decode(t, w) + assert.Contains(t, body["topicId"], "ocid1.onstopic.oc1.iad.") + assert.Equal(t, "alerts", body["name"]) + assert.Equal(t, compartment, body["compartmentId"]) + assert.Equal(t, "production alerts", body["description"]) + assert.Equal(t, "ACTIVE", body["lifecycleState"]) + assert.NotEmpty(t, body["shortTopicId"]) + assert.NotEmpty(t, body["timeCreated"]) + assert.NotEmpty(t, body["etag"]) + assert.Equal(t, "http://example.com", body["apiEndpoint"]) + assert.Equal(t, map[string]any{"env": "prod"}, body["freeformTags"]) + assert.Equal(t, map[string]any{}, body["definedTags"]) + assert.NotEmpty(t, w.Header().Get(ocirest.HeaderRequestID)) +} + +func TestTopicRequestErrors(t *testing.T) { + tests := []struct { + name string + method string + target string + body any + expectCode int + expectErr string + }{ + { + name: "create without compartment", method: http.MethodPost, target: "/20181201/topics", + body: map[string]any{"name": "alerts"}, expectCode: http.StatusBadRequest, expectErr: "InvalidParameter", + }, + { + name: "create with defined tags", method: http.MethodPost, target: "/20181201/topics", + body: map[string]any{ + "name": "alerts", "compartmentId": compartment, + "definedTags": map[string]any{"ns": map[string]any{"k": "v"}}, + }, + expectCode: http.StatusBadRequest, expectErr: "InvalidParameter", + }, + { + name: "create with an illegal name", method: http.MethodPost, target: "/20181201/topics", + body: map[string]any{"name": "bad name!", "compartmentId": compartment}, + expectCode: http.StatusBadRequest, expectErr: "InvalidParameter", + }, + { + name: "list without compartment", method: http.MethodGet, target: "/20181201/topics", + expectCode: http.StatusBadRequest, expectErr: "InvalidParameter", + }, + { + name: "list with an unsupported sort key", method: http.MethodGet, + target: "/20181201/topics?compartmentId=" + compartment + "&sortBy=DISPLAYNAME", + expectCode: http.StatusBadRequest, expectErr: "InvalidParameter", + }, + { + name: "get a missing topic", method: http.MethodGet, + target: "/20181201/topics/ocid1.onstopic.oc1.iad.missing", + expectCode: http.StatusNotFound, expectErr: "NotAuthorizedOrNotFound", + }, + { + name: "unknown sub-resource", method: http.MethodGet, + target: "/20181201/topics/ocid1.onstopic.oc1.iad.abc/bananas", + expectCode: http.StatusNotFound, expectErr: "NotAuthorizedOrNotFound", + }, + { + name: "verb the collection does not serve", method: http.MethodDelete, target: "/20181201/topics", + expectCode: http.StatusMethodNotAllowed, expectErr: "MethodNotAllowed", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + f := newFixture(t) + w := f.do(tc.method, tc.target, tc.body) + + require.Equal(t, tc.expectCode, w.Code, w.Body.String()) + + var body ocirest.ErrorBody + + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(t, tc.expectErr, body.Code) + assert.NotEmpty(t, body.Message) + }) + } +} + +func TestListTopicsFiltersByCompartment(t *testing.T) { + f := newFixture(t) + f.newTopic("mine", compartment) + f.newTopic("theirs", otherCompartment) + + w := f.do(http.MethodGet, "/20181201/topics?compartmentId="+compartment, nil) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + list := decodeList(t, w) + require.Len(t, list, 1) + assert.Equal(t, "mine", list[0]["name"]) + + w = f.do(http.MethodGet, "/20181201/topics?compartmentId="+compartment+"&name=nothing", nil) + require.Equal(t, http.StatusOK, w.Code) + assert.Empty(t, decodeList(t, w)) +} + +func TestUpdateTopic(t *testing.T) { + f := newFixture(t) + topicID := f.newTopic("alerts", compartment) + + w := f.do(http.MethodPut, "/20181201/topics/"+topicID, map[string]any{ + "description": "new description", + "freeformTags": map[string]string{"team": "sre"}, + }) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + body := decode(t, w) + assert.Equal(t, "new description", body["description"]) + assert.Equal(t, "alerts", body["name"]) + assert.Equal(t, map[string]any{"team": "sre"}, body["freeformTags"]) +} + +// TestDeleteTopicIsAsynchronous covers the one ONS mutation that returns a +// work request. +func TestDeleteTopicIsAsynchronous(t *testing.T) { + f := newFixture(t) + topicID := f.newTopic("alerts", compartment) + + w := f.do(http.MethodDelete, "/20181201/topics/"+topicID, nil) + require.Equal(t, http.StatusNoContent, w.Code, w.Body.String()) + + wrID := w.Header().Get(ocirest.HeaderWorkRequestID) + require.NotEmpty(t, wrID) + + wr, ok := f.work.Get(wrID) + require.True(t, ok) + assert.Equal(t, "DELETE_TOPIC", wr.OperationType) + assert.Equal(t, compartment, wr.CompartmentID) + require.Len(t, wr.Resources, 1) + assert.Equal(t, topicID, wr.Resources[0].Identifier) + assert.Equal(t, workrequest.ActionDeleted, wr.Resources[0].ActionType) + + w = f.do(http.MethodGet, "/20181201/topics/"+topicID, nil) + assert.Equal(t, http.StatusNotFound, w.Code) + + w = f.do(http.MethodDelete, "/20181201/topics/"+topicID, nil) + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestChangeTopicCompartment(t *testing.T) { + f := newFixture(t) + topicID := f.newTopic("alerts", compartment) + + w := f.do(http.MethodPost, "/20181201/topics/"+topicID+"/actions/changeCompartment", + map[string]any{"compartmentId": otherCompartment}) + require.Equal(t, http.StatusAccepted, w.Code, w.Body.String()) + assert.NotEmpty(t, w.Header().Get(ocirest.HeaderWorkRequestID)) + + w = f.do(http.MethodGet, "/20181201/topics?compartmentId="+otherCompartment, nil) + require.Equal(t, http.StatusOK, w.Code) + require.Len(t, decodeList(t, w), 1) + + w = f.do(http.MethodPost, "/20181201/topics/"+topicID+"/actions/changeCompartment", map[string]any{}) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestCreateSubscriptionWire(t *testing.T) { + f := newFixture(t) + topicID := f.newTopic("alerts", compartment) + + w := f.do(http.MethodPost, "/20181201/subscriptions", map[string]any{ + "topicId": topicID, + "compartmentId": compartment, + "protocol": "EMAIL", + "endpoint": "ops@example.com", + }) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + body := decode(t, w) + assert.Contains(t, body["id"], "ocid1.onssubscription.oc1.iad.") + assert.Equal(t, topicID, body["topicId"]) + assert.Equal(t, "PENDING", body["lifecycleState"]) + assert.Equal(t, "EMAIL", body["protocol"]) + assert.Equal(t, "ops@example.com", body["endpoint"]) + assert.NotEmpty(t, body["confirmationToken"]) + assert.NotZero(t, body["createdTime"]) +} + +func TestSubscriptionRequestErrors(t *testing.T) { + f := newFixture(t) + topicID := f.newTopic("alerts", compartment) + + tests := []struct { + name string + method string + target string + body any + expectCode int + }{ + { + name: "create without compartment", method: http.MethodPost, target: "/20181201/subscriptions", + body: map[string]any{"topicId": topicID, "protocol": "EMAIL", "endpoint": "a@b.c"}, + expectCode: http.StatusBadRequest, + }, + { + name: "create without topic", method: http.MethodPost, target: "/20181201/subscriptions", + body: map[string]any{"compartmentId": compartment, "protocol": "EMAIL", "endpoint": "a@b.c"}, + expectCode: http.StatusBadRequest, + }, + { + name: "create with an unsupported protocol", method: http.MethodPost, target: "/20181201/subscriptions", + body: map[string]any{ + "topicId": topicID, "compartmentId": compartment, "protocol": "SQS", "endpoint": "q", + }, + expectCode: http.StatusBadRequest, + }, + { + name: "list without compartment", method: http.MethodGet, target: "/20181201/subscriptions", + expectCode: http.StatusBadRequest, + }, + { + name: "get a missing subscription", method: http.MethodGet, + target: "/20181201/subscriptions/ocid1.onssubscription.oc1.iad.missing", + expectCode: http.StatusNotFound, + }, + { + name: "confirm without a token", method: http.MethodGet, + target: "/20181201/subscriptions/ocid1.onssubscription.oc1.iad.abc/confirmation", + expectCode: http.StatusBadRequest, + }, + { + name: "unknown action", method: http.MethodPost, + target: "/20181201/subscriptions/ocid1.onssubscription.oc1.iad.abc/actions/explode", + expectCode: http.StatusNotFound, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w := f.do(tc.method, tc.target, tc.body) + assert.Equal(t, tc.expectCode, w.Code, w.Body.String()) + }) + } +} + +// TestConfirmationFlowOverTheWire walks the whole ONS lifecycle: a PENDING +// subscription receives nothing, confirmation makes it ACTIVE, and only then +// does a publish reach it. +func TestConfirmationFlowOverTheWire(t *testing.T) { + f := newFixture(t) + topicID := f.newTopic("alerts", compartment) + subID, token := f.newSubscription(topicID, "ops@example.com") + + w := f.do(http.MethodPost, "/20181201/topics/"+topicID+"/messages", + map[string]any{"title": "early", "body": "before confirmation"}) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + assert.Empty(t, f.mock.Deliveries(subID), "a PENDING subscription must receive nothing") + + w = f.do(http.MethodGet, "/20181201/subscriptions/"+subID+"/confirmation?token="+token+"&protocol=EMAIL", nil) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + body := decode(t, w) + assert.Equal(t, subID, body["subscriptionId"]) + assert.Equal(t, "alerts", body["topicName"]) + assert.Equal(t, "ops@example.com", body["endpoint"]) + assert.Contains(t, body["unsubscribeUrl"], "/20181201/subscriptions/"+subID+"/unsubscription?") + + w = f.do(http.MethodGet, "/20181201/subscriptions/"+subID, nil) + require.Equal(t, http.StatusOK, w.Code) + + body = decode(t, w) + assert.Equal(t, "ACTIVE", body["lifecycleState"]) + assert.Empty(t, body["confirmationToken"], "the token is dropped once it is spent") + + w = f.do(http.MethodPost, "/20181201/topics/"+topicID+"/messages", + map[string]any{"title": "disk", "body": "after confirmation"}) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + published := decode(t, w) + assert.NotEmpty(t, published["messageId"]) + assert.NotEmpty(t, published["timeStamp"]) + + delivered := f.mock.Deliveries(subID) + require.Len(t, delivered, 1) + assert.Equal(t, "after confirmation", delivered[0].Body) +} + +func TestConfirmWithTheWrongToken(t *testing.T) { + f := newFixture(t) + topicID := f.newTopic("alerts", compartment) + subID, _ := f.newSubscription(topicID, "ops@example.com") + + w := f.do(http.MethodGet, "/20181201/subscriptions/"+subID+"/confirmation?token=wrong", nil) + assert.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) +} + +func TestResendConfirmation(t *testing.T) { + f := newFixture(t) + topicID := f.newTopic("alerts", compartment) + subID, token := f.newSubscription(topicID, "ops@example.com") + + w := f.do(http.MethodPost, "/20181201/subscriptions/"+subID+"/actions/resendConfirmation", nil) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + fresh, _ := decode(t, w)["confirmationToken"].(string) + require.NotEmpty(t, fresh) + assert.NotEqual(t, token, fresh) +} + +func TestUnsubscribeEndpoints(t *testing.T) { + f := newFixture(t) + topicID := f.newTopic("alerts", compartment) + + subID, token := f.newSubscription(topicID, "link@example.com") + w := f.do(http.MethodGet, "/20181201/subscriptions/"+subID+"/unsubscription?token="+token, nil) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + w = f.do(http.MethodGet, "/20181201/subscriptions/"+subID, nil) + assert.Equal(t, http.StatusNotFound, w.Code) + + subID, _ = f.newSubscription(topicID, "api@example.com") + w = f.do(http.MethodDelete, "/20181201/subscriptions/"+subID, nil) + require.Equal(t, http.StatusNoContent, w.Code) + + w = f.do(http.MethodDelete, "/20181201/subscriptions/"+subID, nil) + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestListSubscriptions(t *testing.T) { + f := newFixture(t) + mine := f.newTopic("mine", compartment) + theirs := f.newTopic("theirs", otherCompartment) + + f.newSubscription(mine, "a@example.com") + + w := f.do(http.MethodPost, "/20181201/subscriptions", map[string]any{ + "topicId": theirs, "compartmentId": otherCompartment, "protocol": "EMAIL", "endpoint": "b@example.com", + }) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + w = f.do(http.MethodGet, "/20181201/subscriptions?compartmentId="+compartment, nil) + require.Equal(t, http.StatusOK, w.Code) + assert.Len(t, decodeList(t, w), 1) + + w = f.do(http.MethodGet, "/20181201/subscriptions?compartmentId="+compartment+"&topicId="+theirs, nil) + require.Equal(t, http.StatusOK, w.Code) + assert.Empty(t, decodeList(t, w)) +} + +func TestUpdateAndMoveSubscription(t *testing.T) { + f := newFixture(t) + topicID := f.newTopic("alerts", compartment) + subID, _ := f.newSubscription(topicID, "ops@example.com") + + w := f.do(http.MethodPut, "/20181201/subscriptions/"+subID, map[string]any{ + "deliveryPolicy": map[string]any{ + "backoffRetryPolicy": map[string]any{"maxRetryDuration": 7200, "policyType": "EXPONENTIAL"}, + }, + "freeformTags": map[string]string{"team": "sre"}, + }) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + policy, ok := decode(t, w)["deliveryPolicy"].(map[string]any) + require.True(t, ok) + backoff, ok := policy["backoffRetryPolicy"].(map[string]any) + require.True(t, ok) + assert.InDelta(t, 7200, backoff["maxRetryDuration"], 0) + + w = f.do(http.MethodPost, "/20181201/subscriptions/"+subID+"/actions/changeCompartment", + map[string]any{"compartmentId": otherCompartment}) + require.Equal(t, http.StatusNoContent, w.Code, w.Body.String()) + + w = f.do(http.MethodGet, "/20181201/subscriptions?compartmentId="+otherCompartment, nil) + require.Equal(t, http.StatusOK, w.Code) + assert.Len(t, decodeList(t, w), 1) +} + +func TestPublishRejectsAnUnknownMessageType(t *testing.T) { + f := newFixture(t) + topicID := f.newTopic("alerts", compartment) + + w := f.do(http.MethodPost, "/20181201/topics/"+topicID+"/messages?messageType=PROTOBUF", + map[string]any{"body": "hello"}) + assert.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) + + w = f.do(http.MethodGet, "/20181201/topics/"+topicID+"/messages", nil) + assert.Equal(t, http.StatusMethodNotAllowed, w.Code) +} + +// portableOnly implements the portable driver and nothing else, standing in +// for a non-OCI notification driver wired into the OCI server. +type portableOnly struct{} + +func (portableOnly) CreateTopic(context.Context, notifdriver.TopicConfig) (*notifdriver.TopicInfo, error) { + return nil, nil //nolint:nilnil // never reached; the handler answers 501 first +} + +func (portableOnly) UpdateTopic(context.Context, notifdriver.TopicConfig) (*notifdriver.TopicInfo, error) { + return nil, nil //nolint:nilnil // never reached +} +func (portableOnly) DeleteTopic(context.Context, string) error { return nil } + +func (portableOnly) GetTopic(context.Context, string) (*notifdriver.TopicInfo, error) { + return nil, nil //nolint:nilnil // never reached +} + +func (portableOnly) ListTopics(context.Context, scope.Scope) ([]notifdriver.TopicInfo, error) { + return nil, nil +} + +func (portableOnly) Subscribe( + context.Context, notifdriver.SubscriptionConfig, +) (*notifdriver.SubscriptionInfo, error) { + return nil, nil //nolint:nilnil // never reached +} +func (portableOnly) Unsubscribe(context.Context, string) error { return nil } + +func (portableOnly) ListSubscriptions(context.Context, string) ([]notifdriver.SubscriptionInfo, error) { + return nil, nil +} + +func (portableOnly) Publish(context.Context, notifdriver.PublishInput) (*notifdriver.PublishOutput, error) { + return nil, nil //nolint:nilnil // never reached +} + +func TestDriverWithoutExtrasAnswers501(t *testing.T) { + h := ocinotif.New(portableOnly{}, workrequest.New(config.NewOptions())) + + r := httptest.NewRequest(http.MethodGet, "/20181201/topics?compartmentId="+compartment, nil) + w := httptest.NewRecorder() + + require.True(t, h.Matches(r)) + h.ServeHTTP(w, r) + + require.Equal(t, http.StatusNotImplemented, w.Code) + + var body ocirest.ErrorBody + + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(t, "NotImplemented", body.Code) +} diff --git a/server/oci/notifications/subscriptions.go b/server/oci/notifications/subscriptions.go new file mode 100644 index 000000000..d69990b16 --- /dev/null +++ b/server/oci/notifications/subscriptions.go @@ -0,0 +1,345 @@ +package notifications + +import ( + "net/http" + "net/url" + + notifprovider "github.com/stackshy/cloudemu/v2/providers/oci/notifications" + "github.com/stackshy/cloudemu/v2/server/wire/ocirest" +) + +// serveSubscriptions routes the subscription collection, a single +// subscription, the two token endpoints and the actions on one. +func (h *Handler) serveSubscriptions(w http.ResponseWriter, r *http.Request, rt route) { + switch { + case rt.ID == "" && rt.Sub == "": + h.serveSubscriptionCollection(w, r) + case rt.Sub == "": + h.serveSubscription(w, r, rt.ID) + case rt.Sub == subConfirmation && rt.Action == "": + h.confirmSubscription(w, r, rt.ID) + case rt.Sub == subUnsubscription && rt.Action == "": + h.unsubscribeByToken(w, r, rt.ID) + case rt.Sub == subActions: + h.serveSubscriptionAction(w, r, rt) + default: + notFound(w, r) + } +} + +// serveSubscriptionAction routes /subscriptions/{id}/actions/{action}. +func (h *Handler) serveSubscriptionAction(w http.ResponseWriter, r *http.Request, rt route) { + switch rt.Action { + case actionChangeCompartment: + h.changeSubscriptionCompartment(w, r, rt.ID) + case actionResendConfirm: + h.resendConfirmation(w, r, rt.ID) + default: + notFound(w, r) + } +} + +func (h *Handler) serveSubscriptionCollection(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + h.createSubscription(w, r) + case http.MethodGet: + h.listSubscriptions(w, r) + default: + methodNotAllowed(w, r) + } +} + +func (h *Handler) serveSubscription(w http.ResponseWriter, r *http.Request, id string) { + switch r.Method { + case http.MethodGet: + h.getSubscription(w, r, id) + case http.MethodPut: + h.updateSubscription(w, r, id) + case http.MethodDelete: + h.deleteSubscription(w, r, id) + default: + methodNotAllowed(w, r) + } +} + +// createSubscription creates a PENDING subscription. It stays PENDING, and +// receives nothing, until it is confirmed with its token. +func (h *Handler) createSubscription(w http.ResponseWriter, r *http.Request) { + var req createSubscriptionRequest + + if !ocirest.DecodeJSON(w, r, &req) { + return + } + + if !refuseDefinedTags(w, r, req.DefinedTags) { + return + } + + if req.CompartmentID == "" { + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, "compartmentId is required") + return + } + + if req.TopicID == "" { + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, "topicId is required") + return + } + + sub, err := h.extras.CreateSubscription(r.Context(), notifprovider.SubscriptionSpec{ + TopicID: req.TopicID, + CompartmentID: req.CompartmentID, + Protocol: req.Protocol, + Endpoint: req.Endpoint, + Metadata: req.Metadata, + FreeformTags: req.FreeformTags, + }) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + ocirest.WriteJSON(w, r, http.StatusOK, subscriptionWire(sub)) +} + +func (h *Handler) getSubscription(w http.ResponseWriter, r *http.Request, id string) { + sub, err := h.extras.GetSubscription(r.Context(), id) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + ocirest.WriteJSON(w, r, http.StatusOK, subscriptionWire(sub)) +} + +// listSubscriptions returns the subscriptions in a compartment, narrowed to +// one topic when topicId is given. +func (h *Handler) listSubscriptions(w http.ResponseWriter, r *http.Request) { + compartmentID, given := ocirest.RequireCompartmentID(w, r) + if !given { + return + } + + subs, err := h.extras.ListOCISubscriptions(r.Context(), compartmentID, r.URL.Query().Get("topicId")) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + out := make([]subscriptionResponse, 0, len(subs)) + for i := range subs { + out = append(out, subscriptionWire(&subs[i])) + } + + ocirest.WriteJSON(w, r, http.StatusOK, paginate(w, r, out)) +} + +// updateSubscription replaces a subscription's delivery policy and tags. The +// full subscription is returned; ONS's UpdateSubscriptionDetails is a subset +// of it, so an SDK decoding either sees the fields it expects. +func (h *Handler) updateSubscription(w http.ResponseWriter, r *http.Request, id string) { + var req updateSubscriptionRequest + + if !ocirest.DecodeJSON(w, r, &req) { + return + } + + if !refuseDefinedTags(w, r, req.DefinedTags) { + return + } + + sub, err := h.extras.UpdateSubscription(r.Context(), id, notifprovider.SubscriptionPatch{ + DeliveryPolicy: toDriverPolicy(req.DeliveryPolicy), + FreeformTags: req.FreeformTags, + }) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + ocirest.WriteJSON(w, r, http.StatusOK, subscriptionWire(sub)) +} + +func (h *Handler) deleteSubscription(w http.ResponseWriter, r *http.Request, id string) { + if err := h.notif.Unsubscribe(r.Context(), id); err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + ocirest.WriteJSON(w, r, http.StatusNoContent, nil) +} + +// confirmSubscription moves a subscription from PENDING to ACTIVE. ONS +// authenticates it with the token it mailed to the endpoint rather than with +// the caller's credentials, so it is a GET on the subscription. +func (h *Handler) confirmSubscription(w http.ResponseWriter, r *http.Request, id string) { + if r.Method != http.MethodGet { + methodNotAllowed(w, r) + return + } + + token, protocol, ok := tokenParams(w, r) + if !ok { + return + } + + result, err := h.extras.ConfirmSubscription(r.Context(), id, token, protocol) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + ocirest.WriteJSON(w, r, http.StatusOK, confirmationResult{ + TopicName: result.TopicName, + TopicID: result.TopicID, + Endpoint: result.Endpoint, + SubscriptionID: result.SubscriptionID, + UnsubscribeURL: unsubscribeURL(r, result.SubscriptionID, result.Token, protocol), + Message: result.Message, + }) +} + +// unsubscribeByToken serves the unsubscribe link ONS puts in every delivery. +func (h *Handler) unsubscribeByToken(w http.ResponseWriter, r *http.Request, id string) { + if r.Method != http.MethodGet { + methodNotAllowed(w, r) + return + } + + token, protocol, ok := tokenParams(w, r) + if !ok { + return + } + + if err := h.extras.UnsubscribeByToken(r.Context(), id, token, protocol); err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + ocirest.WriteJSON(w, r, http.StatusOK, "subscription "+id+" removed") +} + +// resendConfirmation re-issues the token of a subscription still PENDING. +func (h *Handler) resendConfirmation(w http.ResponseWriter, r *http.Request, id string) { + if r.Method != http.MethodPost { + methodNotAllowed(w, r) + return + } + + sub, err := h.extras.ResendSubscriptionConfirmation(r.Context(), id) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + ocirest.WriteJSON(w, r, http.StatusOK, subscriptionWire(sub)) +} + +// changeSubscriptionCompartment moves a subscription. ONS runs it +// synchronously, unlike the topic move. +func (h *Handler) changeSubscriptionCompartment(w http.ResponseWriter, r *http.Request, id string) { + if r.Method != http.MethodPost { + methodNotAllowed(w, r) + return + } + + var req changeCompartmentRequest + + if !ocirest.DecodeJSON(w, r, &req) { + return + } + + if err := h.extras.ChangeSubscriptionCompartment(r.Context(), id, req.CompartmentID); err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + ocirest.WriteJSON(w, r, http.StatusNoContent, nil) +} + +// tokenParams reads the token and protocol the confirmation endpoints +// authenticate with, writing the 400 when the token is missing. +func tokenParams(w http.ResponseWriter, r *http.Request) (token, protocol string, ok bool) { + query := r.URL.Query() + + token = query.Get("token") + if token == "" { + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, "token is required") + return "", "", false + } + + return token, query.Get("protocol"), true +} + +// unsubscribeURL is the link ONS hands back with a confirmation, pointing at +// this emulator's own origin. +func unsubscribeURL(r *http.Request, id, token, protocol string) string { + query := url.Values{"token": {token}} + if protocol != "" { + query.Set("protocol", protocol) + } + + return apiEndpoint(r) + "/" + apiVersion + "/" + segSubscriptions + "/" + + url.PathEscape(id) + "/" + subUnsubscription + "?" + query.Encode() +} + +// subscriptionWire renders an ONS subscription. The confirmation token rides +// along only while it is still needed. +func subscriptionWire(sub *notifprovider.Subscription) subscriptionResponse { + out := subscriptionResponse{ + ID: sub.ID, + TopicID: sub.TopicID, + CompartmentID: sub.CompartmentID, + Protocol: sub.Protocol, + Endpoint: sub.Endpoint, + LifecycleState: sub.LifecycleState, + CreatedTime: sub.CreatedTime, + Metadata: sub.Metadata, + DeliveryPolicy: toWirePolicy(sub.DeliveryPolicy), + Etag: sub.Etag, + FreeformTags: sub.FreeformTags, + DefinedTags: definedTags{}, + } + + if out.FreeformTags == nil { + out.FreeformTags = map[string]string{} + } + + if sub.LifecycleState == notifprovider.StatePending { + out.ConfirmationToken = sub.ConfirmationToken + } + + return out +} + +func toDriverPolicy(p *deliveryPolicy) *notifprovider.DeliveryPolicy { + if p == nil { + return nil + } + + out := notifprovider.DeliveryPolicy{} + if p.BackoffRetryPolicy != nil { + out.BackoffRetryPolicy = ¬ifprovider.BackoffRetryPolicy{ + MaxRetryDuration: p.BackoffRetryPolicy.MaxRetryDuration, + PolicyType: p.BackoffRetryPolicy.PolicyType, + } + } + + return &out +} + +func toWirePolicy(p *notifprovider.DeliveryPolicy) *deliveryPolicy { + if p == nil { + return nil + } + + out := deliveryPolicy{} + if p.BackoffRetryPolicy != nil { + out.BackoffRetryPolicy = &backoffRetryPolicy{ + MaxRetryDuration: p.BackoffRetryPolicy.MaxRetryDuration, + PolicyType: p.BackoffRetryPolicy.PolicyType, + } + } + + return &out +} diff --git a/server/oci/notifications/topics.go b/server/oci/notifications/topics.go new file mode 100644 index 000000000..ee7242dea --- /dev/null +++ b/server/oci/notifications/topics.go @@ -0,0 +1,390 @@ +package notifications + +import ( + "net/http" + "net/url" + "sort" + "strconv" + + notifprovider "github.com/stackshy/cloudemu/v2/providers/oci/notifications" + "github.com/stackshy/cloudemu/v2/server/oci/workrequest" + "github.com/stackshy/cloudemu/v2/server/wire/ocirest" + notifdriver "github.com/stackshy/cloudemu/v2/services/notification/driver" + "github.com/stackshy/cloudemu/v2/services/scope" +) + +// Sort keys ListTopics accepts. +const ( + sortByTimeCreated = "TIMECREATED" + sortByLifecycleState = "LIFECYCLESTATE" + + sortOrderAsc = "ASC" + sortOrderDesc = "DESC" +) + +// entityTopic is the resource type a topic work request reports. +const entityTopic = "onstopic" + +// Work request operations the asynchronous topic mutations record. +const ( + operationDeleteTopic = "DELETE_TOPIC" + operationChangeCompartment = "CHANGE_TOPIC_COMPARTMENT" +) + +// serveTopics routes the topic collection, a single topic, its message +// endpoint and its compartment move. +func (h *Handler) serveTopics(w http.ResponseWriter, r *http.Request, rt route) { + switch { + case rt.ID == "" && rt.Sub == "": + h.serveTopicCollection(w, r) + case rt.Sub == "": + h.serveTopic(w, r, rt.ID) + case rt.Sub == subMessages && rt.Action == "": + h.publishMessage(w, r, rt.ID) + case rt.Sub == subActions && rt.Action == actionChangeCompartment: + h.changeTopicCompartment(w, r, rt.ID) + default: + notFound(w, r) + } +} + +func (h *Handler) serveTopicCollection(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + h.createTopic(w, r) + case http.MethodGet: + h.listTopics(w, r) + default: + methodNotAllowed(w, r) + } +} + +func (h *Handler) serveTopic(w http.ResponseWriter, r *http.Request, id string) { + switch r.Method { + case http.MethodGet: + h.getTopic(w, r, id) + case http.MethodPut: + h.updateTopic(w, r, id) + case http.MethodDelete: + h.deleteTopic(w, r, id) + default: + methodNotAllowed(w, r) + } +} + +func (h *Handler) createTopic(w http.ResponseWriter, r *http.Request) { + var req createTopicRequest + + if !ocirest.DecodeJSON(w, r, &req) { + return + } + + if !refuseDefinedTags(w, r, req.DefinedTags) { + return + } + + if req.CompartmentID == "" { + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, "compartmentId is required") + return + } + + info, err := h.notif.CreateTopic(r.Context(), notifdriver.TopicConfig{ + Name: req.Name, + DisplayName: req.Description, + Tags: req.FreeformTags, + Scope: scope.Scope{Compartment: req.CompartmentID}, + }) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + ocirest.WriteJSON(w, r, http.StatusOK, h.topicWire(r, info)) +} + +func (h *Handler) getTopic(w http.ResponseWriter, r *http.Request, id string) { + info, err := h.notif.GetTopic(r.Context(), id) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + ocirest.WriteJSON(w, r, http.StatusOK, h.topicWire(r, info)) +} + +// listTopics returns the topics in a compartment. ONS requires compartmentId +// and offers id, name and lifecycleState narrowing on top of it. +func (h *Handler) listTopics(w http.ResponseWriter, r *http.Request) { + compartmentID, given := ocirest.RequireCompartmentID(w, r) + if !given { + return + } + + order, ok := sortSpec(w, r) + if !ok { + return + } + + infos, err := h.notif.ListTopics(r.Context(), scope.Scope{Compartment: compartmentID}) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + query := r.URL.Query() + out := make([]topicResponse, 0, len(infos)) + + for i := range infos { + topic := h.topicWire(r, &infos[i]) + if !topicMatches(&topic, query) { + continue + } + + out = append(out, topic) + } + + sortTopics(out, order) + + ocirest.WriteJSON(w, r, http.StatusOK, paginate(w, r, out)) +} + +// updateTopic replaces a topic's description and tags. ONS does not rename a +// topic, so the stored name is carried through. +func (h *Handler) updateTopic(w http.ResponseWriter, r *http.Request, id string) { + var req updateTopicRequest + + if !ocirest.DecodeJSON(w, r, &req) { + return + } + + if !refuseDefinedTags(w, r, req.DefinedTags) { + return + } + + info, err := h.notif.UpdateTopic(r.Context(), notifdriver.TopicConfig{ + Name: id, + DisplayName: req.Description, + Tags: req.FreeformTags, + }) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + ocirest.WriteJSON(w, r, http.StatusOK, h.topicWire(r, info)) +} + +// deleteTopic removes a topic and its subscriptions. Real ONS runs it +// asynchronously and answers 204 with the work request the caller polls. +func (h *Handler) deleteTopic(w http.ResponseWriter, r *http.Request, id string) { + if h.work == nil { + ocirest.WriteError(w, r, http.StatusNotImplemented, codeNotImplemented, "work requests are not configured") + return + } + + info, err := h.notif.GetTopic(r.Context(), id) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + compartmentID := info.Scope.Compartment + + if err := h.notif.DeleteTopic(r.Context(), id); err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + wrID := h.work.Accept(operationDeleteTopic, compartmentID, workrequest.Resource{ + EntityType: entityTopic, + ActionType: workrequest.ActionDeleted, + Identifier: id, + }) + + ocirest.SetWorkRequestID(w, wrID) + ocirest.WriteJSON(w, r, http.StatusNoContent, nil) +} + +// changeTopicCompartment moves a topic, which ONS runs asynchronously. +func (h *Handler) changeTopicCompartment(w http.ResponseWriter, r *http.Request, id string) { + if r.Method != http.MethodPost { + methodNotAllowed(w, r) + return + } + + if h.work == nil { + ocirest.WriteError(w, r, http.StatusNotImplemented, codeNotImplemented, "work requests are not configured") + return + } + + var req changeCompartmentRequest + + if !ocirest.DecodeJSON(w, r, &req) { + return + } + + if req.CompartmentID == "" { + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, "compartmentId is required") + return + } + + if _, err := h.notif.UpdateTopic(r.Context(), notifdriver.TopicConfig{ + Name: id, + Scope: scope.Scope{Compartment: req.CompartmentID}, + }); err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + wrID := h.work.Accept(operationChangeCompartment, req.CompartmentID, workrequest.Resource{ + EntityType: entityTopic, + ActionType: workrequest.ActionUpdated, + Identifier: id, + }) + + ocirest.SetWorkRequestID(w, wrID) + ocirest.WriteJSON(w, r, http.StatusAccepted, nil) +} + +// publishMessage is the ONS data plane: a message posted to the topic's own +// endpoint, which CloudEmu serves on the same listener as the control plane. +func (h *Handler) publishMessage(w http.ResponseWriter, r *http.Request, topicID string) { + if r.Method != http.MethodPost { + methodNotAllowed(w, r) + return + } + + var req messageDetails + + if !ocirest.DecodeJSON(w, r, &req) { + return + } + + msg, err := h.extras.PublishMessage(r.Context(), topicID, notifprovider.MessageSpec{ + Title: req.Title, + Body: req.Body, + Type: r.URL.Query().Get("messageType"), + }) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + ocirest.WriteJSON(w, r, http.StatusOK, publishResult{MessageID: msg.ID, TimeStamp: msg.Timestamp}) +} + +// topicWire renders a topic, folding in the OCI-only state the portable +// projection has no room for. +func (h *Handler) topicWire(r *http.Request, info *notifdriver.TopicInfo) topicResponse { + out := topicResponse{ + TopicID: info.ID, + Name: info.Name, + CompartmentID: info.Scope.Compartment, + APIEndpoint: apiEndpoint(r), + LifecycleState: notifprovider.StateActive, + Description: info.DisplayName, + FreeformTags: info.Tags, + DefinedTags: definedTags{}, + } + + if out.FreeformTags == nil { + out.FreeformTags = map[string]string{} + } + + if details, ok := h.extras.TopicDetails(info.ID); ok { + out.LifecycleState = details.LifecycleState + out.TimeCreated = details.TimeCreated + out.Etag = details.Etag + out.ShortTopicID = details.ShortTopicID + } + + return out +} + +// topicMatches applies ONS's id, name and lifecycleState narrowing. +func topicMatches(topic *topicResponse, query url.Values) bool { + if id := query.Get("id"); id != "" && topic.TopicID != id { + return false + } + + if name := query.Get("name"); name != "" && topic.Name != name { + return false + } + + if state := query.Get("lifecycleState"); state != "" && topic.LifecycleState != state { + return false + } + + return true +} + +// sortSpec reads ONS's sortBy and sortOrder, refusing a key this handler does +// not order on rather than returning an arbitrary order under its name. +func sortSpec(w http.ResponseWriter, r *http.Request) (order [2]string, ok bool) { + query := r.URL.Query() + by, dir := query.Get("sortBy"), query.Get("sortOrder") + + if by != "" && by != sortByTimeCreated && by != sortByLifecycleState { + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, + "sortBy must be "+sortByTimeCreated+" or "+sortByLifecycleState) + + return order, false + } + + if dir != "" && dir != sortOrderAsc && dir != sortOrderDesc { + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, + "sortOrder must be "+sortOrderAsc+" or "+sortOrderDesc) + + return order, false + } + + return [2]string{by, dir}, true +} + +// sortTopics applies the caller's sortBy and sortOrder to a listing. +func sortTopics(topics []topicResponse, order [2]string) { + if order[0] == "" && order[1] == "" { + return + } + + less := func(i, j int) bool { return topics[i].TimeCreated < topics[j].TimeCreated } + if order[0] == sortByLifecycleState { + less = func(i, j int) bool { return topics[i].LifecycleState < topics[j].LifecycleState } + } + + sort.SliceStable(topics, less) + + if order[1] == sortOrderDesc { + reverse(topics) + } +} + +func reverse[T any](items []T) { + for i, j := 0, len(items)-1; i < j; i, j = i+1, j-1 { + items[i], items[j] = items[j], items[i] + } +} + +// paginate applies OCI's limit and opaque page cursor, stamping the cursor for +// the next page. The cursor is the offset the next page starts at. +func paginate[T any](w http.ResponseWriter, r *http.Request, items []T) []T { + start := 0 + + if token := ocirest.Page(r); token != "" { + if n, err := strconv.Atoi(token); err == nil && n > 0 { + start = n + } + } + + // items[:0] rather than nil: an empty page is [] on the wire, not null. + if start >= len(items) { + return items[:0] + } + + end := min(start+ocirest.Limit(r), len(items)) + if end < len(items) { + ocirest.SetNextPage(w, strconv.Itoa(end)) + } + + return items[start:end] +} diff --git a/server/oci/notifications/types.go b/server/oci/notifications/types.go new file mode 100644 index 000000000..6a9ba923e --- /dev/null +++ b/server/oci/notifications/types.go @@ -0,0 +1,106 @@ +package notifications + +// OCI Notifications REST shapes. + +// definedTags is OCI's namespaced tag map. CloudEmu does not model tag +// namespaces, so it is echoed back empty and refused on the way in. +type definedTags map[string]map[string]any + +type createTopicRequest struct { + Name string `json:"name"` + CompartmentID string `json:"compartmentId"` + Description string `json:"description,omitempty"` + FreeformTags map[string]string `json:"freeformTags,omitempty"` + DefinedTags definedTags `json:"definedTags,omitempty"` +} + +type updateTopicRequest struct { + Description string `json:"description,omitempty"` + FreeformTags map[string]string `json:"freeformTags,omitempty"` + DefinedTags definedTags `json:"definedTags,omitempty"` +} + +// topicResponse is ONS's NotificationTopic. NotificationTopicSummary carries +// the same fields, so lists render it too. +type topicResponse struct { + TopicID string `json:"topicId"` + Name string `json:"name"` + CompartmentID string `json:"compartmentId"` + APIEndpoint string `json:"apiEndpoint"` + LifecycleState string `json:"lifecycleState"` + Description string `json:"description,omitempty"` + TimeCreated string `json:"timeCreated,omitempty"` + Etag string `json:"etag,omitempty"` + ShortTopicID string `json:"shortTopicId,omitempty"` + FreeformTags map[string]string `json:"freeformTags"` + DefinedTags definedTags `json:"definedTags"` +} + +type createSubscriptionRequest struct { + TopicID string `json:"topicId"` + CompartmentID string `json:"compartmentId"` + Protocol string `json:"protocol"` + Endpoint string `json:"endpoint"` + Metadata string `json:"metadata,omitempty"` + FreeformTags map[string]string `json:"freeformTags,omitempty"` + DefinedTags definedTags `json:"definedTags,omitempty"` +} + +type updateSubscriptionRequest struct { + DeliveryPolicy *deliveryPolicy `json:"deliveryPolicy,omitempty"` + FreeformTags map[string]string `json:"freeformTags,omitempty"` + DefinedTags definedTags `json:"definedTags,omitempty"` +} + +type backoffRetryPolicy struct { + MaxRetryDuration int `json:"maxRetryDuration"` + PolicyType string `json:"policyType"` +} + +type deliveryPolicy struct { + BackoffRetryPolicy *backoffRetryPolicy `json:"backoffRetryPolicy,omitempty"` +} + +// subscriptionResponse is ONS's Subscription; SubscriptionSummary shares its +// fields. +type subscriptionResponse struct { + ID string `json:"id"` + TopicID string `json:"topicId"` + CompartmentID string `json:"compartmentId"` + Protocol string `json:"protocol"` + Endpoint string `json:"endpoint"` + LifecycleState string `json:"lifecycleState"` + CreatedTime int64 `json:"createdTime"` + Metadata string `json:"metadata,omitempty"` + DeliveryPolicy *deliveryPolicy `json:"deliveryPolicy,omitempty"` + Etag string `json:"etag,omitempty"` + FreeformTags map[string]string `json:"freeformTags"` + DefinedTags definedTags `json:"definedTags"` + // ConfirmationToken is not an ONS field: real ONS mails it to the + // endpoint, which the emulator cannot do, so a PENDING subscription + // carries it back to the caller that must confirm it. + ConfirmationToken string `json:"confirmationToken,omitempty"` +} + +type messageDetails struct { + Title string `json:"title,omitempty"` + Body string `json:"body"` +} + +type publishResult struct { + MessageID string `json:"messageId"` + TimeStamp string `json:"timeStamp"` +} + +type confirmationResult struct { + TopicName string `json:"topicName"` + TopicID string `json:"topicId"` + Endpoint string `json:"endpoint"` + SubscriptionID string `json:"subscriptionId"` + UnsubscribeURL string `json:"unsubscribeUrl"` + Message string `json:"message,omitempty"` +} + +type changeCompartmentRequest struct { + CompartmentID string `json:"compartmentId"` +} diff --git a/server/oci/oci.go b/server/oci/oci.go index a367d4810..6786b3ea9 100644 --- a/server/oci/oci.go +++ b/server/oci/oci.go @@ -11,6 +11,7 @@ import ( "github.com/stackshy/cloudemu/v2/server" "github.com/stackshy/cloudemu/v2/server/oci/identity" "github.com/stackshy/cloudemu/v2/server/oci/monitoring" + "github.com/stackshy/cloudemu/v2/server/oci/notifications" "github.com/stackshy/cloudemu/v2/server/oci/vcn" "github.com/stackshy/cloudemu/v2/server/oci/workrequest" cachedriver "github.com/stackshy/cloudemu/v2/services/cache/driver" @@ -96,6 +97,10 @@ func New(d Drivers) *server.Server { srv.Register(vcn.New(d.VCN, d.WorkRequests)) } + if d.Notifications != nil { + srv.Register(notifications.New(d.Notifications, d.WorkRequests)) + } + return srv } From 5c32853289922b28e1cb6c2184a4c799459f9dc7 Mon Sep 17 00:00:00 2001 From: arunesh-j Date: Mon, 7 Sep 2026 23:33:56 +0530 Subject: [PATCH 2/3] docs(oci): document Notifications in services.md; mirror publish.go on the wire Adds the hand-written OCI Notifications section docs/oci-conventions.md's Definition of done requires, splits the wire publish handler out of topics.go so the feature has one filename in both layers, and raises coverage on both packages. PutMetricData is the in-process path a sibling mock emits its own service metrics on, so it now admits the oci_ namespaces those metrics live under; ONS's oci_notification counters were being dropped. --- docs/services.md | 64 ++++- providers/oci/monitoring/metrics.go | 16 +- providers/oci/monitoring/monitoring.go | 8 +- providers/oci/notifications/publish_test.go | 57 +++++ .../oci/notifications/subscriptions_test.go | 111 +++++++++ server/oci/notifications/publish.go | 35 +++ server/oci/notifications/publish_test.go | 84 +++++++ .../oci/notifications/subscriptions_test.go | 175 ++++++++++++++ server/oci/notifications/topics.go | 27 --- server/oci/notifications/topics_test.go | 223 ++++++++++++++++++ 10 files changed, 766 insertions(+), 34 deletions(-) create mode 100644 providers/oci/notifications/publish_test.go create mode 100644 providers/oci/notifications/subscriptions_test.go create mode 100644 server/oci/notifications/publish.go create mode 100644 server/oci/notifications/publish_test.go create mode 100644 server/oci/notifications/subscriptions_test.go create mode 100644 server/oci/notifications/topics_test.go diff --git a/docs/services.md b/docs/services.md index da13153e8..4191d64bc 100644 --- a/docs/services.md +++ b/docs/services.md @@ -1464,7 +1464,7 @@ a source cluster and detach on promote; clone-on-read on every path. ## 14. Notification **Driver interface:** `services/notification/driver/driver.go` -**AWS:** SNS | **Azure:** Notification Hubs | **GCP:** FCM +**AWS:** SNS | **Azure:** Notification Hubs | **GCP:** FCM | **OCI:** Notifications (ONS) ### Topic Operations @@ -1491,6 +1491,68 @@ a source cluster and detach on promote; clone-on-read on every path. **Total: 8 operations** +### OCI Notifications (ONS) + +**Optional capability:** `server/oci/notifications.Extras` — ONS scopes topics +and subscriptions to a compartment, addresses both by OCID, and gates delivery +behind a confirmation handshake, none of which the portable model carries. Its +value types live in `providers/oci/notifications`; a driver that does not +implement `Extras` is served `501` for every path the handler claims. +**Provider:** `providers/oci/notifications` | **Wire:** `server/oci/notifications` + +| Operation | Route | +|-----------|-------| +| `CreateTopic` | `POST /20181201/topics` | +| `ListTopics` | `GET /20181201/topics` | +| `GetTopic` | `GET /20181201/topics/{topicId}` | +| `UpdateTopic` | `PUT /20181201/topics/{topicId}` | +| `DeleteTopic` | `DELETE /20181201/topics/{topicId}` | +| `ChangeTopicCompartment` | `POST /20181201/topics/{topicId}/actions/changeCompartment` | +| `PublishMessage` | `POST /20181201/topics/{topicId}/messages` | +| `CreateSubscription` | `POST /20181201/subscriptions` | +| `ListSubscriptions` | `GET /20181201/subscriptions` | +| `GetSubscription` | `GET /20181201/subscriptions/{id}` | +| `UpdateSubscription` | `PUT /20181201/subscriptions/{id}` | +| `DeleteSubscription` | `DELETE /20181201/subscriptions/{id}` | +| `GetConfirmSubscription` | `GET /20181201/subscriptions/{id}/confirmation` | +| `GetUnsubscription` | `GET /20181201/subscriptions/{id}/unsubscription` | +| `ChangeSubscriptionCompartment` | `POST /20181201/subscriptions/{id}/actions/changeCompartment` | +| `ResendSubscriptionConfirmation` | `POST /20181201/subscriptions/{id}/actions/resendConfirmation` | + +Both list routes require `compartmentId` and paginate with `limit` / `page`, +returning the cursor as `opc-next-page`. `ListTopics` also honours `sortBy` +(`TIMECREATED`, `LIFECYCLESTATE`) with `sortOrder` `ASC` / `DESC`, plus `id`, +`name` and `lifecycleState` filters; an unknown sort key is rejected rather +than answered in an arbitrary order. +`definedTags` are rejected rather than echoed back empty; +`freeformTags` round-trip. + +Real ONS splits the control plane from the data plane **by host, not by API +prefix**: `PublishMessage` goes to the topic's own `apiEndpoint` rather than to +a differently-prefixed path. CloudEmu serves both on one listener, so every +topic reports the origin the caller reached as its `apiEndpoint` and a publish +posted there lands back on the same handler. A client that follows +`apiEndpoint` — as the real SDKs do — needs no special casing. + +A subscription is created `PENDING` and receives nothing until it is confirmed. +Real ONS mails the confirmation token to the endpoint; the emulator has no +channel to mail it on, so a `PENDING` subscription carries its token in the +create response, and `GET .../confirmation?token=…&protocol=…` flips it to +`ACTIVE`. Publishing to a topic whose subscriptions are all `PENDING` +succeeds and delivers to nobody. `.../unsubscription` takes the same token pair +and removes the subscription. Protocols are `EMAIL`, `SMS`, `CUSTOM_HTTPS`, +`SLACK`, `PAGERDUTY` and `ORACLE_FUNCTIONS` (`HTTP` / `HTTPS` alias onto +`CUSTOM_HTTPS`); anything else is rejected rather than stored unused. Message +bodies are `RAW_TEXT` or `JSON`. + +`DeleteTopic` is the one asynchronous mutation: it answers **`204` with an +`opc-work-request-id`**, not the `202` the rest of OCI uses for async work, and +the work request is resolvable through `server/oci/workrequest`. +`ChangeTopicCompartment` records a work request too and answers `202`. Every +subscription mutation is synchronous. Delivery is recorded in-memory and +readable through `Deliveries`; nothing is sent over a real transport, so +delivery policies (`backoffRetryPolicy`) round-trip but never retry. + --- ## 15. Container Registry diff --git a/providers/oci/monitoring/metrics.go b/providers/oci/monitoring/metrics.go index 7cbb33ac4..fc5d2e9a9 100644 --- a/providers/oci/monitoring/metrics.go +++ b/providers/oci/monitoring/metrics.go @@ -80,6 +80,16 @@ type metricSeries struct { // PostMetricData records metric data points against a compartment. func (m *Mock) PostMetricData(_ context.Context, compartmentID, resourceGroup string, data []driver.MetricDatum) error { + return m.postMetricData(compartmentID, resourceGroup, data, false) +} + +// postMetricData records metric data points. allowReserved admits the `oci_` +// namespaces Oracle keeps for its own service metrics, which a sibling mock +// emitting its service's metrics is the producer of and the public +// PostMetricData is not. +func (m *Mock) postMetricData( + compartmentID, resourceGroup string, data []driver.MetricDatum, allowReserved bool, +) error { if compartmentID == "" { return cerrors.New(cerrors.InvalidArgument, "compartmentId is required") } @@ -89,7 +99,7 @@ func (m *Mock) PostMetricData(_ context.Context, compartmentID, resourceGroup st } for i := range data { - if err := validateDatum(&data[i]); err != nil { + if err := validateDatum(&data[i], allowReserved); err != nil { return err } } @@ -320,7 +330,7 @@ func resolutionOf(interval time.Duration, resolution string) (time.Duration, err // validateDatum rejects a data point real OCI would reject. Namespace and // dimension shapes are checked; the metadata, per-request datapoint cap and // ingestion time window are not. -func validateDatum(d *driver.MetricDatum) error { +func validateDatum(d *driver.MetricDatum, allowReserved bool) error { switch { case d.Namespace == "": return cerrors.New(cerrors.InvalidArgument, "namespace is required") @@ -329,7 +339,7 @@ func validateDatum(d *driver.MetricDatum) error { case !validNamespace(d.Namespace): return cerrors.Newf(cerrors.InvalidArgument, "namespace %q must start with a letter and hold only letters, digits and underscores", d.Namespace) - case reservedNamespace(d.Namespace): + case !allowReserved && reservedNamespace(d.Namespace): return cerrors.Newf(cerrors.InvalidArgument, "namespace %q uses a prefix Oracle reserves", d.Namespace) case d.MetricName == "": return cerrors.New(cerrors.InvalidArgument, "metric name is required") diff --git a/providers/oci/monitoring/monitoring.go b/providers/oci/monitoring/monitoring.go index e639e3987..093c6ff14 100644 --- a/providers/oci/monitoring/monitoring.go +++ b/providers/oci/monitoring/monitoring.go @@ -81,9 +81,11 @@ func New(opts *config.Options) *Mock { } } -// PutMetricData stores metric data points in the default compartment. -func (m *Mock) PutMetricData(ctx context.Context, data []driver.MetricDatum) error { - return m.PostMetricData(ctx, m.opts.CompartmentID, "", data) +// PutMetricData stores metric data points in the default compartment. It is +// the in-process path a sibling mock emits its own service metrics on, so it +// admits the `oci_` namespaces those metrics live under. +func (m *Mock) PutMetricData(_ context.Context, data []driver.MetricDatum) error { + return m.postMetricData(m.opts.CompartmentID, "", data, true) } // GetMetricData aggregates the matching series in the default compartment into diff --git a/providers/oci/notifications/publish_test.go b/providers/oci/notifications/publish_test.go new file mode 100644 index 000000000..c0130e359 --- /dev/null +++ b/providers/oci/notifications/publish_test.go @@ -0,0 +1,57 @@ +package notifications_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stackshy/cloudemu/v2/config" + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/providers/oci/monitoring" + "github.com/stackshy/cloudemu/v2/providers/oci/notifications" +) + +func TestPublishToAnUnknownTopic(t *testing.T) { + ctx := context.Background() + m := newMock(t) + + _, err := m.PublishMessage(ctx, "ocid1.onstopic.oc1..missing", notifications.MessageSpec{Body: "hi"}) + require.Error(t, err) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) +} + +func TestPublishRequiresABody(t *testing.T) { + ctx := context.Background() + m := newMock(t) + id := newTopic(t, m, "alpha", compartment) + + _, err := m.PublishMessage(ctx, id, notifications.MessageSpec{Title: "deploy"}) + require.Error(t, err) + assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(err)) +} + +func TestDeliveriesOfAnUnknownSubscription(t *testing.T) { + assert.Empty(t, newMock(t).Deliveries("ocid1.onssubscription.oc1..missing")) +} + +// SetMonitoring points ONS at the monitoring mock, which then carries the +// publish counters. +func TestPublishEmitsMetrics(t *testing.T) { + ctx := context.Background() + opts := config.NewOptions(config.WithRegion(region), config.WithCompartmentID(compartment)) + m := notifications.New(opts) + mon := monitoring.New(opts) + + m.SetMonitoring(mon) + + id := newTopic(t, m, "alpha", compartment) + _, err := m.PublishMessage(ctx, id, notifications.MessageSpec{Body: "shipped"}) + require.NoError(t, err) + + names, err := mon.ListMetrics(ctx, "oci_notification") + require.NoError(t, err) + assert.Contains(t, names, "PublishedMessages") + assert.Contains(t, names, "DeliveredMessages") +} diff --git a/providers/oci/notifications/subscriptions_test.go b/providers/oci/notifications/subscriptions_test.go new file mode 100644 index 000000000..e55ff1c38 --- /dev/null +++ b/providers/oci/notifications/subscriptions_test.go @@ -0,0 +1,111 @@ +package notifications_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/providers/oci/notifications" + "github.com/stackshy/cloudemu/v2/services/notification/driver" + "github.com/stackshy/cloudemu/v2/services/scope" +) + +const missingTopic = "ocid1.onstopic.oc1..missing" + +// newPendingSubscription creates a PENDING subscription and returns it. +func newPendingSubscription(t *testing.T, m *notifications.Mock, topicID string) *notifications.Subscription { + t.Helper() + + sub, err := m.CreateSubscription(context.Background(), notifications.SubscriptionSpec{ + TopicID: topicID, + CompartmentID: compartment, + Protocol: "EMAIL", + Endpoint: "ops@example.com", + }) + require.NoError(t, err) + + return sub +} + +func TestUpdateUnknownTopic(t *testing.T) { + _, err := newMock(t).UpdateTopic(context.Background(), driver.TopicConfig{ + Name: missingTopic, + Scope: scope.Scope{Compartment: compartment}, + }) + require.Error(t, err) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) +} + +func TestListSubscriptionsOfAnUnknownTopic(t *testing.T) { + _, err := newMock(t).ListSubscriptions(context.Background(), missingTopic) + require.Error(t, err) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) +} + +func TestUnsubscribeByTokenErrors(t *testing.T) { + ctx := context.Background() + m := newMock(t) + topicID := newTopic(t, m, "alpha", compartment) + sub := newPendingSubscription(t, m, topicID) + + tests := map[string]struct { + id, token, protocol string + code cerrors.Code + }{ + "no token": {sub.ID, "", "EMAIL", cerrors.InvalidArgument}, + "unknown id": {"ocid1.onssubscription.oc1..missing", sub.ConfirmationToken, "", cerrors.NotFound}, + "wrong token": {sub.ID, "token-wrong", "", cerrors.InvalidArgument}, + "bad protocol": {sub.ID, sub.ConfirmationToken, "CARRIER_PIGEON", cerrors.InvalidArgument}, + "other protocol": {sub.ID, sub.ConfirmationToken, "SMS", cerrors.InvalidArgument}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + err := m.UnsubscribeByToken(ctx, tc.id, tc.token, tc.protocol) + require.Error(t, err) + assert.Equal(t, tc.code, cerrors.GetCode(err)) + }) + } + + // The token alone unsubscribes; the protocol is optional. Removes the + // subscription, so it runs after the rejection cases. + require.NoError(t, m.UnsubscribeByToken(ctx, sub.ID, sub.ConfirmationToken, "")) + assert.Empty(t, m.Deliveries(sub.ID)) +} + +// A confirmed subscription has no token left to re-issue. +func TestResendConfirmationErrors(t *testing.T) { + ctx := context.Background() + m := newMock(t) + topicID := newTopic(t, m, "alpha", compartment) + sub := newPendingSubscription(t, m, topicID) + + _, err := m.ResendSubscriptionConfirmation(ctx, "ocid1.onssubscription.oc1..missing") + require.Error(t, err) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) + + _, err = m.ConfirmSubscription(ctx, sub.ID, sub.ConfirmationToken, "EMAIL") + require.NoError(t, err) + + _, err = m.ResendSubscriptionConfirmation(ctx, sub.ID) + require.Error(t, err) + assert.Equal(t, cerrors.FailedPrecondition, cerrors.GetCode(err)) +} + +func TestChangeSubscriptionCompartmentErrors(t *testing.T) { + ctx := context.Background() + m := newMock(t) + topicID := newTopic(t, m, "alpha", compartment) + sub := newPendingSubscription(t, m, topicID) + + require.Error(t, m.ChangeSubscriptionCompartment(ctx, sub.ID, "")) + assert.Equal(t, cerrors.InvalidArgument, + cerrors.GetCode(m.ChangeSubscriptionCompartment(ctx, sub.ID, ""))) + + err := m.ChangeSubscriptionCompartment(ctx, "ocid1.onssubscription.oc1..missing", otherCompartment) + require.Error(t, err) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) +} diff --git a/server/oci/notifications/publish.go b/server/oci/notifications/publish.go new file mode 100644 index 000000000..5a096cb61 --- /dev/null +++ b/server/oci/notifications/publish.go @@ -0,0 +1,35 @@ +package notifications + +import ( + "net/http" + + notifprovider "github.com/stackshy/cloudemu/v2/providers/oci/notifications" + "github.com/stackshy/cloudemu/v2/server/wire/ocirest" +) + +// publishMessage is the ONS data plane: a message posted to the topic's own +// endpoint, which CloudEmu serves on the same listener as the control plane. +func (h *Handler) publishMessage(w http.ResponseWriter, r *http.Request, topicID string) { + if r.Method != http.MethodPost { + methodNotAllowed(w, r) + return + } + + var req messageDetails + + if !ocirest.DecodeJSON(w, r, &req) { + return + } + + msg, err := h.extras.PublishMessage(r.Context(), topicID, notifprovider.MessageSpec{ + Title: req.Title, + Body: req.Body, + Type: r.URL.Query().Get("messageType"), + }) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + ocirest.WriteJSON(w, r, http.StatusOK, publishResult{MessageID: msg.ID, TimeStamp: msg.Timestamp}) +} diff --git a/server/oci/notifications/publish_test.go b/server/oci/notifications/publish_test.go new file mode 100644 index 000000000..c6500180f --- /dev/null +++ b/server/oci/notifications/publish_test.go @@ -0,0 +1,84 @@ +package notifications_test + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The data plane is reached through the topic's own apiEndpoint, which +// CloudEmu points back at this listener. +func TestPublishThroughTheAdvertisedAPIEndpoint(t *testing.T) { + t.Parallel() + + f := newFixture(t) + id := f.newTopic("alpha", compartment) + + get := f.do(http.MethodGet, "/20181201/topics/"+id, nil) + require.Equal(t, http.StatusOK, get.Code, get.Body.String()) + + endpoint, _ := decode(t, get)["apiEndpoint"].(string) + require.NotEmpty(t, endpoint) + + w := f.do(http.MethodPost, endpoint+"/20181201/topics/"+id+"/messages", map[string]any{ + "title": "deploy", + "body": "shipped", + }) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + body := decode(t, w) + assert.NotEmpty(t, body["messageId"]) + assert.NotEmpty(t, body["timeStamp"]) +} + +func TestPublishToAnUnknownTopic(t *testing.T) { + t.Parallel() + + f := newFixture(t) + + w := f.do(http.MethodPost, "/20181201/topics/ocid1.onstopic.oc1..missing/messages", map[string]any{ + "body": "shipped", + }) + assert.Equal(t, http.StatusNotFound, w.Code, w.Body.String()) +} + +func TestPublishRequiresABody(t *testing.T) { + t.Parallel() + + f := newFixture(t) + id := f.newTopic("alpha", compartment) + + w := f.do(http.MethodPost, "/20181201/topics/"+id+"/messages", map[string]any{"title": "deploy"}) + assert.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) +} + +// A PENDING subscription receives nothing; confirming it opens delivery. +func TestPublishDeliversOnlyToConfirmedSubscriptions(t *testing.T) { + t.Parallel() + + f := newFixture(t) + id := f.newTopic("alpha", compartment) + subID, token := f.newSubscription(id, "ops@example.com") + + publish := func(body string) { + t.Helper() + + w := f.do(http.MethodPost, "/20181201/topics/"+id+"/messages", map[string]any{"body": body}) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + } + + publish("while pending") + assert.Empty(t, f.mock.Deliveries(subID)) + + confirm := f.do(http.MethodGet, + "/20181201/subscriptions/"+subID+"/confirmation?token="+token+"&protocol=EMAIL", nil) + require.Equal(t, http.StatusOK, confirm.Code, confirm.Body.String()) + + publish("after confirming") + + delivered := f.mock.Deliveries(subID) + require.Len(t, delivered, 1) + assert.Equal(t, "after confirming", delivered[0].Body) +} diff --git a/server/oci/notifications/subscriptions_test.go b/server/oci/notifications/subscriptions_test.go new file mode 100644 index 000000000..469b83dd0 --- /dev/null +++ b/server/oci/notifications/subscriptions_test.go @@ -0,0 +1,175 @@ +package notifications_test + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const missingSubscription = "ocid1.onssubscription.oc1..missing" + +func TestSubscriptionMethodNotAllowed(t *testing.T) { + t.Parallel() + + f := newFixture(t) + topicID := f.newTopic("alpha", compartment) + id, _ := f.newSubscription(topicID, "ops@example.com") + + cases := map[string]struct{ method, target string }{ + "collection": {http.MethodPatch, "/20181201/subscriptions?compartmentId=" + compartment}, + "single": {http.MethodPatch, "/20181201/subscriptions/" + id}, + "confirmation": {http.MethodPost, "/20181201/subscriptions/" + id + "/confirmation"}, + "unsubscription": {http.MethodPost, "/20181201/subscriptions/" + id + "/unsubscription"}, + "move": {http.MethodGet, "/20181201/subscriptions/" + id + "/actions/changeCompartment"}, + "resend": {http.MethodGet, "/20181201/subscriptions/" + id + "/actions/resendConfirmation"}, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + w := f.do(tc.method, tc.target, nil) + assert.Equal(t, http.StatusMethodNotAllowed, w.Code, w.Body.String()) + }) + } +} + +func TestSubscriptionUnknownPaths(t *testing.T) { + t.Parallel() + + f := newFixture(t) + topicID := f.newTopic("alpha", compartment) + id, _ := f.newSubscription(topicID, "ops@example.com") + + for _, target := range []string{ + "/20181201/subscriptions/" + id + "/deliveries", + "/20181201/subscriptions/" + id + "/actions/explode", + } { + w := f.do(http.MethodGet, target, nil) + assert.Equal(t, http.StatusNotFound, w.Code, target) + } +} + +func TestCreateSubscriptionRefusesDefinedTags(t *testing.T) { + t.Parallel() + + f := newFixture(t) + topicID := f.newTopic("alpha", compartment) + + w := f.do(http.MethodPost, "/20181201/subscriptions", map[string]any{ + "topicId": topicID, + "compartmentId": compartment, + "protocol": "EMAIL", + "endpoint": "ops@example.com", + "definedTags": map[string]any{"ops": map[string]any{"tier": "gold"}}, + }) + assert.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) +} + +func TestUpdateSubscriptionErrors(t *testing.T) { + t.Parallel() + + f := newFixture(t) + topicID := f.newTopic("alpha", compartment) + id, _ := f.newSubscription(topicID, "ops@example.com") + + defined := f.do(http.MethodPut, "/20181201/subscriptions/"+id, map[string]any{ + "definedTags": map[string]any{"ops": map[string]any{"tier": "gold"}}, + }) + assert.Equal(t, http.StatusBadRequest, defined.Code, defined.Body.String()) + + missing := f.do(http.MethodPut, "/20181201/subscriptions/"+missingSubscription, map[string]any{ + "freeformTags": map[string]string{"team": "ops"}, + }) + assert.Equal(t, http.StatusNotFound, missing.Code, missing.Body.String()) +} + +// An empty deliveryPolicy round-trips as an empty policy rather than being +// read as "no policy given". +func TestUpdateSubscriptionWithAnEmptyDeliveryPolicy(t *testing.T) { + t.Parallel() + + f := newFixture(t) + topicID := f.newTopic("alpha", compartment) + id, _ := f.newSubscription(topicID, "ops@example.com") + + w := f.do(http.MethodPut, "/20181201/subscriptions/"+id, map[string]any{ + "deliveryPolicy": map[string]any{}, + }) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + assert.NotNil(t, decode(t, w)["deliveryPolicy"]) +} + +func TestTokenEndpointsRequireAToken(t *testing.T) { + t.Parallel() + + f := newFixture(t) + topicID := f.newTopic("alpha", compartment) + id, _ := f.newSubscription(topicID, "ops@example.com") + + for _, sub := range []string{"confirmation", "unsubscription"} { + w := f.do(http.MethodGet, "/20181201/subscriptions/"+id+"/"+sub, nil) + assert.Equal(t, http.StatusBadRequest, w.Code, sub) + } +} + +func TestUnsubscribeWithTheWrongToken(t *testing.T) { + t.Parallel() + + f := newFixture(t) + topicID := f.newTopic("alpha", compartment) + id, _ := f.newSubscription(topicID, "ops@example.com") + + w := f.do(http.MethodGet, "/20181201/subscriptions/"+id+"/unsubscription?token=wrong&protocol=EMAIL", nil) + assert.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) +} + +func TestSubscriptionActionsOnAnUnknownSubscription(t *testing.T) { + t.Parallel() + + f := newFixture(t) + + resend := f.do(http.MethodPost, + "/20181201/subscriptions/"+missingSubscription+"/actions/resendConfirmation", nil) + assert.Equal(t, http.StatusNotFound, resend.Code, resend.Body.String()) + + move := f.do(http.MethodPost, + "/20181201/subscriptions/"+missingSubscription+"/actions/changeCompartment", + map[string]any{"compartmentId": otherCompartment}) + assert.Equal(t, http.StatusNotFound, move.Code, move.Body.String()) + + del := f.do(http.MethodDelete, "/20181201/subscriptions/"+missingSubscription, nil) + assert.Equal(t, http.StatusNotFound, del.Code, del.Body.String()) +} + +func TestListSubscriptionsRequiresACompartment(t *testing.T) { + t.Parallel() + + f := newFixture(t) + + w := f.do(http.MethodGet, "/20181201/subscriptions", nil) + assert.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) +} + +func TestListSubscriptionsPaginates(t *testing.T) { + t.Parallel() + + f := newFixture(t) + topicID := f.newTopic("alpha", compartment) + + for _, endpoint := range []string{"a@example.com", "b@example.com", "c@example.com"} { + f.newSubscription(topicID, endpoint) + } + + base := "/20181201/subscriptions?compartmentId=" + compartment + + first := f.do(http.MethodGet, base+"&limit=2", nil) + require.Equal(t, http.StatusOK, first.Code, first.Body.String()) + assert.Len(t, decodeList(t, first), 2) + assert.Equal(t, "2", first.Header().Get("opc-next-page")) + + second := f.do(http.MethodGet, base+"&limit=2&page=2", nil) + assert.Len(t, decodeList(t, second), 1) +} diff --git a/server/oci/notifications/topics.go b/server/oci/notifications/topics.go index ee7242dea..f2aeb9876 100644 --- a/server/oci/notifications/topics.go +++ b/server/oci/notifications/topics.go @@ -246,33 +246,6 @@ func (h *Handler) changeTopicCompartment(w http.ResponseWriter, r *http.Request, ocirest.WriteJSON(w, r, http.StatusAccepted, nil) } -// publishMessage is the ONS data plane: a message posted to the topic's own -// endpoint, which CloudEmu serves on the same listener as the control plane. -func (h *Handler) publishMessage(w http.ResponseWriter, r *http.Request, topicID string) { - if r.Method != http.MethodPost { - methodNotAllowed(w, r) - return - } - - var req messageDetails - - if !ocirest.DecodeJSON(w, r, &req) { - return - } - - msg, err := h.extras.PublishMessage(r.Context(), topicID, notifprovider.MessageSpec{ - Title: req.Title, - Body: req.Body, - Type: r.URL.Query().Get("messageType"), - }) - if err != nil { - ocirest.WriteDriverError(w, r, err) - return - } - - ocirest.WriteJSON(w, r, http.StatusOK, publishResult{MessageID: msg.ID, TimeStamp: msg.Timestamp}) -} - // topicWire renders a topic, folding in the OCI-only state the portable // projection has no room for. func (h *Handler) topicWire(r *http.Request, info *notifdriver.TopicInfo) topicResponse { diff --git a/server/oci/notifications/topics_test.go b/server/oci/notifications/topics_test.go new file mode 100644 index 000000000..4d0b07984 --- /dev/null +++ b/server/oci/notifications/topics_test.go @@ -0,0 +1,223 @@ +package notifications_test + +import ( + "crypto/tls" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stackshy/cloudemu/v2/config" + notifprovider "github.com/stackshy/cloudemu/v2/providers/oci/notifications" + ocinotif "github.com/stackshy/cloudemu/v2/server/oci/notifications" +) + +func TestListTopicsSorts(t *testing.T) { + t.Parallel() + + f := newFixture(t) + for _, name := range []string{"alpha", "bravo", "charlie"} { + f.newTopic(name, compartment) + } + + names := func(target string) []string { + w := f.do(http.MethodGet, target, nil) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + out := make([]string, 0, 3) + for _, topic := range decodeList(t, w) { + name, _ := topic["name"].(string) + out = append(out, name) + } + + return out + } + + base := "/20181201/topics?compartmentId=" + compartment + + assert.Equal(t, []string{"alpha", "bravo", "charlie"}, names(base+"&sortBy=TIMECREATED&sortOrder=ASC")) + assert.Equal(t, []string{"charlie", "bravo", "alpha"}, names(base+"&sortBy=TIMECREATED&sortOrder=DESC")) + // LIFECYCLESTATE ties across an all-ACTIVE listing, so the sort is stable. + assert.Equal(t, []string{"alpha", "bravo", "charlie"}, names(base+"&sortBy=LIFECYCLESTATE")) +} + +func TestListTopicsRejectsAnUnknownSort(t *testing.T) { + t.Parallel() + + f := newFixture(t) + base := "/20181201/topics?compartmentId=" + compartment + + for _, target := range []string{base + "&sortBy=NAME", base + "&sortOrder=SIDEWAYS"} { + w := f.do(http.MethodGet, target, nil) + assert.Equal(t, http.StatusBadRequest, w.Code, target) + } +} + +func TestListTopicsFilters(t *testing.T) { + t.Parallel() + + f := newFixture(t) + first := f.newTopic("alpha", compartment) + f.newTopic("bravo", compartment) + + base := "/20181201/topics?compartmentId=" + compartment + + byID := decodeList(t, f.do(http.MethodGet, base+"&id="+first, nil)) + require.Len(t, byID, 1) + assert.Equal(t, "alpha", byID[0]["name"]) + + byName := decodeList(t, f.do(http.MethodGet, base+"&name=bravo", nil)) + require.Len(t, byName, 1) + assert.Equal(t, "bravo", byName[0]["name"]) + + assert.Empty(t, decodeList(t, f.do(http.MethodGet, base+"&name=missing", nil))) + assert.Empty(t, decodeList(t, f.do(http.MethodGet, base+"&lifecycleState=DELETING", nil))) + assert.Len(t, decodeList(t, f.do(http.MethodGet, base+"&lifecycleState=ACTIVE", nil)), 2) +} + +func TestListTopicsPaginates(t *testing.T) { + t.Parallel() + + f := newFixture(t) + for _, name := range []string{"alpha", "bravo", "charlie"} { + f.newTopic(name, compartment) + } + + base := "/20181201/topics?compartmentId=" + compartment + + first := f.do(http.MethodGet, base+"&limit=2", nil) + require.Equal(t, http.StatusOK, first.Code) + assert.Len(t, decodeList(t, first), 2) + + next := first.Header().Get("opc-next-page") + require.Equal(t, "2", next) + + second := f.do(http.MethodGet, base+"&limit=2&page="+next, nil) + assert.Len(t, decodeList(t, second), 1) + assert.Empty(t, second.Header().Get("opc-next-page")) + + // A cursor past the end is an empty page, not an error. + assert.Empty(t, decodeList(t, f.do(http.MethodGet, base+"&page=99", nil))) +} + +func TestTopicMethodNotAllowed(t *testing.T) { + t.Parallel() + + f := newFixture(t) + id := f.newTopic("alpha", compartment) + + cases := map[string]struct{ method, target string }{ + "collection": {http.MethodPatch, "/20181201/topics?compartmentId=" + compartment}, + "single": {http.MethodPatch, "/20181201/topics/" + id}, + "messages": {http.MethodGet, "/20181201/topics/" + id + "/messages"}, + "move": {http.MethodGet, "/20181201/topics/" + id + "/actions/changeCompartment"}, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + w := f.do(tc.method, tc.target, nil) + assert.Equal(t, http.StatusMethodNotAllowed, w.Code, w.Body.String()) + }) + } +} + +func TestUpdateTopicErrors(t *testing.T) { + t.Parallel() + + f := newFixture(t) + id := f.newTopic("alpha", compartment) + + defined := f.do(http.MethodPut, "/20181201/topics/"+id, map[string]any{ + "definedTags": map[string]any{"ops": map[string]any{"tier": "gold"}}, + }) + assert.Equal(t, http.StatusBadRequest, defined.Code, defined.Body.String()) + + missing := f.do(http.MethodPut, "/20181201/topics/ocid1.onstopic.oc1..missing", map[string]any{ + "description": "x", + }) + assert.Equal(t, http.StatusNotFound, missing.Code, missing.Body.String()) +} + +func TestChangeTopicCompartmentErrors(t *testing.T) { + t.Parallel() + + f := newFixture(t) + id := f.newTopic("alpha", compartment) + target := "/20181201/topics/" + id + "/actions/changeCompartment" + + blank := f.do(http.MethodPost, target, map[string]any{}) + assert.Equal(t, http.StatusBadRequest, blank.Code, blank.Body.String()) + + missing := f.do(http.MethodPost, + "/20181201/topics/ocid1.onstopic.oc1..missing/actions/changeCompartment", + map[string]any{"compartmentId": otherCompartment}) + assert.Equal(t, http.StatusNotFound, missing.Code, missing.Body.String()) +} + +// Deleting and moving a topic both record a work request, so a handler wired +// without a store cannot serve them. +func TestTopicWorkRequestPathsNeedAStore(t *testing.T) { + t.Parallel() + + opts := config.NewOptions(config.WithRegion("us-ashburn-1"), config.WithCompartmentID(compartment)) + mock := notifprovider.New(opts) + handler := ocinotif.New(mock, nil) + + cases := map[string]struct{ method, target string }{ + "delete": {http.MethodDelete, "/20181201/topics/ocid1.onstopic.oc1..x"}, + "move": {http.MethodPost, "/20181201/topics/ocid1.onstopic.oc1..x/actions/changeCompartment"}, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + r := httptest.NewRequest(tc.method, tc.target, strings.NewReader("{}")) + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + + assert.Equal(t, http.StatusNotImplemented, w.Code, w.Body.String()) + }) + } +} + +func TestDeleteUnknownTopic(t *testing.T) { + t.Parallel() + + f := newFixture(t) + + w := f.do(http.MethodDelete, "/20181201/topics/ocid1.onstopic.oc1..missing", nil) + assert.Equal(t, http.StatusNotFound, w.Code, w.Body.String()) +} + +// A topic reports the origin the caller reached as its apiEndpoint, so a TLS +// request gets an https one. +func TestAPIEndpointFollowsTheScheme(t *testing.T) { + t.Parallel() + + f := newFixture(t) + id := f.newTopic("alpha", compartment) + + r := httptest.NewRequest(http.MethodGet, "/20181201/topics/"+id, nil) + r.TLS = &tls.ConnectionState{} + w := httptest.NewRecorder() + f.handler.ServeHTTP(w, r) + + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + endpoint, _ := decode(t, w)["apiEndpoint"].(string) + assert.True(t, strings.HasPrefix(endpoint, "https://"), endpoint) +} + +func TestMalformedNotificationsPath(t *testing.T) { + t.Parallel() + + f := newFixture(t) + + w := f.do(http.MethodGet, "/20181201/topics/a/b/c/d", nil) + assert.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) +} From 344b484d8ac8a31b3f267af49af6957d3a2449d9 Mon Sep 17 00:00:00 2001 From: arunesh-j Date: Mon, 7 Sep 2026 23:59:48 +0530 Subject: [PATCH 3/3] feat(oci): persist Notifications state; 201 on create, if-match, input limits Mock holds three memstore stores but implemented no snapshot.Snapshottable, so the persistence-completeness guard failed and a serve stop/start dropped every topic and subscription. Adds snapshot.go over topics, subscriptions and deliveries, mirroring providers/oci/vcn's shared storeDump table so Snapshot and Restore cannot drift. Deliveries are snapshotted rather than excluded: they stand in for the endpoint inbox real ONS pushes to and are the only thing Deliveries reads, so dropping them would report an ACTIVE subscription as having received nothing. Also brings three responses in line with real ONS: both creates answer 201, update and delete honour an if-match precondition (412 NoEtagMatch on a stale etag), and a malformed endpoint or an over-64 KB message body is rejected naming what is wrong instead of being stored unused. --- docs/services.md | 12 +- providers/oci/notifications/publish.go | 10 + providers/oci/notifications/publish_test.go | 16 ++ providers/oci/notifications/snapshot.go | 94 +++++++++ providers/oci/notifications/snapshot_test.go | 192 ++++++++++++++++++ providers/oci/notifications/subscriptions.go | 28 ++- .../oci/notifications/subscriptions_test.go | 48 ++++- server/oci/notifications/handler.go | 15 ++ server/oci/notifications/handler_test.go | 32 ++- server/oci/notifications/publish_test.go | 20 ++ server/oci/notifications/subscriptions.go | 22 +- .../oci/notifications/subscriptions_test.go | 51 +++++ server/oci/notifications/topics.go | 21 +- server/oci/notifications/topics_test.go | 52 +++++ 14 files changed, 597 insertions(+), 16 deletions(-) create mode 100644 providers/oci/notifications/snapshot.go create mode 100644 providers/oci/notifications/snapshot_test.go diff --git a/docs/services.md b/docs/services.md index 4191d64bc..cc32e749e 100644 --- a/docs/services.md +++ b/docs/services.md @@ -1527,6 +1527,11 @@ than answered in an arbitrary order. `definedTags` are rejected rather than echoed back empty; `freeformTags` round-trip. +Both creates answer `201 Created`. Updating or deleting a topic or a +subscription honours an `if-match` precondition against the stored etag, which +rotates on every mutation: a stale etag is a `412` with code `NoEtagMatch` and +the resource is left alone. An absent `if-match` is unconditional. + Real ONS splits the control plane from the data plane **by host, not by API prefix**: `PublishMessage` goes to the topic's own `apiEndpoint` rather than to a differently-prefixed path. CloudEmu serves both on one listener, so every @@ -1542,8 +1547,11 @@ create response, and `GET .../confirmation?token=…&protocol=…` flips it to succeeds and delivers to nobody. `.../unsubscription` takes the same token pair and removes the subscription. Protocols are `EMAIL`, `SMS`, `CUSTOM_HTTPS`, `SLACK`, `PAGERDUTY` and `ORACLE_FUNCTIONS` (`HTTP` / `HTTPS` alias onto -`CUSTOM_HTTPS`); anything else is rejected rather than stored unused. Message -bodies are `RAW_TEXT` or `JSON`. +`CUSTOM_HTTPS`); anything else is rejected rather than stored unused. The +endpoint is checked against the protocol at create rather than at first +delivery: an `EMAIL` endpoint must hold an `@`, and a `CUSTOM_HTTPS`, `SLACK` +or `PAGERDUTY` endpoint must be an `https` URL. Message bodies are `RAW_TEXT` +or `JSON` and are capped at ONS's 64 KB. `DeleteTopic` is the one asynchronous mutation: it answers **`204` with an `opc-work-request-id`**, not the `202` the rest of OCI uses for async work, and diff --git a/providers/oci/notifications/publish.go b/providers/oci/notifications/publish.go index 68251b8b1..e26b718f8 100644 --- a/providers/oci/notifications/publish.go +++ b/providers/oci/notifications/publish.go @@ -15,6 +15,9 @@ const ( MessageTypeJSON = "JSON" ) +// maxMessageBytes is the 64 KB ONS caps a published message body at. +const maxMessageBytes = 64 * 1024 + // MessageSpec is a message to publish to a topic. type MessageSpec struct { Title string @@ -34,6 +37,8 @@ type Message struct { // Publish publishes a message to a topic. It is the portable entry point onto // PublishMessage. +// +//nolint:gocritic // hugeParam: interface method signature cannot be changed. func (m *Mock) Publish(ctx context.Context, input driver.PublishInput) (*driver.PublishOutput, error) { // ONS carries no per-message attributes, so accepting them would drop // them silently. @@ -61,6 +66,11 @@ func (m *Mock) PublishMessage(_ context.Context, topicID string, spec MessageSpe return nil, cerrors.New(cerrors.InvalidArgument, "message body is required") } + if len(spec.Body) > maxMessageBytes { + return nil, cerrors.Newf(cerrors.InvalidArgument, + "message body is %d bytes; ONS caps a message at %d", len(spec.Body), maxMessageBytes) + } + msgType, err := normalizeMessageType(spec.Type) if err != nil { return nil, err diff --git a/providers/oci/notifications/publish_test.go b/providers/oci/notifications/publish_test.go index c0130e359..0b4fe0edd 100644 --- a/providers/oci/notifications/publish_test.go +++ b/providers/oci/notifications/publish_test.go @@ -2,6 +2,7 @@ package notifications_test import ( "context" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -55,3 +56,18 @@ func TestPublishEmitsMetrics(t *testing.T) { assert.Contains(t, names, "PublishedMessages") assert.Contains(t, names, "DeliveredMessages") } + +// ONS caps a published message body at 64 KB. +func TestPublishMessageSizeCap(t *testing.T) { + ctx := context.Background() + m := newMock(t) + id := newTopic(t, m, "alpha", compartment) + + _, err := m.PublishMessage(ctx, id, notifications.MessageSpec{Body: strings.Repeat("x", 64*1024)}) + require.NoError(t, err) + + _, err = m.PublishMessage(ctx, id, notifications.MessageSpec{Body: strings.Repeat("x", 64*1024+1)}) + require.Error(t, err) + assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(err)) + assert.Contains(t, err.Error(), "65536") +} diff --git a/providers/oci/notifications/snapshot.go b/providers/oci/notifications/snapshot.go new file mode 100644 index 000000000..df079a271 --- /dev/null +++ b/providers/oci/notifications/snapshot.go @@ -0,0 +1,94 @@ +package notifications + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/stackshy/cloudemu/v2/internal/snapshot" +) + +var _ snapshot.Snapshottable = (*Mock)(nil) + +// notificationsSnapshot is the full serialized state of the OCI Notifications +// mock. Each store is dumped keyed by its resource OCID, so a subscription's +// TopicID and a delivery's subscription key still resolve after a restore. A +// PENDING subscription keeps its ConfirmationToken, so a confirmation issued +// before the restart still lands. +// +// Deliveries are snapshotted rather than dropped: they are the emulator's +// stand-in for the endpoint inbox real ONS pushes to, and the only thing +// Deliveries reads. Losing them on restart would report an ACTIVE subscription +// as having received nothing, which is silent data loss rather than +// transience. +// +// topicData is unexported but every field on it is exported, as is every field +// of Subscription and Message, so all three stores round-trip through the +// generic memstore helper. The mutex, *config.Options and the monitoring +// backend are wiring, not state, and are not serialized. +type notificationsSnapshot struct { + Topics json.RawMessage `json:"topics,omitempty"` + Subscriptions json.RawMessage `json:"subscriptions,omitempty"` + Deliveries json.RawMessage `json:"deliveries,omitempty"` +} + +// Snapshot captures the mock's entire state as JSON. includeAssets is unused — +// Notifications holds no bulk object bodies. +func (m *Mock) Snapshot(_ context.Context, _ bool) (json.RawMessage, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + var snap notificationsSnapshot + + for _, d := range m.snapshotDumps(&snap) { + b, err := d.fn() + if err != nil { + return nil, fmt.Errorf("notifications: snapshot store: %w", err) + } + + *d.dst = b + } + + return json.Marshal(snap) +} + +// Restore rebuilds the mock's state under the original identities: every OCID, +// etag and confirmation token is preserved. +func (m *Mock) Restore(_ context.Context, data json.RawMessage) error { + var snap notificationsSnapshot + if err := json.Unmarshal(data, &snap); err != nil { + return fmt.Errorf("notifications: parse snapshot: %w", err) + } + + m.mu.Lock() + defer m.mu.Unlock() + + for _, d := range m.snapshotDumps(&snap) { + if len(*d.dst) == 0 { + continue + } + + if err := d.load(*d.dst); err != nil { + return fmt.Errorf("notifications: restore store: %w", err) + } + } + + return nil +} + +// storeDump pairs a snapshot field with its store's dump and load functions, so +// Snapshot and Restore share one table and cannot drift apart. +type storeDump struct { + dst *json.RawMessage + fn func() ([]byte, error) + load func([]byte) error +} + +// snapshotDumps lists every store alongside the snapshot field it maps to. +func (m *Mock) snapshotDumps(snap *notificationsSnapshot) []storeDump { + return []storeDump{ + {&snap.Topics, m.topics.Snapshot, m.topics.LoadSnapshot}, + {&snap.Subscriptions, m.subs.Snapshot, m.subs.LoadSnapshot}, + {&snap.Deliveries, m.deliveries.Snapshot, m.deliveries.LoadSnapshot}, + } +} diff --git a/providers/oci/notifications/snapshot_test.go b/providers/oci/notifications/snapshot_test.go new file mode 100644 index 000000000..bf45d1201 --- /dev/null +++ b/providers/oci/notifications/snapshot_test.go @@ -0,0 +1,192 @@ +package notifications + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stackshy/cloudemu/v2/config" + "github.com/stackshy/cloudemu/v2/services/notification/driver" + "github.com/stackshy/cloudemu/v2/services/scope" +) + +const snapCompartment = "ocid1.compartment.oc1..aaaaaaaasnap" + +func newSnapshotMock(t *testing.T) *Mock { + t.Helper() + + return New(config.NewOptions( + config.WithRegion("us-ashburn-1"), + config.WithCompartmentID(snapCompartment), + )) +} + +// TestSnapshotRestoreRoundTrip seeds two topics, a confirmed subscription with +// a delivery and a second subscription still PENDING, then restores into a +// fresh mock and asserts identities, the topic cross-reference, the delivery +// history and the pending confirmation all survive. +func TestSnapshotRestoreRoundTrip(t *testing.T) { + ctx := t.Context() + src := newSnapshotMock(t) + + topic, err := src.CreateTopic(ctx, driver.TopicConfig{ + Name: "alerts", + DisplayName: "ops alerts", + Tags: map[string]string{"team": "ops"}, + Scope: scope.Scope{Compartment: snapCompartment}, + }) + require.NoError(t, err) + + other, err := src.CreateTopic(ctx, driver.TopicConfig{ + Name: "audit", + Scope: scope.Scope{Compartment: snapCompartment}, + }) + require.NoError(t, err) + + active, err := src.CreateSubscription(ctx, SubscriptionSpec{ + TopicID: topic.ID, CompartmentID: snapCompartment, + Protocol: ProtocolEmail, Endpoint: "ops@example.com", + }) + require.NoError(t, err) + + _, err = src.ConfirmSubscription(ctx, active.ID, active.ConfirmationToken, ProtocolEmail) + require.NoError(t, err) + + pending, err := src.CreateSubscription(ctx, SubscriptionSpec{ + TopicID: other.ID, CompartmentID: snapCompartment, + Protocol: ProtocolEmail, Endpoint: "audit@example.com", + }) + require.NoError(t, err) + + _, err = src.PublishMessage(ctx, topic.ID, MessageSpec{Title: "deploy", Body: "shipped"}) + require.NoError(t, err) + + data, err := src.Snapshot(ctx, false) + require.NoError(t, err) + + dst := newSnapshotMock(t) + require.NoError(t, dst.Restore(ctx, data)) + + // The topic is back under its OCID with its OCI-only state. + restored, err := dst.GetTopic(ctx, topic.ID) + require.NoError(t, err) + assert.Equal(t, "alerts", restored.Name) + assert.Equal(t, map[string]string{"team": "ops"}, restored.Tags) + + details, ok := dst.TopicDetails(topic.ID) + require.True(t, ok) + assert.Equal(t, StateActive, details.LifecycleState) + assert.NotEmpty(t, details.ShortTopicID) + assert.NotEmpty(t, details.Etag) + + // The subscription still points at the topic it was created on, so the + // cross-reference survived. + subs, err := dst.ListSubscriptions(ctx, topic.ID) + require.NoError(t, err) + require.Len(t, subs, 1) + + got, err := dst.GetSubscription(ctx, active.ID) + require.NoError(t, err) + assert.Equal(t, topic.ID, got.TopicID) + assert.Equal(t, StateActive, got.LifecycleState) + + // The delivery history came back with it. + delivered := dst.Deliveries(active.ID) + require.Len(t, delivered, 1) + assert.Equal(t, "shipped", delivered[0].Body) + + // The second topic's subscription is still PENDING and received nothing. + stillPending, err := dst.GetSubscription(ctx, pending.ID) + require.NoError(t, err) + assert.Equal(t, other.ID, stillPending.TopicID) + assert.Equal(t, StatePending, stillPending.LifecycleState) + assert.Empty(t, dst.Deliveries(pending.ID)) +} + +// A subscription still PENDING at snapshot time is still PENDING after a +// restore, and its original token still confirms it. +func TestSnapshotKeepsAPendingSubscriptionConfirmable(t *testing.T) { + ctx := t.Context() + src := newSnapshotMock(t) + + topic, err := src.CreateTopic(ctx, driver.TopicConfig{ + Name: "alerts", + Scope: scope.Scope{Compartment: snapCompartment}, + }) + require.NoError(t, err) + + pending, err := src.CreateSubscription(ctx, SubscriptionSpec{ + TopicID: topic.ID, CompartmentID: snapCompartment, + Protocol: ProtocolEmail, Endpoint: "ops@example.com", + }) + require.NoError(t, err) + require.Equal(t, StatePending, pending.LifecycleState) + + data, err := src.Snapshot(ctx, false) + require.NoError(t, err) + + dst := newSnapshotMock(t) + require.NoError(t, dst.Restore(ctx, data)) + + got, err := dst.GetSubscription(ctx, pending.ID) + require.NoError(t, err) + assert.Equal(t, StatePending, got.LifecycleState) + assert.Equal(t, pending.ConfirmationToken, got.ConfirmationToken) + + // Publishing before the confirmation still delivers to nobody. + _, err = dst.PublishMessage(ctx, topic.ID, MessageSpec{Body: "early"}) + require.NoError(t, err) + assert.Empty(t, dst.Deliveries(pending.ID)) + + // The token minted before the snapshot still confirms after the restore. + result, err := dst.ConfirmSubscription(ctx, pending.ID, pending.ConfirmationToken, ProtocolEmail) + require.NoError(t, err) + assert.Equal(t, pending.ID, result.SubscriptionID) + + _, err = dst.PublishMessage(ctx, topic.ID, MessageSpec{Body: "later"}) + require.NoError(t, err) + + delivered := dst.Deliveries(pending.ID) + require.Len(t, delivered, 1) + assert.Equal(t, "later", delivered[0].Body) +} + +func TestRestoreRejectsMalformedInput(t *testing.T) { + require.Error(t, newSnapshotMock(t).Restore(t.Context(), json.RawMessage("not json"))) +} + +// An empty snapshot restores cleanly and leaves the mock usable. +func TestRestoreEmptySnapshot(t *testing.T) { + ctx := t.Context() + m := newSnapshotMock(t) + + require.NoError(t, m.Restore(ctx, json.RawMessage("{}"))) + + topics, err := m.ListTopics(ctx, scope.Scope{Compartment: snapCompartment}) + require.NoError(t, err) + assert.Empty(t, topics) + + _, err = m.CreateTopic(ctx, driver.TopicConfig{ + Name: "alerts", + Scope: scope.Scope{Compartment: snapCompartment}, + }) + require.NoError(t, err) +} + +// Snapshotting an untouched mock produces a document that restores to an empty +// mock rather than failing. +func TestSnapshotOfAnEmptyMock(t *testing.T) { + ctx := t.Context() + + data, err := newSnapshotMock(t).Snapshot(ctx, false) + require.NoError(t, err) + + dst := newSnapshotMock(t) + require.NoError(t, dst.Restore(ctx, data)) + + topics, err := dst.ListTopics(ctx, scope.Scope{Compartment: snapCompartment}) + require.NoError(t, err) + assert.Empty(t, topics) +} diff --git a/providers/oci/notifications/subscriptions.go b/providers/oci/notifications/subscriptions.go index 56ae1bef8..e60457f8a 100644 --- a/providers/oci/notifications/subscriptions.go +++ b/providers/oci/notifications/subscriptions.go @@ -117,8 +117,8 @@ func (m *Mock) CreateSubscription(_ context.Context, spec SubscriptionSpec) (*Su return nil, err } - if spec.Endpoint == "" { - return nil, cerrors.New(cerrors.InvalidArgument, "endpoint is required") + if err := validateEndpoint(protocol, spec.Endpoint); err != nil { + return nil, err } m.mu.Lock() @@ -395,6 +395,30 @@ func checkToken(sub *Subscription, token, protocol string) error { return nil } +// validateEndpoint applies the endpoint shape each protocol delivers to. ONS +// rejects a malformed endpoint at create rather than failing the first +// delivery, so the emulator does too, naming what is wrong. +func validateEndpoint(protocol, endpoint string) error { + if endpoint == "" { + return cerrors.New(cerrors.InvalidArgument, "endpoint is required") + } + + switch protocol { + case ProtocolEmail: + if !strings.Contains(endpoint, "@") { + return cerrors.Newf(cerrors.InvalidArgument, + "endpoint %q is not an email address; an %s endpoint must hold an @", endpoint, ProtocolEmail) + } + case ProtocolHTTPS, ProtocolSlack, ProtocolPagerDuty: + if !strings.HasPrefix(strings.ToLower(endpoint), "https://") { + return cerrors.Newf(cerrors.InvalidArgument, + "endpoint %q is not https; a %s endpoint must be an https URL", endpoint, protocol) + } + } + + return nil +} + // normalizeProtocol maps a caller's protocol onto the ONS one, rejecting a // protocol ONS does not deliver over rather than storing it unused. func normalizeProtocol(protocol string) (string, error) { diff --git a/providers/oci/notifications/subscriptions_test.go b/providers/oci/notifications/subscriptions_test.go index e55ff1c38..e52ee22d5 100644 --- a/providers/oci/notifications/subscriptions_test.go +++ b/providers/oci/notifications/subscriptions_test.go @@ -55,11 +55,11 @@ func TestUnsubscribeByTokenErrors(t *testing.T) { id, token, protocol string code cerrors.Code }{ - "no token": {sub.ID, "", "EMAIL", cerrors.InvalidArgument}, - "unknown id": {"ocid1.onssubscription.oc1..missing", sub.ConfirmationToken, "", cerrors.NotFound}, - "wrong token": {sub.ID, "token-wrong", "", cerrors.InvalidArgument}, - "bad protocol": {sub.ID, sub.ConfirmationToken, "CARRIER_PIGEON", cerrors.InvalidArgument}, - "other protocol": {sub.ID, sub.ConfirmationToken, "SMS", cerrors.InvalidArgument}, + "no token": {sub.ID, "", "EMAIL", cerrors.InvalidArgument}, + "unknown id": {"ocid1.onssubscription.oc1..missing", sub.ConfirmationToken, "", cerrors.NotFound}, + "wrong token": {sub.ID, "token-wrong", "", cerrors.InvalidArgument}, + "bad protocol": {sub.ID, sub.ConfirmationToken, "CARRIER_PIGEON", cerrors.InvalidArgument}, + "other protocol": {sub.ID, sub.ConfirmationToken, "SMS", cerrors.InvalidArgument}, } for name, tc := range tests { @@ -109,3 +109,41 @@ func TestChangeSubscriptionCompartmentErrors(t *testing.T) { require.Error(t, err) assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) } + +// The endpoint shape each protocol delivers to is checked at create, naming +// what is wrong rather than failing the first delivery. +func TestCreateSubscriptionEndpointValidation(t *testing.T) { + ctx := context.Background() + m := newMock(t) + topicID := newTopic(t, m, "alpha", compartment) + + tests := map[string]struct { + protocol, endpoint string + wantErr bool + }{ + "email": {"EMAIL", "ops@example.com", false}, + "email without an at": {"EMAIL", "ops-example.com", true}, + "https": {"CUSTOM_HTTPS", "https://hooks.example.com/x", false}, + "https over http": {"CUSTOM_HTTPS", "http://hooks.example.com/x", true}, + "slack over http": {"SLACK", "http://hooks.slack.com/x", true}, + "pagerduty https": {"PAGERDUTY", "https://events.pagerduty.com/x", false}, + "sms is unchecked": {"SMS", "+15550100", false}, + "empty": {"EMAIL", "", true}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + _, err := m.CreateSubscription(ctx, notifications.SubscriptionSpec{ + TopicID: topicID, CompartmentID: compartment, + Protocol: tc.protocol, Endpoint: tc.endpoint, + }) + if !tc.wantErr { + require.NoError(t, err) + return + } + + require.Error(t, err) + assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(err)) + }) + } +} diff --git a/server/oci/notifications/handler.go b/server/oci/notifications/handler.go index b1ad9a5f7..9eaea8ddf 100644 --- a/server/oci/notifications/handler.go +++ b/server/oci/notifications/handler.go @@ -63,6 +63,7 @@ const ( codeMethodNotAllowed = "MethodNotAllowed" codeNotImplemented = "NotImplemented" codeNotFound = "NotAuthorizedOrNotFound" + codeNoEtagMatch = "NoEtagMatch" ) // maxPathSegments is /{version}/{collection}/{id}/{sub}/{action}. @@ -201,6 +202,20 @@ func refuseDefinedTags(w http.ResponseWriter, r *http.Request, tags definedTags) return false } +// checkIfMatch enforces the caller's if-match precondition against the stored +// etag. An absent header is unconditional, as ONS treats it. +func checkIfMatch(w http.ResponseWriter, r *http.Request, etag string) bool { + want := r.Header.Get("If-Match") + if want == "" || want == etag { + return true + } + + ocirest.WriteError(w, r, http.StatusPreconditionFailed, codeNoEtagMatch, + "if-match "+want+" does not match the current etag") + + return false +} + func notFound(w http.ResponseWriter, r *http.Request) { ocirest.WriteError(w, r, http.StatusNotFound, codeNotFound, "unknown notifications path "+r.URL.Path) } diff --git a/server/oci/notifications/handler_test.go b/server/oci/notifications/handler_test.go index 531628acb..7a17ff3ff 100644 --- a/server/oci/notifications/handler_test.go +++ b/server/oci/notifications/handler_test.go @@ -67,6 +67,28 @@ func (f *fixture) do(method, target string, body any) *httptest.ResponseRecorder return w } +// doIfMatch sends a request carrying an if-match precondition. +func (f *fixture) doIfMatch(method, target, etag string, body any) *httptest.ResponseRecorder { + f.t.Helper() + + var reader *bytes.Reader + + if body != nil { + raw, err := json.Marshal(body) + require.NoError(f.t, err) + reader = bytes.NewReader(raw) + } else { + reader = bytes.NewReader(nil) + } + + r := httptest.NewRequest(method, target, reader) + r.Header.Set("if-match", etag) + w := httptest.NewRecorder() + f.handler.ServeHTTP(w, r) + + return w +} + func decode(t *testing.T, w *httptest.ResponseRecorder) map[string]any { t.Helper() @@ -95,7 +117,7 @@ func (f *fixture) newTopic(name, compartmentID string) string { "compartmentId": compartmentID, "description": "topic " + name, }) - require.Equal(f.t, http.StatusOK, w.Code, w.Body.String()) + require.Equal(f.t, http.StatusCreated, w.Code, w.Body.String()) id, _ := decode(f.t, w)["topicId"].(string) @@ -113,7 +135,7 @@ func (f *fixture) newSubscription(topicID, endpoint string) (id, token string) { "protocol": "EMAIL", "endpoint": endpoint, }) - require.Equal(f.t, http.StatusOK, w.Code, w.Body.String()) + require.Equal(f.t, http.StatusCreated, w.Code, w.Body.String()) body := decode(f.t, w) id, _ = body["id"].(string) @@ -182,7 +204,7 @@ func TestCreateTopicWire(t *testing.T) { "description": "production alerts", "freeformTags": map[string]string{"env": "prod"}, }) - require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + require.Equal(t, http.StatusCreated, w.Code, w.Body.String()) body := decode(t, w) assert.Contains(t, body["topicId"], "ocid1.onstopic.oc1.iad.") @@ -353,7 +375,7 @@ func TestCreateSubscriptionWire(t *testing.T) { "protocol": "EMAIL", "endpoint": "ops@example.com", }) - require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + require.Equal(t, http.StatusCreated, w.Code, w.Body.String()) body := decode(t, w) assert.Contains(t, body["id"], "ocid1.onssubscription.oc1.iad.") @@ -515,7 +537,7 @@ func TestListSubscriptions(t *testing.T) { w := f.do(http.MethodPost, "/20181201/subscriptions", map[string]any{ "topicId": theirs, "compartmentId": otherCompartment, "protocol": "EMAIL", "endpoint": "b@example.com", }) - require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + require.Equal(t, http.StatusCreated, w.Code, w.Body.String()) w = f.do(http.MethodGet, "/20181201/subscriptions?compartmentId="+compartment, nil) require.Equal(t, http.StatusOK, w.Code) diff --git a/server/oci/notifications/publish_test.go b/server/oci/notifications/publish_test.go index c6500180f..ce81ce92d 100644 --- a/server/oci/notifications/publish_test.go +++ b/server/oci/notifications/publish_test.go @@ -2,6 +2,7 @@ package notifications_test import ( "net/http" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -82,3 +83,22 @@ func TestPublishDeliversOnlyToConfirmedSubscriptions(t *testing.T) { require.Len(t, delivered, 1) assert.Equal(t, "after confirming", delivered[0].Body) } + +// ONS caps a published message at 64 KB. +func TestPublishRejectsAnOversizedBody(t *testing.T) { + t.Parallel() + + f := newFixture(t) + id := f.newTopic("alerts", compartment) + + w := f.do(http.MethodPost, "/20181201/topics/"+id+"/messages", map[string]any{ + "body": strings.Repeat("x", 64*1024+1), + }) + require.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "65536") + + atLimit := f.do(http.MethodPost, "/20181201/topics/"+id+"/messages", map[string]any{ + "body": strings.Repeat("x", 64*1024), + }) + assert.Equal(t, http.StatusOK, atLimit.Code, atLimit.Body.String()) +} diff --git a/server/oci/notifications/subscriptions.go b/server/oci/notifications/subscriptions.go index d69990b16..e221d1196 100644 --- a/server/oci/notifications/subscriptions.go +++ b/server/oci/notifications/subscriptions.go @@ -99,7 +99,7 @@ func (h *Handler) createSubscription(w http.ResponseWriter, r *http.Request) { return } - ocirest.WriteJSON(w, r, http.StatusOK, subscriptionWire(sub)) + ocirest.WriteJSON(w, r, http.StatusCreated, subscriptionWire(sub)) } func (h *Handler) getSubscription(w http.ResponseWriter, r *http.Request, id string) { @@ -148,6 +148,10 @@ func (h *Handler) updateSubscription(w http.ResponseWriter, r *http.Request, id return } + if !h.subscriptionIfMatch(w, r, id) { + return + } + sub, err := h.extras.UpdateSubscription(r.Context(), id, notifprovider.SubscriptionPatch{ DeliveryPolicy: toDriverPolicy(req.DeliveryPolicy), FreeformTags: req.FreeformTags, @@ -161,6 +165,10 @@ func (h *Handler) updateSubscription(w http.ResponseWriter, r *http.Request, id } func (h *Handler) deleteSubscription(w http.ResponseWriter, r *http.Request, id string) { + if !h.subscriptionIfMatch(w, r, id) { + return + } + if err := h.notif.Unsubscribe(r.Context(), id); err != nil { ocirest.WriteDriverError(w, r, err) return @@ -257,6 +265,18 @@ func (h *Handler) changeSubscriptionCompartment(w http.ResponseWriter, r *http.R ocirest.WriteJSON(w, r, http.StatusNoContent, nil) } +// subscriptionIfMatch enforces an if-match precondition against a +// subscription's stored etag. An unknown subscription passes through to the +// driver's own 404. +func (h *Handler) subscriptionIfMatch(w http.ResponseWriter, r *http.Request, id string) bool { + sub, err := h.extras.GetSubscription(r.Context(), id) + if err != nil { + return true + } + + return checkIfMatch(w, r, sub.Etag) +} + // tokenParams reads the token and protocol the confirmation endpoints // authenticate with, writing the 400 when the token is missing. func tokenParams(w http.ResponseWriter, r *http.Request) (token, protocol string, ok bool) { diff --git a/server/oci/notifications/subscriptions_test.go b/server/oci/notifications/subscriptions_test.go index 469b83dd0..637d81da0 100644 --- a/server/oci/notifications/subscriptions_test.go +++ b/server/oci/notifications/subscriptions_test.go @@ -173,3 +173,54 @@ func TestListSubscriptionsPaginates(t *testing.T) { second := f.do(http.MethodGet, base+"&limit=2&page=2", nil) assert.Len(t, decodeList(t, second), 1) } + +func TestSubscriptionIfMatch(t *testing.T) { + t.Parallel() + + f := newFixture(t) + topicID := f.newTopic("alerts", compartment) + id, _ := f.newSubscription(topicID, "ops@example.com") + + get := f.do(http.MethodGet, "/20181201/subscriptions/"+id, nil) + require.Equal(t, http.StatusOK, get.Code, get.Body.String()) + etag, _ := decode(t, get)["etag"].(string) + require.NotEmpty(t, etag) + + ok := f.doIfMatch(http.MethodPut, "/20181201/subscriptions/"+id, etag, + map[string]any{"freeformTags": map[string]string{"team": "ops"}}) + require.Equal(t, http.StatusOK, ok.Code, ok.Body.String()) + + stale := f.doIfMatch(http.MethodPut, "/20181201/subscriptions/"+id, etag, + map[string]any{"freeformTags": map[string]string{"team": "ignored"}}) + require.Equal(t, http.StatusPreconditionFailed, stale.Code, stale.Body.String()) + + staleDelete := f.doIfMatch(http.MethodDelete, "/20181201/subscriptions/"+id, etag, nil) + assert.Equal(t, http.StatusPreconditionFailed, staleDelete.Code, staleDelete.Body.String()) +} + +// ONS rejects an endpoint the protocol cannot deliver to, naming what is wrong. +func TestCreateSubscriptionRejectsAMalformedEndpoint(t *testing.T) { + t.Parallel() + + f := newFixture(t) + topicID := f.newTopic("alerts", compartment) + + cases := map[string]struct{ protocol, endpoint, want string }{ + "email without an at": {"EMAIL", "ops-example.com", "@"}, + "https over http": {"CUSTOM_HTTPS", "http://hooks.example.com/x", "https"}, + "slack over http": {"SLACK", "http://hooks.slack.com/x", "https"}, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + w := f.do(http.MethodPost, "/20181201/subscriptions", map[string]any{ + "topicId": topicID, "compartmentId": compartment, + "protocol": tc.protocol, "endpoint": tc.endpoint, + }) + require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), tc.want) + }) + } +} diff --git a/server/oci/notifications/topics.go b/server/oci/notifications/topics.go index f2aeb9876..2eb179169 100644 --- a/server/oci/notifications/topics.go +++ b/server/oci/notifications/topics.go @@ -99,7 +99,7 @@ func (h *Handler) createTopic(w http.ResponseWriter, r *http.Request) { return } - ocirest.WriteJSON(w, r, http.StatusOK, h.topicWire(r, info)) + ocirest.WriteJSON(w, r, http.StatusCreated, h.topicWire(r, info)) } func (h *Handler) getTopic(w http.ResponseWriter, r *http.Request, id string) { @@ -161,6 +161,10 @@ func (h *Handler) updateTopic(w http.ResponseWriter, r *http.Request, id string) return } + if !h.topicIfMatch(w, r, id) { + return + } + info, err := h.notif.UpdateTopic(r.Context(), notifdriver.TopicConfig{ Name: id, DisplayName: req.Description, @@ -188,6 +192,10 @@ func (h *Handler) deleteTopic(w http.ResponseWriter, r *http.Request, id string) return } + if !h.topicIfMatch(w, r, id) { + return + } + compartmentID := info.Scope.Compartment if err := h.notif.DeleteTopic(r.Context(), id); err != nil { @@ -274,6 +282,17 @@ func (h *Handler) topicWire(r *http.Request, info *notifdriver.TopicInfo) topicR return out } +// topicIfMatch enforces an if-match precondition against a topic's stored +// etag. An unknown topic passes through to the driver's own 404. +func (h *Handler) topicIfMatch(w http.ResponseWriter, r *http.Request, id string) bool { + details, ok := h.extras.TopicDetails(id) + if !ok { + return true + } + + return checkIfMatch(w, r, details.Etag) +} + // topicMatches applies ONS's id, name and lifecycleState narrowing. func topicMatches(topic *topicResponse, query url.Values) bool { if id := query.Get("id"); id != "" && topic.TopicID != id { diff --git a/server/oci/notifications/topics_test.go b/server/oci/notifications/topics_test.go index 4d0b07984..0f0afb508 100644 --- a/server/oci/notifications/topics_test.go +++ b/server/oci/notifications/topics_test.go @@ -221,3 +221,55 @@ func TestMalformedNotificationsPath(t *testing.T) { w := f.do(http.MethodGet, "/20181201/topics/a/b/c/d", nil) assert.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) } + +// Real ONS answers 201 Created, not 200, on both creates. +func TestCreateAnswers201(t *testing.T) { + t.Parallel() + + f := newFixture(t) + + topic := f.do(http.MethodPost, "/20181201/topics", map[string]any{ + "name": "alerts", "compartmentId": compartment, + }) + require.Equal(t, http.StatusCreated, topic.Code, topic.Body.String()) + + topicID, _ := decode(t, topic)["topicId"].(string) + + sub := f.do(http.MethodPost, "/20181201/subscriptions", map[string]any{ + "topicId": topicID, "compartmentId": compartment, + "protocol": "EMAIL", "endpoint": "ops@example.com", + }) + assert.Equal(t, http.StatusCreated, sub.Code, sub.Body.String()) +} + +// An if-match carrying the current etag proceeds; a stale one is a 412 and +// leaves the topic alone. +func TestTopicIfMatch(t *testing.T) { + t.Parallel() + + f := newFixture(t) + id := f.newTopic("alerts", compartment) + + get := f.do(http.MethodGet, "/20181201/topics/"+id, nil) + require.Equal(t, http.StatusOK, get.Code, get.Body.String()) + etag, _ := decode(t, get)["etag"].(string) + require.NotEmpty(t, etag) + + ok := f.doIfMatch(http.MethodPut, "/20181201/topics/"+id, etag, map[string]any{"description": "fresh"}) + require.Equal(t, http.StatusOK, ok.Code, ok.Body.String()) + assert.Equal(t, "fresh", decode(t, ok)["description"]) + + // The update rotated the etag, so the one just used is now stale. + stale := f.doIfMatch(http.MethodPut, "/20181201/topics/"+id, etag, map[string]any{"description": "ignored"}) + require.Equal(t, http.StatusPreconditionFailed, stale.Code, stale.Body.String()) + + unchanged := f.do(http.MethodGet, "/20181201/topics/"+id, nil) + assert.Equal(t, "fresh", decode(t, unchanged)["description"]) + + staleDelete := f.doIfMatch(http.MethodDelete, "/20181201/topics/"+id, etag, nil) + require.Equal(t, http.StatusPreconditionFailed, staleDelete.Code, staleDelete.Body.String()) + + current, _ := decode(t, f.do(http.MethodGet, "/20181201/topics/"+id, nil))["etag"].(string) + freshDelete := f.doIfMatch(http.MethodDelete, "/20181201/topics/"+id, current, nil) + assert.Equal(t, http.StatusNoContent, freshDelete.Code, freshDelete.Body.String()) +}