diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index dde97a6b0..6c8d89755 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -54,6 +54,36 @@ jobs: php: ['8.2', '8.3', '8.4'] os: ['ubuntu-latest'] + services: + mysql: + image: mariadb + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: icinga_unittest + MYSQL_USER: icinga_unittest + MYSQL_PASSWORD: icinga_unittest + options: >- + --health-cmd "mariadb -s -uroot -proot -e'SHOW DATABASES;' 2> /dev/null | grep icinga_unittest > test" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 3306/tcp + + pgsql: + image: postgres + env: + POSTGRES_USER: icinga_unittest + POSTGRES_PASSWORD: icinga_unittest + POSTGRES_DB: icinga_unittest + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432/tcp + steps: - name: Checkout code base uses: actions/checkout@v4 @@ -75,7 +105,32 @@ jobs: git clone --depth 1 -b snapshot/nightly https://github.com/Icinga/icinga-php-library.git _libraries/ipl git clone --depth 1 -b snapshot/nightly https://github.com/Icinga/icinga-php-thirdparty.git _libraries/vendor + - name: Initialize Icinga Web + run: | + mysql --host="127.0.0.1" --port="${{ job.services.mysql.ports['3306'] }}" --user="root" --password="root" \ + -e "CREATE DATABASE icingaweb; CREATE USER icingaweb@'%' IDENTIFIED BY 'icingaweb'; GRANT ALL ON icingaweb.* TO icingaweb@'%';" + PGPASSWORD=icinga_unittest psql --host="127.0.0.1" --port="${{ job.services.pgsql.ports['5432'] }}" \ + --username "icinga_unittest" -c "CREATE DATABASE icingaweb;" + - name: PHPUnit env: ICINGAWEB_LIBDIR: _libraries + ICINGAWEB_PATH: _icingaweb2 + ICINGA_NOTIFICATIONS_SCHEMA: test/schema + MYSQL_TESTDB: icinga_unittest + MYSQL_TESTDB_HOST: 127.0.0.1 + MYSQL_TESTDB_PORT: ${{ job.services.mysql.ports['3306'] }} + MYSQL_TESTDB_USER: icinga_unittest + MYSQL_TESTDB_PASSWORD: icinga_unittest + MYSQL_ICINGAWEBDB: icingaweb + MYSQL_ICINGAWEBDB_PASSWORD: icingaweb + MYSQL_ICINGAWEBDB_USER: icingaweb + PGSQL_TESTDB: icinga_unittest + PGSQL_TESTDB_HOST: 127.0.0.1 + PGSQL_TESTDB_PORT: ${{ job.services.pgsql.ports['5432'] }} + PGSQL_TESTDB_USER: icinga_unittest + PGSQL_TESTDB_PASSWORD: icinga_unittest + PGSQL_ICINGAWEBDB: icingaweb + PGSQL_ICINGAWEBDB_PASSWORD: icinga_unittest + PGSQL_ICINGAWEBDB_USER: icinga_unittest run: phpunit --bootstrap _icingaweb2/test/php/bootstrap.php diff --git a/application/clicommands/OpenapiCommand.php b/application/clicommands/OpenapiCommand.php new file mode 100644 index 000000000..562cec4d2 --- /dev/null +++ b/application/clicommands/OpenapiCommand.php @@ -0,0 +1,154 @@ +params->get('dir', '/library/Notifications/Api/'); + $exclude = $this->params->get('exclude'); + $include = $this->params->get('include'); + $outputPath = $this->params->get('output'); + $apiVersion = $this->params->get('api-version', 'v1'); + $oadVersion = $this->params->get('oad-version', '3.1.0'); + + $notificationsPath = Icinga::app()->getModuleManager()->getModule('notifications')->getBaseDir(); + $directory = $notificationsPath . $directoryInNotifications; + + $baseDirectory = realpath($directory); + if ($baseDirectory === false || !is_dir($baseDirectory)) { + throw new RuntimeException("Invalid directory: {$directory}"); + } + + $exclude = isset($exclude) ? array_map('trim', explode(',', $exclude)) : []; + $include = isset($include) ? array_map('trim', explode(',', $include)) : []; + $outputPath = $notificationsPath . ($outputPath ?? '/doc/api/api-' . $apiVersion . '-public.json'); + + $files = $this->collectPhpFiles($baseDirectory, $exclude, $include); + + echo "→ Scanning directory: $baseDirectory\n"; + echo "→ Found " . count($files) . " PHP files\n"; +// die; + + $generator = new Generator(new PsrLogger()); + $generator->setVersion($oadVersion); + $generator->getProcessorPipeline()->add(new AddGlobal401Response()); + + try { + $openapi = $generator->generate($files); + + $json = $openapi->toJson( + JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_IGNORE | JSON_PRETTY_PRINT + ); + + $dir = dirname($outputPath); + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + + file_put_contents($outputPath, $json); + + echo "✅ OpenAPI documentation written to: $outputPath\n"; + } catch (Throwable $e) { + fwrite(STDERR, "❌ Error generating OpenAPI: " . $e->getMessage() . "\n"); + exit(1); + } + } + + /** + * Recursively scan a directory for PHP files. + */ + function collectPhpFiles(string $baseDirectory, array $exclude, array $include): array + { + $baseDirectory = rtrim($baseDirectory, '/') . '/'; + if (! is_dir($baseDirectory)) { + throw new RuntimeException("Directory $baseDirectory does not exist"); + } + if (! is_readable($baseDirectory)) { + throw new RuntimeException("Directory $baseDirectory is not readable"); + } + + $files = []; + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($baseDirectory, FilesystemIterator::SKIP_DOTS) + ); + +// echo PHP_EOL; +// var_dump($iterator); +// echo PHP_EOL . PHP_EOL; +// die(); + + /** @var SplFileInfo $file */ + foreach ($iterator as $file) { + if (! $file->isFile() || $file->getExtension() !== 'php') { + continue; + } + + $path = $file->getPathname(); + + // Exclude + if ($exclude !== [] && $this->matchesAnyPattern($path, $exclude)) { + continue; + } + + // Include filter (if defined) + if ($include !== [] && ! $this->matchesAnyPattern($path, $include)) { + continue; + } + + $files[] = $path; + } + + if (empty($files)) { + throw new RuntimeException("No PHP files found in $baseDirectory"); + } + + return $files; + } +// + function matchesAnyPattern(string $string, array $patterns): bool + { + foreach ($patterns as $pattern) { + // Escape regex special chars except for '*' + $regex = '/^' . str_replace('\*', '.*', preg_quote($pattern, '/')) . '$/'; +// echo PHP_EOL . $regex . PHP_EOL; die; + if (preg_match($regex, $string)) { + return true; + } + } + + return false; + } +} diff --git a/application/controllers/ApiController.php b/application/controllers/ApiController.php new file mode 100644 index 000000000..658d5f2e6 --- /dev/null +++ b/application/controllers/ApiController.php @@ -0,0 +1,74 @@ +assertPermission('notifications/api'); + + $pipeline = new MiddlewarePipeline([ + new ErrorHandlingMiddleware(), + new LegacyRequestConversionMiddleware($this->getRequest()), + new RoutingMiddleware(), + new DispatchMiddleware(), + new ValidationMiddleware(), + new EndpointExecutionMiddleware(), + ]); + + $this->emitResponse($pipeline->execute()); + + exit; + } + + /** + * Emit the HTTP response to the client. + * + * @param ResponseInterface $response The response object to emit. + * + * @return void + */ + protected function emitResponse(ResponseInterface $response): void + { + do { + ob_end_clean(); + } while (ob_get_level() > 0); + + http_response_code($response->getStatusCode()); + + foreach ($response->getHeaders() as $name => $values) { + foreach ($values as $value) { + header(sprintf('%s: %s', $name, $value), false); + } + } + header('Content-Type: application/json'); + + $body = $response->getBody(); + while (! $body->eof()) { + echo $body->read(8192); + } + } +} diff --git a/application/forms/ChannelForm.php b/application/forms/ChannelForm.php index 19d1868a7..7194f753b 100644 --- a/application/forms/ChannelForm.php +++ b/application/forms/ChannelForm.php @@ -24,6 +24,7 @@ use ipl\Validator\EmailAddressValidator; use ipl\Web\Common\CsrfCounterMeasure; use ipl\Web\Compat\CompatForm; +use Ramsey\Uuid\Uuid; /** * @phpstan-type ChannelOptionConfig array{ @@ -213,6 +214,7 @@ public function addChannel(): void $channel = $this->getValues(); $channel['config'] = json_encode($this->filterConfig($channel['config']), JSON_FORCE_OBJECT); $channel['changed_at'] = (int) (new DateTime())->format("Uv"); + $channel['external_uuid'] = Uuid::uuid4()->toString(); $this->db->transaction(function (Connection $db) use ($channel): void { $db->insert('channel', $channel); diff --git a/application/forms/ContactGroupForm.php b/application/forms/ContactGroupForm.php index e94985da1..9536b7c7d 100644 --- a/application/forms/ContactGroupForm.php +++ b/application/forms/ContactGroupForm.php @@ -23,6 +23,7 @@ use ipl\Web\Compat\CompatForm; use ipl\Web\FormElement\TermInput; use ipl\Web\FormElement\TermInput\Term; +use Ramsey\Uuid\Uuid; class ContactGroupForm extends CompatForm { @@ -181,7 +182,15 @@ public function addGroup(): int $this->db->beginTransaction(); $changedAt = (int) (new DateTime())->format("Uv"); - $this->db->insert('contactgroup', ['name' => trim($data['group_name']), 'changed_at' => $changedAt]); + + $this->db->insert( + 'contactgroup', + [ + 'name' => trim($data['group_name']), + 'changed_at' => $changedAt, + 'external_uuid' => Uuid::uuid4()->toString() + ] + ); $groupIdentifier = $this->db->lastInsertId(); diff --git a/configuration.php b/configuration.php index 5d098aeb2..439e09da3 100644 --- a/configuration.php +++ b/configuration.php @@ -42,6 +42,11 @@ $this->translate('Allow to configure contact groups') ); +$this->providePermission( + 'notifications/api', + $this->translate('Allow to modify configuration via API') +); + $this->provideRestriction( 'notifications/filter/objects', $this->translate('Restrict access to the objects that match the filter') diff --git a/doc/20-REST-API.md b/doc/20-REST-API.md new file mode 100644 index 000000000..495f92081 --- /dev/null +++ b/doc/20-REST-API.md @@ -0,0 +1,31 @@ +# REST API + +The Icinga Notifications module provides a REST API that allows you to manage notification-related resources programmatically. + +With this API, you can: +- Manage **contacts** and **contact groups** +- Read available **notification channels** + +This API enables easy integration with external tools, automation workflows, and configuration management systems. + +--- + +## API Versioning + +The Notifications API follows a **versioned** structure to ensure backward compatibility and predictable upgrades. + +The current and first stable version is: /icingaweb2/notifications/api/v1 + +Future versions will be accessible under corresponding paths (for example, `/api/v2`), allowing you to migrate at your own pace. + +--- + +## API Description + +The complete API reference for version `v1` is available in [`api/v1.md`](api/v1.md). + +It contains an OpenAPI v3.1 description with detailed information about all endpoints, including: +- Request and response schemas +- Example payloads +- Authentication requirements +- Error handling diff --git a/doc/api/api-v1-public.json b/doc/api/api-v1-public.json new file mode 100644 index 000000000..e79761078 --- /dev/null +++ b/doc/api/api-v1-public.json @@ -0,0 +1,2311 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Icinga Notifications API", + "description": "API for managing notification Channels, Contacts, and Contact Groups in Icinga.", + "version": "1.0.0" + }, + "servers": [ + { + "url": "/icingaweb2/notifications/api/v1", + "description": "Local server" + } + ], + "paths": { + "/channels/{identifier}": { + "get": { + "tags": [ + "Channels" + ], + "summary": "Get a specific Channel by its UUID", + "description": "Retrieve detailed information about a specific notification Channel using its UUID", + "operationId": "getChannel", + "parameters": [ + { + "name": "identifier", + "in": "path", + "description": "The UUID of the Channel to retrieve", + "required": true, + "schema": { + "$ref": "#/components/schemas/ChannelUUID" + } + } + ], + "responses": { + "200": { + "description": "Successful response with a single Channel result", + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "$ref": "#/components/schemas/Channel", + "description": "Successfull response with the Channel object" + } + }, + "type": "object" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidIdentifier": { + "$ref": "#/components/examples/InvalidIdentifier" + }, + "NoIdentifierWithFilter": { + "$ref": "#/components/examples/NoIdentifierWithFilter" + } + } + } + } + }, + "404": { + "description": "Channel Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "ResourceNotFound": { + "summary": "Resource not found", + "value": { + "message": "Channel not found" + } + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + } + } + }, + "/channels": { + "get": { + "tags": [ + "Channels" + ], + "summary": "List all notification channels or filter by parameters", + "description": "List all notification channels or filter by parameters", + "operationId": "listChannel", + "parameters": [ + { + "name": "id", + "in": "query", + "description": "Filter by channel UUID", + "required": false, + "schema": { + "$ref": "#/components/schemas/ChannelUUID" + } + }, + { + "name": "name", + "in": "query", + "description": "Filter by channel name (supports partial matches)", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "type", + "in": "query", + "description": "Filter by channel type", + "required": false, + "schema": { + "$ref": "#/components/schemas/ChannelTypes" + } + } + ], + "responses": { + "200": { + "description": "Successful response with multiple Channel results", + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "description": "Successful response with an array of Channel objects", + "type": "array", + "items": { + "$ref": "#/components/schemas/Channel" + } + } + }, + "type": "object" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "NoIdentifierWithFilter": { + "$ref": "#/components/examples/NoIdentifierWithFilter" + } + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidRequestBodyId": { + "$ref": "#/components/examples/InvalidRequestBodyId" + }, + "InvalidFilterParameter": { + "summary": "Invalid filter parameter", + "value": { + "message": "Invalid request parameter: Filter column x given, only id, name and type are allowed" + } + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + } + } + }, + "/contact-groups/{identifier}": { + "get": { + "tags": [ + "Contact Groups" + ], + "summary": "Get a specific Contact Group by its UUID", + "description": "Retrieve detailed information about a specific notification Contact Group using its UUID", + "operationId": "getContactgroup", + "parameters": [ + { + "name": "identifier", + "in": "path", + "description": "The UUID of the Contact Group to retrieve", + "required": true, + "schema": { + "$ref": "#/components/schemas/ContactgroupUUID" + } + } + ], + "responses": { + "200": { + "description": "Successful response with a single Contactgroup result", + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "$ref": "#/components/schemas/Contactgroup", + "description": "Successfull response with the Contactgroup object" + } + }, + "type": "object" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidIdentifier": { + "$ref": "#/components/examples/InvalidIdentifier" + }, + "NoIdentifierWithFilter": { + "$ref": "#/components/examples/NoIdentifierWithFilter" + } + } + } + } + }, + "404": { + "description": "Contactgroup Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "ResourceNotFound": { + "summary": "Resource not found", + "value": { + "message": "Contactgroup not found" + } + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + } + }, + "put": { + "tags": [ + "Contact Groups" + ], + "summary": "Update a Contact Group by UUID", + "description": "Update a Contact Group by UUID, if it doesn't exist, it will be created. \\\n The identifier must be the same as the payload id", + "operationId": "updateContactgroup", + "parameters": [ + { + "name": "identifier", + "in": "path", + "description": "The UUID of the Contact Group to update", + "required": true, + "schema": { + "$ref": "#/components/schemas/ContactgroupUUID" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Contactgroup" + } + } + } + }, + "responses": { + "201": { + "description": "Contactgroup created successfully", + "headers": { + "X-Resource-Identifier": { + "description": "The identifier of the created Contactgroup", + "schema": { + "type": "string", + "format": "uuid" + } + }, + "Location": { + "description": "The URL of the created Contactgroup", + "schema": { + "type": "string", + "format": "url", + "example": "notifications/api/v1/contactgroups/{identifier}" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + }, + "examples": { + "ContactgroupCreated": { + "summary": "Contactgroup created successfully", + "value": { + "message": "Contactgroup created successfully" + } + } + } + } + }, + "links": { + "GetContactgroupByIdentifiere": { + "operationId": "getContactgroup", + "parameters": { + "identifier": "$response.header.X-Resource-Identifier" + }, + "description": "Retrieve the created contact using the X-Resource-Identifier header" + }, + "UpdateContactgroupByIdentifier": { + "operationId": "updateContactgroup", + "parameters": { + "identifier": "$response.header.X-Resource-Identifier" + }, + "description": "Update the created contact using the X-Resource-Identifier header" + }, + "DeleteContactgroupByIdentifier": { + "operationId": "deleteContactgroup", + "parameters": { + "identifier": "$response.header.X-Resource-Identifier" + }, + "description": "Delete the created contact using the X-Resource-Identifier header" + } + } + }, + "204": { + "description": "Contactgroup updated successfully" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidRequestBodyFormat": { + "$ref": "#/components/examples/InvalidRequestBodyFormat" + }, + "UnexpectedQueryParameter": { + "$ref": "#/components/examples/UnexpectedQueryParameter" + } + } + } + } + }, + "404": { + "description": "Contactgroup Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "ResourceNotFound": { + "summary": "Resource not found", + "value": { + "message": "Contactgroup not found" + } + } + } + } + } + }, + "415": { + "description": "Unsupported Media Type", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidContentType": { + "$ref": "#/components/examples/InvalidContentType" + } + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "Contactgroup AlreadyExists": { + "summary": "Contactgroup already exists", + "value": { + "message": "Contactgroup already exists" + } + }, + "InvalidRequestBodyId": { + "$ref": "#/components/examples/InvalidRequestBodyId" + }, + "IdentifierMismatch": { + "$ref": "#/components/examples/IdentifierMismatch" + }, + "MissingRequiredRequestBodyField": { + "summary": "Missing required request body field", + "value": { + "message": "Invalid request body: the fields id and name must be present and of type string" + } + }, + "InvalidUserFormat": { + "$ref": "#/components/examples/InvalidUserFormat" + }, + "InvalidUserUUID": { + "$ref": "#/components/examples/InvalidUserUUID" + }, + "NameAlreadyExists": { + "$ref": "#/components/examples/NameAlreadyExists" + }, + "UserNotExists": { + "$ref": "#/components/examples/UserNotExists" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + } + }, + "post": { + "tags": [ + "Contact Groups" + ], + "summary": "Replace a Contact Group by UUID", + "description": "Replace a Contact Group by UUID, the identifier must be different from the payload id", + "operationId": "replaceContactgroup", + "parameters": [ + { + "name": "identifier", + "in": "path", + "description": "The UUID of the Contact Group to create", + "required": true, + "schema": { + "$ref": "#/components/schemas/ContactgroupUUID" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Contactgroup" + }, + { + "properties": { + "id": { + "$ref": "#/components/schemas/NewContactgroupUUID" + } + }, + "type": "object" + } + ] + } + } + } + }, + "responses": { + "201": { + "description": "Contactgroup created successfully", + "headers": { + "X-Resource-Identifier": { + "description": "The identifier of the created Contactgroup", + "schema": { + "type": "string", + "format": "uuid" + } + }, + "Location": { + "description": "The URL of the created Contactgroup", + "schema": { + "type": "string", + "format": "url", + "example": "notifications/api/v1/contactgroups/{identifier}" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + }, + "examples": { + "ContactgroupCreated": { + "summary": "Contactgroup created successfully", + "value": { + "message": "Contactgroup created successfully" + } + } + } + } + }, + "links": { + "GetContactgroupByIdentifier": { + "operationId": "getContactgroup", + "parameters": { + "identifier": "$response.header.X-Resource-Identifier" + }, + "description": "Retrieve the created contact using the X-Resource-Identifier header" + }, + "UpdateContactgroupByIdentifier": { + "operationId": "updateContactgroup", + "parameters": { + "identifier": "$response.header.X-Resource-Identifier" + }, + "description": "Update the created contact using the X-Resource-Identifier header" + }, + "DeleteContactgroupByIdentifier": { + "operationId": "deleteContactgroup", + "parameters": { + "identifier": "$response.header.X-Resource-Identifier" + }, + "description": "Delete the created contact using the X-Resource-Identifier header" + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidRequestBodyFormat": { + "$ref": "#/components/examples/InvalidRequestBodyFormat" + }, + "UnexpectedQueryParameter": { + "$ref": "#/components/examples/UnexpectedQueryParameter" + } + } + } + } + }, + "415": { + "description": "Unsupported Media Type", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidContentType": { + "$ref": "#/components/examples/InvalidContentType" + } + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "Contactgroup AlreadyExists": { + "summary": "Contactgroup already exists", + "value": { + "message": "Contactgroup already exists" + } + }, + "InvalidRequestBodyId": { + "$ref": "#/components/examples/InvalidRequestBodyId" + }, + "MissingRequiredRequestBodyField": { + "summary": "Missing required request body field", + "value": { + "message": "Invalid request body: the fields id and name must be present and of type string" + } + }, + "InvalidUserFormat": { + "$ref": "#/components/examples/InvalidUserFormat" + }, + "InvalidUserUUID": { + "$ref": "#/components/examples/InvalidUserUUID" + }, + "NameAlreadyExists": { + "$ref": "#/components/examples/NameAlreadyExists" + }, + "UserNotExists": { + "$ref": "#/components/examples/UserNotExists" + }, + "IdentifierPayloadIdMissmatch": { + "$ref": "#/components/examples/IdentifierPayloadIdMissmatch" + } + } + } + } + }, + "404": { + "description": "Contactgroup Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "ResourceNotFound": { + "summary": "Resource not found", + "value": { + "message": "Contactgroup not found" + } + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + } + }, + "delete": { + "tags": [ + "Contact Groups" + ], + "summary": "Delete a Contact Group by UUID", + "description": "Delete a Contact Group by UUID", + "operationId": "deleteContactgroup", + "parameters": [ + { + "name": "identifier", + "in": "path", + "description": "The UUID of the Contactgroup to delete", + "required": true, + "schema": { + "$ref": "#/components/schemas/ContactgroupUUID" + } + } + ], + "responses": { + "204": { + "description": "No Content - The Contactgroup has been deleted successfully" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidIdentifier": { + "$ref": "#/components/examples/InvalidIdentifier" + } + } + } + } + }, + "404": { + "description": "Contactgroup Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "ResourceNotFound": { + "summary": "Resource not found", + "value": { + "message": "Contactgroup not found" + } + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + } + } + }, + "/contact-groups": { + "get": { + "tags": [ + "Contact Groups" + ], + "summary": "List all Contact Groups or filter by parameters", + "description": "Retrieve all Contact Groups or filter them by parameters.", + "operationId": "listContactgroup", + "parameters": [ + { + "name": "id", + "in": "query", + "description": "Filter by Contact Group UUID", + "required": false, + "schema": { + "schema": "ContactgroupUUID", + "title": "ContactgroupUUID", + "description": "An UUID representing a notification Contactgroup", + "type": "string", + "format": "uuid", + "maxLength": 36, + "minLength": 36, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "example": "123e4567-e89b-42d3-a456-426614174000" + } + }, + { + "name": "name", + "in": "query", + "description": "Filter by Contact Group name", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response with multiple Contactgroup results", + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "description": "Successful response with an array of Contactgroup objects", + "type": "array", + "items": { + "$ref": "#/components/schemas/Contactgroup" + } + } + }, + "type": "object" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "NoIdentifierWithFilter": { + "$ref": "#/components/examples/NoIdentifierWithFilter" + } + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidRequestBodyId": { + "$ref": "#/components/examples/InvalidRequestBodyId" + }, + "InvalidFilterParameter": { + "summary": "Invalid filter parameter", + "value": { + "message": "Invalid request parameter: Filter column x given, only id and name are allowed" + } + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + } + }, + "post": { + "tags": [ + "Contact Groups" + ], + "summary": "Create a new Contact Group", + "description": "Create a new Contact Group", + "operationId": "createContactgroup", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Contactgroup" + }, + { + "properties": { + "id": { + "$ref": "#/components/schemas/NewContactgroupUUID" + } + }, + "type": "object" + } + ] + } + } + } + }, + "responses": { + "201": { + "description": "Contactgroup created successfully", + "headers": { + "X-Resource-Identifier": { + "description": "The identifier of the created Contactgroup", + "schema": { + "type": "string", + "format": "uuid" + } + }, + "Location": { + "description": "The URL of the created Contactgroup", + "schema": { + "type": "string", + "format": "url", + "example": "notifications/api/v1/contactgroups/{identifier}" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + }, + "examples": { + "ContactgroupCreated": { + "summary": "Contactgroup created successfully", + "value": { + "message": "Contactgroup created successfully" + } + } + } + } + }, + "links": { + "GetContactgroupByIdentifier": { + "operationId": "getContactgroup", + "parameters": { + "identifier": "$response.header.X-Resource-Identifier" + }, + "description": "Retrieve the created contact using the X-Resource-Identifier header" + }, + "UpdateContactgroupByIdentifier": { + "operationId": "updateContactgroup", + "parameters": { + "identifier": "$response.header.X-Resource-Identifier" + }, + "description": "Update the created contact using the X-Resource-Identifier header" + }, + "DeleteContactgroupByIdentifier": { + "operationId": "deleteContactgroup", + "parameters": { + "identifier": "$response.header.X-Resource-Identifier" + }, + "description": "Delete the created contact using the X-Resource-Identifier header" + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidRequestBodyFormat": { + "$ref": "#/components/examples/InvalidRequestBodyFormat" + }, + "UnexpectedQueryParameter": { + "$ref": "#/components/examples/UnexpectedQueryParameter" + } + } + } + } + }, + "415": { + "description": "Unsupported Media Type", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidContentType": { + "$ref": "#/components/examples/InvalidContentType" + } + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "Contactgroup AlreadyExists": { + "summary": "Contactgroup already exists", + "value": { + "message": "Contactgroup already exists" + } + }, + "InvalidRequestBodyId": { + "$ref": "#/components/examples/InvalidRequestBodyId" + }, + "MissingRequiredRequestBodyField": { + "summary": "Missing required request body field", + "value": { + "message": "Invalid request body: the fields id and name must be present and of type string" + } + }, + "InvalidUserFormat": { + "$ref": "#/components/examples/InvalidUserFormat" + }, + "InvalidUserUUID": { + "$ref": "#/components/examples/InvalidUserUUID" + }, + "NameAlreadyExists": { + "$ref": "#/components/examples/NameAlreadyExists" + }, + "UserNotExists": { + "$ref": "#/components/examples/UserNotExists" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + } + } + }, + "/contacts/{identifier}": { + "get": { + "tags": [ + "Contacts" + ], + "summary": "Get a specific Contact by its UUID", + "description": "Retrieve detailed information about a specific notification Contact using its UUID", + "operationId": "getContact", + "parameters": [ + { + "name": "identifier", + "in": "path", + "description": "The UUID of the Contact to retrieve", + "required": true, + "schema": { + "$ref": "#/components/schemas/ContactUUID" + } + } + ], + "responses": { + "200": { + "description": "Successful response with a single Contact result", + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "$ref": "#/components/schemas/Contact", + "description": "Successfull response with the Contact object" + } + }, + "type": "object" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidIdentifier": { + "$ref": "#/components/examples/InvalidIdentifier" + }, + "NoIdentifierWithFilter": { + "$ref": "#/components/examples/NoIdentifierWithFilter" + } + } + } + } + }, + "404": { + "description": "Contact Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "ResourceNotFound": { + "summary": "Resource not found", + "value": { + "message": "Contact not found" + } + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + } + }, + "put": { + "tags": [ + "Contacts" + ], + "summary": "Update a Contact by UUID", + "description": "Update a Contact by UUID, if it doesn't exist, it will be created. \\\n The identifier must be the same as the payload id", + "operationId": "updateContact", + "parameters": [ + { + "name": "identifier", + "in": "path", + "description": "The UUID of the Contact to Update", + "required": true, + "schema": { + "$ref": "#/components/schemas/NewContactUUID" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Contact" + } + } + } + }, + "responses": { + "201": { + "description": "Contact created successfully", + "headers": { + "X-Resource-Identifier": { + "description": "The identifier of the created Contact", + "schema": { + "type": "string", + "format": "uuid" + } + }, + "Location": { + "description": "The URL of the created Contact", + "schema": { + "type": "string", + "format": "url", + "example": "notifications/api/v1/contacts/{identifier}" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + }, + "examples": { + "ContactCreated": { + "summary": "Contact created successfully", + "value": { + "message": "Contact created successfully" + } + } + } + } + }, + "links": { + "GetContactByIdentifiere": { + "operationId": "getContact", + "parameters": { + "identifier": "$response.header.X-Resource-Identifier" + }, + "description": "Retrieve the created contact using the X-Resource-Identifier header" + }, + "UpdateContactByIdentifier": { + "operationId": "updateContact", + "parameters": { + "identifier": "$response.header.X-Resource-Identifier" + }, + "description": "Update the created contact using the X-Resource-Identifier header" + }, + "DeleteContactByIdentifier": { + "operationId": "deleteContact", + "parameters": { + "identifier": "$response.header.X-Resource-Identifier" + }, + "description": "Delete the created contact using the X-Resource-Identifier header" + } + } + }, + "204": { + "description": "Contact updated successfully" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidRequestBodyFormat": { + "$ref": "#/components/examples/InvalidRequestBodyFormat" + }, + "UnexpectedQueryParameter": { + "$ref": "#/components/examples/UnexpectedQueryParameter" + }, + "InvalidDefaultChannelUUID": { + "$ref": "#/components/examples/InvalidDefaultChannelUUID" + } + } + } + } + }, + "404": { + "description": "Contact Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "ResourceNotFound": { + "summary": "Resource not found", + "value": { + "message": "Contact not found" + } + } + } + } + } + }, + "415": { + "description": "Unsupported Media Type", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidContentType": { + "$ref": "#/components/examples/InvalidContentType" + } + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "Contact AlreadyExists": { + "summary": "Contact already exists", + "value": { + "message": "Contact already exists" + } + }, + "InvalidRequestBodyId": { + "$ref": "#/components/examples/InvalidRequestBodyId" + }, + "IdentifierMismatch": { + "$ref": "#/components/examples/IdentifierMismatch" + }, + "MissingRequiredRequestBodyField": { + "summary": "Missing required request body field", + "value": { + "message": "Invalid request body: the fields id, full_name and default_channel must be present and of type string" + } + }, + "ContactgroupNotExists": { + "$ref": "#/components/examples/ContactgroupNotExists" + }, + "InvalidAddressFormat": { + "$ref": "#/components/examples/InvalidAddressFormat" + }, + "InvalidAddressType": { + "$ref": "#/components/examples/InvalidAddressType" + }, + "InvalidContactgroupUUID": { + "$ref": "#/components/examples/InvalidContactgroupUUID" + }, + "InvalidContactgroupUUIDFormat": { + "$ref": "#/components/examples/InvalidContactgroupUUIDFormat" + }, + "InvalidEmailAddress": { + "$ref": "#/components/examples/InvalidEmailAddress" + }, + "InvalidEmailAddressFormat": { + "$ref": "#/components/examples/InvalidEmailAddressFormat" + }, + "InvalidGroupsFormat": { + "$ref": "#/components/examples/InvalidGroupsFormat" + }, + "UsernameAlreadyExists": { + "$ref": "#/components/examples/UsernameAlreadyExists" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + } + }, + "post": { + "tags": [ + "Contacts" + ], + "summary": "Replace a Contact by UUID", + "description": "Replace a Contact by UUID, the identifier must be different from the payload id", + "operationId": "replaceContact", + "parameters": [ + { + "name": "identifier", + "in": "path", + "description": "The UUID of the contact to create", + "required": true, + "schema": { + "$ref": "#/components/schemas/ContactUUID" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Contact" + }, + { + "properties": { + "id": { + "$ref": "#/components/schemas/NewContactUUID" + } + }, + "type": "object" + } + ] + } + } + } + }, + "responses": { + "201": { + "description": "Contact created successfully", + "headers": { + "X-Resource-Identifier": { + "description": "The identifier of the created Contact", + "schema": { + "type": "string", + "format": "uuid" + } + }, + "Location": { + "description": "The URL of the created Contact", + "schema": { + "type": "string", + "format": "url", + "example": "notifications/api/v1/contacts/{identifier}" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + }, + "examples": { + "ContactCreated": { + "summary": "Contact created successfully", + "value": { + "message": "Contact created successfully" + } + } + } + } + }, + "links": { + "GetContactByIdentifier": { + "operationId": "getContact", + "parameters": { + "identifier": "$response.header.X-Resource-Identifier" + }, + "description": "Retrieve the created contact using the X-Resource-Identifier header" + }, + "UpdateContactByIdentifier": { + "operationId": "updateContact", + "parameters": { + "identifier": "$response.header.X-Resource-Identifier" + }, + "description": "Update the created contact using the X-Resource-Identifier header" + }, + "DeleteContactByIdentifier": { + "operationId": "deleteContact", + "parameters": { + "identifier": "$response.header.X-Resource-Identifier" + }, + "description": "Delete the created contact using the X-Resource-Identifier header" + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidRequestBodyFormat": { + "$ref": "#/components/examples/InvalidRequestBodyFormat" + }, + "UnexpectedQueryParameter": { + "$ref": "#/components/examples/UnexpectedQueryParameter" + }, + "InvalidDefaultChannelUUID": { + "$ref": "#/components/examples/InvalidDefaultChannelUUID" + } + } + } + } + }, + "415": { + "description": "Unsupported Media Type", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidContentType": { + "$ref": "#/components/examples/InvalidContentType" + } + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "Contact AlreadyExists": { + "summary": "Contact already exists", + "value": { + "message": "Contact already exists" + } + }, + "InvalidRequestBodyId": { + "$ref": "#/components/examples/InvalidRequestBodyId" + }, + "MissingRequiredRequestBodyField": { + "summary": "Missing required request body field", + "value": { + "message": "Invalid request body: the fields id, full_name and default_channel must be present and of type string" + } + }, + "ContactgroupNotExists": { + "$ref": "#/components/examples/ContactgroupNotExists" + }, + "InvalidAddressType": { + "$ref": "#/components/examples/InvalidAddressType" + }, + "InvalidAddressFormat": { + "$ref": "#/components/examples/InvalidAddressFormat" + }, + "InvalidContactgroupUUID": { + "$ref": "#/components/examples/InvalidContactgroupUUID" + }, + "InvalidContactgroupUUIDFormat": { + "$ref": "#/components/examples/InvalidContactgroupUUIDFormat" + }, + "InvalidEmailAddress": { + "$ref": "#/components/examples/InvalidEmailAddress" + }, + "InvalidEmailAddressFormat": { + "$ref": "#/components/examples/InvalidEmailAddressFormat" + }, + "InvalidGroupsFormat": { + "$ref": "#/components/examples/InvalidGroupsFormat" + }, + "UsernameAlreadyExists": { + "$ref": "#/components/examples/UsernameAlreadyExists" + }, + "IdentifierPayloadIdMissmatch": { + "$ref": "#/components/examples/IdentifierPayloadIdMissmatch" + } + } + } + } + }, + "404": { + "description": "Contact Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "ResourceNotFound": { + "summary": "Resource not found", + "value": { + "message": "Contact not found" + } + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + } + }, + "delete": { + "tags": [ + "Contacts" + ], + "summary": "Delete a Contact by UUID", + "description": "Delete a Contact by UUID", + "operationId": "deleteContact", + "parameters": [ + { + "name": "identifier", + "in": "path", + "description": "The UUID of the Contact to delete", + "required": true, + "schema": { + "$ref": "#/components/schemas/ContactUUID" + } + } + ], + "responses": { + "204": { + "description": "No Content - The Contact has been deleted successfully" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidIdentifier": { + "$ref": "#/components/examples/InvalidIdentifier" + } + } + } + } + }, + "404": { + "description": "Contact Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "ResourceNotFound": { + "summary": "Resource not found", + "value": { + "message": "Contact not found" + } + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + } + } + }, + "/contacts": { + "get": { + "tags": [ + "Contacts" + ], + "summary": "List all Contacts or filter by parameters", + "description": "Retrieve all Contacts or filter them by parameters.", + "operationId": "listContact", + "parameters": [ + { + "name": "id", + "in": "query", + "description": "Filter Contacts by UUID", + "required": false, + "schema": { + "schema": "ContactUUID", + "title": "ContactUUID", + "description": "An UUID representing a notification Contact", + "type": "string", + "format": "uuid", + "maxLength": 36, + "minLength": 36, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "example": "123e4567-e89b-42d3-a456-426614174000" + } + }, + { + "name": "full_name", + "in": "query", + "description": "Filter Contacts by full name", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "username", + "in": "query", + "description": "Filter Contacts by username", + "required": false, + "schema": { + "type": "string", + "maxLength": 254 + } + } + ], + "responses": { + "200": { + "description": "Successful response with multiple Contact results", + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "description": "Successful response with an array of Contact objects", + "type": "array", + "items": { + "$ref": "#/components/schemas/Contact" + } + } + }, + "type": "object" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "NoIdentifierWithFilter": { + "$ref": "#/components/examples/NoIdentifierWithFilter" + } + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidRequestBodyId": { + "$ref": "#/components/examples/InvalidRequestBodyId" + }, + "InvalidFilterParameter": { + "summary": "Invalid filter parameter", + "value": { + "message": "Invalid request parameter: Filter column x given, only id, full_name and username are allowed" + } + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + } + }, + "post": { + "tags": [ + "Contacts" + ], + "summary": "Create a new Contact", + "description": "Create a new Contact", + "operationId": "createContact", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Contact" + }, + { + "properties": { + "id": { + "$ref": "#/components/schemas/NewContactUUID" + } + }, + "type": "object" + } + ] + } + } + } + }, + "responses": { + "201": { + "description": "Contact created successfully", + "headers": { + "X-Resource-Identifier": { + "description": "The identifier of the created Contact", + "schema": { + "type": "string", + "format": "uuid" + } + }, + "Location": { + "description": "The URL of the created Contact", + "schema": { + "type": "string", + "format": "url", + "example": "notifications/api/v1/contacts/{identifier}" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + }, + "examples": { + "ContactCreated": { + "summary": "Contact created successfully", + "value": { + "message": "Contact created successfully" + } + } + } + } + }, + "links": { + "GetContactByIdentifier": { + "operationId": "getContact", + "parameters": { + "identifier": "$response.header.X-Resource-Identifier" + }, + "description": "Retrieve the created contact using the X-Resource-Identifier header" + }, + "UpdateContactByIdentifier": { + "operationId": "updateContact", + "parameters": { + "identifier": "$response.header.X-Resource-Identifier" + }, + "description": "Update the created contact using the X-Resource-Identifier header" + }, + "DeleteContactByIdentifier": { + "operationId": "deleteContact", + "parameters": { + "identifier": "$response.header.X-Resource-Identifier" + }, + "description": "Delete the created contact using the X-Resource-Identifier header" + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidRequestBodyFormat": { + "$ref": "#/components/examples/InvalidRequestBodyFormat" + }, + "UnexpectedQueryParameter": { + "$ref": "#/components/examples/UnexpectedQueryParameter" + }, + "InvalidDefaultChannelUUID": { + "$ref": "#/components/examples/InvalidDefaultChannelUUID" + } + } + } + } + }, + "415": { + "description": "Unsupported Media Type", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidContentType": { + "$ref": "#/components/examples/InvalidContentType" + } + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "Contact AlreadyExists": { + "summary": "Contact already exists", + "value": { + "message": "Contact already exists" + } + }, + "InvalidRequestBodyId": { + "$ref": "#/components/examples/InvalidRequestBodyId" + }, + "MissingRequiredRequestBodyField": { + "summary": "Missing required request body field", + "value": { + "message": "Invalid request body: the fields id, full_name and default_channel must be present and of type string" + } + }, + "ContactgroupNotExists": { + "$ref": "#/components/examples/ContactgroupNotExists" + }, + "InvalidAddressType": { + "$ref": "#/components/examples/InvalidAddressType" + }, + "InvalidAddressFormat": { + "$ref": "#/components/examples/InvalidAddressFormat" + }, + "InvalidContactgroupUUID": { + "$ref": "#/components/examples/InvalidContactgroupUUID" + }, + "InvalidContactgroupUUIDFormat": { + "$ref": "#/components/examples/InvalidContactgroupUUIDFormat" + }, + "InvalidEmailAddress": { + "$ref": "#/components/examples/InvalidEmailAddress" + }, + "InvalidEmailAddressFormat": { + "$ref": "#/components/examples/InvalidEmailAddressFormat" + }, + "InvalidGroupsFormat": { + "$ref": "#/components/examples/InvalidGroupsFormat" + }, + "UsernameAlreadyExists": { + "$ref": "#/components/examples/UsernameAlreadyExists" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + } + } + } + }, + "components": { + "schemas": { + "Channel": { + "description": "A notification channel represents a destination for notifications in Icinga. \\\n Channels can be of different types, such as email, webhook, or Rocket.Chat, \n each with its own configuration requirements. \\\n Channels are used to route notifications to users or external systems based on their type and configuration.", + "required": [ + "id", + "name", + "type", + "config" + ], + "properties": { + "id": { + "$ref": "#/components/schemas/ChannelUUID" + }, + "name": { + "description": "The name of the channel", + "type": "string", + "example": "My Webhook Channel" + }, + "type": { + "$ref": "#/components/schemas/ChannelTypes" + }, + "config": { + "description": "The configuration for the channel, varies depending on the channel type", + "type": "object", + "example": { + "url_template": "https://example.com/webhook?token=abc123" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/EmailChannelConfig" + }, + { + "$ref": "#/components/schemas/WebhookChannelConfig" + }, + { + "$ref": "#/components/schemas/RocketChatChannelConfig" + } + ] + } + }, + "type": "object" + }, + "ChannelUUID": { + "title": "ChannelUUID", + "description": "An UUID representing a notification Channel", + "type": "string", + "format": "uuid", + "maxLength": 36, + "minLength": 36, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "example": "bb4af7bd-f0da-489c-ae31-23f714bde714" + }, + "ChannelTypes": { + "description": "Available notification channel types", + "type": "string", + "enum": [ + "email", + "webhook", + "rocketchat" + ], + "example": "webhook" + }, + "WebhookChannelConfig": { + "title": "Webhook Channel Config", + "description": "The configuration for a webhook notification channel", + "required": [ + "url_template" + ], + "properties": { + "url_template": { + "$ref": "#/components/schemas/Url", + "description": "URL template for the webhook" + } + }, + "type": "object" + }, + "EmailChannelConfig": { + "title": "Email Channel Config", + "description": "The configuration for an email notification channel", + "required": [ + "host", + "port", + "sender_mail", + "encryption" + ], + "properties": { + "host": { + "description": "SMTP host for sending emails", + "type": "string" + }, + "port": { + "$ref": "#/components/schemas/Port", + "description": "SMTP port for sending emails" + }, + "sender_name": { + "description": "Name of the sender for the email channel", + "type": "string" + }, + "sender_mail": { + "$ref": "#/components/schemas/Email", + "description": "Email address of the sender" + }, + "user": { + "description": "Username for SMTP authentication", + "type": "string" + }, + "password": { + "description": "Password for SMTP authentication", + "type": "string" + }, + "encryption": { + "description": "Encryption method for SMTP", + "type": "string", + "enum": [ + "none", + "ssl", + "tls" + ] + } + }, + "type": "object" + }, + "RocketChatChannelConfig": { + "title": "RocketChat Channel Config", + "description": "The configuration for a Rocket.Chat notification channel", + "required": [ + "url", + "user_id", + "token" + ], + "properties": { + "url": { + "$ref": "#/components/schemas/Url", + "description": "URL of the Rocket.Chat server" + }, + "user_id": { + "description": "User ID for Rocket.Chat", + "type": "string" + }, + "token": { + "description": "Authentication token for Rocket.Chat", + "type": "string" + } + }, + "type": "object" + }, + "Contactgroup": { + "description": "A contact group", + "required": [ + "id", + "name" + ], + "properties": { + "id": { + "$ref": "#/components/schemas/ContactgroupUUID" + }, + "name": { + "description": "The name of the Contact Group", + "type": "string", + "example": "My Contact Group" + }, + "users": { + "description": "List of user identifiers (UUIDs) that belong to this Contact Group", + "type": "array", + "items": { + "$ref": "#/components/schemas/ContactUUID" + } + } + }, + "type": "object" + }, + "ContactgroupUUID": { + "title": "ContactgroupUUID", + "description": "An UUID representing a notification Contactgroup", + "type": "string", + "format": "uuid", + "maxLength": 36, + "minLength": 36, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "example": "81fb569f-5669-4cd6-93bb-9259446b8b23" + }, + "NewContactgroupUUID": { + "title": "NewContactgroupUUID", + "description": "An UUID representing a notification NewContactgroup", + "type": "string", + "format": "uuid", + "maxLength": 36, + "minLength": 36, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "example": "31fb569f-5669-4cd6-93bb-9259446b8b74" + }, + "Contact": { + "description": "Schema that represents a contact in the Icinga Notifications API", + "required": [ + "id", + "full_name", + "default_channel" + ], + "properties": { + "id": { + "$ref": "#/components/schemas/ContactUUID" + }, + "full_name": { + "description": "The full name of the contact", + "type": "string", + "example": "Icinga User" + }, + "username": { + "description": "The username of the contact", + "type": "string", + "maxLength": 254, + "example": "icingauser" + }, + "default_channel": { + "$ref": "#/components/schemas/ChannelUUID", + "description": "The default channel UUID for the contact" + }, + "groups": { + "description": "List of group UUIDs the contact belongs to", + "type": "array", + "items": { + "$ref": "#/components/schemas/ContactgroupUUID", + "description": "Group UUIDs the contact belongs to" + } + }, + "addresses": { + "$ref": "#/components/schemas/Addresses", + "description": "Contact addresses by type" + } + }, + "type": "object", + "additionalProperties": false + }, + "Addresses": { + "description": "Schema that represents a contact's addresses", + "properties": { + "email": { + "description": "User's email address", + "type": "string", + "format": "email" + }, + "rocketchat": { + "description": "Rocket.Chat identifier or URL", + "type": "string", + "example": "rocketchat.example.com" + }, + "webhook": { + "description": "Comma-separated list of webhook URLs or identifiers", + "type": "string", + "example": "https://example.com/webhook" + } + }, + "type": "object", + "additionalProperties": false + }, + "ContactUUID": { + "title": "ContactUUID", + "description": "An UUID representing a notification Contact", + "type": "string", + "format": "uuid", + "maxLength": 36, + "minLength": 36, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "example": "9e868ad0-e774-465b-8075-c5a07e8f0726" + }, + "NewContactUUID": { + "title": "NewContactUUID", + "description": "An UUID representing a notification NewContact", + "type": "string", + "format": "uuid", + "maxLength": 36, + "minLength": 36, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "example": "52668ad0-e774-465b-8075-c5a07e8f0726" + }, + "SuccessResponse": { + "description": "Success response format", + "properties": { + "message": { + "description": "Detailed success message", + "type": "string" + } + }, + "type": "object" + }, + "ErrorResponse": { + "description": "Error response format", + "properties": { + "message": { + "description": "Detailed error message", + "type": "string" + } + }, + "type": "object" + }, + "Email": { + "description": "An email address", + "type": "string", + "format": "email", + "maxLength": 320 + }, + "Port": { + "description": "A port number", + "type": "string", + "maxLength": 5, + "minLength": 1 + }, + "Url": { + "description": "A URL used in the API", + "type": "string", + "maxLength": 2048, + "example": "example.com" + } + }, + "examples": { + "InvalidUserFormat": { + "summary": "Invalid user format", + "value": { + "message": "Invalid request body: expects users to be an array" + } + }, + "InvalidUserUUID": { + "summary": "Invalid user UUID", + "value": { + "message": "Invalid request body: user identifiers must be valid UUIDs" + } + }, + "NameAlreadyExists": { + "summary": "Name already exists", + "value": { + "message": "Name x already exists" + } + }, + "UserNotExists": { + "summary": "User does not exist", + "value": { + "message": "User with identifier x not found" + } + }, + "ContactgroupNotExists": { + "summary": "Contact Group does not exist", + "value": { + "message": "Contact Group with identifier x does not exist" + } + }, + "InvalidAddressType": { + "summary": "Invalid address type", + "value": { + "message": "Invalid request body: undefined address type x given" + } + }, + "InvalidAddressFormat": { + "summary": "Invalid address format", + "value": { + "message": "Invalid request body: expects addresses to be an array" + } + }, + "InvalidContactgroupUUID": { + "summary": "Invalid Contact Group UUID", + "value": { + "message": "Invalid request body: the group identifier invalid_uuid is not a valid UUID" + } + }, + "InvalidContactgroupUUIDFormat": { + "summary": "Invalid Contact Group UUID format", + "value": { + "message": "Invalid request body: an invalid group identifier format given" + } + }, + "InvalidDefaultChannelUUID": { + "summary": "Invalid default_channel UUID", + "value": { + "message": "Invalid request body: given default_channel is not a valid UUID" + } + }, + "InvalidEmailAddress": { + "summary": "Invalid email address", + "value": { + "message": "Invalid request body: an invalid email address given" + } + }, + "InvalidEmailAddressFormat": { + "summary": "Invalid email address format", + "value": { + "message": "Invalid request body: an invalid email address format given" + } + }, + "InvalidGroupsFormat": { + "summary": "Invalid groups format", + "value": { + "message": "Invalid request body: expects groups to be an array" + } + }, + "UsernameAlreadyExists": { + "summary": "Username already exists", + "value": { + "message": "Username x already exists" + } + }, + "IdentifierMismatch": { + "summary": "Identifier mismatch", + "value": { + "message": "Identifier mismatch" + } + }, + "IdentifierNotFound": { + "summary": "Identifier not found", + "value": { + "message": "Identifier not found" + } + }, + "IdentifierPayloadIdMissmatch": { + "summary": "Identifier and payload Id missmatch", + "value": { + "message": "Identifier mismatch: the Payload id must be different from the URL identifier" + } + }, + "InvalidContentType": { + "summary": "Invalid content type", + "value": { + "message": "Invalid request header: Content-Type must be application/json" + } + }, + "InvalidIdentifier": { + "summary": "Identifier is not valid", + "value": { + "message": "The given identifier is not a valid UUID" + } + }, + "InvalidRequestBodyFormat": { + "summary": "Invalid request body format", + "value": { + "message": "Invalid request body: given content is not a valid JSON" + } + }, + "InvalidRequestBodyId": { + "summary": "Invalid request body id", + "value": { + "message": "Invalid request body: given id is not a valid UUID" + } + }, + "NoIdentifierWithFilter": { + "summary": "No identifier with filter", + "value": { + "message": "Invalid request: GET with identifier and query parameters, it's not allowed to use both together." + } + }, + "UnexpectedQueryParameter": { + "summary": "Unexpected query parameter", + "value": { + "message": "Unexpected query parameter: Filter is only allowed for GET requests" + } + } + }, + "securitySchemes": { + "BasicAuth": { + "type": "http", + "description": "Basic authentication for API access", + "scheme": "basic" + } + } + }, + "security": [ + { + "BasicAuth": [] + } + ], + "tags": [ + { + "name": "Contacts", + "description": "Operations related to notification Contacts" + }, + { + "name": "Contact Groups", + "description": "Operations related to notification Contact Groups" + }, + { + "name": "Channels", + "description": "Operations related to notification Channels" + } + ] +} \ No newline at end of file diff --git a/doc/api/v1.md b/doc/api/v1.md new file mode 100644 index 000000000..da7d71a23 --- /dev/null +++ b/doc/api/v1.md @@ -0,0 +1,5 @@ +# API V1 + +Refer to the OpenAPI specification below for detailed information on each endpoint. + +!!swagger api-v1-public.json!! diff --git a/library/Notifications/Api/ApiCore.php b/library/Notifications/Api/ApiCore.php new file mode 100644 index 000000000..1a97808bf --- /dev/null +++ b/library/Notifications/Api/ApiCore.php @@ -0,0 +1,105 @@ +assertValidRequest($request); + + return $this->handleRequest($request); + } + + /** + * Get allowed HTTP methods for the API. + * + * @return array + */ + public function getAllowedMethods(): array + { + $methods = []; + + foreach (HttpMethod::cases() as $method) { + if (method_exists($this, $method->lowercase())) { + $methods[] = $method->uppercase(); + } + } + + return $methods; + } + + /** + * Validate the incoming request. + * + * Override to implement specific request validation logic. + * + * @param ServerRequestInterface $request The incoming server-request to validate. + * + * @return void + */ + protected function assertValidRequest(ServerRequestInterface $request): void + { + } + + /** + * Create a Response object. + * + * @param int $status The HTTP status code. + * @param array $headers An associative array of HTTP headers. + * @param ?(StreamInterface|resource|string) $body The response body. + * @param string $version The HTTP version. + * @param ?string $reason The reason phrase (optional). + * + * @return ResponseInterface + */ + protected function createResponse( + int $status = 200, + array $headers = [], + $body = null, + string $version = '1.1', + ?string $reason = null + ): ResponseInterface { + $headers['Content-Type'] = 'application/json'; + + return new Response($status, $headers, $body, $version, $reason); + } +} diff --git a/library/Notifications/Api/EndpointInterface.php b/library/Notifications/Api/EndpointInterface.php new file mode 100644 index 000000000..6e85bf105 --- /dev/null +++ b/library/Notifications/Api/EndpointInterface.php @@ -0,0 +1,9 @@ +getAttribute('version'); + $endpoint = $request->getAttribute('endpoint'); + $class = sprintf('Icinga\\Module\\Notifications\\Api\\%s\\%s', $version, $endpoint); + + if (! class_exists($class) || ! is_subclass_of($class, RequestHandlerInterface::class)) { + throw new HttpNotFoundException("Endpoint $endpoint not found"); + } + + $endpointHandler = new $class(); + + return $handler->handle($request->withAttribute('endpointHandler', $endpointHandler)); + } +} diff --git a/library/Notifications/Api/Middleware/EndpointExecutionMiddleware.php b/library/Notifications/Api/Middleware/EndpointExecutionMiddleware.php new file mode 100644 index 000000000..eb10d2eca --- /dev/null +++ b/library/Notifications/Api/Middleware/EndpointExecutionMiddleware.php @@ -0,0 +1,28 @@ +getAttribute('endpointHandler'); + + if (! $endpointHandler instanceof RequestHandlerInterface) { + return $handler->handle($request); + } + return $request->getAttribute('endpointHandler')->handle($request); + } +} diff --git a/library/Notifications/Api/Middleware/ErrorHandlingMiddleware.php b/library/Notifications/Api/Middleware/ErrorHandlingMiddleware.php new file mode 100644 index 000000000..abe6fd102 --- /dev/null +++ b/library/Notifications/Api/Middleware/ErrorHandlingMiddleware.php @@ -0,0 +1,53 @@ +handle($request); + } catch (HttpExceptionInterface $e) { + return new Response( + $e->getStatusCode(), + array_merge($e->getHeaders(), ['Content-Type' => 'application/json']), + Json::sanitize(['message' => $e->getMessage()]) + ); + } catch (InvalidFilterParameterException $e) { + return new Response( + 400, + ['Content-Type' => 'application/json'], + Json::sanitize([ + 'message' => $e->getMessage() + ]) + ); + } catch (Throwable $e) { + Logger::error($e); + Logger::debug(IcingaException::getConfidentialTraceAsString($e)); + return new Response( + 500, + ['Content-Type' => 'application/json'], + Json::sanitize(['message' => 'An error occurred, please check the log.']) + ); + } + } +} diff --git a/library/Notifications/Api/Middleware/LegacyRequestConversionMiddleware.php b/library/Notifications/Api/Middleware/LegacyRequestConversionMiddleware.php new file mode 100644 index 000000000..f326d2d8e --- /dev/null +++ b/library/Notifications/Api/Middleware/LegacyRequestConversionMiddleware.php @@ -0,0 +1,74 @@ +legacyRequest = $legacyRequest; + } + + /** + * @throws HttpBadRequestException + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + if ( + ! $this->legacyRequest->isApiRequest() + && strtolower($this->legacyRequest->getParam('endpoint')) !== (new OpenApi())->getEndpoint() + ) { + throw new HttpBadRequestException('No API request'); + } + + $httpMethod = $this->legacyRequest->getMethod(); + $serverRequest = (new ServerRequest( + $httpMethod, + $this->legacyRequest->getRequestUri(), + serverParams: $this->legacyRequest->getServer() + )) + ->withAttribute('route_params', $this->legacyRequest->getParams()); + + try { + if ($contentType = $this->legacyRequest->getHeader('Content-Type')) { + $serverRequest = $serverRequest->withHeader('Content-Type', $contentType); + } + + $requestBody = $this->legacyRequest->getPost(); + } catch (JsonDecodeException) { + throw new HttpBadRequestException('Invalid request body: given content is not a valid JSON'); + } catch (\Zend_Controller_Request_Exception) { + throw new HttpBadRequestException('Invalid request header: Content-Type must be application/json'); + } + + if ($httpMethod === 'POST' || $httpMethod === 'PUT') { + $serverRequest = $serverRequest->withParsedBody($requestBody); + } else { + if (! empty($requestBody)) { + throw new HttpBadRequestException( + 'Invalid request body: body is only allowed for POST and PUT requests' + ); + } + } + + return $handler->handle($serverRequest); + } +} diff --git a/library/Notifications/Api/Middleware/MiddlewarePipeline.php b/library/Notifications/Api/Middleware/MiddlewarePipeline.php new file mode 100644 index 000000000..46fc890fa --- /dev/null +++ b/library/Notifications/Api/Middleware/MiddlewarePipeline.php @@ -0,0 +1,97 @@ + + */ + private SplQueue $pipeline; + + /** + * @param MiddlewareInterface[] $middlewares + */ + public function __construct( + array $middlewares, + ) { + $this->pipeline = new SplQueue(); + foreach ($middlewares as $middleware) { + try { + $this->pipe($middleware); + } catch (\Exception $e) { + throw new \InvalidArgumentException('All middlewares must implement MiddlewareInterface'); + } + } + } + + /** + * Add middleware to the pipeline. + * + * @param MiddlewareInterface $middleware + * + * @return $this + */ + public function pipe(MiddlewareInterface $middleware): self + { + $this->pipeline->enqueue($middleware); + + return $this; + } + + /** + * Handle the request and process the middleware pipeline. + * This method is used to process the entire pipeline with a real request. + * The request is passed to the first middleware in the pipeline. + * The response is returned from the last middleware in the pipeline. + * If no middleware is left in the pipeline, a 404 Not Found response is returned. + * + * @param ServerRequestInterface $request + * + * @return ResponseInterface + */ + public function handle(ServerRequestInterface $request): ResponseInterface + { + $middleware = $this->pipeline->dequeue(); + + if ($middleware === null) { + return new Response(404, ['Content-Type' => 'application/json'], 'Not Found'); + } + + return $middleware->process($request, $this); + } + + /** + * Execute the middleware pipeline. + * This method is used to process the entire pipeline with a fake request. + * + * @param ServerRequestInterface|null $request + * + * @return ResponseInterface + */ + public function execute(ServerRequestInterface $request = null): ResponseInterface + { + if ($request === null) { + $request = new ServerRequest('GET', '/'); // initial dummy request + } + + return $this->handle($request); + } +} diff --git a/library/Notifications/Api/Middleware/RoutingMiddleware.php b/library/Notifications/Api/Middleware/RoutingMiddleware.php new file mode 100644 index 000000000..92ee982b9 --- /dev/null +++ b/library/Notifications/Api/Middleware/RoutingMiddleware.php @@ -0,0 +1,33 @@ +getAttribute('route_params'); + $version = ucfirst($params['version']); + $endpoint = ucfirst(Str::camel($params['endpoint'])); + $identifier = $params['identifier'] ?? null; + + return $handler->handle( + $request + ->withAttribute('version', ucfirst($version)) + ->withAttribute('endpoint', ucfirst($endpoint)) + ->withAttribute('identifier', $identifier) + ); + } +} diff --git a/library/Notifications/Api/Middleware/ValidationMiddleware.php b/library/Notifications/Api/Middleware/ValidationMiddleware.php new file mode 100644 index 000000000..1d7009e88 --- /dev/null +++ b/library/Notifications/Api/Middleware/ValidationMiddleware.php @@ -0,0 +1,125 @@ +getAttribute('endpointHandler'); + + if (! $endpointHandler instanceof EndpointInterface) { + throw new HttpBadRequestException("No endpoint resolved"); + } + + $request = $this->validateHttpMethod($request, $endpointHandler); + + $this->assertValidRequest($request); + + return $handler->handle($request); + } + + /** + * Validate the HTTP method of the request. + * + * @param ServerRequestInterface $request + * @param EndpointInterface $endpointHandler + * + * @return ServerRequestInterface + * + * @throws HttpException + */ + private function validateHttpMethod( + ServerRequestInterface $request, + EndpointInterface $endpointHandler + ): ServerRequestInterface { + try { + $httpMethod = HttpMethod::fromRequest($request); + } catch (ValueError) { + throw (new HttpException(405, sprintf('HTTP method %s is not supported', $request->getMethod()))) + ->setHeader('Allow', implode(', ', $endpointHandler->getAllowedMethods())); + } + + $request = $request->withAttribute('httpMethod', $httpMethod); + + if (! in_array($httpMethod->uppercase(), $endpointHandler->getAllowedMethods())) { + throw (new HttpException( + 405, + sprintf( + 'Method %s is not supported for endpoint %s', + $httpMethod->uppercase(), + $endpointHandler->getEndpoint() + ) + )) + ->setHeader('Allow', implode(', ', $endpointHandler->getAllowedMethods())); + } + + return $request; + } + + /** + * Assert that the request has a valid format. + * + * @param ServerRequestInterface $request + * + * @return void + * + * @throws HttpBadRequestException + */ + private function assertValidRequest(ServerRequestInterface $request): void + { + $httpMethod = $request->getAttribute('httpMethod'); + $identifier = $request->getAttribute('identifier'); + $queryFilter = $request->getUri()->getQuery(); + + if ($httpMethod !== HttpMethod::GET && ! empty($queryFilter)) { + throw new HttpBadRequestException( + 'Unexpected query parameter: Filter is only allowed for GET requests' + ); + } + + if ($httpMethod === HttpMethod::GET && ! empty($identifier) && ! empty($queryFilter)) { + throw new HttpBadRequestException(sprintf( + 'Invalid request: %s with identifier and query parameters, it\'s not allowed to use both together.', + $httpMethod->uppercase() + )); + } + + if ( + ! in_array($httpMethod, [HttpMethod::PUT, HttpMethod::POST]) + && (! empty($request->getBody()->getSize()) || ! empty($request->getParsedBody())) + ) { + throw new HttpBadRequestException('Invalid request: Body is only allowed for POST and PUT requests'); + } + + if (in_array($httpMethod, [HttpMethod::PUT, HttpMethod::DELETE]) && empty($identifier)) { + throw new HttpBadRequestException("Invalid request: Identifier is required"); + } + + if ((! empty($identifier) || $identifier === '0') && ! Uuid::isValid($identifier)) { + throw new HttpBadRequestException('The given identifier is not a valid UUID'); + } + } +} diff --git a/library/Notifications/Api/OpenApiDescriptionElement/OadV1Delete.php b/library/Notifications/Api/OpenApiDescriptionElement/OadV1Delete.php new file mode 100644 index 000000000..aa2cf5e35 --- /dev/null +++ b/library/Notifications/Api/OpenApiDescriptionElement/OadV1Delete.php @@ -0,0 +1,53 @@ + $message + ] + ), + ] + ), + ], $responses ?? []), + ); + } +} diff --git a/library/Notifications/Api/OpenApiDescriptionElement/OadV1Post.php b/library/Notifications/Api/OpenApiDescriptionElement/OadV1Post.php new file mode 100644 index 000000000..a4ecca066 --- /dev/null +++ b/library/Notifications/Api/OpenApiDescriptionElement/OadV1Post.php @@ -0,0 +1,185 @@ + $entityName . ' created successfully', + ] + ), + ], + headers: [ + new OA\Header( + header: 'X-Resource-Identifier', + description: 'The identifier of the created ' . $entityName, + schema: new OA\Schema( + type: 'string', + format: 'uuid', + ) + ), + new OA\Header( + header: 'Location', + description: 'The URL of the created ' . $entityName, + schema: new OA\Schema( + type: 'string', + format: 'url', + example: 'notifications/api/v1/' . strtolower($entityName) . 's/{identifier}', + ) + ) + ], + links: [ + new OA\Link( + link: 'Get' . $entityName . 'ByIdentifier', + operationId: 'get' . $entityName, + parameters: [ + 'identifier' => '$response.header.X-Resource-Identifier' + ], + description: 'Retrieve the created contact using the X-Resource-Identifier header' + ), + new OA\Link( + link: 'Update' . $entityName . 'ByIdentifier', + operationId: 'update' . $entityName, + parameters: [ + 'identifier' => '$response.header.X-Resource-Identifier' + ], + description: 'Update the created contact using the X-Resource-Identifier header' + ), + new OA\Link( + link: 'Delete' . $entityName . 'ByIdentifier', + operationId: 'delete' . $entityName, + parameters: [ + 'identifier' => '$response.header.X-Resource-Identifier' + ], + description: 'Delete the created contact using the X-Resource-Identifier header' + ), + ] + ); + + if (! empty($requiredFields)) { + $missingRequestBodyFieldsMessage = 'Invalid request body: '; + + if (count($requiredFields) == 1) { + $requiredFieldsStr = $requiredFields[0]; + } elseif (count($requiredFields) == 2) { + $requiredFieldsStr = $requiredFields[0] . ' and ' . $requiredFields[1]; + } else { + $last = array_pop($requiredFields); + $requiredFieldsStr = implode(', ', $requiredFields) . ' and ' . $last; + } + $missingRequestBodyFieldsMessage .= sprintf( + 'the fields %s must be present and of type string', + $requiredFieldsStr + ); + } + + parent::__construct( + path: $path, + operationId: ($hasIdentifier ? 'replace' : 'create') . $entityName, + description: $description, + summary: $summary, + requestBody: $requestBody, + tags: $tags, + parameters: $parameters, + responses: array_merge([ + $successResponse, + new ErrorResponse( + response: 400, + examples: array_merge([ + new ResponseExample('InvalidRequestBodyFormat'), + new ResponseExample('UnexpectedQueryParameter'), + ], $examples400 ?? []) + ), + new ErrorResponse( + response: 415, + examples: [ + new ResponseExample('InvalidContentType'), + ] + ), + new ErrorResponse( + response: 422, + examples: array_merge( + [ + new OA\Examples( + example: $entityName . ' AlreadyExists', + summary: $entityName . ' already exists', + value: ['message' => $entityName . ' already exists'], + ), + new ResponseExample('InvalidRequestBodyId'), + ], + empty($requiredFields) + ? [] + : [ + new OA\Examples( + example: 'MissingRequiredRequestBodyField', + summary: 'Missing required request body field', + value: ['message' => $missingRequestBodyFieldsMessage], + ) + ], + $examples422 ?? [] + ) + ), + + ], $responses ?? []), + ); + } +} diff --git a/library/Notifications/Api/OpenApiDescriptionElement/OadV1Put.php b/library/Notifications/Api/OpenApiDescriptionElement/OadV1Put.php new file mode 100644 index 000000000..3defbb575 --- /dev/null +++ b/library/Notifications/Api/OpenApiDescriptionElement/OadV1Put.php @@ -0,0 +1,164 @@ + $entityName . ' created successfully', + ] + ), + ], + headers: [ + new OA\Header( + header: 'X-Resource-Identifier', + description: 'The identifier of the created ' . $entityName, + schema: new OA\Schema( + type: 'string', + format: 'uuid', + ) + ), + new OA\Header( + header: 'Location', + description: 'The URL of the created ' . $entityName, + schema: new OA\Schema( + type: 'string', + format: 'url', + example: 'notifications/api/v1/' . strtolower($entityName) . 's/{identifier}', + ) + ) + ], + links: [ + new OA\Link( + link: 'Get' . $entityName . 'ByIdentifiere', + operationId: 'get' . $entityName, + parameters: [ + 'identifier' => '$response.header.X-Resource-Identifier' + ], + description: 'Retrieve the created contact using the X-Resource-Identifier header' + ), + new OA\Link( + link: 'Update' . $entityName . 'ByIdentifier', + operationId: 'update' . $entityName, + parameters: [ + 'identifier' => '$response.header.X-Resource-Identifier' + ], + description: 'Update the created contact using the X-Resource-Identifier header' + ), + new OA\Link( + link: 'Delete' . $entityName . 'ByIdentifier', + operationId: 'delete' . $entityName, + parameters: [ + 'identifier' => '$response.header.X-Resource-Identifier' + ], + description: 'Delete the created contact using the X-Resource-Identifier header' + ), + ] + ), + new SuccessResponse( + response: 204, + description: $entityName . ' updated successfully', + ), + new ErrorResponse( + response: 400, + examples: array_merge([ + new ResponseExample('InvalidRequestBodyFormat'), + new ResponseExample('UnexpectedQueryParameter'), + ], $examples400 ?? []) + ), + new Error404Response($entityName), + new ErrorResponse( + response: 415, + examples: [ + new ResponseExample('InvalidContentType'), + ] + ), + new ErrorResponse( + response: 422, + examples: array_merge( + [ + new OA\Examples( + example: $entityName . ' AlreadyExists', + summary: $entityName . ' already exists', + value: ['message' => $entityName . ' already exists'], + ), + new ResponseExample('InvalidRequestBodyId'), + new ResponseExample('IdentifierMismatch') + ], + empty($requiredFields) + ? [] + : [ + new OA\Examples( + example: 'MissingRequiredRequestBodyField', + summary: 'Missing required request body field', + value: ['message' => $missingRequestBodyFieldsMessage], + ) + ], + $examples422 ?? [] + ) + ), + + ], + $responses ?? [] + ), + ); + } +} diff --git a/library/Notifications/Api/OpenApiDescriptionElement/Parameter/PathParameter.php b/library/Notifications/Api/OpenApiDescriptionElement/Parameter/PathParameter.php new file mode 100644 index 000000000..3b0ce933b --- /dev/null +++ b/library/Notifications/Api/OpenApiDescriptionElement/Parameter/PathParameter.php @@ -0,0 +1,40 @@ + $parameter ?? Generator::UNDEFINED, + 'name' => $name ?? Generator::UNDEFINED, + 'description' => $description ?? Generator::UNDEFINED, + 'in' => 'path', + 'required' => $required ?? true, + 'schema' => $schema, + ]; + + $params = $example !== null ? array_merge($params, ['example' => $example]) : $params; + + parent::__construct(...$params); + } +} diff --git a/library/Notifications/Api/OpenApiDescriptionElement/Parameter/QueryParameter.php b/library/Notifications/Api/OpenApiDescriptionElement/Parameter/QueryParameter.php new file mode 100644 index 000000000..82cec69d8 --- /dev/null +++ b/library/Notifications/Api/OpenApiDescriptionElement/Parameter/QueryParameter.php @@ -0,0 +1,39 @@ + $parameter, + 'name' => $name, + 'description' => $description, + 'in' => 'query', + 'required' => $required ?? false, + 'schema' => $schema, + ]; + + $params = $example !== null ? array_merge($params, ['example' => $example]) : $params; + + parent::__construct(...$params); + } +} diff --git a/library/Notifications/Api/OpenApiDescriptionElement/Response/Error404Response.php b/library/Notifications/Api/OpenApiDescriptionElement/Response/Error404Response.php new file mode 100644 index 000000000..4c1b7bac6 --- /dev/null +++ b/library/Notifications/Api/OpenApiDescriptionElement/Response/Error404Response.php @@ -0,0 +1,33 @@ + $endpointName . ' not found'], + ) + ], + ref: '#/components/schemas/ErrorResponse' + ) + ); + } +} diff --git a/library/Notifications/Api/OpenApiDescriptionElement/Response/ErrorResponse.php b/library/Notifications/Api/OpenApiDescriptionElement/Response/ErrorResponse.php new file mode 100644 index 000000000..5e10f896c --- /dev/null +++ b/library/Notifications/Api/OpenApiDescriptionElement/Response/ErrorResponse.php @@ -0,0 +1,61 @@ + 'Bad Request', + 401 => 'Unauthorized', + 403 => 'Forbidden', + 404 => 'Not Found', + 405 => 'Method Not Allowed', + 409 => 'Conflict', + 415 => 'Unsupported Media Type', + 422 => 'Unprocessable Entity', + ]; + + public function __construct( + object|string|null $ref = null, + int $response = 400, + ?array $examples = null, + ?array $headers = null, + ?array $links = null, + ) { + if (isset(self::ERROR_RESPONSES[$response])) { + $description = self::ERROR_RESPONSES[$response]; + } else { + throw new \InvalidArgumentException('Unexpected response type'); + } + + parent::__construct( + ref: $ref, + response: $response, + description: $description, + headers: $headers, + content: new OA\JsonContent( + examples: $examples, + ref: '#/components/schemas/ErrorResponse', + ), + links: $links, + ); + } +} diff --git a/library/Notifications/Api/OpenApiDescriptionElement/Response/Example/ResponseExample.php b/library/Notifications/Api/OpenApiDescriptionElement/Response/Example/ResponseExample.php new file mode 100644 index 000000000..36dfa7eac --- /dev/null +++ b/library/Notifications/Api/OpenApiDescriptionElement/Response/Example/ResponseExample.php @@ -0,0 +1,64 @@ + 'Identifier mismatch'], +)] +#[OA\Examples( + example: 'IdentifierNotFound', + summary: 'Identifier not found', + value: ['message' => 'Identifier not found'] +)] +#[OA\Examples( + example: 'IdentifierPayloadIdMissmatch', + summary: 'Identifier and payload Id missmatch', + value: ['message' => 'Identifier mismatch: the Payload id must be different from the URL identifier'], +)] +#[OA\Examples( + example: 'InvalidContentType', + summary: 'Invalid content type', + value: ['message' => 'Invalid request header: Content-Type must be application/json'], +)] +#[OA\Examples( + example: 'InvalidIdentifier', + summary: 'Identifier is not valid', + value: ['message' => 'The given identifier is not a valid UUID'] +)] +#[OA\Examples( + example: 'InvalidRequestBodyFormat', + summary: 'Invalid request body format', + value: ['message' => 'Invalid request body: given content is not a valid JSON'], +)] +#[OA\Examples( + example: 'InvalidRequestBodyId', + summary: 'Invalid request body id', + value: ['message' => 'Invalid request body: given id is not a valid UUID'], +)] +#[OA\Examples( + example: 'NoIdentifierWithFilter', + summary: 'No identifier with filter', + value: [ + 'message' => + "Invalid request: GET with identifier and query parameters, it's not allowed to use both together.", + ], +)] +#[OA\Examples( + example: 'UnexpectedQueryParameter', + summary: 'Unexpected query parameter', + value: ['message' => 'Unexpected query parameter: Filter is only allowed for GET requests'] +)] +class ResponseExample extends Examples +{ + public function __construct(string $name) + { + parent::__construct(example: $name, ref: '#/components/examples/' . $name); + } +} diff --git a/library/Notifications/Api/OpenApiDescriptionElement/Response/SuccessDataResponse.php b/library/Notifications/Api/OpenApiDescriptionElement/Response/SuccessDataResponse.php new file mode 100644 index 000000000..e6f2af366 --- /dev/null +++ b/library/Notifications/Api/OpenApiDescriptionElement/Response/SuccessDataResponse.php @@ -0,0 +1,49 @@ + 'OK', + 201 => 'Created', + 204 => 'No Content', + ]; + + public function __construct( + int|string|null $response = null, + ?string $description = null, + ?array $examples = null, + ?array $headers = null, + ?array $links = null, + ) { + if (! isset(self::SUCCESS_RESPONSES[$response])) { + throw new \InvalidArgumentException('Unexpected response type'); + } + + $content = $response !== 204 + ? new OA\JsonContent( + examples: $examples, + ref: '#/components/schemas/SuccessResponse', + ) + : null; + + parent::__construct( + response: $response, + description: $description, + headers: $headers, + content: $content, + links: $links + ); + } +} diff --git a/library/Notifications/Api/OpenApiDescriptionElement/Schema/DefaultSchemas.php b/library/Notifications/Api/OpenApiDescriptionElement/Schema/DefaultSchemas.php new file mode 100644 index 000000000..a372ecb21 --- /dev/null +++ b/library/Notifications/Api/OpenApiDescriptionElement/Schema/DefaultSchemas.php @@ -0,0 +1,32 @@ +openapi->paths as $path) { + foreach ($path->operations() as $operation) { + // Avoid duplicates + $already = array_filter( + $operation->responses, + fn($resp) => $resp->response === 401 + ); + + if (! $already) { + $operation->responses[] = new OA\Response([ + 'response' => 401, + 'description' => 'Unauthorized', + ]); + } + } + } + } +} diff --git a/library/Notifications/Api/V1/ApiV1.php b/library/Notifications/Api/V1/ApiV1.php new file mode 100644 index 000000000..d594bad5f --- /dev/null +++ b/library/Notifications/Api/V1/ApiV1.php @@ -0,0 +1,226 @@ + []], + ], +)] +#[OA\Tag( + name: 'Contacts', + description: 'Operations related to notification Contacts' +)] +#[OA\Tag( + name: 'Contact Groups', + description: 'Operations related to notification Contact Groups' +)] +#[OA\Tag( + name: 'Channels', + description: 'Operations related to notification Channels' +)] +#[OA\SecurityScheme( + securityScheme: 'BasicAuth', + type: 'http', + description: 'Basic authentication for API access', + scheme: 'basic', +)] +abstract class ApiV1 extends ApiCore +{ + /** + * This constant defines the version of the API. + * + * @var string + */ + public const VERSION = 'v1'; + + /** + * @throws HttpBadRequestException If the request is not valid. + */ + public function handleRequest(ServerRequestInterface $request): ResponseInterface + { + $identifier = $request->getAttribute('identifier'); + $queryFilter = $request->getUri()->getQuery(); + + return match ($request->getAttribute('httpMethod')) { + HttpMethod::PUT => $this->put($identifier, $this->getValidRequestBody($request)), + HttpMethod::POST => $this->post($identifier, $this->getValidRequestBody($request)), + HttpMethod::GET => $this->get($identifier, $queryFilter), + HttpMethod::DELETE => $this->delete($identifier), + }; + } + + /** + * Override this method to modify the row before it is returned in the response. + * + * @param stdClass $row + * @return void + */ + public function prepareRow(stdClass $row): void + { + } + + /** + * Create a filter from the filter string. + * + * @param string $queryFilter + * @param array $allowedColumns + * @param string $idColumnName + * + * @return array|bool Returns an array of filter rules or false if no filter string is provided. + * + * @throws HttpBadRequestException If the filter string cannot be parsed. + */ + protected function assembleFilter(string $queryFilter, array $allowedColumns, string $idColumnName): array|bool + { + if (empty($queryFilter)) { + return false; + } + + try { + $filterRule = QueryString::fromString($queryFilter) + ->on( + QueryString::ON_CONDITION, + function (Condition $condition) use ($allowedColumns, $idColumnName) { + $column = $condition->getColumn(); + if (! in_array($column, $allowedColumns)) { + throw new InvalidFilterParameterException($column); + } + + if ($column === 'id') { + if (! Uuid::isValid($condition->getValue())) { + throw new HttpBadRequestException('The given filter id is not a valid UUID'); + } + + $condition->setColumn($idColumnName); + } + } + )->parse(); + + return FilterProcessor::assembleFilter($filterRule); + } catch (Exception $e) { + if ($e instanceof InvalidFilterParameterException) { + throw $e; + } + + throw new HttpBadRequestException($e->getMessage()); + } + } + + /** + * Validate that the request has a JSON content type and return the parsed JSON content. + * + * @param ServerRequestInterface $request The request-object to validate. + * + * @return array The validated JSON content as an associative array. + * + * @throws HttpBadRequestException If the content type is not application/json. + */ + private function getValidRequestBody(ServerRequestInterface $request): array + { + if ($request->getHeaderLine('Content-Type') !== 'application/json') { + throw new HttpBadRequestException('Invalid request header: Content-Type must be application/json'); + } + + if (! empty($parsedBody = $request->getParsedBody()) && is_array($parsedBody)) { + return $parsedBody; + } + + $msgPrefix = 'Invalid request body: '; + $body = $request->getBody()->getContents(); + + if (empty($body)) { + throw new HttpBadRequestException($msgPrefix . 'given content is empty'); + } + + try { + $validBody = Json::decode($body, true); + } catch (JsonDecodeException) { + throw new HttpBadRequestException($msgPrefix . 'given content is not a valid JSON'); + } + + return $validBody; + } + + /** + * Generates a streamable response for large datasets. + * + * Enables efficient delivery of data by yielding results in batches. + * + * @param Select $stmt The SQL select statement to execute. + * @param int $batchSize The number of rows to fetch in each batch (default is 500). + * + * @return Generator Yields JSON-encoded strings representing the content. + * + * @throws JsonEncodeException + */ + protected function createContentGenerator( + Select $stmt, + int $batchSize = 500 + ): Generator { + $stmt->limit($batchSize); + $offset = 0; + + if ($stmt->getOrderBy() === null) { + $stmt->orderBy('id'); + } + + yield '{"data":['; + $res = Database::get()->select($stmt->offset($offset)); + do { + /** @var stdClass $row */ + foreach ($res as $i => $row) { + $this->prepareRow($row); + + if ($i > 0 || $offset !== 0) { + yield ","; + } + + yield Json::sanitize($row); + } + + $offset += $batchSize; + $res = Database::get()->select($stmt->offset($offset)); + } while ($res->rowCount()); + + yield ']}'; + } +} diff --git a/library/Notifications/Api/V1/Channels.php b/library/Notifications/Api/V1/Channels.php new file mode 100644 index 000000000..5fcef45ec --- /dev/null +++ b/library/Notifications/Api/V1/Channels.php @@ -0,0 +1,300 @@ + 'https://example.com/webhook?token=abc123', + ], + oneOf: [ + new OA\Schema(ref: '#/components/schemas/EmailChannelConfig'), + new OA\Schema(ref: '#/components/schemas/WebhookChannelConfig'), + new OA\Schema(ref: '#/components/schemas/RocketChatChannelConfig'), + ], + )] + protected array $config; + + public function getEndpoint(): string + { + return 'channels'; + } + + /** + * Get a channel by UUID. + * + * @param string|null $identifier + * @param string $queryFilter + * @return ResponseInterface + * @throws HttpBadRequestException + * @throws HttpNotFoundException + * @throws JsonEncodeException + */ + #[OadV1Get( + entityName: 'Channel', + path: '/channels/{identifier}', + description: 'Retrieve detailed information about a specific notification Channel using its UUID', + summary: 'Get a specific Channel by its UUID', + tags: ['Channels'], + parameters: [ + new PathParameter( + name: 'identifier', + description: 'The UUID of the Channel to retrieve', + identifierSchema: 'ChannelUUID' + ), + ], + responses: [] + )] + public function get(?string $identifier, string $queryFilter): ResponseInterface + { + $stmt = (new Select()) + ->distinct() + ->from('channel ch') + ->columns([ + 'channel_id' => 'ch.id', + 'id' => 'ch.external_uuid', + 'name', + 'type', + 'config' + ]); + + if ($identifier === null) { + return $this->getPlural($queryFilter, $stmt); + } + + $stmt->where(['external_uuid = ?' => $identifier]); + + /** @var stdClass|false $result */ + $result = Database::get()->fetchOne($stmt); + + if ($result === false) { + throw new HttpNotFoundException('Channel not found'); + } + + $this->prepareRow($result); + + return $this->createResponse(body: Json::sanitize(['data' => $result])); + } + + /** + * List channels or get specific channels by filter parameters. + * + * @param string $queryFilter + * @param Select $stmt + * + * @return ResponseInterface + * + * @throws HttpBadRequestException + * @throws JsonEncodeException + */ + #[OadV1GetPlural( + entityName: 'Channel', + path: '/channels', + description: 'List all notification channels or filter by parameters', + summary: 'List all notification channels or filter by parameters', + tags: ['Channels'], + filter: ['id', 'name', 'type'], + parameters: [ + new QueryParameter( + name: 'id', + description: 'Filter by channel UUID', + identifierSchema: 'ChannelUUID', + ), + new QueryParameter( + name: 'name', + description: 'Filter by channel name (supports partial matches)', + ), + new QueryParameter( + name: 'type', + description: 'Filter by channel type', + identifierSchema: 'ChannelTypes', + ), + ], + responses: [] + )] + private function getPlural(string $queryFilter, Select $stmt): ResponseInterface + { + $filter = $this->assembleFilter( + $queryFilter, + ['id', 'name', 'type'], + 'external_uuid' + ); + + if ($filter !== false) { + $stmt->where($filter); + } + + return $this->createResponse(body: $this->createContentGenerator($stmt)); + } + + /** + * Get the channel id with the given identifier + * + * @param string $channelIdentifier + * + * @return int|false + */ + public static function getChannelId(string $channelIdentifier): int|false + { + /** @var stdClass|false $channel */ + $channel = Database::get()->fetchOne( + (new Select()) + ->from('channel') + ->columns('id') + ->where(['external_uuid = ?' => $channelIdentifier]) + ); + + return $channel->id ?? false; + } + + public function prepareRow(stdClass $row): void + { + $row->config = Json::decode($row->config, true); + unset($row->channel_id); + } +} diff --git a/library/Notifications/Api/V1/ContactGroups.php b/library/Notifications/Api/V1/ContactGroups.php new file mode 100644 index 000000000..bda7ee7fc --- /dev/null +++ b/library/Notifications/Api/V1/ContactGroups.php @@ -0,0 +1,815 @@ + 'Invalid request body: expects users to be an array'] + )] + #[OA\Examples( + example: 'InvalidUserUUID', + summary: 'Invalid user UUID', + value: ['message' => 'Invalid request body: the user identifier X is not a valid UUID'] + )] + #[OA\Examples( + example: 'NameAlreadyExists', + summary: 'Name already exists', + value: ['message' => 'Name x already exists'] + )] + #[OA\Examples( + example: 'UserNotExists', + summary: 'User does not exist', + value: ['message' => 'User with identifier x not found'] + )] + protected array $specificResponses = []; + #[OA\Property( + ref: '#/components/schemas/ContactgroupUUID', + )] + protected string $id; + #[OA\Property( + description: 'The name of the Contact Group', + type: 'string', + example: 'My Contact Group', + )] + protected string $name; + #[OA\Property( + description: 'List of user identifiers (UUIDs) that belong to this Contact Group', + type: 'array', + items: new OA\Items(ref: '#/components/schemas/ContactUUID') + )] + protected ?array $users; + + + public function getEndpoint(): string + { + return 'contact-groups'; + } + + /** + * Get a Contact Group by UUID. + * + * @param string|null $identifier + * @param string $queryFilter + * + * @return ResponseInterface + * + * @throws HttpBadRequestException + * @throws HttpNotFoundException + * @throws JsonEncodeException + */ + #[OadV1Get( + entityName: 'Contactgroup', + path: '/contact-groups/{identifier}', + description: 'Retrieve detailed information about a specific notification Contact Group using its UUID', + summary: 'Get a specific Contact Group by its UUID', + tags: ['Contact Groups'], + parameters: [ + new PathParameter( + name: 'identifier', + description: 'The UUID of the Contact Group to retrieve', + identifierSchema: 'ContactgroupUUID' + ), + ], + responses: [] + )] + public function get(?string $identifier, string $queryFilter): ResponseInterface + { + $stmt = (new Select()) + ->distinct() + ->from('contactgroup cg') + ->columns([ + 'contactgroup_id' => 'cg.id', + 'id' => 'cg.external_uuid', + 'name' + ]) + ->where(['cg.deleted = ?' => 'n']); + + if ($identifier === null) { + return $this->getPlural($queryFilter, $stmt); + } + + $stmt->where(['external_uuid = ?' => $identifier]); + + /** @var stdClass|false $result */ + $result = Database::get()->fetchOne($stmt); + + if ($result === false) { + throw new HttpNotFoundException('Contact Group not found'); + } + + $this->prepareRow($result); + + return $this->createResponse(body: Json::sanitize(['data' => $result])); + } + + /** + * List Contact Groups or get specific Contact Groups by filter parameters. + * + * @param string $queryFilter + * @param Select $stmt + * + * @return ResponseInterface + * + * @throws HttpBadRequestException + * @throws JsonEncodeException + */ + #[OadV1GetPlural( + entityName: 'Contactgroup', + path: '/contact-groups', + description: 'Retrieve all Contact Groups or filter them by parameters.', + summary: 'List all Contact Groups or filter by parameters', + tags: ['Contact Groups'], + filter: ['id', 'name'], + parameters: [ + new QueryParameter( + name: 'id', + description: 'Filter by Contact Group UUID', + schema: new SchemaUUID(entityName: 'Contactgroup'), + ), + new QueryParameter( + name: 'name', + description: 'Filter by Contact Group name', + ), + ], + responses: [] + )] + private function getPlural(string $queryFilter, Select $stmt): ResponseInterface + { + $filter = $this->assembleFilter( + $queryFilter, + ['id', 'name'], + 'external_uuid' + ); + + if ($filter !== false) { + $stmt->where($filter); + } + + return $this->createResponse(body: $this->createContentGenerator($stmt)); + } + + /** + * Update a Contact Group by UUID. + * + * @param string $identifier + * @param requestBody $requestBody + * + * @return ResponseInterface + * + * @throws HttpBadRequestException + * @throws HttpException + * @throws JsonEncodeException + */ + #[OadV1Put( + entityName: 'Contactgroup', + path: '/contact-groups/{identifier}', + description: 'Update a Contact Group by UUID, if it doesn\'t exist, it will be created. \ + The identifier must be the same as the payload id', + summary: 'Update a Contact Group by UUID', + requiredFields: ['id', 'name'], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + ref: '#/components/schemas/Contactgroup' + ) + ), + tags: ['Contact Groups'], + parameters: [ + new PathParameter( + name: 'identifier', + description: 'The UUID of the Contact Group to update', + identifierSchema: 'ContactgroupUUID' + ) + ], + examples422: [ + new ResponseExample('InvalidUserFormat'), + new ResponseExample('InvalidUserUUID'), + new ResponseExample('NameAlreadyExists'), + new ResponseExample('UserNotExists'), + ] + )] + public function put(string $identifier, array $requestBody): ResponseInterface + { + if (empty($identifier)) { + throw new HttpBadRequestException('Identifier is required'); + } + + $this->assertValidRequestBody($requestBody); + + if ($identifier !== $requestBody['id']) { + throw new HttpException(422, 'Identifier mismatch'); + } + + Database::get()->beginTransaction(); + + if (($contactgroupId = self::getGroupId($identifier)) !== null) { + $this->updateContactgroup($requestBody, $contactgroupId); + $result = $this->createResponse(204); + } else { + $this->addContactgroup($requestBody); + $result = $this->createResponse( + 201, + [ + 'Location' => sprintf( + 'notifications/api/%s/%s/%s', + self::VERSION, + $this->getEndpoint(), + $requestBody['id'] + ), + 'X-Resource-Identifier' => $requestBody['id'] + ], + Json::sanitize(['message' => 'Contact Group created successfully']) + ); + } + + Database::get()->commitTransaction(); + + return $result; + } + + /** + * Create or replace a Contact Group + * + * @param string|null $identifier The identifier of the Contact Group to update, or null to create a new one + * @param requestBody $requestBody The request body containing the Contact Group data + * + * @return ResponseInterface + * + * @throws HttpBadRequestException + * @throws HttpNotFoundException + * @throws HttpException + * @throws JsonEncodeException + */ + #[OadV1Post( + entityName: 'Contactgroup', + path: '/contact-groups', + description: 'Create a new Contact Group', + summary: 'Create a new Contact Group', + requiredFields: ['id', 'name'], + tags: ['Contact Groups'], + examples422: [ + new ResponseExample('InvalidUserFormat'), + new ResponseExample('InvalidUserUUID'), + new ResponseExample('NameAlreadyExists'), + new ResponseExample('UserNotExists'), + ] + )] + #[OadV1Post( + entityName: 'Contactgroup', + path: '/contact-groups/{identifier}', + description: 'Replace a Contact Group by UUID, the identifier must be different from the payload id', + summary: 'Replace a Contact Group by UUID', + requiredFields: ['id', 'name'], + tags: ['Contact Groups'], + parameters: [ + new PathParameter( + name: 'identifier', + description: 'The UUID of the Contact Group to create', + identifierSchema: 'ContactgroupUUID' + ) + ], + examples422: [ + new ResponseExample('InvalidUserFormat'), + new ResponseExample('InvalidUserUUID'), + new ResponseExample('NameAlreadyExists'), + new ResponseExample('UserNotExists'), + ] + )] + public function post(?string $identifier, array $requestBody): ResponseInterface + { + $this->assertValidRequestBody($requestBody); + + Database::get()->beginTransaction(); + + $emptyIdentifier = $identifier === null; + + if (! $emptyIdentifier) { + if ($identifier === $requestBody['id']) { + throw new HttpException( + 422, + 'Identifier mismatch: the Payload id must be different from the URL identifier' + ); + } + + $groupId = $this->getGroupId($identifier); + + if ($groupId === null) { + throw new HttpNotFoundException('Contact Group not found'); + } + } + + if ($this->getGroupId($requestBody['id']) !== null) { + throw new HttpException(422, 'Contact Group already exists'); + } + + if (! $emptyIdentifier) { + $this->removeContactgroup($groupId); + } + + $this->addContactgroup($requestBody); + Database::get()->commitTransaction(); + + return $this->createResponse( + 201, + [ + 'Location' => sprintf( + 'notifications/api/%s/%s/%s', + self::VERSION, + $this->getEndpoint(), + $requestBody['id'] + ), + 'X-Resource-Identifier' => $requestBody['id'] + ], + Json::sanitize(['message' => 'Contact Group created successfully']) + ); + } + + /** + * Remove the Contact Group with the given id + * + * @param string $identifier + * + * @return ResponseInterface + * + * @throws HttpBadRequestException + * @throws HttpNotFoundException + */ + #[OadV1Delete( + entityName: 'Contactgroup', + path: '/contact-groups/{identifier}', + description: 'Delete a Contact Group by UUID', + summary: 'Delete a Contact Group by UUID', + tags: ['Contact Groups'], + )] + public function delete(string $identifier): ResponseInterface + { + if (empty($identifier)) { + throw new HttpBadRequestException('Identifier is required'); + } + + $contactgroupId = self::getGroupId($identifier); + + if ($contactgroupId === null) { + throw new HttpNotFoundException('Contact Group not found'); + } + + Database::get()->beginTransaction(); + $this->removeContactgroup($contactgroupId); + Database::get()->commitTransaction(); + + return $this->createResponse(204); + } + + /** + * Fetch the group identifiers of the contact with the given id from the contactgroup_member table + * + * @param int $contactId + * + * @return string[] + */ + public static function fetchGroupIdentifiers(int $contactId): array + { + return Database::get()->fetchCol( + (new Select()) + ->from('contactgroup_member cgm') + ->columns('cg.external_uuid') + ->joinLeft('contactgroup cg', 'cg.id = cgm.contactgroup_id') + ->where(['cgm.contact_id = ?' => $contactId, 'cgm.deleted = ?' => 'n']) + ->groupBy('cg.external_uuid') + ); + } + + /** + * Get the group id with the given identifier + * + * @param string $identifier + * + * @return ?int + */ + public static function getGroupId(string $identifier): ?int + { + /** @var stdClass|false $group */ + $group = Database::get()->fetchOne( + (new Select()) + ->from('contactgroup') + ->columns('id') + ->where(['external_uuid = ?' => $identifier]) + ); +// +// if ($group === false) { +// $deletedGroup = Database::get() +// ->fetchCol('SELECT id FROM contactgroup WHERE external_uuid = ?', [$identifier]); +// +// if (! empty($deletedGroup)) { +// throw new HttpException(422, 'Contactgroup id is not available: ' . $identifier); +// } +// } + + return $group->id ?? null; +// $group = Database::get() +// ->fetchCol('SELECT id FROM contactgroup WHERE external_uuid = ?', [$identifier]); +// +// return $group[0] ?? null; + } + + /** + * Remove the Contact Group with the given id and all its references + * + * @param int $id + * + * @return void + */ + private function removeContactgroup(int $id): void + { + $markAsDeleted = ['changed_at' => (int) (new DateTime())->format("Uv"), 'deleted' => 'y']; + $markEntityAsDeleted = array_merge( + $markAsDeleted, + ['external_uuid' => substr_replace(Uuid::uuid4()->toString(), '0', 14, 1)] + ); + $updateCondition = ['contactgroup_id = ?' => $id, 'deleted = ?' => 'n']; + + $rotationAndMemberIds = Database::get()->fetchPairs( + RotationMember::on(Database::get()) + ->columns(['id', 'rotation_id']) + ->filter(Filter::equal('contactgroup_id', $id)) + ->assembleSelect() + ); + + $rotationMemberIds = array_keys($rotationAndMemberIds); + $rotationIds = array_values($rotationAndMemberIds); + + Database::get()->update('rotation_member', $markAsDeleted + ['position' => null], $updateCondition); + + if (! empty($rotationMemberIds)) { + Database::get()->update( + 'timeperiod_entry', + $markAsDeleted, + ['rotation_member_id IN (?)' => $rotationMemberIds, 'deleted = ?' => 'n'] + ); + } + + if (! empty($rotationIds)) { + $rotationIdsWithOtherMembers = Database::get()->fetchCol( + RotationMember::on(Database::get()) + ->columns('rotation_id') + ->filter( + Filter::all( + Filter::equal('rotation_id', $rotationIds), + Filter::unequal('contactgroup_id', $id) + ) + )->assembleSelect() + ); + + $toRemoveRotations = array_diff($rotationIds, $rotationIdsWithOtherMembers); + + if (! empty($toRemoveRotations)) { + $rotations = Rotation::on(Database::get()) + ->columns(['id', 'schedule_id', 'priority', 'timeperiod.id']) + ->filter(Filter::equal('id', $toRemoveRotations)); + + /** @var Rotation $rotation */ + foreach ($rotations as $rotation) { + $rotation->delete(); + } + } + } + + $escalationIds = Database::get()->fetchCol( + RuleEscalationRecipient::on(Database::get()) + ->columns('rule_escalation_id') + ->filter(Filter::equal('contactgroup_id', $id)) + ->assembleSelect() + ); + + Database::get()->update('rule_escalation_recipient', $markAsDeleted, $updateCondition); + + if (! empty($escalationIds)) { + $escalationIdsWithOtherRecipients = Database::get()->fetchCol( + RuleEscalationRecipient::on(Database::get()) + ->columns('rule_escalation_id') + ->filter( + Filter::all( + Filter::equal('rule_escalation_id', $escalationIds), + Filter::unequal('contactgroup_id', $id) + ) + )->assembleSelect() + ); + + $toRemoveEscalations = array_diff($escalationIds, $escalationIdsWithOtherRecipients); + + if (! empty($toRemoveEscalations)) { + Database::get()->update( + 'rule_escalation', + $markAsDeleted + ['position' => null], + ['id IN (?)' => $toRemoveEscalations] + ); + } + } + + Database::get()->update('contactgroup_member', $markAsDeleted, $updateCondition); + + Database::get()->update( + 'contactgroup', + $markEntityAsDeleted, + ['id = ?' => $id, 'deleted = ?' => 'n'] + ); + } + + /** + * Validate the request body for required fields and types + * + * @param requestBody $requestBody + * + * @return void + * + * @throws HttpBadRequestException + * @throws HttpException + */ + private function assertValidRequestBody(array $requestBody): void + { + $msgPrefix = 'Invalid request body: '; + + if ( + ! isset($requestBody['id'], $requestBody['name']) + || ! is_string($requestBody['id']) + || ! is_string($requestBody['name']) + ) { + throw new HttpException( + 422, + $msgPrefix . 'the fields id and name must be present and of type string' + ); + } + + if (! Uuid::isValid($requestBody['id'])) { + throw new HttpBadRequestException($msgPrefix . 'given id is not a valid UUID'); + } + + if (! empty($requestBody['users'])) { + if (! is_array($requestBody['users'])) { + throw new HttpBadRequestException($msgPrefix . 'expects users to be an array'); + } + + foreach ($requestBody['users'] as $user) { + if (! is_string($user) || ! Uuid::isValid($user)) { + throw new HttpException(422, sprintf( + '%sthe user identifier %s is not a valid UUID', + $msgPrefix, + $user + )); + } + //TODO: check if users exist, here? + } + } + } + + /** + * Add a new Contact Group with the given data + * + * @param requestBody $requestBody + * + * @return void + * @throws HttpException + */ + private function addContactgroup(array $requestBody): void + { + Database::get()->insert('contactgroup', [ + 'name' => $requestBody['name'], + 'external_uuid' => $requestBody['id'], + 'changed_at' => (int) (new DateTime())->format("Uv"), + ]); + + $id = Database::get()->lastInsertId(); + + if (! empty($requestBody['users'])) { + $this->addUsers($id, $requestBody['users']); + } + } + + private function updateContactgroup(array $requestBody, int $contactgroupId): void + { + $storedValues = $this->fetchDbValues($contactgroupId); + + $changedAt = (int) (new DateTime())->format("Uv"); + + if ($requestBody['name'] !== $storedValues['group_name']) { + Database::get()->update( + 'contactgroup', + ['name' => $requestBody['name'], 'changed_at' => $changedAt], + ['id = ?' => $contactgroupId] + ); + } + + $storedContacts = []; + if (! empty($storedValues['group_members'])) { + $storedContacts = explode(',', $storedValues['group_members']); + } + + $newContacts = []; + if (! empty($requestBody['users'])) { + foreach ($requestBody['users'] as $identifier) { + $contactId = Contacts::getContactId($identifier); + if ($contactId === null) { + throw new HttpException(422, sprintf('User with identifier %s not found', $identifier)); + } + $newContacts[] = $contactId; + } + } + + $toDelete = array_diff($storedContacts, $newContacts); + $toAdd = array_diff($newContacts, $storedContacts); + + if (! empty($toDelete)) { + Database::get()->update( + 'contactgroup_member', + ['changed_at' => $changedAt, 'deleted' => 'y'], + [ + 'contactgroup_id = ?' => $contactgroupId, + 'contact_id IN (?)' => $toDelete, + 'deleted = ?' => 'n' + ] + ); + } + + if (! empty($toAdd)) { + $contactsMarkedAsDeleted = Database::get()->fetchCol( + (new Select()) + ->from('contactgroup_member') + ->columns(['contact_id']) + ->where([ + 'contactgroup_id = ?' => $contactgroupId, + 'deleted = ?' => 'y', + 'contact_id IN (?)' => $toAdd + ]) + ); + + $toAdd = array_diff($toAdd, $contactsMarkedAsDeleted); + foreach ($toAdd as $contactId) { + Database::get()->insert( + 'contactgroup_member', + [ + 'contactgroup_id' => $contactgroupId, + 'contact_id' => $contactId, + 'changed_at' => $changedAt + ] + ); + } + + if (! empty($contactsMarkedAsDeleted)) { + Database::get()->update( + 'contactgroup_member', + ['changed_at' => $changedAt, 'deleted' => 'n'], + [ + 'contactgroup_id = ?' => $contactgroupId, + 'contact_id IN (?)' => $contactsMarkedAsDeleted + ] + ); + } + } + } + + /** + * Add the given users as contactgroup_member with the given id + * + * @param int $contactgroupId + * @param string[] $users + * + * @return void + * + * @throws HttpException + */ + private function addUsers(int $contactgroupId, array $users): void + { + foreach ($users as $identifier) { + $contactId = Contacts::getContactId($identifier); + + if ($contactId === null) { + throw new HttpException(422, sprintf('User with identifier %s not found', $identifier)); + } + + Database::get()->insert('contactgroup_member', [ + 'contactgroup_id' => $contactgroupId, + 'contact_id' => $contactId, + 'changed_at' => (int) (new DateTime())->format("Uv"), + ]); + } + } + + public function prepareRow(stdClass $row): void + { + $row->users = Contacts::fetchUserIdentifiers($row->contactgroup_id); + + unset($row->contactgroup_id); + } + + /** + * Assert that the name is unique + * + * @param string $name + * @param ?int $contactgroupId The id of the Contact Group to exclude + * + * @return void + * + * @throws HttpException if the username already exists + */ + private function assertUniqueName(string $name, int $contactgroupId = null): void + { + $stmt = (new Select()) + ->from('contactgroup') + ->columns('1') + ->where(['name = ?' => $name]); + + if ($contactgroupId) { + $stmt->where(['id != ?' => $contactgroupId]); + } + + $user = Database::get()->fetchOne($stmt); + + if ($user) { + throw new HttpException(422, sprintf('Username %s already exists', $name)); + } + } + + /** + * Fetch the values from the database + * + * @param int $contactgroupId + * @return array + * + * @throws HttpNotFoundException + */ + private function fetchDbValues(int $contactgroupId): array + { + $query = Contactgroup::on(Database::get()) + ->columns(['id', 'name']) + ->filter(Filter::equal('id', $contactgroupId)); + + $group = $query->first(); + if ($group === null) { + throw new HttpNotFoundException('Contact group not found'); + } + + $groupMembers = []; + foreach ($group->contactgroup_member as $contact) { + $groupMembers[] = $contact->contact_id; + } + + return [ + 'group_name' => $group->name, + 'group_members' => implode(',', $groupMembers) + ]; + } +} diff --git a/library/Notifications/Api/V1/Contacts.php b/library/Notifications/Api/V1/Contacts.php new file mode 100644 index 000000000..09d46cd10 --- /dev/null +++ b/library/Notifications/Api/V1/Contacts.php @@ -0,0 +1,1058 @@ + + * } + */ +#[OA\Schema( + schema: 'Contact', + description: 'Schema that represents a contact in the Icinga Notifications API', + required: [ + 'id', + 'full_name', + 'default_channel', + ], + type: 'object', + additionalProperties: false, +)] +#[OA\Schema( + schema: 'Addresses', + description: 'Schema that represents a contact\'s addresses', + properties: [ + new OA\Property( + property: 'email', + description: "User's email address", + type: 'string', + format: 'email', + ), + new OA\Property( + property: 'rocketchat', + description: 'Rocket.Chat identifier or URL', + type: 'string', + example: 'rocketchat.example.com', + ), + new OA\Property( + property: 'webhook', + description: 'Comma-separated list of webhook URLs or identifiers', + type: 'string', + example: 'https://example.com/webhook', + ), + ], + type: 'object', + additionalProperties: false, +)] +#[SchemaUUID( + entityName: 'Contact', + example: '9e868ad0-e774-465b-8075-c5a07e8f0726', +)] +#[SchemaUUID( + entityName: 'NewContact', + example: '52668ad0-e774-465b-8075-c5a07e8f0726', +)] +class Contacts extends ApiV1 implements RequestHandlerInterface, EndpointInterface +{ + #[OA\Examples( + example: 'ContactgroupNotExists', + summary: 'Contact Group does not exist', + value: ['message' => 'Contact Group with identifier x does not exist'] + )] + #[OA\Examples( + example: 'InvalidAddressType', + summary: 'Invalid address type', + value: ['message' => 'Invalid request body: undefined address type x given'] + )] + #[OA\Examples( + example: 'InvalidAddressFormat', + summary: 'Invalid address format', + value: ['message' => 'Invalid request body: expects addresses to be an array'] + )] + #[OA\Examples( + example: 'InvalidContactgroupUUID', + summary: 'Invalid Contact Group UUID', + value: ['message' => 'Invalid request body: the group identifier invalid_uuid is not a valid UUID'] + )] + #[OA\Examples( + example: 'InvalidContactgroupUUIDFormat', + summary: 'Invalid Contact Group UUID format', + value: ['message' => 'Invalid request body: an invalid group identifier format given'] + )] + #[OA\Examples( + example: 'InvalidDefaultChannelUUID', + summary: 'Invalid default_channel UUID', + value: ['message' => 'Invalid request body: given default_channel is not a valid UUID'] + )] + #[OA\Examples( + example: 'InvalidEmailAddress', + summary: 'Invalid email address', + value: ['message' => 'Invalid request body: an invalid email address given'] + )] + #[OA\Examples( + example: 'InvalidEmailAddressFormat', + summary: 'Invalid email address format', + value: ['message' => 'Invalid request body: an invalid email address format given'] + )] + #[OA\Examples( + example: 'InvalidGroupsFormat', + summary: 'Invalid groups format', + value: ['message' => 'Invalid request body: expects groups to be an array'] + )] + #[OA\Examples( + example: 'UsernameAlreadyExists', + summary: 'Username already exists', + value: ['message' => 'Username x already exists'] + )] + protected array $specificResponses = []; + #[OA\Property( + ref: '#/components/schemas/ContactUUID', + )] + protected string $id; + #[OA\Property( + description: 'The full name of the contact', + type: 'string', + example: 'Icinga User', + )] + protected string $full_name; + #[OA\Property( + description: 'The username of the contact', + type: 'string', + maxLength: 254, + example: 'icingauser', + )] + protected ?string $username = null; + #[OA\Property( + ref: '#/components/schemas/ChannelUUID', + description: 'The default channel UUID for the contact' + )] + protected string $default_channel; + #[OA\Property( + description: 'List of group UUIDs the contact belongs to', + type: 'array', + items: new OA\Items( + ref: '#/components/schemas/ContactgroupUUID', + description: 'Group UUIDs the contact belongs to', + ) + )] + protected ?array $groups = null; + #[OA\Property( + ref: '#/components/schemas/Addresses', + description: 'Contact addresses by type', + )] + protected ?array $addresses = null; + + public function getEndpoint(): string + { + return 'contacts'; + } + + /** + * Get a contact by UUID. + * + * @param string|null $identifier + * @param string $queryFilter + * + * @return ResponseInterface + * + * @throws HttpBadRequestException + * @throws HttpNotFoundException + * @throws JsonEncodeException + */ + #[OadV1Get( + entityName: 'Contact', + path: '/contacts/{identifier}', + description: 'Retrieve detailed information about a specific notification Contact using its UUID', + summary: 'Get a specific Contact by its UUID', + tags: ['Contacts'], + parameters: [ + new PathParameter( + name: 'identifier', + description: 'The UUID of the Contact to retrieve', + identifierSchema: 'ContactUUID' + ), + ], + )] + public function get(?string $identifier, string $queryFilter): ResponseInterface + { + $stmt = (new Select()) + ->distinct() + ->from('contact co') + ->columns([ + 'contact_id' => 'co.id', + 'id' => 'co.external_uuid', + 'full_name', + 'username', + 'default_channel' => 'ch.external_uuid', + ]) + ->joinLeft('contact_address ca', 'ca.contact_id = co.id') + ->joinLeft('channel ch', 'ch.id = co.default_channel_id') + ->where(['co.deleted = ?' => 'n']); + + if ($identifier === null) { + return $this->getPlural($queryFilter, $stmt); + } + + $stmt->where(['co.external_uuid = ?' => $identifier]); + + /** @var stdClass|false $result */ + $result = Database::get()->fetchOne($stmt); + + if ($result === false) { + throw new HttpNotFoundException('Contact not found'); + } + + $this->prepareRow($result); + + return $this->createResponse(body: Json::sanitize(['data' => $result])); + } + + /** + * List contacts or get specific contacts by filter parameters. + * + * @param string $queryFilter + * @param Select $stmt + * + * @return ResponseInterface + * + * @throws HttpBadRequestException + * @throws JsonEncodeException + */ + #[OadV1GetPlural( + entityName: 'Contact', + path: '/contacts', + description: 'Retrieve all Contacts or filter them by parameters.', + summary: 'List all Contacts or filter by parameters', + tags: ['Contacts'], + filter: ['id', 'full_name', 'username'], + parameters: [ + new QueryParameter( + name: 'id', + description: 'Filter Contacts by UUID', + schema: new SchemaUUID(entityName: 'Contact'), + ), + new QueryParameter( + name: 'full_name', + description: 'Filter Contacts by full name', + ), + new QueryParameter( + name: 'username', + description: 'Filter Contacts by username', + schema: new OA\Schema(type: 'string', maxLength: 254) + ), + ], + responses: [] + )] + private function getPlural(string $queryFilter, Select $stmt): ResponseInterface + { + $filter = $this->assembleFilter( + $queryFilter, + ['id', 'full_name', 'username'], + 'co.external_uuid' + ); + + if ($filter !== false) { + $stmt->where($filter); + } + + return $this->createResponse(body: $this->createContentGenerator($stmt)); + } + + /** + * Update a contact by UUID. + * + * @param string $identifier + * @param requestBody $requestBody + * + * @return ResponseInterface + * + * @throws HttpBadRequestException + * @throws HttpException + * @throws JsonEncodeException + */ + #[OadV1Put( + entityName: 'Contact', + path: '/contacts/{identifier}', + description: 'Update a Contact by UUID, if it doesn\'t exist, it will be created. \ + The identifier must be the same as the payload id', + summary: 'Update a Contact by UUID', + requiredFields: ['id', 'full_name', 'default_channel'], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + ref: '#/components/schemas/Contact' + ) + ), + tags: ['Contacts'], + parameters: [ + new PathParameter( + name: 'identifier', + description: 'The UUID of the Contact to Update', + identifierSchema: 'NewContactUUID' + ) + ], + examples400: [ + new ResponseExample('InvalidDefaultChannelUUID'), + ], + examples422: [ + new ResponseExample('ContactgroupNotExists'), + new ResponseExample('InvalidAddressFormat'), + new ResponseExample('InvalidAddressType'), + new ResponseExample('InvalidContactgroupUUID'), + new ResponseExample('InvalidContactgroupUUIDFormat'), + new ResponseExample('InvalidEmailAddress'), + new ResponseExample('InvalidEmailAddressFormat'), + new ResponseExample('InvalidGroupsFormat'), + new ResponseExample('UsernameAlreadyExists'), + ] + )] + public function put(string $identifier, array $requestBody): ResponseInterface + { + if (empty($identifier)) { + throw new HttpBadRequestException('Identifier is required'); + } + + $this->assertValidRequestBody($requestBody); + + if ($identifier !== $requestBody['id']) { + throw new HttpException(422, 'Identifier mismatch'); + } + + Database::get()->beginTransaction(); + + if (($contactId = self::getContactId($identifier)) !== null) { + $this->updateContact($requestBody, $contactId); + + $result = $this->createResponse(204); + } else { + $this->addContact($requestBody); + $result = $this->createResponse( + 201, + [ + 'Location' => sprintf( + 'notifications/api/%s/%s/%s', + self::VERSION, + $this->getEndpoint(), + $requestBody['id'] + ), + 'X-Resource-Identifier' => $requestBody['id'] + ], + Json::sanitize(['message' => 'Contact created successfully']) + ); + } + + Database::get()->commitTransaction(); + + return $result; + } + + /** + * Create a new contact. + * + * @param string|null $identifier + * @param requestBody $requestBody + * + * @return ResponseInterface + * + * @throws HttpBadRequestException + * @throws HttpException + * @throws HttpNotFoundException + * @throws JsonEncodeException + */ + #[OadV1Post( + entityName: 'Contact', + path: '/contacts', + description: 'Create a new Contact', + summary: 'Create a new Contact', + requiredFields: ['id', 'full_name', 'default_channel'], + tags: ['Contacts'], + examples400: [ + new ResponseExample('InvalidDefaultChannelUUID'), + ], + examples422: [ + new ResponseExample('ContactgroupNotExists'), + new ResponseExample('InvalidAddressType'), + new ResponseExample('InvalidAddressFormat'), + new ResponseExample('InvalidContactgroupUUID'), + new ResponseExample('InvalidContactgroupUUIDFormat'), + new ResponseExample('InvalidEmailAddress'), + new ResponseExample('InvalidEmailAddressFormat'), + new ResponseExample('InvalidGroupsFormat'), + new ResponseExample('UsernameAlreadyExists'), + ] + )] + #[OadV1Post( + entityName: 'Contact', + path: '/contacts/{identifier}', + description: 'Replace a Contact by UUID, the identifier must be different from the payload id', + summary: 'Replace a Contact by UUID', + requiredFields: ['id', 'full_name', 'default_channel'], + tags: ['Contacts'], + parameters: [ + new PathParameter( + name: 'identifier', + description: 'The UUID of the contact to create', + identifierSchema: 'ContactUUID' + ) + ], + examples400: [ + new ResponseExample('InvalidDefaultChannelUUID'), + ], + examples422: [ + new ResponseExample('ContactgroupNotExists'), + new ResponseExample('InvalidAddressType'), + new ResponseExample('InvalidAddressFormat'), + new ResponseExample('InvalidContactgroupUUID'), + new ResponseExample('InvalidContactgroupUUIDFormat'), + new ResponseExample('InvalidEmailAddress'), + new ResponseExample('InvalidEmailAddressFormat'), + new ResponseExample('InvalidGroupsFormat'), + new ResponseExample('UsernameAlreadyExists'), + ] + )] + public function post(?string $identifier, array $requestBody): ResponseInterface + { + $this->assertValidRequestBody($requestBody); + + Database::get()->beginTransaction(); + + $emptyIdentifier = $identifier === null; + + if (! $emptyIdentifier) { + if ($identifier === $requestBody['id']) { + throw new HttpException( + 422, + 'Identifier mismatch: the Payload id must be different from the URL identifier' + ); + } + + $contactId = $this->getContactId($identifier); + + if ($contactId === null) { + throw new HttpNotFoundException('Contact not found'); + } + } + + if ($this->getContactId($requestBody['id']) !== null) { + throw new HttpException(422, 'Contact already exists'); + } + + if (! $emptyIdentifier) { + $this->removeContact($contactId); + } + + $this->addContact($requestBody); + Database::get()->commitTransaction(); + + return $this->createResponse( + 201, + [ + 'Location' => sprintf( + 'notifications/api/%s/%s/%s', + self::VERSION, + $this->getEndpoint(), + $requestBody['id'] + ), + 'X-Resource-Identifier' => $requestBody['id'] + ], + Json::sanitize(['message' => 'Contact created successfully']) + ); + } + + /** + * Remove the contact with the given id + * + * @param string $identifier + * + * @return ResponseInterface + * + * @throws HttpBadRequestException + * @throws HttpNotFoundException + */ + #[OadV1Delete( + entityName: 'Contact', + path: '/contacts/{identifier}', + description: 'Delete a Contact by UUID', + summary: 'Delete a Contact by UUID', + tags: ['Contacts'], + )] + public function delete(string $identifier): ResponseInterface + { + if (empty($identifier)) { + throw new HttpBadRequestException('Identifier is required'); + } + + $contactId = $this->getContactId($identifier); + + if ($contactId === null) { + throw new HttpNotFoundException('Contact not found'); + } + + Database::get()->beginTransaction(); + $this->removeContact($contactId); + Database::get()->commitTransaction(); + + return $this->createResponse(204); + } + + public function prepareRow(stdClass $row): void + { + $row->groups = ContactGroups::fetchGroupIdentifiers($row->contact_id); + $row->addresses = self::fetchContactAddresses($row->contact_id) ?: new stdClass(); + + unset($row->contact_id); + } + + /** + * Fetch the addresses of the contact with the given id + * + * @param int $contactId + * + * @return array + */ + public static function fetchContactAddresses(int $contactId): array + { + /** @var array $addresses */ + $addresses = Database::get()->fetchPairs( + (new Select()) + ->from('contact_address') + ->columns(['type', 'address']) + ->where(['contact_id = ?' => $contactId]) + ); + + return $addresses; + } + + /** + * Get the contact id with the given identifier + * + * @param string $identifier + * + * @return ?int Returns null, if contact does not exist + */ + public static function getContactId(string $identifier): ?int + { + /** @var stdClass|false $contact */ + $contact = Database::get()->fetchOne( + (new Select()) + ->from('contact') + ->columns('id') + ->where(['external_uuid = ?' => $identifier]) + ); + +// if ($contact === false) { +// $deletedContact = Database::get() +// ->fetchCol('SELECT id FROM contact WHERE external_uuid = ?', [$identifier]); +// +// if (! empty($deletedContact)) { +// throw new HttpException(422, 'Contact id is not available: ' . $identifier); +// } +// } + + return $contact->id ?? null; + +// $contact = Database::get() +// ->fetchCol('SELECT id FROM contact WHERE external_uuid = ?', [$identifier]); +// +// return $contact[0] ?? null; + } + + /** + * Add the groups to the given contact + * + * @param int $contactId + * @param string[] $groups + * + * @return void + * @throws HttpException + */ + private function addGroups(int $contactId, array $groups): void + { + foreach ($groups as $groupIdentifier) { + $groupId = ContactGroups::getGroupId($groupIdentifier); + + if ($groupId === null) { + throw new HttpException( + 422, + sprintf('Contact Group with identifier %s does not exist', $groupIdentifier) + ); + } + + Database::get()->insert('contactgroup_member', [ + 'contact_id' => $contactId, + 'contactgroup_id' => $groupId, + 'changed_at' => (int) (new DateTime())->format("Uv"), + ]); + } + } + + /** + * Add the addresses to the given contact + * + * @param int $contactId + * @param array $addresses + * + * @return void + */ + private function addAddresses(int $contactId, array $addresses): void + { + foreach ($addresses as $type => $address) { + Database::get()->insert('contact_address', [ + 'contact_id' => $contactId, + 'type' => $type, + 'address' => $address, + 'changed_at' => (int) (new DateTime())->format("Uv"), + ]); + } + } + + /** + * Add a new contact with the given data + * + * @param requestBody $requestBody + * + * @return void + * @throws HttpException + */ + private function addContact(array $requestBody): void + { + if (! empty($requestBody['username'])) { + $this->assertUniqueUsername($requestBody['username']); + } + + $channelID = Channels::getChannelId($requestBody['default_channel']); + if ($channelID === false) { + throw new HttpException(422, 'Channel with identifier 0817d973-398e-41d7-9cd2-61cdb7ef41a3 does not exist'); + } + + Database::get()->insert('contact', [ + 'full_name' => $requestBody['full_name'], + 'username' => $requestBody['username'] ?? null, + 'default_channel_id' => $channelID, + 'external_uuid' => $requestBody['id'], + 'changed_at' => (int) (new DateTime())->format("Uv"), + ]); + + $contactId = Database::get()->lastInsertId(); + + if (! empty($requestBody['addresses'])) { + $this->addAddresses($contactId, $requestBody['addresses']); + } + + if (! empty($requestBody['groups'])) { + $this->addGroups($contactId, $requestBody['groups']); + } + } + + private function updateContact(array $requestBody, int $contactId): void { + + if (! empty($requestBody['username'])) { + $this->assertUniqueUsername($requestBody['username'], $contactId); + } + + $channelID = Channels::getChannelId($requestBody['default_channel']); + + if ($channelID === false) { + throw new HttpException(422, 'Channel with identifier 0817d973-398e-41d7-9cd2-61cdb7ef41a3 does not exist'); + } + + $changedAt = (int) (new DateTime())->format("Uv"); + Database::get()->update('contact', [ + 'full_name' => $requestBody['full_name'], + 'username' => $requestBody['username'] ?? null, + 'default_channel_id' => $channelID, + 'changed_at' => $changedAt, + ], ['id = ?' => $contactId]); + + $markAsDeleted = ['deleted' => 'y']; + Database::get()->update( + 'contact_address', + $markAsDeleted, + ['contact_id = ?' => $contactId, 'deleted = ?' => 'n'] + ); + + if (! empty($requestBody['addresses'])) { + $this->addAddresses($contactId, $requestBody['addresses']); + } + + $storedValues = $this->fetchDbValues($contactId); + $storedContacts = []; + if (! empty($storedValues['group_members'])) { + $storedContacts = explode(',', $storedValues['group_members']); + } + + $newContactgroups = []; + if (! empty($requestBody['groups'])) { + foreach ($requestBody['groups'] as $identifier) { + $contactgroupId = Contactgroups::getGroupId($identifier); + if ($contactgroupId === null) { + throw new HttpException(422, sprintf('Contact Group with identifier %s does not exist', $identifier)); + } + $newContactgroups[] = $contactgroupId; + } + } + + $toDelete = array_diff($storedContacts, $newContactgroups); + $toAdd = array_diff($newContactgroups, $storedContacts); + + if (! empty($toDelete)) { + Database::get()->update( + 'contactgroup_member', + ['changed_at' => $changedAt, 'deleted' => 'y'], + [ + 'contactgroup_id = ?' => $toDelete, + 'contact_id IN (?)' => $contactId, + 'deleted = ?' => 'n' + ] + ); + } + + if (! empty($toAdd)) { + $contactgroupsMarkedAsDeleted = Database::get()->fetchCol( + (new Select()) + ->from('contactgroup_member') + ->columns(['contactgroup_id']) + ->where([ + 'contact_id = ?' => $contactId, + 'deleted = ?' => 'y', + 'contactgroup_id IN (?)' => $toAdd + ]) + ); + + $toAdd = array_diff($toAdd, $contactgroupsMarkedAsDeleted); + foreach ($toAdd as $contactgroupId) { + Database::get()->insert( + 'contactgroup_member', + [ + 'contactgroup_id' => $contactgroupId, + 'contact_id' => $contactId, + 'changed_at' => $changedAt + ] + ); + } + + if (! empty($contactgroupsMarkedAsDeleted)) { + Database::get()->update( + 'contactgroup_member', + ['changed_at' => $changedAt, 'deleted' => 'n'], + [ + 'contact_id = ?' => $contactId, + 'contactgroup_id IN (?)' => $contactgroupsMarkedAsDeleted + ] + ); + } + } + } + + /** + * Remove the contact with the given id + * + * @param int $id + * + * @return void + */ + private function removeContact(int $id): void + { + //TODO: "remove rotations|escalations with no members" taken from form. Is it properly? + + $markAsDeleted = ['changed_at' => (int) (new DateTime())->format("Uv"), 'deleted' => 'y']; + $markEntityAsDeleted = array_merge( + $markAsDeleted, + ['external_uuid' => substr_replace(Uuid::uuid4()->toString(), '0', 14, 1)] + ); + $updateCondition = ['contact_id = ?' => $id, 'deleted = ?' => 'n']; + + $rotationAndMemberIds = Database::get()->fetchPairs( + RotationMember::on(Database::get()) + ->columns(['id', 'rotation_id']) + ->filter(Filter::equal('contact_id', $id)) + ->assembleSelect() + ); + + $rotationMemberIds = array_keys($rotationAndMemberIds); + $rotationIds = array_values($rotationAndMemberIds); + + Database::get()->update('rotation_member', $markAsDeleted + ['position' => null], $updateCondition); + + if (! empty($rotationMemberIds)) { + Database::get()->update( + 'timeperiod_entry', + $markAsDeleted, + ['rotation_member_id IN (?)' => $rotationMemberIds, 'deleted = ?' => 'n'] + ); + } + + if (! empty($rotationIds)) { + $rotationIdsWithOtherMembers = Database::get()->fetchCol( + RotationMember::on(Database::get()) + ->columns('rotation_id') + ->filter( + Filter::all( + Filter::equal('rotation_id', $rotationIds), + Filter::unequal('contact_id', $id) + ) + )->assembleSelect() + ); + + $toRemoveRotations = array_diff($rotationIds, $rotationIdsWithOtherMembers); + + if (! empty($toRemoveRotations)) { + $rotations = Rotation::on(Database::get()) + ->columns(['id', 'schedule_id', 'priority', 'timeperiod.id']) + ->filter(Filter::equal('id', $toRemoveRotations)); + + /** @var Rotation $rotation */ + foreach ($rotations as $rotation) { + $rotation->delete(); + } + } + } + + $escalationIds = Database::get()->fetchCol( + RuleEscalationRecipient::on(Database::get()) + ->columns('rule_escalation_id') + ->filter(Filter::equal('contact_id', $id)) + ->assembleSelect() + ); + + Database::get()->update('rule_escalation_recipient', $markAsDeleted, $updateCondition); + + if (! empty($escalationIds)) { + $escalationIdsWithOtherRecipients = Database::get()->fetchCol( + RuleEscalationRecipient::on(Database::get()) + ->columns('rule_escalation_id') + ->filter( + Filter::all( + Filter::equal('rule_escalation_id', $escalationIds), + Filter::unequal('contact_id', $id) + ) + )->assembleSelect() + ); + + $toRemoveEscalations = array_diff($escalationIds, $escalationIdsWithOtherRecipients); + + if (! empty($toRemoveEscalations)) { + Database::get()->update( + 'rule_escalation', + $markAsDeleted + ['position' => null], + ['id IN (?)' => $toRemoveEscalations] + ); + } + } + + Database::get()->update('contactgroup_member', $markAsDeleted, $updateCondition); + Database::get()->update('contact_address', $markAsDeleted, $updateCondition); + + Database::get()->update('contact', $markEntityAsDeleted + ['username' => null], ['id = ?' => $id]); + } + + /** + * Assert that the username is unique + * + * @param string $username + * @param ?int $contactId The id of the contact to exclude + * + * @return void + * + * @throws HttpException if the username already exists + */ + private function assertUniqueUsername(string $username, int $contactId = null): void + { + $stmt = (new Select()) + ->from('contact') + ->columns('1') + ->where(['username = ?' => $username]); + + if ($contactId) { + $stmt->where(['id != ?' => $contactId]); + } + + $user = Database::get()->fetchOne($stmt); + + if ($user) { + throw new HttpException(422, sprintf('Username %s already exists', $username)); + } + } + + /** + * Validate the request body for required fields and types + * + * @param array $requestBody + * + * @return void + * + * @throws HttpBadRequestException + * @throws HttpException + */ + private function assertValidRequestBody(array $requestBody): void + { + $msgPrefix = 'Invalid request body: '; + + if ( + ! isset($requestBody['id'], $requestBody['full_name'], $requestBody['default_channel']) + || ! is_string($requestBody['id']) + || ! is_string($requestBody['full_name']) + || ! is_string($requestBody['default_channel']) + ) { + throw new HttpException( + 422, + $msgPrefix . 'the fields id, full_name and default_channel must be present and of type string' + ); + } + + if (! Uuid::isValid($requestBody['id'])) { + throw new HttpBadRequestException($msgPrefix . 'given id is not a valid UUID'); + } + + if (! Uuid::isValid($requestBody['default_channel'])) { + throw new HttpException(422, $msgPrefix . 'given default_channel is not a valid UUID'); + } + + if (! empty($requestBody['username']) && ! is_string($requestBody['username'])) { + throw new HttpBadRequestException($msgPrefix . 'expects username to be a string'); + } + + if (! empty($requestBody['groups'])) { + if (! is_array($requestBody['groups'])) { + throw new HttpBadRequestException($msgPrefix . 'expects groups to be an array'); + } + + foreach ($requestBody['groups'] as $group) { + if (! is_string($group)) { + throw new HttpException(422, $msgPrefix . 'an invalid group identifier format given'); + } elseif (! Uuid::isValid($group)) { + throw new HttpException( + 422, + sprintf($msgPrefix . 'the group identifier %s is not a valid UUID', $group) + ); + } + } + } + + if (! empty($requestBody['addresses'])) { + if (! is_array($requestBody['addresses'])) { + throw new HttpBadRequestException($msgPrefix . 'expects addresses to be an array'); + } + + $addressTypes = array_keys($requestBody['addresses']); + + $types = Database::get()->fetchCol( + (new Select()) + ->from('available_channel_type') + ->columns('type') + ->where(['type IN (?)' => $addressTypes]) + ); + + if (count($types) !== count($addressTypes)) { + throw new HttpException( + 422, + sprintf( + $msgPrefix . 'undefined address type %s given', + implode(', ', array_diff($addressTypes, $types)) + ) + ); + } + //TODO: is it a good idea to check valid channel types here?, if yes, + //default_channel and group identifiers must be checked here as well..404 OR 400? + if (isset($requestBody['addresses']['email'])) { + if (! is_string($requestBody['addresses']['email'])) { + throw new HttpBadRequestException($msgPrefix . 'an invalid email address format given'); + } + + if ( + ! empty($requestBody['addresses']['email']) + && ! (new EmailAddressValidator())->isValid($requestBody['addresses']['email']) + ) { + throw new HttpBadRequestException($msgPrefix . 'an invalid email address given'); + } + } + } + } + + /** + * Fetch the user(contact) identifiers of the Contact Group with the given id from the contactgroup_member table + * + * @param int $contactgroupId + * + * @return string[] + */ + public static function fetchUserIdentifiers(int $contactgroupId): array + { + return Database::get()->fetchCol( + (new Select()) + ->from('contactgroup_member cgm') + ->columns('co.external_uuid') + ->joinLeft('contact co', 'co.id = cgm.contact_id') + ->where(['cgm.contactgroup_id = ?' => $contactgroupId, 'cgm.deleted = ?' => 'n']) + ->groupBy('co.external_uuid') + ); + } + + /** + * Fetch the values from the database + * + * @param int $contactId + * @return array + * + * @throws HttpNotFoundException + */ + private function fetchDbValues(int $contactId): array + { + $query = Contact::on(Database::get()) + ->columns(['id', 'full_name', 'default_channel_id']) + ->filter(Filter::equal('id', $contactId)); + + $contact = $query->first(); + if ($contact === null) { + throw new HttpNotFoundException('Contact contact not found'); + } + + $groupMembers = []; + $member = $contact->contactgroup_member; + foreach ($contact->contactgroup_member as $group) { + $groupMembers[] = $group->contactgroup_id; + } + + return [ + 'group_members' => implode(',', $groupMembers) + ]; + } +} diff --git a/library/Notifications/Common/HttpMethod.php b/library/Notifications/Common/HttpMethod.php new file mode 100644 index 000000000..b25993733 --- /dev/null +++ b/library/Notifications/Common/HttpMethod.php @@ -0,0 +1,48 @@ +name; + } + + /** + * Returns the current enum case as string in lowercase. + * + * @return string + */ + public function lowercase(): string + { + return $this->value; + } + + /** + * Retrieves an enum instance from a ServerRequestInterface by extracting the HTTP method. + * + * @param ServerRequestInterface $request The server request containing the HTTP method. + * + * @return HttpMethod The enum instance corresponding to the provided method. + */ + public static function fromRequest(ServerRequestInterface $request): self + { + return self::from(strtolower($request->getMethod())); + } +} diff --git a/library/Notifications/Common/PsrLogger.php b/library/Notifications/Common/PsrLogger.php new file mode 100644 index 000000000..3a7542e5f --- /dev/null +++ b/library/Notifications/Common/PsrLogger.php @@ -0,0 +1,68 @@ + ERROR + * notice -> INFO + */ + private const MAP = [ + LogLevel::EMERGENCY => 'error', + LogLevel::ALERT => 'error', + LogLevel::CRITICAL => 'error', + LogLevel::ERROR => 'error', + LogLevel::WARNING => 'warning', + LogLevel::NOTICE => 'info', + LogLevel::INFO => 'info', + LogLevel::DEBUG => 'debug', + ]; + + /** + * Logs with an arbitrary level. + * + * @param string $level The log level + * @param string|Stringable $message The log message + * @param array $context Additional context variables to interpolate in the message + */ + public function log($level, string|\Stringable $message, array $context = []): void + { + $level = strtolower((string) $level); + $icingaMethod = self::MAP[$level] ?? 'debug'; + + array_unshift($context, (string) $message); + + switch ($icingaMethod) { + case 'error': + IcingaLogger::error(...$context); + break; + case 'warning': + IcingaLogger::warning(...$context); + break; + case 'info': + IcingaLogger::info(...$context); + break; + default: + IcingaLogger::debug(...$context); + break; + } + } +} diff --git a/library/Notifications/Model/Channel.php b/library/Notifications/Model/Channel.php index 7c6a3e9cb..4f6a8b4f5 100644 --- a/library/Notifications/Model/Channel.php +++ b/library/Notifications/Model/Channel.php @@ -45,7 +45,8 @@ public function getColumns(): array 'type', 'config', 'changed_at', - 'deleted' + 'deleted', + 'external_uuid' ]; } @@ -54,7 +55,8 @@ public function getColumnDefinitions(): array return [ 'name' => t('Name'), 'type' => t('Type'), - 'changed_at' => t('Changed At') + 'changed_at' => t('Changed At'), + 'external_uuid' => t('UUID') ]; } diff --git a/library/Notifications/Model/Contact.php b/library/Notifications/Model/Contact.php index 7165bb9a6..35027ce18 100644 --- a/library/Notifications/Model/Contact.php +++ b/library/Notifications/Model/Contact.php @@ -49,7 +49,8 @@ public function getColumns(): array 'username', 'default_channel_id', 'changed_at', - 'deleted' + 'deleted', + 'external_uuid' ]; } @@ -58,7 +59,8 @@ public function getColumnDefinitions(): array return [ 'full_name' => t('Full Name'), 'username' => t('Username'), - 'changed_at' => t('Changed At') + 'changed_at' => t('Changed At'), + 'external_uuid' => t('UUID') ]; } diff --git a/library/Notifications/Model/Contactgroup.php b/library/Notifications/Model/Contactgroup.php index 3dc79481c..491c7ab4f 100644 --- a/library/Notifications/Model/Contactgroup.php +++ b/library/Notifications/Model/Contactgroup.php @@ -42,13 +42,18 @@ public function getColumns(): array return [ 'name', 'changed_at', - 'deleted' + 'deleted', + 'external_uuid' ]; } public function getColumnDefinitions(): array { - return ['name' => t('Name')]; + return [ + 'name' => t('Name'), + 'changed_at' => t('Changed At'), + 'external_uuid' => t('UUID') + ]; } public function getSearchColumns(): array diff --git a/library/Notifications/Test/ApiTestBackends.php b/library/Notifications/Test/ApiTestBackends.php new file mode 100644 index 000000000..105dd3ebe --- /dev/null +++ b/library/Notifications/Test/ApiTestBackends.php @@ -0,0 +1,340 @@ + + */ + private static array $backends = []; + + /** + * Initialize the configuration for the API tests + * + * @param Connection $db + * @param string $driver + * + * @return void + */ + abstract protected static function initializeNotificationsDb(Connection $db, string $driver): void; + + /** + * Provide the endpoints for the API tests plus their accompanying database connections + * + * @return array + */ + final public function apiTestBackends(): array + { + self::initializeBackends(); + + return self::$backends; + } + + /** + * Initialize the API test backends + * + * @return void + * + * @internal Only the trait itself should access this method + */ + final protected static function initializeBackends(): void + { + $webPath = self::getIcingaWebPath(); + + $port = 1792; + foreach (self::sharedDatabases() as $name => $connection) { + if (isset(self::$backends[$name])) { + continue; + } + + $socket = sprintf('127.0.0.1:%d', $port); + $configDir = sys_get_temp_dir() . "/notifications-api-test-backend-$port"; + + self::initializeIcingaWeb($name, $configDir); + + if (self::fork()) { + $env = ['ICINGAWEB_CONFIGDIR' => $configDir]; + + $libDir = getenv('ICINGAWEB_LIBDIR'); + if ($libDir !== false) { + $env['ICINGAWEB_LIBDIR'] = $libDir; + } + + pcntl_exec( + readlink('/proc/self/exe'), + ['-q', '-S', $socket, '-t', "$webPath/public", "$webPath/public/index.php"], + $env + ); + } else { + self::$backends[$name] = [ + $connection[0], + Url::fromRequest(request: new Request()) + ->setScheme('http') + ->setHost('127.0.0.1') + ->setPort($port) + ->setBasePath('/notifications/api') + ->setUsername('test') + ->setPassword('test') + ]; + } + + $port++; + } + } + + /** + * Initialize the Icinga Web configuration + * + * @param string $driver + * @param string $configDir + * + * @return void + * + * @internal Only the trait itself should access this method + */ + final protected static function initializeIcingaWeb(string $driver, string $configDir): void + { + $oldConfigDir = Config::$configDir; + Config::$configDir = $configDir; + + $connectionConfig = self::getConnectionConfig($driver); + + Config::app(fromDisk: true) + ->setSection('global', [ + 'config_resource' => 'web_db' + ])->setSection('logging', [ + 'log' => 'file', + 'file' => $configDir . '/icingaweb.log', + 'level' => 'debug' + ])->saveIni(); + Config::app('resources', true) + ->setSection('web_db', [ + 'type' => 'db', + 'db' => $connectionConfig->db, + 'host' => $connectionConfig->host, + 'port' => $connectionConfig->port, + 'dbname' => self::getEnvironmentVariable(strtoupper($driver) . '_ICINGAWEBDB'), + 'username' => self::getEnvironmentVariable(strtoupper($driver) . '_ICINGAWEBDB_USER'), + 'password' => self::getEnvironmentVariable(strtoupper($driver) . '_ICINGAWEBDB_PASSWORD') + ])->setSection('notifications_db', [ + 'type' => 'db', + 'db' => $connectionConfig->db, + 'host' => $connectionConfig->host, + 'port' => $connectionConfig->port, + 'dbname' => $connectionConfig->dbname, + 'username' => $connectionConfig->username, + 'password' => $connectionConfig->password + ])->saveIni(); + Config::app('roles', true)->setSection('test', [ + 'permissions' => 'module/notifications,notifications/api', + 'users' => 'test' + ])->saveIni(); + Config::app('authentication', true)->setSection('test', [ + 'backend' => 'db', + 'resource' => 'web_db' + ])->saveIni(); + Config::module('notifications', fromDisk: true)->setSection('database', [ + 'resource' => 'notifications_db' + ])->saveIni(); + + Config::$configDir = $oldConfigDir; + + if (! is_link("$configDir/enabledModules/notifications")) { + mkdir("$configDir/enabledModules", 0755, true); + symlink(realpath(__DIR__ . '/../../..'), "$configDir/enabledModules/notifications"); + } + } + + final protected static function setUpSchema(Connection $db, string $driver): void + { + $webSchema = self::getIcingaWebPath() . "/schema/$driver.schema.sql"; + + $notificationSchemaPath = getenv('ICINGA_NOTIFICATIONS_SCHEMA'); + if (! $notificationSchemaPath) { + throw new RuntimeException('Environment variable ICINGA_NOTIFICATIONS_SCHEMA is not set'); + } + + $notificationSchema = $notificationSchemaPath . "/$driver/schema.sql"; + if (! file_exists($notificationSchema)) { + throw new RuntimeException("Schema file $notificationSchema does not exist"); + } + + $webDb = self::connectToIcingaWebDb($driver); + $webDb->exec(file_get_contents($webSchema)); + self::initializeIcingaWebDb($webDb, $driver); + + $db->exec(file_get_contents($notificationSchema)); + static::initializeNotificationsDb($db, $driver); + } + + final protected static function tearDownSchema(Connection $db, string $driver): void + { + $webDb = self::connectToIcingaWebDb($driver); + + if ($driver === 'mysql') { + $webDb->exec(self::MYSQL_DROP_PROCEDURE); + $db->exec(self::MYSQL_DROP_PROCEDURE); + + $webDb->exec(self::MYSQL_PROCEDURE_CALL); + $db->exec(self::MYSQL_PROCEDURE_CALL); + } elseif ($driver === 'pgsql') { + $webDb->exec(self::PGSQL_DROP_PROCEDURE); + $db->exec(self::PGSQL_DROP_PROCEDURE); + } + } + + /** + * Initialize the Icinga Web database + * + * @param Connection $db + * @param string $driver + * + * @return void + * + * @internal Only the trait itself should access this method + */ + final protected static function initializeIcingaWebDb(Connection $db, string $driver): void + { + $db->insert('icingaweb_user', [ + 'name' => 'test', + 'active' => 1, + 'password_hash' => password_hash('test', PASSWORD_DEFAULT), + ]); + } + + /** + * Get the path to the Icinga Web installation + * + * @return string + * + * @internal Only the trait itself should access this method + */ + final protected static function getIcingaWebPath(): string + { + $webPath = getenv('ICINGAWEB_PATH'); + if ($webPath === false) { + echo "ICINGAWEB_PATH environment variable not set\n"; + exit(1); + } + + $webPath = realpath($webPath); + if (! $webPath) { + echo "ICINGAWEB_PATH environment variable is not a valid path: $webPath\n"; + exit(1); + } + + return $webPath; + } + + /** + * Connect to the Icinga Web database + * + * @param string $driver + * + * @return Connection + * + * @internal Only the trait itself should access this method + */ + final protected static function connectToIcingaWebDb(string $driver): Connection + { + return new Connection([ + 'db' => $driver, + 'host' => self::getEnvironmentVariable(strtoupper($driver) . '_TESTDB_HOST'), + 'port' => self::getEnvironmentVariable(strtoupper($driver) . '_TESTDB_PORT'), + 'username' => self::getEnvironmentVariable(strtoupper($driver) . '_ICINGAWEBDB_USER'), + 'password' => self::getEnvironmentVariable(strtoupper($driver) . '_ICINGAWEBDB_PASSWORD'), + 'dbname' => self::getEnvironmentVariable(strtoupper($driver) . '_ICINGAWEBDB') + ]); + } + + /** + * Fork the current process and return true in the child process and false in the parent process + * + * @return bool + * + * @internal Only the trait itself should access this method + */ + final protected static function fork(): bool + { + $pid = pcntl_fork(); + if ($pid == -1) { + echo "Could not fork\n"; + exit(2); + } elseif ($pid) { + register_shutdown_function(function () use ($pid) { + posix_kill($pid, SIGTERM); + }); + + return false; + } + + return true; + } +} diff --git a/library/Notifications/Test/BaseApiV1TestCase.php b/library/Notifications/Test/BaseApiV1TestCase.php new file mode 100644 index 000000000..af7464792 --- /dev/null +++ b/library/Notifications/Test/BaseApiV1TestCase.php @@ -0,0 +1,190 @@ +insert('available_channel_type', [ + 'type' => 'email', + 'name' => 'Email', + 'version' => 1, + 'author' => 'Test', + 'config_attrs' => '' + ]); + $db->insert('available_channel_type', [ + 'type' => 'webhook', + 'name' => 'Webhook', + 'version' => 1, + 'author' => 'Test', + 'config_attrs' => '' + ]); + $db->insert('available_channel_type', [ + 'type' => 'rocketchat', + 'name' => 'rocketchat', + 'version' => 1, + 'author' => 'Test', + 'config_attrs' => '' + ]); + + self::createChannels($db); + self::createContacts($db); + self::createContactGroups($db); + } + + protected static function createChannels(Connection $db): void + { + $db->insert('channel', [ + 'external_uuid' => self::CHANNEL_UUID, + 'name' => 'Test', + 'type' => 'email', + 'changed_at' => (int) (new DateTime())->format("Uv"), + ]); + + $db->insert('channel', [ + 'external_uuid' => self::CHANNEL_UUID_2, + 'name' => 'Test2', + 'type' => 'webhook', + 'changed_at' => (int) (new DateTime())->format("Uv"), + ]); + } + + protected static function deleteChannels(Connection $db): void + { + $db->delete('channel', "external_uuid in ('" . self::CHANNEL_UUID . "', '" . self::CHANNEL_UUID_2 . "')"); + } + + protected static function createContacts(Connection $db): void + { + $channelId = $db->select( + (new Select()) + ->from('channel') + ->columns(['id']) + ->where('external_uuid = ?', self::CHANNEL_UUID) + ->limit(1) + )->fetchColumn(); + + $db->insert('contact', [ + 'full_name' => 'Test', + 'username' => 'test', + 'default_channel_id' => $channelId, + 'external_uuid' => self::CONTACT_UUID, + 'changed_at' => (int) (new DateTime())->format("Uv"), + ]); + $db->insert('contact', [ + 'full_name' => 'Test2', + 'username' => 'test2', + 'default_channel_id' => $channelId, + 'external_uuid' => self::CONTACT_UUID_2, + 'changed_at' => (int) (new DateTime())->format("Uv"), + ]); + } + + protected static function deleteContacts(Connection $db): void + { + $db->delete('contact', "external_uuid in ('" . self::CONTACT_UUID . "', '" . self::CONTACT_UUID_2 . "')"); + } + + protected static function createContactGroups(Connection $db): void + { + $db->insert('contactgroup', [ + 'name' => 'Test', + 'external_uuid' => self::GROUP_UUID, + 'changed_at' => (int) (new DateTime())->format("Uv"), + ]); + $db->insert('contactgroup', [ + 'name' => 'Test2', + 'external_uuid' => self::GROUP_UUID_2, + 'changed_at' => (int) (new DateTime())->format("Uv"), + ]); + } + + protected static function deleteContactGroups(Connection $db): void + { + $db->delete('contactgroup', "external_uuid in ('" . self::GROUP_UUID . "', '" . self::GROUP_UUID_2 . "')"); + } + + protected function sendRequest( + string $method, + Url $endpoint, + string $path, + array $params = [], + ?array $json = null, + ?string $body = null, + ?array $headers = null, + ?array $options = null, + ): ResponseInterface { + $client = new Client(); + + $url = $endpoint->setPath($path)->setParams($params)->getAbsoluteUrl(); + + $options = $options ?? [ + 'http_errors' => false + ]; + $headers = $headers ?? ['Accept' => 'application/json']; + + if (! empty($headers)) { + $options['headers'] = $headers; + } + if ($json !== null) { + $options['json'] = $json; + } + if ($body !== null) { + $options['body'] = $body; + } + + return $client->request($method, $url, $options); + } + + public function jsonEncodeError(string $message): string + { + return Json::sanitize(['message' => $message]); + } + + public function jsonEncodeSuccessMessage(string $message): string + { + return Json::sanitize(['message' => $message]); + } + + public function jsonEncodeResult(array $data): string + { + return Json::sanitize(['data' => $data]); + } + + public function jsonEncodeResults(array $data): string + { + $needsWrapping = ! array_is_list($data) || count(array_filter($data, 'is_array')) !== count($data); + return Json::sanitize(['data' => $needsWrapping ? [$data] : $data]); + } +} diff --git a/library/Notifications/Web/Form/ContactForm.php b/library/Notifications/Web/Form/ContactForm.php index 85ba102cd..d6643ad07 100644 --- a/library/Notifications/Web/Form/ContactForm.php +++ b/library/Notifications/Web/Form/ContactForm.php @@ -27,6 +27,7 @@ use ipl\Web\Compat\CompatForm; use ipl\Web\FormElement\SuggestionElement; use ipl\Web\Url; +use Ramsey\Uuid\Uuid; class ContactForm extends CompatForm { @@ -237,7 +238,10 @@ public function addContact(): void $contactInfo = $this->getValues(); $changedAt = (int) (new DateTime())->format("Uv"); $this->db->beginTransaction(); - $this->db->insert('contact', $contactInfo['contact'] + ['changed_at' => $changedAt]); + $this->db->insert( + 'contact', + $contactInfo['contact'] + ['changed_at' => $changedAt, 'external_uuid' => Uuid::uuid4()->toString()] + ); $this->contactId = $this->db->lastInsertId(); foreach (array_filter($contactInfo['contact_address']) as $type => $address) { diff --git a/run.php b/run.php index 8ac420990..6f77bd7ba 100644 --- a/run.php +++ b/run.php @@ -24,3 +24,20 @@ ] ) ); + +$this->addRoute('notifications/api-plural', new Zend_Controller_Router_Route( + 'notifications/api/:version/:endpoint', + [ + 'module' => 'notifications', + 'controller' => 'api', + 'action' => 'index' + ] +)); +$this->addRoute('notifications/api-single', new Zend_Controller_Router_Route( + 'notifications/api/:version/:endpoint/:identifier', + [ + 'module' => 'notifications', + 'controller' => 'api', + 'action' => 'index' + ] +)); diff --git a/test/php/application/controllers/ApiV1ChannelsTest.php b/test/php/application/controllers/ApiV1ChannelsTest.php new file mode 100644 index 000000000..2e789b50a --- /dev/null +++ b/test/php/application/controllers/ApiV1ChannelsTest.php @@ -0,0 +1,323 @@ +jsonEncodeResults([ + 'id' => BaseApiV1TestCase::CHANNEL_UUID, + 'name' => 'Test', + 'type' => 'email', + 'config' => null, + ]); + + // filter by id + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/channels', + ['id' => BaseApiV1TestCase::CHANNEL_UUID] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + + // filter by name + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/channels', + ['name' => 'Test'] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + + // filter by type + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/channels', + ['type' => 'email'] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + + // filter by all available filters together + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/channels', + ['id' => BaseApiV1TestCase::CHANNEL_UUID, 'name' => 'Test', 'type' => 'email'] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testGetEverything(Connection $db, Url $endpoint): void + { + // At first, there are none + $this->deleteDefaultEntities(); + + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/channels' + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResults([]), $content); + + // Create new contact groups + $this->createDefaultEntities(); + + // There are two + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/channels' + ); + $content = $response->getBody()->getContents(); + + $expected = $this->jsonEncodeResults([ + [ + 'id' => BaseApiV1TestCase::CHANNEL_UUID, + 'name' => 'Test', + 'type' => 'email', + 'config' => null, + ], + [ + 'id' => BaseApiV1TestCase::CHANNEL_UUID_2, + 'name' => 'Test2', + 'type' => 'webhook', + 'config' => null, + ], + ]); + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testGetWithAlreadyExistingIdentifier(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest('GET', $endpoint, 'v1/channels/' . BaseApiV1TestCase::CHANNEL_UUID); + $content = $response->getBody()->getContents(); + + $expected = $this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::CHANNEL_UUID, + 'name' => 'Test', + 'type' => 'email', + 'config' => null, + ]); + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testGetWithNonMatchingFilter(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest('GET', $endpoint, 'v1/channels', ['name' => 'not_test']); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResults([]), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testGetWithInvalidFilter(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest('GET', $endpoint, 'v1/channels', ['nonexistingfilter' => 'value']); + $content = $response->getBody()->getContents(); + + $expected = $this->jsonEncodeError( + 'Invalid request parameter: Filter column nonexistingfilter is not allowed', + ); + $this->assertSame(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testGetWithNewIdentifier(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest('GET', $endpoint, 'v1/channels/' . BaseApiV1TestCase::CHANNEL_UUID_3); + $content = $response->getBody()->getContents(); + + $this->assertSame(404, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeError('Channel not found'), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testGetWithInvalidIdentifier(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest('GET', $endpoint, 'v1/channels/' . BaseApiV1TestCase::UUID_INCOMPLETE); + $content = $response->getBody()->getContents(); + + $this->assertSame(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('The given identifier is not a valid UUID'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testGetWithIdentifierAndFilter(Connection $db, Url $endpoint): void + { + $expected = $this->jsonEncodeError( + 'Invalid request: GET with identifier and query parameters, it\'s not allowed to use both together.', + ); + + // Valid identifier and valid filter + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/channels/' . BaseApiV1TestCase::CHANNEL_UUID, + ['name' => 'Test'] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + + // Invalid identifier and invalid filter + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/channels/' . BaseApiV1TestCase::CHANNEL_UUID, + ['nonexistingfilter' => 'value'] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testRequestWithNonSupportedMethod(Connection $db, Url $endpoint): void + { + $expectedAllowHeader = 'GET'; + // General invalid method + $response = $this->sendRequest('PATCH', $endpoint, 'v1/channels'); + $content = $response->getBody()->getContents(); + + $this->assertSame(405, $response->getStatusCode(), $content); + $this->assertSame([$expectedAllowHeader], $response->getHeader('Allow')); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('HTTP method PATCH is not supported'), + $content + ); + + // Endpoint specific invalid method + // Try to POST + $expected = $this->jsonEncodeError('Method POST is not supported for endpoint channels'); + //Try to POST without identifier + $response = $this->sendRequest('POST', $endpoint, 'v1/channels'); + $content = $response->getBody()->getContents(); + + $this->assertSame(405, $response->getStatusCode(), $content); + $this->assertSame([$expectedAllowHeader], $response->getHeader('Allow')); + $this->assertJsonStringEqualsJsonString($expected, $content); + + // Try to POST with identifier + $response = $this->sendRequest('POST', $endpoint, 'v1/channels/' . BaseApiV1TestCase::CHANNEL_UUID); + $content = $response->getBody()->getContents(); + + $this->assertSame(405, $response->getStatusCode(), $content); + $this->assertSame([$expectedAllowHeader], $response->getHeader('Allow')); + $this->assertJsonStringEqualsJsonString($expected, $content); + + // Try to POST with filter + $response = $this->sendRequest('POST', $endpoint, 'v1/channels', ['name' => 'Test']); + $content = $response->getBody()->getContents(); + + $this->assertSame(405, $response->getStatusCode(), $content); + $this->assertSame([$expectedAllowHeader], $response->getHeader('Allow')); + $this->assertJsonStringEqualsJsonString($expected, $content); + + // Try to POST with identifier and filter + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/channels/' . BaseApiV1TestCase::CHANNEL_UUID, + ['name' => 'Test'] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(405, $response->getStatusCode(), $content); + $this->assertSame([$expectedAllowHeader], $response->getHeader('Allow')); + $this->assertJsonStringEqualsJsonString($expected, $content); + + // Try to PUT + $response = $this->sendRequest('PUT', $endpoint, 'v1/channels'); + $content = $response->getBody()->getContents(); + + $this->assertSame(405, $response->getStatusCode(), $content); + $this->assertSame([$expectedAllowHeader], $response->getHeader('Allow')); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Method PUT is not supported for endpoint channels'), + $content + ); + + // Try to DELETE + $response = $this->sendRequest('DELETE', $endpoint, 'v1/channels'); + $content = $response->getBody()->getContents(); + + $this->assertSame(405, $response->getStatusCode(), $content); + $this->assertSame([$expectedAllowHeader], $response->getHeader('Allow')); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Method DELETE is not supported for endpoint channels'), + $content + ); + } + + protected function deleteDefaultEntities(): void + { + $db = $this->getConnection(); + + self::deleteContacts($db); + self::deleteChannels($db); + } + + protected function createDefaultEntities(): void + { + $db = $this->getConnection(); + + self::createChannels($db); + self::createContacts($db); + } +} diff --git a/test/php/application/controllers/ApiV1ContactGroupsTest.php b/test/php/application/controllers/ApiV1ContactGroupsTest.php new file mode 100644 index 000000000..3e25143e7 --- /dev/null +++ b/test/php/application/controllers/ApiV1ContactGroupsTest.php @@ -0,0 +1,1059 @@ +sendRequest('GET', $endpoint, 'v1/contact-groups', ['name' => 'Test']); + $content = $response->getBody()->getContents(); + + $expected = $this->jsonEncodeResults([ + 'id' => BaseApiV1TestCase::GROUP_UUID, + 'name' => 'Test', + 'users' => [] + ]); + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testGetEverything(Connection $db, Url $endpoint): void + { + // At first, there are none + self::deleteContactGroups($this->getConnection()); + + $response = $this->sendRequest('GET', $endpoint, 'v1/contact-groups'); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResults([]), $content); + + // Create new contact groups + self::createContactGroups($this->getConnection()); + + // Now there are two + $response = $this->sendRequest('GET', $endpoint, 'v1/contact-groups'); + $content = $response->getBody()->getContents(); + + $expected = $this->jsonEncodeResults([ + [ + 'id' => BaseApiV1TestCase::GROUP_UUID, + 'name' => 'Test', + 'users' => [] + ], + [ + 'id' => BaseApiV1TestCase::GROUP_UUID_2, + 'name' => 'Test2', + 'users' => [] + ] + ]); + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testGetWithAlreadyExistingIdentifier(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest('GET', $endpoint, 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID); + $content = $response->getBody()->getContents(); + + $expected = $this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::GROUP_UUID, + 'name' => 'Test', + 'users' => [] + ]); + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testGetWithUnknownIdentifier(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest('GET', $endpoint, 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID_3); + $content = $response->getBody()->getContents(); + + $this->assertSame(404, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeError('Contact Group not found'), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testGetWithNonMatchingFilter(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest('GET', $endpoint, 'v1/contact-groups', ['name' => 'not_test']); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResults([]), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToCreateWithInvalidContent(Connection $db, Url $endpoint): void + { + $body = <<sendRequest( + method: 'POST', + endpoint: $endpoint, + path: 'v1/contact-groups', + body: $body, + headers: [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json' + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: given content is not a valid JSON'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToCreateWithInvalidContentType(Connection $db, Url $endpoint): void + { + $body = <<sendRequest( + method: 'POST', + endpoint: $endpoint, + path: 'v1/contact-groups', + body: $body, + headers: [ + 'Accept' => 'application/json', + 'Content-Type' => 'text/yaml' + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request header: Content-Type must be application/json'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToCreateWithFilter(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contact-groups?id=' . BaseApiV1TestCase::GROUP_UUID, + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID, + 'name' => 'Test', + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Unexpected query parameter: Filter is only allowed for GET requests'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToReplaceWithUnknownIdentifier(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID_3, + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID_4, + 'name' => 'Test', + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(404, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeError('Contact Group not found'), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToReplaceWithIndifferentPayloadId( + Connection $db, + Url $endpoint + ): void { + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID, + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID, + 'name' => 'Test', + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Identifier mismatch: the Payload id must be different from the URL identifier'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToReplaceWithExistingPayloadId( + Connection $db, + Url $endpoint + ): void { + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID, + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID_2, + 'name' => 'Test', + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeError('Contact Group already exists'), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToReplaceWithValidData(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID, + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID_3, + 'name' => 'Test (replaced)', + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(201, $response->getStatusCode(), $content); + $this->assertSame( + ['notifications/api/v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID_3], + $response->getHeader('Location') + ); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeSuccessMessage('Contact Group created successfully'), + $content + ); + + // Make sure the contact group was replaced + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID_3 + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::GROUP_UUID_3, + 'name' => 'Test (replaced)', + 'users' => [] + ]), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToCreateWithAlreadyExistingPayloadId(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contact-groups', + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID, + 'name' => 'Test (replaced)', + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeError('Contact Group already exists'), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToCreateWithValidData(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contact-groups', + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID_3, + 'name' => 'Test' + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(201, $response->getStatusCode(), $content); + $this->assertSame( + ['notifications/api/v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID_3], + $response->getHeader('Location') + ); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeSuccessMessage('Contact Group created successfully'), + $content + ); + + // Let's see the contact group is available at that location + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID_3 + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::GROUP_UUID_3, + 'name' => 'Test', + 'users' => [] + ]), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToReplaceWithMissingRequiredFields( + Connection $db, + Url $endpoint + ): void { + $expected = $this->jsonEncodeError( + 'Invalid request body: the fields id and name must be present and of type string' + ); + + // missing id + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID, + json: [ + 'name' => 'Test', + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + + // missing name + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID, + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID_3, + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToCreateWithValidOptionalData(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contact-groups', + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID_3, + 'name' => 'Test', + 'users' => [BaseApiV1TestCase::CONTACT_UUID] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(201, $response->getStatusCode(), $content); + $this->assertSame( + ['notifications/api/v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID_3], + $response->getHeader('Location') + ); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeSuccessMessage('Contact Group created successfully'), + $content + ); + + // Oh really? + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID_3 + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::GROUP_UUID_3, + 'name' => 'Test', + 'users' => [BaseApiV1TestCase::CONTACT_UUID] + ]), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToCreateWithMissingRequiredFields(Connection $db, Url $endpoint): void + { + $expected = $this->jsonEncodeError( + 'Invalid request body: the fields id and name must be present and of type string' + ); + + // missing name + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contact-groups', + json:[ + 'id' => BaseApiV1TestCase::GROUP_UUID_3, + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + + // missing id + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contact-groups', + json: [ + 'name' => 'Test', + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToCreateWithInvalidOptionalData(Connection $db, Url $endpoint): void + { + // invalid users + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contact-groups', + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID_3, + 'name' => 'Test', + 'users' => [BaseApiV1TestCase::CONTACT_UUID_3] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('User with identifier ' . BaseApiV1TestCase::CONTACT_UUID_3 . ' not found'), + $content + ); + + // invalid user id + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contact-groups', + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID_3, + 'name' => 'Test', + 'users' => ['invalid_uuid'] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: the user identifier invalid_uuid is not a valid UUID'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToUpdateWithInvalidContent(Connection $db, Url $endpoint): void + { + $body = <<sendRequest( + 'PUT', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID, + body: $body, + headers: [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json' + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: given content is not a valid JSON'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToUpdateWithInvalidContentType(Connection $db, Url $endpoint): void + { + $body = <<sendRequest( + 'PUT', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID, + body: $body, + headers: [ + 'Accept' => 'application/json', + 'Content-Type' => 'text/yaml' + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request header: Content-Type must be application/json'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToUpdateWithFilter(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contact-groups?id=' . BaseApiV1TestCase::GROUP_UUID, + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID, + 'name' => 'Test', + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Unexpected query parameter: Filter is only allowed for GET requests'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToUpdateWithoutIdentifier(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contact-groups', + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID, + 'name' => 'Test', + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request: Identifier is required'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToUpdateWithMissingRequiredFields( + Connection $db, + Url $endpoint + ): void { + $expected = $this->jsonEncodeError( + 'Invalid request body: the fields id and name must be present and of type string' + ); + + // missing id + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID, + json: [ + 'name' => 'Test', + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + + // missing name + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID, + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID, + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToUpdateWithDifferentPayloadId( + Connection $db, + Url $endpoint + ): void { + // indifferent id + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID, + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID_2, + 'name' => 'Test', + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeError('Identifier mismatch'), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToCreateWithValidData(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID_3, + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID_3, + 'name' => 'Test' + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(201, $response->getStatusCode(), $content); + $this->assertSame( + ['notifications/api/v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID_3], + $response->getHeader('Location') + ); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeSuccessMessage('Contact Group created successfully'), + $content + ); + + // Let's see the group is actually available + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID_3 + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::GROUP_UUID_3, + 'name' => 'Test', + 'users' => [] + ]), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToUpdateWithValidData(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID, + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID, + 'name' => 'Test (replaced)', + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(204, $response->getStatusCode(), $content); + $this->assertEmpty($content); + + // Oh really? + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::GROUP_UUID, + 'name' => 'Test (replaced)', + 'users' => [] + ]), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToUpdateWithInvalidData(Connection $db, Url $endpoint): void + { + // invalid users + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID, + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID, + 'name' => 'Test', + 'users' => [BaseApiV1TestCase::CONTACT_UUID_3] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('User with identifier ' . BaseApiV1TestCase::CONTACT_UUID_3 . ' not found'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToCreateWithValidOptionalData(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID_3, + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID_3, + 'name' => 'Test', + 'users' => [BaseApiV1TestCase::CONTACT_UUID] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(201, $response->getStatusCode(), $content); + $this->assertSame( + ['notifications/api/v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID_3], + $response->getHeader('Location') + ); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeSuccessMessage('Contact Group created successfully'), + $content + ); + + // Let's see the group is actually available + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID_3 + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::GROUP_UUID_3, + 'name' => 'Test', + 'users' => [BaseApiV1TestCase::CONTACT_UUID] + ]), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToCreateWithMissingRequiredFields(Connection $db, Url $endpoint): void + { + $expected = $this->jsonEncodeError( + 'Invalid request body: the fields id and name must be present and of type string' + ); + + // missing name + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID, + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID, + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + + // missing id + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID, + json: [ + 'name' => 'Test', + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToChangeGroupMemberships(Connection $db, Url $endpoint): void + { + // First add a user to the group + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID, + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID, + 'name' => 'Test', + 'users' => [BaseApiV1TestCase::CONTACT_UUID] + ] + ); + + $this->assertSame(204, $response->getStatusCode(), $response->getBody()->getContents()); + + // Check the result + $response = $this->sendRequest('GET', $endpoint, 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::GROUP_UUID, + 'name' => 'Test', + 'users' => [BaseApiV1TestCase::CONTACT_UUID] + ]), $content); + + // Then remove it + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID, + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID, + 'name' => 'Test', + 'users' => [] + ] + ); + + $this->assertSame(204, $response->getStatusCode(), $response->getBody()->getContents()); + + // Again, check the result + $response = $this->sendRequest('GET', $endpoint, 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::GROUP_UUID, + 'name' => 'Test', + 'users' => [] + ]), $content); + + // And add it again + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID, + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID, + 'name' => 'Test', + 'users' => [BaseApiV1TestCase::CONTACT_UUID] + ] + ); + + $this->assertSame(204, $response->getStatusCode(), $response->getBody()->getContents()); + + // Then verify the final result + $response = $this->sendRequest('GET', $endpoint, 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::GROUP_UUID, + 'name' => 'Test', + 'users' => [BaseApiV1TestCase::CONTACT_UUID] + ]), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testDeleteWithoutIdentifier(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest('DELETE', $endpoint, 'v1/contact-groups'); + $content = $response->getBody()->getContents(); + + $this->assertSame(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request: Identifier is required'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testDeleteWithUnknownIdentifier(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest('DELETE', $endpoint, 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID_3); + $content = $response->getBody()->getContents(); + + $this->assertSame(404, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeError('Contact Group not found'), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testDeleteWithKnownIdentifier(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest('DELETE', $endpoint, 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID); + $content = $response->getBody()->getContents(); + + $this->assertSame(204, $response->getStatusCode(), $content); + $this->assertEmpty($content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testDeleteWithFilter(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest('DELETE', $endpoint, 'v1/contact-groups', ['name~*']); + $content = $response->getBody()->getContents(); + + $this->assertSame(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Unexpected query parameter: Filter is only allowed for GET requests'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testRequestWithNonSupportedMethod(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest('PATCH', $endpoint, 'v1/contact-groups'); + $content = $response->getBody()->getContents(); + + $this->assertSame(405, $response->getStatusCode(), $content); + $this->assertSame(['GET, POST, PUT, DELETE'], $response->getHeader('Allow')); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('HTTP method PATCH is not supported'), + $content + ); + } + + public function setUp(): void + { + $db = $this->getConnection(); + + $db->delete('contactgroup_member'); + $db->delete( + 'contact', + "external_uuid NOT IN ('" . self::CONTACT_UUID . "', '" . self::CONTACT_UUID_2 . "')" + ); + $db->delete('contactgroup'); + + self::createContactGroups($db); + } +} diff --git a/test/php/application/controllers/ApiV1ContactsTest.php b/test/php/application/controllers/ApiV1ContactsTest.php new file mode 100644 index 000000000..10a56d85f --- /dev/null +++ b/test/php/application/controllers/ApiV1ContactsTest.php @@ -0,0 +1,1438 @@ +sendRequest('GET', $endpoint, 'v1/contacts', ['full_name' => 'Test']); + $content = $response->getBody()->getContents(); + + $expected = $this->jsonEncodeResults([ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'username' => 'test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'groups' => [], + 'addresses' => new stdClass() + ]); + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testGetEverything(Connection $db, Url $endpoint): void + { + // At first, there are none + self::deleteContacts($this->getConnection()); + + $response = $this->sendRequest('GET', $endpoint, 'v1/contacts'); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResults([]), $content); + + // Create new contact + self::createContacts($this->getConnection()); + + // Now there are two + $response = $this->sendRequest('GET', $endpoint, 'v1/contacts'); + $content = $response->getBody()->getContents(); + + $expected = $this->jsonEncodeResults([ + [ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'username' => 'test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'groups' => [], + 'addresses' => new stdClass() + ], + [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_2, + 'full_name' => 'Test2', + 'username' => 'test2', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'groups' => [], + 'addresses' => new stdClass() + ], + ]); + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testGetWithAlreadyExistingIdentifier(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest('GET', $endpoint, 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID); + $content = $response->getBody()->getContents(); + + $expected = $this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'username' => 'test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'groups' => [], + 'addresses' => new stdClass() + ]); + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testGetWithUnknownIdentifier(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest('GET', $endpoint, 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID_3); + $content = $response->getBody()->getContents(); + + $this->assertSame(404, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeError('Contact not found'), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testGetWithNonMatchingFilter(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest('GET', $endpoint, 'v1/contacts', ['full_name' => 'not_test']); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResults([]), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testGetWithNonExistingFilter(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest('GET', $endpoint, 'v1/contacts', ['unknown' => 'filter']); + $content = $response->getBody()->getContents(); + + $expected = $this->jsonEncodeError( + 'Invalid request parameter: Filter column unknown is not allowed' + ); + $this->assertSame(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testGetWithIdentifierAndFilter(Connection $db, Url $endpoint): void + { + $expected = $this->jsonEncodeError( + 'Invalid request: GET with identifier and query parameters, it\'s not allowed to use both together.' + ); + + // Valid identifier and valid filter + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + ['full_name' => 'Test'] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + + // Invalid identifier and invalid filter + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + ['unknown' => 'filter'] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToCreateWithInvalidContent(Connection $db, Url $endpoint): void + { + $body = <<sendRequest( + 'POST', + $endpoint, + 'v1/contacts', + body: $body, + headers: [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json' + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: given content is not a valid JSON'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToCreateWithInvalidContentType(Connection $db, Url $endpoint): void + { + $body = <<sendRequest( + 'POST', + $endpoint, + 'v1/contacts', + body: $body, + headers: [ + 'Accept' => 'application/json', + 'Content-Type' => 'text/yaml' + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request header: Content-Type must be application/json'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToCreateWithFilter(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts', + ['id' => BaseApiV1TestCase::CONTACT_UUID], + [ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Unexpected query parameter: Filter is only allowed for GET requests'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToReplaceWithUnknownIdentifier(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID_3, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_4, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(404, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeError('Contact not found'), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToReplaceWithIndifferentPayloadId( + Connection $db, + Url $endpoint + ): void { + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Identifier mismatch: the Payload id must be different from the URL identifier'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToReplaceWithAlreadyExistingPayloadId( + Connection $db, + Url $endpoint + ): void { + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_2, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeError('Contact already exists'), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToReplaceWithValidData(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test (replaced)', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(201, $response->getStatusCode(), $content); + $this->assertSame( + ['notifications/api/v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID_3], + $response->getHeader('Location') + ); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeSuccessMessage('Contact created successfully'), + $content + ); + + // Make sure the contact was replaced + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID_3 + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test (replaced)', + 'username' => null, + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'groups' => [], + 'addresses' => new stdClass() + ]), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToCreateWithExistingPayloadId(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts', + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeError('Contact already exists'), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToCreateWithValidData(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts', + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(201, $response->getStatusCode(), $content); + $this->assertSame( + ['notifications/api/v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID_3], + $response->getHeader('Location') + ); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeSuccessMessage('Contact created successfully'), + $content + ); + + // Let's see the contact is available at that location + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID_3 + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test', + 'username' => null, + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'groups' => [], + 'addresses' => new stdClass() + ]), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToReplaceWithMissingRequiredFields( + Connection $db, + Url $endpoint + ): void { + $expected = $this->jsonEncodeError( + 'Invalid request body: the fields id, full_name and default_channel must be present and of type string' + ); + + // missing id + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + + // missing name + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + + // missing default_channel + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test' + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToCreateWithValidOptionalData(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts', + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test3', + 'username' => 'test3', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'groups' => [BaseApiV1TestCase::GROUP_UUID], + 'addresses' => [ + 'email' => 'test@example.com', + 'webhook' => 'https://example.com/webhook', + 'rocketchat' => 'https://chat.example.com/webhook', + ] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(201, $response->getStatusCode(), $content); + $this->assertSame( + ['notifications/api/v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID_3], + $response->getHeader('Location') + ); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeSuccessMessage('Contact created successfully'), + $content + ); + + // Oh really? + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID_3 + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test3', + 'username' => 'test3', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'groups' => [BaseApiV1TestCase::GROUP_UUID], + 'addresses' => [ + 'email' => 'test@example.com', + 'webhook' => 'https://example.com/webhook', + 'rocketchat' => 'https://chat.example.com/webhook', + ] + ]), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToCreateWithInvalidDefaultChannel(Connection $db, Url $endpoint): void + { + // invalid default_channel uuid + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts', + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test', + 'default_channel' => 'invalid_uuid', + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: given default_channel is not a valid UUID'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToCreateWithMissingRequiredFields(Connection $db, Url $endpoint): void + { + $expected = $this->jsonEncodeError( + 'Invalid request body: the fields id, full_name and default_channel must be present and of type string' + ); + + // missing id + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts', + json: [ + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + + // missing name + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts', + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + + // missing default_channel + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts', + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test' + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToCreateWithInvalidOptionalData(Connection $db, Url $endpoint): void + { + // already existing username + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts', + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test', + 'username' => 'test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeError('Username test already exists'), $content); + + // with non-existing group + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts', + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'groups' => [BaseApiV1TestCase::GROUP_UUID_3], + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError( + 'Contact Group with identifier ' . BaseApiV1TestCase::GROUP_UUID_3 . ' does not exist' + ), + $content + ); + + // invalid group uuid + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts', + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'groups' => ['invalid_uuid'] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: the group identifier invalid_uuid is not a valid UUID'), + $content + ); + + // with invalid address type + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contacts', + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => [ + 'invalid' => 'value' + ] + ] + ); + $content = $response->getBody()->getContents(); + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: undefined address type invalid given'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToUpdateWithInvalidContent(Connection $db, Url $endpoint): void + { + $body = <<sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID_3, + body: $body, + headers: [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json' + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: given content is not a valid JSON'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToUpdateWithInvalidContentType(Connection $db, Url $endpoint): void + { + $body = <<sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID_3, + body: $body, + headers: [ + 'Accept' => 'application/json', + 'Content-Type' => 'text/yaml' + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request header: Content-Type must be application/json'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToUpdateWithFilter(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts?id=' . BaseApiV1TestCase::CONTACT_UUID_3, + [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full-name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Unexpected query parameter: Filter is only allowed for GET requests'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToUpdateWithoutIdentifier(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts', + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request: Identifier is required'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToUpdateWithMissingRequiredFields( + Connection $db, + Url $endpoint + ): void { + $expected = $this->jsonEncodeError( + 'Invalid request body: the fields id, full_name and default_channel must be present and of type string' + ); + + // missing id + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + + // missing name + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + + // missing default_channel + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToUpdateWithDifferentPayloadId( + Connection $db, + Url $endpoint + ): void { + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeError('Identifier mismatch'), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToCreateWithValidData(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID_3, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => [ + 'email' => 'test@example.com', + 'webhook' => 'https://example.com/webhook', + 'rocketchat' => 'https://chat.example.com/webhook', + ] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(201, $response->getStatusCode(), $content); + $this->assertSame( + ['notifications/api/v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID_3], + $response->getHeader('Location') + ); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeSuccessMessage('Contact created successfully'), + $content + ); + + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID_3 + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test', + 'username' => null, + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'groups' => [], + 'addresses' => [ + 'email' => 'test@example.com', + 'webhook' => 'https://example.com/webhook', + 'rocketchat' => 'https://chat.example.com/webhook', + ] + ]), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToUpdateWithValidData(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(204, $response->getStatusCode(), $content); + $this->assertEmpty($content); + + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'username' => null, + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'groups' => [], + 'addresses' => new stdClass() + ]), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToUpdateWithInvalidData(Connection $db, Url $endpoint): void + { + // invalid default_channel uuid + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID_3, + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError( + 'Channel with identifier ' . BaseApiV1TestCase::CHANNEL_UUID_3 . ' does not exist' + ), + $content + ); + + // invalid groups + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'groups' => [BaseApiV1TestCase::GROUP_UUID_3], + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError( + 'Contact Group with identifier ' . BaseApiV1TestCase::GROUP_UUID_3 . ' does not exist' + ), + $content + ); + + // with invalid address type + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => [ + 'invalid' => 'value' + ] + ] + ); + $content = $response->getBody()->getContents(); + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request body: undefined address type invalid given'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToCreateWithMissingRequiredFields(Connection $db, Url $endpoint): void + { + $expected = $this->jsonEncodeError( + 'Invalid request body: the fields id, full_name and default_channel must be present and of type string' + ); + + // missing full_name + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID_3, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + + // missing id + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID_3, + json: [ + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + + // missing default_channel + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID_3, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID_3, + 'full_name' => 'Test', + ] + ); + $content = $response->getBody()->getContents(); + $this->assertEquals(422, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($expected, $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToChangeGroupMemberships(Connection $db, Url $endpoint): void + { + // First add a group to the user + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'groups' => [BaseApiV1TestCase::GROUP_UUID], + ] + ); + + $this->assertSame(204, $response->getStatusCode(), $response->getBody()->getContents()); + + // Check the result + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'username' => null, + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'groups' => [BaseApiV1TestCase::GROUP_UUID], + 'addresses' => new stdClass() + ]), $content); + + // Then remove it + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'groups' => [] + ] + ); + + $this->assertSame(204, $response->getStatusCode(), $response->getBody()->getContents()); + + // Again, check the result + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'username' => null, + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'groups' => [], + 'addresses' => new stdClass() + ]), $content); + + // And add it again + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'groups' => [BaseApiV1TestCase::GROUP_UUID] + ] + ); + + $this->assertSame(204, $response->getStatusCode(), $response->getBody()->getContents()); + + // Then verify the result + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'username' => null, + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'groups' => [BaseApiV1TestCase::GROUP_UUID], + 'addresses' => new stdClass() + ]), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToChangeAddresses(Connection $db, Url $endpoint): void + { + // First add addresses to the user + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => [ + 'email' => 'test@example.com', + 'webhook' => 'https://example.com/webhook', + 'rocketchat' => 'https://chat.example.com/webhook', + ] + ] + ); + + $this->assertSame(204, $response->getStatusCode(), $response->getBody()->getContents()); + + // Check the result + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'username' => null, + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'groups' => [], + 'addresses' => [ + 'email' => 'test@example.com', + 'webhook' => 'https://example.com/webhook', + 'rocketchat' => 'https://chat.example.com/webhook', + ] + ]), $content); + + // Then remove one of them + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => [ + 'webhook' => 'https://example.com/webhook', + 'rocketchat' => 'https://chat.example.com/webhook' + ] + ] + ); + + $this->assertSame(204, $response->getStatusCode(), $response->getBody()->getContents()); + + // Again check the result + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'username' => null, + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'groups' => [], + 'addresses' => [ + 'webhook' => 'https://example.com/webhook', + 'rocketchat' => 'https://chat.example.com/webhook' + ] + ]), $content); + + // And add it again + $response = $this->sendRequest( + 'PUT', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID, + json: [ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'addresses' => [ + 'email' => 'test@example.com', + 'webhook' => 'https://example.com/webhook', + 'rocketchat' => 'https://chat.example.com/webhook', + ] + ] + ); + + $this->assertSame(204, $response->getStatusCode(), $response->getBody()->getContents()); + + // Then verify the result + $response = $this->sendRequest( + 'GET', + $endpoint, + 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(200, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeResult([ + 'id' => BaseApiV1TestCase::CONTACT_UUID, + 'full_name' => 'Test', + 'username' => null, + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + 'groups' => [], + 'addresses' => [ + 'email' => 'test@example.com', + 'webhook' => 'https://example.com/webhook', + 'rocketchat' => 'https://chat.example.com/webhook', + ] + ]), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testDeleteWithoutIdentifier(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest('DELETE', $endpoint, 'v1/contacts'); + $content = $response->getBody()->getContents(); + + $this->assertSame(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Invalid request: Identifier is required'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testDeleteWithUnknownIdentifier(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest('DELETE', $endpoint, 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID_3); + $content = $response->getBody()->getContents(); + + $this->assertSame(404, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString($this->jsonEncodeError('Contact not found'), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testDeleteWithKnownIdentifier(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest('DELETE', $endpoint, 'v1/contacts/' . BaseApiV1TestCase::CONTACT_UUID); + $content = $response->getBody()->getContents(); + + $this->assertSame(204, $response->getStatusCode(), $content); + $this->assertEmpty($content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testDeleteWithFilter(Connection $db, Url $endpoint): void + { + $response = $this->sendRequest('DELETE', $endpoint, 'v1/contacts', ['name~*']); + $content = $response->getBody()->getContents(); + + $this->assertSame(400, $response->getStatusCode(), $content); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('Unexpected query parameter: Filter is only allowed for GET requests'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testRequestWithNonSupportedMethod(Connection $db, Url $endpoint): void + { + // General invalid method + $response = $this->sendRequest('PATCH', $endpoint, 'v1/contacts'); + $content = $response->getBody()->getContents(); + + $this->assertSame(405, $response->getStatusCode(), $content); + $this->assertSame(['GET, POST, PUT, DELETE'], $response->getHeader('Allow')); + $this->assertJsonStringEqualsJsonString( + $this->jsonEncodeError('HTTP method PATCH is not supported'), + $content + ); + } + + public function setUp(): void + { + $db = $this->getConnection(); + + $db->delete('contact_address'); + $db->delete('contactgroup_member'); + $db->delete( + 'contactgroup', + "external_uuid NOT IN ('" . self::GROUP_UUID . "', '" . self::GROUP_UUID_2 . "')" + ); + $db->delete('contact'); + + self::createContacts($db); + } +} diff --git a/test/schema/mysql/schema.sql b/test/schema/mysql/schema.sql new file mode 100644 index 000000000..4ae825617 --- /dev/null +++ b/test/schema/mysql/schema.sql @@ -0,0 +1,464 @@ +CREATE TABLE available_channel_type ( + type varchar(255) NOT NULL, + name text NOT NULL, + version text NOT NULL, + author text NOT NULL, + config_attrs mediumtext NOT NULL, + + CONSTRAINT pk_available_channel_type PRIMARY KEY (type) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE channel ( + id bigint NOT NULL AUTO_INCREMENT, + name text NOT NULL COLLATE utf8mb4_unicode_ci, + type varchar(255) NOT NULL, -- 'email', 'sms', ... + config mediumtext, -- JSON with channel-specific attributes + -- for now type determines the implementation, in the future, this will need a reference to a concrete + -- implementation to allow multiple implementations of a sms channel for example, probably even user-provided ones + + changed_at bigint NOT NULL, + deleted enum('n', 'y') NOT NULL DEFAULT 'n', + + external_uuid char(36) NOT NULL UNIQUE, + + CONSTRAINT pk_channel PRIMARY KEY (id), + CONSTRAINT fk_channel_available_channel_type FOREIGN KEY (type) REFERENCES available_channel_type(type) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE INDEX idx_channel_changed_at ON channel(changed_at); + +CREATE TABLE contact ( + id bigint NOT NULL AUTO_INCREMENT, + full_name text NOT NULL COLLATE utf8mb4_unicode_ci, + username varchar(254) COLLATE utf8mb4_unicode_ci, -- reference to web user + default_channel_id bigint NOT NULL, + + changed_at bigint NOT NULL, + deleted enum('n', 'y') NOT NULL DEFAULT 'n', + + external_uuid char(36) NOT NULL UNIQUE, + + CONSTRAINT pk_contact PRIMARY KEY (id), + + -- As the username is unique, it must be NULLed for deletion via "deleted = 'y'" + CONSTRAINT uk_contact_username UNIQUE (username), + + CONSTRAINT fk_contact_channel FOREIGN KEY (default_channel_id) REFERENCES channel(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE INDEX idx_contact_changed_at ON contact(changed_at); + +CREATE TABLE contact_address ( + id bigint NOT NULL AUTO_INCREMENT, + contact_id bigint NOT NULL, + type varchar(255) NOT NULL, -- 'phone', 'email', ... + address text NOT NULL, -- phone number, email address, ... + + changed_at bigint NOT NULL, + deleted enum('n', 'y') NOT NULL DEFAULT 'n', + + CONSTRAINT pk_contact_address PRIMARY KEY (id), + CONSTRAINT fk_contact_address_contact FOREIGN KEY (contact_id) REFERENCES contact(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE INDEX idx_contact_address_changed_at ON contact_address(changed_at); + +CREATE TABLE contactgroup ( + id bigint NOT NULL AUTO_INCREMENT, + name text NOT NULL COLLATE utf8mb4_unicode_ci, + + changed_at bigint NOT NULL, + deleted enum('n', 'y') NOT NULL DEFAULT 'n', + + external_uuid char(36) NOT NULL UNIQUE, + + CONSTRAINT pk_contactgroup PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE INDEX idx_contactgroup_changed_at ON contactgroup(changed_at); + +CREATE TABLE contactgroup_member ( + contactgroup_id bigint NOT NULL, + contact_id bigint NOT NULL, + + changed_at bigint NOT NULL, + deleted enum('n', 'y') NOT NULL DEFAULT 'n', + + CONSTRAINT pk_contactgroup_member PRIMARY KEY (contactgroup_id, contact_id), + CONSTRAINT fk_contactgroup_member_contactgroup FOREIGN KEY (contactgroup_id) REFERENCES contactgroup(id), + CONSTRAINT fk_contactgroup_member_contact FOREIGN KEY (contact_id) REFERENCES contact(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE INDEX idx_contactgroup_member_changed_at ON contactgroup_member(changed_at); + +CREATE TABLE schedule ( + id bigint NOT NULL AUTO_INCREMENT, + name text NOT NULL COLLATE utf8mb4_unicode_ci, + + changed_at bigint NOT NULL, + deleted enum('n', 'y') NOT NULL DEFAULT 'n', + + CONSTRAINT pk_schedule PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE INDEX idx_schedule_changed_at ON schedule(changed_at); + +CREATE TABLE rotation ( + id bigint NOT NULL AUTO_INCREMENT, + schedule_id bigint NOT NULL, + -- the lower the more important, starting at 0, avoids the need to re-index upon addition + priority integer, + name text NOT NULL, + mode enum('24-7', 'partial', 'multi'), -- NOT NULL is enforced via CHECK not to default to '24-7' + -- JSON with rotation-specific attributes + -- Needed exclusively by Web to simplify editing and visualisation + options mediumtext NOT NULL, + + -- A date in the format 'YYYY-MM-DD' when the first handoff should happen. + -- It is a string as handoffs are restricted to happen only once per day + first_handoff date, + + -- Set to the actual time of the first handoff. + -- If this is in the past during creation of the rotation, it is set to the creation time. + -- Used by Web to avoid showing shifts that never happened + actual_handoff bigint NOT NULL, + + changed_at bigint NOT NULL, + deleted enum('n', 'y') NOT NULL DEFAULT 'n', + + CONSTRAINT pk_rotation PRIMARY KEY (id), + + -- Each schedule can only have one rotation with a given priority starting at a given date. + -- Columns schedule_id, priority, first_handoff must be NULLed for deletion via "deleted = 'y'". + CONSTRAINT uk_rotation_schedule_id_priority_first_handoff UNIQUE (schedule_id, priority, first_handoff), + CONSTRAINT ck_rotation_non_deleted_needs_priority_first_handoff CHECK (deleted = 'y' OR priority IS NOT NULL AND first_handoff IS NOT NULL), + + CONSTRAINT ck_rotation_mode_notnull CHECK (mode IS NOT NULL), + CONSTRAINT fk_rotation_schedule FOREIGN KEY (schedule_id) REFERENCES schedule(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE INDEX idx_rotation_changed_at ON rotation(changed_at); + +CREATE TABLE timeperiod ( + id bigint NOT NULL AUTO_INCREMENT, + owned_by_rotation_id bigint, -- nullable for future standalone timeperiods + + changed_at bigint NOT NULL, + deleted enum('n', 'y') NOT NULL DEFAULT 'n', + + CONSTRAINT pk_timeperiod PRIMARY KEY (id), + CONSTRAINT fk_timeperiod_rotation FOREIGN KEY (owned_by_rotation_id) REFERENCES rotation(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE INDEX idx_timeperiod_changed_at ON timeperiod(changed_at); + +CREATE TABLE rotation_member ( + id bigint NOT NULL AUTO_INCREMENT, + rotation_id bigint NOT NULL, + contact_id bigint, + contactgroup_id bigint, + position integer, + + changed_at bigint NOT NULL, + deleted enum('n', 'y') NOT NULL DEFAULT 'n', + + -- Each position in a rotation can only be used once. + -- Column position must be NULLed for deletion via "deleted = 'y'". + CONSTRAINT uk_rotation_member_rotation_id_position UNIQUE (rotation_id, position), + + -- Two UNIQUE constraints prevent duplicate memberships of the same contact or contactgroup in a single rotation. + -- Multiple NULLs are not considered to be duplicates, so rows with a contact_id but no contactgroup_id are + -- basically ignored in the UNIQUE constraint over contactgroup_id and vice versa. The CHECK constraint below + -- ensures that each row has only non-NULL values in one of these constraints. + CONSTRAINT uk_rotation_member_rotation_id_contact_id UNIQUE (rotation_id, contact_id), + CONSTRAINT uk_rotation_member_rotation_id_contactgroup_id UNIQUE (rotation_id, contactgroup_id), + + CONSTRAINT ck_rotation_member_either_contact_id_or_contactgroup_id CHECK (if(contact_id IS NULL, 0, 1) + if(contactgroup_id IS NULL, 0, 1) = 1), + CONSTRAINT ck_rotation_member_non_deleted_needs_position CHECK (deleted = 'y' OR position IS NOT NULL), + + CONSTRAINT pk_rotation_member PRIMARY KEY (id), + CONSTRAINT fk_rotation_member_rotation FOREIGN KEY (rotation_id) REFERENCES rotation(id), + CONSTRAINT fk_rotation_member_contact FOREIGN KEY (contact_id) REFERENCES contact(id), + CONSTRAINT fk_rotation_member_contactgroup FOREIGN KEY (contactgroup_id) REFERENCES contactgroup(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE INDEX idx_rotation_member_changed_at ON rotation_member(changed_at); + +CREATE TABLE timeperiod_entry ( + id bigint NOT NULL AUTO_INCREMENT, + timeperiod_id bigint NOT NULL, + rotation_member_id bigint, -- nullable for future standalone timeperiods + start_time bigint NOT NULL, + end_time bigint NOT NULL, + -- Is needed by icinga-notifications-web to prefilter entries, which matches until this time and should be ignored by the daemon. + until_time bigint, + timezone text NOT NULL, -- e.g. 'Europe/Berlin', relevant for evaluating rrule (DST changes differ between zones) + rrule text, -- recurrence rule (RFC5545) + + changed_at bigint NOT NULL, + deleted enum('n', 'y') NOT NULL DEFAULT 'n', + + CONSTRAINT pk_timeperiod_entry PRIMARY KEY (id), + CONSTRAINT fk_timeperiod_entry_timeperiod FOREIGN KEY (timeperiod_id) REFERENCES timeperiod(id), + CONSTRAINT fk_timeperiod_entry_rotation_member FOREIGN KEY (rotation_member_id) REFERENCES rotation_member(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE INDEX idx_timeperiod_entry_changed_at ON timeperiod_entry(changed_at); + +CREATE TABLE source ( + id bigint NOT NULL AUTO_INCREMENT, + -- The type "icinga2" is special and requires (at least some of) the icinga2_ prefixed columns. + type text NOT NULL, + name text NOT NULL COLLATE utf8mb4_unicode_ci, + -- will likely need a distinguishing value for multiple sources of the same type in the future, like for example + -- the Icinga DB environment ID for Icinga 2 sources + + -- The column listener_password_hash is type-dependent. + -- If type is not "icinga2", listener_password_hash is required to limit API access for incoming connections + -- to the Listener. The username will be "source-${id}", allowing early verification. + listener_password_hash text, + + -- Following columns are for the "icinga2" type. + -- At least icinga2_base_url, icinga2_auth_user, and icinga2_auth_pass are required - see CHECK below. + icinga2_base_url text, + icinga2_auth_user text, + icinga2_auth_pass text, + -- icinga2_ca_pem specifies a custom CA to be used in the PEM format, if not NULL. + icinga2_ca_pem text, + -- icinga2_common_name requires Icinga 2's certificate to hold this Common Name if not NULL. This allows using a + -- differing Common Name - maybe an Icinga 2 Endpoint object name - from the FQDN within icinga2_base_url. + icinga2_common_name text, + icinga2_insecure_tls enum('n', 'y') NOT NULL DEFAULT 'n', + + changed_at bigint NOT NULL, + deleted enum('n', 'y') NOT NULL DEFAULT 'n', + + -- The hash is a PHP password_hash with PASSWORD_DEFAULT algorithm, defaulting to bcrypt. This check roughly ensures + -- that listener_password_hash can only be populated with bcrypt hashes. + -- https://icinga.com/docs/icinga-web/latest/doc/20-Advanced-Topics/#manual-user-creation-for-database-authentication-backend + CONSTRAINT ck_source_bcrypt_listener_password_hash CHECK (listener_password_hash IS NULL OR listener_password_hash LIKE '$2y$%'), + CONSTRAINT ck_source_icinga2_has_config CHECK (type != 'icinga2' OR (icinga2_base_url IS NOT NULL AND icinga2_auth_user IS NOT NULL AND icinga2_auth_pass IS NOT NULL)), + + CONSTRAINT pk_source PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE INDEX idx_source_changed_at ON source(changed_at); + +CREATE TABLE object ( + id binary(32) NOT NULL, -- SHA256 of identifying tags and the source.id + source_id bigint NOT NULL, + name text NOT NULL, + + url text, + -- mute_reason indicates whether an object is currently muted by its source, and its non-zero value is mapped to true. + mute_reason mediumtext, + + CONSTRAINT pk_object PRIMARY KEY (id), + CONSTRAINT fk_object_source FOREIGN KEY (source_id) REFERENCES source(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE object_id_tag ( + object_id binary(32) NOT NULL, + tag varchar(255) NOT NULL, + value text NOT NULL, + + CONSTRAINT pk_object_id_tag PRIMARY KEY (object_id, tag), + CONSTRAINT fk_object_id_tag_object FOREIGN KEY (object_id) REFERENCES object(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE object_extra_tag ( + object_id binary(32) NOT NULL, + tag varchar(255) NOT NULL, + value text NOT NULL, + + CONSTRAINT pk_object_extra_tag PRIMARY KEY (object_id, tag), + CONSTRAINT fk_object_extra_tag_object FOREIGN KEY (object_id) REFERENCES object(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE event ( + id bigint NOT NULL AUTO_INCREMENT, + time bigint NOT NULL, + object_id binary(32) NOT NULL, + -- NOT NULL is enforced via CHECK not to default to 'acknowledgement-cleared' + type enum('acknowledgement-cleared', 'acknowledgement-set', 'custom', 'downtime-end', 'downtime-removed', 'downtime-start', 'flapping-end', 'flapping-start', 'incident-age', 'mute', 'state', 'unmute'), + severity enum('ok', 'debug', 'info', 'notice', 'warning', 'err', 'crit', 'alert', 'emerg'), + message mediumtext, + username text COLLATE utf8mb4_unicode_ci, + mute enum('n', 'y'), + mute_reason mediumtext, + + CONSTRAINT pk_event PRIMARY KEY (id), + CONSTRAINT ck_event_type_notnull CHECK (type IS NOT NULL), + CONSTRAINT fk_event_object FOREIGN KEY (object_id) REFERENCES object(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE rule ( + id bigint NOT NULL AUTO_INCREMENT, + name text NOT NULL COLLATE utf8mb4_unicode_ci, + timeperiod_id bigint, + object_filter text, + + changed_at bigint NOT NULL, + deleted enum('n', 'y') NOT NULL DEFAULT 'n', + + CONSTRAINT pk_rule PRIMARY KEY (id), + CONSTRAINT fk_rule_timeperiod FOREIGN KEY (timeperiod_id) REFERENCES timeperiod(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE INDEX idx_rule_changed_at ON rule(changed_at); + +CREATE TABLE rule_escalation ( + id bigint NOT NULL AUTO_INCREMENT, + rule_id bigint NOT NULL, + position integer, + `condition` text, + name text COLLATE utf8mb4_unicode_ci, -- if not set, recipients are used as a fallback for display purposes + fallback_for bigint, + + changed_at bigint NOT NULL, + deleted enum('n', 'y') NOT NULL DEFAULT 'n', + + CONSTRAINT pk_rule_escalation PRIMARY KEY (id), + + -- Each position in an escalation can only be used once. + -- Column position must be NULLed for deletion via "deleted = 'y'" + CONSTRAINT uk_rule_escalation_rule_id_position UNIQUE (rule_id, position), + + CONSTRAINT ck_rule_escalation_not_both_condition_and_fallback_for CHECK (NOT (`condition` IS NOT NULL AND fallback_for IS NOT NULL)), + CONSTRAINT ck_rule_escalation_non_deleted_needs_position CHECK (deleted = 'y' OR position IS NOT NULL), + CONSTRAINT fk_rule_escalation_rule FOREIGN KEY (rule_id) REFERENCES rule(id), + CONSTRAINT fk_rule_escalation_rule_escalation FOREIGN KEY (fallback_for) REFERENCES rule_escalation(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE INDEX idx_rule_escalation_changed_at ON rule_escalation(changed_at); + +CREATE TABLE rule_escalation_recipient ( + id bigint NOT NULL AUTO_INCREMENT, + rule_escalation_id bigint NOT NULL, + contact_id bigint, + contactgroup_id bigint, + schedule_id bigint, + channel_id bigint, + + changed_at bigint NOT NULL, + deleted enum('n', 'y') NOT NULL DEFAULT 'n', + + CONSTRAINT pk_rule_escalation_recipient PRIMARY KEY (id), + CONSTRAINT ck_rule_escalation_recipient_has_exactly_one_recipient CHECK (if(contact_id IS NULL, 0, 1) + if(contactgroup_id IS NULL, 0, 1) + if(schedule_id IS NULL, 0, 1) = 1), + CONSTRAINT fk_rule_escalation_recipient_rule_escalation FOREIGN KEY (rule_escalation_id) REFERENCES rule_escalation(id), + CONSTRAINT fk_rule_escalation_recipient_contact FOREIGN KEY (contact_id) REFERENCES contact(id), + CONSTRAINT fk_rule_escalation_recipient_contactgroup FOREIGN KEY (contactgroup_id) REFERENCES contactgroup(id), + CONSTRAINT fk_rule_escalation_recipient_schedule FOREIGN KEY (schedule_id) REFERENCES schedule(id), + CONSTRAINT fk_rule_escalation_recipient_channel FOREIGN KEY (channel_id) REFERENCES channel(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE INDEX idx_rule_escalation_recipient_changed_at ON rule_escalation_recipient(changed_at); + +CREATE TABLE incident ( + id bigint NOT NULL AUTO_INCREMENT, + object_id binary(32) NOT NULL, + started_at bigint NOT NULL, + recovered_at bigint, + -- NOT NULL is enforced via CHECK not to default to 'ok' + severity enum('ok', 'debug', 'info', 'notice', 'warning', 'err', 'crit', 'alert', 'emerg'), + + CONSTRAINT pk_incident PRIMARY KEY (id), + CONSTRAINT ck_incident_severity_notnull CHECK (severity IS NOT NULL), + CONSTRAINT fk_incident_object FOREIGN KEY (object_id) REFERENCES object(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE incident_event ( + incident_id bigint NOT NULL, + event_id bigint NOT NULL, + + CONSTRAINT pk_incident_event PRIMARY KEY (incident_id, event_id), + CONSTRAINT fk_incident_event_incident FOREIGN KEY (incident_id) REFERENCES incident(id), + CONSTRAINT fk_incident_event_event FOREIGN KEY (event_id) REFERENCES event(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE incident_contact ( + incident_id bigint NOT NULL, + contact_id bigint, + contactgroup_id bigint, + schedule_id bigint, + role enum('recipient', 'subscriber', 'manager'), -- NOT NULL is enforced via CHECK not to default to 'recipient' + + CONSTRAINT uk_incident_contact_incident_id_contact_id UNIQUE (incident_id, contact_id), + CONSTRAINT uk_incident_contact_incident_id_contactgroup_id UNIQUE (incident_id, contactgroup_id), + CONSTRAINT uk_incident_contact_incident_id_schedule_id UNIQUE (incident_id, schedule_id), + + CONSTRAINT ck_incident_contact_has_exactly_one_recipient CHECK (if(contact_id IS NULL, 0, 1) + if(contactgroup_id IS NULL, 0, 1) + if(schedule_id IS NULL, 0, 1) = 1), + CONSTRAINT ck_incident_contact_role_notnull CHECK (role IS NOT NULL), + CONSTRAINT fk_incident_contact_incident FOREIGN KEY (incident_id) REFERENCES incident(id), + CONSTRAINT fk_incident_contact_contact FOREIGN KEY (contact_id) REFERENCES contact(id), + CONSTRAINT fk_incident_contact_contactgroup FOREIGN KEY (contactgroup_id) REFERENCES contactgroup(id), + CONSTRAINT fk_incident_contact_schedule FOREIGN KEY (schedule_id) REFERENCES schedule(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE incident_rule ( + incident_id bigint NOT NULL, + rule_id bigint NOT NULL, + + CONSTRAINT pk_incident_rule PRIMARY KEY (incident_id, rule_id), + CONSTRAINT fk_incident_rule_incident FOREIGN KEY (incident_id) REFERENCES incident(id), + CONSTRAINT fk_incident_rule_rule FOREIGN KEY (rule_id) REFERENCES rule(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE incident_rule_escalation_state ( + incident_id bigint NOT NULL, + rule_escalation_id bigint NOT NULL, + triggered_at bigint NOT NULL, + + CONSTRAINT pk_incident_rule_escalation_state PRIMARY KEY (incident_id, rule_escalation_id), + CONSTRAINT fk_incident_rule_escalation_state_incident FOREIGN KEY (incident_id) REFERENCES incident(id), + CONSTRAINT fk_incident_rule_escalation_state_rule_escalation FOREIGN KEY (rule_escalation_id) REFERENCES rule_escalation(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE TABLE incident_history ( + id bigint NOT NULL AUTO_INCREMENT, + incident_id bigint NOT NULL, + rule_escalation_id bigint, + event_id bigint, + contact_id bigint, + contactgroup_id bigint, + schedule_id bigint, + rule_id bigint, + channel_id bigint, + time bigint NOT NULL, + message mediumtext, + -- Order to be honored for events with identical millisecond timestamps. + -- NOT NULL is enforced via CHECK not to default to 'opened' + type enum('opened', 'muted', 'unmuted', 'incident_severity_changed', 'rule_matched', 'escalation_triggered', 'recipient_role_changed', 'closed', 'notified'), + new_severity enum('ok', 'debug', 'info', 'notice', 'warning', 'err', 'crit', 'alert', 'emerg'), + old_severity enum('ok', 'debug', 'info', 'notice', 'warning', 'err', 'crit', 'alert', 'emerg'), + new_recipient_role enum('recipient', 'subscriber', 'manager'), + old_recipient_role enum('recipient', 'subscriber', 'manager'), + notification_state enum('suppressed', 'pending', 'sent', 'failed'), + sent_at bigint, + + CONSTRAINT pk_incident_history PRIMARY KEY (id), + CONSTRAINT ck_incident_history_type_notnull CHECK (type IS NOT NULL), + CONSTRAINT fk_incident_history_incident_rule_escalation_state FOREIGN KEY (incident_id, rule_escalation_id) REFERENCES incident_rule_escalation_state(incident_id, rule_escalation_id), + CONSTRAINT fk_incident_history_incident FOREIGN KEY (incident_id) REFERENCES incident(id), + CONSTRAINT fk_incident_history_rule_escalation FOREIGN KEY (rule_escalation_id) REFERENCES rule_escalation(id), + CONSTRAINT fk_incident_history_event FOREIGN KEY (event_id) REFERENCES event(id), + CONSTRAINT fk_incident_history_contact FOREIGN KEY (contact_id) REFERENCES contact(id), + CONSTRAINT fk_incident_history_contactgroup FOREIGN KEY (contactgroup_id) REFERENCES contactgroup(id), + CONSTRAINT fk_incident_history_schedule FOREIGN KEY (schedule_id) REFERENCES schedule(id), + CONSTRAINT fk_incident_history_rule FOREIGN KEY (rule_id) REFERENCES rule(id), + CONSTRAINT fk_incident_history_channel FOREIGN KEY (channel_id) REFERENCES channel(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE INDEX idx_incident_history_time_type ON incident_history(time, type) COMMENT 'Incident History ordered by time/type'; + +CREATE TABLE browser_session ( + php_session_id varchar(256) NOT NULL, + username varchar(254) NOT NULL COLLATE utf8mb4_unicode_ci, + user_agent text NOT NULL, + authenticated_at bigint NOT NULL, + + CONSTRAINT pk_browser_session PRIMARY KEY (php_session_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +CREATE INDEX idx_browser_session_authenticated_at ON browser_session (authenticated_at DESC); +CREATE INDEX idx_browser_session_username_agent ON browser_session (username, user_agent(512)); diff --git a/test/schema/pgsql/schema.sql b/test/schema/pgsql/schema.sql new file mode 100644 index 000000000..57ecd69fc --- /dev/null +++ b/test/schema/pgsql/schema.sql @@ -0,0 +1,510 @@ +CREATE EXTENSION IF NOT EXISTS citext; + +CREATE TYPE boolenum AS ENUM ( 'n', 'y' ); +CREATE TYPE incident_history_event_type AS ENUM ( + -- Order to be honored for events with identical millisecond timestamps. + 'opened', + 'muted', + 'unmuted', + 'incident_severity_changed', + 'rule_matched', + 'escalation_triggered', + 'recipient_role_changed', + 'closed', + 'notified' +); +CREATE TYPE rotation_type AS ENUM ( '24-7', 'partial', 'multi' ); +CREATE TYPE notification_state_type AS ENUM ( 'suppressed', 'pending', 'sent', 'failed' ); + +-- IPL ORM renders SQL queries with LIKE operators for all suggestions in the search bar, +-- which fails for numeric and enum types on PostgreSQL. Just like in Icinga DB Web. +CREATE OR REPLACE FUNCTION anynonarrayliketext(anynonarray, text) + RETURNS bool + LANGUAGE plpgsql + IMMUTABLE + PARALLEL SAFE + AS $$ + BEGIN + RETURN $1::TEXT LIKE $2; + END; + $$; +CREATE OPERATOR ~~ (LEFTARG=anynonarray, RIGHTARG=text, PROCEDURE=anynonarrayliketext); + +CREATE TABLE available_channel_type ( + type varchar(255) NOT NULL, + name text NOT NULL, + version text NOT NULL, + author text NOT NULL, + config_attrs text NOT NULL, + + CONSTRAINT pk_available_channel_type PRIMARY KEY (type) +); + +CREATE TABLE channel ( + id bigserial, + name citext NOT NULL, + type varchar(255) NOT NULL, -- 'email', 'sms', ... + config text, -- JSON with channel-specific attributes + -- for now type determines the implementation, in the future, this will need a reference to a concrete + -- implementation to allow multiple implementations of a sms channel for example, probably even user-provided ones + + changed_at bigint NOT NULL, + deleted boolenum NOT NULL DEFAULT 'n', + + external_uuid uuid NOT NULL UNIQUE, + + CONSTRAINT pk_channel PRIMARY KEY (id), + CONSTRAINT fk_channel_available_channel_type FOREIGN KEY (type) REFERENCES available_channel_type(type) +); + +CREATE INDEX idx_channel_changed_at ON channel(changed_at); + +CREATE TABLE contact ( + id bigserial, + full_name citext NOT NULL, + username citext, -- reference to web user + default_channel_id bigint NOT NULL, + + changed_at bigint NOT NULL, + deleted boolenum NOT NULL DEFAULT 'n', + + external_uuid uuid NOT NULL UNIQUE, + + CONSTRAINT pk_contact PRIMARY KEY (id), + + -- As the username is unique, it must be NULLed for deletion via "deleted = 'y'" + CONSTRAINT uk_contact_username UNIQUE (username), + + CONSTRAINT ck_contact_username_up_to_254_chars CHECK (length(username) <= 254), + CONSTRAINT fk_contact_channel FOREIGN KEY (default_channel_id) REFERENCES channel(id) +); + +CREATE INDEX idx_contact_changed_at ON contact(changed_at); + +CREATE TABLE contact_address ( + id bigserial, + contact_id bigint NOT NULL, + type varchar(255) NOT NULL, -- 'phone', 'email', ... + address text NOT NULL, -- phone number, email address, ... + + changed_at bigint NOT NULL, + deleted boolenum NOT NULL DEFAULT 'n', + + CONSTRAINT pk_contact_address PRIMARY KEY (id), + CONSTRAINT fk_contact_address_contact FOREIGN KEY (contact_id) REFERENCES contact(id) +); + +CREATE INDEX idx_contact_address_changed_at ON contact_address(changed_at); + +CREATE TABLE contactgroup ( + id bigserial, + name citext NOT NULL, + + changed_at bigint NOT NULL, + deleted boolenum NOT NULL DEFAULT 'n', + + external_uuid uuid NOT NULL UNIQUE, + + CONSTRAINT pk_contactgroup PRIMARY KEY (id) +); + +CREATE INDEX idx_contactgroup_changed_at ON contactgroup(changed_at); + +CREATE TABLE contactgroup_member ( + contactgroup_id bigint NOT NULL, + contact_id bigint NOT NULL, + + changed_at bigint NOT NULL, + deleted boolenum NOT NULL DEFAULT 'n', + + CONSTRAINT pk_contactgroup_member PRIMARY KEY (contactgroup_id, contact_id), + CONSTRAINT fk_contactgroup_member_contactgroup FOREIGN KEY (contactgroup_id) REFERENCES contactgroup(id), + CONSTRAINT fk_contactgroup_member_contact FOREIGN KEY (contact_id) REFERENCES contact(id) +); + +CREATE INDEX idx_contactgroup_member_changed_at ON contactgroup_member(changed_at); + +CREATE TABLE schedule ( + id bigserial, + name citext NOT NULL, + + changed_at bigint NOT NULL, + deleted boolenum NOT NULL DEFAULT 'n', + + CONSTRAINT pk_schedule PRIMARY KEY (id) +); + +CREATE INDEX idx_schedule_changed_at ON schedule(changed_at); + +CREATE TABLE rotation ( + id bigserial, + schedule_id bigint NOT NULL, + -- the lower the more important, starting at 0, avoids the need to re-index upon addition + priority integer, + name text NOT NULL, + mode rotation_type NOT NULL, + -- JSON with rotation-specific attributes + -- Needed exclusively by Web to simplify editing and visualisation + options text NOT NULL, + + -- A date in the format 'YYYY-MM-DD' when the first handoff should happen. + -- It is a string as handoffs are restricted to happen only once per day + first_handoff date, + + -- Set to the actual time of the first handoff. + -- If this is in the past during creation of the rotation, it is set to the creation time. + -- Used by Web to avoid showing shifts that never happened + actual_handoff bigint NOT NULL, + + changed_at bigint NOT NULL, + deleted boolenum NOT NULL DEFAULT 'n', + + CONSTRAINT pk_rotation PRIMARY KEY (id), + + -- Each schedule can only have one rotation with a given priority starting at a given date. + -- Columns schedule_id, priority, first_handoff must be NULLed for deletion via "deleted = 'y'". + CONSTRAINT uk_rotation_schedule_id_priority_first_handoff UNIQUE (schedule_id, priority, first_handoff), + CONSTRAINT ck_rotation_non_deleted_needs_priority_first_handoff CHECK (deleted = 'y' OR priority IS NOT NULL AND first_handoff IS NOT NULL), + + CONSTRAINT fk_rotation_schedule FOREIGN KEY (schedule_id) REFERENCES schedule(id) +); + +CREATE INDEX idx_rotation_changed_at ON rotation(changed_at); + +CREATE TABLE timeperiod ( + id bigserial, + owned_by_rotation_id bigint, -- nullable for future standalone timeperiods + + changed_at bigint NOT NULL, + deleted boolenum NOT NULL DEFAULT 'n', + + CONSTRAINT pk_timeperiod PRIMARY KEY (id), + CONSTRAINT fk_timeperiod_rotation FOREIGN KEY (owned_by_rotation_id) REFERENCES rotation(id) +); + +CREATE INDEX idx_timeperiod_changed_at ON timeperiod(changed_at); + +CREATE TABLE rotation_member ( + id bigserial, + rotation_id bigint NOT NULL, + contact_id bigint, + contactgroup_id bigint, + position integer, + + changed_at bigint NOT NULL, + deleted boolenum NOT NULL DEFAULT 'n', + + -- Each position in a rotation can only be used once. + -- Column position must be NULLed for deletion via "deleted = 'y'". + CONSTRAINT uk_rotation_member_rotation_id_position UNIQUE (rotation_id, position), + + -- Two UNIQUE constraints prevent duplicate memberships of the same contact or contactgroup in a single rotation. + -- Multiple NULLs are not considered to be duplicates, so rows with a contact_id but no contactgroup_id are + -- basically ignored in the UNIQUE constraint over contactgroup_id and vice versa. The CHECK constraint below + -- ensures that each row has only non-NULL values in one of these constraints. + CONSTRAINT uk_rotation_member_rotation_id_contact_id UNIQUE (rotation_id, contact_id), + CONSTRAINT uk_rotation_member_rotation_id_contactgroup_id UNIQUE (rotation_id, contactgroup_id), + + CONSTRAINT ck_rotation_member_either_contact_id_or_contactgroup_id CHECK (num_nonnulls(contact_id, contactgroup_id) = 1), + CONSTRAINT ck_rotation_member_non_deleted_needs_position CHECK (deleted = 'y' OR position IS NOT NULL), + + CONSTRAINT pk_rotation_member PRIMARY KEY (id), + CONSTRAINT fk_rotation_member_rotation FOREIGN KEY (rotation_id) REFERENCES rotation(id), + CONSTRAINT fk_rotation_member_contact FOREIGN KEY (contact_id) REFERENCES contact(id), + CONSTRAINT fk_rotation_member_contactgroup FOREIGN KEY (contactgroup_id) REFERENCES contactgroup(id) +); + +CREATE INDEX idx_rotation_member_changed_at ON rotation_member(changed_at); + +CREATE TABLE timeperiod_entry ( + id bigserial, + timeperiod_id bigint NOT NULL, + rotation_member_id bigint, -- nullable for future standalone timeperiods + start_time bigint NOT NULL, + end_time bigint NOT NULL, + -- Is needed by icinga-notifications-web to prefilter entries, which matches until this time and should be ignored by the daemon. + until_time bigint, + timezone text NOT NULL, -- e.g. 'Europe/Berlin', relevant for evaluating rrule (DST changes differ between zones) + rrule text, -- recurrence rule (RFC5545) + + changed_at bigint NOT NULL, + deleted boolenum NOT NULL DEFAULT 'n', + + CONSTRAINT pk_timeperiod_entry PRIMARY KEY (id), + CONSTRAINT fk_timeperiod_entry_timeperiod FOREIGN KEY (timeperiod_id) REFERENCES timeperiod(id), + CONSTRAINT fk_timeperiod_entry_rotation_member FOREIGN KEY (rotation_member_id) REFERENCES rotation_member(id) +); + +CREATE INDEX idx_timeperiod_entry_changed_at ON timeperiod_entry(changed_at); + +CREATE TABLE source ( + id bigserial, + -- The type "icinga2" is special and requires (at least some of) the icinga2_ prefixed columns. + type text NOT NULL, + name citext NOT NULL, + -- will likely need a distinguishing value for multiple sources of the same type in the future, like for example + -- the Icinga DB environment ID for Icinga 2 sources + + -- The column listener_password_hash is type-dependent. + -- If type is not "icinga2", listener_password_hash is required to limit API access for incoming connections + -- to the Listener. The username will be "source-${id}", allowing early verification. + listener_password_hash text, + + -- Following columns are for the "icinga2" type. + -- At least icinga2_base_url, icinga2_auth_user, and icinga2_auth_pass are required - see CHECK below. + icinga2_base_url text, + icinga2_auth_user text, + icinga2_auth_pass text, + -- icinga2_ca_pem specifies a custom CA to be used in the PEM format, if not NULL. + icinga2_ca_pem text, + -- icinga2_common_name requires Icinga 2's certificate to hold this Common Name if not NULL. This allows using a + -- differing Common Name - maybe an Icinga 2 Endpoint object name - from the FQDN within icinga2_base_url. + icinga2_common_name text, + icinga2_insecure_tls boolenum NOT NULL DEFAULT 'n', + + changed_at bigint NOT NULL, + deleted boolenum NOT NULL DEFAULT 'n', + + -- The hash is a PHP password_hash with PASSWORD_DEFAULT algorithm, defaulting to bcrypt. This check roughly ensures + -- that listener_password_hash can only be populated with bcrypt hashes. + -- https://icinga.com/docs/icinga-web/latest/doc/20-Advanced-Topics/#manual-user-creation-for-database-authentication-backend + CONSTRAINT ck_source_bcrypt_listener_password_hash CHECK (listener_password_hash IS NULL OR listener_password_hash LIKE '$2y$%'), + CONSTRAINT ck_source_icinga2_has_config CHECK (type != 'icinga2' OR (icinga2_base_url IS NOT NULL AND icinga2_auth_user IS NOT NULL AND icinga2_auth_pass IS NOT NULL)), + + CONSTRAINT pk_source PRIMARY KEY (id) +); + +CREATE INDEX idx_source_changed_at ON source(changed_at); + +CREATE TABLE object ( + id bytea NOT NULL, -- SHA256 of identifying tags and the source.id + source_id bigint NOT NULL, + name text NOT NULL, + + url text, + -- mute_reason indicates whether an object is currently muted by its source, and its non-zero value is mapped to true. + mute_reason text, + + CONSTRAINT pk_object PRIMARY KEY (id), + CONSTRAINT ck_object_id_is_sha256 CHECK (length(id) = 256/8), + CONSTRAINT fk_object_source FOREIGN KEY (source_id) REFERENCES source(id) +); + +CREATE TABLE object_id_tag ( + object_id bytea NOT NULL, + tag varchar(255) NOT NULL, + value text NOT NULL, + + CONSTRAINT pk_object_id_tag PRIMARY KEY (object_id, tag), + CONSTRAINT fk_object_id_tag_object FOREIGN KEY (object_id) REFERENCES object(id) +); + +CREATE TABLE object_extra_tag ( + object_id bytea NOT NULL, + tag varchar(255) NOT NULL, + value text NOT NULL, + + CONSTRAINT pk_object_extra_tag PRIMARY KEY (object_id, tag), + CONSTRAINT fk_object_extra_tag_object FOREIGN KEY (object_id) REFERENCES object(id) +); + +CREATE TYPE event_type AS ENUM ( + 'acknowledgement-cleared', + 'acknowledgement-set', + 'custom', + 'downtime-end', + 'downtime-removed', + 'downtime-start', + 'flapping-end', + 'flapping-start', + 'incident-age', + 'mute', + 'state', + 'unmute' +); +CREATE TYPE severity AS ENUM ('ok', 'debug', 'info', 'notice', 'warning', 'err', 'crit', 'alert', 'emerg'); + +CREATE TABLE event ( + id bigserial, + time bigint NOT NULL, + object_id bytea NOT NULL, + type event_type NOT NULL, + severity severity, + message text, + username citext, + mute boolenum, + mute_reason text, + + CONSTRAINT pk_event PRIMARY KEY (id), + CONSTRAINT fk_event_object FOREIGN KEY (object_id) REFERENCES object(id) +); + +CREATE TABLE rule ( + id bigserial, + name citext NOT NULL, + timeperiod_id bigint, + object_filter text, + + changed_at bigint NOT NULL, + deleted boolenum NOT NULL DEFAULT 'n', + + CONSTRAINT pk_rule PRIMARY KEY (id), + CONSTRAINT fk_rule_timeperiod FOREIGN KEY (timeperiod_id) REFERENCES timeperiod(id) +); + +CREATE INDEX idx_rule_changed_at ON rule(changed_at); + +CREATE TABLE rule_escalation ( + id bigserial, + rule_id bigint NOT NULL, + position integer, + condition text, + name citext, -- if not set, recipients are used as a fallback for display purposes + fallback_for bigint, + + changed_at bigint NOT NULL, + deleted boolenum NOT NULL DEFAULT 'n', + + CONSTRAINT pk_rule_escalation PRIMARY KEY (id), + + -- Each position in an escalation can only be used once. + -- Column position must be NULLed for deletion via "deleted = 'y'" + CONSTRAINT uk_rule_escalation_rule_id_position UNIQUE (rule_id, position), + + CONSTRAINT ck_rule_escalation_not_both_condition_and_fallback_for CHECK (NOT (condition IS NOT NULL AND fallback_for IS NOT NULL)), + CONSTRAINT ck_rule_escalation_non_deleted_needs_position CHECK (deleted = 'y' OR position IS NOT NULL), + CONSTRAINT fk_rule_escalation_rule FOREIGN KEY (rule_id) REFERENCES rule(id), + CONSTRAINT fk_rule_escalation_rule_escalation FOREIGN KEY (fallback_for) REFERENCES rule_escalation(id) +); + +CREATE INDEX idx_rule_escalation_changed_at ON rule_escalation(changed_at); + +CREATE TABLE rule_escalation_recipient ( + id bigserial, + rule_escalation_id bigint NOT NULL, + contact_id bigint, + contactgroup_id bigint, + schedule_id bigint, + channel_id bigint, + + changed_at bigint NOT NULL, + deleted boolenum NOT NULL DEFAULT 'n', + + CONSTRAINT pk_rule_escalation_recipient PRIMARY KEY (id), + CONSTRAINT ck_rule_escalation_recipient_has_exactly_one_recipient CHECK (num_nonnulls(contact_id, contactgroup_id, schedule_id) = 1), + CONSTRAINT fk_rule_escalation_recipient_rule_escalation FOREIGN KEY (rule_escalation_id) REFERENCES rule_escalation(id), + CONSTRAINT fk_rule_escalation_recipient_contact FOREIGN KEY (contact_id) REFERENCES contact(id), + CONSTRAINT fk_rule_escalation_recipient_contactgroup FOREIGN KEY (contactgroup_id) REFERENCES contactgroup(id), + CONSTRAINT fk_rule_escalation_recipient_schedule FOREIGN KEY (schedule_id) REFERENCES schedule(id), + CONSTRAINT fk_rule_escalation_recipient_channel FOREIGN KEY (channel_id) REFERENCES channel(id) +); + +CREATE INDEX idx_rule_escalation_recipient_changed_at ON rule_escalation_recipient(changed_at); + +CREATE TABLE incident ( + id bigserial, + object_id bytea NOT NULL, + started_at bigint NOT NULL, + recovered_at bigint, + severity severity NOT NULL, + + CONSTRAINT pk_incident PRIMARY KEY (id), + CONSTRAINT fk_incident_object FOREIGN KEY (object_id) REFERENCES object(id) +); + +CREATE TABLE incident_event ( + incident_id bigint NOT NULL, + event_id bigint NOT NULL, + + CONSTRAINT pk_incident_event PRIMARY KEY (incident_id, event_id), + CONSTRAINT fk_incident_event_incident FOREIGN KEY (incident_id) REFERENCES incident(id), + CONSTRAINT fk_incident_event_event FOREIGN KEY (event_id) REFERENCES event(id) +); + +CREATE TYPE incident_contact_role AS ENUM ('recipient', 'subscriber', 'manager'); + +CREATE TABLE incident_contact ( + incident_id bigint NOT NULL, + contact_id bigint, + contactgroup_id bigint, + schedule_id bigint, + role incident_contact_role NOT NULL, + + -- Keep in sync with internal/incident/db_types.go! + CONSTRAINT uk_incident_contact_incident_id_contact_id UNIQUE (incident_id, contact_id), + CONSTRAINT uk_incident_contact_incident_id_contactgroup_id UNIQUE (incident_id, contactgroup_id), + CONSTRAINT uk_incident_contact_incident_id_schedule_id UNIQUE (incident_id, schedule_id), + + CONSTRAINT ck_incident_contact_has_exactly_one_recipient CHECK (num_nonnulls(contact_id, contactgroup_id, schedule_id) = 1), + CONSTRAINT fk_incident_contact_incident FOREIGN KEY (incident_id) REFERENCES incident(id), + CONSTRAINT fk_incident_contact_contact FOREIGN KEY (contact_id) REFERENCES contact(id), + CONSTRAINT fk_incident_contact_contactgroup FOREIGN KEY (contactgroup_id) REFERENCES contactgroup(id), + CONSTRAINT fk_incident_contact_schedule FOREIGN KEY (schedule_id) REFERENCES schedule(id) +); + +CREATE TABLE incident_rule ( + incident_id bigint NOT NULL, + rule_id bigint NOT NULL, + + CONSTRAINT pk_incident_rule PRIMARY KEY (incident_id, rule_id), + CONSTRAINT fk_incident_rule_incident FOREIGN KEY (incident_id) REFERENCES incident(id), + CONSTRAINT fk_incident_rule_rule FOREIGN KEY (rule_id) REFERENCES rule(id) +); + +CREATE TABLE incident_rule_escalation_state ( + incident_id bigint NOT NULL, + rule_escalation_id bigint NOT NULL, + triggered_at bigint NOT NULL, + + CONSTRAINT pk_incident_rule_escalation_state PRIMARY KEY (incident_id, rule_escalation_id), + CONSTRAINT fk_incident_rule_escalation_state_incident FOREIGN KEY (incident_id) REFERENCES incident(id), + CONSTRAINT fk_incident_rule_escalation_state_rule_escalation FOREIGN KEY (rule_escalation_id) REFERENCES rule_escalation(id) +); + +CREATE TABLE incident_history ( + id bigserial, + incident_id bigint NOT NULL, + rule_escalation_id bigint, + event_id bigint, + contact_id bigint, + contactgroup_id bigint, + schedule_id bigint, + rule_id bigint, + channel_id bigint, + time bigint NOT NULL, + message text, + type incident_history_event_type NOT NULL, + new_severity severity, + old_severity severity, + new_recipient_role incident_contact_role, + old_recipient_role incident_contact_role, + notification_state notification_state_type, + sent_at bigint, + + CONSTRAINT pk_incident_history PRIMARY KEY (id), + CONSTRAINT fk_incident_history_incident_rule_escalation_state FOREIGN KEY (incident_id, rule_escalation_id) REFERENCES incident_rule_escalation_state(incident_id, rule_escalation_id), + CONSTRAINT fk_incident_history_incident FOREIGN KEY (incident_id) REFERENCES incident(id), + CONSTRAINT fk_incident_history_rule_escalation FOREIGN KEY (rule_escalation_id) REFERENCES rule_escalation(id), + CONSTRAINT fk_incident_history_event FOREIGN KEY (event_id) REFERENCES event(id), + CONSTRAINT fk_incident_history_contact FOREIGN KEY (contact_id) REFERENCES contact(id), + CONSTRAINT fk_incident_history_contactgroup FOREIGN KEY (contactgroup_id) REFERENCES contactgroup(id), + CONSTRAINT fk_incident_history_schedule FOREIGN KEY (schedule_id) REFERENCES schedule(id), + CONSTRAINT fk_incident_history_rule FOREIGN KEY (rule_id) REFERENCES rule(id), + CONSTRAINT fk_incident_history_channel FOREIGN KEY (channel_id) REFERENCES channel(id) +); + +CREATE INDEX idx_incident_history_time_type ON incident_history(time, type); +COMMENT ON INDEX idx_incident_history_time_type IS 'Incident History ordered by time/type'; + +CREATE TABLE browser_session ( + php_session_id varchar(256) NOT NULL, + username citext NOT NULL, + user_agent text NOT NULL, + authenticated_at bigint NOT NULL, + + CONSTRAINT pk_browser_session PRIMARY KEY (php_session_id), + CONSTRAINT ck_browser_session_username_up_to_254_chars CHECK (length(username) <= 254) +); + +CREATE INDEX idx_browser_session_authenticated_at ON browser_session (authenticated_at DESC); +CREATE INDEX idx_browser_session_username_agent ON browser_session (username, user_agent); diff --git a/test/services/mysql/2-setup.sh b/test/services/mysql/2-setup.sh new file mode 100755 index 000000000..161d381d3 --- /dev/null +++ b/test/services/mysql/2-setup.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +set -e +set -o pipefail + +apt update +apt install -y wget + +wget -O schemas/icingaweb.sql https://github.com/Icinga/icingaweb2/blob/main/schema/pgsql.schema.sql diff --git a/test/services/mysql/3-import-schemas.sh b/test/services/mysql/3-import-schemas.sh new file mode 100755 index 000000000..4ea4dd86e --- /dev/null +++ b/test/services/mysql/3-import-schemas.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -e +set -o pipefail + +echo +for f in schemas/*; do + db=$(basename $f .sql) + case "$f" in + *.sql) echo "$0: running $f"; "${mysql[@]}" -c "CREATE DATABASE $db;"; "${mysql[@]}" "$db" < "$f"; echo ;; + *) echo "$0: ignoring $f" ;; + esac + echo +done diff --git a/test/services/mysql/schemas/readme.txt b/test/services/mysql/schemas/readme.txt new file mode 100644 index 000000000..0b84351c1 --- /dev/null +++ b/test/services/mysql/schemas/readme.txt @@ -0,0 +1 @@ +Place schemas here to automatically import them. Filenames are used as table names. diff --git a/test/services/pgsql/2-setup.sh b/test/services/pgsql/2-setup.sh new file mode 100755 index 000000000..161d381d3 --- /dev/null +++ b/test/services/pgsql/2-setup.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +set -e +set -o pipefail + +apt update +apt install -y wget + +wget -O schemas/icingaweb.sql https://github.com/Icinga/icingaweb2/blob/main/schema/pgsql.schema.sql diff --git a/test/services/pgsql/3-import-schemas.sh b/test/services/pgsql/3-import-schemas.sh new file mode 100755 index 000000000..259cc6783 --- /dev/null +++ b/test/services/pgsql/3-import-schemas.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -e +set -o pipefail + +echo +for f in schemas/*; do + db=$(basename $f .sql) + case "$f" in + *.sql) echo "$0: running $f"; psql --username "$POSTGRES_USER" -c "CREATE DATABASE $db;"; psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$db" < "$f"; echo ;; + *) echo "$0: ignoring $f" ;; + esac + echo +done diff --git a/test/services/pgsql/schemas/readme.txt b/test/services/pgsql/schemas/readme.txt new file mode 100644 index 000000000..0b84351c1 --- /dev/null +++ b/test/services/pgsql/schemas/readme.txt @@ -0,0 +1 @@ +Place schemas here to automatically import them. Filenames are used as table names.