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/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/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..7d15410ed --- /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' => sprintf('Invalid request parameter: Filter column %s is not allowed', $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..7a0bb7b9b --- /dev/null +++ b/library/Notifications/Api/Middleware/LegacyRequestConversionMiddleware.php @@ -0,0 +1,71 @@ +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..d545d774a --- /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(); + array_map(function ($middleware) { + if (! $middleware instanceof MiddlewareInterface) { + throw new \InvalidArgumentException('All middlewares must implement MiddlewareInterface'); + } + + $this->pipeline->enqueue($middleware); + }, $middlewares); + } + + /** + * 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..5d365ddc0 --- /dev/null +++ b/library/Notifications/Api/OpenApiDescriptionElement/OadV1Post.php @@ -0,0 +1,172 @@ + $entityName . ' created successfully', + ] + ), + ], + headers: [ + 'Location' => sprintf( + 'notifications/api/v1/%s/{identifier}', + strtolower($entityName) . 's' + ) + ], + 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..11d84e0ff --- /dev/null +++ b/library/Notifications/Api/OpenApiDescriptionElement/OadV1Put.php @@ -0,0 +1,151 @@ + $entityName . ' created successfully', + ] + ), + ], + headers: [ + 'Location' => sprintf( + 'notifications/api/v1/%s/{identifier}', + strtolower($entityName) . 's' + ) + ], + 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..2210f4526 --- /dev/null +++ b/library/Notifications/Api/OpenApiDescriptionElement/Response/SuccessDataResponse.php @@ -0,0 +1,50 @@ + '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/Util/BinaryUuidConverter.php b/library/Notifications/Api/Util/BinaryUuidConverter.php new file mode 100644 index 000000000..efb8de6cc --- /dev/null +++ b/library/Notifications/Api/Util/BinaryUuidConverter.php @@ -0,0 +1,24 @@ + []], + ], +)] +#[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..39e227024 --- /dev/null +++ b/library/Notifications/Api/V1/ContactGroups.php @@ -0,0 +1,715 @@ + 'Invalid request body: expects users to be an array'] + )] + #[OA\Examples( + example: 'InvalidUserUUID', + summary: 'Invalid user UUID', + value: ['message' => 'Invalid request body: user identifiers must be valid UUIDs'] + )] + #[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' + ]); + + 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) { + if (! empty($requestBody['name'])) { + $this->assertUniqueName($requestBody['name'], $contactgroupId); + } + + Database::get()->update( + 'contactgroup', + ['name' => $requestBody['name']], + ['id = ?' => $contactgroupId] + ); + Database::get()->update( + 'contactgroup_member', + ['deleted' => 'y'], + ['contactgroup_id = ?' => $contactgroupId, 'deleted = ?' => 'n'] + ); + + if (! empty($requestBody['users'])) { + $this->addUsers($contactgroupId, $requestBody['users']); + } + + $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]) + ->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 HttpBadRequestException($msgPrefix . 'user identifiers must be valid UUIDs'); + } + //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']); + } + } + + /** + * 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)); + } + } +} diff --git a/library/Notifications/Api/V1/Contacts.php b/library/Notifications/Api/V1/Contacts.php new file mode 100644 index 000000000..9bdeb1a49 --- /dev/null +++ b/library/Notifications/Api/V1/Contacts.php @@ -0,0 +1,962 @@ + + * } + */ +#[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) { + if (! empty($requestBody['username'])) { + $this->assertUniqueUsername($requestBody['username'], $contactId); + } + + $channelID = Channels::getChannelId($requestBody['default_channel']); + + if ($channelID === false) { + throw new HttpException(422, 'Default channel mismatch'); + } + + Database::get()->update('contact', [ + 'full_name' => $requestBody['full_name'], + 'username' => $requestBody['username'] ?? null, + 'default_channel_id' => $channelID, + 'changed_at' => (int) (new DateTime())->format("Uv"), + ], ['id = ?' => $contactId]); + + $markAsDeleted = ['deleted' => 'y']; + Database::get()->update( + 'contact_address', + $markAsDeleted, + ['contact_id = ?' => $contactId, 'deleted = ?' => 'n'] + ); + Database::get()->update( + 'contactgroup_member', + $markAsDeleted, + ['contact_id = ?' => $contactId, 'deleted = ?' => 'n'] + ); + + if (! empty($requestBody['addresses'])) { + $this->addAddresses($contactId, $requestBody['addresses']); + } + + if (! empty($requestBody['groups'])) { + $this->addGroups($contactId, $requestBody['groups']); + } + + $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, 'Default channel mismatch'); + } + + 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']); + } + } + + /** + * 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 HttpBadRequestException($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]) + ->groupBy('co.external_uuid') + ); + } +} diff --git a/library/Notifications/Api/V1/OpenApi.php b/library/Notifications/Api/V1/OpenApi.php new file mode 100644 index 000000000..a349d20e8 --- /dev/null +++ b/library/Notifications/Api/V1/OpenApi.php @@ -0,0 +1,211 @@ + 'success', +// 'message' => 'Contact created successfully', +// ] +// ), +//// new OA\Examples( +//// example: 'IDParameterInvalidUUID', +//// summary: 'Invalid UUID format', +//// value: [ +//// 'status' => 'error', +//// 'message' => 'Provided id-parameter is not a valid UUID', +//// ], +//// ), +//// new OA\Examples( +//// example: 'IdentifierNotFound', +//// summary: 'Identifier not found', +//// value: ['message' => 'Identifier not found'] +//// ), +// new OA\Examples( +// example: 'InvalidIdentifier', +// summary: 'Identifier is not valid', +// value: ['message' => 'The given identifier is not a valid UUID'] +// ), +// new OA\Examples( +// example: 'MissingRequiredRequestBodyField', +// summary: 'Missing required request body field', +// value: [ +// 'status' => 'error', +// 'message' => 'Missing required field in request body: X', +// ], +// ), +// new OA\Examples( +// example: 'InvalidRequestBodyField', +// summary: 'Invalid request body field', +// value: [ +// 'status' => 'error', +// 'message' => 'Invalid field in request body: X', +// ], +// ), +class OpenApi extends ApiV1 implements RequestHandlerInterface, EndpointInterface +{ + public const OPENAPI_PATH = __DIR__ . '/docs/openapi.json'; + + public function getEndpoint(): string + { + return 'openapi'; + } + + /** + * Generate OpenAPI documentation for the Notifications API + * + * @return ResponseInterface + * + * @throws ProgrammingError + */ + public function get(): ResponseInterface + { + // TODO: Create the documentation during CI and not on request + if (file_exists(self::OPENAPI_PATH)) { + $oad = file_get_contents(self::OPENAPI_PATH); + } else { + $files = $this->getFilesIncludingDocs(); + + $generator = new Generator(new PsrLogger()); + $generator->setVersion(\OpenApi\Annotations\OpenApi::VERSION_3_1_0); + + $generator->getProcessorPipeline()->add(new AddGlobal401Response()); + + try { + $openapi = $generator->generate($files); + } catch (RuntimeException $e) { + throw new RuntimeException('Unable to generate OpenApi: ' . $e->getMessage()); + } + + $oad = $openapi->toJson( + JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_IGNORE | JSON_PRETTY_PRINT + ); + + if (! is_dir(dirname(self::OPENAPI_PATH))) { + mkdir(dirname(self::OPENAPI_PATH), 0755, true); + } + + file_put_contents(self::OPENAPI_PATH, $oad); + } + + return $this->createResponse(body: $oad); + } + + /** + * Get the files including the ApiCore.php file and any other files matching the given filter. + * + * @param string $fileFilter + * + * @return array + * + * @throws ProgrammingError + */ + protected function getFilesIncludingDocs(string $fileFilter = '*'): array + { +// $apiCoreDir = __DIR__ . '/ApiCore.php'; + // TODO: find a way to get the module name from the request or class context +// $moduleName = $this->getRequest()->getModuleName() ?: 'default;'; + $moduleName = 'notifications'; + + if ($moduleName === 'default' || $moduleName === '') { + $baseDir = Icinga::app()->getLibraryDir('Icinga/Application/Api/'); + } else { + $baseDir = Icinga::app()->getModuleManager()->getModuleDir($moduleName) + . '/library/' . ucfirst($moduleName) . '/Api/'; + } + $dirEndpoints = $baseDir . strtoupper(static::VERSION) . '/'; + $dirElements = $baseDir . 'OpenApiDescriptionElements/'; + $dirs = [$dirEndpoints, $dirElements]; + + $files = []; + foreach ($dirs as $dir) { + $dir = rtrim($dir, '/') . '/'; + if (! is_dir($dir)) { + throw new RuntimeException("Directory $dir does not exist"); + } + if (! is_readable($dir)) { + throw new RuntimeException("Directory $dir is not readable"); + } + +// $currentFiles = glob($dir . '*', GLOB_NOSORT | GLOB_BRACE | GLOB_MARK); + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS) + ); + + $currentFiles = []; + foreach ($iterator as $file) { + if ($file->isFile() && $file->getExtension() === 'php') { + $currentFiles[] = $file->getPathname(); + } + } +// array_unshift($currentFiles, $apiCoreDir); + if ($currentFiles === []) { + throw new RuntimeException("Failed to read files from directory: $dir"); + } + $files = array_merge($files, $currentFiles); + } + + return $files; + } +} 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..9a2af6ba0 --- /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, $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..d431dc006 --- /dev/null +++ b/library/Notifications/Test/ApiTestBackends.php @@ -0,0 +1,339 @@ + + */ + 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' => 'php', + '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..1953b8ee2 --- /dev/null +++ b/test/php/application/controllers/ApiV1ChannelsTest.php @@ -0,0 +1,311 @@ +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->assertSame($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->assertSame($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->assertSame($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->assertSame($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->assertSame($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->assertSame($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->assertSame($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->assertSame($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->assertSame($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->assertSame($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->assertSame($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->assertSame($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->assertSame($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->assertSame($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->assertSame($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->assertSame($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->assertSame($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->assertSame($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->assertSame($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->assertSame($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..e13d92f6e --- /dev/null +++ b/test/php/application/controllers/ApiV1ContactGroupsTest.php @@ -0,0 +1,841 @@ +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->assertSame($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->assertSame($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->assertSame($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->assertSame($expected, $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testGetWithNewIdentifier(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->assertSame($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->assertSame($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->assertSame($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->assertSame( + $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->assertSame( + $this->jsonEncodeError('Unexpected query parameter: Filter is only allowed for GET requests'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToReplaceWithNonExistingIdentifier(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->assertSame($this->jsonEncodeError('Contact Group not found'), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToReplaceWithAlreadyExistingIdentifierAndIndifferentPayloadId( + 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->assertSame( + $this->jsonEncodeError('Identifier mismatch: the Payload id must be different from the URL identifier'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToReplaceWithAlreadyExistingIdentifierAndExistingPayloadId( + 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->assertSame($this->jsonEncodeError('Contact Group already exists'), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToReplaceWithAlreadyExistingIdentifierAndValidData(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->assertSame($this->jsonEncodeSuccessMessage('Contact Group created successfully'), $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->assertSame($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', + '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->assertSame($this->jsonEncodeSuccessMessage('Contact Group created successfully'), $content); + + // without optional field users + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contact-groups', + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID_4, + 'name' => 'Test' + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(201, $response->getStatusCode(), $content); + $this->assertSame( + ['notifications/api/v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID_4], + $response->getHeader('Location') + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToCreateWithInvalidData(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->assertSame($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->assertSame($expected, $content); + + // 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->assertSame( + $this->jsonEncodeError('User with identifier ' . BaseApiV1TestCase::CONTACT_UUID_3 . ' not found'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToReplaceWithAlreadyExistingIdentifierAndInvalidData(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->assertSame($expected, $content); + + // missing name + $response = $this->sendRequest( + 'POST', + $endpoint, + 'v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID, + json: [ + 'id' => BaseApiV1TestCase::GROUP_UUID, + 'users' => [] + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertSame($expected, $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->assertSame($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->assertSame( + $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->assertSame( + $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->assertSame($this->jsonEncodeError('Invalid request: Identifier is required'), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToUpdateWithAlreadyExistingIdentifierAndMissingRequiredFields( + 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->assertSame($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->assertSame($expected, $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToUpdateWithAlreadyExistingIdentifierAndDifferentPayloadId( + 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->assertSame($this->jsonEncodeError('Identifier mismatch'), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToCreateWithNewIdentifierAndValidData(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->assertSame(201, $response->getStatusCode(), $content); + $this->assertSame( + ['notifications/api/v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID_3], + $response->getHeader('Location') + ); + $this->assertSame($this->jsonEncodeSuccessMessage('Contact Group created successfully'), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToUpdateWithAlreadyExistingIdentifierAndValidData(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); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToCreateWithNewIdentifierAndInvalidData(Connection $db, Url $endpoint): void + { + // different 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->assertEquals(422, $response->getStatusCode(), $content); + $this->assertSame($this->jsonEncodeError('Identifier mismatch'), $content); + + // 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->assertSame( + $this->jsonEncodeError('User with identifier ' . BaseApiV1TestCase::CONTACT_UUID_3 . ' not found'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToCreateWithNewIdentifierAndValidOptionalData(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(); + + var_dump($content); + $this->assertEquals(201, $response->getStatusCode(), $content); + $this->assertSame( + ['notifications/api/v1/contact-groups/' . BaseApiV1TestCase::GROUP_UUID_3], + $response->getHeader('Location') + ); + $this->assertSame($this->jsonEncodeSuccessMessage('Contact Group created successfully'), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToCreateWithNewIdentifierAndMissingRequiredFields(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->assertSame($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->assertSame($expected, $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->assertSame($this->jsonEncodeError('Invalid request: Identifier is required'), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testDeleteWithNewIdentifier(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->assertSame($this->jsonEncodeError('Contact Group not found'), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testDeleteWithAlreadyExistingIdentifier(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->assertSame( + $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->assertSame($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..24620772a --- /dev/null +++ b/test/php/application/controllers/ApiV1ContactsTest.php @@ -0,0 +1,1058 @@ +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->assertSame($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->assertSame($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->assertSame($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->assertSame($expected, $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testGetWithNewIdentifier(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->assertSame($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->assertSame($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->assertSame($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->assertSame($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->assertSame($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->assertSame($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->assertSame( + $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->assertSame( + $this->jsonEncodeError('Unexpected query parameter: Filter is only allowed for GET requests'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToReplaceWithNewIdentifier(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->assertSame($this->jsonEncodeError('Contact not found'), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToReplaceWithAlreadyExistingIdentifierAndIndifferentPayloadId( + 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->assertSame( + $this->jsonEncodeError('Identifier mismatch: the Payload id must be different from the URL identifier'), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToReplaceWithAlreadyExistingIdentifierAndExistingPayloadId( + 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->assertSame($this->jsonEncodeError('Contact already exists'), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToReplaceWithAlreadyExistingIdentifierAndValidData(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->assertSame($this->jsonEncodeSuccessMessage('Contact created successfully'), $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->assertSame($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->assertSame($this->jsonEncodeSuccessMessage('Contact created successfully'), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToReplaceWithAlreadyExistingIdentifierAndMissingRequiredFields( + 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->assertSame($expected, $content); + + // missing name + $response = $this->sendRequest( + 'POST', + $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->assertSame($expected, $content); + + // missing default_channel + $response = $this->sendRequest( + 'POST', + $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->assertSame($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->assertSame($this->jsonEncodeSuccessMessage('Contact created successfully'), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPostToCreateWithInvalidData(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(400, $response->getStatusCode(), $content); + $this->assertSame( + $this->jsonEncodeError('Invalid request body: given default_channel is not a valid UUID'), + $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', + json: [ + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertSame($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->assertSame($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->assertSame($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->assertSame($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->assertSame( + $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->assertSame( + $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->assertSame( + $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->assertSame($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->assertSame( + $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->assertSame( + $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->assertSame($this->jsonEncodeError('Invalid request: Identifier is required'), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToUpdateWithAlreadyExistingIdentifierAndMissingRequiredFields( + Connection $db, + Url $endpoint + ): void { + // TODO: same results if identifier exists + $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_3, + json: [ + 'full_name' => 'Test', + 'default_channel' => BaseApiV1TestCase::CHANNEL_UUID, + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertSame(422, $response->getStatusCode(), $content); + $this->assertSame($expected, $content); + + // missing 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->assertSame(422, $response->getStatusCode(), $content); + $this->assertSame($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->assertSame(422, $response->getStatusCode(), $content); + $this->assertSame($expected, $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToUpdateWithAlreadyExistingIdentifierAndDifferentPayloadId( + 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->assertSame($this->jsonEncodeError('Identifier mismatch'), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToCreateWithNewIdentifierAndValidData(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->assertSame($this->jsonEncodeSuccessMessage('Contact created successfully'), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToUpdateWithAlreadyExistingIdentifierAndValidData(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); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToUpdateWithNewIdentifierAndInvalidData(Connection $db, Url $endpoint): void + { + // different id + $response = $this->sendRequest( + 'PUT', + $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->assertEquals(422, $response->getStatusCode(), $content); + $this->assertSame($this->jsonEncodeError('Identifier mismatch'), $content); + + // invalid groups + $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, + 'groups' => [BaseApiV1TestCase::GROUP_UUID_3], + ] + ); + $content = $response->getBody()->getContents(); + + $this->assertEquals(422, $response->getStatusCode(), $content); + $this->assertSame( + $this->jsonEncodeError( + 'Contact Group with identifier ' . BaseApiV1TestCase::GROUP_UUID_3 . ' does not exist' + ), + $content + ); + } + + /** + * @dataProvider apiTestBackends + */ + public function testPutToUpdateWithNewIdentifierAndMissingRequiredFields(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->assertSame($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->assertSame($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->assertSame($expected, $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->assertSame($this->jsonEncodeError('Invalid request: Identifier is required'), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testDeleteWithNewIdentifier(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->assertSame($this->jsonEncodeError('Contact not found'), $content); + } + + /** + * @dataProvider apiTestBackends + */ + public function testDeleteWithAlreadyExistingIdentifier(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->assertSame( + $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->assertSame($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.