From 7b81b65795da519313fe1ccaf3bfd5d8ab0a3e9e Mon Sep 17 00:00:00 2001 From: arunesh-j Date: Sat, 22 Aug 2026 00:03:26 +0530 Subject: [PATCH 1/4] 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 3a049805b..5231f4d2d 100644 --- a/docs/coverage/README.md +++ b/docs/coverage/README.md @@ -133,7 +133,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 9ff0dda5d..e3a6bb2c6 100644 --- a/docs/coverage/coverage.json +++ b/docs/coverage/coverage.json @@ -12884,7 +12884,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 8a022e2ab..a7bc5c2b8 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 76e721a43..ab96057d9 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 20b2d8f31..b049f74f6 100644 --- a/server/oci/oci.go +++ b/server/oci/oci.go @@ -13,6 +13,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" @@ -108,6 +109,10 @@ func New(d Drivers) *server.Server { srv.Register(vcnHandler) } + if d.Notifications != nil { + srv.Register(notifications.New(d.Notifications, d.WorkRequests)) + } + return srv } From 44753beee720179e24e51aafaff5b7f2b3ed29f3 Mon Sep 17 00:00:00 2001 From: arunesh-j Date: Mon, 7 Sep 2026 23:33:56 +0530 Subject: [PATCH 2/4] 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 de9a8f545..7e426e9fb 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 a02fc34fb96bc483f82d01aed6b11a0072da75d7 Mon Sep 17 00:00:00 2001 From: arunesh-j Date: Mon, 7 Sep 2026 23:59:48 +0530 Subject: [PATCH 3/4] 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 7e426e9fb..dc5fc9554 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()) +} From a2b74eb7d055446141bcd9e6017eb2877534d5bf Mon Sep 17 00:00:00 2001 From: arunesh-j Date: Tue, 6 Oct 2026 01:03:32 +0530 Subject: [PATCH 4/4] fix(oci): match ONS wire shapes to the SDK; compartment checks, atomic if-match Wire shapes, checked against oci-go-sdk's ons package: - Create/Get/UpdateSubscription return ONS's Subscription, which carries the delivery policy as a JSON string in deliverPolicy; only the List item (SubscriptionSummary) keeps the deliveryPolicy object. Terraform's oci_ons_subscription.delivery_policy reads the former. - PublishMessage reads messageType from the header the SDK sends it in; the query parameter stays as a fallback. - Create, get and update of topics and subscriptions set the etag response header the SDK maps Etag from. - Confirm and unsubscribe require protocol, which ONS marks mandatory. Behaviour: - ORACLE_FUNCTIONS subscriptions are created ACTIVE with no token; ONS does not ask a function to confirm. - Topic names are unique across the tenancy, not per compartment. - CreateTopic, ChangeTopicCompartment, CreateSubscription and ChangeSubscriptionCompartment reject a nonexistent compartment with 404, via the same SetCompartmentChecker hook VCN uses, wired from Identity. - if-match is compared under the provider lock on topic update, delete and compartment move and on subscription update and delete, so of several writers holding one etag exactly one wins; the rest get 412 NoEtagMatch. - A page token the listing never issued is 400 instead of restarting. - Delivery history keeps the last 100 messages per subscription. - ONS metrics land in the topic's compartment keyed by resourceId, through a new monitoring PostServiceMetricData. PutMetricData is back to rejecting oci_ namespaces for portable callers. Docs: DeleteTopic and ChangeTopicCompartment return only opc-request-id in real ONS; CloudEmu's extra work request is now described as such. Em dashes this branch added are replaced with plain punctuation. --- docs/services.md | 59 +++++--- providers/oci/monitoring/metrics.go | 13 +- providers/oci/monitoring/monitoring.go | 8 +- providers/oci/notifications/notifications.go | 107 +++++++++++--- .../oci/notifications/notifications_test.go | 45 ++++-- providers/oci/notifications/publish.go | 41 ++++-- providers/oci/notifications/publish_test.go | 63 +++++++- providers/oci/notifications/snapshot.go | 2 +- providers/oci/notifications/subscriptions.go | 88 ++++++++--- .../oci/notifications/subscriptions_test.go | 118 ++++++++++++++- server/oci/notifications/handler.go | 90 +++++++++--- server/oci/notifications/handler_test.go | 43 +++++- server/oci/notifications/publish.go | 13 +- server/oci/notifications/publish_test.go | 28 ++++ server/oci/notifications/subscriptions.go | 108 +++++++++----- .../oci/notifications/subscriptions_test.go | 97 ++++++++++++- server/oci/notifications/topics.go | 92 ++++++------ server/oci/notifications/topics_test.go | 137 ++++++++++++++++++ server/oci/notifications/types.go | 24 ++- server/oci/oci.go | 12 +- server/oci/oci_test.go | 29 ++++ 21 files changed, 999 insertions(+), 218 deletions(-) diff --git a/docs/services.md b/docs/services.md index dc5fc9554..5e9e220e8 100644 --- a/docs/services.md +++ b/docs/services.md @@ -1493,7 +1493,7 @@ a source cluster and detach on promote; clone-on-read on every path. ### OCI Notifications (ONS) -**Optional capability:** `server/oci/notifications.Extras` — ONS scopes topics +**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 @@ -1523,21 +1523,37 @@ 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. +than answered in an arbitrary order. A page token the listing never issued is +a `400 InvalidParameter`, not a silent restart from page one. `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. +Topic names are unique **across the tenancy**, not per compartment, so the same +name in a second compartment is a `409`. When Identity is wired (as `serve` +does), creating a topic or subscription, or moving either, into a compartment +that does not exist is a `404 NotAuthorizedOrNotFound`, as for VCN. + +Both creates answer `201 Created`. Create, get and update of a topic or a +subscription return the etag in the `etag` response header as well as the body, +since that header is where the SDK reads `Etag` from. Update, delete and +`ChangeTopicCompartment` honour 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. The comparison happens under the +same lock as the write, so of several writers holding one etag exactly one +wins. An absent `if-match` is unconditional. + +A single subscription (create, get, update) is ONS's `Subscription`, which +carries the delivery policy as a JSON-encoded **string** in `deliverPolicy`. A +list item is `SubscriptionSummary`, which carries it as an **object** in +`deliveryPolicy`. Terraform's `oci_ons_subscription.delivery_policy` reads the +former. 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. +`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 @@ -1545,21 +1561,30 @@ 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`, +and removes the subscription. Both `token` and `protocol` are mandatory on +these two endpoints; either missing is a `400`. The exception is +`ORACLE_FUNCTIONS`: ONS does not ask a function to confirm, so that +subscription is created `ACTIVE` with no token. 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. 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 -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. +or `JSON`, declared in the `messageType` **header** as the SDK sends it (a +`messageType` query parameter is accepted as a fallback), and are capped at +ONS's 64 KB. + +`DeleteTopic` answers `204` and `ChangeTopicCompartment` answers `202`. Real +ONS returns only an `opc-request-id` for both; CloudEmu additionally records a +work request for each, resolvable through `server/oci/workrequest`, and returns +its `opc-work-request-id`, which SDK clients ignore. Every subscription +mutation is synchronous. + +Delivery is recorded in-memory and readable through `Deliveries`, keeping the +most recent 100 messages per subscription; nothing is sent over a real +transport, so delivery policies (`backoffRetryPolicy`) round-trip but never +retry. Each publish emits `PublishedMessages` and `DeliveredMessages` under +`oci_notification` into the topic's own compartment, keyed by `resourceId`. --- diff --git a/providers/oci/monitoring/metrics.go b/providers/oci/monitoring/metrics.go index fc5d2e9a9..955a0674e 100644 --- a/providers/oci/monitoring/metrics.go +++ b/providers/oci/monitoring/metrics.go @@ -83,10 +83,15 @@ func (m *Mock) PostMetricData(_ context.Context, compartmentID, resourceGroup st 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. +// PostServiceMetricData records a sibling OCI service's own metrics against a +// compartment. Unlike PostMetricData it admits the oci_ namespaces Oracle +// reserves for service metrics, which only the service itself produces. +func (m *Mock) PostServiceMetricData(_ context.Context, compartmentID string, data []driver.MetricDatum) error { + return m.postMetricData(compartmentID, "", data, true) +} + +// postMetricData records metric data points. allowReserved admits the oci_ +// namespaces; only PostServiceMetricData sets it. func (m *Mock) postMetricData( compartmentID, resourceGroup string, data []driver.MetricDatum, allowReserved bool, ) error { diff --git a/providers/oci/monitoring/monitoring.go b/providers/oci/monitoring/monitoring.go index 093c6ff14..e639e3987 100644 --- a/providers/oci/monitoring/monitoring.go +++ b/providers/oci/monitoring/monitoring.go @@ -81,11 +81,9 @@ func New(opts *config.Options) *Mock { } } -// 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) +// 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) } // GetMetricData aggregates the matching series in the default compartment into diff --git a/providers/oci/notifications/notifications.go b/providers/oci/notifications/notifications.go index 1b95daefb..c29b09771 100644 --- a/providers/oci/notifications/notifications.go +++ b/providers/oci/notifications/notifications.go @@ -6,6 +6,8 @@ package notifications import ( "context" + "errors" + "fmt" "maps" "regexp" "sync" @@ -58,6 +60,17 @@ var topicNamePattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) // metricNamespace is the namespace ONS publishes its metrics under. const metricNamespace = "oci_notification" +// ErrNoEtagMatch marks an if-match precondition that did not hold. It wraps a +// FailedPrecondition, so a portable caller still sees that code. +var ErrNoEtagMatch = errors.New("no etag match") + +// serviceMetricSink records a service's own metrics into a compartment, under +// the oci_ namespaces Oracle reserves for them. providers/oci/monitoring.Mock +// implements it; a portable monitoring driver cannot take a reserved namespace. +type serviceMetricSink interface { + PostServiceMetricData(ctx context.Context, compartmentID string, data []mondriver.MetricDatum) error +} + // TopicDetails is the OCI-only state of a topic; driver.TopicInfo has no room // for it. type TopicDetails struct { @@ -135,9 +148,10 @@ func (m *Mock) CreateTopic(_ context.Context, cfg driver.TopicConfig) (*driver.T 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) + // ONS topic names are unique across the tenancy, not per compartment. + if existing := m.topicByName(cfg.Name); existing != nil { + return nil, cerrors.Newf(cerrors.AlreadyExists, "topic %q already exists in the tenancy (in compartment %s)", + cfg.Name, existing.Scope.Compartment) } id := idgen.OCID(typeTopic, m.opts.Realm, m.opts.OCIRegion()) @@ -200,11 +214,34 @@ func (m *Mock) UpdateTopic(_ context.Context, cfg driver.TopicConfig) (*driver.T m.mu.Lock() defer m.mu.Unlock() - td := m.resolveTopic(cfg.Name, cfg.Scope.Compartment) + return m.updateTopic(&cfg, "") +} + +// UpdateTopicIfMatch is UpdateTopic under an if-match precondition, compared +// against the stored etag under the same lock as the write. An empty ifMatch +// is unconditional. +// +//nolint:gocritic // hugeParam: mirrors UpdateTopic's by-value TopicConfig. +func (m *Mock) UpdateTopicIfMatch( + _ context.Context, cfg driver.TopicConfig, ifMatch string, +) (*driver.TopicInfo, error) { + m.mu.Lock() + defer m.mu.Unlock() + + return m.updateTopic(&cfg, ifMatch) +} + +// updateTopic applies a topic update. The caller holds mu. +func (m *Mock) updateTopic(cfg *driver.TopicConfig, ifMatch string) (*driver.TopicInfo, error) { + td := m.resolveTopic(cfg.Name) if td == nil { return nil, cerrors.Newf(cerrors.NotFound, "topic %q not found", cfg.Name) } + if err := checkEtag(td.Etag, ifMatch); err != nil { + return nil, err + } + if cfg.DisplayName != "" { td.Description = cfg.DisplayName } @@ -229,10 +266,29 @@ func (m *Mock) DeleteTopic(_ context.Context, id string) error { m.mu.Lock() defer m.mu.Unlock() - if !m.topics.Has(id) { + return m.deleteTopic(id, "") +} + +// DeleteTopicIfMatch is DeleteTopic under an if-match precondition. An empty +// ifMatch is unconditional. +func (m *Mock) DeleteTopicIfMatch(_ context.Context, id, ifMatch string) error { + m.mu.Lock() + defer m.mu.Unlock() + + return m.deleteTopic(id, ifMatch) +} + +// deleteTopic removes a topic and its subscriptions. The caller holds mu. +func (m *Mock) deleteTopic(id, ifMatch string) error { + td, ok := m.topics.Get(id) + if !ok { return cerrors.Newf(cerrors.NotFound, "topic %q not found", id) } + if err := checkEtag(td.Etag, ifMatch); err != nil { + return err + } + m.topics.Delete(id) for subID, sub := range m.subs.All() { @@ -285,10 +341,11 @@ func (m *Mock) topicInfo(td *topicData) *driver.TopicInfo { } } -// topicByName finds a topic by name within a compartment. The caller holds mu. -func (m *Mock) topicByName(compartment, name string) *topicData { +// topicByName finds a topic by name anywhere in the tenancy, the scope ONS +// keeps topic names unique across. The caller holds mu. +func (m *Mock) topicByName(name string) *topicData { for _, td := range m.topics.SortedValues() { - if td.Name == name && td.Scope.Compartment == compartment { + if td.Name == name { return td } } @@ -296,33 +353,41 @@ func (m *Mock) topicByName(compartment, name string) *topicData { 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 { +// resolveTopic finds a topic by OCID, falling back to its tenancy-unique name. +// The caller holds mu. +func (m *Mock) resolveTopic(ref string) *topicData { if td, ok := m.topics.Get(ref); ok { return td } - if compartment == "" { - compartment = m.opts.CompartmentID + return m.topicByName(ref) +} + +// checkEtag enforces an if-match precondition against a stored etag. An empty +// want is unconditional. +func checkEtag(current, want string) error { + if want == "" || want == current { + return nil } - return m.topicByName(compartment, ref) + return fmt.Errorf("%w: %w", ErrNoEtagMatch, + cerrors.Newf(cerrors.FailedPrecondition, "if-match %s does not match the current etag", want)) } -// emitMetric records an ONS metric. Called with mu released. -func (m *Mock) emitMetric(name string, value float64, dims map[string]string) { +// emitMetric records an ONS metric in the topic's compartment, keyed by +// resourceId as real oci_notification metrics are. Called with mu released. +func (m *Mock) emitMetric(compartmentID, topicID, name string, value float64) { m.mu.RLock() - mon := m.monitoring + sink, ok := m.monitoring.(serviceMetricSink) m.mu.RUnlock() - if mon == nil { + if !ok { return } - _ = mon.PutMetricData(context.Background(), []mondriver.MetricDatum{{ + _ = sink.PostServiceMetricData(context.Background(), compartmentID, []mondriver.MetricDatum{{ Namespace: metricNamespace, MetricName: name, Value: value, Unit: "Count", - Dimensions: dims, Timestamp: m.opts.Clock.Now(), + Dimensions: map[string]string{"resourceId": topicID}, Timestamp: m.opts.Clock.Now(), }}) } @@ -341,7 +406,7 @@ func validateTopicName(name string) error { return nil } -// shortTopicID is the leading run of the OCID's opaque suffix, which ONS +// shortTopicID is the trailing 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):] diff --git a/providers/oci/notifications/notifications_test.go b/providers/oci/notifications/notifications_test.go index 08f3dc7d5..93b3e90e6 100644 --- a/providers/oci/notifications/notifications_test.go +++ b/providers/oci/notifications/notifications_test.go @@ -232,12 +232,38 @@ func TestListTopicsFiltersByCompartment(t *testing.T) { } } -func TestSameTopicNameInAnotherCompartment(t *testing.T) { +// ONS topic names are unique across the tenancy, so the same name in another +// compartment is a conflict. +func TestTopicNameIsUniqueAcrossTheTenancy(t *testing.T) { m := newMock(t) - first := newTopic(t, m, "alerts", compartment) - second := newTopic(t, m, "alerts", otherCompartment) + newTopic(t, m, "alerts", compartment) - assert.NotEqual(t, first, second) + _, err := m.CreateTopic(t.Context(), driver.TopicConfig{ + Name: "alerts", + Scope: scope.Scope{Compartment: otherCompartment}, + }) + require.Error(t, err) + assert.Equal(t, cerrors.AlreadyExists, cerrors.GetCode(err)) +} + +// Moving a topic keeps its name taken across the tenancy: the compartment it +// left cannot reuse the name either. +func TestTopicNameStaysTakenAcrossACompartmentMove(t *testing.T) { + m := newMock(t) + id := newTopic(t, m, "alerts", compartment) + + _, err := m.UpdateTopic(t.Context(), driver.TopicConfig{ + Name: id, + Scope: scope.Scope{Compartment: otherCompartment}, + }) + require.NoError(t, err) + + _, err = m.CreateTopic(t.Context(), driver.TopicConfig{ + Name: "alerts", + Scope: scope.Scope{Compartment: compartment}, + }) + require.Error(t, err) + assert.Equal(t, cerrors.AlreadyExists, cerrors.GetCode(err)) } func TestCreateSubscription(t *testing.T) { @@ -357,15 +383,16 @@ func TestConfirmSubscriptionErrors(t *testing.T) { 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: "wrong token", id: sub.ID, token: "nope", protocol: "EMAIL", code: cerrors.InvalidArgument}, + {name: "missing token", id: sub.ID, token: "", protocol: "EMAIL", code: cerrors.InvalidArgument}, + {name: "missing protocol", id: sub.ID, token: sub.ConfirmationToken, 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, + token: sub.ConfirmationToken, protocol: "EMAIL", code: cerrors.NotFound, }, } @@ -391,7 +418,7 @@ func TestResendConfirmation(t *testing.T) { require.NoError(t, err) assert.NotEqual(t, sub.ConfirmationToken, resent.ConfirmationToken) - _, err = m.ConfirmSubscription(t.Context(), sub.ID, resent.ConfirmationToken, "") + _, err = m.ConfirmSubscription(t.Context(), sub.ID, resent.ConfirmationToken, "EMAIL") require.NoError(t, err) _, err = m.ResendSubscriptionConfirmation(t.Context(), sub.ID) @@ -421,7 +448,7 @@ func TestUnsubscribeByToken(t *testing.T) { }) require.NoError(t, err) - err = m.UnsubscribeByToken(t.Context(), sub.ID, "wrong", "") + err = m.UnsubscribeByToken(t.Context(), sub.ID, "wrong", "EMAIL") assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(err)) require.NoError(t, m.UnsubscribeByToken(t.Context(), sub.ID, sub.ConfirmationToken, "EMAIL")) diff --git a/providers/oci/notifications/publish.go b/providers/oci/notifications/publish.go index e26b718f8..ad824cbc8 100644 --- a/providers/oci/notifications/publish.go +++ b/providers/oci/notifications/publish.go @@ -2,6 +2,7 @@ package notifications import ( "context" + "slices" "strings" cerrors "github.com/stackshy/cloudemu/v2/errors" @@ -18,6 +19,10 @@ const ( // maxMessageBytes is the 64 KB ONS caps a published message body at. const maxMessageBytes = 64 * 1024 +// maxDeliveriesPerSubscription bounds the delivery history kept, and +// snapshotted, per subscription. Older messages are dropped. +const maxDeliveriesPerSubscription = 100 + // MessageSpec is a message to publish to a topic. type MessageSpec struct { Title string @@ -76,31 +81,33 @@ func (m *Mock) PublishMessage(_ context.Context, topicID string, spec MessageSpe return nil, err } - msg, delivered, err := m.deliver(topicID, spec, msgType) + msg, compartmentID, 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) + m.emitMetric(compartmentID, topicID, "PublishedMessages", 1) + m.emitMetric(compartmentID, topicID, "DeliveredMessages", float64(delivered)) 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) { +// reports the topic's compartment and how many received it. +func (m *Mock) deliver( + topicID string, spec MessageSpec, msgType string, +) (msg *Message, compartmentID string, delivered int, err 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) + td, ok := m.topics.Get(topicID) + if !ok { + return nil, "", 0, cerrors.Newf(cerrors.NotFound, "topic %q not found", topicID) } - msg := Message{ + sent := Message{ ID: idgen.GenerateID("msg-"), TopicID: topicID, Title: spec.Title, @@ -109,20 +116,28 @@ func (m *Mock) deliver(topicID string, spec MessageSpec, msgType string) (*Messa 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)) + m.deliveries.Set(sub.ID, keepRecent(append(existing, sent))) delivered++ } - return &msg, delivered, nil + return &sent, td.Scope.Compartment, delivered, nil +} + +// keepRecent trims a delivery history to its last maxDeliveriesPerSubscription +// messages, copying so the dropped ones do not stay pinned in memory. +func keepRecent(history []Message) []Message { + if len(history) <= maxDeliveriesPerSubscription { + return history + } + + return slices.Clone(history[len(history)-maxDeliveriesPerSubscription:]) } // Deliveries returns the messages a subscription received. Real ONS pushes to diff --git a/providers/oci/notifications/publish_test.go b/providers/oci/notifications/publish_test.go index 0b4fe0edd..b25f95238 100644 --- a/providers/oci/notifications/publish_test.go +++ b/providers/oci/notifications/publish_test.go @@ -2,6 +2,7 @@ package notifications_test import ( "context" + "strconv" "strings" "testing" @@ -12,6 +13,7 @@ import ( cerrors "github.com/stackshy/cloudemu/v2/errors" "github.com/stackshy/cloudemu/v2/providers/oci/monitoring" "github.com/stackshy/cloudemu/v2/providers/oci/notifications" + mondriver "github.com/stackshy/cloudemu/v2/services/monitoring/driver" ) func TestPublishToAnUnknownTopic(t *testing.T) { @@ -37,9 +39,9 @@ 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) { +// ONS metrics land in the topic's own compartment, keyed by resourceId, as +// real oci_notification metrics are, not in the default compartment. +func TestPublishEmitsMetricsIntoTheTopicCompartment(t *testing.T) { ctx := context.Background() opts := config.NewOptions(config.WithRegion(region), config.WithCompartmentID(compartment)) m := notifications.New(opts) @@ -47,14 +49,61 @@ func TestPublishEmitsMetrics(t *testing.T) { m.SetMonitoring(mon) - id := newTopic(t, m, "alpha", compartment) + id := newTopic(t, m, "alpha", otherCompartment) _, err := m.PublishMessage(ctx, id, notifications.MessageSpec{Body: "shipped"}) require.NoError(t, err) - names, err := mon.ListMetrics(ctx, "oci_notification") + metrics, err := mon.ListOCIMetrics(ctx, otherCompartment, monitoring.OCIMetricFilter{Namespace: "oci_notification"}) + require.NoError(t, err) + + names := make([]string, 0, len(metrics)) + for i := range metrics { + names = append(names, metrics[i].Name) + assert.Equal(t, map[string]string{"resourceId": id}, metrics[i].Dimensions) + } + + assert.ElementsMatch(t, []string{"PublishedMessages", "DeliveredMessages"}, names) + + inDefault, err := mon.ListOCIMetrics(ctx, compartment, monitoring.OCIMetricFilter{Namespace: "oci_notification"}) require.NoError(t, err) - assert.Contains(t, names, "PublishedMessages") - assert.Contains(t, names, "DeliveredMessages") + assert.Empty(t, inDefault) +} + +// The reserved oci_ namespace is open only to a sibling service's own path; +// the portable PutMetricData still rejects it. +func TestReservedNamespaceStaysClosedToPortableCallers(t *testing.T) { + ctx := context.Background() + mon := monitoring.New(config.NewOptions(config.WithCompartmentID(compartment))) + + err := mon.PutMetricData(ctx, []mondriver.MetricDatum{{ + Namespace: "oci_notification", MetricName: "Forged", Value: 1, + }}) + require.Error(t, err) + assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(err)) +} + +// A busy subscription keeps only its most recent deliveries. +func TestDeliveryHistoryIsBounded(t *testing.T) { + ctx := context.Background() + m := newMock(t) + topicID := newTopic(t, m, "alpha", compartment) + + sub, err := m.CreateSubscription(ctx, notifications.SubscriptionSpec{ + TopicID: topicID, CompartmentID: compartment, + Protocol: notifications.ProtocolFunctions, Endpoint: "ocid1.fnfunc.oc1..x", + }) + require.NoError(t, err) + + const sent = 105 + for i := range sent { + _, err := m.PublishMessage(ctx, topicID, notifications.MessageSpec{Body: strconv.Itoa(i)}) + require.NoError(t, err) + } + + got := m.Deliveries(sub.ID) + require.Len(t, got, 100) + assert.Equal(t, "5", got[0].Body, "the oldest five were dropped") + assert.Equal(t, "104", got[len(got)-1].Body) } // ONS caps a published message body at 64 KB. diff --git a/providers/oci/notifications/snapshot.go b/providers/oci/notifications/snapshot.go index df079a271..3062e9c6b 100644 --- a/providers/oci/notifications/snapshot.go +++ b/providers/oci/notifications/snapshot.go @@ -32,7 +32,7 @@ type notificationsSnapshot struct { Deliveries json.RawMessage `json:"deliveries,omitempty"` } -// Snapshot captures the mock's entire state as JSON. includeAssets is unused — +// 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() diff --git a/providers/oci/notifications/subscriptions.go b/providers/oci/notifications/subscriptions.go index e60457f8a..cab191cb5 100644 --- a/providers/oci/notifications/subscriptions.go +++ b/providers/oci/notifications/subscriptions.go @@ -56,10 +56,11 @@ type SubscriptionSpec struct { } // SubscriptionPatch carries the mutable fields of a subscription. A nil field -// leaves the stored one alone. +// leaves the stored one alone. IfMatch, when set, must equal the stored etag. type SubscriptionPatch struct { DeliveryPolicy *DeliveryPolicy FreeformTags map[string]string + IfMatch string } // Subscription is an ONS subscription in full. @@ -142,17 +143,24 @@ func (m *Mock) CreateSubscription(_ context.Context, spec SubscriptionSpec) (*Su } 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), + 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-"), + FreeformTags: maps.Clone(spec.FreeformTags), + } + + // ONS does not ask a function subscription to confirm, so it starts ACTIVE + // with no token; every other protocol waits on one. + if protocol == ProtocolFunctions { + sub.LifecycleState = StateActive + } else { + sub.ConfirmationToken = idgen.GenerateID("token-") } m.subs.Set(sub.ID, sub) @@ -231,6 +239,10 @@ func (m *Mock) UpdateSubscription(_ context.Context, id string, patch Subscripti return nil, cerrors.Newf(cerrors.NotFound, "subscription %q not found", id) } + if err := checkEtag(sub.Etag, patch.IfMatch); err != nil { + return nil, err + } + if patch.DeliveryPolicy != nil { sub.DeliveryPolicy = cloneDeliveryPolicy(patch.DeliveryPolicy) } @@ -251,11 +263,32 @@ 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) + return m.deleteSubscription(subscriptionID, "") +} + +// DeleteSubscription is Unsubscribe under an if-match precondition. An empty +// ifMatch is unconditional. +func (m *Mock) DeleteSubscription(_ context.Context, id, ifMatch string) error { + m.mu.Lock() + defer m.mu.Unlock() + + return m.deleteSubscription(id, ifMatch) +} + +// deleteSubscription removes a subscription and its delivery history. The +// caller holds mu. +func (m *Mock) deleteSubscription(id, ifMatch string) error { + sub, ok := m.subs.Get(id) + if !ok { + return cerrors.Newf(cerrors.NotFound, "subscription %q not found", id) } - m.deliveries.Delete(subscriptionID) + if err := checkEtag(sub.Etag, ifMatch); err != nil { + return err + } + + m.subs.Delete(id) + m.deliveries.Delete(id) return nil } @@ -263,8 +296,8 @@ func (m *Mock) Unsubscribe(_ context.Context, subscriptionID string) error { // 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") + if err := requireTokenAndProtocol(token, protocol); err != nil { + return nil, err } m.mu.Lock() @@ -303,8 +336,8 @@ func (m *Mock) ConfirmSubscription(_ context.Context, id, token, protocol string // 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") + if err := requireTokenAndProtocol(token, protocol); err != nil { + return err } m.mu.Lock() @@ -378,10 +411,6 @@ func checkToken(sub *Subscription, token, protocol string) error { 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 @@ -395,6 +424,19 @@ func checkToken(sub *Subscription, token, protocol string) error { return nil } +// requireTokenAndProtocol rejects a token endpoint call missing either +// parameter; ONS marks both mandatory on confirm and unsubscribe. +func requireTokenAndProtocol(token, protocol string) error { + switch { + case token == "": + return cerrors.New(cerrors.InvalidArgument, "token is required") + case protocol == "": + return cerrors.New(cerrors.InvalidArgument, "protocol is required") + } + + 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. diff --git a/providers/oci/notifications/subscriptions_test.go b/providers/oci/notifications/subscriptions_test.go index e52ee22d5..030a45d88 100644 --- a/providers/oci/notifications/subscriptions_test.go +++ b/providers/oci/notifications/subscriptions_test.go @@ -2,6 +2,10 @@ package notifications_test import ( "context" + "errors" + "strconv" + "sync" + "sync/atomic" "testing" "github.com/stretchr/testify/assert" @@ -56,8 +60,9 @@ func TestUnsubscribeByTokenErrors(t *testing.T) { 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}, + "no protocol": {sub.ID, sub.ConfirmationToken, "", cerrors.InvalidArgument}, + "unknown id": {"ocid1.onssubscription.oc1..missing", sub.ConfirmationToken, "EMAIL", cerrors.NotFound}, + "wrong token": {sub.ID, "token-wrong", "EMAIL", cerrors.InvalidArgument}, "bad protocol": {sub.ID, sub.ConfirmationToken, "CARRIER_PIGEON", cerrors.InvalidArgument}, "other protocol": {sub.ID, sub.ConfirmationToken, "SMS", cerrors.InvalidArgument}, } @@ -70,9 +75,9 @@ func TestUnsubscribeByTokenErrors(t *testing.T) { }) } - // 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, "")) + // The token and its protocol unsubscribe. Removes the subscription, so it + // runs after the rejection cases. + require.NoError(t, m.UnsubscribeByToken(ctx, sub.ID, sub.ConfirmationToken, "EMAIL")) assert.Empty(t, m.Deliveries(sub.ID)) } @@ -147,3 +152,106 @@ func TestCreateSubscriptionEndpointValidation(t *testing.T) { }) } } + +// ONS does not ask a function subscription to confirm: it starts ACTIVE with no +// token and receives the very next publish. +func TestOracleFunctionsSubscriptionStartsActive(t *testing.T) { + ctx := context.Background() + m := newMock(t) + topicID := newTopic(t, m, "alpha", compartment) + + sub, err := m.CreateSubscription(ctx, notifications.SubscriptionSpec{ + TopicID: topicID, CompartmentID: compartment, + Protocol: notifications.ProtocolFunctions, Endpoint: "ocid1.fnfunc.oc1..x", + }) + require.NoError(t, err) + assert.Equal(t, notifications.StateActive, sub.LifecycleState) + assert.Empty(t, sub.ConfirmationToken) + + _, err = m.PublishMessage(ctx, topicID, notifications.MessageSpec{Body: "hello"}) + require.NoError(t, err) + require.Len(t, m.Deliveries(sub.ID), 1) + + // There is no confirmation to resend. + _, err = m.ResendSubscriptionConfirmation(ctx, sub.ID) + assert.Equal(t, cerrors.FailedPrecondition, cerrors.GetCode(err)) + + // The portable entry point reports it confirmed, not pending. + info, err := m.Subscribe(ctx, driver.SubscriptionConfig{ + TopicID: topicID, Protocol: "ORACLE_FUNCTIONS", Endpoint: "ocid1.fnfunc.oc1..y", + }) + require.NoError(t, err) + assert.Equal(t, notifications.StatusConfirmed, info.Status) +} + +// The if-match precondition is compared under the same lock as the write, so of +// several writers holding the same etag exactly one wins. +func TestUpdateTopicIfMatchHasOneWinner(t *testing.T) { + ctx := context.Background() + m := newMock(t) + id := newTopic(t, m, "alpha", compartment) + + details, ok := m.TopicDetails(id) + require.True(t, ok) + + const writers = 8 + + var ( + wg sync.WaitGroup + won atomic.Int32 + rejected atomic.Int32 + ) + + for i := range writers { + wg.Add(1) + + go func() { + defer wg.Done() + + _, err := m.UpdateTopicIfMatch(ctx, driver.TopicConfig{ + Name: id, DisplayName: "writer " + strconv.Itoa(i), + }, details.Etag) + + switch { + case err == nil: + won.Add(1) + case errors.Is(err, notifications.ErrNoEtagMatch): + rejected.Add(1) + } + }() + } + + wg.Wait() + + assert.Equal(t, int32(1), won.Load()) + assert.Equal(t, int32(writers-1), rejected.Load()) +} + +// A stale etag is refused on every guarded write and leaves the resource alone. +func TestStaleIfMatchIsRefused(t *testing.T) { + ctx := context.Background() + m := newMock(t) + topicID := newTopic(t, m, "alpha", compartment) + sub := newPendingSubscription(t, m, topicID) + + err := m.DeleteTopicIfMatch(ctx, topicID, "etag-stale") + require.ErrorIs(t, err, notifications.ErrNoEtagMatch) + assert.Equal(t, cerrors.FailedPrecondition, cerrors.GetCode(err)) + + _, err = m.UpdateSubscription(ctx, sub.ID, notifications.SubscriptionPatch{ + FreeformTags: map[string]string{"team": "ops"}, IfMatch: "etag-stale", + }) + require.ErrorIs(t, err, notifications.ErrNoEtagMatch) + + require.ErrorIs(t, m.DeleteSubscription(ctx, sub.ID, "etag-stale"), notifications.ErrNoEtagMatch) + + // Nothing changed, and the current etag still works. + got, err := m.GetSubscription(ctx, sub.ID) + require.NoError(t, err) + assert.Empty(t, got.FreeformTags) + require.NoError(t, m.DeleteSubscription(ctx, sub.ID, got.Etag)) + + details, ok := m.TopicDetails(topicID) + require.True(t, ok) + require.NoError(t, m.DeleteTopicIfMatch(ctx, topicID, details.Etag)) +} diff --git a/server/oci/notifications/handler.go b/server/oci/notifications/handler.go index 9eaea8ddf..4c8eb09d5 100644 --- a/server/oci/notifications/handler.go +++ b/server/oci/notifications/handler.go @@ -3,14 +3,14 @@ // // 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/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/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 @@ -19,18 +19,23 @@ // 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. +// confirmed, except an ORACLE_FUNCTIONS one, which ONS creates ACTIVE. 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. +// +// Real ONS answers DeleteTopic and ChangeTopicCompartment with only an +// opc-request-id. CloudEmu also records a work request for each and returns +// its opc-work-request-id, which SDK clients ignore. package notifications import ( "context" + "errors" "net/http" "strings" + cerrors "github.com/stackshy/cloudemu/v2/errors" 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" @@ -71,11 +76,16 @@ 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. +// the PENDING confirmation handshake, a topic's lifecycle state, short id and +// etag, and writes whose if-match is checked under the provider's own lock. +// *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) + UpdateTopicIfMatch( + ctx context.Context, cfg notifdriver.TopicConfig, ifMatch string, + ) (*notifdriver.TopicInfo, error) + DeleteTopicIfMatch(ctx context.Context, id, ifMatch string) error CreateSubscription( ctx context.Context, spec notifprovider.SubscriptionSpec, @@ -89,6 +99,7 @@ type Extras interface { 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 + DeleteSubscription(ctx context.Context, id, ifMatch string) error PublishMessage( ctx context.Context, topicID string, spec notifprovider.MessageSpec, @@ -100,6 +111,10 @@ type Handler struct { notif notifdriver.Notification extras Extras work *workrequest.Store + // compartmentExists reports whether a compartment OCID exists. It is nil + // unless SetCompartmentChecker wires it from Identity; a nil checker skips + // the check so handlers built without identity keep working. + compartmentExists func(id string) bool } // New returns a Notifications handler. work records the asynchronous topic @@ -110,6 +125,25 @@ func New(n notifdriver.Notification, work *workrequest.Store) *Handler { return &Handler{notif: n, extras: extras, work: work} } +// SetCompartmentChecker wires a compartment-existence check so a create or move +// into a compartment that does not exist is rejected with 404 +// NotAuthorizedOrNotFound, as real OCI does. When unset (nil) the check is +// skipped, so handlers constructed without identity keep working. +func (h *Handler) SetCompartmentChecker(fn func(id string) bool) { h.compartmentExists = fn } + +// requireCompartment reports whether compartmentID exists; if not it writes +// the OCI 404 NotAuthorizedOrNotFound and returns false. A nil checker (no +// identity wired) is a no-op that allows the request. +func (h *Handler) requireCompartment(w http.ResponseWriter, r *http.Request, compartmentID string) bool { + if h.compartmentExists == nil || compartmentID == "" || h.compartmentExists(compartmentID) { + return true + } + + ocirest.WriteError(w, r, http.StatusNotFound, codeNotFound, compartmentID+" not found") + + return false +} + // route is a parsed Notifications path. type route struct { Collection string @@ -202,18 +236,26 @@ 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 +// writeError renders a driver error, turning a failed if-match precondition +// into ONS's 412 NoEtagMatch. The provider compares the etag under the same +// lock as the write, so exactly one of several racing writers wins. +func writeError(w http.ResponseWriter, r *http.Request, err error) { + if errors.Is(err, notifprovider.ErrNoEtagMatch) { + ocirest.WriteError(w, r, http.StatusPreconditionFailed, codeNoEtagMatch, cerrors.Message(err)) + return } - ocirest.WriteError(w, r, http.StatusPreconditionFailed, codeNoEtagMatch, - "if-match "+want+" does not match the current etag") + ocirest.WriteDriverError(w, r, err) +} - return false +// ifMatch is the caller's if-match precondition; empty is unconditional. +func ifMatch(r *http.Request) string { return r.Header.Get("If-Match") } + +// setEtag stamps the etag response header the SDK maps a response's Etag from. +func setEtag(w http.ResponseWriter, etag string) { + if etag != "" { + w.Header().Set("Etag", etag) + } } func notFound(w http.ResponseWriter, r *http.Request) { diff --git a/server/oci/notifications/handler_test.go b/server/oci/notifications/handler_test.go index 7a17ff3ff..4ddcc1f12 100644 --- a/server/oci/notifications/handler_test.go +++ b/server/oci/notifications/handler_test.go @@ -89,6 +89,28 @@ func (f *fixture) doIfMatch(method, target, etag string, body any) *httptest.Res return w } +// serve sends a raw request through a handler. It never fails the test, so it +// is safe to call from goroutines. +func serve(h http.Handler, method, target string, header http.Header, body []byte) *httptest.ResponseRecorder { + r := httptest.NewRequest(method, target, bytes.NewReader(body)) + for k, v := range header { + r.Header[k] = v + } + + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + return w +} + +// knownCompartments wires a compartment checker that recognises only the two +// test compartments. +func (f *fixture) knownCompartments() { + f.handler.SetCompartmentChecker(func(id string) bool { + return id == compartment || id == otherCompartment + }) +} + func decode(t *testing.T, w *httptest.ResponseRecorder) map[string]any { t.Helper() @@ -513,7 +535,7 @@ func TestUnsubscribeEndpoints(t *testing.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) + w := f.do(http.MethodGet, "/20181201/subscriptions/"+subID+"/unsubscription?token="+token+"&protocol=EMAIL", nil) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) w = f.do(http.MethodGet, "/20181201/subscriptions/"+subID, nil) @@ -561,7 +583,24 @@ func TestUpdateAndMoveSubscription(t *testing.T) { }) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) - policy, ok := decode(t, w)["deliveryPolicy"].(map[string]any) + // Update and Get return ONS's Subscription, which carries the policy as a + // JSON string in deliverPolicy; there is no deliveryPolicy object. + wantPolicy := `{"backoffRetryPolicy":{"maxRetryDuration":7200,"policyType":"EXPONENTIAL"}}` + + updated := decode(t, w) + assert.JSONEq(t, wantPolicy, updated["deliverPolicy"].(string)) + assert.NotContains(t, updated, "deliveryPolicy") + + got := decode(t, f.do(http.MethodGet, "/20181201/subscriptions/"+subID, nil)) + assert.JSONEq(t, wantPolicy, got["deliverPolicy"].(string)) + assert.NotContains(t, got, "deliveryPolicy") + + // List returns SubscriptionSummary, which carries it as an object. + items := decodeList(t, f.do(http.MethodGet, "/20181201/subscriptions?compartmentId="+compartment, nil)) + require.Len(t, items, 1) + assert.NotContains(t, items[0], "deliverPolicy") + + policy, ok := items[0]["deliveryPolicy"].(map[string]any) require.True(t, ok) backoff, ok := policy["backoffRetryPolicy"].(map[string]any) require.True(t, ok) diff --git a/server/oci/notifications/publish.go b/server/oci/notifications/publish.go index 5a096cb61..62097c833 100644 --- a/server/oci/notifications/publish.go +++ b/server/oci/notifications/publish.go @@ -24,7 +24,7 @@ func (h *Handler) publishMessage(w http.ResponseWriter, r *http.Request, topicID msg, err := h.extras.PublishMessage(r.Context(), topicID, notifprovider.MessageSpec{ Title: req.Title, Body: req.Body, - Type: r.URL.Query().Get("messageType"), + Type: messageType(r), }) if err != nil { ocirest.WriteDriverError(w, r, err) @@ -33,3 +33,14 @@ func (h *Handler) publishMessage(w http.ResponseWriter, r *http.Request, topicID ocirest.WriteJSON(w, r, http.StatusOK, publishResult{MessageID: msg.ID, TimeStamp: msg.Timestamp}) } + +// messageType is the body encoding the caller declared. The SDK sends it as +// the messageType header (canonically Messagetype); the query parameter is +// accepted as a fallback. +func messageType(r *http.Request) string { + if t := r.Header.Get("Messagetype"); t != "" { + return t + } + + return r.URL.Query().Get("messageType") +} diff --git a/server/oci/notifications/publish_test.go b/server/oci/notifications/publish_test.go index ce81ce92d..2bc7f0f2f 100644 --- a/server/oci/notifications/publish_test.go +++ b/server/oci/notifications/publish_test.go @@ -102,3 +102,31 @@ func TestPublishRejectsAnOversizedBody(t *testing.T) { }) assert.Equal(t, http.StatusOK, atLimit.Code, atLimit.Body.String()) } + +// The SDK sends messageType as a header, so that is where an unknown value +// must be caught and where JSON must be honoured. +func TestPublishReadsMessageTypeFromTheHeader(t *testing.T) { + t.Parallel() + + f := newFixture(t) + id := f.newTopic("alerts", compartment) + + subID, token := f.newSubscription(id, "ops@example.com") + confirm := f.do(http.MethodGet, + "/20181201/subscriptions/"+subID+"/confirmation?token="+token+"&protocol=EMAIL", nil) + require.Equal(t, http.StatusOK, confirm.Code, confirm.Body.String()) + + target := "/20181201/topics/" + id + "/messages" + body := []byte(`{"body":"{\"k\":1}"}`) + + bad := serve(f.handler, http.MethodPost, target, http.Header{"Messagetype": {"NOPE"}}, body) + require.Equal(t, http.StatusBadRequest, bad.Code, bad.Body.String()) + assert.Contains(t, bad.Body.String(), "NOPE") + + ok := serve(f.handler, http.MethodPost, target, http.Header{"Messagetype": {"JSON"}}, body) + require.Equal(t, http.StatusOK, ok.Code, ok.Body.String()) + + delivered := f.mock.Deliveries(subID) + require.Len(t, delivered, 1) + assert.Equal(t, "JSON", delivered[0].Type) +} diff --git a/server/oci/notifications/subscriptions.go b/server/oci/notifications/subscriptions.go index e221d1196..e99d50b35 100644 --- a/server/oci/notifications/subscriptions.go +++ b/server/oci/notifications/subscriptions.go @@ -1,6 +1,7 @@ package notifications import ( + "encoding/json" "net/http" "net/url" @@ -63,8 +64,9 @@ func (h *Handler) serveSubscription(w http.ResponseWriter, r *http.Request, id s } } -// createSubscription creates a PENDING subscription. It stays PENDING, and -// receives nothing, until it is confirmed with its token. +// createSubscription creates a subscription. It stays PENDING, and receives +// nothing, until it is confirmed with its token; an ORACLE_FUNCTIONS one is +// created ACTIVE, as ONS does not ask a function to confirm. func (h *Handler) createSubscription(w http.ResponseWriter, r *http.Request) { var req createSubscriptionRequest @@ -86,6 +88,10 @@ func (h *Handler) createSubscription(w http.ResponseWriter, r *http.Request) { return } + if !h.requireCompartment(w, r, req.CompartmentID) { + return + } + sub, err := h.extras.CreateSubscription(r.Context(), notifprovider.SubscriptionSpec{ TopicID: req.TopicID, CompartmentID: req.CompartmentID, @@ -99,7 +105,7 @@ func (h *Handler) createSubscription(w http.ResponseWriter, r *http.Request) { return } - ocirest.WriteJSON(w, r, http.StatusCreated, subscriptionWire(sub)) + writeSubscription(w, r, http.StatusCreated, sub) } func (h *Handler) getSubscription(w http.ResponseWriter, r *http.Request, id string) { @@ -109,7 +115,14 @@ func (h *Handler) getSubscription(w http.ResponseWriter, r *http.Request, id str return } - ocirest.WriteJSON(w, r, http.StatusOK, subscriptionWire(sub)) + writeSubscription(w, r, http.StatusOK, sub) +} + +// writeSubscription renders a single subscription in ONS's Subscription shape, +// with the etag header the SDK reads. +func writeSubscription(w http.ResponseWriter, r *http.Request, status int, sub *notifprovider.Subscription) { + setEtag(w, sub.Etag) + ocirest.WriteJSON(w, r, status, subscriptionWire(sub)) } // listSubscriptions returns the subscriptions in a compartment, narrowed to @@ -126,12 +139,17 @@ func (h *Handler) listSubscriptions(w http.ResponseWriter, r *http.Request) { return } - out := make([]subscriptionResponse, 0, len(subs)) + out := make([]subscriptionSummary, 0, len(subs)) for i := range subs { - out = append(out, subscriptionWire(&subs[i])) + out = append(out, subscriptionSummaryWire(&subs[i])) + } + + page, ok := paginate(w, r, out) + if !ok { + return } - ocirest.WriteJSON(w, r, http.StatusOK, paginate(w, r, out)) + ocirest.WriteJSON(w, r, http.StatusOK, page) } // updateSubscription replaces a subscription's delivery policy and tags. The @@ -148,29 +166,22 @@ 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, + IfMatch: ifMatch(r), }) if err != nil { - ocirest.WriteDriverError(w, r, err) + writeError(w, r, err) return } - ocirest.WriteJSON(w, r, http.StatusOK, subscriptionWire(sub)) + writeSubscription(w, r, http.StatusOK, sub) } 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) + if err := h.extras.DeleteSubscription(r.Context(), id, ifMatch(r)); err != nil { + writeError(w, r, err) return } @@ -257,6 +268,10 @@ func (h *Handler) changeSubscriptionCompartment(w http.ResponseWriter, r *http.R return } + if !h.requireCompartment(w, r, req.CompartmentID) { + return + } + if err := h.extras.ChangeSubscriptionCompartment(r.Context(), id, req.CompartmentID); err != nil { ocirest.WriteDriverError(w, r, err) return @@ -265,30 +280,23 @@ 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. +// authenticate with. ONS marks both mandatory, so either missing is a 400. func tokenParams(w http.ResponseWriter, r *http.Request) (token, protocol string, ok bool) { query := r.URL.Query() - token = query.Get("token") - if token == "" { + token, protocol = query.Get("token"), query.Get("protocol") + + switch { + case token == "": ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, "token is required") return "", "", false + case protocol == "": + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, "protocol is required") + return "", "", false } - return token, query.Get("protocol"), true + return token, protocol, true } // unsubscribeURL is the link ONS hands back with a confirmation, pointing at @@ -303,10 +311,33 @@ func unsubscribeURL(r *http.Request, id, token, protocol string) string { url.PathEscape(id) + "/" + subUnsubscription + "?" + query.Encode() } -// subscriptionWire renders an ONS subscription. The confirmation token rides -// along only while it is still needed. +// subscriptionWire renders ONS's Subscription, whose delivery policy is a +// JSON-encoded string in deliverPolicy. func subscriptionWire(sub *notifprovider.Subscription) subscriptionResponse { - out := subscriptionResponse{ + out := subscriptionResponse{subscriptionFields: subscriptionBase(sub)} + + if p := toWirePolicy(sub.DeliveryPolicy); p != nil { + // A struct of ints and strings always marshals. + raw, _ := json.Marshal(p) //nolint:errchkjson // cannot fail for deliveryPolicy + out.DeliverPolicy = string(raw) + } + + return out +} + +// subscriptionSummaryWire renders ONS's SubscriptionSummary, whose delivery +// policy is an object in deliveryPolicy. +func subscriptionSummaryWire(sub *notifprovider.Subscription) subscriptionSummary { + return subscriptionSummary{ + subscriptionFields: subscriptionBase(sub), + DeliveryPolicy: toWirePolicy(sub.DeliveryPolicy), + } +} + +// subscriptionBase fills the fields Subscription and SubscriptionSummary +// share. The confirmation token rides along only while it is still needed. +func subscriptionBase(sub *notifprovider.Subscription) subscriptionFields { + out := subscriptionFields{ ID: sub.ID, TopicID: sub.TopicID, CompartmentID: sub.CompartmentID, @@ -315,7 +346,6 @@ func subscriptionWire(sub *notifprovider.Subscription) subscriptionResponse { LifecycleState: sub.LifecycleState, CreatedTime: sub.CreatedTime, Metadata: sub.Metadata, - DeliveryPolicy: toWirePolicy(sub.DeliveryPolicy), Etag: sub.Etag, FreeformTags: sub.FreeformTags, DefinedTags: definedTags{}, diff --git a/server/oci/notifications/subscriptions_test.go b/server/oci/notifications/subscriptions_test.go index 637d81da0..bfecc045d 100644 --- a/server/oci/notifications/subscriptions_test.go +++ b/server/oci/notifications/subscriptions_test.go @@ -99,7 +99,7 @@ func TestUpdateSubscriptionWithAnEmptyDeliveryPolicy(t *testing.T) { "deliveryPolicy": map[string]any{}, }) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) - assert.NotNil(t, decode(t, w)["deliveryPolicy"]) + assert.Equal(t, "{}", decode(t, w)["deliverPolicy"]) } func TestTokenEndpointsRequireAToken(t *testing.T) { @@ -224,3 +224,98 @@ func TestCreateSubscriptionRejectsAMalformedEndpoint(t *testing.T) { }) } } + +func TestCreateSubscriptionRequiresAnExistingCompartment(t *testing.T) { + t.Parallel() + + f := newFixture(t) + f.knownCompartments() + topicID := f.newTopic("alerts", compartment) + + w := f.do(http.MethodPost, "/20181201/subscriptions", map[string]any{ + "topicId": topicID, "compartmentId": "ocid1.compartment.oc1..bogus", + "protocol": "EMAIL", "endpoint": "ops@example.com", + }) + require.Equal(t, http.StatusNotFound, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), "NotAuthorizedOrNotFound") +} + +// ONS does not ask a function subscription to confirm: it is created ACTIVE, +// carries no token, and receives the next publish. +func TestOracleFunctionsSubscriptionIsActiveOverTheWire(t *testing.T) { + t.Parallel() + + f := newFixture(t) + topicID := f.newTopic("alerts", compartment) + + w := f.do(http.MethodPost, "/20181201/subscriptions", map[string]any{ + "topicId": topicID, "compartmentId": compartment, + "protocol": "ORACLE_FUNCTIONS", "endpoint": "ocid1.fnfunc.oc1..x", + }) + require.Equal(t, http.StatusCreated, w.Code, w.Body.String()) + + body := decode(t, w) + assert.Equal(t, "ACTIVE", body["lifecycleState"]) + assert.NotContains(t, body, "confirmationToken") + + id, _ := body["id"].(string) + + publish := f.do(http.MethodPost, "/20181201/topics/"+topicID+"/messages", map[string]any{"body": "hello"}) + require.Equal(t, http.StatusOK, publish.Code, publish.Body.String()) + assert.Len(t, f.mock.Deliveries(id), 1) +} + +// The SDK maps Create, Get and UpdateSubscription's Etag from the etag header. +func TestSubscriptionEtagHeader(t *testing.T) { + t.Parallel() + + f := newFixture(t) + topicID := f.newTopic("alerts", compartment) + + create := f.do(http.MethodPost, "/20181201/subscriptions", map[string]any{ + "topicId": topicID, "compartmentId": compartment, + "protocol": "EMAIL", "endpoint": "ops@example.com", + }) + require.Equal(t, http.StatusCreated, create.Code) + + body := decode(t, create) + id, _ := body["id"].(string) + assert.NotEmpty(t, create.Header().Get("etag")) + assert.Equal(t, body["etag"], create.Header().Get("etag")) + + get := f.do(http.MethodGet, "/20181201/subscriptions/"+id, nil) + assert.Equal(t, decode(t, get)["etag"], get.Header().Get("etag")) + + update := f.do(http.MethodPut, "/20181201/subscriptions/"+id, + map[string]any{"freeformTags": map[string]string{"team": "ops"}}) + require.Equal(t, http.StatusOK, update.Code) + assert.Equal(t, decode(t, update)["etag"], update.Header().Get("etag")) +} + +// ONS marks protocol mandatory on both token endpoints. +func TestTokenEndpointsRequireAProtocol(t *testing.T) { + t.Parallel() + + f := newFixture(t) + topicID := f.newTopic("alerts", compartment) + id, token := f.newSubscription(topicID, "ops@example.com") + + for _, sub := range []string{"confirmation", "unsubscription"} { + w := f.do(http.MethodGet, "/20181201/subscriptions/"+id+"/"+sub+"?token="+token, nil) + require.Equal(t, http.StatusBadRequest, w.Code, sub) + assert.Contains(t, w.Body.String(), "protocol is required", sub) + } + + // Neither refusal touched the subscription. + got := decode(t, f.do(http.MethodGet, "/20181201/subscriptions/"+id, nil)) + assert.Equal(t, "PENDING", got["lifecycleState"]) +} + +func TestListSubscriptionsRejectsABadPageToken(t *testing.T) { + t.Parallel() + + f := newFixture(t) + + w := f.do(http.MethodGet, "/20181201/subscriptions?compartmentId="+compartment+"&page=garbage", nil) + assert.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) +} diff --git a/server/oci/notifications/topics.go b/server/oci/notifications/topics.go index 2eb179169..555f4f9eb 100644 --- a/server/oci/notifications/topics.go +++ b/server/oci/notifications/topics.go @@ -25,7 +25,7 @@ const ( // entityTopic is the resource type a topic work request reports. const entityTopic = "onstopic" -// Work request operations the asynchronous topic mutations record. +// Work request operations CloudEmu records for the topic delete and move. const ( operationDeleteTopic = "DELETE_TOPIC" operationChangeCompartment = "CHANGE_TOPIC_COMPARTMENT" @@ -88,6 +88,10 @@ func (h *Handler) createTopic(w http.ResponseWriter, r *http.Request) { return } + if !h.requireCompartment(w, r, req.CompartmentID) { + return + } + info, err := h.notif.CreateTopic(r.Context(), notifdriver.TopicConfig{ Name: req.Name, DisplayName: req.Description, @@ -99,7 +103,7 @@ func (h *Handler) createTopic(w http.ResponseWriter, r *http.Request) { return } - ocirest.WriteJSON(w, r, http.StatusCreated, h.topicWire(r, info)) + h.writeTopic(w, r, http.StatusCreated, info) } func (h *Handler) getTopic(w http.ResponseWriter, r *http.Request, id string) { @@ -109,7 +113,14 @@ func (h *Handler) getTopic(w http.ResponseWriter, r *http.Request, id string) { return } - ocirest.WriteJSON(w, r, http.StatusOK, h.topicWire(r, info)) + h.writeTopic(w, r, http.StatusOK, info) +} + +// writeTopic renders a single topic with the etag header the SDK reads. +func (h *Handler) writeTopic(w http.ResponseWriter, r *http.Request, status int, info *notifdriver.TopicInfo) { + out := h.topicWire(r, info) + setEtag(w, out.Etag) + ocirest.WriteJSON(w, r, status, out) } // listTopics returns the topics in a compartment. ONS requires compartmentId @@ -145,7 +156,12 @@ func (h *Handler) listTopics(w http.ResponseWriter, r *http.Request) { sortTopics(out, order) - ocirest.WriteJSON(w, r, http.StatusOK, paginate(w, r, out)) + page, ok := paginate(w, r, out) + if !ok { + return + } + + ocirest.WriteJSON(w, r, http.StatusOK, page) } // updateTopic replaces a topic's description and tags. ONS does not rename a @@ -161,25 +177,21 @@ 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{ + info, err := h.extras.UpdateTopicIfMatch(r.Context(), notifdriver.TopicConfig{ Name: id, DisplayName: req.Description, Tags: req.FreeformTags, - }) + }, ifMatch(r)) if err != nil { - ocirest.WriteDriverError(w, r, err) + writeError(w, r, err) return } - ocirest.WriteJSON(w, r, http.StatusOK, h.topicWire(r, info)) + h.writeTopic(w, r, http.StatusOK, info) } -// deleteTopic removes a topic and its subscriptions. Real ONS runs it -// asynchronously and answers 204 with the work request the caller polls. +// deleteTopic removes a topic and its subscriptions, answering 204. Real ONS +// returns only an opc-request-id; CloudEmu also records a work request. 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") @@ -192,14 +204,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 { - ocirest.WriteDriverError(w, r, err) + if err := h.extras.DeleteTopicIfMatch(r.Context(), id, ifMatch(r)); err != nil { + writeError(w, r, err) return } @@ -213,7 +221,8 @@ func (h *Handler) deleteTopic(w http.ResponseWriter, r *http.Request, id string) ocirest.WriteJSON(w, r, http.StatusNoContent, nil) } -// changeTopicCompartment moves a topic, which ONS runs asynchronously. +// changeTopicCompartment moves a topic. Real ONS returns only an +// opc-request-id; CloudEmu also records a work request and answers 202. func (h *Handler) changeTopicCompartment(w http.ResponseWriter, r *http.Request, id string) { if r.Method != http.MethodPost { methodNotAllowed(w, r) @@ -236,11 +245,15 @@ func (h *Handler) changeTopicCompartment(w http.ResponseWriter, r *http.Request, return } - if _, err := h.notif.UpdateTopic(r.Context(), notifdriver.TopicConfig{ + if !h.requireCompartment(w, r, req.CompartmentID) { + return + } + + if _, err := h.extras.UpdateTopicIfMatch(r.Context(), notifdriver.TopicConfig{ Name: id, Scope: scope.Scope{Compartment: req.CompartmentID}, - }); err != nil { - ocirest.WriteDriverError(w, r, err) + }, ifMatch(r)); err != nil { + writeError(w, r, err) return } @@ -282,17 +295,6 @@ 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 { @@ -358,19 +360,27 @@ func reverse[T any](items []T) { } // 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 { +// the next page. The cursor is the offset the next page starts at. A cursor +// this handler never issued is a 400, not a silent restart from page one, so +// a client holding a corrupted cursor cannot loop forever. +func paginate[T any](w http.ResponseWriter, r *http.Request, items []T) ([]T, bool) { start := 0 if token := ocirest.Page(r); token != "" { - if n, err := strconv.Atoi(token); err == nil && n > 0 { - start = n + n, err := strconv.Atoi(token) + if err != nil || n < 0 { + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, + "page "+strconv.Quote(token)+" is not a page token this listing issued") + + return nil, false } + + start = n } // items[:0] rather than nil: an empty page is [] on the wire, not null. if start >= len(items) { - return items[:0] + return items[:0], true } end := min(start+ocirest.Limit(r), len(items)) @@ -378,5 +388,5 @@ func paginate[T any](w http.ResponseWriter, r *http.Request, items []T) []T { ocirest.SetNextPage(w, strconv.Itoa(end)) } - return items[start:end] + return items[start:end], true } diff --git a/server/oci/notifications/topics_test.go b/server/oci/notifications/topics_test.go index 0f0afb508..35d63625e 100644 --- a/server/oci/notifications/topics_test.go +++ b/server/oci/notifications/topics_test.go @@ -4,7 +4,9 @@ import ( "crypto/tls" "net/http" "net/http/httptest" + "strconv" "strings" + "sync" "testing" "github.com/stretchr/testify/assert" @@ -273,3 +275,138 @@ func TestTopicIfMatch(t *testing.T) { freshDelete := f.doIfMatch(http.MethodDelete, "/20181201/topics/"+id, current, nil) assert.Equal(t, http.StatusNoContent, freshDelete.Code, freshDelete.Body.String()) } + +const bogusCompartment = "ocid1.compartment.oc1..bogus" + +// A create or move into a compartment that does not exist is 404, as for VCN. +func TestTopicsRequireAnExistingCompartment(t *testing.T) { + t.Parallel() + + f := newFixture(t) + f.knownCompartments() + + create := f.do(http.MethodPost, "/20181201/topics", map[string]any{ + "name": "alerts", "compartmentId": bogusCompartment, + }) + require.Equal(t, http.StatusNotFound, create.Code, create.Body.String()) + assert.Contains(t, create.Body.String(), "NotAuthorizedOrNotFound") + + id := f.newTopic("alerts", compartment) + + move := f.do(http.MethodPost, "/20181201/topics/"+id+"/actions/changeCompartment", + map[string]any{"compartmentId": bogusCompartment}) + require.Equal(t, http.StatusNotFound, move.Code, move.Body.String()) + + got := decode(t, f.do(http.MethodGet, "/20181201/topics/"+id, nil)) + assert.Equal(t, compartment, got["compartmentId"], "a refused move leaves the topic where it was") +} + +// Topic names are unique across the tenancy, not per compartment. +func TestTopicNameIsUniqueAcrossTheTenancyOverTheWire(t *testing.T) { + t.Parallel() + + f := newFixture(t) + f.newTopic("t1", compartment) + + w := f.do(http.MethodPost, "/20181201/topics", map[string]any{ + "name": "t1", "compartmentId": otherCompartment, + }) + assert.Equal(t, http.StatusConflict, w.Code, w.Body.String()) +} + +// The SDK maps CreateTopic, GetTopic and UpdateTopic's Etag from the etag +// response header, not the body. +func TestTopicEtagHeader(t *testing.T) { + t.Parallel() + + f := newFixture(t) + + create := f.do(http.MethodPost, "/20181201/topics", map[string]any{ + "name": "alerts", "compartmentId": compartment, + }) + require.Equal(t, http.StatusCreated, create.Code) + + body := decode(t, create) + id, _ := body["topicId"].(string) + assert.Equal(t, body["etag"], create.Header().Get("etag")) + assert.NotEmpty(t, create.Header().Get("etag")) + + get := f.do(http.MethodGet, "/20181201/topics/"+id, nil) + assert.Equal(t, decode(t, get)["etag"], get.Header().Get("etag")) + + update := f.do(http.MethodPut, "/20181201/topics/"+id, map[string]any{"description": "new"}) + require.Equal(t, http.StatusOK, update.Code) + assert.Equal(t, decode(t, update)["etag"], update.Header().Get("etag")) + assert.NotEqual(t, get.Header().Get("etag"), update.Header().Get("etag"), "an update rotates the etag") +} + +// Eight writers holding the same current etag: exactly one wins, the rest get +// 412. The precondition and the write happen under one provider lock. +func TestConcurrentTopicUpdatesWithOneEtagHaveOneWinner(t *testing.T) { + t.Parallel() + + f := newFixture(t) + id := f.newTopic("alerts", compartment) + etag, _ := decode(t, f.do(http.MethodGet, "/20181201/topics/"+id, nil))["etag"].(string) + + const writers = 8 + + codes := make(chan int, writers) + header := http.Header{"If-Match": {etag}, "Content-Type": {"application/json"}} + + var wg sync.WaitGroup + + for i := range writers { + wg.Add(1) + + go func() { + defer wg.Done() + + body := []byte(`{"description":"writer ` + strconv.Itoa(i) + `"}`) + codes <- serve(f.handler, http.MethodPut, "/20181201/topics/"+id, header, body).Code + }() + } + + wg.Wait() + close(codes) + + count := map[int]int{} + for code := range codes { + count[code]++ + } + + assert.Equal(t, map[int]int{http.StatusOK: 1, http.StatusPreconditionFailed: writers - 1}, count) +} + +// A stale etag on a compartment move is 412 too; ONS takes if-match there. +func TestChangeTopicCompartmentIfMatch(t *testing.T) { + t.Parallel() + + f := newFixture(t) + id := f.newTopic("alerts", compartment) + target := "/20181201/topics/" + id + "/actions/changeCompartment" + + stale := f.doIfMatch(http.MethodPost, target, "etag-stale", map[string]any{"compartmentId": otherCompartment}) + require.Equal(t, http.StatusPreconditionFailed, stale.Code, stale.Body.String()) + + etag, _ := decode(t, f.do(http.MethodGet, "/20181201/topics/"+id, nil))["etag"].(string) + fresh := f.doIfMatch(http.MethodPost, target, etag, map[string]any{"compartmentId": otherCompartment}) + assert.Equal(t, http.StatusAccepted, fresh.Code, fresh.Body.String()) +} + +// A page token this listing never issued is 400, not a silent restart from +// page one. +func TestListTopicsRejectsABadPageToken(t *testing.T) { + t.Parallel() + + f := newFixture(t) + f.newTopic("alerts", compartment) + + base := "/20181201/topics?compartmentId=" + compartment + "&limit=1&page=" + + for _, token := range []string{"garbage", "-1"} { + w := f.do(http.MethodGet, base+token, nil) + assert.Equal(t, http.StatusBadRequest, w.Code, token) + assert.Contains(t, w.Body.String(), "InvalidParameter", token) + } +} diff --git a/server/oci/notifications/types.go b/server/oci/notifications/types.go index 6a9ba923e..084586483 100644 --- a/server/oci/notifications/types.go +++ b/server/oci/notifications/types.go @@ -61,9 +61,9 @@ type deliveryPolicy struct { BackoffRetryPolicy *backoffRetryPolicy `json:"backoffRetryPolicy,omitempty"` } -// subscriptionResponse is ONS's Subscription; SubscriptionSummary shares its -// fields. -type subscriptionResponse struct { +// subscriptionFields is what ONS's Subscription and SubscriptionSummary share. +// The two differ only in how they carry the delivery policy. +type subscriptionFields struct { ID string `json:"id"` TopicID string `json:"topicId"` CompartmentID string `json:"compartmentId"` @@ -72,7 +72,6 @@ type subscriptionResponse struct { 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"` @@ -82,6 +81,23 @@ type subscriptionResponse struct { ConfirmationToken string `json:"confirmationToken,omitempty"` } +// subscriptionResponse is ONS's Subscription, returned by Create, Get and +// Update. It carries the delivery policy as a JSON-encoded string in +// deliverPolicy, which the SDK and Terraform read. +type subscriptionResponse struct { + subscriptionFields + + DeliverPolicy string `json:"deliverPolicy,omitempty"` +} + +// subscriptionSummary is ONS's SubscriptionSummary, returned by List. Unlike +// Subscription it carries the delivery policy as an object in deliveryPolicy. +type subscriptionSummary struct { + subscriptionFields + + DeliveryPolicy *deliveryPolicy `json:"deliveryPolicy,omitempty"` +} + type messageDetails struct { Title string `json:"title,omitempty"` Body string `json:"body"` diff --git a/server/oci/oci.go b/server/oci/oci.go index b049f74f6..c9a8e2270 100644 --- a/server/oci/oci.go +++ b/server/oci/oci.go @@ -110,7 +110,17 @@ func New(d Drivers) *server.Server { } if d.Notifications != nil { - srv.Register(notifications.New(d.Notifications, d.WorkRequests)) + notifHandler := notifications.New(d.Notifications, d.WorkRequests) + + if comps, ok := d.Identity.(identity.Compartments); ok { + notifHandler.SetCompartmentChecker(func(id string) bool { + _, err := comps.GetCompartment(context.Background(), id) + + return err == nil + }) + } + + srv.Register(notifHandler) } return srv diff --git a/server/oci/oci_test.go b/server/oci/oci_test.go index 03e60474e..0ef88f93c 100644 --- a/server/oci/oci_test.go +++ b/server/oci/oci_test.go @@ -4,6 +4,8 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strconv" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -101,3 +103,30 @@ func TestDriversFromProviderBuildsServer(t *testing.T) { assert.Equal(t, http.StatusOK, resp.StatusCode) } + +// New wires the Notifications handler's compartment check from Identity, so a +// topic create into a compartment that does not exist is 404 while the tenancy +// root, which always exists, is accepted. +func TestNotificationsCompartmentCheckIsWiredFromIdentity(t *testing.T) { + srv := ociserver.New(ociserver.DriversFrom(ociprovider.New())) + + ts := httptest.NewServer(srv) + defer ts.Close() + + create := func(compartmentID string) int { + t.Helper() + + body := strings.NewReader(`{"name":"wired-` + strconv.Itoa(len(compartmentID)) + + `","compartmentId":"` + compartmentID + `"}`) + + resp, err := ts.Client().Post(ts.URL+"/20181201/topics", "application/json", body) + require.NoError(t, err) + + defer resp.Body.Close() + + return resp.StatusCode + } + + assert.Equal(t, http.StatusNotFound, create("ocid1.compartment.oc1..bogus")) + assert.Equal(t, http.StatusCreated, create(config.DefaultTenancyOCID)) +}