diff --git a/docs/coverage/README.md b/docs/coverage/README.md index 67243417b..742f5f391 100644 --- a/docs/coverage/README.md +++ b/docs/coverage/README.md @@ -113,7 +113,7 @@ code does not implement. Machine-readable: [`coverage.json`](./coverage.json). | `networkconnectivity` | — | — | [NetworkConnectivity](./gcp/networkconnectivity.md) | — | 11 | | `networkfirewall` | [NetworkFirewall](./aws/networkfirewall.md) | — | — | — | 23 | | `networking` | [VPC](./aws/vpc.md) | [VNet](./azure/vnet.md) | [VPC](./gcp/vpc.md) | [VCN](./oci/vcn.md) | 57 | -| `notification` | [SNS](./aws/sns.md) | [NotificationHubs](./azure/notificationhubs.md) | [FCM](./gcp/fcm.md) | — | 9 | +| `notification` | [SNS](./aws/sns.md) | [NotificationHubs](./azure/notificationhubs.md) | [FCM](./gcp/fcm.md) | [Notifications](./oci/notifications.md) | 9 | | `opensearch` | [OpenSearch](./aws/opensearch.md) | — | — | — | 96 | | `parameterstore` | [SSM](./aws/ssm.md) | — | — | — | 9 | | `postgresflex` | — | [PostgresFlex](./azure/postgresflex.md) | — | — | 21 | diff --git a/docs/coverage/coverage.json b/docs/coverage/coverage.json index 3cf0f52b7..ef7c02b54 100644 --- a/docs/coverage/coverage.json +++ b/docs/coverage/coverage.json @@ -10973,7 +10973,8 @@ "providers": { "aws": "SNS", "azure": "NotificationHubs", - "gcp": "FCM" + "gcp": "FCM", + "oci": "Notifications" } }, { diff --git a/docs/coverage/oci/README.md b/docs/coverage/oci/README.md index a04d3407d..16370719b 100644 --- a/docs/coverage/oci/README.md +++ b/docs/coverage/oci/README.md @@ -7,5 +7,6 @@ Services cloudemu emulates for OCI, by native name. Back to the [cross-provider | --- | --- | --- | | [Identity](./identity.md) | `iam` | 40 | | [Monitoring](./monitoring.md) | `monitoring` | 12 | +| [Notifications](./notifications.md) | `notification` | 9 | | [VCN](./vcn.md) | `networking` | 57 | | [Workrequest](./workrequest.md) | — (provider-native) | 4 | diff --git a/docs/coverage/oci/notifications.md b/docs/coverage/oci/notifications.md new file mode 100644 index 000000000..9367101d2 --- /dev/null +++ b/docs/coverage/oci/notifications.md @@ -0,0 +1,22 @@ + +# Notifications + +OCI's `notification` service · portable interface `driver.Notification` · [OCI index](./README.md) + +## Operations (9) + +| Operation | Description | +| --- | --- | +| `CreateTopic` | | +| `DeleteTopic` | | +| `GetTopic` | | +| `ListSubscriptions` | | +| `ListTopics` | | +| `Publish` | | +| `Subscribe` | | +| `Unsubscribe` | | +| `UpdateTopic` | UpdateTopic replaces the mutable fields (display name, tags) of an | + +## Not in scope + +_Not documented yet. See the [emulator boundary](../../../README.md) for cloudemu-wide non-goals._ diff --git a/docs/services.md b/docs/services.md index da13153e8..cc32e749e 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,76 @@ 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. + +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 +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. 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. + --- ## 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/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..e26b718f8 --- /dev/null +++ b/providers/oci/notifications/publish.go @@ -0,0 +1,157 @@ +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" +) + +// 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 + 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. +// +//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. + 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") + } + + 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 + } + + 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/publish_test.go b/providers/oci/notifications/publish_test.go new file mode 100644 index 000000000..0b4fe0edd --- /dev/null +++ b/providers/oci/notifications/publish_test.go @@ -0,0 +1,73 @@ +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/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") +} + +// 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 new file mode 100644 index 000000000..e60457f8a --- /dev/null +++ b/providers/oci/notifications/subscriptions.go @@ -0,0 +1,476 @@ +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 err := validateEndpoint(protocol, spec.Endpoint); err != nil { + return nil, err + } + + 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 +} + +// 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) { + 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/notifications/subscriptions_test.go b/providers/oci/notifications/subscriptions_test.go new file mode 100644 index 000000000..e52ee22d5 --- /dev/null +++ b/providers/oci/notifications/subscriptions_test.go @@ -0,0 +1,149 @@ +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)) +} + +// 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/providers/oci/oci.go b/providers/oci/oci.go index 0036a9a58..bca03b148 100644 --- a/providers/oci/oci.go +++ b/providers/oci/oci.go @@ -6,6 +6,7 @@ import ( "github.com/stackshy/cloudemu/v2/internal/snapshot" "github.com/stackshy/cloudemu/v2/providers/oci/identity" "github.com/stackshy/cloudemu/v2/providers/oci/monitoring" + notifprovider "github.com/stackshy/cloudemu/v2/providers/oci/notifications" vcnprovider "github.com/stackshy/cloudemu/v2/providers/oci/vcn" cachedriver "github.com/stackshy/cloudemu/v2/services/cache/driver" computedriver "github.com/stackshy/cloudemu/v2/services/compute/driver" @@ -76,6 +77,7 @@ func New(opts ...config.Option) *Provider { } p.Identity = identity.New(o) p.VCN = vcnprovider.New(o) + p.Notifications = notifprovider.New(o) p.Monitoring = monitoring.New(o) diff --git a/server/oci/notifications/handler.go b/server/oci/notifications/handler.go new file mode 100644 index 000000000..9eaea8ddf --- /dev/null +++ b/server/oci/notifications/handler.go @@ -0,0 +1,226 @@ +// 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" + codeNoEtagMatch = "NoEtagMatch" +) + +// 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 +} + +// 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) +} + +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..7a17ff3ff --- /dev/null +++ b/server/oci/notifications/handler_test.go @@ -0,0 +1,642 @@ +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 +} + +// 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() + + 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.StatusCreated, 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.StatusCreated, 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.StatusCreated, 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.StatusCreated, 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.StatusCreated, 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/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..ce81ce92d --- /dev/null +++ b/server/oci/notifications/publish_test.go @@ -0,0 +1,104 @@ +package notifications_test + +import ( + "net/http" + "strings" + "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) +} + +// 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 new file mode 100644 index 000000000..e221d1196 --- /dev/null +++ b/server/oci/notifications/subscriptions.go @@ -0,0 +1,365 @@ +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.StatusCreated, 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 + } + + if !h.subscriptionIfMatch(w, r, id) { + 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 !h.subscriptionIfMatch(w, r, id) { + return + } + + 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) +} + +// 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) { + 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/subscriptions_test.go b/server/oci/notifications/subscriptions_test.go new file mode 100644 index 000000000..637d81da0 --- /dev/null +++ b/server/oci/notifications/subscriptions_test.go @@ -0,0 +1,226 @@ +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) +} + +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 new file mode 100644 index 000000000..2eb179169 --- /dev/null +++ b/server/oci/notifications/topics.go @@ -0,0 +1,382 @@ +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.StatusCreated, 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 + } + + if !h.topicIfMatch(w, r, id) { + 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 + } + + 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) + 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) +} + +// 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 +} + +// 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 { + 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/topics_test.go b/server/oci/notifications/topics_test.go new file mode 100644 index 000000000..0f0afb508 --- /dev/null +++ b/server/oci/notifications/topics_test.go @@ -0,0 +1,275 @@ +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()) +} + +// 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()) +} diff --git a/server/oci/notifications/types.go b/server/oci/notifications/types.go new file mode 100644 index 000000000..6a9ba923e --- /dev/null +++ b/server/oci/notifications/types.go @@ -0,0 +1,106 @@ +package notifications + +// OCI Notifications REST shapes. + +// definedTags is OCI's namespaced tag map. CloudEmu does not model tag +// namespaces, so it is echoed back empty and refused on the way in. +type definedTags map[string]map[string]any + +type createTopicRequest struct { + Name string `json:"name"` + CompartmentID string `json:"compartmentId"` + Description string `json:"description,omitempty"` + FreeformTags map[string]string `json:"freeformTags,omitempty"` + DefinedTags definedTags `json:"definedTags,omitempty"` +} + +type updateTopicRequest struct { + Description string `json:"description,omitempty"` + FreeformTags map[string]string `json:"freeformTags,omitempty"` + DefinedTags definedTags `json:"definedTags,omitempty"` +} + +// topicResponse is ONS's NotificationTopic. NotificationTopicSummary carries +// the same fields, so lists render it too. +type topicResponse struct { + TopicID string `json:"topicId"` + Name string `json:"name"` + CompartmentID string `json:"compartmentId"` + APIEndpoint string `json:"apiEndpoint"` + LifecycleState string `json:"lifecycleState"` + Description string `json:"description,omitempty"` + TimeCreated string `json:"timeCreated,omitempty"` + Etag string `json:"etag,omitempty"` + ShortTopicID string `json:"shortTopicId,omitempty"` + FreeformTags map[string]string `json:"freeformTags"` + DefinedTags definedTags `json:"definedTags"` +} + +type createSubscriptionRequest struct { + TopicID string `json:"topicId"` + CompartmentID string `json:"compartmentId"` + Protocol string `json:"protocol"` + Endpoint string `json:"endpoint"` + Metadata string `json:"metadata,omitempty"` + FreeformTags map[string]string `json:"freeformTags,omitempty"` + DefinedTags definedTags `json:"definedTags,omitempty"` +} + +type updateSubscriptionRequest struct { + DeliveryPolicy *deliveryPolicy `json:"deliveryPolicy,omitempty"` + FreeformTags map[string]string `json:"freeformTags,omitempty"` + DefinedTags definedTags `json:"definedTags,omitempty"` +} + +type backoffRetryPolicy struct { + MaxRetryDuration int `json:"maxRetryDuration"` + PolicyType string `json:"policyType"` +} + +type deliveryPolicy struct { + BackoffRetryPolicy *backoffRetryPolicy `json:"backoffRetryPolicy,omitempty"` +} + +// subscriptionResponse is ONS's Subscription; SubscriptionSummary shares its +// fields. +type subscriptionResponse struct { + ID string `json:"id"` + TopicID string `json:"topicId"` + CompartmentID string `json:"compartmentId"` + Protocol string `json:"protocol"` + Endpoint string `json:"endpoint"` + LifecycleState string `json:"lifecycleState"` + CreatedTime int64 `json:"createdTime"` + Metadata string `json:"metadata,omitempty"` + DeliveryPolicy *deliveryPolicy `json:"deliveryPolicy,omitempty"` + Etag string `json:"etag,omitempty"` + FreeformTags map[string]string `json:"freeformTags"` + DefinedTags definedTags `json:"definedTags"` + // ConfirmationToken is not an ONS field: real ONS mails it to the + // endpoint, which the emulator cannot do, so a PENDING subscription + // carries it back to the caller that must confirm it. + ConfirmationToken string `json:"confirmationToken,omitempty"` +} + +type messageDetails struct { + Title string `json:"title,omitempty"` + Body string `json:"body"` +} + +type publishResult struct { + MessageID string `json:"messageId"` + TimeStamp string `json:"timeStamp"` +} + +type confirmationResult struct { + TopicName string `json:"topicName"` + TopicID string `json:"topicId"` + Endpoint string `json:"endpoint"` + SubscriptionID string `json:"subscriptionId"` + UnsubscribeURL string `json:"unsubscribeUrl"` + Message string `json:"message,omitempty"` +} + +type changeCompartmentRequest struct { + CompartmentID string `json:"compartmentId"` +} diff --git a/server/oci/oci.go b/server/oci/oci.go index a367d4810..6786b3ea9 100644 --- a/server/oci/oci.go +++ b/server/oci/oci.go @@ -11,6 +11,7 @@ import ( "github.com/stackshy/cloudemu/v2/server" "github.com/stackshy/cloudemu/v2/server/oci/identity" "github.com/stackshy/cloudemu/v2/server/oci/monitoring" + "github.com/stackshy/cloudemu/v2/server/oci/notifications" "github.com/stackshy/cloudemu/v2/server/oci/vcn" "github.com/stackshy/cloudemu/v2/server/oci/workrequest" cachedriver "github.com/stackshy/cloudemu/v2/services/cache/driver" @@ -96,6 +97,10 @@ func New(d Drivers) *server.Server { srv.Register(vcn.New(d.VCN, d.WorkRequests)) } + if d.Notifications != nil { + srv.Register(notifications.New(d.Notifications, d.WorkRequests)) + } + return srv }