diff --git a/.editorconfig b/.editorconfig index ee3e4f5..d0ec8f1 100644 --- a/.editorconfig +++ b/.editorconfig @@ -503,4 +503,55 @@ dotnet_naming_symbols.private_internal_fields.applicable_kinds = field dotnet_naming_symbols.private_internal_fields.applicable_accessibilities = private, internal dotnet_naming_style.camel_case_underscore_style.required_prefix = _ -dotnet_naming_style.camel_case_underscore_style.capitalization = camel_case \ No newline at end of file +dotnet_naming_style.camel_case_underscore_style.capitalization = camel_case + +# Meziantou.Analyzer rules +# https://github.com/meziantou/Meziantou.Analyzer/tree/main/docs/Rules + +# MA0002: Use IEqualityComparer/IComparer - HashSet/Dictionary use ordinal by default which is correct for most cases +dotnet_diagnostic.MA0002.severity = suggestion + +# MA0004: Use Task.ConfigureAwait +dotnet_diagnostic.MA0004.severity = suggestion + +# MA0006: Use string.Equals instead of == for string comparison +dotnet_diagnostic.MA0006.severity = suggestion + +# MA0011: IFormatProvider is missing +dotnet_diagnostic.MA0011.severity = suggestion + +# MA0015: Specify the parameter name in ArgumentException - lowered since validation methods validate properties not parameters +dotnet_diagnostic.MA0015.severity = suggestion + +# MA0016: Prefer using collection abstraction instead of implementation +dotnet_diagnostic.MA0016.severity = suggestion + +# MA0018: Do not declare static members on generic types +dotnet_diagnostic.MA0018.severity = suggestion + +# MA0026: Fix TODO comment +dotnet_diagnostic.MA0026.severity = suggestion + +# MA0039: Do not write your own certificate validation method - intentional for simulator to accept self-signed certs +dotnet_diagnostic.MA0039.severity = none + +# MA0048: File name must match type name - noisy for internal helper types in same file +dotnet_diagnostic.MA0048.severity = suggestion + +# MA0049: Type name should not match containing namespace +dotnet_diagnostic.MA0049.severity = suggestion + +# MA0051: Method is too long +dotnet_diagnostic.MA0051.severity = suggestion + +# MA0055: Do not use finalizer - allowed in test fixtures for cleanup safety nets +dotnet_diagnostic.MA0055.severity = suggestion + +# MA0158: Use System.Threading.Lock (.NET 9+) - lowered due to multi-targeting .NET 8 which doesn't have Lock +dotnet_diagnostic.MA0158.severity = suggestion + +# MA0074: Use overload with StringComparison parameter +dotnet_diagnostic.MA0074.severity = warning + +# MA0099: Use the null-forgiving operator with caution (disallow !) +dotnet_diagnostic.MA0099.severity = warning \ No newline at end of file diff --git a/src/Azure Event Grid Simulator.postman_collection.json b/src/Azure Event Grid Simulator.postman_collection.json index 4abb4cf..6ff342c 100644 --- a/src/Azure Event Grid Simulator.postman_collection.json +++ b/src/Azure Event Grid Simulator.postman_collection.json @@ -1,1666 +1,2038 @@ { - "info": { - "_postman_id": "5931e5ae-86d5-4bd9-8f70-b1dff37cde9c", - "name": "Azure Event Grid Simulator", - "description": "A comprehensive collection for testing the Azure Event Grid Simulator.\n\nSupports both Event Grid Schema and CloudEvents v1.0 Schema.\n\n## Environment Variables\n- `baseUrl`: The simulator base URL (default: https://127.0.0.1:60101)\n- `sasKey`: The SAS key for authentication (default: TheLocal+DevelopmentKey=)\n\n## Schema Types\n1. **Event Grid Schema**: The original Azure Event Grid format\n2. **CloudEvents Schema**: CloudEvents v1.0 specification with structured, batch, and binary modes", - "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" - }, - "item": [ - { - "name": "Event Grid Schema", - "item": [ - { - "name": "Single Event", - "event": [ - { - "listen": "prerequest", - "script": { - "exec": [ - "var uuid = require('uuid');", - "", - "pm.collectionVariables.set('eventId', uuid.v4());", - "pm.collectionVariables.set('eventTime', (new Date()).toISOString());" - ], - "type": "text/javascript" - } - }, - { - "listen": "test", - "script": { - "exec": [ - "pm.test('Status code is 200 OK', function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test('Response time is acceptable', function () {", - " pm.expect(pm.response.responseTime).to.be.below(5000);", - "});", - "", - "pm.test('Response body is empty or valid JSON', function () {", - " if (pm.response.text()) {", - " pm.response.to.be.json;", - " }", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - }, - { - "key": "aeg-sas-key", - "value": "{{sasKey}}", - "type": "text" - }, - { - "key": "aeg-event-type", - "value": "Notification", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "[\n {\n \"id\": \"{{eventId}}\",\n \"subject\": \"/example/subject\",\n \"data\": {\n \"MyProperty\": \"This is my awesome data!\",\n \"Count\": 42\n },\n \"eventType\": \"Example.EventType\",\n \"eventTime\": \"{{eventTime}}\",\n \"dataVersion\": \"1.0\"\n }\n]" - }, - "url": { - "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "events" - ], - "query": [ - { - "key": "api-version", - "value": "2018-01-01" - } - ] - }, - "description": "Sends a single event using the Event Grid schema.\n\nRequired fields:\n- `id`: Unique event identifier\n- `subject`: Resource path relative to topic\n- `eventType`: Type of event that occurred\n- `eventTime`: UTC timestamp (ISO 8601)\n- `dataVersion`: Schema version of data object\n\nOptional fields:\n- `data`: Event-specific payload\n- `metadataVersion`: Must be \"1\" or null" - }, - "response": [] - }, - { - "name": "Batch Events", - "event": [ - { - "listen": "prerequest", - "script": { - "exec": [ - "var uuid = require('uuid');", - "", - "pm.collectionVariables.set('eventId1', uuid.v4());", - "pm.collectionVariables.set('eventId2', uuid.v4());", - "pm.collectionVariables.set('eventId3', uuid.v4());", - "pm.collectionVariables.set('eventTime', (new Date()).toISOString());" - ], - "type": "text/javascript" - } - }, - { - "listen": "test", - "script": { - "exec": [ - "pm.test('Status code is 200 OK', function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test('Batch of events processed successfully', function () {", - " pm.response.to.have.status(200);", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - }, - { - "key": "aeg-sas-key", - "value": "{{sasKey}}", - "type": "text" - }, - { - "key": "aeg-event-type", - "value": "Notification", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "[\n {\n \"id\": \"{{eventId1}}\",\n \"subject\": \"/orders/order-123\",\n \"data\": {\n \"orderId\": \"order-123\",\n \"status\": \"created\"\n },\n \"eventType\": \"Order.Created\",\n \"eventTime\": \"{{eventTime}}\",\n \"dataVersion\": \"1.0\"\n },\n {\n \"id\": \"{{eventId2}}\",\n \"subject\": \"/orders/order-123\",\n \"data\": {\n \"orderId\": \"order-123\",\n \"status\": \"processing\"\n },\n \"eventType\": \"Order.Updated\",\n \"eventTime\": \"{{eventTime}}\",\n \"dataVersion\": \"1.0\"\n },\n {\n \"id\": \"{{eventId3}}\",\n \"subject\": \"/orders/order-123\",\n \"data\": {\n \"orderId\": \"order-123\",\n \"status\": \"completed\"\n },\n \"eventType\": \"Order.Completed\",\n \"eventTime\": \"{{eventTime}}\",\n \"dataVersion\": \"1.0\"\n }\n]" - }, - "url": { - "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "events" - ], - "query": [ - { - "key": "api-version", - "value": "2018-01-01" - } - ] - }, - "description": "Sends multiple events in a single batch request using the Event Grid schema.\n\nThis demonstrates the typical order lifecycle events pattern." - }, - "response": [] - }, - { - "name": "With Metadata Version", - "event": [ - { - "listen": "prerequest", - "script": { - "exec": [ - "var uuid = require('uuid');", - "", - "pm.collectionVariables.set('eventId', uuid.v4());", - "pm.collectionVariables.set('eventTime', (new Date()).toISOString());" - ], - "type": "text/javascript" - } - }, - { - "listen": "test", - "script": { - "exec": [ - "pm.test('Status code is 200 OK', function () {", - " pm.response.to.have.status(200);", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - }, - { - "key": "aeg-sas-key", - "value": "{{sasKey}}", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "[\n {\n \"id\": \"{{eventId}}\",\n \"subject\": \"/example/subject\",\n \"data\": {\n \"message\": \"Event with explicit metadata version\"\n },\n \"eventType\": \"Example.MetadataTest\",\n \"eventTime\": \"{{eventTime}}\",\n \"dataVersion\": \"2.0\",\n \"metadataVersion\": \"1\"\n }\n]" - }, - "url": { - "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "events" - ], - "query": [ - { - "key": "api-version", - "value": "2018-01-01" - } - ] - }, - "description": "Sends an event with explicit metadataVersion set to \"1\".\n\nNote: metadataVersion must be either \"1\" or null." - }, - "response": [] - }, - { - "name": "Complex Data Payload", - "event": [ - { - "listen": "prerequest", - "script": { - "exec": [ - "var uuid = require('uuid');", - "", - "pm.collectionVariables.set('eventId', uuid.v4());", - "pm.collectionVariables.set('eventTime', (new Date()).toISOString());" - ], - "type": "text/javascript" - } - }, - { - "listen": "test", - "script": { - "exec": [ - "pm.test('Status code is 200 OK', function () {", - " pm.response.to.have.status(200);", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - }, - { - "key": "aeg-sas-key", - "value": "{{sasKey}}", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "[\n {\n \"id\": \"{{eventId}}\",\n \"subject\": \"/users/user-456\",\n \"data\": {\n \"user\": {\n \"id\": \"user-456\",\n \"name\": \"John Doe\",\n \"email\": \"john.doe@example.com\",\n \"roles\": [\"admin\", \"user\"]\n },\n \"metadata\": {\n \"ipAddress\": \"192.168.1.100\",\n \"userAgent\": \"Mozilla/5.0\",\n \"timestamp\": \"{{eventTime}}\"\n },\n \"changes\": [\n {\n \"field\": \"email\",\n \"oldValue\": \"john@old.com\",\n \"newValue\": \"john.doe@example.com\"\n }\n ]\n },\n \"eventType\": \"User.ProfileUpdated\",\n \"eventTime\": \"{{eventTime}}\",\n \"dataVersion\": \"1.0\"\n }\n]" - }, - "url": { - "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "events" - ], - "query": [ - { - "key": "api-version", - "value": "2018-01-01" - } - ] - }, - "description": "Demonstrates an event with a complex, nested data payload including arrays and objects." - }, - "response": [] - }, - { - "name": "Validation Error - Missing Required Fields", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test('Status code is 400 Bad Request', function () {", - " pm.response.to.have.status(400);", - "});", - "", - "pm.test('Response contains validation error', function () {", - " var jsonData = pm.response.json();", - " pm.expect(jsonData.error).to.exist;", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - }, - { - "key": "aeg-sas-key", - "value": "{{sasKey}}", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "[\n {\n \"data\": {\n \"message\": \"Missing required fields\"\n }\n }\n]" - }, - "url": { - "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "events" - ], - "query": [ - { - "key": "api-version", - "value": "2018-01-01" - } - ] - }, - "description": "Demonstrates validation error when required fields are missing.\n\nRequired fields: id, subject, eventType, eventTime, dataVersion" - }, - "response": [] - }, - { - "name": "Validation Error - Invalid Metadata Version", - "event": [ - { - "listen": "prerequest", - "script": { - "exec": [ - "var uuid = require('uuid');", - "", - "pm.collectionVariables.set('eventId', uuid.v4());", - "pm.collectionVariables.set('eventTime', (new Date()).toISOString());" - ], - "type": "text/javascript" - } - }, - { - "listen": "test", - "script": { - "exec": [ - "pm.test('Status code is 400 Bad Request', function () {", - " pm.response.to.have.status(400);", - "});", - "", - "pm.test('Response contains metadata version error', function () {", - " var jsonData = pm.response.json();", - " pm.expect(jsonData.error.message.toLowerCase()).to.include('metadataversion');", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - }, - { - "key": "aeg-sas-key", - "value": "{{sasKey}}", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "[\n {\n \"id\": \"{{eventId}}\",\n \"subject\": \"/example/subject\",\n \"data\": {},\n \"eventType\": \"Example.Test\",\n \"eventTime\": \"{{eventTime}}\",\n \"dataVersion\": \"1.0\",\n \"metadataVersion\": \"2\"\n }\n]" - }, - "url": { - "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "events" - ], - "query": [ - { - "key": "api-version", - "value": "2018-01-01" - } - ] - }, - "description": "Demonstrates validation error when metadataVersion is not \"1\".\n\nmetadataVersion must be either \"1\" or null." - }, - "response": [] - } - ], - "description": "Requests using the Azure Event Grid native schema format.\n\nThe Event Grid schema requires:\n- `id` (string): Unique event identifier\n- `subject` (string): Resource path relative to topic path\n- `eventType` (string): Type of event that occurred\n- `eventTime` (string): UTC timestamp in ISO 8601 format\n- `data` (object): Event-specific data payload\n- `dataVersion` (string): Schema version of data object" - }, - { - "name": "CloudEvents Schema", - "item": [ - { - "name": "Structured Mode", - "item": [ - { - "name": "Single Event", - "event": [ - { - "listen": "prerequest", - "script": { - "exec": [ - "var uuid = require('uuid');", - "", - "pm.collectionVariables.set('eventId', uuid.v4());", - "pm.collectionVariables.set('eventTime', (new Date()).toISOString());" - ], - "type": "text/javascript" - } - }, - { - "listen": "test", - "script": { - "exec": [ - "pm.test('Status code is 200 OK', function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test('Response time is acceptable', function () {", - " pm.expect(pm.response.responseTime).to.be.below(5000);", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/cloudevents+json; charset=utf-8", - "type": "text" - }, - { - "key": "aeg-sas-key", - "value": "{{sasKey}}", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"specversion\": \"1.0\",\n \"type\": \"com.example.someevent\",\n \"source\": \"/mycontext/subcontext\",\n \"id\": \"{{eventId}}\",\n \"time\": \"{{eventTime}}\",\n \"subject\": \"/example/subject\",\n \"datacontenttype\": \"application/json\",\n \"data\": {\n \"MyProperty\": \"This is my awesome data!\",\n \"Count\": 42\n }\n}" - }, - "url": { - "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "events" - ], - "query": [ - { - "key": "api-version", - "value": "2018-01-01" - } - ] - }, - "description": "Sends a single CloudEvent in structured mode.\n\nStructured mode sends the entire CloudEvent as a JSON object with:\n- Content-Type: `application/cloudevents+json`\n\nRequired fields:\n- `specversion`: Must be \"1.0\"\n- `type`: Event type\n- `source`: Event source (URI-reference)\n- `id`: Unique event identifier\n\nOptional fields:\n- `time`: RFC 3339 timestamp\n- `subject`: Event subject\n- `datacontenttype`: Content type of data\n- `dataschema`: URI reference to data schema\n- `data`: Event payload" - }, - "response": [] - }, - { - "name": "With Data Schema", - "event": [ - { - "listen": "prerequest", - "script": { - "exec": [ - "var uuid = require('uuid');", - "", - "pm.collectionVariables.set('eventId', uuid.v4());", - "pm.collectionVariables.set('eventTime', (new Date()).toISOString());" - ], - "type": "text/javascript" - } - }, - { - "listen": "test", - "script": { - "exec": [ - "pm.test('Status code is 200 OK', function () {", - " pm.response.to.have.status(200);", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/cloudevents+json; charset=utf-8", - "type": "text" - }, - { - "key": "aeg-sas-key", - "value": "{{sasKey}}", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"specversion\": \"1.0\",\n \"type\": \"com.example.order.created\",\n \"source\": \"/orders\",\n \"id\": \"{{eventId}}\",\n \"time\": \"{{eventTime}}\",\n \"subject\": \"/orders/order-123\",\n \"datacontenttype\": \"application/json\",\n \"dataschema\": \"https://example.com/schemas/order/v1.0\",\n \"data\": {\n \"orderId\": \"order-123\",\n \"customerId\": \"cust-456\",\n \"items\": [\n {\n \"productId\": \"prod-789\",\n \"quantity\": 2,\n \"price\": 29.99\n }\n ],\n \"total\": 59.98\n }\n}" - }, - "url": { - "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "events" - ], - "query": [ - { - "key": "api-version", - "value": "2018-01-01" - } - ] - }, - "description": "CloudEvent with an explicit dataschema field pointing to a schema URI.\n\nThe dataschema must be a valid URI." - }, - "response": [] - }, - { - "name": "Minimal Event (Required Fields Only)", - "event": [ - { - "listen": "prerequest", - "script": { - "exec": [ - "var uuid = require('uuid');", - "", - "pm.collectionVariables.set('eventId', uuid.v4());" - ], - "type": "text/javascript" - } - }, - { - "listen": "test", - "script": { - "exec": [ - "pm.test('Status code is 200 OK', function () {", - " pm.response.to.have.status(200);", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/cloudevents+json; charset=utf-8", - "type": "text" - }, - { - "key": "aeg-sas-key", - "value": "{{sasKey}}", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"specversion\": \"1.0\",\n \"type\": \"com.example.minimal\",\n \"source\": \"/minimal\",\n \"id\": \"{{eventId}}\"\n}" - }, - "url": { - "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "events" - ], - "query": [ - { - "key": "api-version", - "value": "2018-01-01" - } - ] - }, - "description": "A minimal CloudEvent with only required fields.\n\nRequired fields:\n- specversion\n- type\n- source\n- id" - }, - "response": [] - } - ], - "description": "CloudEvents sent in structured mode where the entire event is serialized as JSON.\n\nContent-Type: `application/cloudevents+json; charset=utf-8`\n\nNote: For sending multiple events, use Batch Mode with `application/cloudevents-batch+json`." - }, - { - "name": "Batch Mode", - "item": [ - { - "name": "Multiple Events", - "event": [ - { - "listen": "prerequest", - "script": { - "exec": [ - "var uuid = require('uuid');", - "", - "pm.collectionVariables.set('eventId1', uuid.v4());", - "pm.collectionVariables.set('eventId2', uuid.v4());", - "pm.collectionVariables.set('eventId3', uuid.v4());", - "pm.collectionVariables.set('eventTime', (new Date()).toISOString());" - ], - "type": "text/javascript" - } - }, - { - "listen": "test", - "script": { - "exec": [ - "pm.test('Status code is 200 OK', function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test('Batch processed successfully', function () {", - " pm.response.to.have.status(200);", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/cloudevents-batch+json; charset=utf-8", - "type": "text" - }, - { - "key": "aeg-sas-key", - "value": "{{sasKey}}", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "[\n {\n \"specversion\": \"1.0\",\n \"type\": \"com.example.order.created\",\n \"source\": \"/orders\",\n \"id\": \"{{eventId1}}\",\n \"time\": \"{{eventTime}}\",\n \"subject\": \"/orders/order-001\",\n \"data\": {\n \"orderId\": \"order-001\",\n \"status\": \"created\"\n }\n },\n {\n \"specversion\": \"1.0\",\n \"type\": \"com.example.order.processing\",\n \"source\": \"/orders\",\n \"id\": \"{{eventId2}}\",\n \"time\": \"{{eventTime}}\",\n \"subject\": \"/orders/order-001\",\n \"data\": {\n \"orderId\": \"order-001\",\n \"status\": \"processing\"\n }\n },\n {\n \"specversion\": \"1.0\",\n \"type\": \"com.example.order.completed\",\n \"source\": \"/orders\",\n \"id\": \"{{eventId3}}\",\n \"time\": \"{{eventTime}}\",\n \"subject\": \"/orders/order-001\",\n \"data\": {\n \"orderId\": \"order-001\",\n \"status\": \"completed\"\n }\n }\n]" - }, - "url": { - "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "events" - ], - "query": [ - { - "key": "api-version", - "value": "2018-01-01" - } - ] - }, - "description": "Sends multiple CloudEvents in batch mode.\n\nContent-Type: `application/cloudevents-batch+json`\n\nThe body must be a JSON array of CloudEvent objects." - }, - "response": [] - } - ], - "description": "CloudEvents sent in batch mode as a JSON array.\n\nContent-Type: `application/cloudevents-batch+json; charset=utf-8`" - }, - { - "name": "Binary Mode", - "item": [ - { - "name": "Simple Event", - "event": [ - { - "listen": "prerequest", - "script": { - "exec": [ - "var uuid = require('uuid');", - "", - "pm.collectionVariables.set('eventId', uuid.v4());", - "pm.collectionVariables.set('eventTime', (new Date()).toISOString());" - ], - "type": "text/javascript" - } - }, - { - "listen": "test", - "script": { - "exec": [ - "pm.test('Status code is 200 OK', function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test('Binary mode event processed', function () {", - " pm.response.to.have.status(200);", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - }, - { - "key": "aeg-sas-key", - "value": "{{sasKey}}", - "type": "text" - }, - { - "key": "ce-specversion", - "value": "1.0", - "type": "text" - }, - { - "key": "ce-type", - "value": "com.example.binaryevent", - "type": "text" - }, - { - "key": "ce-source", - "value": "/binary/source", - "type": "text" - }, - { - "key": "ce-id", - "value": "{{eventId}}", - "type": "text" - }, - { - "key": "ce-time", - "value": "{{eventTime}}", - "type": "text" - }, - { - "key": "ce-subject", - "value": "/binary/subject", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"MyProperty\": \"This is data sent in binary mode!\",\n \"Count\": 123\n}" - }, - "url": { - "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "events" - ], - "query": [ - { - "key": "api-version", - "value": "2018-01-01" - } - ] - }, - "description": "Sends a CloudEvent in binary mode.\n\nIn binary mode:\n- CloudEvent attributes are sent as HTTP headers with `ce-` prefix\n- The request body contains only the event data\n- Content-Type indicates the data type (typically application/json)\n\nRequired headers:\n- `ce-specversion`: \"1.0\"\n- `ce-type`: Event type\n- `ce-source`: Event source\n- `ce-id`: Unique event ID\n\nOptional headers:\n- `ce-time`: RFC 3339 timestamp\n- `ce-subject`: Event subject\n- `ce-datacontenttype`: Content type of body\n- `ce-dataschema`: Schema URI" - }, - "response": [] - }, - { - "name": "With All Optional Headers", - "event": [ - { - "listen": "prerequest", - "script": { - "exec": [ - "var uuid = require('uuid');", - "", - "pm.collectionVariables.set('eventId', uuid.v4());", - "pm.collectionVariables.set('eventTime', (new Date()).toISOString());" - ], - "type": "text/javascript" - } - }, - { - "listen": "test", - "script": { - "exec": [ - "pm.test('Status code is 200 OK', function () {", - " pm.response.to.have.status(200);", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - }, - { - "key": "aeg-sas-key", - "value": "{{sasKey}}", - "type": "text" - }, - { - "key": "ce-specversion", - "value": "1.0", - "type": "text" - }, - { - "key": "ce-type", - "value": "com.example.fullbinary", - "type": "text" - }, - { - "key": "ce-source", - "value": "/full/binary/source", - "type": "text" - }, - { - "key": "ce-id", - "value": "{{eventId}}", - "type": "text" - }, - { - "key": "ce-time", - "value": "{{eventTime}}", - "type": "text" - }, - { - "key": "ce-subject", - "value": "/users/user-789", - "type": "text" - }, - { - "key": "ce-datacontenttype", - "value": "application/json", - "type": "text" - }, - { - "key": "ce-dataschema", - "value": "https://example.com/schemas/user/v2.0", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"user\": {\n \"id\": \"user-789\",\n \"name\": \"Jane Smith\",\n \"email\": \"jane@example.com\"\n },\n \"action\": \"profile_updated\",\n \"previousValues\": {\n \"name\": \"Jane Doe\"\n }\n}" - }, - "url": { - "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "events" - ], - "query": [ - { - "key": "api-version", - "value": "2018-01-01" - } - ] - }, - "description": "CloudEvent in binary mode with all optional headers populated." - }, - "response": [] - }, - { - "name": "Minimal (Required Headers Only)", - "event": [ - { - "listen": "prerequest", - "script": { - "exec": [ - "var uuid = require('uuid');", - "", - "pm.collectionVariables.set('eventId', uuid.v4());" - ], - "type": "text/javascript" - } - }, - { - "listen": "test", - "script": { - "exec": [ - "pm.test('Status code is 200 OK', function () {", - " pm.response.to.have.status(200);", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - }, - { - "key": "aeg-sas-key", - "value": "{{sasKey}}", - "type": "text" - }, - { - "key": "ce-specversion", - "value": "1.0", - "type": "text" - }, - { - "key": "ce-type", - "value": "com.example.minimal", - "type": "text" - }, - { - "key": "ce-source", - "value": "/minimal", - "type": "text" - }, - { - "key": "ce-id", - "value": "{{eventId}}", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"simple\": \"data\"\n}" - }, - "url": { - "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "events" - ], - "query": [ - { - "key": "api-version", - "value": "2018-01-01" - } - ] - }, - "description": "Minimal CloudEvent in binary mode with only required headers." - }, - "response": [] - }, - { - "name": "Plain Text Data", - "event": [ - { - "listen": "prerequest", - "script": { - "exec": [ - "var uuid = require('uuid');", - "", - "pm.collectionVariables.set('eventId', uuid.v4());", - "pm.collectionVariables.set('eventTime', (new Date()).toISOString());" - ], - "type": "text/javascript" - } - }, - { - "listen": "test", - "script": { - "exec": [ - "pm.test('Status code is 200 OK', function () {", - " pm.response.to.have.status(200);", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "text/plain", - "type": "text" - }, - { - "key": "aeg-sas-key", - "value": "{{sasKey}}", - "type": "text" - }, - { - "key": "ce-specversion", - "value": "1.0", - "type": "text" - }, - { - "key": "ce-type", - "value": "com.example.textevent", - "type": "text" - }, - { - "key": "ce-source", - "value": "/text/source", - "type": "text" - }, - { - "key": "ce-id", - "value": "{{eventId}}", - "type": "text" - }, - { - "key": "ce-time", - "value": "{{eventTime}}", - "type": "text" - }, - { - "key": "ce-datacontenttype", - "value": "text/plain", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "This is plain text event data. It doesn't have to be JSON!" - }, - "url": { - "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "events" - ], - "query": [ - { - "key": "api-version", - "value": "2018-01-01" - } - ] - }, - "description": "CloudEvent in binary mode with plain text data instead of JSON." - }, - "response": [] - } - ], - "description": "CloudEvents sent in binary mode where:\n- Event attributes are sent as HTTP headers (ce-* prefix)\n- Request body contains only the event data\n\nRequired headers:\n- `ce-specversion`: \"1.0\"\n- `ce-type`: Event type\n- `ce-source`: Event source URI\n- `ce-id`: Unique event identifier" - }, - { - "name": "Validation Errors", - "item": [ - { - "name": "Invalid Spec Version", - "event": [ - { - "listen": "prerequest", - "script": { - "exec": [ - "var uuid = require('uuid');", - "", - "pm.collectionVariables.set('eventId', uuid.v4());" - ], - "type": "text/javascript" - } - }, - { - "listen": "test", - "script": { - "exec": [ - "pm.test('Status code is 400 Bad Request', function () {", - " pm.response.to.have.status(400);", - "});", - "", - "pm.test('Response contains specversion error', function () {", - " var jsonData = pm.response.json();", - " pm.expect(jsonData.error.message.toLowerCase()).to.include('specversion');", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/cloudevents+json; charset=utf-8", - "type": "text" - }, - { - "key": "aeg-sas-key", - "value": "{{sasKey}}", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"specversion\": \"2.0\",\n \"type\": \"com.example.test\",\n \"source\": \"/test\",\n \"id\": \"{{eventId}}\"\n}" - }, - "url": { - "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "events" - ], - "query": [ - { - "key": "api-version", - "value": "2018-01-01" - } - ] - }, - "description": "Demonstrates validation error when specversion is not \"1.0\"." - }, - "response": [] - }, - { - "name": "Missing Required Fields", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test('Status code is 400 Bad Request', function () {", - " pm.response.to.have.status(400);", - "});", - "", - "pm.test('Response contains validation error', function () {", - " var jsonData = pm.response.json();", - " pm.expect(jsonData.error).to.exist;", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/cloudevents+json; charset=utf-8", - "type": "text" - }, - { - "key": "aeg-sas-key", - "value": "{{sasKey}}", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"specversion\": \"1.0\",\n \"data\": {\n \"message\": \"Missing type, source, and id\"\n }\n}" - }, - "url": { - "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "events" - ], - "query": [ - { - "key": "api-version", - "value": "2018-01-01" - } - ] - }, - "description": "Demonstrates validation error when required fields are missing.\n\nRequired: specversion, type, source, id" - }, - "response": [] - }, - { - "name": "Missing Source", - "event": [ - { - "listen": "prerequest", - "script": { - "exec": [ - "var uuid = require('uuid');", - "", - "pm.collectionVariables.set('eventId', uuid.v4());" - ], - "type": "text/javascript" - } - }, - { - "listen": "test", - "script": { - "exec": [ - "pm.test('Status code is 400 Bad Request', function () {", - " pm.response.to.have.status(400);", - "});", - "", - "pm.test('Response contains source error', function () {", - " var jsonData = pm.response.json();", - " pm.expect(jsonData.error.message.toLowerCase()).to.include('source');", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/cloudevents+json; charset=utf-8", - "type": "text" - }, - { - "key": "aeg-sas-key", - "value": "{{sasKey}}", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"specversion\": \"1.0\",\n \"type\": \"com.example.test\",\n \"source\": \"\",\n \"id\": \"{{eventId}}\"\n}" - }, - "url": { - "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "events" - ], - "query": [ - { - "key": "api-version", - "value": "2018-01-01" - } - ] - }, - "description": "Demonstrates validation error when source is empty.\n\nThe source field is required and must be a non-empty URI-reference." - }, - "response": [] - }, - { - "name": "Invalid Time Format", - "event": [ - { - "listen": "prerequest", - "script": { - "exec": [ - "var uuid = require('uuid');", - "", - "pm.collectionVariables.set('eventId', uuid.v4());" - ], - "type": "text/javascript" - } - }, - { - "listen": "test", - "script": { - "exec": [ - "pm.test('Status code is 400 Bad Request', function () {", - " pm.response.to.have.status(400);", - "});", - "", - "pm.test('Response contains time error', function () {", - " var jsonData = pm.response.json();", - " pm.expect(jsonData.error.message.toLowerCase()).to.include('time');", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/cloudevents+json; charset=utf-8", - "type": "text" - }, - { - "key": "aeg-sas-key", - "value": "{{sasKey}}", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"specversion\": \"1.0\",\n \"type\": \"com.example.test\",\n \"source\": \"/test\",\n \"id\": \"{{eventId}}\",\n \"time\": \"not-a-valid-timestamp\"\n}" - }, - "url": { - "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "events" - ], - "query": [ - { - "key": "api-version", - "value": "2018-01-01" - } - ] - }, - "description": "Demonstrates validation error when time is not a valid RFC 3339 timestamp." - }, - "response": [] - } - ], - "description": "Examples of CloudEvents validation errors." - } - ], - "description": "Requests using the CloudEvents v1.0 schema format.\n\nCloudEvents can be sent in three modes:\n1. **Structured Mode**: Entire event as JSON (Content-Type: application/cloudevents+json)\n2. **Batch Mode**: Array of events (Content-Type: application/cloudevents-batch+json)\n3. **Binary Mode**: Attributes in headers, data in body (ce-* headers)" - }, - { - "name": "Authentication", - "item": [ - { - "name": "Missing SAS Key (401)", - "event": [ - { - "listen": "prerequest", - "script": { - "exec": [ - "var uuid = require('uuid');", - "", - "pm.collectionVariables.set('eventId', uuid.v4());", - "pm.collectionVariables.set('eventTime', (new Date()).toISOString());" - ], - "type": "text/javascript" - } - }, - { - "listen": "test", - "script": { - "exec": [ - "pm.test('Status code is 401 Unauthorized', function () {", - " pm.response.to.have.status(401);", - "});", - "", - "pm.test('Response indicates authentication failure', function () {", - " var jsonData = pm.response.json();", - " pm.expect(jsonData.error).to.exist;", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "[\n {\n \"id\": \"{{eventId}}\",\n \"subject\": \"/example/subject\",\n \"data\": {},\n \"eventType\": \"Example.Test\",\n \"eventTime\": \"{{eventTime}}\",\n \"dataVersion\": \"1.0\"\n }\n]" - }, - "url": { - "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "events" - ], - "query": [ - { - "key": "api-version", - "value": "2018-01-01" - } - ] - }, - "description": "Demonstrates 401 Unauthorized when SAS key is missing (assuming topic has key configured)." - }, - "response": [] - }, - { - "name": "Invalid SAS Key (401)", - "event": [ - { - "listen": "prerequest", - "script": { - "exec": [ - "var uuid = require('uuid');", - "", - "pm.collectionVariables.set('eventId', uuid.v4());", - "pm.collectionVariables.set('eventTime', (new Date()).toISOString());" - ], - "type": "text/javascript" - } - }, - { - "listen": "test", - "script": { - "exec": [ - "pm.test('Status code is 401 Unauthorized', function () {", - " pm.response.to.have.status(401);", - "});", - "", - "pm.test('Response indicates authentication failure', function () {", - " var jsonData = pm.response.json();", - " pm.expect(jsonData.error).to.exist;", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json", - "type": "text" - }, - { - "key": "aeg-sas-key", - "value": "InvalidKey123=", - "type": "text" - } - ], - "body": { - "mode": "raw", - "raw": "[\n {\n \"id\": \"{{eventId}}\",\n \"subject\": \"/example/subject\",\n \"data\": {},\n \"eventType\": \"Example.Test\",\n \"eventTime\": \"{{eventTime}}\",\n \"dataVersion\": \"1.0\"\n }\n]" - }, - "url": { - "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "events" - ], - "query": [ - { - "key": "api-version", - "value": "2018-01-01" - } - ] - }, - "description": "Demonstrates 401 Unauthorized when SAS key is invalid." - }, - "response": [] - } - ], - "description": "Examples demonstrating authentication behavior.\n\nThe simulator supports two authentication methods:\n- `aeg-sas-key`: Simple SAS key matching\n- `aeg-sas-token`: SHA256-hashed SAS token" - }, - { - "name": "Subscription Validation", - "item": [ - { - "name": "Manual Validation", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test('Status code is 200 OK or 400 Bad Request', function () {", - " pm.expect(pm.response.code).to.be.oneOf([200, 400]);", - "});", - "", - "pm.test('Response is text or JSON', function () {", - " var contentType = pm.response.headers.get('Content-Type');", - " pm.expect(contentType).to.match(/text|json/);", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}/validate?id=d9ebebb7-f884-40b5-8c0d-9ec72f6f19cd", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "validate" - ], - "query": [ - { - "key": "id", - "value": "d9ebebb7-f884-40b5-8c0d-9ec72f6f19cd" - } - ] - }, - "description": "Manually validates a subscription using a validation ID.\n\nWhen a webhook endpoint doesn't automatically respond to the validation challenge, the validation URL can be accessed manually to complete the subscription validation.\n\nThe `id` parameter should be the GUID from the validation URL provided during subscription setup." - }, - "response": [] - } - ], - "description": "Subscription validation endpoints.\n\nWhen a new subscription is created, Event Grid sends a validation request to the webhook endpoint. The endpoint must respond with the validation code to confirm ownership." - }, - { - "name": "Health Check", - "item": [ - { - "name": "Health", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test('Status code is 200 OK', function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test('Response body is OK', function () {", - " pm.expect(pm.response.text()).to.equal('OK');", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}/api/health", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "api", - "health" - ] - }, - "description": "Health check endpoint to verify the simulator is running.\n\nReturns \"OK\" if the service is healthy." - }, - "response": [] - } - ], - "description": "Health check endpoints for monitoring the simulator status." - } - ], - "event": [ - { - "listen": "prerequest", - "script": { - "type": "text/javascript", - "exec": [ - "" - ] - } - }, - { - "listen": "test", - "script": { - "type": "text/javascript", - "exec": [ - "" - ] - } - } - ], - "variable": [ - { - "key": "baseUrl", - "value": "https://127.0.0.1:60101", - "type": "string" - }, - { - "key": "sasKey", - "value": "TheLocal+DevelopmentKey=", - "type": "string" - }, - { - "key": "eventId", - "value": "", - "type": "string" - }, - { - "key": "eventTime", - "value": "", - "type": "string" - }, - { - "key": "eventId1", - "value": "", - "type": "string" - }, - { - "key": "eventId2", - "value": "", - "type": "string" - }, - { - "key": "eventId3", - "value": "", - "type": "string" - } - ], - "protocolProfileBehavior": {} + "info": { + "_postman_id": "5931e5ae-86d5-4bd9-8f70-b1dff37cde9c", + "name": "Azure Event Grid Simulator", + "description": "A comprehensive collection for testing the Azure Event Grid Simulator.\n\nSupports both Event Grid Schema and CloudEvents v1.0 Schema.\n\n## Environment Variables\n- `baseUrl`: The simulator base URL (default: https://127.0.0.1:60101)\n- `sasKey`: The SAS key for authentication (default: TheLocal+DevelopmentKey=)\n\n## Schema Types\n1. **Event Grid Schema**: The original Azure Event Grid format\n2. **CloudEvents Schema**: CloudEvents v1.0 specification with structured, batch, and binary modes", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "name": "Event Grid Schema", + "item": [ + { + "name": "Single Event", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "var uuid = require('uuid');", + "", + "pm.collectionVariables.set('eventId', uuid.v4());", + "pm.collectionVariables.set('eventTime', (new Date()).toISOString());" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 200 OK', function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test('Response time is acceptable', function () {", + " pm.expect(pm.response.responseTime).to.be.below(5000);", + "});", + "", + "pm.test('Response body is empty or valid JSON', function () {", + " if (pm.response.text()) {", + " pm.response.to.be.json;", + " }", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "aeg-sas-key", + "value": "{{sasKey}}", + "type": "text" + }, + { + "key": "aeg-event-type", + "value": "Notification", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "[\n {\n \"id\": \"{{eventId}}\",\n \"subject\": \"/example/subject\",\n \"data\": {\n \"MyProperty\": \"This is my awesome data!\",\n \"Count\": 42\n },\n \"eventType\": \"Example.EventType\",\n \"eventTime\": \"{{eventTime}}\",\n \"dataVersion\": \"1.0\"\n }\n]" + }, + "url": { + "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", + "host": ["{{baseUrl}}"], + "path": ["api", "events"], + "query": [ + { + "key": "api-version", + "value": "2018-01-01" + } + ] + }, + "description": "Sends a single event using the Event Grid schema.\n\nRequired fields:\n- `id`: Unique event identifier\n- `subject`: Resource path relative to topic\n- `eventType`: Type of event that occurred\n- `eventTime`: UTC timestamp (ISO 8601)\n- `dataVersion`: Schema version of data object\n\nOptional fields:\n- `data`: Event-specific payload\n- `metadataVersion`: Must be \"1\" or null" + }, + "response": [] + }, + { + "name": "Batch Events", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "var uuid = require('uuid');", + "", + "pm.collectionVariables.set('eventId1', uuid.v4());", + "pm.collectionVariables.set('eventId2', uuid.v4());", + "pm.collectionVariables.set('eventId3', uuid.v4());", + "pm.collectionVariables.set('eventTime', (new Date()).toISOString());" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 200 OK', function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test('Batch of events processed successfully', function () {", + " pm.response.to.have.status(200);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "aeg-sas-key", + "value": "{{sasKey}}", + "type": "text" + }, + { + "key": "aeg-event-type", + "value": "Notification", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "[\n {\n \"id\": \"{{eventId1}}\",\n \"subject\": \"/orders/order-123\",\n \"data\": {\n \"orderId\": \"order-123\",\n \"status\": \"created\"\n },\n \"eventType\": \"Order.Created\",\n \"eventTime\": \"{{eventTime}}\",\n \"dataVersion\": \"1.0\"\n },\n {\n \"id\": \"{{eventId2}}\",\n \"subject\": \"/orders/order-123\",\n \"data\": {\n \"orderId\": \"order-123\",\n \"status\": \"processing\"\n },\n \"eventType\": \"Order.Updated\",\n \"eventTime\": \"{{eventTime}}\",\n \"dataVersion\": \"1.0\"\n },\n {\n \"id\": \"{{eventId3}}\",\n \"subject\": \"/orders/order-123\",\n \"data\": {\n \"orderId\": \"order-123\",\n \"status\": \"completed\"\n },\n \"eventType\": \"Order.Completed\",\n \"eventTime\": \"{{eventTime}}\",\n \"dataVersion\": \"1.0\"\n }\n]" + }, + "url": { + "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", + "host": ["{{baseUrl}}"], + "path": ["api", "events"], + "query": [ + { + "key": "api-version", + "value": "2018-01-01" + } + ] + }, + "description": "Sends multiple events in a single batch request using the Event Grid schema.\n\nThis demonstrates the typical order lifecycle events pattern." + }, + "response": [] + }, + { + "name": "With Metadata Version", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "var uuid = require('uuid');", + "", + "pm.collectionVariables.set('eventId', uuid.v4());", + "pm.collectionVariables.set('eventTime', (new Date()).toISOString());" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 200 OK', function () {", + " pm.response.to.have.status(200);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "aeg-sas-key", + "value": "{{sasKey}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "[\n {\n \"id\": \"{{eventId}}\",\n \"subject\": \"/example/subject\",\n \"data\": {\n \"message\": \"Event with explicit metadata version\"\n },\n \"eventType\": \"Example.MetadataTest\",\n \"eventTime\": \"{{eventTime}}\",\n \"dataVersion\": \"2.0\",\n \"metadataVersion\": \"1\"\n }\n]" + }, + "url": { + "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", + "host": ["{{baseUrl}}"], + "path": ["api", "events"], + "query": [ + { + "key": "api-version", + "value": "2018-01-01" + } + ] + }, + "description": "Sends an event with explicit metadataVersion set to \"1\".\n\nNote: metadataVersion must be either \"1\" or null." + }, + "response": [] + }, + { + "name": "Complex Data Payload", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "var uuid = require('uuid');", + "", + "pm.collectionVariables.set('eventId', uuid.v4());", + "pm.collectionVariables.set('eventTime', (new Date()).toISOString());" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 200 OK', function () {", + " pm.response.to.have.status(200);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "aeg-sas-key", + "value": "{{sasKey}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "[\n {\n \"id\": \"{{eventId}}\",\n \"subject\": \"/users/user-456\",\n \"data\": {\n \"user\": {\n \"id\": \"user-456\",\n \"name\": \"John Doe\",\n \"email\": \"john.doe@example.com\",\n \"roles\": [\"admin\", \"user\"]\n },\n \"metadata\": {\n \"ipAddress\": \"192.168.1.100\",\n \"userAgent\": \"Mozilla/5.0\",\n \"timestamp\": \"{{eventTime}}\"\n },\n \"changes\": [\n {\n \"field\": \"email\",\n \"oldValue\": \"john@old.com\",\n \"newValue\": \"john.doe@example.com\"\n }\n ]\n },\n \"eventType\": \"User.ProfileUpdated\",\n \"eventTime\": \"{{eventTime}}\",\n \"dataVersion\": \"1.0\"\n }\n]" + }, + "url": { + "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", + "host": ["{{baseUrl}}"], + "path": ["api", "events"], + "query": [ + { + "key": "api-version", + "value": "2018-01-01" + } + ] + }, + "description": "Demonstrates an event with a complex, nested data payload including arrays and objects." + }, + "response": [] + }, + { + "name": "Validation Error - Missing Required Fields", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 400 Bad Request', function () {", + " pm.response.to.have.status(400);", + "});", + "", + "pm.test('Response contains validation error', function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData.error).to.exist;", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "aeg-sas-key", + "value": "{{sasKey}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "[\n {\n \"data\": {\n \"message\": \"Missing required fields\"\n }\n }\n]" + }, + "url": { + "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", + "host": ["{{baseUrl}}"], + "path": ["api", "events"], + "query": [ + { + "key": "api-version", + "value": "2018-01-01" + } + ] + }, + "description": "Demonstrates validation error when required fields are missing.\n\nRequired fields: id, subject, eventType, eventTime, dataVersion" + }, + "response": [] + }, + { + "name": "Validation Error - Invalid Metadata Version", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "var uuid = require('uuid');", + "", + "pm.collectionVariables.set('eventId', uuid.v4());", + "pm.collectionVariables.set('eventTime', (new Date()).toISOString());" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 400 Bad Request', function () {", + " pm.response.to.have.status(400);", + "});", + "", + "pm.test('Response contains metadata version error', function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData.error.message.toLowerCase()).to.include('metadataversion');", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "aeg-sas-key", + "value": "{{sasKey}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "[\n {\n \"id\": \"{{eventId}}\",\n \"subject\": \"/example/subject\",\n \"data\": {},\n \"eventType\": \"Example.Test\",\n \"eventTime\": \"{{eventTime}}\",\n \"dataVersion\": \"1.0\",\n \"metadataVersion\": \"2\"\n }\n]" + }, + "url": { + "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", + "host": ["{{baseUrl}}"], + "path": ["api", "events"], + "query": [ + { + "key": "api-version", + "value": "2018-01-01" + } + ] + }, + "description": "Demonstrates validation error when metadataVersion is not \"1\".\n\nmetadataVersion must be either \"1\" or null." + }, + "response": [] + }, + { + "name": "Validation Error - Empty Id", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 400 Bad Request', function () {", + " pm.response.to.have.status(400);", + "});", + "", + "pm.test('Response contains id error', function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData.error.message.toLowerCase()).to.include('id');", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "aeg-sas-key", + "value": "{{sasKey}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "[\n {\n \"id\": \"\",\n \"subject\": \"/example/subject\",\n \"data\": {},\n \"eventType\": \"Example.Test\",\n \"eventTime\": \"2024-01-01T00:00:00Z\",\n \"dataVersion\": \"1.0\"\n }\n]" + }, + "url": { + "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", + "host": ["{{baseUrl}}"], + "path": ["api", "events"], + "query": [ + { + "key": "api-version", + "value": "2018-01-01" + } + ] + }, + "description": "Demonstrates validation error when id is an empty string.\n\nThe id field must be a non-empty string." + }, + "response": [] + }, + { + "name": "Validation Error - Empty Subject", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "var uuid = require('uuid');", + "", + "pm.collectionVariables.set('eventId', uuid.v4());" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 400 Bad Request', function () {", + " pm.response.to.have.status(400);", + "});", + "", + "pm.test('Response contains subject error', function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData.error.message.toLowerCase()).to.include('subject');", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "aeg-sas-key", + "value": "{{sasKey}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "[\n {\n \"id\": \"{{eventId}}\",\n \"subject\": \"\",\n \"data\": {},\n \"eventType\": \"Example.Test\",\n \"eventTime\": \"2024-01-01T00:00:00Z\",\n \"dataVersion\": \"1.0\"\n }\n]" + }, + "url": { + "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", + "host": ["{{baseUrl}}"], + "path": ["api", "events"], + "query": [ + { + "key": "api-version", + "value": "2018-01-01" + } + ] + }, + "description": "Demonstrates validation error when subject is an empty string.\n\nThe subject field must be a non-empty string." + }, + "response": [] + }, + { + "name": "Validation Error - Empty EventType", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "var uuid = require('uuid');", + "", + "pm.collectionVariables.set('eventId', uuid.v4());" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 400 Bad Request', function () {", + " pm.response.to.have.status(400);", + "});", + "", + "pm.test('Response contains eventType error', function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData.error.message.toLowerCase()).to.include('eventtype');", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "aeg-sas-key", + "value": "{{sasKey}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "[\n {\n \"id\": \"{{eventId}}\",\n \"subject\": \"/example/subject\",\n \"data\": {},\n \"eventType\": \"\",\n \"eventTime\": \"2024-01-01T00:00:00Z\",\n \"dataVersion\": \"1.0\"\n }\n]" + }, + "url": { + "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", + "host": ["{{baseUrl}}"], + "path": ["api", "events"], + "query": [ + { + "key": "api-version", + "value": "2018-01-01" + } + ] + }, + "description": "Demonstrates validation error when eventType is an empty string.\n\nThe eventType field must be a non-empty string." + }, + "response": [] + }, + { + "name": "Validation Error - Empty DataVersion", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "var uuid = require('uuid');", + "", + "pm.collectionVariables.set('eventId', uuid.v4());" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 400 Bad Request', function () {", + " pm.response.to.have.status(400);", + "});", + "", + "pm.test('Response contains dataVersion error', function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData.error.message.toLowerCase()).to.include('dataversion');", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "aeg-sas-key", + "value": "{{sasKey}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "[\n {\n \"id\": \"{{eventId}}\",\n \"subject\": \"/example/subject\",\n \"data\": {},\n \"eventType\": \"Example.Test\",\n \"eventTime\": \"2024-01-01T00:00:00Z\",\n \"dataVersion\": \"\"\n }\n]" + }, + "url": { + "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", + "host": ["{{baseUrl}}"], + "path": ["api", "events"], + "query": [ + { + "key": "api-version", + "value": "2018-01-01" + } + ] + }, + "description": "Demonstrates validation error when dataVersion is an empty string.\n\nThe dataVersion field must be a non-empty string." + }, + "response": [] + } + ], + "description": "Requests using the Azure Event Grid native schema format.\n\nThe Event Grid schema requires:\n- `id` (string): Unique event identifier\n- `subject` (string): Resource path relative to topic path\n- `eventType` (string): Type of event that occurred\n- `eventTime` (string): UTC timestamp in ISO 8601 format\n- `data` (object): Event-specific data payload\n- `dataVersion` (string): Schema version of data object" + }, + { + "name": "CloudEvents Schema", + "item": [ + { + "name": "Structured Mode", + "item": [ + { + "name": "Single Event", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "var uuid = require('uuid');", + "", + "pm.collectionVariables.set('eventId', uuid.v4());", + "pm.collectionVariables.set('eventTime', (new Date()).toISOString());" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 200 OK', function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test('Response time is acceptable', function () {", + " pm.expect(pm.response.responseTime).to.be.below(5000);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/cloudevents+json; charset=utf-8", + "type": "text" + }, + { + "key": "aeg-sas-key", + "value": "{{sasKey}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"specversion\": \"1.0\",\n \"type\": \"com.example.someevent\",\n \"source\": \"/mycontext/subcontext\",\n \"id\": \"{{eventId}}\",\n \"time\": \"{{eventTime}}\",\n \"subject\": \"/example/subject\",\n \"datacontenttype\": \"application/json\",\n \"data\": {\n \"MyProperty\": \"This is my awesome data!\",\n \"Count\": 42\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", + "host": ["{{baseUrl}}"], + "path": ["api", "events"], + "query": [ + { + "key": "api-version", + "value": "2018-01-01" + } + ] + }, + "description": "Sends a single CloudEvent in structured mode.\n\nStructured mode sends the entire CloudEvent as a JSON object with:\n- Content-Type: `application/cloudevents+json`\n\nRequired fields:\n- `specversion`: Must be \"1.0\"\n- `type`: Event type\n- `source`: Event source (URI-reference)\n- `id`: Unique event identifier\n\nOptional fields:\n- `time`: RFC 3339 timestamp\n- `subject`: Event subject\n- `datacontenttype`: Content type of data\n- `dataschema`: URI reference to data schema\n- `data`: Event payload" + }, + "response": [] + }, + { + "name": "With Data Schema", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "var uuid = require('uuid');", + "", + "pm.collectionVariables.set('eventId', uuid.v4());", + "pm.collectionVariables.set('eventTime', (new Date()).toISOString());" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 200 OK', function () {", + " pm.response.to.have.status(200);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/cloudevents+json; charset=utf-8", + "type": "text" + }, + { + "key": "aeg-sas-key", + "value": "{{sasKey}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"specversion\": \"1.0\",\n \"type\": \"com.example.order.created\",\n \"source\": \"/orders\",\n \"id\": \"{{eventId}}\",\n \"time\": \"{{eventTime}}\",\n \"subject\": \"/orders/order-123\",\n \"datacontenttype\": \"application/json\",\n \"dataschema\": \"https://example.com/schemas/order/v1.0\",\n \"data\": {\n \"orderId\": \"order-123\",\n \"customerId\": \"cust-456\",\n \"items\": [\n {\n \"productId\": \"prod-789\",\n \"quantity\": 2,\n \"price\": 29.99\n }\n ],\n \"total\": 59.98\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", + "host": ["{{baseUrl}}"], + "path": ["api", "events"], + "query": [ + { + "key": "api-version", + "value": "2018-01-01" + } + ] + }, + "description": "CloudEvent with an explicit dataschema field pointing to a schema URI.\n\nThe dataschema must be a valid URI." + }, + "response": [] + }, + { + "name": "Minimal Event (Required Fields Only)", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "var uuid = require('uuid');", + "", + "pm.collectionVariables.set('eventId', uuid.v4());" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 200 OK', function () {", + " pm.response.to.have.status(200);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/cloudevents+json; charset=utf-8", + "type": "text" + }, + { + "key": "aeg-sas-key", + "value": "{{sasKey}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"specversion\": \"1.0\",\n \"type\": \"com.example.minimal\",\n \"source\": \"/minimal\",\n \"id\": \"{{eventId}}\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", + "host": ["{{baseUrl}}"], + "path": ["api", "events"], + "query": [ + { + "key": "api-version", + "value": "2018-01-01" + } + ] + }, + "description": "A minimal CloudEvent with only required fields.\n\nRequired fields:\n- specversion\n- type\n- source\n- id" + }, + "response": [] + } + ], + "description": "CloudEvents sent in structured mode where the entire event is serialized as JSON.\n\nContent-Type: `application/cloudevents+json; charset=utf-8`\n\nNote: For sending multiple events, use Batch Mode with `application/cloudevents-batch+json`." + }, + { + "name": "Batch Mode", + "item": [ + { + "name": "Multiple Events", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "var uuid = require('uuid');", + "", + "pm.collectionVariables.set('eventId1', uuid.v4());", + "pm.collectionVariables.set('eventId2', uuid.v4());", + "pm.collectionVariables.set('eventId3', uuid.v4());", + "pm.collectionVariables.set('eventTime', (new Date()).toISOString());" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 200 OK', function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test('Batch processed successfully', function () {", + " pm.response.to.have.status(200);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/cloudevents-batch+json; charset=utf-8", + "type": "text" + }, + { + "key": "aeg-sas-key", + "value": "{{sasKey}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "[\n {\n \"specversion\": \"1.0\",\n \"type\": \"com.example.order.created\",\n \"source\": \"/orders\",\n \"id\": \"{{eventId1}}\",\n \"time\": \"{{eventTime}}\",\n \"subject\": \"/orders/order-001\",\n \"data\": {\n \"orderId\": \"order-001\",\n \"status\": \"created\"\n }\n },\n {\n \"specversion\": \"1.0\",\n \"type\": \"com.example.order.processing\",\n \"source\": \"/orders\",\n \"id\": \"{{eventId2}}\",\n \"time\": \"{{eventTime}}\",\n \"subject\": \"/orders/order-001\",\n \"data\": {\n \"orderId\": \"order-001\",\n \"status\": \"processing\"\n }\n },\n {\n \"specversion\": \"1.0\",\n \"type\": \"com.example.order.completed\",\n \"source\": \"/orders\",\n \"id\": \"{{eventId3}}\",\n \"time\": \"{{eventTime}}\",\n \"subject\": \"/orders/order-001\",\n \"data\": {\n \"orderId\": \"order-001\",\n \"status\": \"completed\"\n }\n }\n]" + }, + "url": { + "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", + "host": ["{{baseUrl}}"], + "path": ["api", "events"], + "query": [ + { + "key": "api-version", + "value": "2018-01-01" + } + ] + }, + "description": "Sends multiple CloudEvents in batch mode.\n\nContent-Type: `application/cloudevents-batch+json`\n\nThe body must be a JSON array of CloudEvent objects." + }, + "response": [] + } + ], + "description": "CloudEvents sent in batch mode as a JSON array.\n\nContent-Type: `application/cloudevents-batch+json; charset=utf-8`" + }, + { + "name": "Binary Mode", + "item": [ + { + "name": "Simple Event", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "var uuid = require('uuid');", + "", + "pm.collectionVariables.set('eventId', uuid.v4());", + "pm.collectionVariables.set('eventTime', (new Date()).toISOString());" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 200 OK', function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test('Binary mode event processed', function () {", + " pm.response.to.have.status(200);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "aeg-sas-key", + "value": "{{sasKey}}", + "type": "text" + }, + { + "key": "ce-specversion", + "value": "1.0", + "type": "text" + }, + { + "key": "ce-type", + "value": "com.example.binaryevent", + "type": "text" + }, + { + "key": "ce-source", + "value": "/binary/source", + "type": "text" + }, + { + "key": "ce-id", + "value": "{{eventId}}", + "type": "text" + }, + { + "key": "ce-time", + "value": "{{eventTime}}", + "type": "text" + }, + { + "key": "ce-subject", + "value": "/binary/subject", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"MyProperty\": \"This is data sent in binary mode!\",\n \"Count\": 123\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", + "host": ["{{baseUrl}}"], + "path": ["api", "events"], + "query": [ + { + "key": "api-version", + "value": "2018-01-01" + } + ] + }, + "description": "Sends a CloudEvent in binary mode.\n\nIn binary mode:\n- CloudEvent attributes are sent as HTTP headers with `ce-` prefix\n- The request body contains only the event data\n- Content-Type indicates the data type (typically application/json)\n\nRequired headers:\n- `ce-specversion`: \"1.0\"\n- `ce-type`: Event type\n- `ce-source`: Event source\n- `ce-id`: Unique event ID\n\nOptional headers:\n- `ce-time`: RFC 3339 timestamp\n- `ce-subject`: Event subject\n- `ce-datacontenttype`: Content type of body\n- `ce-dataschema`: Schema URI" + }, + "response": [] + }, + { + "name": "With All Optional Headers", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "var uuid = require('uuid');", + "", + "pm.collectionVariables.set('eventId', uuid.v4());", + "pm.collectionVariables.set('eventTime', (new Date()).toISOString());" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 200 OK', function () {", + " pm.response.to.have.status(200);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "aeg-sas-key", + "value": "{{sasKey}}", + "type": "text" + }, + { + "key": "ce-specversion", + "value": "1.0", + "type": "text" + }, + { + "key": "ce-type", + "value": "com.example.fullbinary", + "type": "text" + }, + { + "key": "ce-source", + "value": "/full/binary/source", + "type": "text" + }, + { + "key": "ce-id", + "value": "{{eventId}}", + "type": "text" + }, + { + "key": "ce-time", + "value": "{{eventTime}}", + "type": "text" + }, + { + "key": "ce-subject", + "value": "/users/user-789", + "type": "text" + }, + { + "key": "ce-datacontenttype", + "value": "application/json", + "type": "text" + }, + { + "key": "ce-dataschema", + "value": "https://example.com/schemas/user/v2.0", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"user\": {\n \"id\": \"user-789\",\n \"name\": \"Jane Smith\",\n \"email\": \"jane@example.com\"\n },\n \"action\": \"profile_updated\",\n \"previousValues\": {\n \"name\": \"Jane Doe\"\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", + "host": ["{{baseUrl}}"], + "path": ["api", "events"], + "query": [ + { + "key": "api-version", + "value": "2018-01-01" + } + ] + }, + "description": "CloudEvent in binary mode with all optional headers populated." + }, + "response": [] + }, + { + "name": "Minimal (Required Headers Only)", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "var uuid = require('uuid');", + "", + "pm.collectionVariables.set('eventId', uuid.v4());" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 200 OK', function () {", + " pm.response.to.have.status(200);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "aeg-sas-key", + "value": "{{sasKey}}", + "type": "text" + }, + { + "key": "ce-specversion", + "value": "1.0", + "type": "text" + }, + { + "key": "ce-type", + "value": "com.example.minimal", + "type": "text" + }, + { + "key": "ce-source", + "value": "/minimal", + "type": "text" + }, + { + "key": "ce-id", + "value": "{{eventId}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"simple\": \"data\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", + "host": ["{{baseUrl}}"], + "path": ["api", "events"], + "query": [ + { + "key": "api-version", + "value": "2018-01-01" + } + ] + }, + "description": "Minimal CloudEvent in binary mode with only required headers." + }, + "response": [] + }, + { + "name": "Plain Text Data (415 Error)", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "var uuid = require('uuid');", + "", + "pm.collectionVariables.set('eventId', uuid.v4());", + "pm.collectionVariables.set('eventTime', (new Date()).toISOString());" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 415 Unsupported Media Type', function () {", + " pm.response.to.have.status(415);", + "});", + "", + "pm.test('Response contains content-type error', function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData.error.message.toLowerCase()).to.include('content-type');", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "text/plain", + "type": "text" + }, + { + "key": "aeg-sas-key", + "value": "{{sasKey}}", + "type": "text" + }, + { + "key": "ce-specversion", + "value": "1.0", + "type": "text" + }, + { + "key": "ce-type", + "value": "com.example.textevent", + "type": "text" + }, + { + "key": "ce-source", + "value": "/text/source", + "type": "text" + }, + { + "key": "ce-id", + "value": "{{eventId}}", + "type": "text" + }, + { + "key": "ce-time", + "value": "{{eventTime}}", + "type": "text" + }, + { + "key": "ce-datacontenttype", + "value": "text/plain", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "This is plain text event data. It doesn't have to be JSON!" + }, + "url": { + "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", + "host": ["{{baseUrl}}"], + "path": ["api", "events"], + "query": [ + { + "key": "api-version", + "value": "2018-01-01" + } + ] + }, + "description": "Demonstrates that Azure Event Grid CloudEvents topics require application/cloudevents+json or application/cloudevents-batch+json content types. Plain text is not supported." + }, + "response": [] + } + ], + "description": "CloudEvents sent in binary mode where:\n- Event attributes are sent as HTTP headers (ce-* prefix)\n- Request body contains only the event data\n\nRequired headers:\n- `ce-specversion`: \"1.0\"\n- `ce-type`: Event type\n- `ce-source`: Event source URI\n- `ce-id`: Unique event identifier" + }, + { + "name": "Validation Errors", + "item": [ + { + "name": "Any Spec Version Accepted", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "var uuid = require('uuid');", + "", + "pm.collectionVariables.set('eventId', uuid.v4());" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 200 OK', function () {", + " pm.response.to.have.status(200);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/cloudevents+json; charset=utf-8", + "type": "text" + }, + { + "key": "aeg-sas-key", + "value": "{{sasKey}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"specversion\": \"2.0\",\n \"type\": \"com.example.test\",\n \"source\": \"/test\",\n \"id\": \"{{eventId}}\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", + "host": ["{{baseUrl}}"], + "path": ["api", "events"], + "query": [ + { + "key": "api-version", + "value": "2018-01-01" + } + ] + }, + "description": "Demonstrates that Azure Event Grid accepts any specversion value (not just \"1.0\")." + }, + "response": [] + }, + { + "name": "Missing Required Fields", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 400 Bad Request', function () {", + " pm.response.to.have.status(400);", + "});", + "", + "pm.test('Response contains validation error', function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData.error).to.exist;", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/cloudevents+json; charset=utf-8", + "type": "text" + }, + { + "key": "aeg-sas-key", + "value": "{{sasKey}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"specversion\": \"1.0\",\n \"data\": {\n \"message\": \"Missing type, source, and id\"\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", + "host": ["{{baseUrl}}"], + "path": ["api", "events"], + "query": [ + { + "key": "api-version", + "value": "2018-01-01" + } + ] + }, + "description": "Demonstrates validation error when required fields are missing.\n\nRequired: specversion, type, source, id" + }, + "response": [] + }, + { + "name": "Empty Source Accepted", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "var uuid = require('uuid');", + "", + "pm.collectionVariables.set('eventId', uuid.v4());" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 200 OK', function () {", + " pm.response.to.have.status(200);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/cloudevents+json; charset=utf-8", + "type": "text" + }, + { + "key": "aeg-sas-key", + "value": "{{sasKey}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"specversion\": \"1.0\",\n \"type\": \"com.example.test\",\n \"source\": \"\",\n \"id\": \"{{eventId}}\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", + "host": ["{{baseUrl}}"], + "path": ["api", "events"], + "query": [ + { + "key": "api-version", + "value": "2018-01-01" + } + ] + }, + "description": "Demonstrates that Azure Event Grid accepts empty source values." + }, + "response": [] + }, + { + "name": "Invalid Time Format", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "var uuid = require('uuid');", + "", + "pm.collectionVariables.set('eventId', uuid.v4());" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 400 Bad Request', function () {", + " pm.response.to.have.status(400);", + "});", + "", + "pm.test('Response contains time error', function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData.error.message.toLowerCase()).to.include('time');", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/cloudevents+json; charset=utf-8", + "type": "text" + }, + { + "key": "aeg-sas-key", + "value": "{{sasKey}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"specversion\": \"1.0\",\n \"type\": \"com.example.test\",\n \"source\": \"/test\",\n \"id\": \"{{eventId}}\",\n \"time\": \"not-a-valid-timestamp\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", + "host": ["{{baseUrl}}"], + "path": ["api", "events"], + "query": [ + { + "key": "api-version", + "value": "2018-01-01" + } + ] + }, + "description": "Demonstrates validation error when time is not a valid RFC 3339 timestamp." + }, + "response": [] + }, + { + "name": "Empty Id", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 400 Bad Request', function () {", + " pm.response.to.have.status(400);", + "});", + "", + "pm.test('Response contains id error', function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData.error.message.toLowerCase()).to.include('id');", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/cloudevents+json; charset=utf-8", + "type": "text" + }, + { + "key": "aeg-sas-key", + "value": "{{sasKey}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"specversion\": \"1.0\",\n \"type\": \"com.example.test\",\n \"source\": \"/test\",\n \"id\": \"\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", + "host": ["{{baseUrl}}"], + "path": ["api", "events"], + "query": [ + { + "key": "api-version", + "value": "2018-01-01" + } + ] + }, + "description": "Demonstrates validation error when id is an empty string.\n\nThe id field must be a non-empty string." + }, + "response": [] + }, + { + "name": "Empty Type", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "var uuid = require('uuid');", + "", + "pm.collectionVariables.set('eventId', uuid.v4());" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 400 Bad Request', function () {", + " pm.response.to.have.status(400);", + "});", + "", + "pm.test('Response contains type error', function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData.error.message.toLowerCase()).to.include('type');", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/cloudevents+json; charset=utf-8", + "type": "text" + }, + { + "key": "aeg-sas-key", + "value": "{{sasKey}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"specversion\": \"1.0\",\n \"type\": \"\",\n \"source\": \"/test\",\n \"id\": \"{{eventId}}\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", + "host": ["{{baseUrl}}"], + "path": ["api", "events"], + "query": [ + { + "key": "api-version", + "value": "2018-01-01" + } + ] + }, + "description": "Demonstrates validation error when type is an empty string.\n\nThe type field must be a non-empty string." + }, + "response": [] + } + ], + "description": "Examples of CloudEvents validation errors." + } + ], + "description": "Requests using the CloudEvents v1.0 schema format.\n\nCloudEvents can be sent in three modes:\n1. **Structured Mode**: Entire event as JSON (Content-Type: application/cloudevents+json)\n2. **Batch Mode**: Array of events (Content-Type: application/cloudevents-batch+json)\n3. **Binary Mode**: Attributes in headers, data in body (ce-* headers)" + }, + { + "name": "Authentication", + "item": [ + { + "name": "Missing SAS Key (401)", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "var uuid = require('uuid');", + "", + "pm.collectionVariables.set('eventId', uuid.v4());", + "pm.collectionVariables.set('eventTime', (new Date()).toISOString());" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 401 Unauthorized', function () {", + " pm.response.to.have.status(401);", + "});", + "", + "pm.test('Response indicates authentication failure', function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData.error).to.exist;", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "[\n {\n \"id\": \"{{eventId}}\",\n \"subject\": \"/example/subject\",\n \"data\": {},\n \"eventType\": \"Example.Test\",\n \"eventTime\": \"{{eventTime}}\",\n \"dataVersion\": \"1.0\"\n }\n]" + }, + "url": { + "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", + "host": ["{{baseUrl}}"], + "path": ["api", "events"], + "query": [ + { + "key": "api-version", + "value": "2018-01-01" + } + ] + }, + "description": "Demonstrates 401 Unauthorized when SAS key is missing (assuming topic has key configured)." + }, + "response": [] + }, + { + "name": "Invalid SAS Key (401)", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "var uuid = require('uuid');", + "", + "pm.collectionVariables.set('eventId', uuid.v4());", + "pm.collectionVariables.set('eventTime', (new Date()).toISOString());" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 401 Unauthorized', function () {", + " pm.response.to.have.status(401);", + "});", + "", + "pm.test('Response indicates authentication failure', function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData.error).to.exist;", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "aeg-sas-key", + "value": "InvalidKey123=", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "[\n {\n \"id\": \"{{eventId}}\",\n \"subject\": \"/example/subject\",\n \"data\": {},\n \"eventType\": \"Example.Test\",\n \"eventTime\": \"{{eventTime}}\",\n \"dataVersion\": \"1.0\"\n }\n]" + }, + "url": { + "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", + "host": ["{{baseUrl}}"], + "path": ["api", "events"], + "query": [ + { + "key": "api-version", + "value": "2018-01-01" + } + ] + }, + "description": "Demonstrates 401 Unauthorized when SAS key is invalid." + }, + "response": [] + } + ], + "description": "Examples demonstrating authentication behavior.\n\nThe simulator supports two authentication methods:\n- `aeg-sas-key`: Simple SAS key matching\n- `aeg-sas-token`: SHA256-hashed SAS token" + }, + { + "name": "Subscription Validation", + "item": [ + { + "name": "Manual Validation", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 200 OK or 400 Bad Request', function () {", + " pm.expect(pm.response.code).to.be.oneOf([200, 400]);", + "});", + "", + "pm.test('Response is text or JSON', function () {", + " var contentType = pm.response.headers.get('Content-Type');", + " pm.expect(contentType).to.match(/text|json/);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/validate?id=d9ebebb7-f884-40b5-8c0d-9ec72f6f19cd", + "host": ["{{baseUrl}}"], + "path": ["validate"], + "query": [ + { + "key": "id", + "value": "d9ebebb7-f884-40b5-8c0d-9ec72f6f19cd" + } + ] + }, + "description": "Manually validates a subscription using a validation ID.\n\nWhen a webhook endpoint doesn't automatically respond to the validation challenge, the validation URL can be accessed manually to complete the subscription validation.\n\nThe `id` parameter should be the GUID from the validation URL provided during subscription setup." + }, + "response": [] + } + ], + "description": "Subscription validation endpoints.\n\nWhen a new subscription is created, Event Grid sends a validation request to the webhook endpoint. The endpoint must respond with the validation code to confirm ownership." + }, + { + "name": "Health Check", + "item": [ + { + "name": "Health", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 200 OK', function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test('Response body is OK', function () {", + " pm.expect(pm.response.text()).to.equal('OK');", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/health", + "host": ["{{baseUrl}}"], + "path": ["api", "health"] + }, + "description": "Health check endpoint to verify the simulator is running.\n\nReturns \"OK\" if the service is healthy." + }, + "response": [] + } + ], + "description": "Health check endpoints for monitoring the simulator status." + }, + { + "name": "API Versions", + "item": [ + { + "name": "Version 2018-01-01", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "var uuid = require('uuid');", + "", + "pm.collectionVariables.set('eventId', uuid.v4());", + "pm.collectionVariables.set('eventTime', (new Date()).toISOString());" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 200 OK', function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test('API version 2018-01-01 is supported', function () {", + " pm.response.to.have.status(200);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "aeg-sas-key", + "value": "{{sasKey}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "[\n {\n \"id\": \"{{eventId}}\",\n \"subject\": \"/api-version-test\",\n \"data\": { \"apiVersion\": \"2018-01-01\" },\n \"eventType\": \"ApiVersion.Test\",\n \"eventTime\": \"{{eventTime}}\",\n \"dataVersion\": \"1.0\"\n }\n]" + }, + "url": { + "raw": "{{baseUrl}}/api/events?api-version=2018-01-01", + "host": ["{{baseUrl}}"], + "path": ["api", "events"], + "query": [ + { + "key": "api-version", + "value": "2018-01-01" + } + ] + }, + "description": "Tests the 2018-01-01 API version - the only version supported by Azure Event Grid Custom Topics." + }, + "response": [] + }, + { + "name": "No Version (Uses Default)", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "var uuid = require('uuid');", + "", + "pm.collectionVariables.set('eventId', uuid.v4());", + "pm.collectionVariables.set('eventTime', (new Date()).toISOString());" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status code is 200 OK', function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test('No api-version uses default', function () {", + " pm.response.to.have.status(200);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + }, + { + "key": "aeg-sas-key", + "value": "{{sasKey}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "[\n {\n \"id\": \"{{eventId}}\",\n \"subject\": \"/api-version-test\",\n \"data\": { \"apiVersion\": \"none (default)\" },\n \"eventType\": \"ApiVersion.Test\",\n \"eventTime\": \"{{eventTime}}\",\n \"dataVersion\": \"1.0\"\n }\n]" + }, + "url": { + "raw": "{{baseUrl}}/api/events", + "host": ["{{baseUrl}}"], + "path": ["api", "events"] + }, + "description": "Tests that requests without api-version query parameter work using the default version (2018-01-01)." + }, + "response": [] + } + ], + "description": "Tests for API version handling.\n\nAzure Event Grid Custom Topics only support api-version=2018-01-01.\n\nNote: Newer versions (2023-11-01, 2024-01-01, 2024-06-01) are only for Namespace Topics, which have a different API structure." + } + ], + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [""] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [""] + } + } + ], + "variable": [ + { + "key": "baseUrl", + "value": "https://127.0.0.1:60101", + "type": "string" + }, + { + "key": "sasKey", + "value": "TheLocal+DevelopmentKey=", + "type": "string" + }, + { + "key": "eventId", + "value": "", + "type": "string" + }, + { + "key": "eventTime", + "value": "", + "type": "string" + }, + { + "key": "eventId1", + "value": "", + "type": "string" + }, + { + "key": "eventId2", + "value": "", + "type": "string" + }, + { + "key": "eventId3", + "value": "", + "type": "string" + } + ], + "protocolProfileBehavior": {} } diff --git a/src/AzureEventGridSimulator.ServiceDefaults/Extensions.cs b/src/AzureEventGridSimulator.ServiceDefaults/Extensions.cs index fbcd468..3ccccab 100644 --- a/src/AzureEventGridSimulator.ServiceDefaults/Extensions.cs +++ b/src/AzureEventGridSimulator.ServiceDefaults/Extensions.cs @@ -69,8 +69,14 @@ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) .AddAspNetCoreInstrumentation(tracing => // Exclude health check requests from tracing tracing.Filter = context => - !context.Request.Path.StartsWithSegments(HealthEndpointPath) - && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) + !context.Request.Path.StartsWithSegments( + HealthEndpointPath, + StringComparison.OrdinalIgnoreCase + ) + && !context.Request.Path.StartsWithSegments( + AlivenessEndpointPath, + StringComparison.OrdinalIgnoreCase + ) ) // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) //.AddGrpcClientInstrumentation() diff --git a/src/AzureEventGridSimulator.Tests/ActualSimulatorTests/ActualSimulatorFixture.cs b/src/AzureEventGridSimulator.Tests/ActualSimulatorTests/ActualSimulatorFixture.cs index 8f31380..680fabe 100644 --- a/src/AzureEventGridSimulator.Tests/ActualSimulatorTests/ActualSimulatorFixture.cs +++ b/src/AzureEventGridSimulator.Tests/ActualSimulatorTests/ActualSimulatorFixture.cs @@ -9,9 +9,8 @@ public class ActualSimulatorFixture : IDisposable, IAsyncLifetime private const int MaxStartupWaitTimeMs = 30000; private const int PollingIntervalMs = 100; private bool _disposed; - private string _simulatorExePath; - - private Process _simulatorProcess; + private string? _simulatorExePath; + private Process? _simulatorProcess; public async Task InitializeAsync() { @@ -30,7 +29,7 @@ public async Task InitializeAsync() CreateNoWindow = true, Environment = { - new KeyValuePair("ASPNETCORE_ENVIRONMENT", "Test"), + new KeyValuePair("ASPNETCORE_ENVIRONMENT", "Test"), }, } ); @@ -98,6 +97,11 @@ private static async Task WaitForSimulatorToBeReady() private void KillExistingSimulators() { + if (_simulatorExePath == null) + { + return; + } + try { // Kill any existing instances of the test simulator that may still be hanging around. diff --git a/src/AzureEventGridSimulator.Tests/Domain/Services/Dashboard/EventHistoryServiceTests.cs b/src/AzureEventGridSimulator.Tests/Domain/Services/Dashboard/EventHistoryServiceTests.cs index c455dc3..8cf9ef2 100644 --- a/src/AzureEventGridSimulator.Tests/Domain/Services/Dashboard/EventHistoryServiceTests.cs +++ b/src/AzureEventGridSimulator.Tests/Domain/Services/Dashboard/EventHistoryServiceTests.cs @@ -112,13 +112,7 @@ public void RecordDeliveryAttempt_UpdatesDeliveryStatus() _service.RecordDeliveryQueued("event-1", subscriber); // Act - var attempt = new DeliveryAttempt - { - AttemptNumber = 1, - AttemptTime = DateTimeOffset.UtcNow, - Outcome = DeliveryOutcome.Success, - HttpStatusCode = 200, - }; + var attempt = new DeliveryAttempt(1, DeliveryOutcome.Success, DateTimeOffset.UtcNow, 200); _service.RecordDeliveryAttempt("event-1", "http-subscriber", attempt); // Assert @@ -133,12 +127,7 @@ public void RecordDeliveryAttempt_UpdatesDeliveryStatus() [Fact] public void RecordDeliveryAttempt_NonExistingEvent_DoesNotThrow() { - var attempt = new DeliveryAttempt - { - AttemptNumber = 1, - AttemptTime = DateTimeOffset.UtcNow, - Outcome = DeliveryOutcome.Success, - }; + var attempt = new DeliveryAttempt(1, DeliveryOutcome.Success, DateTimeOffset.UtcNow); Should.NotThrow(() => _service.RecordDeliveryAttempt("non-existing", "subscriber", attempt) diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/CloudEvents/CloudEventSchemaParserTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/CloudEvents/CloudEventSchemaParserTests.cs index 632fb2f..7cd278d 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/CloudEvents/CloudEventSchemaParserTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/CloudEvents/CloudEventSchemaParserTests.cs @@ -1,6 +1,7 @@ using AzureEventGridSimulator.Domain; using AzureEventGridSimulator.Domain.Entities; using AzureEventGridSimulator.Domain.Services; +using AzureEventGridSimulator.Tests.UnitTests.Common; using Shouldly; using Xunit; @@ -32,10 +33,11 @@ public void GivenBinaryModeRequest_WhenParsed_ThenCloudEventCreatedFromHeaders() events.ShouldHaveSingleItem(); events[0].Schema.ShouldBe(EventSchema.CloudEventV1_0); - events[0].CloudEvent.SpecVersion.ShouldBe("1.0"); - events[0].CloudEvent.Type.ShouldBe("com.example.test"); - events[0].CloudEvent.Source.ShouldBe("/test/source"); - events[0].CloudEvent.Id.ShouldBe("test-id-123"); + var cloudEvent = events[0].CloudEvent.ShouldNotBeNullAnd(); + cloudEvent.SpecVersion.ShouldBe("1.0"); + cloudEvent.Type.ShouldBe("com.example.test"); + cloudEvent.Source.ShouldBe("/test/source"); + cloudEvent.Id.ShouldBe("test-id-123"); } [Fact] @@ -56,10 +58,11 @@ public void GivenBinaryModeRequestWithOptionalHeaders_WhenParsed_ThenOptionalFie var events = _parser.Parse(context, requestBody); events.ShouldHaveSingleItem(); - events[0].CloudEvent.Time.ShouldBe("2025-01-15T10:30:00Z"); - events[0].CloudEvent.Subject.ShouldBe("/test/subject"); - events[0].CloudEvent.DataContentType.ShouldBe("application/json"); - events[0].CloudEvent.DataSchema.ShouldBe("https://example.com/schema"); + var cloudEvent = events[0].CloudEvent.ShouldNotBeNullAnd(); + cloudEvent.Time.ShouldBe("2025-01-15T10:30:00Z"); + cloudEvent.Subject.ShouldBe("/test/subject"); + cloudEvent.DataContentType.ShouldBe("application/json"); + cloudEvent.DataSchema.ShouldBe("https://example.com/schema"); } [Fact] @@ -75,7 +78,7 @@ public void GivenBinaryModeRequestWithJsonBody_WhenParsed_ThenDataIsDeserialized var events = _parser.Parse(context, requestBody); - events[0].CloudEvent.Data.ShouldNotBeNull(); + events[0].CloudEvent.ShouldNotBeNullAnd().Data.ShouldNotBeNull(); } [Fact] @@ -91,7 +94,7 @@ public void GivenBinaryModeRequestWithNonJsonBody_WhenParsed_ThenDataIsString() var events = _parser.Parse(context, requestBody); - events[0].CloudEvent.Data.ShouldBe("plain text data"); + events[0].CloudEvent.ShouldNotBeNullAnd().Data.ShouldBe("plain text data"); } [Fact] @@ -107,7 +110,7 @@ public void GivenBinaryModeRequestWithEmptyBody_WhenParsed_ThenDataIsNull() var events = _parser.Parse(context, requestBody); - events[0].CloudEvent.Data.ShouldBeNull(); + events[0].CloudEvent.ShouldNotBeNullAnd().Data.ShouldBeNull(); } [Fact] @@ -127,10 +130,10 @@ public void GivenStructuredModeRequest_WhenParsed_ThenCloudEventCreatedFromBody( events.ShouldHaveSingleItem(); events[0].Schema.ShouldBe(EventSchema.CloudEventV1_0); - events[0].CloudEvent.SpecVersion.ShouldBe("1.0"); - events[0].CloudEvent.Type.ShouldBe("com.example.test"); - events[0].CloudEvent.Source.ShouldBe("/test/source"); - events[0].CloudEvent.Id.ShouldBe("test-id-456"); + events[0].CloudEvent.ShouldNotBeNullAnd().SpecVersion.ShouldBe("1.0"); + events[0].CloudEvent.ShouldNotBeNullAnd().Type.ShouldBe("com.example.test"); + events[0].CloudEvent.ShouldNotBeNullAnd().Source.ShouldBe("/test/source"); + events[0].CloudEvent.ShouldNotBeNullAnd().Id.ShouldBe("test-id-456"); } [Fact] @@ -154,11 +157,11 @@ public void GivenStructuredModeRequestWithOptionalFields_WhenParsed_ThenAllField var events = _parser.Parse(context, requestBody); events.ShouldHaveSingleItem(); - events[0].CloudEvent.Time.ShouldBe("2025-01-15T10:30:00Z"); - events[0].CloudEvent.Subject.ShouldBe("/test/subject"); - events[0].CloudEvent.DataContentType.ShouldBe("application/json"); - events[0].CloudEvent.DataSchema.ShouldBe("https://example.com/schema"); - events[0].CloudEvent.Data.ShouldNotBeNull(); + events[0].CloudEvent.ShouldNotBeNullAnd().Time.ShouldBe("2025-01-15T10:30:00Z"); + events[0].CloudEvent.ShouldNotBeNullAnd().Subject.ShouldBe("/test/subject"); + events[0].CloudEvent.ShouldNotBeNullAnd().DataContentType.ShouldBe("application/json"); + events[0].CloudEvent.ShouldNotBeNullAnd().DataSchema.ShouldBe("https://example.com/schema"); + events[0].CloudEvent.ShouldNotBeNullAnd().Data.ShouldNotBeNull(); } [Fact] @@ -177,7 +180,7 @@ public void GivenStructuredModeRequestWithArrayOfOne_WhenParsed_ThenSingleEventR var events = _parser.Parse(context, requestBody); events.ShouldHaveSingleItem(); - events[0].CloudEvent.Id.ShouldBe("test-id-789"); + events[0].CloudEvent.ShouldNotBeNullAnd().Id.ShouldBe("test-id-789"); } [Fact] @@ -240,10 +243,10 @@ public void GivenBatchModeRequest_WhenParsed_ThenMultipleEventsReturned() var events = _parser.Parse(context, requestBody); events.Length.ShouldBe(2); - events[0].CloudEvent.Id.ShouldBe("event-1"); - events[0].CloudEvent.Type.ShouldBe("com.example.test1"); - events[1].CloudEvent.Id.ShouldBe("event-2"); - events[1].CloudEvent.Type.ShouldBe("com.example.test2"); + events[0].CloudEvent.ShouldNotBeNullAnd().Id.ShouldBe("event-1"); + events[0].CloudEvent.ShouldNotBeNullAnd().Type.ShouldBe("com.example.test1"); + events[1].CloudEvent.ShouldNotBeNullAnd().Id.ShouldBe("event-2"); + events[1].CloudEvent.ShouldNotBeNullAnd().Type.ShouldBe("com.example.test2"); } [Fact] @@ -262,7 +265,7 @@ public void GivenBatchModeRequestWithSingleEvent_WhenParsed_ThenSingleEventRetur var events = _parser.Parse(context, requestBody); events.ShouldHaveSingleItem(); - events[0].CloudEvent.Id.ShouldBe("single-event"); + events[0].CloudEvent.ShouldNotBeNullAnd().Id.ShouldBe("single-event"); } [Fact] @@ -321,20 +324,20 @@ public void GivenValidEvents_WhenValidated_ThenNoExceptionThrown() } [Fact] - public void GivenInvalidEvents_WhenValidated_ThenExceptionThrown() + public void GivenCloudEventJsonWithMissingRequiredFields_WhenParsed_ThenExceptionThrown() { - var events = new[] - { - SimulatorEvent.FromCloudEvent( - new CloudEvent - { - // Missing required fields - SpecVersion = "1.0", - } - ), - }; + // CloudEvents with missing required fields should fail during JSON deserialization + var context = CreateStructuredModeContext(); + const string requestBody = """ + { + "specversion": "1.0" + } + """; - Should.Throw(() => _parser.Validate(events)); + var exception = Should.Throw(() => + _parser.Parse(context, requestBody) + ); + exception.Message.ShouldContain("parse"); } [Fact] @@ -353,8 +356,8 @@ public void GivenBinaryModeRequestWithPercentEncodedHeaders_WhenParsed_ThenValue var events = _parser.Parse(context, requestBody); events.ShouldHaveSingleItem(); - events[0].CloudEvent.Source.ShouldBe("/test/source with spaces"); - events[0].CloudEvent.Subject.ShouldBe("Euro €"); + events[0].CloudEvent.ShouldNotBeNullAnd().Source.ShouldBe("/test/source with spaces"); + events[0].CloudEvent.ShouldNotBeNullAnd().Subject.ShouldBe("Euro €"); } [Fact] @@ -371,7 +374,7 @@ public void GivenBinaryModeRequestWithNonEncodedHeaders_WhenParsed_ThenValuesPas var events = _parser.Parse(context, requestBody); events.ShouldHaveSingleItem(); - events[0].CloudEvent.Source.ShouldBe("/test/source"); + events[0].CloudEvent.ShouldNotBeNullAnd().Source.ShouldBe("/test/source"); } private static DefaultHttpContext CreateBinaryModeContext( @@ -379,10 +382,10 @@ private static DefaultHttpContext CreateBinaryModeContext( string type, string source, string id, - string time = null, - string subject = null, - string dataContentType = null, - string dataSchema = null + string? time = null, + string? subject = null, + string? dataContentType = null, + string? dataSchema = null ) { var context = new DefaultHttpContext @@ -446,7 +449,7 @@ private static DefaultHttpContext CreateBinaryModeContextWithRawHeaders( string type, string source, string id, - string subject = null + string? subject = null ) { var context = new DefaultHttpContext diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/CloudEvents/CloudEventValidationTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/CloudEvents/CloudEventValidationTests.cs index 9b1941c..c6592a3 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/CloudEvents/CloudEventValidationTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/CloudEvents/CloudEventValidationTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using AzureEventGridSimulator.Domain.Entities; using Shouldly; using Xunit; @@ -41,119 +42,204 @@ public void GivenValidCloudEventWithOptionalFields_WhenValidated_ThenNoException } [Fact] - public void GivenCloudEventWithMissingSpecVersion_WhenValidated_ThenExceptionThrown() + public void GivenCloudEventJsonWithMissingSpecVersion_WhenDeserialized_ThenExceptionThrown() { + const string json = """ + { + "type": "com.example.test", + "source": "/test/source", + "id": "test-id-123" + } + """; + + var exception = Should.Throw(() => + JsonSerializer.Deserialize(json) + ); + exception.Message.ShouldContain("specversion"); + } + + [Theory] + [InlineData("0.3")] + [InlineData("2.0")] + [InlineData("invalid")] + public void GivenCloudEventWithAnySpecVersion_WhenValidated_ThenNoExceptionThrown( + string specVersion + ) + { + // Azure Event Grid does not validate specversion value - it accepts any value var cloudEvent = new CloudEvent { + SpecVersion = specVersion, Type = "com.example.test", Source = "/test/source", Id = "test-id-123", }; - var exception = Should.Throw(() => cloudEvent.Validate()); - exception.Message.ShouldContain("SpecVersion"); + Should.NotThrow(() => cloudEvent.Validate()); + } + + [Fact] + public void GivenCloudEventJsonWithMissingType_WhenDeserialized_ThenExceptionThrown() + { + const string json = """ + { + "specversion": "1.0", + "source": "/test/source", + "id": "test-id-123" + } + """; + + var exception = Should.Throw(() => + JsonSerializer.Deserialize(json) + ); + exception.Message.ShouldContain("type"); + } + + [Fact] + public void GivenCloudEventJsonWithMissingSource_WhenDeserialized_ThenExceptionThrown() + { + const string json = """ + { + "specversion": "1.0", + "type": "com.example.test", + "id": "test-id-123" + } + """; + + var exception = Should.Throw(() => + JsonSerializer.Deserialize(json) + ); + exception.Message.ShouldContain("source"); } [Fact] - public void GivenCloudEventWithWrongSpecVersion_WhenValidated_ThenExceptionThrown() + public void GivenCloudEventJsonWithMissingId_WhenDeserialized_ThenExceptionThrown() + { + const string json = """ + { + "specversion": "1.0", + "type": "com.example.test", + "source": "/test/source" + } + """; + + var exception = Should.Throw(() => + JsonSerializer.Deserialize(json) + ); + exception.Message.ShouldContain("id"); + } + + [Theory] + [InlineData("not-a-valid-timestamp")] + [InlineData("2025-01-15 10:30:00")] // Missing timezone + public void GivenCloudEventWithInvalidTime_WhenValidated_ThenExceptionThrown(string time) { var cloudEvent = new CloudEvent { - SpecVersion = "0.3", + SpecVersion = "1.0", Type = "com.example.test", Source = "/test/source", Id = "test-id-123", + Time = time, }; var exception = Should.Throw(() => cloudEvent.Validate()); - exception.Message.ShouldContain("1.0"); + exception.Message.ShouldContain( + "The event time property 'time' was not a valid date/time." + ); } [Fact] - public void GivenCloudEventWithMissingType_WhenValidated_ThenExceptionThrown() + public void GivenCloudEventWithBothDataAndDataBase64_WhenValidated_ThenExceptionThrown() { var cloudEvent = new CloudEvent { SpecVersion = "1.0", + Type = "com.example.test", Source = "/test/source", Id = "test-id-123", + Data = new { Property = "Value" }, + DataBase64 = "SGVsbG8gV29ybGQ=", }; var exception = Should.Throw(() => cloudEvent.Validate()); - exception.Message.ShouldContain("Type"); + exception.Message.ShouldContain("mutually exclusive"); } - [Fact] - public void GivenCloudEventWithMissingSource_WhenValidated_ThenExceptionThrown() + [Theory] + [InlineData("/relative/path")] + [InlineData("https://example.com/absolute")] + [InlineData("urn:uuid:6e8bc430-9c3a-11d9-9669-0800200c9a66")] + [InlineData("/")] + [InlineData("//authority/path")] + public void GivenCloudEventWithValidUriReferenceSource_WhenValidated_ThenNoExceptionThrown( + string source + ) { var cloudEvent = new CloudEvent { SpecVersion = "1.0", Type = "com.example.test", + Source = source, Id = "test-id-123", }; - var exception = Should.Throw(() => cloudEvent.Validate()); - exception.Message.ShouldContain("Source"); + Should.NotThrow(() => cloudEvent.Validate()); } [Fact] - public void GivenCloudEventWithMissingId_WhenValidated_ThenExceptionThrown() + public void GivenCloudEventWithEmptyId_WhenValidated_ThenExceptionThrown() { var cloudEvent = new CloudEvent { SpecVersion = "1.0", Type = "com.example.test", Source = "/test/source", + Id = "", }; var exception = Should.Throw(() => cloudEvent.Validate()); - exception.Message.ShouldContain("Id"); + exception.Message.ShouldContain("'id'"); + exception.Message.ShouldContain("CloudEventV10"); } [Fact] - public void GivenCloudEventWithInvalidTime_WhenValidated_ThenExceptionThrown() + public void GivenCloudEventWithWhitespaceId_WhenValidated_ThenExceptionThrown() { var cloudEvent = new CloudEvent { SpecVersion = "1.0", Type = "com.example.test", Source = "/test/source", - Id = "test-id-123", - Time = "not-a-valid-timestamp", + Id = " ", }; var exception = Should.Throw(() => cloudEvent.Validate()); - exception.Message.ShouldContain("Time"); + exception.Message.ShouldContain("'id'"); } [Fact] - public void GivenCloudEventWithBothDataAndDataBase64_WhenValidated_ThenExceptionThrown() + public void GivenCloudEventWithEmptyType_WhenValidated_ThenExceptionThrown() { var cloudEvent = new CloudEvent { SpecVersion = "1.0", - Type = "com.example.test", + Type = "", Source = "/test/source", Id = "test-id-123", - Data = new { Property = "Value" }, - DataBase64 = "SGVsbG8gV29ybGQ=", }; var exception = Should.Throw(() => cloudEvent.Validate()); - exception.Message.ShouldContain("mutually exclusive"); + exception.Message.ShouldContain("'eventType'"); + exception.Message.ShouldContain("CloudEventV10"); } [Theory] - [InlineData("/relative/path")] - [InlineData("https://example.com/absolute")] - [InlineData("urn:uuid:6e8bc430-9c3a-11d9-9669-0800200c9a66")] - [InlineData("/")] - [InlineData("//authority/path")] - public void GivenCloudEventWithValidUriReferenceSource_WhenValidated_ThenNoExceptionThrown( - string source - ) + [InlineData("")] + [InlineData(" ")] + public void GivenCloudEventWithEmptySource_WhenValidated_ThenNoExceptionThrown(string source) { + // Azure Event Grid does not validate source value - it accepts any value var cloudEvent = new CloudEvent { SpecVersion = "1.0", diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/CloudEvents/SimulatorEventTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/CloudEvents/SimulatorEventTests.cs index ac6824e..a53d493 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/CloudEvents/SimulatorEventTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/CloudEvents/SimulatorEventTests.cs @@ -109,18 +109,19 @@ public void GivenSimulatorEventWithCloudEvent_WhenValidateCalled_ThenCloudEventV } [Fact] - public void GivenSimulatorEventWithInvalidCloudEvent_WhenValidateCalled_ThenExceptionThrown() + public void GivenSimulatorEventWithAnySpecVersion_WhenValidateCalled_ThenNoExceptionThrown() { + // Azure Event Grid does not validate specversion value - it accepts any value var cloudEvent = new CloudEvent { Id = "event-456", Type = "com.example.test", - // Missing Source - SpecVersion = "1.0", + Source = "/test/source", + SpecVersion = "0.3", // Azure accepts any value }; var simulatorEvent = SimulatorEvent.FromCloudEvent(cloudEvent); - Should.Throw(() => simulatorEvent.Validate()); + Should.NotThrow(() => simulatorEvent.Validate()); } } diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Commands/SendNotificationEventsToSubscriberCommandHandlerTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Commands/SendNotificationEventsToSubscriberCommandHandlerTests.cs index a7d3708..951f875 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/Commands/SendNotificationEventsToSubscriberCommandHandlerTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Commands/SendNotificationEventsToSubscriberCommandHandlerTests.cs @@ -4,6 +4,7 @@ using AzureEventGridSimulator.Domain.Services.Retry; using AzureEventGridSimulator.Infrastructure.Settings; using AzureEventGridSimulator.Infrastructure.Settings.Subscribers; +using AzureEventGridSimulator.Tests.UnitTests.Common; using NSubstitute; using Shouldly; using Xunit; @@ -49,17 +50,16 @@ public async Task GivenNoSubscribers_WhenHandled_ThenLogsWarning() .Log( LogLevel.Warning, Arg.Any(), - Arg.Is(o => o.ToString().Contains("has no subscribers")), - Arg.Any(), - Arg.Any>() + Arg.Is(o => (o.ToString() ?? "").Contains("has no subscribers")), + Arg.Any(), + Arg.Any>() ); } [Fact] public async Task GivenAllSubscribersDisabled_WhenHandled_ThenLogsWarning() { - var subscriber = CreateHttpSubscriber(); - subscriber.Disabled = true; + var subscriber = CreateHttpSubscriber(true); var topic = CreateTopicWithHttpSubscriber(subscriber); var events = CreateTestEvents(); var command = new SendNotificationEventsToSubscriberCommand( @@ -75,20 +75,18 @@ public async Task GivenAllSubscribersDisabled_WhenHandled_ThenLogsWarning() .Log( LogLevel.Warning, Arg.Any(), - Arg.Is(o => o.ToString().Contains("has no enabled subscribers")), - Arg.Any(), - Arg.Any>() + Arg.Is(o => (o.ToString() ?? "").Contains("has no enabled subscribers")), + Arg.Any(), + Arg.Any>() ); } [Fact] public async Task GivenEventFilteredByAllSubscribers_WhenHandled_ThenLogsWarningForFilteredEvent() { - var subscriber = CreateHttpSubscriber(); - subscriber.Filter = new FilterSetting - { - IncludedEventTypes = new[] { "Some.Other.EventType" }, - }; + var subscriber = CreateHttpSubscriber( + filter: new FilterSetting { IncludedEventTypes = new[] { "Some.Other.EventType" } } + ); var topic = CreateTopicWithHttpSubscriber(subscriber); var events = CreateTestEvents(); var command = new SendNotificationEventsToSubscriberCommand( @@ -104,17 +102,16 @@ public async Task GivenEventFilteredByAllSubscribers_WhenHandled_ThenLogsWarning .Log( LogLevel.Warning, Arg.Any(), - Arg.Is(o => o.ToString().Contains("filtered out")), - Arg.Any(), - Arg.Any>() + Arg.Is(o => (o.ToString() ?? "").Contains("filtered out")), + Arg.Any(), + Arg.Any>() ); } [Fact] public async Task GivenEventGridEvent_WhenHandled_ThenEventIsEnriched() { - var topic = CreateTopicWithoutSubscribers(); - topic.Name = "MyTestTopic"; + var topic = CreateTopicWithoutSubscribers("MyTestTopic"); var events = CreateTestEvents(); var command = new SendNotificationEventsToSubscriberCommand( events, @@ -124,15 +121,17 @@ public async Task GivenEventGridEvent_WhenHandled_ThenEventIsEnriched() await _handler.Handle(command, CancellationToken.None); - events[0].EventGridEvent.Topic.ShouldContain("MyTestTopic"); - events[0].EventGridEvent.MetadataVersion.ShouldBe("1"); + events[0] + .EventGridEvent.ShouldNotBeNullAnd() + .Topic.ShouldNotBeNullAnd() + .ShouldContain("MyTestTopic"); + events[0].EventGridEvent.ShouldNotBeNullAnd().MetadataVersion.ShouldBe("1"); } [Fact] public async Task GivenCloudEvent_WhenHandled_ThenSourceIsPreservedIfSet() { - var topic = CreateTopicWithoutSubscribers(); - topic.Name = "MyTestTopic"; + var topic = CreateTopicWithoutSubscribers("MyTestTopic"); var cloudEvent = new CloudEvent { SpecVersion = "1.0", @@ -149,38 +148,13 @@ public async Task GivenCloudEvent_WhenHandled_ThenSourceIsPreservedIfSet() await _handler.Handle(command, CancellationToken.None); - events[0].CloudEvent.Source.ShouldBe("/original/source"); - } - - [Fact] - public async Task GivenCloudEventWithoutSource_WhenHandled_ThenSourceIsSetToTopicPath() - { - var topic = CreateTopicWithoutSubscribers(); - topic.Name = "MyTestTopic"; - var cloudEvent = new CloudEvent - { - SpecVersion = "1.0", - Type = "Test.EventType", - Source = null, - Id = "test-id", - }; - var events = new[] { SimulatorEvent.FromCloudEvent(cloudEvent) }; - var command = new SendNotificationEventsToSubscriberCommand( - events, - topic, - EventSchema.CloudEventV1_0 - ); - - await _handler.Handle(command, CancellationToken.None); - - events[0].CloudEvent.Source.ShouldContain("MyTestTopic"); + events[0].CloudEvent.ShouldNotBeNullAnd().Source.ShouldBe("/original/source"); } [Fact] public async Task GivenCloudEventWithEmptySource_WhenHandled_ThenSourceIsSetToTopicPath() { - var topic = CreateTopicWithoutSubscribers(); - topic.Name = "MyTestTopic"; + var topic = CreateTopicWithoutSubscribers("MyTestTopic"); var cloudEvent = new CloudEvent { SpecVersion = "1.0", @@ -197,7 +171,10 @@ public async Task GivenCloudEventWithEmptySource_WhenHandled_ThenSourceIsSetToTo await _handler.Handle(command, CancellationToken.None); - events[0].CloudEvent.Source.ShouldContain("MyTestTopic"); + events[0] + .CloudEvent.ShouldNotBeNullAnd() + .Source.ShouldNotBeNullAnd() + .ShouldContain("MyTestTopic"); } [Fact] @@ -222,17 +199,16 @@ public async Task GivenEvents_WhenHandled_ThenLogsEventCount() .Log( LogLevel.Information, Arg.Any(), - Arg.Is(o => o.ToString().Contains("2 event(s) received")), - Arg.Any(), - Arg.Any>() + Arg.Is(o => (o.ToString() ?? "").Contains("2 event(s) received")), + Arg.Any(), + Arg.Any>() ); } [Fact] public async Task GivenDisabledHttpSubscriber_WhenHandled_ThenLogsDebugAndSkips() { - var subscriber = CreateHttpSubscriber(); - subscriber.Disabled = true; + var subscriber = CreateHttpSubscriber(true); var topic = CreateTopicWithMixedSubscribers(subscriber); var events = CreateTestEvents(); var command = new SendNotificationEventsToSubscriberCommand( @@ -248,9 +224,9 @@ public async Task GivenDisabledHttpSubscriber_WhenHandled_ThenLogsDebugAndSkips( .Log( LogLevel.Debug, Arg.Any(), - Arg.Is(o => o.ToString().Contains("Skipping disabled subscriber")), - Arg.Any(), - Arg.Any>() + Arg.Is(o => (o.ToString() ?? "").Contains("Skipping disabled subscriber")), + Arg.Any(), + Arg.Any>() ); } @@ -272,9 +248,9 @@ public async Task GivenEventGridSchema_WhenHandled_ThenSchemaIsLoggedCorrectly() .Log( LogLevel.Information, Arg.Any(), - Arg.Is(o => o.ToString().Contains("EventGridSchema")), - Arg.Any(), - Arg.Any>() + Arg.Is(o => (o.ToString() ?? "").Contains("EventGridSchema")), + Arg.Any(), + Arg.Any>() ); } @@ -303,17 +279,16 @@ public async Task GivenCloudEventSchema_WhenHandled_ThenSchemaIsLoggedCorrectly( .Log( LogLevel.Information, Arg.Any(), - Arg.Is(o => o.ToString().Contains("CloudEventV1_0")), - Arg.Any(), - Arg.Any>() + Arg.Is(o => (o.ToString() ?? "").Contains("CloudEventV1_0")), + Arg.Any(), + Arg.Any>() ); } [Fact] public async Task GivenMultipleEvents_WhenHandled_ThenAllEventsAreEnriched() { - var topic = CreateTopicWithoutSubscribers(); - topic.Name = "MyTestTopic"; + var topic = CreateTopicWithoutSubscribers("MyTestTopic"); var events = new[] { CreateTestEventGridEvent("event-1"), @@ -328,14 +303,19 @@ public async Task GivenMultipleEvents_WhenHandled_ThenAllEventsAreEnriched() await _handler.Handle(command, CancellationToken.None); - foreach (var evt in events) + foreach ( + var eventGridEvent in events.Select(evt => evt.EventGridEvent.ShouldNotBeNullAnd()) + ) { - evt.EventGridEvent.Topic.ShouldContain("MyTestTopic"); - evt.EventGridEvent.MetadataVersion.ShouldBe("1"); + eventGridEvent.Topic.ShouldNotBeNullAnd().ShouldContain("MyTestTopic"); + eventGridEvent.MetadataVersion.ShouldBe("1"); } } - private static HttpSubscriberSettings CreateHttpSubscriber() + private static HttpSubscriberSettings CreateHttpSubscriber( + bool disabled = false, + FilterSetting? filter = null + ) { return new HttpSubscriberSettings { @@ -343,14 +323,16 @@ private static HttpSubscriberSettings CreateHttpSubscriber() Endpoint = "https://example.com/webhook", DisableValidation = true, ValidationStatus = SubscriptionValidationStatus.ValidationSuccessful, + Disabled = disabled, + Filter = filter, }; } - private static TopicSettings CreateTopicWithoutSubscribers() + private static TopicSettings CreateTopicWithoutSubscribers(string name = "TestTopic") { return new TopicSettings { - Name = "TestTopic", + Name = name, Port = 60101, Key = "TestKey", Subscribers = new SubscribersSettings(), diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Commands/ValidateSubscriptionCommandHandlerTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Commands/ValidateSubscriptionCommandHandlerTests.cs index 7e4ba7e..fada384 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/Commands/ValidateSubscriptionCommandHandlerTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Commands/ValidateSubscriptionCommandHandlerTests.cs @@ -57,9 +57,9 @@ public async Task GivenMatchingValidationCode_WhenHandled_ThenLogsInformation() .Log( LogLevel.Information, Arg.Any(), - Arg.Is(o => o.ToString().Contains("successfully validated")), - Arg.Any(), - Arg.Any>() + Arg.Is(o => (o.ToString() ?? "").Contains("successfully validated")), + Arg.Any(), + Arg.Any>() ); } @@ -91,9 +91,9 @@ public async Task GivenNonMatchingValidationCode_WhenHandled_ThenLogsWarning() .Log( LogLevel.Warning, Arg.Any(), - Arg.Is(o => o.ToString().Contains("Validation failed")), - Arg.Any(), - Arg.Any>() + Arg.Is(o => (o.ToString() ?? "").Contains("Validation failed")), + Arg.Any(), + Arg.Any>() ); } diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Common/TestHelpers.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Common/TestHelpers.cs index 6728513..099ba95 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/Common/TestHelpers.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Common/TestHelpers.cs @@ -2,9 +2,27 @@ using AzureEventGridSimulator.Domain.Entities; using AzureEventGridSimulator.Infrastructure.Settings; using AzureEventGridSimulator.Infrastructure.Settings.Subscribers; +using Shouldly; namespace AzureEventGridSimulator.Tests.UnitTests.Common; +/// +/// Extension methods for null assertions in tests. +/// +public static class NullAssertionExtensions +{ + /// + /// Asserts that the value is not null and returns it. + /// This allows null-safe property access without using the null-forgiving operator. + /// + public static T ShouldNotBeNullAnd(this T? value, string? customMessage = null) + where T : class + { + value.ShouldNotBeNull(customMessage); + return value; + } +} + /// /// Shared test helper methods for creating test objects. /// @@ -26,10 +44,10 @@ public static HttpContext CreateCloudEventsBinaryModeContext( string type = "com.example.test", string source = "/test/source", string id = "test-id-123", - string time = null, - string subject = null, - string dataContentType = null, - string dataSchema = null + string? time = null, + string? subject = null, + string? dataContentType = null, + string? dataSchema = null ) { var context = new DefaultHttpContext @@ -101,7 +119,7 @@ public static EventGridEvent CreateValidEventGridEvent( string eventType = "Test.EventType", string eventTime = "2025-01-15T10:30:00Z", string dataVersion = "1.0", - object data = null + object? data = null ) { return new EventGridEvent @@ -123,9 +141,9 @@ public static CloudEvent CreateValidCloudEvent( string type = "com.example.test", string source = "/test/source", string id = "test-id-123", - string time = null, - string subject = null, - object data = null + string? time = null, + string? subject = null, + object? data = null ) { return new CloudEvent @@ -149,7 +167,7 @@ public static SimulatorEvent CreateSimulatorEventFromEventGrid( string eventType = "Test.EventType", string eventTime = "2025-01-15T10:30:00Z", string dataVersion = "1.0", - object data = null + object? data = null ) { return SimulatorEvent.FromEventGridEvent( @@ -165,9 +183,9 @@ public static SimulatorEvent CreateSimulatorEventFromCloudEvent( string type = "com.example.test", string source = "/test/source", string id = "test-id-123", - string time = null, - string subject = null, - object data = null + string? time = null, + string? subject = null, + object? data = null ) { return SimulatorEvent.FromCloudEvent( @@ -181,7 +199,7 @@ public static SimulatorEvent CreateSimulatorEventFromCloudEvent( public static ServiceBusSubscriberSettings CreateValidServiceBusSettings( string name = "TestSubscriber", string queue = "my-queue", - string topic = null + string? topic = null ) { return new ServiceBusSubscriberSettings diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Configuration/ConfigurationLoadingTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Configuration/ConfigurationLoadingTests.cs index de4b0f2..3de5407 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/Configuration/ConfigurationLoadingTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Configuration/ConfigurationLoadingTests.cs @@ -1,6 +1,7 @@ using System.Text; using System.Text.Json; using AzureEventGridSimulator.Infrastructure.Settings; +using AzureEventGridSimulator.Tests.UnitTests.Common; using Shouldly; using Xunit; @@ -52,7 +53,9 @@ public void IConfigurationBind_ShouldLoadEventHubSubscribers() var eventHubSubscriber = topic.Subscribers.EventHubSubscribers.First(); eventHubSubscriber.Name.ShouldBe("EventHubSubscriber"); eventHubSubscriber.EventHubName.ShouldBe("test-hub"); - eventHubSubscriber.ConnectionString.ShouldContain("sb://test.servicebus.windows.net"); + eventHubSubscriber + .ConnectionString.ShouldNotBeNullAnd() + .ShouldContain("sb://test.servicebus.windows.net"); } [Fact] diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/EventGrid/EventGridSchemaParserTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/EventGrid/EventGridSchemaParserTests.cs index 7e82a7e..67b492b 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/EventGrid/EventGridSchemaParserTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/EventGrid/EventGridSchemaParserTests.cs @@ -1,5 +1,6 @@ using AzureEventGridSimulator.Domain.Entities; using AzureEventGridSimulator.Domain.Services; +using AzureEventGridSimulator.Tests.UnitTests.Common; using Shouldly; using Xunit; @@ -29,10 +30,10 @@ public void GivenValidEventGridEventsArray_WhenParsed_ThenEventsCreated() events.ShouldHaveSingleItem(); events[0].Schema.ShouldBe(EventSchema.EventGridSchema); - events[0].EventGridEvent.Id.ShouldBe("test-id-123"); - events[0].EventGridEvent.Subject.ShouldBe("/test/subject"); - events[0].EventGridEvent.EventType.ShouldBe("Test.EventType"); - events[0].EventGridEvent.DataVersion.ShouldBe("1.0"); + events[0].EventGridEvent.ShouldNotBeNullAnd().Id.ShouldBe("test-id-123"); + events[0].EventGridEvent.ShouldNotBeNullAnd().Subject.ShouldBe("/test/subject"); + events[0].EventGridEvent.ShouldNotBeNullAnd().EventType.ShouldBe("Test.EventType"); + events[0].EventGridEvent.ShouldNotBeNullAnd().DataVersion.ShouldBe("1.0"); } [Fact] @@ -61,10 +62,10 @@ public void GivenMultipleEvents_WhenParsed_ThenAllEventsReturned() var events = _parser.Parse(context, requestBody); events.Length.ShouldBe(2); - events[0].EventGridEvent.Id.ShouldBe("event-1"); - events[0].EventGridEvent.EventType.ShouldBe("Test.EventType1"); - events[1].EventGridEvent.Id.ShouldBe("event-2"); - events[1].EventGridEvent.EventType.ShouldBe("Test.EventType2"); + events[0].EventGridEvent.ShouldNotBeNullAnd().Id.ShouldBe("event-1"); + events[0].EventGridEvent.ShouldNotBeNullAnd().EventType.ShouldBe("Test.EventType1"); + events[1].EventGridEvent.ShouldNotBeNullAnd().Id.ShouldBe("event-2"); + events[1].EventGridEvent.ShouldNotBeNullAnd().EventType.ShouldBe("Test.EventType2"); } [Fact] @@ -86,8 +87,8 @@ public void GivenEventWithAllOptionalFields_WhenParsed_ThenAllFieldsPopulated() var events = _parser.Parse(context, requestBody); events.ShouldHaveSingleItem(); - events[0].EventGridEvent.MetadataVersion.ShouldBe("1"); - events[0].EventGridEvent.Data.ShouldNotBeNull(); + events[0].EventGridEvent.ShouldNotBeNullAnd().MetadataVersion.ShouldBe("1"); + events[0].EventGridEvent.ShouldNotBeNullAnd().Data.ShouldNotBeNull(); } [Fact] @@ -106,9 +107,9 @@ public void GivenEventWithMinimalFields_WhenParsed_ThenRequiredFieldsPresent() var events = _parser.Parse(context, requestBody); events.ShouldHaveSingleItem(); - events[0].EventGridEvent.Id.ShouldBe("min-id"); - events[0].EventGridEvent.Subject.ShouldBe("/min/subject"); - events[0].EventGridEvent.EventType.ShouldBe("Min.Type"); + events[0].EventGridEvent.ShouldNotBeNullAnd().Id.ShouldBe("min-id"); + events[0].EventGridEvent.ShouldNotBeNullAnd().Subject.ShouldBe("/min/subject"); + events[0].EventGridEvent.ShouldNotBeNullAnd().EventType.ShouldBe("Min.Type"); } [Fact] @@ -140,7 +141,9 @@ public void GivenNullBody_WhenParsed_ThenExceptionThrown() { var context = CreateEventGridContext(); - var exception = Should.Throw(() => _parser.Parse(context, null)); + var exception = Should.Throw(() => + _parser.Parse(context, null!) + ); exception.Message.ShouldContain("empty"); } @@ -208,50 +211,39 @@ public void GivenValidEvents_WhenValidated_ThenNoExceptionThrown() } [Fact] - public void GivenInvalidEvents_WhenValidated_ThenExceptionThrown() + public void GivenEventJsonWithMissingId_WhenParsed_ThenExceptionThrown() { - var events = new[] - { - SimulatorEvent.FromEventGridEvent( - new EventGridEvent - { - // Missing required Id field - Subject = "/test/subject", - EventType = "Test.EventType", - EventTime = "2025-01-15T10:30:00Z", - } - ), - }; + var context = CreateEventGridContext(); + const string requestBody = """ + [{ + "subject": "/test/subject", + "eventType": "Test.EventType", + "eventTime": "2025-01-15T10:30:00Z" + }] + """; - Should.Throw(() => _parser.Validate(events)); + var exception = Should.Throw(() => + _parser.Parse(context, requestBody) + ); + exception.Message.ShouldContain("parse"); } [Fact] - public void GivenMultipleEventsWithOneInvalid_WhenValidated_ThenExceptionThrown() + public void GivenEventJsonWithMissingSubject_WhenParsed_ThenExceptionThrown() { - var events = new[] - { - SimulatorEvent.FromEventGridEvent( - new EventGridEvent - { - Id = "valid-id", - Subject = "/test/subject", - EventType = "Test.EventType", - EventTime = "2025-01-15T10:30:00Z", - } - ), - SimulatorEvent.FromEventGridEvent( - new EventGridEvent - { - Id = "invalid-id", - // Missing Subject - EventType = "Test.EventType", - EventTime = "2025-01-15T10:30:00Z", - } - ), - }; + var context = CreateEventGridContext(); + const string requestBody = """ + [{ + "id": "test-id", + "eventType": "Test.EventType", + "eventTime": "2025-01-15T10:30:00Z" + }] + """; - Should.Throw(() => _parser.Validate(events)); + var exception = Should.Throw(() => + _parser.Parse(context, requestBody) + ); + exception.Message.ShouldContain("parse"); } [Fact] @@ -277,7 +269,7 @@ public void GivenEventWithComplexData_WhenParsed_ThenDataPreserved() var events = _parser.Parse(context, requestBody); events.ShouldHaveSingleItem(); - events[0].EventGridEvent.Data.ShouldNotBeNull(); + events[0].EventGridEvent.ShouldNotBeNullAnd().Data.ShouldNotBeNull(); } [Fact] diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/EventGrid/EventGridValidationTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/EventGrid/EventGridValidationTests.cs index 0958219..5765d33 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/EventGrid/EventGridValidationTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/EventGrid/EventGridValidationTests.cs @@ -40,134 +40,71 @@ public void GivenValidEventWithOptionalFields_WhenValidated_ThenNoExceptionThrow } [Fact] - public void GivenEventWithMissingId_WhenValidated_ThenExceptionThrown() + public void GivenEventJsonWithMissingId_WhenDeserialized_ThenExceptionThrown() { - var eventGridEvent = new EventGridEvent - { - Subject = "/test/subject", - EventType = "Test.EventType", - EventTime = "2025-01-15T10:30:00Z", - }; - - var exception = Should.Throw(() => eventGridEvent.Validate()); - exception.Message.ShouldContain("Id"); - } - - [Fact] - public void GivenEventWithEmptyId_WhenValidated_ThenExceptionThrown() - { - var eventGridEvent = new EventGridEvent - { - Id = "", - Subject = "/test/subject", - EventType = "Test.EventType", - EventTime = "2025-01-15T10:30:00Z", - }; - - var exception = Should.Throw(() => eventGridEvent.Validate()); - exception.Message.ShouldContain("Id"); - } - - [Fact] - public void GivenEventWithWhitespaceId_WhenValidated_ThenExceptionThrown() - { - var eventGridEvent = new EventGridEvent - { - Id = " ", - Subject = "/test/subject", - EventType = "Test.EventType", - EventTime = "2025-01-15T10:30:00Z", - }; - - var exception = Should.Throw(() => eventGridEvent.Validate()); - exception.Message.ShouldContain("Id"); - } - - [Fact] - public void GivenEventWithMissingSubject_WhenValidated_ThenExceptionThrown() - { - var eventGridEvent = new EventGridEvent - { - Id = "test-id-123", - EventType = "Test.EventType", - EventTime = "2025-01-15T10:30:00Z", - }; - - var exception = Should.Throw(() => eventGridEvent.Validate()); - exception.Message.ShouldContain("Subject"); - } - - [Fact] - public void GivenEventWithEmptySubject_WhenValidated_ThenExceptionThrown() - { - var eventGridEvent = new EventGridEvent - { - Id = "test-id-123", - Subject = "", - EventType = "Test.EventType", - EventTime = "2025-01-15T10:30:00Z", - }; - - var exception = Should.Throw(() => eventGridEvent.Validate()); - exception.Message.ShouldContain("Subject"); - } - - [Fact] - public void GivenEventWithMissingEventType_WhenValidated_ThenExceptionThrown() - { - var eventGridEvent = new EventGridEvent - { - Id = "test-id-123", - Subject = "/test/subject", - EventTime = "2025-01-15T10:30:00Z", - }; + const string json = """ + { + "subject": "/test/subject", + "eventType": "Test.EventType", + "eventTime": "2025-01-15T10:30:00Z" + } + """; - var exception = Should.Throw(() => eventGridEvent.Validate()); - exception.Message.ShouldContain("EventType"); + var exception = Should.Throw(() => + JsonSerializer.Deserialize(json) + ); + exception.Message.ShouldContain("id"); } [Fact] - public void GivenEventWithEmptyEventType_WhenValidated_ThenExceptionThrown() + public void GivenEventJsonWithMissingSubject_WhenDeserialized_ThenExceptionThrown() { - var eventGridEvent = new EventGridEvent - { - Id = "test-id-123", - Subject = "/test/subject", - EventType = "", - EventTime = "2025-01-15T10:30:00Z", - }; + const string json = """ + { + "id": "test-id-123", + "eventType": "Test.EventType", + "eventTime": "2025-01-15T10:30:00Z" + } + """; - var exception = Should.Throw(() => eventGridEvent.Validate()); - exception.Message.ShouldContain("EventType"); + var exception = Should.Throw(() => + JsonSerializer.Deserialize(json) + ); + exception.Message.ShouldContain("subject"); } [Fact] - public void GivenEventWithMissingEventTime_WhenValidated_ThenExceptionThrown() + public void GivenEventJsonWithMissingEventType_WhenDeserialized_ThenExceptionThrown() { - var eventGridEvent = new EventGridEvent - { - Id = "test-id-123", - Subject = "/test/subject", - EventType = "Test.EventType", - }; + const string json = """ + { + "id": "test-id-123", + "subject": "/test/subject", + "eventTime": "2025-01-15T10:30:00Z" + } + """; - var exception = Should.Throw(() => eventGridEvent.Validate()); - exception.Message.ShouldContain("EventTime"); + var exception = Should.Throw(() => + JsonSerializer.Deserialize(json) + ); + exception.Message.ShouldContain("eventType"); } [Fact] - public void GivenEventWithEmptyEventTime_WhenValidated_ThenExceptionThrown() + public void GivenEventJsonWithMissingEventTime_WhenDeserialized_ThenExceptionThrown() { - var eventGridEvent = new EventGridEvent - { - Id = "test-id-123", - Subject = "/test/subject", - EventType = "Test.EventType", - EventTime = "", - }; + const string json = """ + { + "id": "test-id-123", + "subject": "/test/subject", + "eventType": "Test.EventType" + } + """; - var exception = Should.Throw(() => eventGridEvent.Validate()); - exception.Message.ShouldContain("EventTime"); + var exception = Should.Throw(() => + JsonSerializer.Deserialize(json) + ); + exception.Message.ShouldContain("eventTime"); } [Fact] @@ -233,14 +170,15 @@ public void GivenEventWithInvalidMetadataVersion_WhenValidated_ThenExceptionThro }; var exception = Should.Throw(() => eventGridEvent.Validate()); - exception.Message.ShouldContain("MetadataVersion"); + exception.Message.ShouldContain("'metadataVersion'"); + exception.Message.ShouldContain("was expected to either be null or be set to 1"); } [Theory] [InlineData(null)] [InlineData("1")] public void GivenEventWithValidMetadataVersion_WhenValidated_ThenNoExceptionThrown( - string metadataVersion + string? metadataVersion ) { var eventGridEvent = new EventGridEvent @@ -271,7 +209,8 @@ public void GivenEventWithNonNullTopic_WhenValidated_ThenExceptionThrown() """; var eventGridEvent = JsonSerializer.Deserialize(json); - var exception = Should.Throw(() => eventGridEvent!.Validate()); + eventGridEvent.ShouldNotBeNull(); + var exception = Should.Throw(() => eventGridEvent.Validate()); exception.Message.ShouldContain("Topic"); } @@ -305,7 +244,8 @@ public void GivenEventWithEmptyTopic_WhenValidated_ThenNoExceptionThrown() """; var eventGridEvent = JsonSerializer.Deserialize(json); - Should.NotThrow(() => eventGridEvent!.Validate()); + eventGridEvent.ShouldNotBeNull(); + Should.NotThrow(() => eventGridEvent.Validate()); } [Fact] @@ -339,4 +279,84 @@ public void GivenEventWithNullDataVersion_WhenValidated_ThenNoExceptionThrown() Should.NotThrow(() => eventGridEvent.Validate()); } + + [Fact] + public void GivenEventWithEmptyId_WhenValidated_ThenExceptionThrown() + { + var eventGridEvent = new EventGridEvent + { + Id = "", + Subject = "/test/subject", + EventType = "Test.EventType", + EventTime = "2025-01-15T10:30:00Z", + }; + + var exception = Should.Throw(() => eventGridEvent.Validate()); + exception.Message.ShouldContain("'id'"); + exception.Message.ShouldContain("EventGridEvent"); + } + + [Fact] + public void GivenEventWithWhitespaceId_WhenValidated_ThenExceptionThrown() + { + var eventGridEvent = new EventGridEvent + { + Id = " ", + Subject = "/test/subject", + EventType = "Test.EventType", + EventTime = "2025-01-15T10:30:00Z", + }; + + var exception = Should.Throw(() => eventGridEvent.Validate()); + exception.Message.ShouldContain("'id'"); + } + + [Fact] + public void GivenEventWithEmptySubject_WhenValidated_ThenExceptionThrown() + { + var eventGridEvent = new EventGridEvent + { + Id = "test-id-123", + Subject = "", + EventType = "Test.EventType", + EventTime = "2025-01-15T10:30:00Z", + }; + + var exception = Should.Throw(() => eventGridEvent.Validate()); + exception.Message.ShouldContain("'subject'"); + exception.Message.ShouldContain("EventGridEvent"); + } + + [Fact] + public void GivenEventWithEmptyEventType_WhenValidated_ThenExceptionThrown() + { + var eventGridEvent = new EventGridEvent + { + Id = "test-id-123", + Subject = "/test/subject", + EventType = "", + EventTime = "2025-01-15T10:30:00Z", + }; + + var exception = Should.Throw(() => eventGridEvent.Validate()); + exception.Message.ShouldContain("'eventType'"); + exception.Message.ShouldContain("EventGridEvent"); + } + + [Fact] + public void GivenEventWithEmptyDataVersion_WhenValidated_ThenExceptionThrown() + { + var eventGridEvent = new EventGridEvent + { + Id = "test-id-123", + Subject = "/test/subject", + EventType = "Test.EventType", + EventTime = "2025-01-15T10:30:00Z", + DataVersion = "", + }; + + var exception = Should.Throw(() => eventGridEvent.Validate()); + exception.Message.ShouldContain("'dataVersion'"); + exception.Message.ShouldContain("requires 'dataVersion' property to be set"); + } } diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Filtering/AdvancedFilterEventAcceptanceTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Filtering/AdvancedFilterEventAcceptanceTests.cs index a31a2a4..2e1f01d 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/Filtering/AdvancedFilterEventAcceptanceTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Filtering/AdvancedFilterEventAcceptanceTests.cs @@ -1,6 +1,7 @@ -using AzureEventGridSimulator.Domain.Entities; +using AzureEventGridSimulator.Domain.Entities; using AzureEventGridSimulator.Infrastructure.Extensions; using AzureEventGridSimulator.Infrastructure.Settings; +using AzureEventGridSimulator.Tests.UnitTests.Common; using Shouldly; using Xunit; @@ -16,6 +17,9 @@ private static EventGridEvent CreateTestEvent() var evt = new EventGridEvent { Id = "EventId", + Subject = "TheEventSubject", + EventType = "this.is.a.test.event.type", + EventTime = DateTimeOffset.UtcNow.ToString("O"), Data = new { NumberValue = 1, @@ -26,10 +30,7 @@ private static EventGridEvent CreateTestEvent() SubObject = new { Id = 1, Name = "Test" }, }, DataVersion = "5.0", - EventTime = DateTimeOffset.UtcNow.ToString("O"), - EventType = "this.is.a.test.event.type", - MetadataVersion = "2.3.4", - Subject = "TheEventSubject", + MetadataVersion = "1", }; evt.SetTopic("THE_EVENT_TOPIC"); return evt; @@ -66,17 +67,17 @@ public void TestSimpleEventDataFilteringSuccess() { var filterConfig = new FilterSetting { - AdvancedFilters = new[] - { + AdvancedFilters = + [ new AdvancedFilterSetting { Key = "Data", OperatorType = AdvancedFilterSetting.AdvancedFilterOperatorType.NumberIn, - Values = new object[] { 1 }, + Values = [1], }, - }, + ], }; - var gridEvent = new EventGridEvent { Data = 1 }; + var gridEvent = TestHelpers.CreateValidEventGridEvent(data: 1); filterConfig.AcceptsEvent(gridEvent).ShouldBeTrue(); } @@ -86,8 +87,8 @@ public void TestSimpleEventDataFilteringUsingValueSuccess() { var filterConfig = new FilterSetting { - AdvancedFilters = new[] - { + AdvancedFilters = + [ new AdvancedFilterSetting { Key = "Data", @@ -104,9 +105,9 @@ public void TestSimpleEventDataFilteringUsingValueSuccess() .NumberLessThanOrEquals, Value = 1, }, - }, + ], }; - var gridEvent = new EventGridEvent { Data = 1 }; + var gridEvent = TestHelpers.CreateValidEventGridEvent(data: 1); filterConfig.AcceptsEvent(gridEvent).ShouldBeTrue(); } @@ -116,17 +117,17 @@ public void TestSimpleEventDataFilteringFailure() { var filterConfig = new FilterSetting { - AdvancedFilters = new[] - { + AdvancedFilters = + [ new AdvancedFilterSetting { Key = "Data", OperatorType = AdvancedFilterSetting.AdvancedFilterOperatorType.NumberIn, Value = 1, }, - }, + ], }; - var gridEvent = new EventGridEvent { Data = 1 }; + var gridEvent = TestHelpers.CreateValidEventGridEvent(data: 1); filterConfig.AcceptsEvent(gridEvent).ShouldBeFalse(); } diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Filtering/ArrayFilteringTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Filtering/ArrayFilteringTests.cs new file mode 100644 index 0000000..f746d58 --- /dev/null +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Filtering/ArrayFilteringTests.cs @@ -0,0 +1,356 @@ +using AzureEventGridSimulator.Domain.Entities; +using AzureEventGridSimulator.Infrastructure.Extensions; +using AzureEventGridSimulator.Infrastructure.Settings; +using Shouldly; +using Xunit; + +namespace AzureEventGridSimulator.Tests.UnitTests.Filtering; + +[Trait("Category", "unit")] +public class ArrayFilteringTests +{ + /// + /// Test event with array data for filtering tests. + /// + private static EventGridEvent CreateEventWithArrayData() + { + var evt = new EventGridEvent + { + Id = "test-id", + Subject = "test-subject", + EventType = "Test.Event.Type", + EventTime = DateTimeOffset.UtcNow.ToString("O"), + Data = new + { + Tags = new[] { "important", "urgent", "review" }, + Numbers = new[] { 1, 2, 3, 4, 5 }, + Categories = new[] { "CategoryA", "CategoryB" }, + SingleValue = "not-an-array", + }, + DataVersion = "1.0", + MetadataVersion = "1", + }; + evt.SetTopic("test-topic"); + return evt; + } + + [Fact] + public void StringIn_WithArrayFiltering_MatchesAnyElement() + { + var filter = new FilterSetting + { + EnableAdvancedFilteringOnArrays = true, + AdvancedFilters = + [ + new AdvancedFilterSetting + { + Key = "Data.Tags", + OperatorType = AdvancedFilterSetting.AdvancedFilterOperatorType.StringIn, + Values = ["important"], + }, + ], + }; + + filter.AcceptsEvent(CreateEventWithArrayData()).ShouldBeTrue(); + } + + [Fact] + public void StringIn_WithArrayFiltering_MatchesMiddleElement() + { + var filter = new FilterSetting + { + EnableAdvancedFilteringOnArrays = true, + AdvancedFilters = + [ + new AdvancedFilterSetting + { + Key = "Data.Tags", + OperatorType = AdvancedFilterSetting.AdvancedFilterOperatorType.StringIn, + Values = ["urgent"], + }, + ], + }; + + filter.AcceptsEvent(CreateEventWithArrayData()).ShouldBeTrue(); + } + + [Fact] + public void StringIn_WithArrayFiltering_NoMatch() + { + var filter = new FilterSetting + { + EnableAdvancedFilteringOnArrays = true, + AdvancedFilters = + [ + new AdvancedFilterSetting + { + Key = "Data.Tags", + OperatorType = AdvancedFilterSetting.AdvancedFilterOperatorType.StringIn, + Values = ["nonexistent"], + }, + ], + }; + + filter.AcceptsEvent(CreateEventWithArrayData()).ShouldBeFalse(); + } + + [Fact] + public void StringContains_WithArrayFiltering_MatchesPartialElement() + { + var filter = new FilterSetting + { + EnableAdvancedFilteringOnArrays = true, + AdvancedFilters = + [ + new AdvancedFilterSetting + { + Key = "Data.Tags", + OperatorType = AdvancedFilterSetting.AdvancedFilterOperatorType.StringContains, + Values = ["port"], // matches "important" + }, + ], + }; + + filter.AcceptsEvent(CreateEventWithArrayData()).ShouldBeTrue(); + } + + [Fact] + public void StringBeginsWith_WithArrayFiltering_MatchesElement() + { + var filter = new FilterSetting + { + EnableAdvancedFilteringOnArrays = true, + AdvancedFilters = + [ + new AdvancedFilterSetting + { + Key = "Data.Tags", + OperatorType = AdvancedFilterSetting + .AdvancedFilterOperatorType + .StringBeginsWith, + Values = ["urg"], // matches "urgent" + }, + ], + }; + + filter.AcceptsEvent(CreateEventWithArrayData()).ShouldBeTrue(); + } + + [Fact] + public void StringEndsWith_WithArrayFiltering_MatchesElement() + { + var filter = new FilterSetting + { + EnableAdvancedFilteringOnArrays = true, + AdvancedFilters = + [ + new AdvancedFilterSetting + { + Key = "Data.Tags", + OperatorType = AdvancedFilterSetting.AdvancedFilterOperatorType.StringEndsWith, + Values = ["view"], // matches "review" + }, + ], + }; + + filter.AcceptsEvent(CreateEventWithArrayData()).ShouldBeTrue(); + } + + [Fact] + public void NumberIn_WithArrayFiltering_MatchesElement() + { + var filter = new FilterSetting + { + EnableAdvancedFilteringOnArrays = true, + AdvancedFilters = + [ + new AdvancedFilterSetting + { + Key = "Data.Numbers", + OperatorType = AdvancedFilterSetting.AdvancedFilterOperatorType.NumberIn, + Values = [3], + }, + ], + }; + + filter.AcceptsEvent(CreateEventWithArrayData()).ShouldBeTrue(); + } + + [Fact] + public void NumberGreaterThan_WithArrayFiltering_MatchesAnyElement() + { + var filter = new FilterSetting + { + EnableAdvancedFilteringOnArrays = true, + AdvancedFilters = + [ + new AdvancedFilterSetting + { + Key = "Data.Numbers", + OperatorType = AdvancedFilterSetting + .AdvancedFilterOperatorType + .NumberGreaterThan, + Value = 4, // 5 > 4 + }, + ], + }; + + filter.AcceptsEvent(CreateEventWithArrayData()).ShouldBeTrue(); + } + + [Fact] + public void NumberGreaterThan_WithArrayFiltering_NoElementMatches() + { + var filter = new FilterSetting + { + EnableAdvancedFilteringOnArrays = true, + AdvancedFilters = + [ + new AdvancedFilterSetting + { + Key = "Data.Numbers", + OperatorType = AdvancedFilterSetting + .AdvancedFilterOperatorType + .NumberGreaterThan, + Value = 10, // no element > 10 + }, + ], + }; + + filter.AcceptsEvent(CreateEventWithArrayData()).ShouldBeFalse(); + } + + [Fact] + public void StringNotIn_WithArrayFiltering_AllElementsMustNotMatch() + { + // For negation operators, ALL elements must satisfy the condition + var filter = new FilterSetting + { + EnableAdvancedFilteringOnArrays = true, + AdvancedFilters = + [ + new AdvancedFilterSetting + { + Key = "Data.Tags", + OperatorType = AdvancedFilterSetting.AdvancedFilterOperatorType.StringNotIn, + Values = ["nonexistent"], // none of the elements match "nonexistent" + }, + ], + }; + + filter.AcceptsEvent(CreateEventWithArrayData()).ShouldBeTrue(); + } + + [Fact] + public void StringNotIn_WithArrayFiltering_FailsIfAnyElementMatches() + { + // For negation operators, ALL elements must satisfy the condition + // If one element matches the "not in" list, the filter fails + var filter = new FilterSetting + { + EnableAdvancedFilteringOnArrays = true, + AdvancedFilters = + [ + new AdvancedFilterSetting + { + Key = "Data.Tags", + OperatorType = AdvancedFilterSetting.AdvancedFilterOperatorType.StringNotIn, + Values = ["important"], // "important" IS in the array + }, + ], + }; + + filter.AcceptsEvent(CreateEventWithArrayData()).ShouldBeFalse(); + } + + [Fact] + public void StringNotContains_WithArrayFiltering_AllElementsMustPass() + { + var filter = new FilterSetting + { + EnableAdvancedFilteringOnArrays = true, + AdvancedFilters = + [ + new AdvancedFilterSetting + { + Key = "Data.Tags", + OperatorType = AdvancedFilterSetting + .AdvancedFilterOperatorType + .StringNotContains, + Values = ["xyz"], // none of the elements contain "xyz" + }, + ], + }; + + filter.AcceptsEvent(CreateEventWithArrayData()).ShouldBeTrue(); + } + + [Fact] + public void ArrayFiltering_DisabledByDefault_TreatsArrayAsNonMatchingValue() + { + // When EnableAdvancedFilteringOnArrays is false (default), + // array values don't match string filters + var filter = new FilterSetting + { + EnableAdvancedFilteringOnArrays = false, + AdvancedFilters = + [ + new AdvancedFilterSetting + { + Key = "Data.Tags", + OperatorType = AdvancedFilterSetting.AdvancedFilterOperatorType.StringIn, + Values = ["important"], + }, + ], + }; + + filter.AcceptsEvent(CreateEventWithArrayData()).ShouldBeFalse(); + } + + [Fact] + public void ArrayFiltering_WorksWithNonArrayValues() + { + // Array filtering should not break normal scalar filtering + var filter = new FilterSetting + { + EnableAdvancedFilteringOnArrays = true, + AdvancedFilters = + [ + new AdvancedFilterSetting + { + Key = "Data.SingleValue", + OperatorType = AdvancedFilterSetting.AdvancedFilterOperatorType.StringIn, + Values = ["not-an-array"], + }, + ], + }; + + filter.AcceptsEvent(CreateEventWithArrayData()).ShouldBeTrue(); + } + + [Fact] + public void ArrayFiltering_MultipleFilters_AllMustPass() + { + var filter = new FilterSetting + { + EnableAdvancedFilteringOnArrays = true, + AdvancedFilters = + [ + new AdvancedFilterSetting + { + Key = "Data.Tags", + OperatorType = AdvancedFilterSetting.AdvancedFilterOperatorType.StringContains, + Values = ["important"], + }, + new AdvancedFilterSetting + { + Key = "Data.Numbers", + OperatorType = AdvancedFilterSetting.AdvancedFilterOperatorType.NumberIn, + Values = [1, 2, 3], + }, + ], + }; + + filter.AcceptsEvent(CreateEventWithArrayData()).ShouldBeTrue(); + } +} diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Filtering/SimpleFilterEventAcceptanceTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Filtering/SimpleFilterEventAcceptanceTests.cs index bdfc521..f97aeb1 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/Filtering/SimpleFilterEventAcceptanceTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Filtering/SimpleFilterEventAcceptanceTests.cs @@ -1,6 +1,6 @@ -using AzureEventGridSimulator.Domain.Entities; using AzureEventGridSimulator.Infrastructure.Extensions; using AzureEventGridSimulator.Infrastructure.Settings; +using AzureEventGridSimulator.Tests.UnitTests.Common; using Shouldly; using Xunit; @@ -13,7 +13,7 @@ public class SimpleFilterEventAcceptanceTests public void TestDefaultFilterSettingsAcceptsDefaultGridEvent() { var filterConfig = new FilterSetting(); - var gridEvent = new EventGridEvent(); + var gridEvent = TestHelpers.CreateValidEventGridEvent(); filterConfig.AcceptsEvent(gridEvent).ShouldBeTrue(); } @@ -22,10 +22,10 @@ public void TestDefaultFilterSettingsAcceptsDefaultGridEvent() [InlineData(null)] [InlineData([new[] { "All" }])] [InlineData([new[] { "This.is.a.test" }])] - public void TestEventTypeFilteringSuccess(string[] includedEventTypes) + public void TestEventTypeFilteringSuccess(string[]? includedEventTypes) { var filterConfig = new FilterSetting { IncludedEventTypes = includedEventTypes }; - var gridEvent = new EventGridEvent { EventType = "This.is.a.test" }; + var gridEvent = TestHelpers.CreateValidEventGridEvent(eventType: "This.is.a.test"); filterConfig.AcceptsEvent(gridEvent).ShouldBeTrue(); } @@ -40,7 +40,7 @@ public void TestEventTypeFilteringSuccess(string[] includedEventTypes) public void TestEventTypeFilteringFailure(string[] includedEventTypes) { var filterConfig = new FilterSetting { IncludedEventTypes = includedEventTypes }; - var gridEvent = new EventGridEvent { EventType = "This.is.a.test" }; + var gridEvent = TestHelpers.CreateValidEventGridEvent(eventType: "This.is.a.test"); filterConfig.AcceptsEvent(gridEvent).ShouldBeFalse(); } @@ -65,7 +65,11 @@ public void TestEventTypeFilteringFailure(string[] includedEventTypes) [InlineData(null, "_Subject", true)] [InlineData(null, "_subject", false)] [InlineData(null, "_SUBJECT", false)] - public void TestSubjectFilteringSuccess(string beginsWith, string endsWith, bool caseSensitive) + public void TestSubjectFilteringSuccess( + string? beginsWith, + string? endsWith, + bool caseSensitive + ) { var filterConfig = new FilterSetting { @@ -73,7 +77,7 @@ public void TestSubjectFilteringSuccess(string beginsWith, string endsWith, bool SubjectEndsWith = endsWith, IsSubjectCaseSensitive = caseSensitive, }; - var gridEvent = new EventGridEvent { Subject = "This_Is_A_Test_Subject" }; + var gridEvent = TestHelpers.CreateValidEventGridEvent(subject: "This_Is_A_Test_Subject"); filterConfig.AcceptsEvent(gridEvent).ShouldBeTrue(); } @@ -92,7 +96,11 @@ public void TestSubjectFilteringSuccess(string beginsWith, string endsWith, bool [InlineData(null, "this_is_a_test_subject", true)] [InlineData(null, "_subject", true)] [InlineData(null, "_SUBJECT", true)] - public void TestSubjectFilteringFailure(string beginsWith, string endsWith, bool caseSensitive) + public void TestSubjectFilteringFailure( + string? beginsWith, + string? endsWith, + bool caseSensitive + ) { var filterConfig = new FilterSetting { @@ -100,7 +108,7 @@ public void TestSubjectFilteringFailure(string beginsWith, string endsWith, bool SubjectEndsWith = endsWith, IsSubjectCaseSensitive = caseSensitive, }; - var gridEvent = new EventGridEvent { Subject = "This_Is_A_Test_Subject" }; + var gridEvent = TestHelpers.CreateValidEventGridEvent(subject: "This_Is_A_Test_Subject"); filterConfig.AcceptsEvent(gridEvent).ShouldBeFalse(); } diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Filtering/SimulatorEventFilterTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Filtering/SimulatorEventFilterTests.cs index 6228bcd..8021938 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/Filtering/SimulatorEventFilterTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Filtering/SimulatorEventFilterTests.cs @@ -171,7 +171,7 @@ public void GivenCombinedFilters_WhenOneFails_ThenRejected() [Fact] public void GivenNullFilter_WhenFilteringSimulatorEvent_ThenAccepted() { - FilterSetting filter = null; + FilterSetting? filter = null; var cloudEvent = new CloudEvent { SpecVersion = "1.0", @@ -181,6 +181,7 @@ public void GivenNullFilter_WhenFilteringSimulatorEvent_ThenAccepted() }; var simulatorEvent = SimulatorEvent.FromCloudEvent(cloudEvent); - filter.AcceptsEvent(simulatorEvent).ShouldBeTrue(); + // The extension method handles null by returning true (accepts all events) + (filter?.AcceptsEvent(simulatorEvent) ?? true).ShouldBeTrue(); } } diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Middleware/SasKeyValidatorAegSasKeyTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Middleware/SasKeyValidatorAegSasKeyTests.cs index 12ecd00..5df2ca0 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/Middleware/SasKeyValidatorAegSasKeyTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Middleware/SasKeyValidatorAegSasKeyTests.cs @@ -40,9 +40,9 @@ public void GivenInvalidAegSasKey_WhenValidated_ThenLogsError() .Log( LogLevel.Error, Arg.Any(), - Arg.Is(o => o.ToString().Contains("aeg-sas-key")), - Arg.Any(), - Arg.Any>() + Arg.Is(o => (o.ToString() ?? "").Contains("aeg-sas-key")), + Arg.Any(), + Arg.Any>() ); } diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Middleware/SasKeyValidatorAegSasTokenTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Middleware/SasKeyValidatorAegSasTokenTests.cs index 76c9a9b..dc17082 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/Middleware/SasKeyValidatorAegSasTokenTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Middleware/SasKeyValidatorAegSasTokenTests.cs @@ -70,9 +70,9 @@ public void GivenInvalidAegSasToken_WhenValidated_ThenLogsError() .Log( LogLevel.Error, Arg.Any(), - Arg.Is(o => o.ToString().Contains("aeg-sas-token")), - Arg.Any(), - Arg.Any>() + Arg.Is(o => (o.ToString() ?? "").Contains("aeg-sas-token")), + Arg.Any(), + Arg.Any>() ); } diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Middleware/SasKeyValidatorAuthorizationHeaderTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Middleware/SasKeyValidatorAuthorizationHeaderTests.cs index eb25f5f..70938ac 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/Middleware/SasKeyValidatorAuthorizationHeaderTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Middleware/SasKeyValidatorAuthorizationHeaderTests.cs @@ -83,9 +83,9 @@ public void GivenInvalidAuthorizationHeader_WhenValidated_ThenLogsError() .Log( LogLevel.Error, Arg.Any(), - Arg.Is(o => o.ToString().Contains("SharedAccessSignature")), - Arg.Any(), - Arg.Any>() + Arg.Is(o => (o.ToString() ?? "").Contains("SharedAccessSignature")), + Arg.Any(), + Arg.Any>() ); } diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Retry/DeadLetterServiceTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Retry/DeadLetterServiceTests.cs index 2a3a294..771486a 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/Retry/DeadLetterServiceTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Retry/DeadLetterServiceTests.cs @@ -35,7 +35,7 @@ public void Dispose() private static PendingDelivery CreatePendingDelivery( bool? deadLetterEnabled = true, string folderPath = "./dead-letters", - string eventId = null, + string? eventId = null, string topicName = "TestTopic", string subscriberName = "TestSubscriber" ) @@ -92,8 +92,8 @@ public async Task GivenEnabledDeadLetter_WhenWriting_ThenCreatesFile() var expectedFolder = Path.Combine( _tempFolder, - delivery.Topic.Name, - delivery.Subscriber.Name + delivery.Topic.Name!, + delivery.Subscriber.Name! ); Directory.Exists(expectedFolder).ShouldBeTrue(); @@ -127,22 +127,21 @@ public async Task GivenDelivery_WhenWriting_ThenFileContainsCorrectJson() var delivery = CreatePendingDelivery(true, _tempFolder); delivery.AttemptCount = 5; delivery.Attempts.Add( - new DeliveryAttempt - { - AttemptNumber = 5, - AttemptTime = DateTimeOffset.UtcNow, - Outcome = DeliveryOutcome.HttpError, - HttpStatusCode = 503, - ErrorMessage = "Service Unavailable", - } + new DeliveryAttempt( + 5, + DeliveryOutcome.HttpError, + DateTimeOffset.UtcNow, + 503, + "Service Unavailable" + ) ); await _service.WriteDeadLetterAsync(delivery, "MaxDeliveryAttemptsExceeded"); var expectedFolder = Path.Combine( _tempFolder, - delivery.Topic.Name, - delivery.Subscriber.Name + delivery.Topic.Name!, + delivery.Subscriber.Name! ); var files = Directory.GetFiles(expectedFolder, "*.json"); var content = await File.ReadAllTextAsync(files[0]); @@ -173,8 +172,8 @@ public async Task GivenEventGridEvent_WhenWriting_ThenEventPayloadIncluded() var expectedFolder = Path.Combine( _tempFolder, - delivery.Topic.Name, - delivery.Subscriber.Name + delivery.Topic.Name!, + delivery.Subscriber.Name! ); var files = Directory.GetFiles(expectedFolder, "*.json"); var content = await File.ReadAllTextAsync(files[0]); @@ -196,8 +195,8 @@ public async Task GivenDelivery_WhenWriting_ThenFileNameContainsTimestampAndEven var expectedFolder = Path.Combine( _tempFolder, - delivery.Topic.Name, - delivery.Subscriber.Name + delivery.Topic.Name!, + delivery.Subscriber.Name! ); var files = Directory.GetFiles(expectedFolder, "*.json"); var fileName = Path.GetFileName(files[0]); @@ -220,8 +219,8 @@ public async Task GivenEventIdWithInvalidChars_WhenWriting_ThenCreatesValidFile( var expectedFolder = Path.Combine( _tempFolder, - delivery.Topic.Name, - delivery.Subscriber.Name + delivery.Topic.Name!, + delivery.Subscriber.Name! ); var files = Directory.GetFiles(expectedFolder, "*.json"); files.Length.ShouldBe(1); // File was created successfully @@ -241,8 +240,8 @@ public async Task GivenVeryLongEventId_WhenWriting_ThenTruncatesFileName() var expectedFolder = Path.Combine( _tempFolder, - delivery.Topic.Name, - delivery.Subscriber.Name + delivery.Topic.Name!, + delivery.Subscriber.Name! ); var files = Directory.GetFiles(expectedFolder, "*.json"); files.Length.ShouldBe(1); @@ -280,8 +279,8 @@ public async Task GivenMultipleDeliveries_WhenWriting_ThenCreatesMultipleFiles() var expectedFolder = Path.Combine( _tempFolder, - delivery1.Topic.Name, - delivery1.Subscriber.Name + delivery1.Topic.Name!, + delivery1.Subscriber.Name! ); var files = Directory.GetFiles(expectedFolder, "*.json"); files.Length.ShouldBe(2); @@ -299,9 +298,9 @@ public async Task GivenSuccessfulWrite_WhenWriting_ThenLogsWarning() .Log( LogLevel.Warning, Arg.Any(), - Arg.Is(o => o.ToString().Contains("dead-lettered")), - Arg.Any(), - Arg.Any>() + Arg.Is(o => (o.ToString() ?? "").Contains("dead-lettered")), + Arg.Any(), + Arg.Any>() ); } @@ -317,9 +316,9 @@ public async Task GivenDisabledDeadLetter_WhenWriting_ThenLogsDebug() .Log( LogLevel.Debug, Arg.Any(), - Arg.Is(o => o.ToString().Contains("disabled")), - Arg.Any(), - Arg.Any>() + Arg.Is(o => (o.ToString() ?? "").Contains("disabled")), + Arg.Any(), + Arg.Any>() ); } } diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Retry/HttpEventDeliveryServiceTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Retry/HttpEventDeliveryServiceTests.cs index 8eea3fd..c95b3b9 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/Retry/HttpEventDeliveryServiceTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Retry/HttpEventDeliveryServiceTests.cs @@ -6,6 +6,7 @@ using AzureEventGridSimulator.Domain.Services.Delivery; using AzureEventGridSimulator.Infrastructure.Settings; using AzureEventGridSimulator.Infrastructure.Settings.Subscribers; +using AzureEventGridSimulator.Tests.UnitTests.Common; using NSubstitute; using Shouldly; using Xunit; @@ -149,13 +150,13 @@ public async Task GivenNonHttpSubscriber_WhenDelivering_ThenReturnsError() result.Success.ShouldBeFalse(); result.Outcome.ShouldBe(DeliveryOutcome.NetworkError); - result.ErrorMessage.ShouldContain("Invalid subscriber type"); + result.ErrorMessage.ShouldNotBeNullAnd().ShouldContain("Invalid subscriber type"); } [Fact] public async Task GivenMultipleAttempts_WhenDelivering_ThenIncludesDeliveryCountHeader() { - string capturedDeliveryCount = null; + string? capturedDeliveryCount = null; var httpClientFactory = CreateMockHttpClientFactory(captureHeaders: headers => { if (headers.TryGetValues(Constants.AegDeliveryCountHeader, out var values)) @@ -186,7 +187,7 @@ public async Task GivenNetworkError_WhenDelivering_ThenReturnsNetworkError() result.Success.ShouldBeFalse(); result.Outcome.ShouldBe(DeliveryOutcome.NetworkError); - result.ErrorMessage.ShouldContain("Connection refused"); + result.ErrorMessage.ShouldNotBeNullAnd().ShouldContain("Connection refused"); } [Fact] @@ -202,14 +203,14 @@ public async Task GivenDnsError_WhenDelivering_ThenReturnsNetworkError() result.Success.ShouldBeFalse(); result.Outcome.ShouldBe(DeliveryOutcome.NetworkError); - result.ErrorMessage.ShouldContain("No such host"); + result.ErrorMessage.ShouldNotBeNullAnd().ShouldContain("No such host"); } private IHttpClientFactory CreateMockHttpClientFactory( HttpStatusCode statusCode = HttpStatusCode.OK, - Exception throwException = null, - Action responseAction = null, - Action captureHeaders = null + Exception? throwException = null, + Action? responseAction = null, + Action? captureHeaders = null ) { var handler = new MockHttpMessageHandler( @@ -273,17 +274,17 @@ private static SimulatorEvent CreateTestEvent() private class MockHttpMessageHandler : HttpMessageHandler { - private readonly Action _captureHeaders; - private readonly Exception _exception; - private readonly Action _responseAction; + private readonly Action? _captureHeaders; + private readonly Exception? _exception; + private readonly Action? _responseAction; private readonly List _responses = []; private readonly HttpStatusCode _statusCode; public MockHttpMessageHandler( HttpStatusCode statusCode, - Exception exception = null, - Action responseAction = null, - Action captureHeaders = null + Exception? exception = null, + Action? responseAction = null, + Action? captureHeaders = null ) { _statusCode = statusCode; diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Retry/PendingDeliveryTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Retry/PendingDeliveryTests.cs index f3d83bd..15b112b 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/Retry/PendingDeliveryTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Retry/PendingDeliveryTests.cs @@ -18,7 +18,7 @@ private static PendingDelivery CreatePendingDelivery( DateTimeOffset? enqueuedTime = null ) { - RetryPolicySettings retryPolicy = null; + RetryPolicySettings? retryPolicy = null; if (retryEnabled.HasValue || ttlMinutes.HasValue || maxAttempts.HasValue) { @@ -211,13 +211,7 @@ public void GivenNoAttempts_WhenGettingLastAttempt_ThenReturnsNull() public void GivenOneAttempt_WhenGettingLastAttempt_ThenReturnsThatAttempt() { var delivery = CreatePendingDelivery(); - var attempt = new DeliveryAttempt - { - AttemptNumber = 1, - AttemptTime = FixedTime, - Outcome = DeliveryOutcome.HttpError, - HttpStatusCode = 500, - }; + var attempt = new DeliveryAttempt(1, DeliveryOutcome.HttpError, FixedTime, 500); delivery.Attempts.Add(attempt); delivery.LastAttempt.ShouldBe(attempt); @@ -227,26 +221,14 @@ public void GivenOneAttempt_WhenGettingLastAttempt_ThenReturnsThatAttempt() public void GivenMultipleAttempts_WhenGettingLastAttempt_ThenReturnsLast() { var delivery = CreatePendingDelivery(); - var attempt1 = new DeliveryAttempt - { - AttemptNumber = 1, - AttemptTime = FixedTime.AddMinutes(-10), - Outcome = DeliveryOutcome.HttpError, - HttpStatusCode = 500, - }; - var attempt2 = new DeliveryAttempt - { - AttemptNumber = 2, - AttemptTime = FixedTime.AddMinutes(-5), - Outcome = DeliveryOutcome.Timeout, - }; - var attempt3 = new DeliveryAttempt - { - AttemptNumber = 3, - AttemptTime = FixedTime, - Outcome = DeliveryOutcome.HttpError, - HttpStatusCode = 503, - }; + var attempt1 = new DeliveryAttempt( + 1, + DeliveryOutcome.HttpError, + FixedTime.AddMinutes(-10), + 500 + ); + var attempt2 = new DeliveryAttempt(2, DeliveryOutcome.Timeout, FixedTime.AddMinutes(-5)); + var attempt3 = new DeliveryAttempt(3, DeliveryOutcome.HttpError, FixedTime, 503); delivery.Attempts.Add(attempt1); delivery.Attempts.Add(attempt2); delivery.Attempts.Add(attempt3); diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Delivery/DeliveryPropertyResolverTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Delivery/DeliveryPropertyResolverTests.cs index 0050024..94bb063 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Delivery/DeliveryPropertyResolverTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Delivery/DeliveryPropertyResolverTests.cs @@ -185,7 +185,7 @@ public void ResolveProperty_DynamicInvalidTopLevelProperty_ShouldReturnNull() [Fact] public void ResolveProperty_WithNullSetting_ShouldReturnNull() { - var result = _resolver.ResolveProperty(null, CreateTestEvent()); + var result = _resolver.ResolveProperty(null!, CreateTestEvent()); result.ShouldBeNull(); } diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Delivery/EventHubEventDeliveryServiceTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Delivery/EventHubEventDeliveryServiceTests.cs index 4dbefe3..37173e6 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Delivery/EventHubEventDeliveryServiceTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Delivery/EventHubEventDeliveryServiceTests.cs @@ -3,6 +3,7 @@ using AzureEventGridSimulator.Domain.Services.Delivery; using AzureEventGridSimulator.Infrastructure.Settings; using AzureEventGridSimulator.Infrastructure.Settings.Subscribers; +using AzureEventGridSimulator.Tests.UnitTests.Common; using NSubstitute; using Shouldly; using Xunit; @@ -65,9 +66,9 @@ private static SimulatorEvent CreateTestEvent() } private static PendingDelivery CreatePendingDelivery( - EventHubSubscriberSettings subscriber = null, - SimulatorEvent evt = null, - TopicSettings topic = null + EventHubSubscriberSettings? subscriber = null, + SimulatorEvent? evt = null, + TopicSettings? topic = null ) { return new PendingDelivery @@ -82,15 +83,21 @@ private static PendingDelivery CreatePendingDelivery( [Fact] public async Task GivenDisabledSubscription_WhenDelivering_ThenReturnsEventHubError() { - var subscription = CreateValidSettings(); - subscription.Disabled = true; + var subscription = new EventHubSubscriberSettings + { + Name = "TestSubscriber", + ConnectionString = + "Endpoint=sb://my-namespace.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=abc123", + EventHubName = "my-event-hub", + Disabled = true, + }; var delivery = CreatePendingDelivery(subscription); var result = await _service.DeliverAsync(delivery, CancellationToken.None); result.Success.ShouldBeFalse(); result.Outcome.ShouldBe(DeliveryOutcome.EventHubError); - result.ErrorMessage.ShouldContain("disabled"); + result.ErrorMessage.ShouldNotBeNullAnd().ShouldContain("disabled"); } [Fact] @@ -116,7 +123,7 @@ public async Task GivenInvalidSubscriberType_WhenDelivering_ThenReturnsEventHubE result.Success.ShouldBeFalse(); result.Outcome.ShouldBe(DeliveryOutcome.EventHubError); - result.ErrorMessage.ShouldContain("Invalid subscriber type"); + result.ErrorMessage.ShouldNotBeNullAnd().ShouldContain("Invalid subscriber type"); } [Fact] @@ -143,8 +150,14 @@ public async Task GivenService_WhenDisposed_ThenResourcesCleanedUp() [Fact] public void GivenSubscriptionWithDeliverySchema_WhenConfigured_ThenSchemaIsUsed() { - var subscription = CreateValidSettings(); - subscription.DeliverySchema = EventSchema.CloudEventV1_0; + var subscription = new EventHubSubscriberSettings + { + Name = "TestSubscriber", + ConnectionString = + "Endpoint=sb://my-namespace.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=abc123", + EventHubName = "my-event-hub", + DeliverySchema = EventSchema.CloudEventV1_0, + }; subscription.DeliverySchema.ShouldBe(EventSchema.CloudEventV1_0); } diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Delivery/ServiceBusEventDeliveryServiceTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Delivery/ServiceBusEventDeliveryServiceTests.cs index e4c8335..3597fd5 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Delivery/ServiceBusEventDeliveryServiceTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Delivery/ServiceBusEventDeliveryServiceTests.cs @@ -82,8 +82,14 @@ private static SimulatorEvent CreateTestEvent() [Fact] public async Task GivenDisabledSubscription_WhenSending_ThenLogsWarningAndReturnsEarly() { - var subscription = CreateValidQueueSettings(); - subscription.Disabled = true; + var subscription = new ServiceBusSubscriberSettings + { + Name = "TestSubscriber", + ConnectionString = + "Endpoint=sb://my-namespace.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=abc123", + Queue = "my-queue", + Disabled = true, + }; var topic = CreateTopicSettings(); var evt = CreateTestEvent(); @@ -95,9 +101,9 @@ public async Task GivenDisabledSubscription_WhenSending_ThenLogsWarningAndReturn .Log( LogLevel.Warning, Arg.Any(), - Arg.Is(o => o.ToString().Contains("disabled")), - Arg.Any(), - Arg.Any>() + Arg.Is(o => (o.ToString() ?? "").Contains("disabled")), + Arg.Any(), + Arg.Any>() ); } @@ -145,8 +151,14 @@ public async Task GivenService_WhenDisposed_ThenResourcesCleanedUp() [Fact] public void GivenSubscriptionWithDeliverySchema_WhenConfigured_ThenSchemaIsUsed() { - var subscription = CreateValidQueueSettings(); - subscription.DeliverySchema = EventSchema.CloudEventV1_0; + var subscription = new ServiceBusSubscriberSettings + { + Name = "TestSubscriber", + ConnectionString = + "Endpoint=sb://my-namespace.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=abc123", + Queue = "my-queue", + DeliverySchema = EventSchema.CloudEventV1_0, + }; subscription.DeliverySchema.ShouldBe(EventSchema.CloudEventV1_0); } diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Delivery/StorageQueueEventDeliveryServiceTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Delivery/StorageQueueEventDeliveryServiceTests.cs index 52350ff..d2e35c4 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Delivery/StorageQueueEventDeliveryServiceTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Delivery/StorageQueueEventDeliveryServiceTests.cs @@ -66,8 +66,14 @@ private static SimulatorEvent CreateTestEvent() [Fact] public async Task GivenDisabledSubscription_WhenSending_ThenLogsWarningAndReturnsEarly() { - var subscription = CreateValidSettings(); - subscription.Disabled = true; + var subscription = new StorageQueueSubscriberSettings + { + Name = "TestSubscriber", + ConnectionString = + "DefaultEndpointsProtocol=https;AccountName=teststorage;AccountKey=abc123;EndpointSuffix=core.windows.net", + QueueName = "my-queue", + Disabled = true, + }; var topic = CreateTopicSettings(); var evt = CreateTestEvent(); @@ -79,9 +85,9 @@ public async Task GivenDisabledSubscription_WhenSending_ThenLogsWarningAndReturn .Log( LogLevel.Warning, Arg.Any(), - Arg.Is(o => o.ToString().Contains("disabled")), - Arg.Any(), - Arg.Any>() + Arg.Is(o => (o.ToString() ?? "").Contains("disabled")), + Arg.Any(), + Arg.Any>() ); } @@ -107,8 +113,14 @@ public async Task GivenService_WhenDisposed_ThenResourcesCleanedUp() [Fact] public void GivenSubscriptionWithDeliverySchema_WhenConfigured_ThenSchemaIsUsed() { - var subscription = CreateValidSettings(); - subscription.DeliverySchema = EventSchema.CloudEventV1_0; + var subscription = new StorageQueueSubscriberSettings + { + Name = "TestSubscriber", + ConnectionString = + "DefaultEndpointsProtocol=https;AccountName=teststorage;AccountKey=abc123;EndpointSuffix=core.windows.net", + QueueName = "my-queue", + DeliverySchema = EventSchema.CloudEventV1_0, + }; subscription.DeliverySchema.ShouldBe(EventSchema.CloudEventV1_0); } diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/SubscribersSettingsConverterTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/SubscribersSettingsConverterTests.cs index 7639130..4145f7d 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/SubscribersSettingsConverterTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/SubscribersSettingsConverterTests.cs @@ -1,6 +1,7 @@ using System.Text.Json; using AzureEventGridSimulator.Infrastructure.Settings; using AzureEventGridSimulator.Infrastructure.Settings.Subscribers; +using AzureEventGridSimulator.Tests.UnitTests.Common; using Shouldly; using Xunit; @@ -72,7 +73,11 @@ public void LegacyArrayFormat_WithMultipleSubscribers_ShouldDeserializeAll() var settings = JsonSerializer.Deserialize(json); - settings.Topics.First().Subscribers.HttpSubscribers.Count().ShouldBe(2); + settings + .ShouldNotBeNullAnd() + .Topics.First() + .Subscribers.HttpSubscribers.Count() + .ShouldBe(2); } [Fact] @@ -207,7 +212,10 @@ public void ServiceBusSubscriber_WithNamespaceCredentials_ShouldDeserialize() var settings = JsonSerializer.Deserialize(json); - var sbSub = settings.Topics.First().Subscribers.ServiceBusSubscribers.First(); + var sbSub = settings + .ShouldNotBeNullAnd() + .Topics.First() + .Subscribers.ServiceBusSubscribers.First(); sbSub.Namespace.ShouldBe("my-namespace"); sbSub.SharedAccessKeyName.ShouldBe("RootManageSharedAccessKey"); sbSub.SharedAccessKey.ShouldBe("abc123"); @@ -245,7 +253,10 @@ public void ServiceBusSubscriber_WithDeliveryProperties_ShouldDeserialize() var settings = JsonSerializer.Deserialize(json); - var sbSub = settings.Topics.First().Subscribers.ServiceBusSubscribers.First(); + var sbSub = settings + .ShouldNotBeNullAnd() + .Topics.First() + .Subscribers.ServiceBusSubscribers.First(); sbSub.Properties.ShouldNotBeNull(); sbSub.Properties.Count.ShouldBe(2); @@ -273,8 +284,9 @@ public void EmptySubscribersArray_ShouldDeserializeToEmptyLists() var settings = JsonSerializer.Deserialize(json); - settings.Topics.First().Subscribers.HttpSubscribers.ShouldBeEmpty(); - settings.Topics.First().Subscribers.ServiceBusSubscribers.ShouldBeEmpty(); + var validSettings = settings.ShouldNotBeNullAnd(); + validSettings.Topics.First().Subscribers.HttpSubscribers.ShouldBeEmpty(); + validSettings.Topics.First().Subscribers.ServiceBusSubscribers.ShouldBeEmpty(); } [Fact] @@ -294,8 +306,9 @@ public void EmptySubscribersObject_ShouldDeserializeToEmptyLists() var settings = JsonSerializer.Deserialize(json); - settings.Topics.First().Subscribers.HttpSubscribers.ShouldBeEmpty(); - settings.Topics.First().Subscribers.ServiceBusSubscribers.ShouldBeEmpty(); + var validSettings = settings.ShouldNotBeNullAnd(); + validSettings.Topics.First().Subscribers.HttpSubscribers.ShouldBeEmpty(); + validSettings.Topics.First().Subscribers.ServiceBusSubscribers.ShouldBeEmpty(); } [Fact] @@ -323,7 +336,7 @@ public void AllSubscribers_ShouldReturnCombinedList() var settings = JsonSerializer.Deserialize(json); - var allSubscribers = settings.Topics.First().Subscribers.All.ToList(); + var allSubscribers = settings.ShouldNotBeNullAnd().Topics.First().Subscribers.All.ToList(); allSubscribers.Count.ShouldBe(3); allSubscribers.Select(s => s.Name).ShouldBe(_expected); } @@ -385,9 +398,12 @@ public void LegacyFormat_WithFilter_ShouldDeserialize() var settings = JsonSerializer.Deserialize(json); - var httpSub = settings.Topics.First().Subscribers.HttpSubscribers.First(); + var httpSub = settings + .ShouldNotBeNullAnd() + .Topics.First() + .Subscribers.HttpSubscribers.First(); httpSub.Filter.ShouldNotBeNull(); - httpSub.Filter.IncludedEventTypes.ShouldContain("MyEvent"); + httpSub.Filter.IncludedEventTypes.ShouldNotBeNullAnd().ShouldContain("MyEvent"); httpSub.Filter.SubjectBeginsWith.ShouldBe("test/"); } diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/SubscribersSettingsDefaultTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/SubscribersSettingsDefaultTests.cs index 427be1c..790352c 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/SubscribersSettingsDefaultTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/SubscribersSettingsDefaultTests.cs @@ -16,14 +16,21 @@ public void GivenEmptySettings_WhenValidated_ThenNoException() } [Fact] - public void GivenDefaultSettings_ThenAllArraysAreEmpty() + public void GivenDefaultSettings_ThenAllArraysAreNull() { var settings = new SubscribersSettings(); - settings.Http.ShouldBeEmpty(); - settings.ServiceBus.ShouldBeEmpty(); - settings.StorageQueue.ShouldBeEmpty(); + settings.Http.ShouldBeNull(); + settings.ServiceBus.ShouldBeNull(); + settings.StorageQueue.ShouldBeNull(); + settings.EventHub.ShouldBeNull(); + + // Computed properties should still work correctly with null arrays settings.All.ShouldBeEmpty(); + settings.HttpSubscribers.ShouldBeEmpty(); + settings.ServiceBusSubscribers.ShouldBeEmpty(); + settings.StorageQueueSubscribers.ShouldBeEmpty(); + settings.EventHubSubscribers.ShouldBeEmpty(); settings.Any.ShouldBeFalse(); settings.Count.ShouldBe(0); } diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/SubscribersSettingsIndividualValidationTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/SubscribersSettingsIndividualValidationTests.cs index 5b4166f..f64b86a 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/SubscribersSettingsIndividualValidationTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/SubscribersSettingsIndividualValidationTests.cs @@ -32,9 +32,13 @@ public void GivenInvalidServiceBusSubscriber_WhenValidated_ThenThrowsException() [Fact] public void GivenInvalidStorageQueueSubscriber_WhenValidated_ThenThrowsException() { + // Missing ConnectionString - should fail validation var settings = new SubscribersSettings { - StorageQueue = [new StorageQueueSubscriberSettings { Name = "Test" }], + StorageQueue = + [ + new StorageQueueSubscriberSettings { Name = "Test", QueueName = "test-queue" }, + ], }; Should.Throw(() => settings.Validate()); diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Validation/EventHubSubscriberSettingsValidationTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Validation/EventHubSubscriberSettingsValidationTests.cs index 5bad15d..2627d9d 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Validation/EventHubSubscriberSettingsValidationTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Validation/EventHubSubscriberSettingsValidationTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using AzureEventGridSimulator.Infrastructure.Settings; using AzureEventGridSimulator.Infrastructure.Settings.Subscribers; using Shouldly; @@ -50,19 +51,28 @@ public void Validate_WithNamespaceCredentialsAndEventHubName_ShouldPass() [Fact] public void Validate_WithoutName_ShouldThrow() { - var settings = CreateValidConnectionStringSettings(); - settings.Name = null; - - var exception = Should.Throw(() => settings.Validate()); - exception.ParamName.ShouldBe("Name"); - exception.Message.ShouldContain("name is required"); + var json = """ + { + "connectionString": "Endpoint=sb://my-namespace.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=abc123", + "eventHubName": "my-event-hub" + } + """; + + Should.Throw(() => + JsonSerializer.Deserialize(json) + ); } [Fact] public void Validate_WithEmptyName_ShouldThrow() { - var settings = CreateValidConnectionStringSettings(); - settings.Name = " "; + var settings = new EventHubSubscriberSettings + { + Name = " ", + ConnectionString = + "Endpoint=sb://my-namespace.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=abc123", + EventHubName = "my-event-hub", + }; var exception = Should.Throw(() => settings.Validate()); exception.ParamName.ShouldBe("Name"); @@ -71,18 +81,28 @@ public void Validate_WithEmptyName_ShouldThrow() [Fact] public void Validate_WithoutEventHubName_ShouldThrow() { - var settings = CreateValidConnectionStringSettings(); - settings.EventHubName = null; - - var exception = Should.Throw(() => settings.Validate()); - exception.Message.ShouldContain("eventHubName"); + var json = """ + { + "name": "TestSubscriber", + "connectionString": "Endpoint=sb://my-namespace.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=abc123" + } + """; + + Should.Throw(() => + JsonSerializer.Deserialize(json) + ); } [Fact] public void Validate_WithEmptyEventHubName_ShouldThrow() { - var settings = CreateValidConnectionStringSettings(); - settings.EventHubName = " "; + var settings = new EventHubSubscriberSettings + { + Name = "TestSubscriber", + ConnectionString = + "Endpoint=sb://my-namespace.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=abc123", + EventHubName = " ", + }; var exception = Should.Throw(() => settings.Validate()); exception.Message.ShouldContain("eventHubName"); @@ -105,10 +125,16 @@ public void Validate_WithoutAuthenticationSettings_ShouldThrow() [Fact] public void Validate_WithBothConnectionStringAndNamespaceCredentials_ShouldThrow() { - var settings = CreateValidConnectionStringSettings(); - settings.Namespace = "my-namespace"; - settings.SharedAccessKeyName = "RootManageSharedAccessKey"; - settings.SharedAccessKey = "abc123"; + var settings = new EventHubSubscriberSettings + { + Name = "TestSubscriber", + ConnectionString = + "Endpoint=sb://my-namespace.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=abc123", + Namespace = "my-namespace", + SharedAccessKeyName = "RootManageSharedAccessKey", + SharedAccessKey = "abc123", + EventHubName = "my-event-hub", + }; var exception = Should.Throw(() => settings.Validate()); exception.Message.ShouldContain("not both"); @@ -132,11 +158,17 @@ public void Validate_WithIncompleteNamespaceCredentials_ShouldThrow() [Fact] public void Validate_WithValidDeliveryProperties_ShouldPass() { - var settings = CreateValidConnectionStringSettings(); - settings.Properties = new Dictionary + var settings = new EventHubSubscriberSettings { - ["Label"] = new() { Type = "dynamic", Value = "Subject" }, - ["Region"] = new() { Type = "static", Value = "west-us" }, + Name = "TestSubscriber", + ConnectionString = + "Endpoint=sb://my-namespace.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=abc123", + EventHubName = "my-event-hub", + Properties = new Dictionary + { + ["Label"] = new() { Type = "dynamic", Value = "Subject" }, + ["Region"] = new() { Type = "static", Value = "west-us" }, + }, }; Should.NotThrow(() => settings.Validate()); @@ -145,10 +177,16 @@ public void Validate_WithValidDeliveryProperties_ShouldPass() [Fact] public void Validate_WithInvalidPropertyType_ShouldThrow() { - var settings = CreateValidConnectionStringSettings(); - settings.Properties = new Dictionary + var settings = new EventHubSubscriberSettings { - ["Label"] = new() { Type = "invalid", Value = "Subject" }, + Name = "TestSubscriber", + ConnectionString = + "Endpoint=sb://my-namespace.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=abc123", + EventHubName = "my-event-hub", + Properties = new Dictionary + { + ["Label"] = new() { Type = "invalid", Value = "Subject" }, + }, }; var exception = Should.Throw(() => settings.Validate()); @@ -160,10 +198,16 @@ public void Validate_WithInvalidPropertyType_ShouldThrow() [Fact] public void Validate_WithEmptyPropertyType_ShouldThrow() { - var settings = CreateValidConnectionStringSettings(); - settings.Properties = new Dictionary + var settings = new EventHubSubscriberSettings { - ["Label"] = new() { Type = "", Value = "Subject" }, + Name = "TestSubscriber", + ConnectionString = + "Endpoint=sb://my-namespace.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=abc123", + EventHubName = "my-event-hub", + Properties = new Dictionary + { + ["Label"] = new() { Type = "", Value = "Subject" }, + }, }; var exception = Should.Throw(() => settings.Validate()); @@ -174,10 +218,16 @@ public void Validate_WithEmptyPropertyType_ShouldThrow() [Fact] public void Validate_WithEmptyPropertyValue_ShouldThrow() { - var settings = CreateValidConnectionStringSettings(); - settings.Properties = new Dictionary + var settings = new EventHubSubscriberSettings { - ["Label"] = new() { Type = "static", Value = "" }, + Name = "TestSubscriber", + ConnectionString = + "Endpoint=sb://my-namespace.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=abc123", + EventHubName = "my-event-hub", + Properties = new Dictionary + { + ["Label"] = new() { Type = "static", Value = "" }, + }, }; var exception = Should.Throw(() => settings.Validate()); @@ -209,6 +259,8 @@ public void EffectiveConnectionString_WithTopicLevelConnectionString_ShouldFallb var parentTopic = new TopicSettings { Name = "test-topic", + Port = 60101, + Key = "TestKey", EventHubConnectionString = "Endpoint=sb://topic-namespace.servicebus.windows.net/;SharedAccessKeyName=TopicKey;SharedAccessKey=topicabc123", }; @@ -229,6 +281,8 @@ public void EffectiveConnectionString_WithTopicLevelNamespaceCredentials_ShouldF var parentTopic = new TopicSettings { Name = "test-topic", + Port = 60101, + Key = "TestKey", EventHubNamespace = "topic-namespace", EventHubSharedAccessKeyName = "TopicKey", EventHubSharedAccessKey = "topicabc123", @@ -257,11 +311,17 @@ public void SubscriberType_ShouldReturnEventHub() [Fact] public void Validate_WithFilter_ShouldValidateFilter() { - var settings = CreateValidConnectionStringSettings(); - settings.Filter = new FilterSetting + var settings = new EventHubSubscriberSettings { - IncludedEventTypes = new List { "MyEvent" }, - SubjectBeginsWith = "test/", + Name = "TestSubscriber", + ConnectionString = + "Endpoint=sb://my-namespace.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=abc123", + EventHubName = "my-event-hub", + Filter = new FilterSetting + { + IncludedEventTypes = new List { "MyEvent" }, + SubjectBeginsWith = "test/", + }, }; Should.NotThrow(() => settings.Validate()); @@ -273,6 +333,8 @@ public void Validate_WithTopicLevelConnectionString_ShouldPass() var parentTopic = new TopicSettings { Name = "test-topic", + Port = 60101, + Key = "TestKey", EventHubConnectionString = "Endpoint=sb://topic-namespace.servicebus.windows.net/;SharedAccessKeyName=TopicKey;SharedAccessKey=topicabc123", }; diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Validation/HttpSubscriberSettingsEndpointValidationTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Validation/HttpSubscriberSettingsEndpointValidationTests.cs index d8f3045..dfafd25 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Validation/HttpSubscriberSettingsEndpointValidationTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Validation/HttpSubscriberSettingsEndpointValidationTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using AzureEventGridSimulator.Infrastructure.Settings; using AzureEventGridSimulator.Infrastructure.Settings.Subscribers; using Shouldly; @@ -9,12 +10,11 @@ namespace AzureEventGridSimulator.Tests.UnitTests.Subscribers.Validation; public class HttpSubscriberSettingsEndpointValidationTests { [Fact] - public void GivenMissingEndpoint_WhenValidated_ThenThrowsException() + public void GivenMissingEndpoint_WhenDeserialized_ThenThrowsException() { - var settings = new HttpSubscriberSettings { Name = "TestSubscriber", Endpoint = null }; + var json = """{ "name": "TestSubscriber" }"""; - var exception = Should.Throw(() => settings.Validate()); - exception.Message.ShouldContain("Endpoint"); + Should.Throw(() => JsonSerializer.Deserialize(json)); } [Fact] diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Validation/HttpSubscriberSettingsNameValidationTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Validation/HttpSubscriberSettingsNameValidationTests.cs index a352b6a..508e150 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Validation/HttpSubscriberSettingsNameValidationTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Validation/HttpSubscriberSettingsNameValidationTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using AzureEventGridSimulator.Infrastructure.Settings.Subscribers; using Shouldly; using Xunit; @@ -8,12 +9,11 @@ namespace AzureEventGridSimulator.Tests.UnitTests.Subscribers.Validation; public class HttpSubscriberSettingsNameValidationTests { [Fact] - public void GivenMissingName_WhenValidated_ThenThrowsException() + public void GivenMissingName_WhenDeserialized_ThenThrowsException() { - var settings = new HttpSubscriberSettings { Name = null, Endpoint = "https://example.com" }; + var json = """{ "endpoint": "https://example.com" }"""; - var exception = Should.Throw(() => settings.Validate()); - exception.Message.ShouldContain("name"); + Should.Throw(() => JsonSerializer.Deserialize(json)); } [Fact] diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Validation/ServiceBusSubscriberSettingsValidationTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Validation/ServiceBusSubscriberSettingsValidationTests.cs index d81cf30..0d1f949 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Validation/ServiceBusSubscriberSettingsValidationTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Validation/ServiceBusSubscriberSettingsValidationTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using AzureEventGridSimulator.Infrastructure.Settings; using AzureEventGridSimulator.Infrastructure.Settings.Subscribers; using Shouldly; @@ -8,18 +9,25 @@ namespace AzureEventGridSimulator.Tests.UnitTests.Subscribers.Validation; [Trait("Category", "unit")] public class ServiceBusSubscriberSettingsValidationTests { - private static ServiceBusSubscriberSettings CreateValidConnectionStringSettings() + private static ServiceBusSubscriberSettings CreateValidConnectionStringSettings( + string? queue = "my-queue", + string? topic = null + ) { return new ServiceBusSubscriberSettings { Name = "TestSubscriber", ConnectionString = "Endpoint=sb://my-namespace.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=abc123", - Queue = "my-queue", + Queue = queue, + Topic = topic, }; } - private static ServiceBusSubscriberSettings CreateValidNamespaceSettings() + private static ServiceBusSubscriberSettings CreateValidNamespaceSettings( + string? queue = "my-queue", + string? topic = null + ) { return new ServiceBusSubscriberSettings { @@ -27,7 +35,8 @@ private static ServiceBusSubscriberSettings CreateValidNamespaceSettings() Namespace = "my-namespace", SharedAccessKeyName = "RootManageSharedAccessKey", SharedAccessKey = "abc123", - Queue = "my-queue", + Queue = queue, + Topic = topic, }; } @@ -42,9 +51,7 @@ public void Validate_WithConnectionStringAndQueue_ShouldPass() [Fact] public void Validate_WithConnectionStringAndTopic_ShouldPass() { - var settings = CreateValidConnectionStringSettings(); - settings.Queue = null; - settings.Topic = "my-topic"; + var settings = CreateValidConnectionStringSettings(null, "my-topic"); Should.NotThrow(() => settings.Validate()); } @@ -60,9 +67,7 @@ public void Validate_WithNamespaceCredentialsAndQueue_ShouldPass() [Fact] public void Validate_WithNamespaceCredentialsAndTopic_ShouldPass() { - var settings = CreateValidNamespaceSettings(); - settings.Queue = null; - settings.Topic = "my-topic"; + var settings = CreateValidNamespaceSettings(null, "my-topic"); Should.NotThrow(() => settings.Validate()); } @@ -70,19 +75,28 @@ public void Validate_WithNamespaceCredentialsAndTopic_ShouldPass() [Fact] public void Validate_WithoutName_ShouldThrow() { - var settings = CreateValidConnectionStringSettings(); - settings.Name = null; - - var exception = Should.Throw(() => settings.Validate()); - exception.ParamName.ShouldBe("Name"); - exception.Message.ShouldContain("name is required"); + var json = """ + { + "connectionString": "Endpoint=sb://my-namespace.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=abc123", + "queue": "my-queue" + } + """; + + Should.Throw(() => + JsonSerializer.Deserialize(json) + ); } [Fact] public void Validate_WithEmptyName_ShouldThrow() { - var settings = CreateValidConnectionStringSettings(); - settings.Name = " "; + var settings = new ServiceBusSubscriberSettings + { + Name = " ", + ConnectionString = + "Endpoint=sb://my-namespace.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=abc123", + Queue = "my-queue", + }; var exception = Should.Throw(() => settings.Validate()); exception.ParamName.ShouldBe("Name"); @@ -105,10 +119,16 @@ public void Validate_WithoutAuthenticationSettings_ShouldThrow() [Fact] public void Validate_WithBothConnectionStringAndNamespaceCredentials_ShouldThrow() { - var settings = CreateValidConnectionStringSettings(); - settings.Namespace = "my-namespace"; - settings.SharedAccessKeyName = "RootManageSharedAccessKey"; - settings.SharedAccessKey = "abc123"; + var settings = new ServiceBusSubscriberSettings + { + Name = "TestSubscriber", + ConnectionString = + "Endpoint=sb://my-namespace.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=abc123", + Namespace = "my-namespace", + SharedAccessKeyName = "RootManageSharedAccessKey", + SharedAccessKey = "abc123", + Queue = "my-queue", + }; var exception = Should.Throw(() => settings.Validate()); exception.Message.ShouldContain("not both"); @@ -132,9 +152,14 @@ public void Validate_WithIncompleteNamespaceCredentials_ShouldThrow() [Fact] public void Validate_WithoutDestination_ShouldThrow() { - var settings = CreateValidConnectionStringSettings(); - settings.Queue = null; - settings.Topic = null; + var settings = new ServiceBusSubscriberSettings + { + Name = "TestSubscriber", + ConnectionString = + "Endpoint=sb://my-namespace.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=abc123", + Queue = null, + Topic = null, + }; var exception = Should.Throw(() => settings.Validate()); exception.Message.ShouldContain("topic or queue"); @@ -143,8 +168,14 @@ public void Validate_WithoutDestination_ShouldThrow() [Fact] public void Validate_WithBothQueueAndTopic_ShouldThrow() { - var settings = CreateValidConnectionStringSettings(); - settings.Topic = "my-topic"; + var settings = new ServiceBusSubscriberSettings + { + Name = "TestSubscriber", + ConnectionString = + "Endpoint=sb://my-namespace.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=abc123", + Queue = "my-queue", + Topic = "my-topic", + }; var exception = Should.Throw(() => settings.Validate()); exception.Message.ShouldContain("not both"); @@ -153,11 +184,17 @@ public void Validate_WithBothQueueAndTopic_ShouldThrow() [Fact] public void Validate_WithValidDeliveryProperties_ShouldPass() { - var settings = CreateValidConnectionStringSettings(); - settings.Properties = new Dictionary + var settings = new ServiceBusSubscriberSettings { - ["Label"] = new() { Type = "dynamic", Value = "Subject" }, - ["Region"] = new() { Type = "static", Value = "west-us" }, + Name = "TestSubscriber", + ConnectionString = + "Endpoint=sb://my-namespace.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=abc123", + Queue = "my-queue", + Properties = new Dictionary + { + ["Label"] = new() { Type = "dynamic", Value = "Subject" }, + ["Region"] = new() { Type = "static", Value = "west-us" }, + }, }; Should.NotThrow(() => settings.Validate()); @@ -166,10 +203,16 @@ public void Validate_WithValidDeliveryProperties_ShouldPass() [Fact] public void Validate_WithInvalidPropertyType_ShouldThrow() { - var settings = CreateValidConnectionStringSettings(); - settings.Properties = new Dictionary + var settings = new ServiceBusSubscriberSettings { - ["Label"] = new() { Type = "invalid", Value = "Subject" }, + Name = "TestSubscriber", + ConnectionString = + "Endpoint=sb://my-namespace.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=abc123", + Queue = "my-queue", + Properties = new Dictionary + { + ["Label"] = new() { Type = "invalid", Value = "Subject" }, + }, }; var exception = Should.Throw(() => settings.Validate()); @@ -181,10 +224,16 @@ public void Validate_WithInvalidPropertyType_ShouldThrow() [Fact] public void Validate_WithEmptyPropertyType_ShouldThrow() { - var settings = CreateValidConnectionStringSettings(); - settings.Properties = new Dictionary + var settings = new ServiceBusSubscriberSettings { - ["Label"] = new() { Type = "", Value = "Subject" }, + Name = "TestSubscriber", + ConnectionString = + "Endpoint=sb://my-namespace.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=abc123", + Queue = "my-queue", + Properties = new Dictionary + { + ["Label"] = new() { Type = "", Value = "Subject" }, + }, }; var exception = Should.Throw(() => settings.Validate()); @@ -195,10 +244,16 @@ public void Validate_WithEmptyPropertyType_ShouldThrow() [Fact] public void Validate_WithEmptyPropertyValue_ShouldThrow() { - var settings = CreateValidConnectionStringSettings(); - settings.Properties = new Dictionary + var settings = new ServiceBusSubscriberSettings { - ["Label"] = new() { Type = "static", Value = "" }, + Name = "TestSubscriber", + ConnectionString = + "Endpoint=sb://my-namespace.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=abc123", + Queue = "my-queue", + Properties = new Dictionary + { + ["Label"] = new() { Type = "static", Value = "" }, + }, }; var exception = Should.Throw(() => settings.Validate()); @@ -209,9 +264,7 @@ public void Validate_WithEmptyPropertyValue_ShouldThrow() [Fact] public void IsTopic_WithTopicSet_ShouldReturnTrue() { - var settings = CreateValidConnectionStringSettings(); - settings.Queue = null; - settings.Topic = "my-topic"; + var settings = CreateValidConnectionStringSettings(null, "my-topic"); settings.IsTopic.ShouldBeTrue(); } @@ -227,9 +280,7 @@ public void IsTopic_WithQueueSet_ShouldReturnFalse() [Fact] public void DestinationName_WithTopic_ShouldReturnTopicName() { - var settings = CreateValidConnectionStringSettings(); - settings.Queue = null; - settings.Topic = "my-topic"; + var settings = CreateValidConnectionStringSettings(null, "my-topic"); settings.DestinationName.ShouldBe("my-topic"); } @@ -271,11 +322,17 @@ public void SubscriberType_ShouldReturnServiceBus() [Fact] public void Validate_WithFilter_ShouldValidateFilter() { - var settings = CreateValidConnectionStringSettings(); - settings.Filter = new FilterSetting + var settings = new ServiceBusSubscriberSettings { - IncludedEventTypes = new List { "MyEvent" }, - SubjectBeginsWith = "test/", + Name = "TestSubscriber", + ConnectionString = + "Endpoint=sb://my-namespace.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=abc123", + Queue = "my-queue", + Filter = new FilterSetting + { + IncludedEventTypes = new List { "MyEvent" }, + SubjectBeginsWith = "test/", + }, }; Should.NotThrow(() => settings.Validate()); diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Validation/StorageQueueSubscriberSettingsValidationTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Validation/StorageQueueSubscriberSettingsValidationTests.cs index e0890fb..801b574 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Validation/StorageQueueSubscriberSettingsValidationTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Validation/StorageQueueSubscriberSettingsValidationTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using AzureEventGridSimulator.Infrastructure.Settings; using AzureEventGridSimulator.Infrastructure.Settings.Subscribers; using Shouldly; @@ -56,18 +57,28 @@ public void Validate_WithTopicLevelConnectionString_ShouldPass() [Fact] public void Validate_WithoutName_ShouldThrow() { - var settings = CreateValidSettings(); - settings.Name = null; + var json = """ + { + "connectionString": "DefaultEndpointsProtocol=https;AccountName=teststorage;AccountKey=abc123;EndpointSuffix=core.windows.net", + "queueName": "my-queue" + } + """; - var exception = Should.Throw(() => settings.Validate()); - exception.Message.ShouldContain("name"); + Should.Throw(() => + JsonSerializer.Deserialize(json) + ); } [Fact] public void Validate_WithEmptyName_ShouldThrow() { - var settings = CreateValidSettings(); - settings.Name = " "; + var settings = new StorageQueueSubscriberSettings + { + Name = " ", + ConnectionString = + "DefaultEndpointsProtocol=https;AccountName=teststorage;AccountKey=abc123;EndpointSuffix=core.windows.net", + QueueName = "my-queue", + }; var exception = Should.Throw(() => settings.Validate()); exception.Message.ShouldContain("name"); @@ -90,18 +101,28 @@ public void Validate_WithoutConnectionString_ShouldThrow() [Fact] public void Validate_WithoutQueueName_ShouldThrow() { - var settings = CreateValidSettings(); - settings.QueueName = null; + var json = """ + { + "name": "TestSubscriber", + "connectionString": "DefaultEndpointsProtocol=https;AccountName=teststorage;AccountKey=abc123;EndpointSuffix=core.windows.net" + } + """; - var exception = Should.Throw(() => settings.Validate()); - exception.Message.ShouldContain("queueName"); + Should.Throw(() => + JsonSerializer.Deserialize(json) + ); } [Fact] public void Validate_WithEmptyQueueName_ShouldThrow() { - var settings = CreateValidSettings(); - settings.QueueName = " "; + var settings = new StorageQueueSubscriberSettings + { + Name = "TestSubscriber", + ConnectionString = + "DefaultEndpointsProtocol=https;AccountName=teststorage;AccountKey=abc123;EndpointSuffix=core.windows.net", + QueueName = " ", + }; var exception = Should.Throw(() => settings.Validate()); exception.Message.ShouldContain("queueName"); @@ -110,11 +131,17 @@ public void Validate_WithEmptyQueueName_ShouldThrow() [Fact] public void Validate_WithValidFilter_ShouldPass() { - var settings = CreateValidSettings(); - settings.Filter = new FilterSetting + var settings = new StorageQueueSubscriberSettings { - IncludedEventTypes = new List { "MyEvent" }, - SubjectBeginsWith = "test/", + Name = "TestSubscriber", + ConnectionString = + "DefaultEndpointsProtocol=https;AccountName=teststorage;AccountKey=abc123;EndpointSuffix=core.windows.net", + QueueName = "my-queue", + Filter = new FilterSetting + { + IncludedEventTypes = new List { "MyEvent" }, + SubjectBeginsWith = "test/", + }, }; Should.NotThrow(() => settings.Validate()); @@ -172,8 +199,14 @@ public void Disabled_DefaultValue_ShouldBeFalse() [Fact] public void Disabled_WhenSetToTrue_ShouldBeTrue() { - var settings = CreateValidSettings(); - settings.Disabled = true; + var settings = new StorageQueueSubscriberSettings + { + Name = "TestSubscriber", + ConnectionString = + "DefaultEndpointsProtocol=https;AccountName=teststorage;AccountKey=abc123;EndpointSuffix=core.windows.net", + QueueName = "my-queue", + Disabled = true, + }; settings.Disabled.ShouldBeTrue(); } diff --git a/src/AzureEventGridSimulator/Controllers/NotificationController.cs b/src/AzureEventGridSimulator/Controllers/NotificationController.cs index a12ff99..bb0d0ad 100644 --- a/src/AzureEventGridSimulator/Controllers/NotificationController.cs +++ b/src/AzureEventGridSimulator/Controllers/NotificationController.cs @@ -21,9 +21,20 @@ public async Task Post() t.Port == HttpContext.Request.Host.Port ); - // Events are parsed and validated in the middleware - var events = (SimulatorEvent[])HttpContext.Items["ParsedEvents"]; - var detectedSchema = (EventSchema)HttpContext.Items["DetectedSchema"]; + // Events are parsed and validated by EventParsingMiddleware + if (HttpContext.Items["ParsedEvents"] is not SimulatorEvent[] events) + { + throw new InvalidOperationException( + "ParsedEvents not found in HttpContext. Ensure EventParsingMiddleware is configured." + ); + } + + if (HttpContext.Items["DetectedSchema"] is not EventSchema detectedSchema) + { + throw new InvalidOperationException( + "DetectedSchema not found in HttpContext. Ensure EventParsingMiddleware is configured." + ); + } await mediator.Send( new SendNotificationEventsToSubscriberCommand( diff --git a/src/AzureEventGridSimulator/Controllers/SubscriptionValidationController.cs b/src/AzureEventGridSimulator/Controllers/SubscriptionValidationController.cs index f29d650..3329ea4 100644 --- a/src/AzureEventGridSimulator/Controllers/SubscriptionValidationController.cs +++ b/src/AzureEventGridSimulator/Controllers/SubscriptionValidationController.cs @@ -33,7 +33,8 @@ public async Task Get(Guid id) new ErrorMessage( HttpStatusCode.BadRequest, "The validation code was not correct.", - null + null, + ErrorDetailCodes.InputJsonInvalid ) ); } diff --git a/src/AzureEventGridSimulator/Domain/Commands/SendNotificationEventsToSubscriberCommandHandler.cs b/src/AzureEventGridSimulator/Domain/Commands/SendNotificationEventsToSubscriberCommandHandler.cs index bd3a8dc..e4454d9 100644 --- a/src/AzureEventGridSimulator/Domain/Commands/SendNotificationEventsToSubscriberCommandHandler.cs +++ b/src/AzureEventGridSimulator/Domain/Commands/SendNotificationEventsToSubscriberCommandHandler.cs @@ -71,7 +71,9 @@ CancellationToken cancellationToken // Log events that are filtered out by all subscribers var eventsFilteredOutByAllSubscribers = request - .Events.Where(e => allSubscribers.All(s => !s.Filter.AcceptsEvent(e))) + .Events.Where(e => + allSubscribers.All(s => !(s.Filter ?? new FilterSetting()).AcceptsEvent(e)) + ) .ToArray(); foreach (var filteredEvent in eventsFilteredOutByAllSubscribers) @@ -116,7 +118,7 @@ subscriber is HttpSubscriberSettings httpSubscriber foreach (var evt in request.Events) { - if (!subscriber.Filter.AcceptsEvent(evt)) + if (!(subscriber.Filter ?? new FilterSetting()).AcceptsEvent(evt)) { logger.LogDebug( "Event {EventId} filtered out for subscriber '{SubscriberName}'", diff --git a/src/AzureEventGridSimulator/Domain/Constants.cs b/src/AzureEventGridSimulator/Domain/Constants.cs index 632eeb7..726e620 100644 --- a/src/AzureEventGridSimulator/Domain/Constants.cs +++ b/src/AzureEventGridSimulator/Domain/Constants.cs @@ -15,8 +15,11 @@ public static class Constants public const string NotificationEventType = "Notification"; public const string ValidationEventType = "SubscriptionValidation"; - // Other + // API Versions + // Note: Custom Topics only support 2018-01-01 + // Newer versions (2023-11-01, 2024-01-01, 2024-06-01) are for Namespace Topics only public const string SupportedApiVersion = "2018-01-01"; + public const string SasAuthorizationType = "SharedAccessSignature"; // CloudEvents Headers (binary mode) diff --git a/src/AzureEventGridSimulator/Domain/Entities/CloudEvent.cs b/src/AzureEventGridSimulator/Domain/Entities/CloudEvent.cs index af18a15..cbcf31e 100644 --- a/src/AzureEventGridSimulator/Domain/Entities/CloudEvent.cs +++ b/src/AzureEventGridSimulator/Domain/Entities/CloudEvent.cs @@ -8,65 +8,66 @@ namespace AzureEventGridSimulator.Domain.Entities; /// public class CloudEvent { + private const string SchemaName = "CloudEventV10"; + /// /// Gets or sets the CloudEvents specification version (required). - /// Must be "1.0". /// [JsonPropertyName("specversion")] - public string SpecVersion { get; set; } + public required string SpecVersion { get; set; } /// /// Gets or sets the event type (required). /// [JsonPropertyName("type")] - public string Type { get; set; } + public required string Type { get; set; } /// /// Gets or sets the event source URI (required). /// [JsonPropertyName("source")] - public string Source { get; set; } + public required string Source { get; set; } /// /// Gets or sets the unique event identifier (required). /// [JsonPropertyName("id")] - public string Id { get; set; } + public required string Id { get; set; } /// /// Gets or sets the event timestamp in RFC 3339 format (optional). /// [JsonPropertyName("time")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Time { get; set; } + public string? Time { get; set; } /// /// Gets or sets the subject of the event (optional). /// [JsonPropertyName("subject")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string Subject { get; set; } + public string? Subject { get; set; } /// /// Gets or sets the content type of the data attribute (optional). /// [JsonPropertyName("datacontenttype")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string DataContentType { get; set; } + public string? DataContentType { get; set; } /// /// Gets or sets a URI reference to the schema for the data attribute (optional). /// [JsonPropertyName("dataschema")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string DataSchema { get; set; } + public string? DataSchema { get; set; } /// /// Gets or sets the event payload (optional). /// [JsonPropertyName("data")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public object Data { get; set; } + public object? Data { get; set; } /// /// Gets or sets the base64-encoded binary event payload (optional). @@ -74,7 +75,7 @@ public class CloudEvent /// [JsonPropertyName("data_base64")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string DataBase64 { get; set; } + public string? DataBase64 { get; set; } [JsonIgnore] private DateTimeOffset? TimeParsed => @@ -96,79 +97,46 @@ public class CloudEvent /// /// Validate the CloudEvent according to the CloudEvents v1.0 specification. + /// Required properties (SpecVersion, Type, Source, Id) are enforced by the 'required' modifier + /// for presence, but this method validates they are non-empty and have valid formats. /// /// /// Thrown if validation fails /// public void Validate() { - // Required: specversion - if (string.IsNullOrWhiteSpace(SpecVersion)) - { - throw new InvalidOperationException( - $"Required property '{nameof(SpecVersion)}' was not set." - ); - } - - if (SpecVersion != "1.0") + // Validate required fields are non-empty + if (string.IsNullOrWhiteSpace(Id)) { throw new InvalidOperationException( - $"Property '{nameof(SpecVersion)}' must be '1.0', but was '{SpecVersion}'." + $"This resource is configured for '{SchemaName}' schema and requires 'id' property to be set." ); } - // Required: type if (string.IsNullOrWhiteSpace(Type)) - { - throw new InvalidOperationException($"Required property '{nameof(Type)}' was not set."); - } - - // Required: source - must be a non-empty URI-reference per CloudEvents spec - if (string.IsNullOrWhiteSpace(Source)) { throw new InvalidOperationException( - $"Required property '{nameof(Source)}' was not set." + $"This resource is configured for '{SchemaName}' schema and requires 'eventType' property to be set." ); } - if (!Uri.TryCreate(Source, UriKind.RelativeOrAbsolute, out _)) + // Note: Azure Event Grid does not validate specversion or source values - it accepts any value + + // Optional: time - if present, must be valid date/time + if (!string.IsNullOrEmpty(Time) && (!TimeIsValid || !TimeHasTimezone)) { throw new InvalidOperationException( - $"Property '{nameof(Source)}' must be a valid URI-reference." + "The event time property 'time' was not a valid date/time." ); } - // Required: id - if (string.IsNullOrWhiteSpace(Id)) - { - throw new InvalidOperationException($"Required property '{nameof(Id)}' was not set."); - } - - // Optional: time - if present, must be valid RFC 3339 - if (!string.IsNullOrEmpty(Time)) - { - if (!TimeIsValid) - { - throw new InvalidOperationException( - $"Property '{nameof(Time)}' was not a valid RFC 3339 timestamp." - ); - } - - if (!TimeHasTimezone) - { - throw new InvalidOperationException( - $"Property '{nameof(Time)}' must include timezone information (e.g., 'Z' for UTC or an offset like '+00:00')." - ); - } - } - // Optional: dataschema - if present, must be a valid URI if (!string.IsNullOrEmpty(DataSchema)) { if (!Uri.TryCreate(DataSchema, UriKind.RelativeOrAbsolute, out _)) { throw new InvalidOperationException( - $"Property '{nameof(DataSchema)}' must be a valid URI." + $"This resource is configured for '{SchemaName}' schema and requires 'dataschema' property to be a valid URI." ); } } @@ -177,7 +145,7 @@ public void Validate() if (Data != null && !string.IsNullOrEmpty(DataBase64)) { throw new InvalidOperationException( - "Properties 'data' and 'data_base64' are mutually exclusive." + $"This resource is configured for '{SchemaName}' schema. The 'data' and 'data_base64' properties are mutually exclusive." ); } } diff --git a/src/AzureEventGridSimulator/Domain/Entities/Dashboard/AttemptRecord.cs b/src/AzureEventGridSimulator/Domain/Entities/Dashboard/AttemptRecord.cs index 2e13941..f05d0d3 100644 --- a/src/AzureEventGridSimulator/Domain/Entities/Dashboard/AttemptRecord.cs +++ b/src/AzureEventGridSimulator/Domain/Entities/Dashboard/AttemptRecord.cs @@ -1,49 +1,42 @@ -#nullable enable - namespace AzureEventGridSimulator.Domain.Entities.Dashboard; /// /// Represents a single delivery attempt to a subscriber. /// -public class AttemptRecord +/// +/// 1-based attempt count. +/// +/// +/// When the attempt was made. +/// +/// +/// Result of the attempt. +/// +/// +/// HTTP status code if applicable. +/// +/// +/// Error details if failed. +/// +public record AttemptRecord( + int AttemptNumber, + DateTimeOffset AttemptedAt, + DeliveryOutcome Outcome, + int? HttpStatusCode = null, + string? ErrorMessage = null +) { - /// - /// 1-based attempt count. - /// - public int AttemptNumber { get; init; } - - /// - /// When the attempt was made. - /// - public DateTimeOffset AttemptedAt { get; init; } - - /// - /// Result of the attempt. - /// - public DeliveryOutcome Outcome { get; init; } - - /// - /// HTTP status code if applicable. - /// - public int? HttpStatusCode { get; init; } - - /// - /// Error details if failed. - /// - public string? ErrorMessage { get; init; } - /// /// Creates an AttemptRecord from an existing DeliveryAttempt. /// public static AttemptRecord FromDeliveryAttempt(DeliveryAttempt attempt) { - return new AttemptRecord - { - AttemptNumber = attempt.AttemptNumber, - AttemptedAt = attempt.AttemptTime, - Outcome = attempt.Outcome, - HttpStatusCode = attempt.HttpStatusCode, - ErrorMessage = attempt.ErrorMessage, - }; + return new AttemptRecord( + attempt.AttemptNumber, + attempt.AttemptTime, + attempt.Outcome, + attempt.HttpStatusCode, + attempt.ErrorMessage + ); } } diff --git a/src/AzureEventGridSimulator/Domain/Entities/Dashboard/DashboardStats.cs b/src/AzureEventGridSimulator/Domain/Entities/Dashboard/DashboardStats.cs index 493be9b..99af7d8 100644 --- a/src/AzureEventGridSimulator/Domain/Entities/Dashboard/DashboardStats.cs +++ b/src/AzureEventGridSimulator/Domain/Entities/Dashboard/DashboardStats.cs @@ -3,50 +3,41 @@ namespace AzureEventGridSimulator.Domain.Entities.Dashboard; /// /// Summary statistics for the dashboard header. /// -public class DashboardStats -{ - /// - /// Total events received since startup (may exceed 100). - /// - public int TotalEventsReceived { get; init; } - - /// - /// Current number of events in history (max 100). - /// - public int EventsInHistory { get; init; } - - /// - /// Count of successful deliveries. - /// - public int TotalDelivered { get; init; } - - /// - /// Count of failed/dead-lettered deliveries. - /// - public int TotalFailed { get; init; } - - /// - /// Count of pending deliveries. - /// - public int TotalPending { get; init; } - - /// - /// Count of rejected events (validation/parse failures). - /// - public int TotalRejected { get; init; } - - /// - /// Number of enabled topics. - /// - public int TopicsActive { get; init; } - - /// - /// Timestamp of oldest event in history. - /// - public DateTimeOffset? OldestEventTime { get; init; } - - /// - /// Timestamp of newest event in history. - /// - public DateTimeOffset? NewestEventTime { get; init; } -} +/// +/// Total events received since startup (may exceed 100). +/// +/// +/// Current number of events in history (max 100). +/// +/// +/// Count of successful deliveries. +/// +/// +/// Count of failed/dead-lettered deliveries. +/// +/// +/// Count of pending deliveries. +/// +/// +/// Count of rejected events (validation/parse failures). +/// +/// +/// Number of enabled topics. +/// +/// +/// Timestamp of oldest event in history. +/// +/// +/// Timestamp of newest event in history. +/// +public record DashboardStats( + int TotalEventsReceived, + int EventsInHistory, + int TotalDelivered, + int TotalFailed, + int TotalPending, + int TotalRejected, + int TopicsActive, + DateTimeOffset? OldestEventTime, + DateTimeOffset? NewestEventTime +); diff --git a/src/AzureEventGridSimulator/Domain/Entities/Dashboard/DeliveryRecord.cs b/src/AzureEventGridSimulator/Domain/Entities/Dashboard/DeliveryRecord.cs index b0b3044..cec298c 100644 --- a/src/AzureEventGridSimulator/Domain/Entities/Dashboard/DeliveryRecord.cs +++ b/src/AzureEventGridSimulator/Domain/Entities/Dashboard/DeliveryRecord.cs @@ -1,5 +1,3 @@ -#nullable enable - using AzureEventGridSimulator.Infrastructure.Settings.Subscribers; namespace AzureEventGridSimulator.Domain.Entities.Dashboard; @@ -55,7 +53,9 @@ public static DeliveryRecord FromSubscriber(ISubscriberSettings subscriber) ServiceBusSubscriberSettings sb => sb.DestinationName, StorageQueueSubscriberSettings sq => sq.QueueName, EventHubSubscriberSettings eh => eh.EventHubName, - _ => "Unknown", + _ => throw new InvalidOperationException( + $"Unknown subscriber type: {subscriber.GetType().Name}" + ), }; return new DeliveryRecord diff --git a/src/AzureEventGridSimulator/Domain/Entities/Dashboard/EventHistoryRecord.cs b/src/AzureEventGridSimulator/Domain/Entities/Dashboard/EventHistoryRecord.cs index c4b2de5..53140bc 100644 --- a/src/AzureEventGridSimulator/Domain/Entities/Dashboard/EventHistoryRecord.cs +++ b/src/AzureEventGridSimulator/Domain/Entities/Dashboard/EventHistoryRecord.cs @@ -1,5 +1,3 @@ -#nullable enable - using System.Text.Json; namespace AzureEventGridSimulator.Domain.Entities.Dashboard; @@ -99,7 +97,7 @@ EventSchema inputSchema ReceivedAt = DateTimeOffset.UtcNow, TopicName = topicName, TopicPort = topicPort, - EventType = evt.EventType ?? "Unknown", + EventType = evt.EventType, Subject = evt.Subject, Source = evt.Source, EventTime = evt.EventTime, diff --git a/src/AzureEventGridSimulator/Domain/Entities/Dashboard/RejectedEventRecord.cs b/src/AzureEventGridSimulator/Domain/Entities/Dashboard/RejectedEventRecord.cs index 204ab80..b77968c 100644 --- a/src/AzureEventGridSimulator/Domain/Entities/Dashboard/RejectedEventRecord.cs +++ b/src/AzureEventGridSimulator/Domain/Entities/Dashboard/RejectedEventRecord.cs @@ -1,5 +1,3 @@ -#nullable enable - using System.Net; namespace AzureEventGridSimulator.Domain.Entities.Dashboard; diff --git a/src/AzureEventGridSimulator/Domain/Entities/DeadLetterEvent.cs b/src/AzureEventGridSimulator/Domain/Entities/DeadLetterEvent.cs index 8e2d654..9b09ec8 100644 --- a/src/AzureEventGridSimulator/Domain/Entities/DeadLetterEvent.cs +++ b/src/AzureEventGridSimulator/Domain/Entities/DeadLetterEvent.cs @@ -21,10 +21,11 @@ public class DeadLetterEvent public int DeliveryAttempts { get; init; } /// - /// Gets or sets the outcome of the last delivery attempt. + /// Gets or sets the outcome of the last delivery attempt, if any. /// [JsonPropertyName("lastDeliveryOutcome")] - public string LastDeliveryOutcome { get; init; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? LastDeliveryOutcome { get; init; } /// /// Gets or sets the HTTP status code of the last attempt, if applicable. @@ -38,7 +39,7 @@ public class DeadLetterEvent /// [JsonPropertyName("lastErrorMessage")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string LastErrorMessage { get; init; } + public string? LastErrorMessage { get; init; } /// /// Gets or sets the time the event was originally published. @@ -47,10 +48,11 @@ public class DeadLetterEvent public DateTimeOffset PublishTime { get; init; } /// - /// Gets or sets the time of the last delivery attempt. + /// Gets or sets the time of the last delivery attempt, if any. /// [JsonPropertyName("lastDeliveryAttemptTime")] - public DateTimeOffset LastDeliveryAttemptTime { get; init; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DateTimeOffset? LastDeliveryAttemptTime { get; init; } /// /// Gets or sets the topic name. diff --git a/src/AzureEventGridSimulator/Domain/Entities/DeliveryAttempt.cs b/src/AzureEventGridSimulator/Domain/Entities/DeliveryAttempt.cs index ab1da10..193077e 100644 --- a/src/AzureEventGridSimulator/Domain/Entities/DeliveryAttempt.cs +++ b/src/AzureEventGridSimulator/Domain/Entities/DeliveryAttempt.cs @@ -3,30 +3,32 @@ namespace AzureEventGridSimulator.Domain.Entities; /// /// Records the outcome of a single delivery attempt. /// -public class DeliveryAttempt +/// +/// The attempt number (1-based). +/// +/// +/// The outcome of the attempt. +/// +/// +/// The time of the attempt. +/// +/// +/// The HTTP status code if applicable. +/// +/// +/// The error message if the attempt failed. +/// +public record DeliveryAttempt( + int AttemptNumber, + DeliveryOutcome Outcome, + DateTimeOffset AttemptTime, + int? HttpStatusCode = null, + string? ErrorMessage = null +) { /// - /// Gets or sets the time of the attempt. + /// Creates a new delivery attempt with the current UTC time. /// - public DateTimeOffset AttemptTime { get; init; } = DateTimeOffset.UtcNow; - - /// - /// Gets or sets the attempt number (1-based). - /// - public int AttemptNumber { get; init; } - - /// - /// Gets or sets the outcome of the attempt. - /// - public DeliveryOutcome Outcome { get; init; } - - /// - /// Gets or sets the HTTP status code if applicable. - /// - public int? HttpStatusCode { get; init; } - - /// - /// Gets or sets the error message if the attempt failed. - /// - public string ErrorMessage { get; init; } + public DeliveryAttempt(int attemptNumber, DeliveryOutcome outcome) + : this(attemptNumber, outcome, DateTimeOffset.UtcNow) { } } diff --git a/src/AzureEventGridSimulator/Domain/Entities/EventGridEvent.cs b/src/AzureEventGridSimulator/Domain/Entities/EventGridEvent.cs index 7486d68..3cab690 100644 --- a/src/AzureEventGridSimulator/Domain/Entities/EventGridEvent.cs +++ b/src/AzureEventGridSimulator/Domain/Entities/EventGridEvent.cs @@ -8,35 +8,37 @@ namespace AzureEventGridSimulator.Domain.Entities; /// public class EventGridEvent { + private const string SchemaName = "EventGridEvent"; + /// - /// Gets or sets an unique identifier for the event. + /// Gets or sets an unique identifier for the event (required). /// [JsonPropertyName("id")] - public string Id { get; set; } + public required string Id { get; set; } /// - /// Gets or sets a resource path relative to the topic path. + /// Gets or sets a resource path relative to the topic path (required). /// [JsonPropertyName("subject")] - public string Subject { get; set; } + public required string Subject { get; set; } /// - /// Gets or sets event data specific to the event type. + /// Gets or sets event data specific to the event type (optional). /// [JsonPropertyName("data")] - public object Data { get; set; } + public object? Data { get; set; } /// - /// Gets or sets the type of the event that occurred. + /// Gets or sets the type of the event that occurred (required). /// [JsonPropertyName("eventType")] - public string EventType { get; set; } + public required string EventType { get; set; } /// - /// Gets or sets the time (in UTC) the event was generated. + /// Gets or sets the time (in UTC) the event was generated (required). /// [JsonPropertyName("eventTime")] - public string EventTime { get; set; } + public required string EventTime { get; set; } [JsonIgnore] private DateTimeOffset EventTimeParsed => @@ -56,13 +58,13 @@ public class EventGridEvent /// Gets or sets the schema version of the data object. /// [JsonPropertyName("dataVersion")] - public string DataVersion { get; set; } + public string? DataVersion { get; set; } /// /// Gets the schema version of the event metadata. /// [JsonPropertyName("metadataVersion")] - public string MetadataVersion { get; set; } + public string? MetadataVersion { get; set; } /// /// Gets the resource path of the event source. @@ -70,7 +72,7 @@ public class EventGridEvent /// [JsonPropertyName("topic")] [JsonInclude] - public string Topic { get; private set; } + public string? Topic { get; private set; } /// /// Indicates whether the Topic has been set by the simulator. @@ -89,6 +91,8 @@ internal void SetTopic(string topic) /// /// Validate the object. + /// Required properties (Id, Subject, EventType, EventTime) are enforced by the 'required' modifier + /// for presence, but this method validates they are non-empty and have valid formats. /// /// /// Thrown if validation fails @@ -97,48 +101,51 @@ public void Validate() { if (string.IsNullOrWhiteSpace(Id)) { - throw new InvalidOperationException($"Required property '{nameof(Id)}' was not set."); + throw new InvalidOperationException( + $"This resource is configured for '{SchemaName}' schema and requires 'id' property to be set." + ); } if (string.IsNullOrWhiteSpace(Subject)) { throw new InvalidOperationException( - $"Required property '{nameof(Subject)}' was not set." + $"This resource is configured for '{SchemaName}' schema and requires 'subject' property to be set." ); } if (string.IsNullOrWhiteSpace(EventType)) { throw new InvalidOperationException( - $"Required property '{nameof(EventType)}' was not set." + $"This resource is configured for '{SchemaName}' schema and requires 'eventType' property to be set." ); } - if (string.IsNullOrWhiteSpace(EventTime)) + // DataVersion is optional, but if provided it must be non-empty + if (DataVersion != null && string.IsNullOrWhiteSpace(DataVersion)) { throw new InvalidOperationException( - $"Required property '{nameof(EventTime)}' was not set." + $"This resource is configured for '{SchemaName}' schema and requires 'dataVersion' property to be set." ); } if (!EventTimeIsValid) { throw new InvalidOperationException( - $"The event time property '{nameof(EventTime)}' was not a valid date/time." + $"This resource is configured for '{SchemaName}' schema and requires 'eventTime' property to be a valid RFC 3339 timestamp." ); } if (!EventTimeHasTimezone) { throw new InvalidOperationException( - $"Property '{nameof(EventTime)}' must include a timezone indicator (e.g., 'Z' for UTC or an offset like '+00:00')." + $"This resource is configured for '{SchemaName}' schema and requires 'eventTime' property to include timezone information (e.g., 'Z' for UTC or an offset like '+00:00')." ); } if (MetadataVersion != null && MetadataVersion != "1") { throw new InvalidOperationException( - $"Property '{nameof(MetadataVersion)}' was found to be set to '{MetadataVersion}', but was expected to either be null or be set to 1." + $"Property 'metadataVersion' was found to be set to {MetadataVersion}, but was expected to either be null or be set to 1." ); } @@ -147,7 +154,7 @@ public void Validate() if (!TopicHasBeenSet && !string.IsNullOrEmpty(Topic)) { throw new InvalidOperationException( - $"Property '{nameof(Topic)}' was found to be set to '{Topic}', but was expected to either be null/empty." + $"This resource is configured for '{SchemaName}' schema. The 'topic' property must not be set by the publisher." ); } } diff --git a/src/AzureEventGridSimulator/Domain/Entities/PendingDelivery.cs b/src/AzureEventGridSimulator/Domain/Entities/PendingDelivery.cs index b3112b3..b2ea039 100644 --- a/src/AzureEventGridSimulator/Domain/Entities/PendingDelivery.cs +++ b/src/AzureEventGridSimulator/Domain/Entities/PendingDelivery.cs @@ -72,7 +72,7 @@ public class PendingDelivery /// /// Gets the last delivery attempt, if any. /// - public DeliveryAttempt LastAttempt => Attempts.Count > 0 ? Attempts[^1] : null; + public DeliveryAttempt? LastAttempt => Attempts.Count > 0 ? Attempts[^1] : null; /// /// Determines whether the event has expired based on TTL. diff --git a/src/AzureEventGridSimulator/Domain/Entities/SimulatorEvent.cs b/src/AzureEventGridSimulator/Domain/Entities/SimulatorEvent.cs index 5b9958f..8d17846 100644 --- a/src/AzureEventGridSimulator/Domain/Entities/SimulatorEvent.cs +++ b/src/AzureEventGridSimulator/Domain/Entities/SimulatorEvent.cs @@ -14,12 +14,12 @@ public class SimulatorEvent /// /// Gets or sets the underlying EventGridEvent (when Schema is EventGridSchema). /// - public EventGridEvent EventGridEvent { get; set; } + public EventGridEvent? EventGridEvent { get; set; } /// /// Gets or sets the underlying CloudEvent (when Schema is CloudEventV1_0). /// - public CloudEvent CloudEvent { get; set; } + public CloudEvent? CloudEvent { get; set; } /// /// Gets the unique event identifier. @@ -27,8 +27,10 @@ public class SimulatorEvent public string Id => Schema switch { - EventSchema.EventGridSchema => EventGridEvent?.Id, - EventSchema.CloudEventV1_0 => CloudEvent?.Id, + EventSchema.EventGridSchema => EventGridEvent?.Id + ?? throw new InvalidOperationException("EventGridEvent is null"), + EventSchema.CloudEventV1_0 => CloudEvent?.Id + ?? throw new InvalidOperationException("CloudEvent is null"), _ => throw new InvalidOperationException($"Unknown schema: {Schema}"), }; @@ -39,8 +41,11 @@ public class SimulatorEvent public string Subject => Schema switch { - EventSchema.EventGridSchema => EventGridEvent?.Subject, - EventSchema.CloudEventV1_0 => CloudEvent?.Subject ?? CloudEvent?.Source, + EventSchema.EventGridSchema => EventGridEvent?.Subject + ?? throw new InvalidOperationException("EventGridEvent is null"), + EventSchema.CloudEventV1_0 => CloudEvent?.Subject + ?? CloudEvent?.Source + ?? throw new InvalidOperationException("CloudEvent is null"), _ => throw new InvalidOperationException($"Unknown schema: {Schema}"), }; @@ -50,15 +55,17 @@ public class SimulatorEvent public string EventType => Schema switch { - EventSchema.EventGridSchema => EventGridEvent?.EventType, - EventSchema.CloudEventV1_0 => CloudEvent?.Type, + EventSchema.EventGridSchema => EventGridEvent?.EventType + ?? throw new InvalidOperationException("EventGridEvent is null"), + EventSchema.CloudEventV1_0 => CloudEvent?.Type + ?? throw new InvalidOperationException("CloudEvent is null"), _ => throw new InvalidOperationException($"Unknown schema: {Schema}"), }; /// /// Gets the event timestamp. /// - public string EventTime => + public string? EventTime => Schema switch { EventSchema.EventGridSchema => EventGridEvent?.EventTime, @@ -69,7 +76,7 @@ public class SimulatorEvent /// /// Gets the event data payload. /// - public object Data => + public object? Data => Schema switch { EventSchema.EventGridSchema => EventGridEvent?.Data, @@ -80,7 +87,7 @@ public class SimulatorEvent /// /// Gets the event source/topic. /// - public string Source => + public string? Source => Schema switch { EventSchema.EventGridSchema => EventGridEvent?.Topic, @@ -91,7 +98,7 @@ public class SimulatorEvent /// /// Gets the data version/schema. /// - public string DataVersion => + public string? DataVersion => Schema switch { EventSchema.EventGridSchema => EventGridEvent?.DataVersion, diff --git a/src/AzureEventGridSimulator/Domain/Services/CloudEventSchemaFormatter.cs b/src/AzureEventGridSimulator/Domain/Services/CloudEventSchemaFormatter.cs index 470447c..2108c56 100644 --- a/src/AzureEventGridSimulator/Domain/Services/CloudEventSchemaFormatter.cs +++ b/src/AzureEventGridSimulator/Domain/Services/CloudEventSchemaFormatter.cs @@ -92,7 +92,7 @@ private CloudEvent ConvertEventGridToCloudEvent(EventGridEvent eventGridEvent) /// /// Converts a data version to a CloudEvents dataschema URI. /// - private string ConvertDataVersionToSchema(string dataVersion) + private string? ConvertDataVersionToSchema(string? dataVersion) { if (string.IsNullOrEmpty(dataVersion)) { diff --git a/src/AzureEventGridSimulator/Domain/Services/CloudEventSchemaParser.cs b/src/AzureEventGridSimulator/Domain/Services/CloudEventSchemaParser.cs index 89457d8..ea18c5f 100644 --- a/src/AzureEventGridSimulator/Domain/Services/CloudEventSchemaParser.cs +++ b/src/AzureEventGridSimulator/Domain/Services/CloudEventSchemaParser.cs @@ -47,10 +47,10 @@ private SimulatorEvent[] ParseBinaryMode(HttpContext context, string requestBody var cloudEvent = new CloudEvent { - SpecVersion = GetHeaderValue(headers, Constants.CeSpecVersionHeader), - Id = GetHeaderValue(headers, Constants.CeIdHeader), - Source = GetHeaderValue(headers, Constants.CeSourceHeader), - Type = GetHeaderValue(headers, Constants.CeTypeHeader), + SpecVersion = GetRequiredHeaderValue(headers, Constants.CeSpecVersionHeader), + Id = GetRequiredHeaderValue(headers, Constants.CeIdHeader), + Source = GetRequiredHeaderValue(headers, Constants.CeSourceHeader), + Type = GetRequiredHeaderValue(headers, Constants.CeTypeHeader), Time = GetHeaderValue(headers, Constants.CeTimeHeader), Subject = GetHeaderValue(headers, Constants.CeSubjectHeader), DataContentType = @@ -88,7 +88,7 @@ private SimulatorEvent[] ParseStructuredMode(string requestBody) throw new InvalidOperationException("Request body is empty."); } - CloudEvent cloudEvent; + CloudEvent? cloudEvent; try { @@ -104,7 +104,7 @@ private SimulatorEvent[] ParseStructuredMode(string requestBody) // Handle single event in array format var events = JsonSerializer.Deserialize(requestBody); - return events.Select(SimulatorEvent.FromCloudEvent).ToArray(); + return events?.Select(SimulatorEvent.FromCloudEvent).ToArray() ?? []; } cloudEvent = JsonSerializer.Deserialize(requestBody); @@ -132,7 +132,7 @@ private SimulatorEvent[] ParseBatchStructuredMode(string requestBody) throw new InvalidOperationException("Request body is empty."); } - CloudEvent[] events; + CloudEvent[]? events; try { @@ -157,7 +157,7 @@ private SimulatorEvent[] ParseBatchStructuredMode(string requestBody) /// /// Gets and decodes a header value, handling percent-encoding per CloudEvents HTTP binding spec. /// - private static string GetHeaderValue(IHeaderDictionary headers, string headerName) + private static string? GetHeaderValue(IHeaderDictionary headers, string headerName) { if (!headers.TryGetValue(headerName, out var values)) { @@ -170,6 +170,38 @@ private static string GetHeaderValue(IHeaderDictionary headers, string headerNam return value; } + return DecodeHeaderValue(value); + } + + /// + /// Gets and decodes a required header value. + /// Throws if the header is missing or empty. + /// + private static string GetRequiredHeaderValue(IHeaderDictionary headers, string headerName) + { + if (!headers.TryGetValue(headerName, out var values)) + { + throw new InvalidOperationException( + $"Required CloudEvents header '{headerName}' is missing." + ); + } + + var value = values.FirstOrDefault(); + if (string.IsNullOrEmpty(value)) + { + throw new InvalidOperationException( + $"Required CloudEvents header '{headerName}' is empty." + ); + } + + return DecodeHeaderValue(value); + } + + /// + /// Decodes a header value, handling percent-encoding per CloudEvents HTTP binding spec. + /// + private static string DecodeHeaderValue(string value) + { // CloudEvents HTTP Protocol Binding requires percent-encoding for certain characters // in header values (spaces, non-ASCII, etc.). We need to decode them. try diff --git a/src/AzureEventGridSimulator/Domain/Services/Dashboard/EventHistoryService.cs b/src/AzureEventGridSimulator/Domain/Services/Dashboard/EventHistoryService.cs index d9b3c1e..bcaf517 100644 --- a/src/AzureEventGridSimulator/Domain/Services/Dashboard/EventHistoryService.cs +++ b/src/AzureEventGridSimulator/Domain/Services/Dashboard/EventHistoryService.cs @@ -1,5 +1,3 @@ -#nullable enable - using AzureEventGridSimulator.Domain.Entities; using AzureEventGridSimulator.Domain.Entities.Dashboard; using AzureEventGridSimulator.Infrastructure.Settings; @@ -30,7 +28,7 @@ public void RecordEventReceived(SimulatorEvent evt, TopicSettings topic, EventSc } /// - public void RecordDeliveryQueued(string eventId, ISubscriberSettings subscriber) + public void RecordDeliveryQueued(string? eventId, ISubscriberSettings subscriber) { var delivery = DeliveryRecord.FromSubscriber(subscriber); store.UpdateDelivery(eventId, delivery); @@ -44,8 +42,8 @@ public void RecordDeliveryQueued(string eventId, ISubscriberSettings subscriber) /// public void RecordDeliveryAttempt( - string eventId, - string subscriberName, + string? eventId, + string? subscriberName, DeliveryAttempt attempt ) { @@ -96,8 +94,8 @@ DeliveryAttempt attempt /// public void RecordDeliveryCompleted( - string eventId, - string subscriberName, + string? eventId, + string? subscriberName, DeliveryStatus status, DateTimeOffset completedAt ) diff --git a/src/AzureEventGridSimulator/Domain/Services/Dashboard/EventHistoryStore.cs b/src/AzureEventGridSimulator/Domain/Services/Dashboard/EventHistoryStore.cs index d022b76..774c950 100644 --- a/src/AzureEventGridSimulator/Domain/Services/Dashboard/EventHistoryStore.cs +++ b/src/AzureEventGridSimulator/Domain/Services/Dashboard/EventHistoryStore.cs @@ -1,5 +1,3 @@ -#nullable enable - using System.Collections.Concurrent; using AzureEventGridSimulator.Domain.Entities.Dashboard; @@ -90,9 +88,9 @@ public void Add(EventHistoryRecord record) /// /// Updates delivery information for an event. /// - public void UpdateDelivery(string eventId, DeliveryRecord delivery) + public void UpdateDelivery(string? eventId, DeliveryRecord delivery) { - if (_records.TryGetValue(eventId, out var record)) + if (eventId != null && _records.TryGetValue(eventId, out var record)) { record.AddOrUpdateDelivery(delivery); } @@ -101,9 +99,9 @@ public void UpdateDelivery(string eventId, DeliveryRecord delivery) /// /// Gets an event by ID. /// - public EventHistoryRecord? Get(string eventId) + public EventHistoryRecord? Get(string? eventId) { - return _records.TryGetValue(eventId, out var record) ? record : null; + return eventId != null && _records.TryGetValue(eventId, out var record) ? record : null; } /// @@ -160,18 +158,17 @@ public DashboardStats GetStats(int topicsActive) } } - return new DashboardStats - { - TotalEventsReceived = _totalEventsReceived, - EventsInHistory = records.Count, - TotalDelivered = totalDelivered, - TotalFailed = totalFailed, - TotalPending = totalPending, - TotalRejected = _totalRejections, - TopicsActive = topicsActive, - OldestEventTime = records.MinBy(r => r.ReceivedAt)?.ReceivedAt, - NewestEventTime = records.MaxBy(r => r.ReceivedAt)?.ReceivedAt, - }; + return new DashboardStats( + _totalEventsReceived, + records.Count, + totalDelivered, + totalFailed, + totalPending, + _totalRejections, + topicsActive, + records.MinBy(r => r.ReceivedAt)?.ReceivedAt, + records.MaxBy(r => r.ReceivedAt)?.ReceivedAt + ); } /// diff --git a/src/AzureEventGridSimulator/Domain/Services/Dashboard/IEventHistoryService.cs b/src/AzureEventGridSimulator/Domain/Services/Dashboard/IEventHistoryService.cs index 09607f7..4a9e433 100644 --- a/src/AzureEventGridSimulator/Domain/Services/Dashboard/IEventHistoryService.cs +++ b/src/AzureEventGridSimulator/Domain/Services/Dashboard/IEventHistoryService.cs @@ -1,5 +1,3 @@ -#nullable enable - using AzureEventGridSimulator.Domain.Entities; using AzureEventGridSimulator.Domain.Entities.Dashboard; using AzureEventGridSimulator.Infrastructure.Settings; @@ -20,19 +18,19 @@ public interface IEventHistoryService /// /// Records that a delivery was queued for a subscriber. /// - void RecordDeliveryQueued(string eventId, ISubscriberSettings subscriber); + void RecordDeliveryQueued(string? eventId, ISubscriberSettings subscriber); /// /// Records a delivery attempt. /// - void RecordDeliveryAttempt(string eventId, string subscriberName, DeliveryAttempt attempt); + void RecordDeliveryAttempt(string? eventId, string? subscriberName, DeliveryAttempt attempt); /// /// Records that delivery completed (success or failure). /// void RecordDeliveryCompleted( - string eventId, - string subscriberName, + string? eventId, + string? subscriberName, DeliveryStatus status, DateTimeOffset completedAt ); diff --git a/src/AzureEventGridSimulator/Domain/Services/Delivery/DeliveryPropertyResolver.cs b/src/AzureEventGridSimulator/Domain/Services/Delivery/DeliveryPropertyResolver.cs index 1d4a718..a6b7082 100644 --- a/src/AzureEventGridSimulator/Domain/Services/Delivery/DeliveryPropertyResolver.cs +++ b/src/AzureEventGridSimulator/Domain/Services/Delivery/DeliveryPropertyResolver.cs @@ -23,7 +23,7 @@ public class DeliveryPropertyResolver /// A dictionary of resolved property names and values. /// public Dictionary ResolveProperties( - Dictionary properties, + Dictionary? properties, SimulatorEvent evt ) { @@ -58,7 +58,7 @@ SimulatorEvent evt /// /// The resolved property value, or null if not found. /// - public object ResolveProperty(DeliveryPropertySettings setting, SimulatorEvent evt) + public object? ResolveProperty(DeliveryPropertySettings setting, SimulatorEvent evt) { if (setting == null) { @@ -82,7 +82,7 @@ public object ResolveProperty(DeliveryPropertySettings setting, SimulatorEvent e /// Gets a value from an event using a property path (e.g., "Subject", "data.customerId"). /// Uses the same property access pattern as the filter extensions. /// - private static object GetValueFromEvent(SimulatorEvent evt, string path) + private static object? GetValueFromEvent(SimulatorEvent evt, string? path) { if (string.IsNullOrWhiteSpace(path)) { @@ -136,7 +136,7 @@ private static object GetValueFromEvent(SimulatorEvent evt, string path) /// /// Gets a nested value from an object using a property path. /// - private static object GetNestedValue(object data, string[] pathParts, int startIndex) + private static object? GetNestedValue(object data, string[] pathParts, int startIndex) { try { @@ -187,7 +187,7 @@ private static object GetNestedValue(object data, string[] pathParts, int startI /// /// Converts a JsonElement to an appropriate .NET type for use as a Service Bus message property. /// - private static object ConvertJsonElement(JsonElement element) + private static object? ConvertJsonElement(JsonElement element) { return element.ValueKind switch { @@ -203,7 +203,7 @@ JsonValueKind.Number when element.TryGetInt64(out var l) => l, }; } - private static bool TryParseDateTime(string value, out DateTimeOffset result) + private static bool TryParseDateTime(string? value, out DateTimeOffset result) { result = default; if (string.IsNullOrEmpty(value)) @@ -214,7 +214,7 @@ private static bool TryParseDateTime(string value, out DateTimeOffset result) return DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, out result); } - private static bool TryParseGuid(string value, out Guid result) + private static bool TryParseGuid(string? value, out Guid result) { result = Guid.Empty; if (string.IsNullOrEmpty(value)) diff --git a/src/AzureEventGridSimulator/Domain/Services/Delivery/EventHubEventDeliveryService.cs b/src/AzureEventGridSimulator/Domain/Services/Delivery/EventHubEventDeliveryService.cs index 3a3252c..9a3be7c 100644 --- a/src/AzureEventGridSimulator/Domain/Services/Delivery/EventHubEventDeliveryService.cs +++ b/src/AzureEventGridSimulator/Domain/Services/Delivery/EventHubEventDeliveryService.cs @@ -80,7 +80,7 @@ CancellationToken cancellationToken var eventData = new EventData(Encoding.UTF8.GetBytes(json)) { ContentType = formatter.ContentType, - MessageId = delivery.Event.Id ?? Guid.NewGuid().ToString(), + MessageId = delivery.Event.Id, }; // Add delivery properties @@ -106,10 +106,7 @@ CancellationToken cancellationToken } // Send the event with partition key based on event ID - var sendOptions = new SendEventOptions - { - PartitionKey = delivery.Event.Id ?? Guid.NewGuid().ToString(), - }; + var sendOptions = new SendEventOptions { PartitionKey = delivery.Event.Id }; await producer.SendAsync([eventData], sendOptions, cancellationToken); @@ -197,7 +194,7 @@ EventSchema inputSchema var eventData = new EventData(Encoding.UTF8.GetBytes(json)) { ContentType = formatter.ContentType, - MessageId = evt.Id ?? Guid.NewGuid().ToString(), + MessageId = evt.Id, }; // Add delivery properties @@ -220,10 +217,7 @@ EventSchema inputSchema } // Send the event with partition key based on event ID - var sendOptions = new SendEventOptions - { - PartitionKey = evt.Id ?? Guid.NewGuid().ToString(), - }; + var sendOptions = new SendEventOptions { PartitionKey = evt.Id }; await producer.SendAsync([eventData], sendOptions); @@ -257,14 +251,17 @@ private EventHubProducerClient GetOrCreateProducer(EventHubSubscriberSettings su { // Mask the connection string for logging (show endpoint but hide key) var connectionForLogging = subscription.EffectiveConnectionString; - var keyIndex = connectionForLogging?.IndexOf( - "SharedAccessKey=", - StringComparison.OrdinalIgnoreCase - ); - if (keyIndex >= 0) + if (connectionForLogging != null) { - connectionForLogging = - connectionForLogging[..(keyIndex.Value + 16)] + "***REDACTED***"; + var keyIndex = connectionForLogging.IndexOf( + "SharedAccessKey=", + StringComparison.OrdinalIgnoreCase + ); + if (keyIndex > 0) + { + connectionForLogging = + connectionForLogging[..(keyIndex + 16)] + "***REDACTED***"; + } } logger.LogInformation( @@ -274,10 +271,17 @@ private EventHubProducerClient GetOrCreateProducer(EventHubSubscriberSettings su connectionForLogging ); - return new EventHubProducerClient( - subscription.EffectiveConnectionString, + var effectiveConnectionString = + subscription.EffectiveConnectionString + ?? throw new InvalidOperationException( + $"No connection string for Event Hub subscription '{subscription.Name}'" + ); + var eventHubName = subscription.EventHubName - ); + ?? throw new InvalidOperationException( + $"No Event Hub name for subscription '{subscription.Name}'" + ); + return new EventHubProducerClient(effectiveConnectionString, eventHubName); } ); } diff --git a/src/AzureEventGridSimulator/Domain/Services/Delivery/IEventDeliveryService.cs b/src/AzureEventGridSimulator/Domain/Services/Delivery/IEventDeliveryService.cs index 32b1eaf..ed6ee35 100644 --- a/src/AzureEventGridSimulator/Domain/Services/Delivery/IEventDeliveryService.cs +++ b/src/AzureEventGridSimulator/Domain/Services/Delivery/IEventDeliveryService.cs @@ -44,5 +44,5 @@ public record DeliveryResult( bool Success, DeliveryOutcome Outcome, int? HttpStatusCode = null, - string ErrorMessage = null + string? ErrorMessage = null ); diff --git a/src/AzureEventGridSimulator/Domain/Services/Delivery/ServiceBusEventDeliveryService.cs b/src/AzureEventGridSimulator/Domain/Services/Delivery/ServiceBusEventDeliveryService.cs index 69b4013..b55e09b 100644 --- a/src/AzureEventGridSimulator/Domain/Services/Delivery/ServiceBusEventDeliveryService.cs +++ b/src/AzureEventGridSimulator/Domain/Services/Delivery/ServiceBusEventDeliveryService.cs @@ -76,7 +76,7 @@ CancellationToken cancellationToken var message = new ServiceBusMessage(Encoding.UTF8.GetBytes(json)) { ContentType = formatter.ContentType, - MessageId = delivery.Event.Id ?? Guid.NewGuid().ToString(), + MessageId = delivery.Event.Id, }; // Add delivery properties @@ -191,7 +191,7 @@ EventSchema inputSchema var message = new ServiceBusMessage(Encoding.UTF8.GetBytes(json)) { ContentType = formatter.ContentType, - MessageId = evt.Id ?? Guid.NewGuid().ToString(), + MessageId = evt.Id, }; // Add delivery properties @@ -254,16 +254,22 @@ private ServiceBusSender GetOrCreateSender(ServiceBusSubscriberSettings subscrip private ServiceBusClient GetOrCreateClient(ServiceBusSubscriberSettings subscription) { + var connectionString = + subscription.EffectiveConnectionString + ?? throw new InvalidOperationException( + $"No connection string available for subscription '{subscription.Name}'" + ); + return _clients.GetOrAdd( - subscription.EffectiveConnectionString, - connectionString => + connectionString, + cs => { logger.LogDebug( "Creating Service Bus client for subscription '{SubscriberName}'", subscription.Name ); - return new ServiceBusClient(connectionString); + return new ServiceBusClient(cs); } ); } diff --git a/src/AzureEventGridSimulator/Domain/Services/EventGridSchemaFormatter.cs b/src/AzureEventGridSimulator/Domain/Services/EventGridSchemaFormatter.cs index 1083499..7219011 100644 --- a/src/AzureEventGridSimulator/Domain/Services/EventGridSchemaFormatter.cs +++ b/src/AzureEventGridSimulator/Domain/Services/EventGridSchemaFormatter.cs @@ -79,7 +79,7 @@ private EventGridEvent ConvertCloudEventToEventGrid(CloudEvent cloudEvent) /// /// Extracts a data version from a CloudEvents dataschema URI. /// - private string ExtractDataVersion(string dataSchema) + private string ExtractDataVersion(string? dataSchema) { if (string.IsNullOrEmpty(dataSchema)) { diff --git a/src/AzureEventGridSimulator/Domain/Services/EventGridSchemaParser.cs b/src/AzureEventGridSimulator/Domain/Services/EventGridSchemaParser.cs index f012cb2..f7b3520 100644 --- a/src/AzureEventGridSimulator/Domain/Services/EventGridSchemaParser.cs +++ b/src/AzureEventGridSimulator/Domain/Services/EventGridSchemaParser.cs @@ -19,7 +19,7 @@ public SimulatorEvent[] Parse(HttpContext context, string requestBody) throw new InvalidOperationException("Request body is empty."); } - EventGridEvent[] events; + EventGridEvent[]? events; try { diff --git a/src/AzureEventGridSimulator/Domain/Services/Retry/DeadLetterService.cs b/src/AzureEventGridSimulator/Domain/Services/Retry/DeadLetterService.cs index 1bf041e..a620601 100644 --- a/src/AzureEventGridSimulator/Domain/Services/Retry/DeadLetterService.cs +++ b/src/AzureEventGridSimulator/Domain/Services/Retry/DeadLetterService.cs @@ -52,11 +52,11 @@ public async Task WriteDeadLetterAsync(PendingDelivery delivery, string reason) { DeadLetterReason = reason, DeliveryAttempts = delivery.AttemptCount, - LastDeliveryOutcome = lastAttempt?.Outcome.ToString() ?? "Unknown", + LastDeliveryOutcome = lastAttempt?.Outcome.ToString(), LastHttpStatusCode = lastAttempt?.HttpStatusCode, LastErrorMessage = lastAttempt?.ErrorMessage, PublishTime = delivery.EnqueuedTime, - LastDeliveryAttemptTime = lastAttempt?.AttemptTime ?? delivery.EnqueuedTime, + LastDeliveryAttemptTime = lastAttempt?.AttemptTime, TopicName = delivery.Topic.Name, SubscriberName = delivery.Subscriber.Name, SubscriberType = delivery.Subscriber.SubscriberType, @@ -64,7 +64,7 @@ public async Task WriteDeadLetterAsync(PendingDelivery delivery, string reason) }; var timestamp = delivery.EnqueuedTime.ToString("yyyyMMdd_HHmmss"); - var eventId = SanitizeFileName(delivery.Event.Id ?? Guid.NewGuid().ToString()); + var eventId = SanitizeFileName(delivery.Event.Id); var fileName = $"{timestamp}_{eventId}.json"; var filePath = Path.Combine(folder, fileName); @@ -97,19 +97,23 @@ private static object GetEventPayload(PendingDelivery delivery) { return delivery.Event.Schema switch { - EventSchema.EventGridSchema => delivery.Event.EventGridEvent, - EventSchema.CloudEventV1_0 => delivery.Event.CloudEvent, - _ => new - { - delivery.Event.Id, - delivery.Event.EventType, - delivery.Event.Subject, - }, + EventSchema.EventGridSchema => delivery.Event.EventGridEvent + ?? throw new InvalidOperationException( + "EventGridEvent is null for EventGridSchema" + ), + EventSchema.CloudEventV1_0 => delivery.Event.CloudEvent + ?? throw new InvalidOperationException( + "CloudEvent is null for CloudEventV1_0 schema" + ), + _ => throw new InvalidOperationException( + $"Unsupported event schema: {delivery.Event.Schema}" + ), }; } /// /// Sanitizes a string to be safe for use as a file name. + /// Note: Event IDs are GUIDs which only contain valid path characters. /// private static string SanitizeFileName(string name) { @@ -117,26 +121,16 @@ private static string SanitizeFileName(string name) var sanitized = new string(name.Where(c => !invalidChars.Contains(c)).ToArray()); // Limit length - if (sanitized.Length > 50) - { - sanitized = sanitized[..50]; - } - - return string.IsNullOrWhiteSpace(sanitized) ? "unknown" : sanitized; + return sanitized.Length > 50 ? sanitized[..50] : sanitized; } /// /// Sanitizes a string to be safe for use as a directory name. - /// Removes path separators and invalid characters to prevent path traversal. + /// Note: Topic and subscriber names are validated to only contain letters, numbers, and dashes. /// private static string SanitizeDirectoryName(string name) { - if (string.IsNullOrWhiteSpace(name)) - { - return "unknown"; - } - - // Remove path separators and invalid path characters + // Remove path separators and invalid path characters (defense in depth) var invalidChars = Path.GetInvalidFileNameChars() .Concat([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar]) .ToHashSet(); @@ -144,11 +138,6 @@ private static string SanitizeDirectoryName(string name) var sanitized = new string(name.Where(c => !invalidChars.Contains(c)).ToArray()); // Limit length - if (sanitized.Length > 100) - { - sanitized = sanitized[..100]; - } - - return string.IsNullOrWhiteSpace(sanitized) ? "unknown" : sanitized; + return sanitized.Length > 100 ? sanitized[..100] : sanitized; } } diff --git a/src/AzureEventGridSimulator/Domain/Services/Retry/IDeliveryQueue.cs b/src/AzureEventGridSimulator/Domain/Services/Retry/IDeliveryQueue.cs index c776bcb..11d2a46 100644 --- a/src/AzureEventGridSimulator/Domain/Services/Retry/IDeliveryQueue.cs +++ b/src/AzureEventGridSimulator/Domain/Services/Retry/IDeliveryQueue.cs @@ -46,4 +46,17 @@ public interface IDeliveryQueue /// Enumerable of due deliveries. /// IEnumerable GetDueDeliveries(); + + /// + /// Gets all deliveries that are due for processing as an async stream. + /// + /// + /// Cancellation token. + /// + /// + /// Async enumerable of due deliveries. + /// + IAsyncEnumerable GetDueDeliveriesAsync( + CancellationToken cancellationToken = default + ); } diff --git a/src/AzureEventGridSimulator/Domain/Services/Retry/InMemoryDeliveryQueue.cs b/src/AzureEventGridSimulator/Domain/Services/Retry/InMemoryDeliveryQueue.cs index 82b49b4..b3a478e 100644 --- a/src/AzureEventGridSimulator/Domain/Services/Retry/InMemoryDeliveryQueue.cs +++ b/src/AzureEventGridSimulator/Domain/Services/Retry/InMemoryDeliveryQueue.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Runtime.CompilerServices; using AzureEventGridSimulator.Domain.Entities; namespace AzureEventGridSimulator.Domain.Services.Retry; @@ -64,6 +65,32 @@ public IEnumerable GetDueDeliveries() .ToList(); // Materialize to avoid modification during enumeration } + /// + public async IAsyncEnumerable GetDueDeliveriesAsync( + [EnumeratorCancellation] CancellationToken cancellationToken = default + ) + { + var now = timeProvider.GetUtcNow(); + + var dueDeliveries = _queue + .Values.Where(d => d.NextAttemptTime <= now) + .OrderBy(d => d.NextAttemptTime) + .ToList(); + + foreach (var delivery in dueDeliveries) + { + if (cancellationToken.IsCancellationRequested) + { + yield break; + } + + yield return delivery; + + // Yield to allow other async operations + await Task.Yield(); + } + } + /// public int Count => _queue.Count; } diff --git a/src/AzureEventGridSimulator/Domain/Services/Retry/RetryDeliveryBackgroundService.cs b/src/AzureEventGridSimulator/Domain/Services/Retry/RetryDeliveryBackgroundService.cs index eae4438..154de07 100644 --- a/src/AzureEventGridSimulator/Domain/Services/Retry/RetryDeliveryBackgroundService.cs +++ b/src/AzureEventGridSimulator/Domain/Services/Retry/RetryDeliveryBackgroundService.cs @@ -61,15 +61,8 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) /// private async Task ProcessDueDeliveriesAsync(CancellationToken cancellationToken) { - var dueDeliveries = queue.GetDueDeliveries().ToList(); - - foreach (var delivery in dueDeliveries) + await foreach (var delivery in queue.GetDueDeliveriesAsync(cancellationToken)) { - if (cancellationToken.IsCancellationRequested) - { - break; - } - // Remove from queue before processing (we'll re-add if retry needed) queue.Remove(delivery.Id); @@ -129,14 +122,13 @@ CancellationToken cancellationToken // Record the attempt delivery.AttemptCount++; - var attempt = new DeliveryAttempt - { - AttemptTime = timeProvider.GetUtcNow(), - AttemptNumber = delivery.AttemptCount, - Outcome = result.Outcome, - HttpStatusCode = result.HttpStatusCode, - ErrorMessage = result.ErrorMessage, - }; + var attempt = new DeliveryAttempt( + delivery.AttemptCount, + result.Outcome, + timeProvider.GetUtcNow(), + result.HttpStatusCode, + result.ErrorMessage + ); delivery.Attempts.Add(attempt); // Record attempt for dashboard diff --git a/src/AzureEventGridSimulator/Domain/Services/SubscriptionValidationRequest.cs b/src/AzureEventGridSimulator/Domain/Services/SubscriptionValidationRequest.cs index f5bec68..b5ac132 100644 --- a/src/AzureEventGridSimulator/Domain/Services/SubscriptionValidationRequest.cs +++ b/src/AzureEventGridSimulator/Domain/Services/SubscriptionValidationRequest.cs @@ -8,5 +8,5 @@ public class SubscriptionValidationRequest public Guid ValidationCode { get; set; } [JsonPropertyName("validationUrl")] - public string ValidationUrl { get; set; } + public required string ValidationUrl { get; set; } } diff --git a/src/AzureEventGridSimulator/Domain/Services/SubscriptionValidationResponse.cs b/src/AzureEventGridSimulator/Domain/Services/SubscriptionValidationResponse.cs index c410b27..9eb55be 100644 --- a/src/AzureEventGridSimulator/Domain/Services/SubscriptionValidationResponse.cs +++ b/src/AzureEventGridSimulator/Domain/Services/SubscriptionValidationResponse.cs @@ -2,8 +2,12 @@ namespace AzureEventGridSimulator.Domain.Services; -public class SubscriptionValidationResponse -{ - [JsonPropertyName("validationResponse")] - public Guid ValidationResponse { get; set; } -} +/// +/// Response for subscription validation. +/// +/// +/// The validation response code. +/// +public record SubscriptionValidationResponse( + [property: JsonPropertyName("validationResponse")] Guid ValidationResponse +); diff --git a/src/AzureEventGridSimulator/Infrastructure/Dashboard/DashboardEndpoints.cs b/src/AzureEventGridSimulator/Infrastructure/Dashboard/DashboardEndpoints.cs index 4321c4e..d911fd9 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Dashboard/DashboardEndpoints.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Dashboard/DashboardEndpoints.cs @@ -1,5 +1,3 @@ -#nullable enable - using AzureEventGridSimulator.Domain.Entities.Dashboard; using AzureEventGridSimulator.Domain.Services.Dashboard; diff --git a/src/AzureEventGridSimulator/Infrastructure/Dashboard/DashboardMiddleware.cs b/src/AzureEventGridSimulator/Infrastructure/Dashboard/DashboardMiddleware.cs index dad0fac..9576c59 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Dashboard/DashboardMiddleware.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Dashboard/DashboardMiddleware.cs @@ -1,5 +1,3 @@ -#nullable enable - using System.Reflection; namespace AzureEventGridSimulator.Infrastructure.Dashboard; diff --git a/src/AzureEventGridSimulator/Infrastructure/ErrorMessage.cs b/src/AzureEventGridSimulator/Infrastructure/ErrorMessage.cs index 34490a4..5f1ecdc 100644 --- a/src/AzureEventGridSimulator/Infrastructure/ErrorMessage.cs +++ b/src/AzureEventGridSimulator/Infrastructure/ErrorMessage.cs @@ -3,17 +3,31 @@ namespace AzureEventGridSimulator.Infrastructure; -public class ErrorMessage(HttpStatusCode statusCode, string errorMessage, string code) +/// +/// Error response format matching Azure Event Grid's error structure. +/// +public class ErrorMessage( + HttpStatusCode statusCode, + string errorMessage, + string? code, + string? detailCode = null +) { [JsonPropertyName("error")] - public ErrorDetails Error { get; } = new(statusCode, errorMessage, code); + public ErrorDetails Error { get; } = new(statusCode, errorMessage, code, detailCode); public class ErrorDetails { - internal ErrorDetails(HttpStatusCode statusCode, string errorMessage, string code) + internal ErrorDetails( + HttpStatusCode statusCode, + string errorMessage, + string? code, + string? detailCode + ) { Code = code ?? statusCode.ToString(); Message = errorMessage; + Details = [new ErrorDetail(detailCode ?? Code, errorMessage)]; } [JsonPropertyName("code")] @@ -21,5 +35,28 @@ internal ErrorDetails(HttpStatusCode statusCode, string errorMessage, string cod [JsonPropertyName("message")] public string Message { get; } + + [JsonPropertyName("details")] + public ErrorDetail[] Details { get; } + } + + public class ErrorDetail(string code, string message) + { + [JsonPropertyName("code")] + public string Code { get; } = code; + + [JsonPropertyName("message")] + public string Message { get; } = message; } } + +/// +/// Common Azure Event Grid error detail codes. +/// +public static class ErrorDetailCodes +{ + public const string InputJsonInvalid = "InputJsonInvalid"; + public const string InvalidContentType = "InvalidContentType"; + public const string ResourceNotFound = "ResourceNotFound"; + public const string Unauthorized = "Unauthorized"; +} diff --git a/src/AzureEventGridSimulator/Infrastructure/Extensions/CollectionExtensions.cs b/src/AzureEventGridSimulator/Infrastructure/Extensions/CollectionExtensions.cs index c7de28a..8f1fb1a 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Extensions/CollectionExtensions.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Extensions/CollectionExtensions.cs @@ -2,21 +2,18 @@ public static class CollectionExtensions { - extension(ICollection collection) + extension(ICollection? collection) { public bool HasItems() { return collection != null && collection.Count != 0; } - public string Separate(string separator = ", ", Func toStringFunction = null) + public string Separate(string separator = ", ", Func? toStringFunction = null) { - toStringFunction ??= t => t.ToString(); + toStringFunction ??= t => t?.ToString() ?? string.Empty; - return string.Join( - separator, - (collection ?? Array.Empty()).Select(c => toStringFunction(c)) - ); + return string.Join(separator, (collection ?? []).Select(c => toStringFunction(c))); } } } diff --git a/src/AzureEventGridSimulator/Infrastructure/Extensions/ConfigurationExtensions.cs b/src/AzureEventGridSimulator/Infrastructure/Extensions/ConfigurationExtensions.cs index 79105e6..a1e972a 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Extensions/ConfigurationExtensions.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Extensions/ConfigurationExtensions.cs @@ -4,6 +4,6 @@ public static class ConfigurationExtensions { public static string EnvironmentName(this IConfiguration configuration) { - return configuration["ENVIRONMENT"].Otherwise("Production"); + return (configuration["ENVIRONMENT"] ?? "Production").Otherwise("Production"); } } diff --git a/src/AzureEventGridSimulator/Infrastructure/Extensions/HttpContextExtensions.cs b/src/AzureEventGridSimulator/Infrastructure/Extensions/HttpContextExtensions.cs index a2ecefc..7b1153e 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Extensions/HttpContextExtensions.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Extensions/HttpContextExtensions.cs @@ -18,7 +18,8 @@ public async Task RequestBody() public async Task WriteErrorResponse( HttpStatusCode statusCode, string errorMessage, - string code + string? code, + string? detailCode = null ) { context.Response.Headers[HeaderNames.ContentType] = "application/json"; @@ -27,7 +28,7 @@ string code await context.Response.WriteAsync( JsonSerializer.Serialize( - new ErrorMessage(statusCode, errorMessage, code), + new ErrorMessage(statusCode, errorMessage, code, detailCode), new JsonSerializerOptions { WriteIndented = true } ) ); diff --git a/src/AzureEventGridSimulator/Infrastructure/Extensions/KestrelServerOptionsExtensions.cs b/src/AzureEventGridSimulator/Infrastructure/Extensions/KestrelServerOptionsExtensions.cs index 214e969..76a1acc 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Extensions/KestrelServerOptionsExtensions.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Extensions/KestrelServerOptionsExtensions.cs @@ -9,25 +9,25 @@ public static KestrelServerOptions ConfigureSimulatorCertificate( this KestrelServerOptions options ) { - var configuration = options.ApplicationServices.GetService(); + var configuration = options.ApplicationServices.GetRequiredService(); - var certificateFile = configuration!["Kestrel:Certificates:Default:Path"]; + var certificateFile = configuration["Kestrel:Certificates:Default:Path"]; var certificateFileSpecified = !string.IsNullOrWhiteSpace(certificateFile); var certificatePassword = configuration["Kestrel:Certificates:Default:Password"]; var certificatePasswordSpecified = !string.IsNullOrWhiteSpace(certificatePassword); - X509Certificate2 certificate = null; + X509Certificate2? certificate = null; if (certificateFileSpecified && certificatePasswordSpecified) { // The certificate file and password was specified. #if NET9_0_OR_GREATER certificate = X509CertificateLoader.LoadPkcs12FromFile( - certificateFile, + certificateFile!, certificatePassword ); #else - certificate = new X509Certificate2(certificateFile, certificatePassword); + certificate = new X509Certificate2(certificateFile!, certificatePassword); #endif } else if (certificateFileSpecified) diff --git a/src/AzureEventGridSimulator/Infrastructure/Extensions/ServiceProviderExtensions.cs b/src/AzureEventGridSimulator/Infrastructure/Extensions/ServiceProviderExtensions.cs index f7f9731..f7ede46 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Extensions/ServiceProviderExtensions.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Extensions/ServiceProviderExtensions.cs @@ -8,7 +8,7 @@ public static class ServiceProviderExtensions { public SimulatorSettings SimulatorSettings() { - return provider.GetService(); + return provider.GetRequiredService(); } public IEnumerable EnabledTopics() diff --git a/src/AzureEventGridSimulator/Infrastructure/Extensions/SubscriptionSettingsFilterExtensions.cs b/src/AzureEventGridSimulator/Infrastructure/Extensions/SubscriptionSettingsFilterExtensions.cs index ed82fec..32bcab6 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Extensions/SubscriptionSettingsFilterExtensions.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Extensions/SubscriptionSettingsFilterExtensions.cs @@ -19,7 +19,7 @@ or AdvancedFilterSetting.AdvancedFilterOperatorType.StringNotBeginsWith or AdvancedFilterSetting.AdvancedFilterOperatorType.StringNotEndsWith; } - private static bool EvaluateAdvancedFilter(AdvancedFilterSetting filter, object value) + private static bool EvaluateAdvancedFilter(AdvancedFilterSetting filter, object? value) { bool retVal; @@ -64,7 +64,7 @@ private static bool EvaluateAdvancedFilter(AdvancedFilterSetting filter, object .Where(v => !string.IsNullOrEmpty(v)) .Any(filterValue => valueAsString.Contains( - filterValue, + filterValue!, StringComparison.OrdinalIgnoreCase ) ) @@ -82,7 +82,7 @@ private static bool EvaluateAdvancedFilter(AdvancedFilterSetting filter, object .Where(v => !string.IsNullOrEmpty(v)) .Any(filterValue => valueAsString.StartsWith( - filterValue, + filterValue!, StringComparison.OrdinalIgnoreCase ) ) @@ -100,7 +100,7 @@ private static bool EvaluateAdvancedFilter(AdvancedFilterSetting filter, object .Where(v => !string.IsNullOrEmpty(v)) .Any(filterValue => valueAsString.EndsWith( - filterValue, + filterValue!, StringComparison.OrdinalIgnoreCase ) ) @@ -137,7 +137,7 @@ private static bool EvaluateAdvancedFilter(AdvancedFilterSetting filter, object .Where(v => !string.IsNullOrEmpty(v)) .Any(filterValue => valueAsString.Contains( - filterValue, + filterValue!, StringComparison.OrdinalIgnoreCase ) ) @@ -154,7 +154,7 @@ private static bool EvaluateAdvancedFilter(AdvancedFilterSetting filter, object .Where(v => !string.IsNullOrEmpty(v)) .Any(filterValue => valueAsString.StartsWith( - filterValue, + filterValue!, StringComparison.OrdinalIgnoreCase ) ) @@ -171,7 +171,7 @@ private static bool EvaluateAdvancedFilter(AdvancedFilterSetting filter, object .Where(v => !string.IsNullOrEmpty(v)) .Any(filterValue => valueAsString.EndsWith( - filterValue, + filterValue!, StringComparison.OrdinalIgnoreCase ) ) @@ -198,8 +198,8 @@ private static bool EvaluateAdvancedFilter(AdvancedFilterSetting filter, object private static bool TryGetValue( this SimulatorEvent simulatorEvent, - string key, - out object value + string? key, + out object? value ) { value = null; @@ -266,7 +266,7 @@ private static bool TryGetNestedValue( object data, string[] pathParts, int startIndex, - out object value + out object? value ) { value = null; @@ -317,7 +317,7 @@ out object value } } - private static object ConvertJsonElement(JsonElement element) + private static object? ConvertJsonElement(JsonElement element) { return element.ValueKind switch { @@ -327,11 +327,71 @@ JsonValueKind.Number when element.TryGetInt64(out var l) => l, JsonValueKind.True => true, JsonValueKind.False => false, JsonValueKind.Null => null, + JsonValueKind.Array => ConvertJsonArray(element), _ => element.GetRawText(), }; } - private static double ToNumber(this object value) + private static List ConvertJsonArray(JsonElement arrayElement) + { + var result = new List(); + foreach (var item in arrayElement.EnumerateArray()) + { + result.Add(ConvertJsonElement(item)); + } + + return result; + } + + /// + /// Attempts to extract array elements from a value for array filtering. + /// Returns null if the value is not an array. + /// + private static IEnumerable? TryGetArrayElements(object? value) + { + return value switch + { + List list => list, + IEnumerable enumerable => enumerable.Cast(), + JsonElement { ValueKind: JsonValueKind.Array } jsonArray => ConvertJsonArray(jsonArray), + _ => null, + }; + } + + /// + /// Evaluates an advanced filter against a value, with optional array filtering support. + /// When enableArrayFiltering is true and the value is an array, returns true if ANY element matches. + /// + private static bool EvaluateWithArraySupport( + AdvancedFilterSetting filter, + object? value, + bool enableArrayFiltering + ) + { + if (!enableArrayFiltering) + { + return EvaluateAdvancedFilter(filter, value); + } + + // Check if the value is an array + var arrayElements = TryGetArrayElements(value); + if (arrayElements == null) + { + // Not an array, evaluate normally + return EvaluateAdvancedFilter(filter, value); + } + + // For negation operators on arrays, ALL elements must satisfy the condition + if (IsNegationOperator(filter.OperatorType)) + { + return arrayElements.All(element => EvaluateAdvancedFilter(filter, element)); + } + + // For positive operators on arrays, ANY element must satisfy the condition + return arrayElements.Any(element => EvaluateAdvancedFilter(filter, element)); + } + + private static double ToNumber(this object? value) { if (value == null) { @@ -360,7 +420,7 @@ private static bool Try(Func function, bool valueOnException = false) /// Checks if a number is within any of the specified ranges. /// Ranges are specified as arrays like [[min1, max1], [min2, max2]] in the Values collection. /// - private static bool IsNumberInRanges(double value, ICollection ranges) + private static bool IsNumberInRanges(double value, ICollection? ranges) { if (ranges == null || ranges.Count == 0) { @@ -414,7 +474,7 @@ private static bool IsNumberInRanges(double value, ICollection ranges) return false; } - private static bool TryGetValue(this EventGridEvent gridEvent, string key, out object value) + private static bool TryGetValue(this EventGridEvent gridEvent, string? key, out object? value) { var retval = false; value = null; @@ -485,7 +545,7 @@ public bool AcceptsEvent(SimulatorEvent simulatorEvent) return true; } - var subject = simulatorEvent.Subject ?? ""; + var subject = simulatorEvent.Subject; // Check event type filter var retVal = @@ -523,7 +583,11 @@ public bool AcceptsEvent(SimulatorEvent simulatorEvent) retVal = retVal && (filter.AdvancedFilters ?? Array.Empty()).All(af => - af.AcceptsEvent(simulatorEvent) + AcceptsAdvancedFilter( + af, + simulatorEvent, + filter.EnableAdvancedFilteringOnArrays + ) ); return retVal; @@ -534,25 +598,25 @@ public bool AcceptsEvent(SimulatorEvent simulatorEvent) /// public bool AcceptsEvent(EventGridEvent gridEvent) { - var retVal = filter == null; - - if (retVal) + if (filter == null) { return true; } // we have a filter to parse - retVal = + var retVal = filter.IncludedEventTypes == null || filter.IncludedEventTypes.Contains("All") || filter.IncludedEventTypes.Contains(gridEvent.EventType); + var subject = gridEvent.Subject; + // short circuit if we have decided the event type is not acceptable retVal = retVal && ( string.IsNullOrWhiteSpace(filter.SubjectBeginsWith) - || gridEvent.Subject.StartsWith( + || subject.StartsWith( filter.SubjectBeginsWith, filter.IsSubjectCaseSensitive ? StringComparison.Ordinal @@ -565,7 +629,7 @@ public bool AcceptsEvent(EventGridEvent gridEvent) retVal && ( string.IsNullOrWhiteSpace(filter.SubjectEndsWith) - || gridEvent.Subject.EndsWith( + || subject.EndsWith( filter.SubjectEndsWith, filter.IsSubjectCaseSensitive ? StringComparison.Ordinal @@ -576,69 +640,70 @@ public bool AcceptsEvent(EventGridEvent gridEvent) retVal = retVal && (filter.AdvancedFilters ?? Array.Empty()).All(af => - af.AcceptsEvent(gridEvent) + AcceptsAdvancedFilter(af, gridEvent, filter.EnableAdvancedFilteringOnArrays) ); return retVal; } } - extension(AdvancedFilterSetting filter) + /// + /// Evaluates an advanced filter against a SimulatorEvent with array filtering support. + /// + private static bool AcceptsAdvancedFilter( + AdvancedFilterSetting filter, + SimulatorEvent simulatorEvent, + bool enableArrayFiltering + ) { - private bool AcceptsEvent(SimulatorEvent simulatorEvent) - { - if (filter == null) - { - return true; - } - - var keyExists = simulatorEvent.TryGetValue(filter.Key, out var value); - var valueIsNull = keyExists && value == null; - - // Handle null check operators specially - they evaluate based on key existence - switch (filter.OperatorType) - { - case AdvancedFilterSetting.AdvancedFilterOperatorType.IsNullOrUndefined: - return !keyExists || valueIsNull; - case AdvancedFilterSetting.AdvancedFilterOperatorType.IsNotNull: - return keyExists && !valueIsNull; - } + var keyExists = simulatorEvent.TryGetValue(filter.Key, out var value); + var valueIsNull = keyExists && value == null; - // For "Not" operators, return true when key doesn't exist (per Azure docs) - if (!keyExists) - { - return IsNegationOperator(filter.OperatorType); - } - - return EvaluateAdvancedFilter(filter, value); + // Handle null check operators specially - they evaluate based on key existence + switch (filter.OperatorType) + { + case AdvancedFilterSetting.AdvancedFilterOperatorType.IsNullOrUndefined: + return !keyExists || valueIsNull; + case AdvancedFilterSetting.AdvancedFilterOperatorType.IsNotNull: + return keyExists && !valueIsNull; } - private bool AcceptsEvent(EventGridEvent gridEvent) + // For "Not" operators, return true when key doesn't exist (per Azure docs) + if (!keyExists) { - if (filter == null) - { - return true; - } + return IsNegationOperator(filter.OperatorType); + } - var keyExists = gridEvent.TryGetValue(filter.Key, out var value); - var valueIsNull = keyExists && value == null; + return EvaluateWithArraySupport(filter, value, enableArrayFiltering); + } - // Handle null check operators specially - they evaluate based on key existence - switch (filter.OperatorType) - { - case AdvancedFilterSetting.AdvancedFilterOperatorType.IsNullOrUndefined: - return !keyExists || valueIsNull; - case AdvancedFilterSetting.AdvancedFilterOperatorType.IsNotNull: - return keyExists && !valueIsNull; - } + /// + /// Evaluates an advanced filter against an EventGridEvent with array filtering support. + /// + private static bool AcceptsAdvancedFilter( + AdvancedFilterSetting filter, + EventGridEvent gridEvent, + bool enableArrayFiltering + ) + { + var keyExists = gridEvent.TryGetValue(filter.Key, out var value); + var valueIsNull = keyExists && value == null; - // For "Not" operators, return true when key doesn't exist (per Azure docs) - if (!keyExists) - { - return IsNegationOperator(filter.OperatorType); - } + // Handle null check operators specially - they evaluate based on key existence + switch (filter.OperatorType) + { + case AdvancedFilterSetting.AdvancedFilterOperatorType.IsNullOrUndefined: + return !keyExists || valueIsNull; + case AdvancedFilterSetting.AdvancedFilterOperatorType.IsNotNull: + return keyExists && !valueIsNull; + } - return EvaluateAdvancedFilter(filter, value); + // For "Not" operators, return true when key doesn't exist (per Azure docs) + if (!keyExists) + { + return IsNegationOperator(filter.OperatorType); } + + return EvaluateWithArraySupport(filter, value, enableArrayFiltering); } } diff --git a/src/AzureEventGridSimulator/Infrastructure/Mediator/Mediator.cs b/src/AzureEventGridSimulator/Infrastructure/Mediator/Mediator.cs index 6e137e5..c08ac1a 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Mediator/Mediator.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Mediator/Mediator.cs @@ -10,15 +10,7 @@ public class Mediator(IServiceProvider serviceProvider) : IMediator public Task Send(TRequest request, CancellationToken cancellationToken = default) where TRequest : IRequest { - var handler = serviceProvider.GetService>(); - - if (handler is null) - { - throw new InvalidOperationException( - $"No handler registered for request type {typeof(TRequest).Name}" - ); - } - + var handler = serviceProvider.GetRequiredService>(); return handler.Handle(request, cancellationToken); } @@ -33,16 +25,17 @@ public Task Send( requestType, typeof(TResponse) ); - var handler = serviceProvider.GetService(handlerType); - - if (handler is null) - { - throw new InvalidOperationException( - $"No handler registered for request type {requestType.Name}" + var handler = serviceProvider.GetRequiredService(handlerType); + var handleMethod = + handlerType.GetMethod("Handle") + ?? throw new InvalidOperationException( + $"Handle method not found on handler type {handlerType.Name}" ); - } - var handleMethod = handlerType.GetMethod("Handle"); - return (Task)handleMethod!.Invoke(handler, [request, cancellationToken])!; + var result = + handleMethod.Invoke(handler, [request, cancellationToken]) + ?? throw new InvalidOperationException($"Handler for {requestType.Name} returned null"); + + return (Task)result; } } diff --git a/src/AzureEventGridSimulator/Infrastructure/Middleware/EventGridMiddleware.cs b/src/AzureEventGridSimulator/Infrastructure/Middleware/EventGridMiddleware.cs index 3414630..2948bae 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Middleware/EventGridMiddleware.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Middleware/EventGridMiddleware.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Net; +using System.Net; using System.Text.Json; using AzureEventGridSimulator.Domain; using AzureEventGridSimulator.Domain.Entities; @@ -93,7 +91,12 @@ await ValidateNotificationRequest( ); } - await context.WriteErrorResponse(HttpStatusCode.BadRequest, errorMessage, null); + await context.WriteErrorResponse( + HttpStatusCode.BadRequest, + errorMessage, + null, + ErrorDetailCodes.InputJsonInvalid + ); } private static async Task TryReadBody(HttpContext context) @@ -151,17 +154,19 @@ ILogger logger // // Validate the key/ token supplied in the header. // - if ( - !string.IsNullOrWhiteSpace(topic.Key) - && !sasHeaderValidator.IsValid(context.Request.Headers, topic.Key) - ) + if (!string.IsNullOrWhiteSpace(topic.Key)) { - await context.WriteErrorResponse( - HttpStatusCode.Unauthorized, - "The request did not contain a valid aeg-sas-key or aeg-sas-token.", - null - ); - return; + var validationResult = sasHeaderValidator.Validate(context.Request.Headers, topic.Key); + if (!validationResult.IsValid) + { + await context.WriteErrorResponse( + HttpStatusCode.Unauthorized, + GetAuthErrorMessage(validationResult.FailureReason, topic.Name), + ErrorDetailCodes.Unauthorized, + ErrorDetailCodes.Unauthorized + ); + return; + } } context.Request.EnableBuffering(); @@ -200,6 +205,69 @@ await context.WriteErrorResponse( // Detect the schema (use configured input schema or auto-detect) // var detectedSchema = topic.InputSchema ?? schemaDetector.DetectSchema(context); + + // + // Validate content-type for CloudEvents schema + // + if (detectedSchema == EventSchema.CloudEventV1_0) + { + if (IsCloudEventsBinaryMode(context)) + { + // Binary mode: Content-Type is the data's content type + // Azure accepts application/json but rejects text/plain + if (!IsValidBinaryModeContentType(contentType)) + { + var errorMessage = + "The Content-Type header is either missing or it doesn't have a valid value. The content type header must either be application/cloudevents+json; charset=utf-8 or application/cloudevents-batch+json; charset=UTF-8."; + + RecordRejection( + eventHistoryService, + topic.Name, + topic.Port, + HttpStatusCode.UnsupportedMediaType, + errorMessage, + requestBody, + contentType + ); + + await context.WriteErrorResponse( + HttpStatusCode.UnsupportedMediaType, + errorMessage, + null, + ErrorDetailCodes.InvalidContentType + ); + return; + } + } + else + { + // Structured/batch mode: require CloudEvents content types + if (!IsValidCloudEventsContentType(contentType)) + { + var errorMessage = + "The Content-Type header is either missing or it doesn't have a valid value. The content type header must either be application/cloudevents+json; charset=utf-8 or application/cloudevents-batch+json; charset=UTF-8."; + + RecordRejection( + eventHistoryService, + topic.Name, + topic.Port, + HttpStatusCode.UnsupportedMediaType, + errorMessage, + requestBody, + contentType + ); + + await context.WriteErrorResponse( + HttpStatusCode.UnsupportedMediaType, + errorMessage, + null, + ErrorDetailCodes.InvalidContentType + ); + return; + } + } + } + var parser = parserFactory.GetParser(detectedSchema); SimulatorEvent[] events; @@ -221,7 +289,12 @@ await context.WriteErrorResponse( contentType ); - await context.WriteErrorResponse(HttpStatusCode.BadRequest, ex.Message, null); + await context.WriteErrorResponse( + HttpStatusCode.BadRequest, + ex.Message, + null, + ErrorDetailCodes.InputJsonInvalid + ); return; } @@ -238,7 +311,12 @@ await context.WriteErrorResponse( contentType ); - await context.WriteErrorResponse(HttpStatusCode.BadRequest, errorMessage, null); + await context.WriteErrorResponse( + HttpStatusCode.BadRequest, + errorMessage, + null, + ErrorDetailCodes.InputJsonInvalid + ); return; } @@ -297,7 +375,12 @@ await context.WriteErrorResponse( contentType ); - await context.WriteErrorResponse(HttpStatusCode.BadRequest, ex.Message, null); + await context.WriteErrorResponse( + HttpStatusCode.BadRequest, + ex.Message, + null, + ErrorDetailCodes.InputJsonInvalid + ); return; } @@ -409,4 +492,60 @@ private static bool IsHealthRequest(HttpContext context) StringComparison.OrdinalIgnoreCase ); } + + private static bool IsValidCloudEventsContentType(string? contentType) + { + if (string.IsNullOrWhiteSpace(contentType)) + { + return false; + } + + return contentType.Contains( + Constants.CloudEventsContentTypeBase, + StringComparison.OrdinalIgnoreCase + ) + || contentType.Contains( + Constants.CloudEventsBatchContentTypeBase, + StringComparison.OrdinalIgnoreCase + ); + } + + private static bool IsValidBinaryModeContentType(string? contentType) + { + if (string.IsNullOrWhiteSpace(contentType)) + { + return false; + } + + // In binary mode, Content-Type represents the data's content type + // Azure accepts application/json but rejects text/plain + return contentType.Contains("application/json", StringComparison.OrdinalIgnoreCase) + || contentType.Contains("application/xml", StringComparison.OrdinalIgnoreCase) + || contentType.Contains("application/octet-stream", StringComparison.OrdinalIgnoreCase); + } + + private static string GetAuthErrorMessage( + SasValidationFailureReason? failureReason, + string topicName + ) + { + var upperTopicName = topicName.ToUpperInvariant(); + return failureReason switch + { + SasValidationFailureReason.MissingKey => + "Request must contain one of the following authorization signature: aeg-sas-token, aeg-sas-key.", + SasValidationFailureReason.KeyMismatch => + $"The specified aeg-sas-key is invalid for topic '{upperTopicName}'.", + SasValidationFailureReason.InvalidBase64 => + $"The SAS key is not a valid Base-64 string for topic '{upperTopicName}'.", + SasValidationFailureReason.TokenExpired => + $"The specified SAS token has expired for topic '{upperTopicName}'.", + SasValidationFailureReason.SignatureMismatch => + $"The specified SAS token signature is invalid for topic '{upperTopicName}'.", + SasValidationFailureReason.InvalidTokenFormat => + $"The specified SAS token format is invalid for topic '{upperTopicName}'.", + _ => + "Request must contain one of the following authorization signature: aeg-sas-token, aeg-sas-key.", + }; + } } diff --git a/src/AzureEventGridSimulator/Infrastructure/Middleware/SasKeyValidator.cs b/src/AzureEventGridSimulator/Infrastructure/Middleware/SasKeyValidator.cs index dbbccc8..03b2879 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Middleware/SasKeyValidator.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Middleware/SasKeyValidator.cs @@ -7,9 +7,55 @@ namespace AzureEventGridSimulator.Infrastructure.Middleware; +/// +/// Result of SAS key validation. +/// +public record SasValidationResult(bool IsValid, SasValidationFailureReason? FailureReason = null); + +/// +/// Reasons for SAS validation failure. +/// +public enum SasValidationFailureReason +{ + /// + /// No authorization header was provided. + /// + MissingKey, + + /// + /// The aeg-sas-key value did not match. + /// + KeyMismatch, + + /// + /// The key is not a valid Base-64 string. + /// + InvalidBase64, + + /// + /// The token has expired. + /// + TokenExpired, + + /// + /// The token signature did not match. + /// + SignatureMismatch, + + /// + /// The token format is invalid. + /// + InvalidTokenFormat, +} + public class SasKeyValidator(TimeProvider timeProvider, ILogger logger) { public bool IsValid(IHeaderDictionary requestHeaders, string topicKey) + { + return Validate(requestHeaders, topicKey).IsValid; + } + + public SasValidationResult Validate(IHeaderDictionary requestHeaders, string topicKey) { if ( requestHeaders.Any(h => @@ -20,10 +66,10 @@ public bool IsValid(IHeaderDictionary requestHeaders, string topicKey) if (!string.Equals(requestHeaders[Constants.AegSasKeyHeader], topicKey)) { logger.LogError("'aeg-sas-key' value did not match the expected value!"); - return false; + return new SasValidationResult(false, SasValidationFailureReason.KeyMismatch); } - return true; + return new SasValidationResult(true); } if ( @@ -36,14 +82,20 @@ public bool IsValid(IHeaderDictionary requestHeaders, string topicKey) ) ) { - var token = requestHeaders[Constants.AegSasTokenHeader].First(); - if (!TokenIsValid(token, topicKey)) + var token = requestHeaders[Constants.AegSasTokenHeader].FirstOrDefault(); + if (token == null) + { + return new SasValidationResult(false, SasValidationFailureReason.MissingKey); + } + + var tokenResult = ValidateToken(token, topicKey); + if (!tokenResult.IsValid) { logger.LogError("'aeg-sas-token' value did not match the expected value!"); - return false; + return tokenResult; } - return true; + return new SasValidationResult(true); } if ( @@ -54,29 +106,43 @@ public bool IsValid(IHeaderDictionary requestHeaders, string topicKey) { var token = requestHeaders[HeaderNames.Authorization].ToString(); if ( - token.StartsWith(Constants.SasAuthorizationType) - && !TokenIsValid(token.Replace(Constants.SasAuthorizationType, "").Trim(), topicKey) + token.StartsWith(Constants.SasAuthorizationType, StringComparison.OrdinalIgnoreCase) ) { - logger.LogError( - "'Authorization: SharedAccessSignature' value did not match the expected value!" - ); - return false; + var tokenValue = token + .Replace(Constants.SasAuthorizationType, "", StringComparison.OrdinalIgnoreCase) + .Trim(); + + var tokenResult = ValidateToken(tokenValue, topicKey); + if (!tokenResult.IsValid) + { + logger.LogError( + "'Authorization: SharedAccessSignature' value did not match the expected value!" + ); + return tokenResult; + } + + return new SasValidationResult(true); } - return true; + return new SasValidationResult(true); } - return false; + return new SasValidationResult(false, SasValidationFailureReason.MissingKey); } - private bool TokenIsValid(string token, string key) + private SasValidationResult ValidateToken(string token, string key) { var query = HttpUtility.ParseQueryString(token); var decodedResource = HttpUtility.UrlDecode(query["r"], Encoding.UTF8); var decodedExpiration = HttpUtility.UrlDecode(query["e"], Encoding.UTF8); var encodedSignature = query["s"]; + if (string.IsNullOrEmpty(decodedExpiration) || string.IsNullOrEmpty(encodedSignature)) + { + return new SasValidationResult(false, SasValidationFailureReason.InvalidTokenFormat); + } + if ( !DateTimeOffset.TryParse( decodedExpiration, @@ -86,7 +152,7 @@ out var tokenExpiryDateTime || tokenExpiryDateTime.ToUniversalTime() <= timeProvider.GetUtcNow() ) { - return false; + return new SasValidationResult(false, SasValidationFailureReason.TokenExpired); } var encodedResource = HttpUtility.UrlEncode(decodedResource); @@ -94,23 +160,30 @@ out var tokenExpiryDateTime var unsignedSas = $"r={encodedResource}&e={encodedExpiration}"; - using var hmac = new HMACSHA256(Convert.FromBase64String(key)); - var signature = Convert.ToBase64String( - hmac.ComputeHash(Encoding.UTF8.GetBytes(unsignedSas)) - ); - var encodedComputedSignature = HttpUtility.UrlEncode(signature); - - if (encodedSignature == signature) + try { - return true; - } + using var hmac = new HMACSHA256(Convert.FromBase64String(key)); + var signature = Convert.ToBase64String( + hmac.ComputeHash(Encoding.UTF8.GetBytes(unsignedSas)) + ); + var encodedComputedSignature = HttpUtility.UrlEncode(signature); + + if (encodedSignature == signature) + { + return new SasValidationResult(true); + } - logger.LogWarning( - "{ExpectedSignature} != {MessageSignature}", - encodedComputedSignature, - signature - ); + logger.LogWarning( + "{ExpectedSignature} != {MessageSignature}", + encodedComputedSignature, + signature + ); - return false; + return new SasValidationResult(false, SasValidationFailureReason.SignatureMismatch); + } + catch (FormatException) + { + return new SasValidationResult(false, SasValidationFailureReason.InvalidBase64); + } } } diff --git a/src/AzureEventGridSimulator/Infrastructure/Settings/AdvancedFilterSetting.cs b/src/AzureEventGridSimulator/Infrastructure/Settings/AdvancedFilterSetting.cs index 0991164..3bf52bb 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Settings/AdvancedFilterSetting.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Settings/AdvancedFilterSetting.cs @@ -39,16 +39,16 @@ public enum AdvancedFilterOperatorType [JsonPropertyName("operatorType")] [JsonConverter(typeof(JsonStringEnumConverter))] - public AdvancedFilterOperatorType OperatorType { get; set; } + public AdvancedFilterOperatorType OperatorType { get; init; } [JsonPropertyName("key")] - public string Key { get; set; } + public string? Key { get; init; } [JsonPropertyName("value")] - public object Value { get; set; } + public object? Value { get; init; } [JsonPropertyName("values")] - public ICollection Values { get; set; } + public ICollection? Values { get; init; } internal void Validate() { @@ -134,7 +134,7 @@ public override string ToString() Key, OperatorType, Value ?? "null", - string.Join(", ", Values.HasItems() ? Values.Select(v => v.ToString()) : values), + string.Join(", ", Values?.Select(v => v.ToString()) ?? values), Guid.NewGuid() ); } diff --git a/src/AzureEventGridSimulator/Infrastructure/Settings/FilterSetting.cs b/src/AzureEventGridSimulator/Infrastructure/Settings/FilterSetting.cs index d8a154c..9658e0c 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Settings/FilterSetting.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Settings/FilterSetting.cs @@ -5,19 +5,27 @@ namespace AzureEventGridSimulator.Infrastructure.Settings; public class FilterSetting { [JsonPropertyName("includedEventTypes")] - public ICollection IncludedEventTypes { get; set; } + public ICollection? IncludedEventTypes { get; init; } [JsonPropertyName("isSubjectCaseSensitive")] - public bool IsSubjectCaseSensitive { get; set; } + public bool IsSubjectCaseSensitive { get; init; } [JsonPropertyName("subjectBeginsWith")] - public string SubjectBeginsWith { get; set; } + public string? SubjectBeginsWith { get; init; } [JsonPropertyName("subjectEndsWith")] - public string SubjectEndsWith { get; set; } + public string? SubjectEndsWith { get; init; } [JsonPropertyName("advancedFilters")] - public ICollection AdvancedFilters { get; set; } + public ICollection? AdvancedFilters { get; init; } + + /// + /// When true, advanced filters can match against array values in the event data. + /// If the event property is an array, the filter matches if ANY element satisfies the condition. + /// Added in API version 2020-10-15-preview. + /// + [JsonPropertyName("enableAdvancedFilteringOnArrays")] + public bool EnableAdvancedFilteringOnArrays { get; init; } internal void Validate() { @@ -30,7 +38,7 @@ internal void Validate() ); } - foreach (var advancedFilter in AdvancedFilters ?? Array.Empty()) + foreach (var advancedFilter in AdvancedFilters ?? []) { advancedFilter.Validate(); } diff --git a/src/AzureEventGridSimulator/Infrastructure/Settings/SimulatorSettings.cs b/src/AzureEventGridSimulator/Infrastructure/Settings/SimulatorSettings.cs index f02e692..a53126f 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Settings/SimulatorSettings.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Settings/SimulatorSettings.cs @@ -92,9 +92,17 @@ public void Validate() } // Validate filters - foreach (var filter in allSubscribers.Where(s => s.Filter != null).Select(s => s.Filter)) + foreach (var filter in allSubscribers.Where(s => s.Filter != null).Select(s => s.Filter!)) { filter.Validate(); } + + // Validate dashboard port is determinable if dashboard is enabled + if (DashboardEnabled && DashboardPort is null && !Topics.Any(t => !t.Disabled)) + { + throw new InvalidOperationException( + "Dashboard is enabled but no port is available. Either set 'dashboardPort' or enable at least one topic." + ); + } } } diff --git a/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/DeliveryPropertySettings.cs b/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/DeliveryPropertySettings.cs index 61ed428..6e42ee9 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/DeliveryPropertySettings.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/DeliveryPropertySettings.cs @@ -11,7 +11,7 @@ public class DeliveryPropertySettings /// Gets or sets the type of property: "static" or "dynamic". /// [JsonPropertyName("type")] - public string Type { get; set; } + public required string Type { get; init; } /// /// Gets or sets the value of the property. @@ -20,7 +20,7 @@ public class DeliveryPropertySettings /// "data.customerId"). /// [JsonPropertyName("value")] - public string Value { get; set; } + public required string Value { get; init; } /// /// Gets whether this is a static property. diff --git a/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/EventHubSubscriberSettings.cs b/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/EventHubSubscriberSettings.cs index a2cb936..e9d6ef0 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/EventHubSubscriberSettings.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/EventHubSubscriberSettings.cs @@ -13,45 +13,45 @@ public class EventHubSubscriberSettings : ISubscriberSettings /// Set during validation in SimulatorSettings. /// [JsonIgnore] - internal TopicSettings ParentTopic { get; set; } + internal TopicSettings? ParentTopic { get; set; } /// /// Gets or sets the Event Hub connection string. /// Either this OR (Namespace + SharedAccessKeyName + SharedAccessKey) must be provided. /// [JsonPropertyName("connectionString")] - public string ConnectionString { get; set; } + public string? ConnectionString { get; init; } /// /// Gets or sets the Event Hub namespace (without .servicebus.windows.net suffix). /// [JsonPropertyName("namespace")] - public string Namespace { get; set; } + public string? Namespace { get; init; } /// /// Gets or sets the shared access key name. /// [JsonPropertyName("sharedAccessKeyName")] - public string SharedAccessKeyName { get; set; } + public string? SharedAccessKeyName { get; init; } /// /// Gets or sets the shared access key. /// [JsonPropertyName("sharedAccessKey")] - public string SharedAccessKey { get; set; } + public string? SharedAccessKey { get; init; } /// /// Gets or sets the Event Hub name. /// [JsonPropertyName("eventHubName")] - public string EventHubName { get; set; } + public required string EventHubName { get; init; } /// /// Gets or sets the delivery properties to add to Event Hub messages. /// Keys are property names, values specify whether the property is static or dynamic. /// [JsonPropertyName("properties")] - public Dictionary Properties { get; set; } + public Dictionary? Properties { get; init; } /// /// Gets the connection string, either directly specified, built from components, or inherited from @@ -59,7 +59,7 @@ public class EventHubSubscriberSettings : ISubscriberSettings /// Falls back to topic-level defaults if not specified at subscriber level. /// [JsonIgnore] - public string EffectiveConnectionString + public string? EffectiveConnectionString { get { @@ -72,7 +72,7 @@ public string EffectiveConnectionString // Subscriber-level namespace components if (HasSubscriberNamespaceCredentials()) { - return BuildConnectionString(Namespace, SharedAccessKeyName, SharedAccessKey); + return BuildConnectionString(Namespace!, SharedAccessKeyName!, SharedAccessKey!); } // Fall back to topic-level connection string @@ -88,9 +88,9 @@ public string EffectiveConnectionString if (HasTopicNamespaceCredentials() && ParentTopic != null) { return BuildConnectionString( - ParentTopic.EventHubNamespace, - ParentTopic.EventHubSharedAccessKeyName, - ParentTopic.EventHubSharedAccessKey + ParentTopic.EventHubNamespace!, + ParentTopic.EventHubSharedAccessKeyName!, + ParentTopic.EventHubSharedAccessKey! ); } @@ -100,13 +100,13 @@ public string EffectiveConnectionString } [JsonPropertyName("name")] - public string Name { get; set; } + public required string Name { get; init; } [JsonPropertyName("filter")] - public FilterSetting Filter { get; set; } + public FilterSetting? Filter { get; init; } [JsonPropertyName("disabled")] - public bool Disabled { get; set; } + public bool Disabled { get; init; } /// /// Gets or sets the delivery schema for events sent to this subscriber. @@ -114,21 +114,21 @@ public string EffectiveConnectionString /// [JsonPropertyName("deliverySchema")] [JsonConverter(typeof(JsonStringEnumConverter))] - public EventSchema? DeliverySchema { get; set; } + public EventSchema? DeliverySchema { get; init; } /// /// Gets or sets the retry policy for this subscriber. /// If null, default Azure Event Grid retry behavior is used (enabled with 30 attempts, 24h TTL). /// [JsonPropertyName("retryPolicy")] - public RetryPolicySettings RetryPolicy { get; set; } = new(); + public RetryPolicySettings? RetryPolicy { get; init; } /// /// Gets or sets the dead-letter settings for this subscriber. /// Events that cannot be delivered are written to the dead-letter destination. /// [JsonPropertyName("deadLetter")] - public DeadLetterSettings DeadLetter { get; set; } = new(); + public DeadLetterSettings? DeadLetter { get; init; } [JsonIgnore] public string SubscriberType => "eventHub"; diff --git a/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/HttpSubscriberSettings.cs b/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/HttpSubscriberSettings.cs index 9231f5a..797c152 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/HttpSubscriberSettings.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/HttpSubscriberSettings.cs @@ -12,10 +12,10 @@ public class HttpSubscriberSettings : ISubscriberSettings private readonly DateTimeOffset _createdAt = DateTimeOffset.UtcNow; [JsonPropertyName("endpoint")] - public string Endpoint { get; set; } + public required string Endpoint { get; init; } [JsonPropertyName("disableValidation")] - public bool DisableValidation { get; set; } + public bool DisableValidation { get; init; } [JsonIgnore] public SubscriptionValidationStatus ValidationStatus { get; set; } @@ -24,13 +24,13 @@ public class HttpSubscriberSettings : ISubscriberSettings public Guid ValidationCode => GetValidationCode(); [JsonPropertyName("name")] - public string Name { get; set; } + public required string Name { get; init; } [JsonPropertyName("filter")] - public FilterSetting Filter { get; set; } + public FilterSetting? Filter { get; init; } [JsonPropertyName("disabled")] - public bool Disabled { get; set; } + public bool Disabled { get; init; } /// /// Gets or sets the delivery schema for events sent to this subscriber. @@ -38,21 +38,21 @@ public class HttpSubscriberSettings : ISubscriberSettings /// [JsonPropertyName("deliverySchema")] [JsonConverter(typeof(JsonStringEnumConverter))] - public EventSchema? DeliverySchema { get; set; } + public EventSchema? DeliverySchema { get; init; } /// /// Gets or sets the retry policy for this subscriber. /// If null, default Azure Event Grid retry behavior is used (enabled with 30 attempts, 24h TTL). /// [JsonPropertyName("retryPolicy")] - public RetryPolicySettings RetryPolicy { get; set; } = new(); + public RetryPolicySettings? RetryPolicy { get; init; } /// /// Gets or sets the dead-letter settings for this subscriber. /// Events that cannot be delivered are written to the dead-letter destination. /// [JsonPropertyName("deadLetter")] - public DeadLetterSettings DeadLetter { get; set; } = new(); + public DeadLetterSettings? DeadLetter { get; init; } [JsonIgnore] public string SubscriberType => "http"; diff --git a/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/ISubscriberSettings.cs b/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/ISubscriberSettings.cs index 25cce21..0fe0a62 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/ISubscriberSettings.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/ISubscriberSettings.cs @@ -8,25 +8,25 @@ namespace AzureEventGridSimulator.Infrastructure.Settings.Subscribers; public interface ISubscriberSettings { /// - /// Gets or sets the name of the subscription. + /// Gets the name of the subscription. /// - string Name { get; set; } + string Name { get; } /// - /// Gets or sets the filter settings for this subscription. + /// Gets the filter settings for this subscription. /// - FilterSetting Filter { get; set; } + FilterSetting? Filter { get; } /// - /// Gets or sets whether this subscription is disabled. + /// Gets whether this subscription is disabled. /// - bool Disabled { get; set; } + bool Disabled { get; } /// - /// Gets or sets the delivery schema for events sent to this subscriber. + /// Gets the delivery schema for events sent to this subscriber. /// If null, uses the topic's output schema or the original event schema. /// - EventSchema? DeliverySchema { get; set; } + EventSchema? DeliverySchema { get; } /// /// Gets the type of subscriber (http, serviceBus, etc.). @@ -34,16 +34,16 @@ public interface ISubscriberSettings string SubscriberType { get; } /// - /// Gets or sets the retry policy for this subscriber. + /// Gets the retry policy for this subscriber. /// If null, default Azure Event Grid retry behavior is used. /// - RetryPolicySettings RetryPolicy { get; set; } + RetryPolicySettings? RetryPolicy { get; } /// - /// Gets or sets the dead-letter settings for this subscriber. + /// Gets the dead-letter settings for this subscriber. /// Events that cannot be delivered are written to the dead-letter destination. /// - DeadLetterSettings DeadLetter { get; set; } + DeadLetterSettings? DeadLetter { get; } /// /// Validates the subscriber settings. diff --git a/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/ServiceBusSubscriberSettings.cs b/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/ServiceBusSubscriberSettings.cs index 42c54db..c381edf 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/ServiceBusSubscriberSettings.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/ServiceBusSubscriberSettings.cs @@ -13,57 +13,60 @@ public class ServiceBusSubscriberSettings : ISubscriberSettings /// Set during validation in SimulatorSettings. /// [JsonIgnore] - internal TopicSettings ParentTopic { get; set; } + internal TopicSettings? ParentTopic { get; set; } /// /// Gets or sets the Service Bus connection string. /// Either this OR (Namespace + SharedAccessKeyName + SharedAccessKey) must be provided. /// [JsonPropertyName("connectionString")] - public string ConnectionString { get; set; } + public string? ConnectionString { get; init; } /// /// Gets or sets the Service Bus namespace (without .servicebus.windows.net suffix). /// [JsonPropertyName("namespace")] - public string Namespace { get; set; } + public string? Namespace { get; init; } /// /// Gets or sets the shared access key name. /// [JsonPropertyName("sharedAccessKeyName")] - public string SharedAccessKeyName { get; set; } + public string? SharedAccessKeyName { get; init; } /// /// Gets or sets the shared access key. /// [JsonPropertyName("sharedAccessKey")] - public string SharedAccessKey { get; set; } + public string? SharedAccessKey { get; init; } /// /// Gets or sets the topic name. Either Topic or Queue must be specified, but not both. /// [JsonPropertyName("topic")] - public string Topic { get; set; } + public string? Topic { get; init; } /// /// Gets or sets the queue name. Either Topic or Queue must be specified, but not both. /// [JsonPropertyName("queue")] - public string Queue { get; set; } + public string? Queue { get; init; } /// /// Gets or sets the delivery properties to add to Service Bus messages. /// Keys are property names, values specify whether the property is static or dynamic. /// [JsonPropertyName("properties")] - public Dictionary Properties { get; set; } + public Dictionary? Properties { get; init; } /// /// Gets the destination name (either topic or queue). /// [JsonIgnore] - public string DestinationName => !string.IsNullOrWhiteSpace(Topic) ? Topic : Queue; + public string DestinationName => + !string.IsNullOrWhiteSpace(Topic) + ? Topic + : Queue ?? throw new InvalidOperationException("Neither Topic nor Queue is set"); /// /// Gets whether the destination is a topic (vs a queue). @@ -77,7 +80,7 @@ public class ServiceBusSubscriberSettings : ISubscriberSettings /// Falls back to topic-level defaults if not specified at subscriber level. /// [JsonIgnore] - public string EffectiveConnectionString + public string? EffectiveConnectionString { get { @@ -90,7 +93,7 @@ public string EffectiveConnectionString // Subscriber-level namespace components if (HasSubscriberNamespaceCredentials()) { - return BuildConnectionString(Namespace, SharedAccessKeyName, SharedAccessKey); + return BuildConnectionString(Namespace!, SharedAccessKeyName!, SharedAccessKey!); } // Fall back to topic-level connection string @@ -108,9 +111,9 @@ public string EffectiveConnectionString if (ParentTopic != null) { return BuildConnectionString( - ParentTopic.ServiceBusNamespace, - ParentTopic.ServiceBusSharedAccessKeyName, - ParentTopic.ServiceBusSharedAccessKey + ParentTopic.ServiceBusNamespace!, + ParentTopic.ServiceBusSharedAccessKeyName!, + ParentTopic.ServiceBusSharedAccessKey! ); } } @@ -121,13 +124,13 @@ public string EffectiveConnectionString } [JsonPropertyName("name")] - public string Name { get; set; } + public required string Name { get; init; } [JsonPropertyName("filter")] - public FilterSetting Filter { get; set; } + public FilterSetting? Filter { get; init; } [JsonPropertyName("disabled")] - public bool Disabled { get; set; } + public bool Disabled { get; init; } /// /// Gets or sets the delivery schema for events sent to this subscriber. @@ -135,21 +138,21 @@ public string EffectiveConnectionString /// [JsonPropertyName("deliverySchema")] [JsonConverter(typeof(JsonStringEnumConverter))] - public EventSchema? DeliverySchema { get; set; } + public EventSchema? DeliverySchema { get; init; } /// /// Gets or sets the retry policy for this subscriber. /// If null, default Azure Event Grid retry behavior is used (enabled with 30 attempts, 24h TTL). /// [JsonPropertyName("retryPolicy")] - public RetryPolicySettings RetryPolicy { get; set; } = new(); + public RetryPolicySettings? RetryPolicy { get; init; } /// /// Gets or sets the dead-letter settings for this subscriber. /// Events that cannot be delivered are written to the dead-letter destination. /// [JsonPropertyName("deadLetter")] - public DeadLetterSettings DeadLetter { get; set; } = new(); + public DeadLetterSettings? DeadLetter { get; init; } [JsonIgnore] public string SubscriberType => "serviceBus"; diff --git a/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/StorageQueueSubscriberSettings.cs b/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/StorageQueueSubscriberSettings.cs index 8aa04bd..95fb7eb 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/StorageQueueSubscriberSettings.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/StorageQueueSubscriberSettings.cs @@ -13,37 +13,37 @@ public class StorageQueueSubscriberSettings : ISubscriberSettings /// Set during validation in SimulatorSettings. /// [JsonIgnore] - internal TopicSettings ParentTopic { get; set; } + internal TopicSettings? ParentTopic { get; set; } /// /// Gets or sets the Storage Queue connection string. /// [JsonPropertyName("connectionString")] - public string ConnectionString { get; set; } + public string? ConnectionString { get; init; } /// /// Gets or sets the queue name. /// [JsonPropertyName("queueName")] - public string QueueName { get; set; } + public required string QueueName { get; init; } /// /// Gets the effective connection string, either from subscriber or topic level. /// [JsonIgnore] - public string EffectiveConnectionString => + public string? EffectiveConnectionString => !string.IsNullOrWhiteSpace(ConnectionString) ? ConnectionString : ParentTopic?.StorageQueueConnectionString; [JsonPropertyName("name")] - public string Name { get; set; } + public required string Name { get; init; } [JsonPropertyName("filter")] - public FilterSetting Filter { get; set; } + public FilterSetting? Filter { get; init; } [JsonPropertyName("disabled")] - public bool Disabled { get; set; } + public bool Disabled { get; init; } /// /// Gets or sets the delivery schema for events sent to this subscriber. @@ -51,21 +51,21 @@ public class StorageQueueSubscriberSettings : ISubscriberSettings /// [JsonPropertyName("deliverySchema")] [JsonConverter(typeof(JsonStringEnumConverter))] - public EventSchema? DeliverySchema { get; set; } + public EventSchema? DeliverySchema { get; init; } /// /// Gets or sets the retry policy for this subscriber. /// If null, default Azure Event Grid retry behavior is used (enabled with 30 attempts, 24h TTL). /// [JsonPropertyName("retryPolicy")] - public RetryPolicySettings RetryPolicy { get; set; } = new(); + public RetryPolicySettings? RetryPolicy { get; init; } /// /// Gets or sets the dead-letter settings for this subscriber. /// Events that cannot be delivered are written to the dead-letter destination. /// [JsonPropertyName("deadLetter")] - public DeadLetterSettings DeadLetter { get; set; } = new(); + public DeadLetterSettings? DeadLetter { get; init; } [JsonIgnore] public string SubscriberType => "storageQueue"; diff --git a/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/SubscribersSettings.cs b/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/SubscribersSettings.cs index ab04b79..0ea4a5a 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/SubscribersSettings.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/SubscribersSettings.cs @@ -11,25 +11,25 @@ public class SubscribersSettings /// Gets or sets HTTP webhook subscribers. /// [JsonPropertyName("http")] - public HttpSubscriberSettings[] Http { get; set; } = []; + public HttpSubscriberSettings[]? Http { get; set; } /// /// Gets or sets Azure Service Bus subscribers. /// [JsonPropertyName("serviceBus")] - public ServiceBusSubscriberSettings[] ServiceBus { get; set; } = []; + public ServiceBusSubscriberSettings[]? ServiceBus { get; set; } /// /// Gets or sets Azure Storage Queue subscribers. /// [JsonPropertyName("storageQueue")] - public StorageQueueSubscriberSettings[] StorageQueue { get; set; } = []; + public StorageQueueSubscriberSettings[]? StorageQueue { get; set; } /// /// Gets or sets Azure Event Hub subscribers. /// [JsonPropertyName("eventHub")] - public EventHubSubscriberSettings[] EventHub { get; set; } = []; + public EventHubSubscriberSettings[]? EventHub { get; set; } /// /// Gets all subscribers of all types. diff --git a/src/AzureEventGridSimulator/Infrastructure/Settings/SubscriptionSettings.cs b/src/AzureEventGridSimulator/Infrastructure/Settings/SubscriptionSettings.cs index b897d85..ba19bf9 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Settings/SubscriptionSettings.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Settings/SubscriptionSettings.cs @@ -9,19 +9,19 @@ public class SubscriptionSettings private readonly DateTimeOffset _createdAt = DateTimeOffset.UtcNow; [JsonPropertyName("name")] - public string Name { get; set; } + public required string Name { get; init; } [JsonPropertyName("endpoint")] - public string Endpoint { get; set; } + public required string Endpoint { get; init; } [JsonPropertyName("filter")] - public FilterSetting Filter { get; set; } + public FilterSetting? Filter { get; init; } [JsonPropertyName("disableValidation")] - public bool DisableValidation { get; set; } + public bool DisableValidation { get; init; } [JsonPropertyName("disabled")] - public bool Disabled { get; set; } + public bool Disabled { get; init; } /// /// Gets or sets the delivery schema for events sent to this subscriber. @@ -29,7 +29,7 @@ public class SubscriptionSettings /// [JsonPropertyName("deliverySchema")] [JsonConverter(typeof(JsonStringEnumConverter))] - public EventSchema? DeliverySchema { get; set; } + public EventSchema? DeliverySchema { get; init; } [JsonIgnore] public SubscriptionValidationStatus ValidationStatus { get; set; } diff --git a/src/AzureEventGridSimulator/Infrastructure/Settings/TopicSettings.cs b/src/AzureEventGridSimulator/Infrastructure/Settings/TopicSettings.cs index 0137495..5eb5591 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Settings/TopicSettings.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Settings/TopicSettings.cs @@ -7,16 +7,16 @@ namespace AzureEventGridSimulator.Infrastructure.Settings; public class TopicSettings { [JsonPropertyName("key")] - public string Key { get; set; } + public string? Key { get; init; } [JsonPropertyName("name")] - public string Name { get; set; } + public required string Name { get; init; } [JsonPropertyName("port")] - public int Port { get; set; } + public int Port { get; init; } [JsonPropertyName("disabled")] - public bool Disabled { get; set; } + public bool Disabled { get; init; } /// /// Gets or sets the subscribers for this topic. @@ -24,7 +24,7 @@ public class TopicSettings /// [JsonPropertyName("subscribers")] [JsonConverter(typeof(SubscribersSettingsConverter))] - public SubscribersSettings Subscribers { get; set; } = new(); + public SubscribersSettings Subscribers { get; init; } = new(); /// /// Gets or sets the expected input schema for events published to this topic. @@ -32,7 +32,7 @@ public class TopicSettings /// [JsonPropertyName("inputSchema")] [JsonConverter(typeof(JsonStringEnumConverter))] - public EventSchema? InputSchema { get; set; } + public EventSchema? InputSchema { get; init; } /// /// Gets or sets the output schema for events delivered to subscribers. @@ -40,64 +40,64 @@ public class TopicSettings /// [JsonPropertyName("outputSchema")] [JsonConverter(typeof(JsonStringEnumConverter))] - public EventSchema? OutputSchema { get; set; } + public EventSchema? OutputSchema { get; init; } /// /// Gets or sets the default Service Bus connection string for subscribers. /// Subscribers can override this by specifying their own connection string or namespace credentials. /// [JsonPropertyName("serviceBusConnectionString")] - public string ServiceBusConnectionString { get; set; } + public string? ServiceBusConnectionString { get; init; } /// /// Gets or sets the default Service Bus namespace (without .servicebus.windows.net suffix). /// Used with ServiceBusSharedAccessKeyName and ServiceBusSharedAccessKey to build a connection string. /// [JsonPropertyName("serviceBusNamespace")] - public string ServiceBusNamespace { get; set; } + public string? ServiceBusNamespace { get; init; } /// /// Gets or sets the default Service Bus shared access key name. /// [JsonPropertyName("serviceBusSharedAccessKeyName")] - public string ServiceBusSharedAccessKeyName { get; set; } + public string? ServiceBusSharedAccessKeyName { get; init; } /// /// Gets or sets the default Service Bus shared access key. /// [JsonPropertyName("serviceBusSharedAccessKey")] - public string ServiceBusSharedAccessKey { get; set; } + public string? ServiceBusSharedAccessKey { get; init; } /// /// Gets or sets the default Storage Queue connection string for subscribers. /// Subscribers can override this by specifying their own connection string. /// [JsonPropertyName("storageQueueConnectionString")] - public string StorageQueueConnectionString { get; set; } + public string? StorageQueueConnectionString { get; init; } /// /// Gets or sets the default Event Hub connection string for subscribers. /// Subscribers can override this by specifying their own connection string or namespace credentials. /// [JsonPropertyName("eventHubConnectionString")] - public string EventHubConnectionString { get; set; } + public string? EventHubConnectionString { get; init; } /// /// Gets or sets the default Event Hub namespace (without .servicebus.windows.net suffix). /// Used with EventHubSharedAccessKeyName and EventHubSharedAccessKey to build a connection string. /// [JsonPropertyName("eventHubNamespace")] - public string EventHubNamespace { get; set; } + public string? EventHubNamespace { get; init; } /// /// Gets or sets the default Event Hub shared access key name. /// [JsonPropertyName("eventHubSharedAccessKeyName")] - public string EventHubSharedAccessKeyName { get; set; } + public string? EventHubSharedAccessKeyName { get; init; } /// /// Gets or sets the default Event Hub shared access key. /// [JsonPropertyName("eventHubSharedAccessKey")] - public string EventHubSharedAccessKey { get; set; } + public string? EventHubSharedAccessKey { get; init; } } diff --git a/src/AzureEventGridSimulator/Infrastructure/ValidationIpAddressProvider.cs b/src/AzureEventGridSimulator/Infrastructure/ValidationIpAddressProvider.cs index 65df7f3..7fe51f6 100644 --- a/src/AzureEventGridSimulator/Infrastructure/ValidationIpAddressProvider.cs +++ b/src/AzureEventGridSimulator/Infrastructure/ValidationIpAddressProvider.cs @@ -6,7 +6,7 @@ namespace AzureEventGridSimulator.Infrastructure; public class ValidationIpAddressProvider { - private static string _ipAddress; + private static string? _ipAddress; private static readonly object _lock = new(); public string Create() @@ -17,7 +17,7 @@ public string Create() .SelectMany(o => o.GetIPProperties().DnsAddresses) .FirstOrDefault(ip => ip.AddressFamily == AddressFamily.InterNetwork - && !ip.ToString().StartsWith("172") + && !ip.ToString().StartsWith("172", StringComparison.Ordinal) && !IPAddress.IsLoopback(ip) ) ?? IPAddress.Loopback diff --git a/src/AzureEventGridSimulator/Program.cs b/src/AzureEventGridSimulator/Program.cs index 3e48d80..f99423b 100644 --- a/src/AzureEventGridSimulator/Program.cs +++ b/src/AzureEventGridSimulator/Program.cs @@ -39,8 +39,8 @@ public static async Task Main(string[] args) app.UseEventGridMiddleware(); // Conditionally enable dashboard based on settings - var simulatorSettings = app.Services.GetService(); - if (simulatorSettings?.DashboardEnabled ?? true) + var simulatorSettings = app.Services.GetRequiredService(); + if (simulatorSettings.DashboardEnabled) { app.UseDashboard(); } @@ -53,7 +53,7 @@ public static async Task Main(string[] args) app.MapDefaultEndpoints(); #endif - if (simulatorSettings?.DashboardEnabled ?? true) + if (simulatorSettings.DashboardEnabled) { app.MapDashboardEndpoints(); } @@ -95,9 +95,9 @@ IHostApplicationLifetime lifetime { Log.Verbose("Started"); - var simulatorSettings = app.ApplicationServices.GetService(); + var simulatorSettings = app.ApplicationServices.GetRequiredService(); - if (simulatorSettings is null || simulatorSettings.Topics.Length == 0) + if (simulatorSettings.Topics.Length == 0) { DisplayConfigurationHelp(); lifetime.StopApplication(); @@ -113,14 +113,7 @@ IHostApplicationLifetime lifetime return; } - var mediator = app.ApplicationServices.GetService(); - - if (mediator is null) - { - Log.Fatal("Required component was not found. The application will now exit"); - lifetime.StopApplication(); - return; - } + var mediator = app.ApplicationServices.GetRequiredService(); await mediator.Send(new ValidateAllSubscriptionsCommand()); @@ -147,17 +140,17 @@ IHostApplicationLifetime lifetime } // Log dashboard availability + // Note: Validation ensures DashboardPort is set or at least one topic is enabled if (simulatorSettings.DashboardEnabled) { - var firstEnabledTopic = simulatorSettings.Topics.FirstOrDefault(t => !t.Disabled); - var dashboardPort = simulatorSettings.DashboardPort ?? firstEnabledTopic?.Port ?? 0; - if (dashboardPort > 0) - { - Log.Information( - "Dashboard available at https://localhost:{Port}/dashboard", - dashboardPort - ); - } + var dashboardPort = + simulatorSettings.DashboardPort + ?? simulatorSettings.Topics.First(t => !t.Disabled).Port; + + Log.Information( + "Dashboard available at https://localhost:{Port}/dashboard", + dashboardPort + ); } Log.Information("It's alive !"); @@ -171,7 +164,7 @@ IHostApplicationLifetime lifetime private static void DisplayConfigurationHelp() { - var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "Unknown"; + var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0"; Console.WriteLine(); Console.WriteLine($"Azure Event Grid Simulator v{version}"); @@ -253,7 +246,7 @@ private static IConfigurationRoot BuildConfiguration(string[] args) .AddCustomSimulatorConfigFileIfSpecified(environmentAndCommandLineConfiguration) .AddEnvironmentVariablesAndCommandLine(args) .AddInMemoryCollection( - new Dictionary + new Dictionary { ["AEGS_Serilog__Using__0"] = "Serilog.Sinks.Console", ["AEGS_Serilog__Using__1"] = "Serilog.Sinks.File", @@ -365,7 +358,7 @@ IConfiguration configuration .Enrich.WithProperty("Application", nameof(AzureEventGridSimulator)) .Enrich.WithProperty( "Version", - Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "Unknown" + Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0" ) // The sensible defaults .MinimumLevel.Is(LogEventLevel.Information) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index df41c45..4c3a237 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -3,12 +3,20 @@ net10.0 latest enable - disable - false + enable + true true true + + + + all + runtime; build; native; contentfiles; analyzers + + + none false diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index ab64fa7..b85790b 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -31,6 +31,8 @@ + + diff --git a/src/environments/Azure Event Grid (CloudEvents Schema).postman_environment.json b/src/environments/Azure Event Grid (CloudEvents Schema).postman_environment.json new file mode 100644 index 0000000..ea04967 --- /dev/null +++ b/src/environments/Azure Event Grid (CloudEvents Schema).postman_environment.json @@ -0,0 +1,19 @@ +{ + "id": "azure-cloudevents", + "name": "Azure Event Grid (CloudEvents Schema)", + "values": [ + { + "key": "baseUrl", + "value": "https://YOUR-CLOUDEVENTS-TOPIC.YOUR-REGION.eventgrid.azure.net", + "type": "default", + "enabled": true + }, + { + "key": "sasKey", + "value": "YOUR-CLOUDEVENTS-TOPIC-KEY", + "type": "secret", + "enabled": true + } + ], + "_postman_variable_scope": "environment" +} diff --git a/src/environments/Azure Event Grid (EventGrid Schema).postman_environment.json b/src/environments/Azure Event Grid (EventGrid Schema).postman_environment.json new file mode 100644 index 0000000..3da33da --- /dev/null +++ b/src/environments/Azure Event Grid (EventGrid Schema).postman_environment.json @@ -0,0 +1,19 @@ +{ + "id": "azure-eventgrid", + "name": "Azure Event Grid (EventGrid Schema)", + "values": [ + { + "key": "baseUrl", + "value": "https://YOUR-EVENTGRID-TOPIC.YOUR-REGION.eventgrid.azure.net", + "type": "default", + "enabled": true + }, + { + "key": "sasKey", + "value": "YOUR-EVENTGRID-TOPIC-KEY", + "type": "secret", + "enabled": true + } + ], + "_postman_variable_scope": "environment" +} diff --git a/src/environments/Local Simulator.postman_environment.json b/src/environments/Local Simulator.postman_environment.json new file mode 100644 index 0000000..042789d --- /dev/null +++ b/src/environments/Local Simulator.postman_environment.json @@ -0,0 +1,19 @@ +{ + "id": "local-simulator", + "name": "Local Simulator", + "values": [ + { + "key": "baseUrl", + "value": "https://127.0.0.1:60101", + "type": "default", + "enabled": true + }, + { + "key": "sasKey", + "value": "TheLocal+DevelopmentKey=", + "type": "secret", + "enabled": true + } + ], + "_postman_variable_scope": "environment" +} diff --git a/wiki b/wiki index 4ee0692..8638568 160000 --- a/wiki +++ b/wiki @@ -1 +1 @@ -Subproject commit 4ee0692353565d405255ca12dfe0f75367eb9c37 +Subproject commit 8638568a117a71446e81eb7a927be343c0f0f963