Compare commits

..

No commits in common. "14a0df4c183e9f4f6c43fcbd55a025ddc1ed1300" and "7c2dee2b6c30aa63c4589692f1ac8657db59b006" have entirely different histories.

36 changed files with 210 additions and 759 deletions

5
.gitignore vendored
View file

@ -74,7 +74,10 @@ web_modules/
# dotenv environment variable files
.env
.env.*
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache

121
.idea/workspace.xml generated
View file

@ -5,21 +5,30 @@
</component>
<component name="ChangeListManager">
<list default="true" id="d7a71994-2699-4ae4-9fd2-ee13b7f33d35" name="Changes" comment="docs: refactor documentation and upgrade to Node.js 24&#10;&#10;This commit upgrades the project to Node.js 24 (LTS) and performs a major refactoring of the documentation to support both advanced users and AI-based automation (MCP).&#10;&#10;Changes:&#10;- Environment &amp; CI/CD:&#10; - Set Node.js version to &gt;=24 in package.json and .nvmrc.&#10; - Updated Dockerfile to use Node 24 base image.&#10; - Updated @types/node to ^24.10.9.&#10;- Documentation:&#10; - Refactored README.md with comprehensive technical reference, configuration details, and Zabbix-to-GraphQL mapping.&#10; - Created docs/howtos/cookbook.md with practical recipes for common tasks and AI test generation.&#10; - Updated docs/howtos/mcp.md to emphasize GraphQL's advantages for AI agents and Model Context Protocol.&#10; - Added readme.improvement.plan.md to track documentation evolution.&#10; - Enhanced all how-to guides with improved cross-references and up-to-date information.&#10;- Guidelines:&#10; - Updated .junie/guidelines.md with Node 24 requirements and enhanced commit message standards (Conventional Commits 1.0.0).&#10;- Infrastructure &amp; Code:&#10; - Updated docker-compose.yml with Apollo MCP server integration.&#10; - Refined configuration and schema handling in src/api/ and src/datasources/.&#10; - Synchronized generated TypeScript types with schema updates.">
<change afterPath="$PROJECT_DIR$/docs/use-cases/VCR - Technical product information.pdf" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/testdata/templates/zbx_device_tracker_vcr.yaml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/README.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/datasources/zabbix-api.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/datasources/zabbix-api.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/docker-compose.yml" beforeDir="false" afterPath="$PROJECT_DIR$/docker-compose.yml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/docs/VCR - Technical product information.pdf" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/docs/howtos/cookbook.md" beforeDir="false" afterPath="$PROJECT_DIR$/docs/howtos/cookbook.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/docs/howtos/mcp.md" beforeDir="false" afterPath="$PROJECT_DIR$/docs/howtos/mcp.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/docs/queries/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/docs/queries/README.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/docs/tests.md" beforeDir="false" afterPath="$PROJECT_DIR$/docs/tests.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/docs/use-cases/trade-fair-logistics-requirements.md" beforeDir="false" afterPath="$PROJECT_DIR$/docs/use-cases/trade-fair-logistics-requirements.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/mcp-config.yaml" beforeDir="false" afterPath="$PROJECT_DIR$/mcp-config.yaml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/samples/extensions/location_tracker_devices.graphql" beforeDir="false" afterPath="$PROJECT_DIR$/samples/extensions/location_tracker_devices.graphql" afterDir="false" />
<change beforePath="$PROJECT_DIR$/schema/mutations.graphql" beforeDir="false" afterPath="$PROJECT_DIR$/schema/mutations.graphql" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/api/resolvers.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/api/resolvers.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/api/start.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/api/start.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/common_utils.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/common_utils.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/datasources/zabbix-history.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/datasources/zabbix-history.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/datasources/zabbix-hostgroups.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/datasources/zabbix-hostgroups.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/datasources/zabbix-usergroups.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/datasources/zabbix-usergroups.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/test/history_push.test.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/test/history_push.test.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/test/indirect_dependencies.test.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/test/indirect_dependencies.test.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/test/query_optimization.test.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/test/query_optimization.test.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/test/template_integration.test.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/test/template_integration.test.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/test/template_link.test.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/test/template_link.test.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/test/template_query.test.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/test/template_query.test.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/test/user_rights.test.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/test/user_rights.test.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/test/user_rights_integration.test.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/test/user_rights_integration.test.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/test/zabbix_docs_samples.test.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/test/zabbix_docs_samples.test.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/datasources/zabbix-items.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/datasources/zabbix-items.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/datasources/zabbix-request.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/datasources/zabbix-request.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/execution/host_exporter.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/execution/host_exporter.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/execution/regression_test_executor.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/execution/regression_test_executor.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/schema/generated/graphql.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/schema/generated/graphql.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/testdata/templates/zbx_default_templates_vcr.yaml" beforeDir="false" afterPath="$PROJECT_DIR$/src/testdata/templates/zbx_default_templates_vcr.yaml" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@ -30,7 +39,7 @@
<execution />
</component>
<component name="EmbeddingIndexingInfo">
<option name="cachedIndexableFilesCount" value="168" />
<option name="cachedIndexableFilesCount" value="169" />
<option name="fileBasedEmbeddingIndicesEnabled" value="true" />
</component>
<component name="Git.Settings">
@ -78,50 +87,45 @@
<option name="openDirectoriesWithSingleClick" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;NIXITCH_NIXPKGS_CONFIG&quot;: &quot;&quot;,
&quot;NIXITCH_NIX_CONF_DIR&quot;: &quot;&quot;,
&quot;NIXITCH_NIX_OTHER_STORES&quot;: &quot;&quot;,
&quot;NIXITCH_NIX_PATH&quot;: &quot;&quot;,
&quot;NIXITCH_NIX_PROFILES&quot;: &quot;&quot;,
&quot;NIXITCH_NIX_REMOTE&quot;: &quot;&quot;,
&quot;NIXITCH_NIX_USER_PROFILE_DIR&quot;: &quot;&quot;,
&quot;Node.js.index.ts.executor&quot;: &quot;Run&quot;,
&quot;RunOnceActivity.MCP Project settings loaded&quot;: &quot;true&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;,
&quot;com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultModelSelectionForGA.v1&quot;: &quot;true&quot;,
&quot;git-widget-placeholder&quot;: &quot;main&quot;,
&quot;go.import.settings.migrated&quot;: &quot;true&quot;,
&quot;javascript.preferred.runtime.type.id&quot;: &quot;node&quot;,
&quot;junie.onboarding.icon.badge.shown&quot;: &quot;true&quot;,
&quot;last_opened_file_path&quot;: &quot;//wsl.localhost/Ubuntu/home/ahilbig/git/vcr/zabbix-graphql-api/docs/use-cases&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_interpreter_path&quot;: &quot;wsl://Ubuntu@/home/ahilbig/.nvm/versions/node/v24.12.0/bin/node&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;npm.codegen.executor&quot;: &quot;Run&quot;,
&quot;npm.compile.executor&quot;: &quot;Run&quot;,
&quot;npm.copy-schema.executor&quot;: &quot;Run&quot;,
&quot;npm.prod.executor&quot;: &quot;Run&quot;,
&quot;npm.test.executor&quot;: &quot;Run&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;junie.mcp&quot;,
&quot;settings.editor.splitter.proportion&quot;: &quot;0.23751687&quot;,
&quot;to.speed.mode.migration.done&quot;: &quot;true&quot;,
&quot;ts.external.directory.path&quot;: &quot;\\\\wsl.localhost\\Ubuntu\\home\\ahilbig\\git\\vcr\\zabbix-graphql-api\\node_modules\\typescript\\lib&quot;,
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
},
&quot;keyToStringList&quot;: {
&quot;com.intellij.ide.scratch.ScratchImplUtil$2/New Scratch File&quot;: [
&quot;TEXT&quot;
]
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"NIXITCH_NIXPKGS_CONFIG": "",
"NIXITCH_NIX_CONF_DIR": "",
"NIXITCH_NIX_OTHER_STORES": "",
"NIXITCH_NIX_PATH": "",
"NIXITCH_NIX_PROFILES": "",
"NIXITCH_NIX_REMOTE": "",
"NIXITCH_NIX_USER_PROFILE_DIR": "",
"Node.js.index.ts.executor": "Run",
"RunOnceActivity.MCP Project settings loaded": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
"RunOnceActivity.git.unshallow": "true",
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
"com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultModelSelectionForGA.v1": "true",
"git-widget-placeholder": "main",
"go.import.settings.migrated": "true",
"javascript.preferred.runtime.type.id": "node",
"junie.onboarding.icon.badge.shown": "true",
"last_opened_file_path": "//wsl.localhost/Ubuntu/home/ahilbig/git/vcr/zabbix-graphql-api/docs/use-cases",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
"nodejs_interpreter_path": "wsl://Ubuntu@/home/ahilbig/.nvm/versions/node/v24.12.0/bin/node",
"nodejs_package_manager_path": "npm",
"npm.codegen.executor": "Run",
"npm.compile.executor": "Run",
"npm.copy-schema.executor": "Run",
"npm.prod.executor": "Run",
"npm.test.executor": "Run",
"settings.editor.selected.configurable": "junie.mcp",
"settings.editor.splitter.proportion": "0.23751687",
"to.speed.mode.migration.done": "true",
"ts.external.directory.path": "\\\\wsl.localhost\\Ubuntu\\home\\ahilbig\\git\\vcr\\zabbix-graphql-api\\node_modules\\typescript\\lib",
"vue.rearranger.settings.migration": "true"
}
}</component>
}]]></component>
<component name="RecapSpentCounter">
<option name="endsOfQuotaMs" value="1772398800000" />
<option name="spentUsd" value="0.0915201" />
@ -227,10 +231,7 @@
<workItem from="1769789496322" duration="14281000" />
<workItem from="1769849767328" duration="18404000" />
<workItem from="1769955114366" duration="3276000" />
<workItem from="1770107035156" duration="4817000" />
<workItem from="1770129804879" duration="13000" />
<workItem from="1770129846593" duration="5283000" />
<workItem from="1770167580486" duration="1386000" />
<workItem from="1770107035156" duration="3830000" />
</task>
<task id="LOCAL-00001" summary="chore: Update IntelliJ workspace settings and add GitHub Actions workflow for Docker deployment">
<option name="closed" value="true" />

View file

@ -52,7 +52,7 @@ Before you begin, ensure you have met the following requirements:
- **Node.js**: Version 24 (LTS) or higher recommended.
- **Docker**: Version 27 or higher and **Docker Compose** v2.29 or higher (use `docker compose` instead of `docker-compose`).
- **Zabbix**: A running Zabbix instance (compatible with Zabbix 6.0+) with API access. See [Zabbix Version Compatibility](#-zabbix-version-compatibility) for details.
- **Zabbix**: A running Zabbix instance (compatible with Zabbix 6.0+) with API access.
- **Zabbix Super Admin Token** (for full functionality / privilege escalation).
- **Zabbix User Access** (groups and roles depending on your use case).
@ -81,20 +81,6 @@ Builds the project and runs the compiled code:
npm run prod
```
#### 🐳 Local Zabbix Environment
For development and testing, you can start a complete environment including Zabbix from scratch:
```bash
# Start with local Zabbix (latest 7.0)
docker compose --profile zabbix-local up -d
# Start with a specific Zabbix version (e.g. 6.0)
ZABBIX_VERSION=alpine-6.0-latest docker compose --profile zabbix-local up -d
```
> **Guide**: For detailed setup and configuration of the local environment, see [Local Development Environment](./docs/howtos/local_development.md).
> **Important**: On fresh Zabbix installations, you must manually create the base host group (e.g., `Roadwork`) before the API can import devices.
The API will be available at `http://localhost:4000/`.
## ⚙️ Configuration
@ -250,20 +236,7 @@ The API version is automatically set during the Docker build process based on th
### 🔧 Zabbix Version Compatibility
This API is officially supported and productively used with **Zabbix 7.0 (LTS)** and newer. It also maintains compatibility with **Zabbix 6.0 (LTS)** and **6.4**, with the following version-specific behaviors:
- **Zabbix 7.0 (LTS)**:
- Full feature support.
- **History Push**: Uses the native `history.push` API for efficient data ingestion.
- **Zabbix 6.4**:
- **History Push**: Not supported (requires Zabbix 7.0+). The `pushHistory` mutation returns a clear error.
- **Group Propagation**: Fully supported via the `hostgroup.propagate` API (introduced in 6.2).
- **UUIDs**: Fully supported for both Host Groups and Template Groups.
- **Zabbix 6.0 (LTS)**:
- **History Push**: Not supported.
- **Group Propagation**: `hostgroup.propagate` is unavailable. The API automatically performs **manual host group expansion** during import to ensure consistent behavior.
- **UUIDs**: Host Groups in 6.0 lack UUIDs. The API uses a **name-based fallback** for matching host group permissions during import.
- **Template Groups**: The API correctly handles the separation of Host Groups and Template Groups introduced in Zabbix 6.0 across all versions.
This API is designed to work with Zabbix 7.4, which is the version it runs productively with. While it may work with earlier versions (like 6.0+), 7.4 is the officially supported and tested version.
## 🛠️ Technical Maintenance

View file

@ -43,50 +43,5 @@ services:
- mcp-shared:/mcp-data
command: sh -c "cat /schema/*.graphql > /mcp-data/schema.graphql"
postgres-server:
image: postgres:16-alpine
profiles:
- zabbix-local
environment:
- POSTGRES_USER=zabbix
- POSTGRES_PASSWORD=zabbix
- POSTGRES_DB=zabbix
volumes:
- zbx_db_data:/var/lib/postgresql/data
zabbix-server:
image: zabbix/zabbix-server-pgsql:${ZABBIX_VERSION:-alpine-7.0-latest}
profiles:
- zabbix-local
ports:
- "10051:10051"
environment:
- DB_SERVER_HOST=postgres-server
- POSTGRES_USER=zabbix
- POSTGRES_PASSWORD=zabbix
- POSTGRES_DB=zabbix
- ZBX_ALLOWUNSUPPORTEDDBVERSIONS=1
depends_on:
- postgres-server
zabbix-web:
image: zabbix/zabbix-web-nginx-pgsql:${ZABBIX_VERSION:-alpine-7.0-latest}
profiles:
- zabbix-local
ports:
- "8080:8080"
environment:
- ZBX_SERVER_HOST=zabbix-server
- DB_SERVER_HOST=postgres-server
- POSTGRES_USER=zabbix
- POSTGRES_PASSWORD=zabbix
- POSTGRES_DB=zabbix
- PHP_TZ=UTC
- ZBX_ALLOWUNSUPPORTEDDBVERSIONS=1
depends_on:
- postgres-server
- zabbix-server
volumes:
mcp-shared:
zbx_db_data:

View file

@ -22,9 +22,6 @@ Discover how the permission system works, how to define permission levels using
### 🛠️ [Technical Maintenance](./maintenance.md)
Guide on code generation (GraphQL Codegen), running Jest tests, and local Docker builds.
### 💻 [Local Development Environment](./local_development.md)
Detailed instructions for setting up a fully isolated local development environment with Zabbix using Docker Compose.
### 🧪 [Test Specification](../tests.md)
Detailed list of test cases, categories (Unit, Integration, E2E), and coverage checklist.

View file

@ -1,88 +0,0 @@
# 💻 Local Development Environment
This guide provides detailed instructions on how to set up a fully isolated local development environment for the Zabbix GraphQL API using Docker Compose.
## 🚀 Overview
The project includes a Docker Compose profile that launches a complete Zabbix stack alongside the GraphQL API and MCP server. This allows you to develop and test features against different Zabbix versions without needing an external Zabbix installation.
### Included Services
- **`zabbix-graphql-api`**: The main GraphQL server.
- **`apollo-mcp-server`**: The Model Context Protocol server for AI integration.
- **`postgres-server`**: PostgreSQL 16 database for Zabbix.
- **`zabbix-server`**: Zabbix Server (PostgreSQL version).
- **`zabbix-web`**: Zabbix Web interface (Nginx/PostgreSQL).
## 🛠️ Step-by-Step Setup
### 1. Start the Environment
Use the `zabbix-local` profile to start all services. You can optionally specify the Zabbix version using the `ZABBIX_VERSION` environment variable.
```bash
# Start the latest 7.0 (default)
docker compose --profile zabbix-local up -d
# Start Zabbix 6.0 (LTS)
ZABBIX_VERSION=alpine-6.0-latest docker compose --profile zabbix-local up -d
```
### 2. Configure Zabbix
Once the containers are running, you need to perform a minimal setup in the Zabbix UI:
1. Open `http://localhost:8080` in your browser.
2. Log in with the default credentials:
- **User**: `Admin`
- **Password**: `zabbix`
3. Navigate to **Administration** > **Users** > **API tokens**.
4. Create a new token for the `Admin` user (or a dedicated service user) and copy it.
5. **Create Base Host Group**: The API requires a base host group to exist for importing devices.
- Navigate to **Configuration** > **Host groups** (or **Data collection** > **Host groups** in Zabbix 7.0).
- Create a host group named `Roadwork` (or the value of `ZABBIX_EDGE_DEVICE_BASE_GROUP` in your `.env`).
- **Note**: If your base group is nested (e.g., `Roadwork/Devices`), ensure the full path exists. Zabbix does not automatically create parent groups when creating child groups via the API.
### 3. Connect the API
To connect the GraphQL API to your local Zabbix instance, update your `.env` file with the following values (referenced in `samples/zabbix-local.env`):
```env
# Internal Docker network URL
ZABBIX_BASE_URL=http://zabbix-web:8080/
# The token you created in Step 2
ZABBIX_PRIVILEGE_ESCALATION_TOKEN=your-newly-created-token
```
### 4. Restart the API
Apply the configuration by restarting the API container:
```bash
docker compose restart zabbix-graphql-api
```
## ✅ Verification
You can verify the setup by running a simple query against the local API:
```bash
curl -X POST -H "Content-Type: application/json" \
-d '{"query": "{ apiVersion zabbixVersion }"}' \
http://localhost:4001/
```
The `zabbixVersion` should match the version you specified in `ZABBIX_VERSION`.
## 💡 Advanced Usage
### Data Persistence
Zabbix data is stored in a named Docker volume called `zbx_db_data`. This ensures that your configuration, templates, and hosts are preserved even if you stop or remove the containers.
To perform a clean reset of the environment:
```bash
docker compose --profile zabbix-local down -v
```
### Testing Multiple Versions
The local environment is perfect for testing the API's version-specific logic (e.g. `history.push` vs `zabbix_sender`). Simply change the `ZABBIX_VERSION` variable and restart the stack.
Supported tags can be found on the [Zabbix Docker Hub](https://hub.docker.com/u/zabbix).
---
**Related Guide**: [Technical Maintenance](./maintenance.md)
**Related Reference**: [Zabbix Version Compatibility](../../README.md#-zabbix-version-compatibility)

View file

@ -34,9 +34,6 @@ We use [Jest](https://jestjs.io/) for unit and integration testing.
npm run test
```
#### Local Development Setup
For running integration tests against a real Zabbix instance, it is recommended to use the [Local Development Environment](./local_development.md). This setup allows you to test the API against specific Zabbix versions (e.g. 6.0, 7.0) in an isolated way.
#### Adding New Tests
- **Location**: Place new test files in `src/test/` with the `.test.ts` extension.
- **Coverage**: Ensure you cover both successful operations and error scenarios.

View file

@ -6,21 +6,12 @@ This document outlines the achieved milestones and planned enhancements for the
- **🎯 VCR Product Integration**: Developed a specialized **GraphQL API** as part of the VCR Product to enable the use of **Zabbix** as a robust base for monitoring and controlling **IoT devices**.
- *First use case*: Control of mobile traffic jam warning installations on **German Autobahns**.
- **⚡ Query Optimization**: Optimized GraphQL API queries to reduce the amount of data fetched from Zabbix depending on the fields really requested.
- *Implementation*: Added dynamic output selection and field pruning in `ZabbixRequest`.
- **🔓 Open Source Extraction & AI Integration**: Extracted the core functionality of the API to publish it as an **Open Source** project.
- *AI Integration*: Enhanced with **Model Context Protocol (MCP)** and **AI agent** integration to enable workflow and agent-supported use cases.
- **🐳 Local Development Environment**: Integrated a complete Zabbix stack into the Docker Compose configuration using profiles.
- *Feature*: Support for multiple Zabbix versions (6.0, 6.4, 7.0+) for development and testing.
- *Implementation*: Added `zabbix-local` profile and `ZABBIX_VERSION` dynamic image tagging.
- **🔧 Multi-Version Compatibility**: Verified and enhanced support for Zabbix 6.0 LTS, 6.2, 6.4, and 7.0 LTS.
- *Feature*: Automatic fallback logic for older Zabbix versions (auth, name-based matching, manual expansion).
- *Verification*: Full regression and smoketest suites passed across all mentioned versions.
## 📅 Planned Enhancements
- **⚡ Query Optimization**: Optimize GraphQL API queries to reduce the amount of data fetched from Zabbix depending on the fields really requested and improve performance.
- **🏗️ Trade Fair Logistics Use Case**: Extend the API to support trade fair logistics use cases by analyzing requirements from business stakeholders.
- *Analysis*: Analysis of "Trade Fair Logistics" and derived [requirements document](docs/use-cases/trade-fair-logistics-requirements.md).
- *Simulation*:

View file

@ -1,10 +0,0 @@
# Zabbix Connection for local Docker Compose profile (zabbix-local)
# Use the contents of this file in your .env or pass them to docker compose
# when running with 'docker compose --profile zabbix-local up'
# Internal URL for the API to connect to the local Zabbix container
ZABBIX_BASE_URL=http://zabbix-web:8080/
# Note: After Zabbix starts, you must log in to http://localhost:8080 (Admin/zabbix)
# and create an API token to use as ZABBIX_PRIVILEGE_ESCALATION_TOKEN.
ZABBIX_PRIVILEGE_ESCALATION_TOKEN=your-newly-created-zabbix-token

View file

@ -166,22 +166,32 @@ export function createResolvers(): Resolvers {
zabbixAuthToken,
cookie, dataSources
}: any, info: any) => {
let params: any = {}
if (args.hostids) {
// @ts-ignore
args.templateids = args.hostids
delete args.hostids
params.templateids = args.hostids
}
if (args.name_pattern) {
params.search = {
name: args.name_pattern
}
}
const output = GraphqlParamsToNeededZabbixOutput.mapTemplates(info);
return await new ZabbixQueryTemplatesRequest(zabbixAuthToken, cookie)
.executeRequestThrowError(dataSources?.zabbixAPI || zabbixAPI, new ParsedArgs(args), output);
.executeRequestThrowError(dataSources?.zabbixAPI || zabbixAPI, new ParsedArgs(params), output);
},
allTemplateGroups: async (_parent: any, args: any, {
zabbixAuthToken,
cookie
}: any) => {
let params: any = {}
if (args.name_pattern) {
params.search = {
name: args.name_pattern
}
}
return await new ZabbixQueryTemplateGroupRequest(zabbixAuthToken, cookie)
.executeRequestThrowError(zabbixAPI, new ParsedArgs(args));
.executeRequestThrowError(zabbixAPI, new ParsedArgs(params));
}
},
Mutation: {

View file

@ -80,20 +80,6 @@ export class ZabbixAPI
return super.post(path, request);
}
private static version: string | undefined
async getVersion(): Promise<string> {
if (!ZabbixAPI.version) {
const response = await this.requestByPath<string>("apiinfo.version")
if (typeof response === "string") {
ZabbixAPI.version = response
} else {
return "0.0.0"
}
}
return ZabbixAPI.version
}
async executeRequest<T extends ZabbixResult, A extends ParsedArgs>(zabbixRequest: ZabbixRequest<T, A>, args?: A, throwApiError: boolean = true, output?: string[]): Promise<T | ZabbixErrorResult> {
return throwApiError ? zabbixRequest.executeRequestThrowError(this, args, output) : zabbixRequest.executeRequestReturnError(this, args, output);
}

View file

@ -81,10 +81,6 @@ export class ZabbixHistoryPushRequest extends ZabbixRequest<ZabbixHistoryPushRes
async prepare(zabbixAPI: ZabbixAPI, args?: ZabbixHistoryPushParams): Promise<ZabbixHistoryPushResult | ZabbixErrorResult | undefined> {
if (!args) return undefined;
const version = await zabbixAPI.getVersion();
if (version < "7.0.0") {
throw new GraphQLError(`history.push is only supported in Zabbix 7.0.0 and newer. Current version is ${version}. For older versions, please use Zabbix trapper items and zabbix_sender protocol.`);
}
if (!args.itemid && (!args.key || !args.host)) {
throw new GraphQLError("if itemid is empty both key and host must be filled");

View file

@ -100,33 +100,4 @@ export class GroupHelper {
}
return result
}
public static async findSubgroupIds(groupids: number[], zabbixApi: ZabbixAPI, zabbixAuthToken?: string, cookie?: string): Promise<number[]> {
if (groupids.length === 0) return [];
// Get the names of the parent groups
const parentGroups = await new ZabbixQueryHostgroupsRequest(zabbixAuthToken, cookie).executeRequestReturnError(zabbixApi, new ZabbixQueryHostgroupsParams({
groupids: groupids
}));
if (isZabbixErrorResult(parentGroups) || !parentGroups.length) {
return [];
}
const subgroupids: number[] = [];
for (const parent of parentGroups) {
const subgroups = await new ZabbixQueryHostgroupsRequest(zabbixAuthToken, cookie).executeRequestReturnError(zabbixApi, new ZabbixQueryHostgroupsParams({
search: {
name: parent.name + "/*"
},
searchWildcardsEnabled: true
}));
if (!isZabbixErrorResult(subgroups)) {
subgroups.forEach(sg => subgroupids.push(Number(sg.groupid)));
}
}
return [...new Set(subgroupids)];
}
}

View file

@ -25,23 +25,8 @@ export class ZabbixQueryHostsGenericRequest<T extends ZabbixResult, A extends Pa
this.impliedFields.set("hostType", ["tags"]);
}
async executeRequestReturnError(zabbixAPI: ZabbixAPI, args?: A, output?: string[]): Promise<ZabbixErrorResult | T> {
const version = await zabbixAPI.getVersion();
const result = await super.executeRequestReturnError(zabbixAPI, args, output);
if (!isZabbixErrorResult(result) && version < "6.2.0") {
const hosts = result as any[];
for (const host of hosts) {
if (host.groups) {
host.hostgroups = host.groups;
}
}
}
return result;
}
createZabbixParams(args?: A, output?: string[]): ZabbixParams {
const params: any = {
return this.optimizeZabbixParams({
...super.createZabbixParams(args),
selectParentTemplates: [
"templateid",
@ -60,15 +45,11 @@ export class ZabbixQueryHostsGenericRequest<T extends ZabbixResult, A extends Pa
"hostid",
"host",
"name",
"hostgroups",
"description",
"parentTemplates"
]
};
// Zabbix 6.0 compatibility: use selectGroups instead of selectHostGroups
// We include both, Zabbix will ignore the unsupported one.
params.selectGroups = ["groupid", "name"];
return this.optimizeZabbixParams(params, output);
}, output);
}
}
@ -121,7 +102,10 @@ export class ZabbixQueryHostsGenericRequestWithItems<T extends ZabbixResult, A e
"hostid",
"host",
"name",
"hostgroups",
"items",
"description",
"parentTemplates"
],
}, output);
}

View file

@ -1,4 +1,4 @@
import {isZabbixErrorResult, ParsedArgs, ZabbixErrorResult, ZabbixRequest, ZabbixResult} from "./zabbix-request.js";
import {ParsedArgs, ZabbixErrorResult, ZabbixRequest, ZabbixResult} from "./zabbix-request.js";
import {ZabbixAPI} from "./zabbix-api.js";
import {InputMaybe, Permission, QueryHasPermissionsArgs, UserPermission} from "../schema/generated/graphql.js";
import {ApiErrorCode, PermissionNumber} from "../model/model_enum_values.js";
@ -18,10 +18,6 @@ export class ZabbixRequestWithPermissions<T extends ZabbixResult, A extends Pars
return this.prepResult;
}
async assureUserPermissions(zabbixAPI: ZabbixAPI) {
if (this.authToken && this.authToken === Config.ZABBIX_PRIVILEGE_ESCALATION_TOKEN) {
// Bypass permission check for the privilege escalation token as it is assumed to have required rights
return undefined;
}
if (this.permissionsNeeded &&
!await ZabbixPermissionsHelper.hasUserPermissions(zabbixAPI, this.permissionsNeeded, this.authToken, this.cookie)) {
return {
@ -79,31 +75,7 @@ class ZabbixQueryUserGroupPermissionsRequest extends ZabbixRequest<ZabbixUserGro
super("usergroup.get.permissions", authToken, cookie);
}
async executeRequestReturnError(zabbixAPI: ZabbixAPI, args?: ParsedArgs, output?: string[]): Promise<ZabbixUserGroupResponse[] | ZabbixErrorResult> {
const version = await zabbixAPI.getVersion();
const result = await super.executeRequestReturnError(zabbixAPI, args, output);
if (!isZabbixErrorResult(result) && version < "6.2.0") {
// Map 6.0 'rights' to 6.2+ style 'templategroup_rights'
const usergroups = result as ZabbixUserGroupResponse[];
for (const usergroup of usergroups) {
// @ts-ignore
if (usergroup.rights) {
// @ts-ignore
usergroup.templategroup_rights = usergroup.rights.map((r: any) => ({
id: r.id,
permission: r.permission
}));
}
}
}
return result;
}
async createZabbixParams(args?: ParsedArgs) {
// We don't have access to ZabbixAPI here to check version easily,
// but we can just include both selectRights and selectTemplateGroupRights.
// Zabbix 6.2+ will ignore selectRights, Zabbix 6.0 will ignore selectTemplateGroupRights.
createZabbixParams(args?: ParsedArgs) {
return {
...super.createZabbixParams(args),
"output": [
@ -115,8 +87,7 @@ class ZabbixQueryUserGroupPermissionsRequest extends ZabbixRequest<ZabbixUserGro
"selectTemplateGroupRights": [
"id",
"permission"
],
"selectRights": "extend"
]
};
}
}
@ -139,7 +110,7 @@ export class ZabbixPermissionsHelper {
const userGroupPermissions = await new ZabbixQueryUserGroupPermissionsRequest(zabbixAuthToken, cookie).executeRequestThrowError(zabbixAPI)
// Prepare the list of templateIds that are not loaded yet
const templateIdsToLoad = new Set(userGroupPermissions.flatMap(usergroup => (usergroup.templategroup_rights || []).map(templateGroupRight => templateGroupRight.id)));
const templateIdsToLoad = new Set(userGroupPermissions.flatMap(usergroup => usergroup.templategroup_rights.map(templateGroupRight => templateGroupRight.id)));
// Remove all templateIds that are already in the permissionObjectNameCache
templateIdsToLoad.forEach(id => {
@ -174,7 +145,7 @@ export class ZabbixPermissionsHelper {
let objectNamesFilter = this.createMatcherFromWildcardArray(objectNames);
(usergroup.templategroup_rights || []).forEach(templateGroupPermission => {
usergroup.templategroup_rights.forEach(templateGroupPermission => {
const objectName = this.permissionObjectNameCache.get(templateGroupPermission.id);
if (objectName && (objectNamesFilter == undefined || objectNamesFilter.test(objectName))) {
const permissionValue = Number(templateGroupPermission.permission) as PermissionNumber;

View file

@ -9,7 +9,6 @@ class ZabbixRequestBody {
public method
public id = 1
public params?: ZabbixParams
public auth?: string
constructor(method: string) {
this.method = method;
@ -139,7 +138,6 @@ export class ParsedArgs {
(<any>result).search.name = this.name_pattern;
(<any>result).search.host = this.name_pattern;
(<any>result).searchByAny = true;
(<any>result).searchWildcardsEnabled = true;
}
return result
@ -179,12 +177,6 @@ export class ZabbixRequest<T extends ZabbixResult, A extends ParsedArgs = Parsed
if (params.output) {
if (Array.isArray(params.output)) {
params.output = params.output.filter(field => topLevelOutput.includes(field));
// Add any missing top-level fields that are needed
topLevelOutput.forEach(top => {
if (!params.output.includes(top)) {
params.output.push(top);
}
});
} else if (params.output === "extend") {
params.output = topLevelOutput;
}
@ -219,15 +211,10 @@ export class ZabbixRequest<T extends ZabbixResult, A extends ParsedArgs = Parsed
const p = zabbixParams ?? this.createZabbixParams(args, output);
params = Array.isArray(p) ? p : {...this.requestBodyTemplate.params, ...p}
}
const body: ZabbixRequestBody = params ? {
return params ? {
...this.requestBodyTemplate,
params: params
} : {...this.requestBodyTemplate}
if (this.authToken) {
body.auth = this.authToken
}
return body
} : this.requestBodyTemplate
};
headers() {
@ -259,17 +246,6 @@ export class ZabbixRequest<T extends ZabbixResult, A extends ParsedArgs = Parsed
}
async executeRequestReturnError(zabbixAPI: ZabbixAPI, args?: A, output?: string[]): Promise<T | ZabbixErrorResult> {
if (this.path !== "apiinfo.version") {
const version = await zabbixAPI.getVersion();
if (version < "6.2.0") {
if (this.path.startsWith("templategroup.")) {
this.path = this.path.replace("templategroup.", "hostgroup.");
this.method = this.path.split(".", 2).join(".");
this.requestBodyTemplate.method = this.method;
}
}
}
let prepareResult = await this.prepare(zabbixAPI, args);
if (prepareResult) {
return prepareResult;

View file

@ -17,9 +17,8 @@ import {
ZabbixGroupRightInput
} from "../schema/generated/graphql.js";
import {ZabbixAPI} from "./zabbix-api.js";
import {logger} from "../logging/logger.js";
import {ZabbixQueryTemplateGroupRequest, ZabbixQueryTemplateGroupResponse} from "./zabbix-templates.js";
import {GroupHelper, ZabbixQueryHostgroupsRequest, ZabbixQueryHostgroupsResult} from "./zabbix-hostgroups.js";
import {ZabbixQueryHostgroupsRequest, ZabbixQueryHostgroupsResult} from "./zabbix-hostgroups.js";
import {ApiErrorCode} from "../model/model_enum_values.js";
@ -78,30 +77,13 @@ export class ZabbixExportUserGroupsRequest extends ZabbixPrepareGetTemplatesAndH
...super.createZabbixParams(args),
output: "extend",
selectTemplateGroupRights: "extend",
selectHostGroupRights: "extend",
selectRights: "extend"
selectHostGroupRights: "extend"
};
}
async executeRequestReturnError(zabbixAPI: ZabbixAPI, args?: ZabbixExportUserGroupArgs): Promise<ZabbixErrorResult | UserGroup[]> {
const version = await zabbixAPI.getVersion();
let result = await super.executeRequestReturnError(zabbixAPI, args);
if (!isZabbixErrorResult(result)) {
if (version < "6.2.0") {
for (let userGroup of result) {
// @ts-ignore
if (userGroup.rights) {
// In 6.0, 'rights' contains both host and template group permissions
// @ts-ignore
userGroup.hostgroup_rights = userGroup.rights.map((r: any) => ({
id: r.id,
permission: r.permission
}));
userGroup.templategroup_rights = []; // Or duplicates?
}
}
}
for (let userGroup of result) {
for (let template_permission of userGroup.templategroup_rights || []) {
for (let templategroup of this.templategroups) {
@ -171,55 +153,19 @@ export class ZabbixImportUserGroupsRequest
let createGroupRequest = new ZabbixCreateOrUpdateRequest<
ZabbixCreateUserGroupResponse, ZabbixQueryUserGroupsRequest, ZabbixCreateOrUpdateParams>(
"usergroup", "usrgrpid", ZabbixQueryUserGroupsRequest, this.authToken, this.cookie);
const version = await zabbixAPI.getVersion();
const needsManualPropagation = version < "6.2.0";
for (let userGroup of args?.usergroups || []) {
let templategroup_rights = this.calc_templategroup_rights(userGroup);
let hostgroup_rights = this.calc_hostgroup_rights(userGroup);
if (needsManualPropagation && hostgroup_rights.hostgroup_rights.length > 0) {
const parentGroupids = hostgroup_rights.hostgroup_rights.map(r => r.id);
const subgroupids = await GroupHelper.findSubgroupIds(parentGroupids, zabbixAPI, this.authToken ?? undefined, this.cookie ?? undefined);
for (const sgid of subgroupids) {
if (!hostgroup_rights.hostgroup_rights.find(r => r.id === sgid)) {
// Find the permission of the parent group to apply it to the subgroup
// This is a simplification: if multiple parents match, we take one.
// Actually, we should find which parent this subgroup belongs to.
// But for simplicity, we can just look at the parent permissions.
// In most cases, it's just one parent being propagated.
const parent = hostgroup_rights.hostgroup_rights.find(r => parentGroupids.includes(r.id)); // just take first
if (parent) {
hostgroup_rights.hostgroup_rights.push({
id: sgid,
permission: parent.permission
});
}
}
}
}
let errors: ApiError[] = [];
let paramsObj: any = {
let params = new ZabbixCreateOrUpdateParams({
name: userGroup.name,
gui_access: userGroup.gui_access,
users_status: userGroup.users_status,
};
if (version < "6.2.0") {
paramsObj.rights = [
...hostgroup_rights.hostgroup_rights,
...templategroup_rights.templategroup_rights
];
} else {
paramsObj.hostgroup_rights = hostgroup_rights.hostgroup_rights;
paramsObj.templategroup_rights = templategroup_rights.templategroup_rights;
}
let params = new ZabbixCreateOrUpdateParams(paramsObj, args?.dryRun)
hostgroup_rights: hostgroup_rights.hostgroup_rights,
templategroup_rights: templategroup_rights.templategroup_rights,
}, args?.dryRun)
let result = await createGroupRequest.executeRequestReturnError(zabbixAPI, params);
if (isZabbixErrorResult(result)) {
@ -268,52 +214,34 @@ export class ZabbixImportUserGroupsRequest
for (let hostgroup_right of usergroup.hostgroup_rights || []) {
let success = false;
let matchedName = "";
let matchedId: number | undefined = undefined;
// Try matching by UUID first
for (let hostgroup of this.hostgroups) {
if (hostgroup.uuid && hostgroup_right.uuid && hostgroup.uuid === hostgroup_right.uuid) {
matchedId = Number(hostgroup.groupid);
matchedName = hostgroup.name;
success = true;
break;
}
}
// Fallback to matching by name (important for Zabbix 6.0 which lacks Host Group UUIDs)
if (!success && hostgroup_right.name) {
for (let hostgroup of this.hostgroups) {
if (hostgroup.name === hostgroup_right.name) {
matchedId = Number(hostgroup.groupid);
matchedName = hostgroup.name;
success = true;
break;
}
}
}
if (success) {
result.push(
{
id: matchedId!,
permission: hostgroup_right.permission,
}
)
if (hostgroup_right.name && hostgroup_right.name != matchedName) {
errors.push(
if (hostgroup.uuid == hostgroup_right.uuid) {
result.push(
{
code: ApiErrorCode.OK,
message: `WARNING: Hostgroup found and permissions set, but target name=${matchedName} does not match provided name=${hostgroup_right.name}`,
data: hostgroup_right,
id: Number(hostgroup.groupid),
permission: hostgroup_right.permission,
}
)
success = true;
matchedName = hostgroup.name;
break;
}
} else {
}
if (success && hostgroup_right.name && hostgroup_right.name != matchedName) {
errors.push(
{
code: ApiErrorCode.OK,
message: `WARNING: Hostgroup found and permissions set, but target name=${matchedName} does not match`,
data: hostgroup_right,
}
)
}
if (!success) {
errors.push(
{
code: ApiErrorCode.ZABBIX_HOSTGROUP_NOT_FOUND,
message: `Hostgroup with UUID ${hostgroup_right.uuid} ${hostgroup_right.name ? "or name " + hostgroup_right.name : ""} not found`,
message: `Hostgroup with UUID ${hostgroup_right.uuid} not found`,
data: hostgroup_right,
}
)
@ -334,52 +262,33 @@ export class ZabbixImportUserGroupsRequest
for (let templategroup_right of usergroup.templategroup_rights || []) {
let success = false;
let matchedName = "";
let matchedId: number | undefined = undefined;
// Try matching by UUID first
for (let templategroup of this.templategroups) {
if (templategroup.uuid && templategroup_right.uuid && templategroup.uuid === templategroup_right.uuid) {
matchedId = Number(templategroup.groupid);
matchedName = templategroup.name;
if (templategroup.uuid == templategroup_right.uuid) {
result.push(
{
id: Number(templategroup.groupid),
permission: templategroup_right.permission,
}
)
success = true;
matchedName = templategroup.name
break;
}
}
// Fallback to matching by name
if (!success && templategroup_right.name) {
for (let templategroup of this.templategroups) {
if (templategroup.name === templategroup_right.name) {
matchedId = Number(templategroup.groupid);
matchedName = templategroup.name;
success = true;
break;
}
}
}
if (success) {
result.push(
if (success && templategroup_right.name && templategroup_right.name != matchedName) {
errors.push(
{
id: matchedId!,
permission: templategroup_right.permission,
code: ApiErrorCode.OK,
message: `WARNING: Templategroup found and permissions set, but target name=${matchedName} does not match`,
data: templategroup_right,
}
)
if (templategroup_right.name && templategroup_right.name != matchedName) {
errors.push(
{
code: ApiErrorCode.OK,
message: `WARNING: Templategroup found and permissions set, but target name=${matchedName} does not match provided name=${templategroup_right.name}`,
data: templategroup_right,
}
)
}
} else {
}
if (!success) {
errors.push(
{
code: ApiErrorCode.ZABBIX_TEMPLATEGROUP_NOT_FOUND,
message: `Templategroup with UUID ${templategroup_right.uuid} ${templategroup_right.name ? "or name " + templategroup_right.name : ""} not found`,
message: `Templategroup with UUID ${templategroup_right.uuid} not found`,
data: templategroup_right,
}
)
@ -408,17 +317,6 @@ export class ZabbixPropagateHostGroupsRequest extends ZabbixRequest<ZabbixCreate
super("hostgroup.propagate", authToken, cookie);
}
async prepare(zabbixAPI: ZabbixAPI, args?: ZabbixPropagateHostGroupsParams): Promise<ZabbixCreateUserGroupResponse | ZabbixErrorResult | undefined> {
const version = await zabbixAPI.getVersion();
if (version < "6.2.0") {
logger.warn(`hostgroup.propagate is only supported in Zabbix 6.2.0 and newer. Current version is ${version}. Skipping propagation.`);
return {
usrgrpids: []
} as ZabbixCreateUserGroupResponse;
}
return super.prepare(zabbixAPI, args);
}
createZabbixParams(args?: ZabbixPropagateHostGroupsParams): ZabbixParams {
return {
groups: [...new Set(args?.groups || [])].map(value => {

View file

@ -23,19 +23,6 @@ export class RegressionTestExecutor {
const hostName = "REG_HOST_" + Math.random().toString(36).substring(7);
const groupName = "REG_GROUP_" + Math.random().toString(36).substring(7);
const regTemplateName = "REG_TEMP_" + Math.random().toString(36).substring(7);
const httpTempName = "REG_HTTP_" + Math.random().toString(36).substring(7);
const macroTemplateName = "REG_MACRO_TEMP_" + Math.random().toString(36).substring(7);
const macroHostName = "REG_MACRO_HOST_" + Math.random().toString(36).substring(7);
const metaTempName = "REG_META_TEMP_" + Math.random().toString(36).substring(7);
const metaHostName = "REG_META_HOST_" + Math.random().toString(36).substring(7);
const depTempName = "REG_DEP_TEMP_" + Math.random().toString(36).substring(7);
const stateTempName = "REG_STATE_TEMP_" + Math.random().toString(36).substring(7);
const stateHostName = "REG_STATE_HOST_" + Math.random().toString(36).substring(7);
const devHostNameWithTag = "REG_DEV_WITH_TAG_" + Math.random().toString(36).substring(7);
const devHostNameWithoutTag = "REG_DEV_WITHOUT_TAG_" + Math.random().toString(36).substring(7);
const pushHostName = "REG_PUSH_HOST_" + Math.random().toString(36).substring(7);
try {
// Regression 1: Locations query argument order
// This verifies the fix where getLocations was called with (authToken, args) instead of (args, authToken)
@ -57,6 +44,7 @@ export class RegressionTestExecutor {
// Regression 2: Template lookup by technical name
// Verifies that importHosts can link templates using their technical name (host)
const regTemplateName = "REG_TEMP_" + Math.random().toString(36).substring(7);
const regGroupName = "Templates/Roadwork/Devices";
const hostGroupName = "Roadwork/Devices";
@ -67,7 +55,7 @@ export class RegressionTestExecutor {
const tempResult = await TemplateImporter.importTemplates([{
host: regTemplateName,
name: "Regression Test Template " + regTemplateName,
name: "Regression Test Template",
groupNames: [regGroupName]
}], zabbixAuthToken, cookie);
@ -81,9 +69,10 @@ export class RegressionTestExecutor {
// Regression 3: HTTP Agent URL support
// Verifies that templates with HTTP Agent items (including URL) can be imported
const httpTempName = "REG_HTTP_" + Math.random().toString(36).substring(7);
const httpTempResult = await TemplateImporter.importTemplates([{
host: httpTempName,
name: "Regression HTTP Template " + httpTempName,
name: "Regression HTTP Template",
groupNames: [regGroupName],
items: [{
name: "HTTP Master",
@ -105,9 +94,12 @@ export class RegressionTestExecutor {
if (!httpSuccess) success = false;
// Regression 4: User Macro assignment for host and template creation
const macroTemplateName = "REG_MACRO_TEMP_" + Math.random().toString(36).substring(7);
const macroHostName = "REG_MACRO_HOST_" + Math.random().toString(36).substring(7);
const macroTempResult = await TemplateImporter.importTemplates([{
host: macroTemplateName,
name: "Regression Macro Template " + macroTemplateName,
name: "Regression Macro Template",
groupNames: [regGroupName],
macros: [
{ macro: "{$TEMP_MACRO}", value: "temp_value" }
@ -221,9 +213,12 @@ export class RegressionTestExecutor {
}
// Regression 6: Item Metadata (preprocessing, units, description, error)
const metaTempName = "REG_META_TEMP_" + Math.random().toString(36).substring(7);
const metaHostName = "REG_META_HOST_" + Math.random().toString(36).substring(7);
const metaTempResult = await TemplateImporter.importTemplates([{
host: metaTempName,
name: "Regression Meta Template " + metaTempName,
name: "Regression Meta Template",
groupNames: [regGroupName],
items: [{
name: "Meta Item",
@ -314,17 +309,19 @@ export class RegressionTestExecutor {
// 3. Test indirect dependencies: state implies items
const testParams3 = optRequest.createZabbixParams(new ParsedArgs({}), ["hostid", "state"]);
const hasSelectItems3 = "selectItems" in testParams3;
const hasOutput3 = Array.isArray(testParams3.output) && testParams3.output.includes("hostid") && testParams3.output.includes("items");
optSuccess = optSuccess && hasSelectItems3;
optSuccess = optSuccess && hasSelectItems3 && hasOutput3;
// 4. Test indirect dependencies: deviceType implies tags
const testParams4 = optRequest.createZabbixParams(new ParsedArgs({}), ["hostid", "deviceType"]);
const hasSelectTags4 = "selectTags" in testParams4;
const hasOutput4 = Array.isArray(testParams4.output) && testParams4.output.includes("hostid");
optSuccess = optSuccess && hasSelectTags4;
optSuccess = optSuccess && hasSelectTags4 && hasOutput4;
if (!optSuccess) {
logger.error(`REG-OPT: Optimization verification failed. hasSelectItems1: ${hasSelectItems1}, hasOutput1: ${hasOutput1}, hasSelectItems2: ${hasSelectItems2}, hasSelectTags2: ${hasSelectTags2}, hasSelectItems3: ${hasSelectItems3}, hasSelectTags4: ${hasSelectTags4}`);
logger.error(`REG-OPT: Optimization verification failed. hasSelectItems1: ${hasSelectItems1}, hasOutput1: ${hasOutput1}, hasSelectItems2: ${hasSelectItems2}, hasSelectTags2: ${hasSelectTags2}, hasSelectItems3: ${hasSelectItems3}, hasOutput3: ${hasOutput3}, hasSelectTags4: ${hasSelectTags4}, hasOutput4: ${hasOutput4}`);
}
} catch (error) {
logger.error(`REG-OPT: Error during optimization test: ${error}`);
@ -360,9 +357,10 @@ export class RegressionTestExecutor {
if (!emptySuccess) success = false;
// Regression 9: Dependent Items in Templates
const depTempName = "REG_DEP_TEMP_" + Math.random().toString(36).substring(7);
const depTempResult = await TemplateImporter.importTemplates([{
host: depTempName,
name: "Regression Dependent Template " + depTempName,
name: "Regression Dependent Template",
groupNames: [regGroupName],
items: [
{
@ -392,9 +390,12 @@ export class RegressionTestExecutor {
if (!depSuccess) success = false;
// Regression 10: State sub-properties retrieval (Optimization indirect dependency)
const stateTempName = "REG_STATE_TEMP_" + Math.random().toString(36).substring(7);
const stateHostName = "REG_STATE_HOST_" + Math.random().toString(36).substring(7);
const stateTempResult = await TemplateImporter.importTemplates([{
host: stateTempName,
name: "Regression State Template " + stateTempName,
name: "Regression State Template",
groupNames: [regGroupName],
tags: [{ tag: "deviceType", value: "GenericDevice" }],
items: [{
@ -481,6 +482,9 @@ export class RegressionTestExecutor {
// Regression 12: allDevices deviceType filter
// Verifies that allDevices only returns hosts with a deviceType tag
const devHostNameWithTag = "REG_DEV_WITH_TAG_" + Math.random().toString(36).substring(7);
const devHostNameWithoutTag = "REG_DEV_WITHOUT_TAG_" + Math.random().toString(36).substring(7);
// Get groupid for hostGroupName
const groupQuery: any = await new ZabbixRequest("hostgroup.get", zabbixAuthToken, cookie)
.executeRequestReturnError(zabbixAPI, new ParsedArgs({ filter_name: hostGroupName }));
@ -528,59 +532,48 @@ export class RegressionTestExecutor {
}
// Regression 13: pushHistory mutation
let pushSuccess = false;
const version = await zabbixAPI.getVersion();
const pushHostName = "REG_PUSH_HOST_" + Math.random().toString(36).substring(7);
const pushItemKey = "trap.json";
if (version < "7.0.0") {
logger.info(`REG-PUSH: Skipping pushHistory test as it is not supported on Zabbix version ${version}`);
pushSuccess = true; // Mark as success for old versions to allow overall test success
} else {
const pushItemKey = "trap.json";
// Create host
const pushHostResult = await HostImporter.importHosts([{
deviceKey: pushHostName,
deviceType: "RegressionHost",
groupNames: [hostGroupName],
templateNames: []
}], zabbixAuthToken, cookie);
let pushSuccess = false;
if (pushHostResult?.length && pushHostResult[0].hostid) {
const pushHostId = pushHostResult[0].hostid;
// Create host
const pushHostResult = await HostImporter.importHosts([{
deviceKey: pushHostName,
deviceType: "RegressionHost",
groupNames: [hostGroupName],
templateNames: []
}], zabbixAuthToken, cookie);
// Add trapper item to host
const pushItemResult = await new ZabbixRequest("item.create", zabbixAuthToken, cookie).executeRequestReturnError(zabbixAPI, new ParsedArgs({
name: "Trapper JSON Item",
key_: pushItemKey,
hostid: pushHostId,
type: 2, // Zabbix trapper
value_type: 4, // Text
history: "1d"
}));
if (pushHostResult?.length && pushHostResult[0].hostid) {
const pushHostId = pushHostResult[0].hostid;
if (!isZabbixErrorResult(pushItemResult)) {
// Push data
const pushRequest = new ZabbixHistoryPushRequest(zabbixAuthToken, cookie);
const pushParams = new ZabbixHistoryPushParams(
[{ timestamp: new Date().toISOString(), value: { hello: "world" } }],
undefined, pushItemKey, pushHostName
);
// Add trapper item to host
const pushItemResult = await new ZabbixRequest("item.create", zabbixAuthToken, cookie).executeRequestReturnError(zabbixAPI, new ParsedArgs({
name: "Trapper JSON Item",
key_: pushItemKey,
hostid: pushHostId,
type: 2, // Zabbix trapper
value_type: 4, // Text
history: "1d"
}));
if (!isZabbixErrorResult(pushItemResult)) {
// Push data
const pushRequest = new ZabbixHistoryPushRequest(zabbixAuthToken, cookie);
const pushParams = new ZabbixHistoryPushParams(
[{ timestamp: new Date().toISOString(), value: { hello: "world" } }],
undefined, pushItemKey, pushHostName
);
const pushDataResult = await pushRequest.executeRequestReturnError(zabbixAPI, pushParams);
pushSuccess = !isZabbixErrorResult(pushDataResult) && pushDataResult.response === "success";
}
// Cleanup push host
await HostDeleter.deleteHosts([Number(pushHostId)], null, zabbixAuthToken, cookie);
const pushDataResult = await pushRequest.executeRequestReturnError(zabbixAPI, pushParams);
pushSuccess = !isZabbixErrorResult(pushDataResult) && pushDataResult.response === "success";
}
}
steps.push({
name: "REG-PUSH: pushHistory mutation",
success: pushSuccess,
message: version < "7.0.0"
? `Skipped (not supported on ${version})`
: (pushSuccess ? "Successfully pushed history data to trapper item" : "Failed to push history data")
message: pushSuccess ? "Successfully pushed history data to trapper item" : "Failed to push history data"
});
if (!pushSuccess) success = false;
@ -620,22 +613,6 @@ export class RegressionTestExecutor {
success: false,
message: error.message || String(error)
});
} finally {
// Cleanup
await HostDeleter.deleteHosts(null, hostName, zabbixAuthToken, cookie);
await HostDeleter.deleteHosts(null, macroHostName, zabbixAuthToken, cookie);
await HostDeleter.deleteHosts(null, metaHostName, zabbixAuthToken, cookie);
await HostDeleter.deleteHosts(null, devHostNameWithTag, zabbixAuthToken, cookie);
await HostDeleter.deleteHosts(null, devHostNameWithoutTag, zabbixAuthToken, cookie);
await HostDeleter.deleteHosts(null, pushHostName, zabbixAuthToken, cookie);
await TemplateDeleter.deleteTemplates(null, regTemplateName, zabbixAuthToken, cookie);
await TemplateDeleter.deleteTemplates(null, httpTempName, zabbixAuthToken, cookie);
await TemplateDeleter.deleteTemplates(null, macroTemplateName, zabbixAuthToken, cookie);
await TemplateDeleter.deleteTemplates(null, metaTempName, zabbixAuthToken, cookie);
await TemplateDeleter.deleteTemplates(null, depTempName, zabbixAuthToken, cookie);
await TemplateDeleter.deleteTemplates(null, stateTempName, zabbixAuthToken, cookie);
await HostDeleter.deleteHosts(null, stateHostName, zabbixAuthToken, cookie);
// We don't delete the group here as it might be shared or used by other tests in this run
}
return {

View file

@ -66,11 +66,11 @@ export class SmoketestExecutor {
templateNames: [templateName]
}], zabbixAuthToken, cookie);
const hostSuccess = !!hostResult?.length && !hostResult[0].error && !!hostResult[0].hostid;
const hostSuccess = !!hostResult?.length && !hostResult[0].error;
steps.push({
name: "Create and Link Host",
success: hostSuccess,
message: hostSuccess ? `Host ${hostName} created and linked to ${templateName}` : `Failed: ${hostResult?.[0]?.error?.message || hostResult?.[0]?.message || "Unknown error"}`
message: hostSuccess ? `Host ${hostName} created and linked to ${templateName}` : `Failed: ${hostResult?.[0]?.error?.message || "Unknown error"}`
});
if (!hostSuccess) success = false;
} else {

View file

@ -6,7 +6,6 @@ import {GraphQLError} from "graphql";
jest.mock("../datasources/zabbix-api.js", () => ({
zabbixAPI: {
post: jest.fn(),
getVersion: jest.fn().mockResolvedValue("7.0.0")
}
}));
@ -64,12 +63,4 @@ describe("ZabbixHistoryPushRequest", () => {
await expect(request.prepare(zabbixAPI, params)).rejects.toThrow("if itemid is empty both key and host must be filled");
});
test("prepare - throw error if Zabbix version < 7.0.0", async () => {
(zabbixAPI.getVersion as jest.Mock).mockResolvedValue("6.0.0");
const values = [{ timestamp: "2024-01-01T10:00:00Z", value: "val" }];
const params = new ZabbixHistoryPushParams(values, "1");
await expect(request.prepare(zabbixAPI, params)).rejects.toThrow("history.push is only supported in Zabbix 7.0.0 and newer");
});
});

View file

@ -6,7 +6,6 @@ import {zabbixAPI} from '../datasources/zabbix-api.js';
jest.mock("../datasources/zabbix-api.js", () => ({
zabbixAPI: {
post: jest.fn(),
getVersion: jest.fn().mockResolvedValue("7.0.0"),
}
}));

View file

@ -6,7 +6,6 @@ jest.mock("../datasources/zabbix-api.js", () => ({
zabbixAPI: {
executeRequest: jest.fn(),
post: jest.fn(),
getVersion: jest.fn().mockResolvedValue("7.0.0"),
requestByPath: jest.fn()
},
ZABBIX_EDGE_DEVICE_BASE_GROUP: "Roadwork"

View file

@ -8,7 +8,6 @@ import {ZABBIX_EDGE_DEVICE_BASE_GROUP, zabbixAPI} from '../datasources/zabbix-ap
jest.mock("../datasources/zabbix-api.js", () => ({
zabbixAPI: {
post: jest.fn(),
getVersion: jest.fn().mockResolvedValue("7.0.0"),
executeRequest: jest.fn(),
baseURL: 'http://localhost/zabbix',
getLocations: jest.fn(),

View file

@ -7,7 +7,6 @@ jest.mock("../datasources/zabbix-api.js", () => ({
zabbixAPI: {
executeRequest: jest.fn(),
post: jest.fn(),
getVersion: jest.fn().mockResolvedValue("7.0.0"),
baseURL: "http://mock-zabbix",
getLocations: jest.fn()
},

View file

@ -7,7 +7,6 @@ jest.mock("../datasources/zabbix-api.js", () => ({
zabbixAPI: {
executeRequest: jest.fn(),
post: jest.fn(),
getVersion: jest.fn().mockResolvedValue("7.0.0"),
baseURL: "http://mock-zabbix",
}
}));

View file

@ -5,8 +5,7 @@ import {zabbixAPI} from "../datasources/zabbix-api.js";
jest.mock("../datasources/zabbix-api.js", () => ({
zabbixAPI: {
executeRequest: jest.fn(),
post: jest.fn(),
getVersion: jest.fn().mockResolvedValue("7.0.0"),
post: jest.fn()
}
}));

View file

@ -7,7 +7,6 @@ jest.mock("../datasources/zabbix-api.js", () => ({
zabbixAPI: {
executeRequest: jest.fn(),
post: jest.fn(),
getVersion: jest.fn().mockResolvedValue("7.0.0"),
baseURL: "http://mock-zabbix",
}
}));
@ -91,7 +90,7 @@ describe("Query Optimization", () => {
expect(zabbixAPI.post).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
body: expect.objectContaining({
params: expect.objectContaining({
output: expect.arrayContaining(["hostid", "tags"]),
output: ["hostid"],
selectTags: expect.any(Array)
})
})
@ -188,7 +187,7 @@ describe("Query Optimization", () => {
expect(zabbixAPI.post).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
body: expect.objectContaining({
params: expect.objectContaining({
output: expect.arrayContaining(["hostid", "tags"]),
output: ["hostid"],
selectTags: expect.any(Array)
})
})

View file

@ -5,8 +5,7 @@ import {zabbixAPI} from "../datasources/zabbix-api.js";
jest.mock("../datasources/zabbix-api.js", () => ({
zabbixAPI: {
executeRequest: jest.fn(),
post: jest.fn(),
getVersion: jest.fn().mockResolvedValue("7.0.0"),
post: jest.fn()
}
}));

View file

@ -5,8 +5,7 @@ import {zabbixAPI} from "../datasources/zabbix-api.js";
jest.mock("../datasources/zabbix-api.js", () => ({
zabbixAPI: {
executeRequest: jest.fn(),
post: jest.fn(),
getVersion: jest.fn().mockResolvedValue("7.0.0"),
post: jest.fn()
}
}));

View file

@ -8,7 +8,6 @@ import {zabbixAPI} from '../datasources/zabbix-api.js';
jest.mock("../datasources/zabbix-api.js", () => ({
zabbixAPI: {
post: jest.fn(),
getVersion: jest.fn().mockResolvedValue("7.0.0"),
executeRequest: jest.fn(),
baseURL: 'http://localhost/zabbix'
}

View file

@ -6,7 +6,6 @@ import {zabbixAPI} from '../datasources/zabbix-api.js';
jest.mock("../datasources/zabbix-api.js", () => ({
zabbixAPI: {
post: jest.fn(),
getVersion: jest.fn().mockResolvedValue("7.0.0"),
executeRequest: jest.fn(),
baseURL: 'http://localhost/zabbix',
requestByPath: jest.fn()

View file

@ -7,7 +7,6 @@ jest.mock("../datasources/zabbix-api.js", () => ({
zabbixAPI: {
executeRequest: jest.fn(),
post: jest.fn(),
getVersion: jest.fn().mockResolvedValue("7.0.0"),
baseURL: "http://mock-zabbix"
},
ZABBIX_EDGE_DEVICE_BASE_GROUP: "Roadwork"
@ -83,11 +82,8 @@ describe("Template Resolver", () => {
method: "template.get",
params: expect.objectContaining({
search: {
name: "Template",
host: "Template"
},
searchByAny: true,
searchWildcardsEnabled: true
name: "Template"
}
})
})
}));
@ -109,11 +105,8 @@ describe("Template Resolver", () => {
method: "template.get",
params: expect.objectContaining({
search: {
name: "Temp%1",
host: "Temp%1"
},
searchByAny: true,
searchWildcardsEnabled: true
name: "Temp%1"
}
})
})
}));

View file

@ -6,7 +6,6 @@ jest.mock("../datasources/zabbix-api.js", () => ({
zabbixAPI: {
executeRequest: jest.fn(),
post: jest.fn(),
getVersion: jest.fn().mockResolvedValue("7.0.0"),
baseURL: "http://mock-zabbix"
}
}));

View file

@ -8,7 +8,6 @@ import {zabbixAPI} from '../datasources/zabbix-api.js';
jest.mock("../datasources/zabbix-api.js", () => ({
zabbixAPI: {
post: jest.fn(),
getVersion: jest.fn().mockResolvedValue("7.0.0"),
executeRequest: jest.fn(),
baseURL: 'http://localhost/zabbix',
getLocations: jest.fn(),
@ -44,8 +43,7 @@ describe("User Rights Integration Tests", () => {
.mockResolvedValueOnce([{ groupid: "101", name: "Group1", uuid: "uuid1" }]) // templategroup.get for groups (in prepare)
.mockResolvedValueOnce([{ groupid: "201", name: "ConstructionSite/Test", uuid: "uuid2" }]) // hostgroup.get for groups (in prepare)
.mockResolvedValueOnce([{ usrgrpid: "1", name: "Test Group" }]) // usergroup.get
.mockResolvedValueOnce({ usrgrpids: ["1"] }) // usergroup.update
.mockResolvedValueOnce({ usrgrpids: [] }); // hostgroup.propagate
.mockResolvedValueOnce({ usrgrpids: ["1"] }); // usergroup.update
const response = await server.executeOperation({
query: mutation,

View file

@ -1,106 +0,0 @@
import {createResolvers} from "../api/resolvers.js";
import {zabbixAPI} from "../datasources/zabbix-api.js";
import {Permission} from "../schema/generated/graphql.js";
// Mocking ZabbixAPI
jest.mock("../datasources/zabbix-api.js", () => ({
zabbixAPI: {
executeRequest: jest.fn(),
post: jest.fn(),
getVersion: jest.fn(),
baseURL: "http://mock-zabbix"
}
}));
describe("Zabbix 6.0 Compatibility", () => {
let resolvers: any;
beforeEach(() => {
jest.clearAllMocks();
resolvers = createResolvers();
(zabbixAPI.getVersion as jest.Mock).mockResolvedValue("6.0.0");
});
test("importUserRights uses name-based fallback for host groups on Zabbix 6.0", async () => {
// Mock Zabbix 6.0 behavior where hostgroup.get does NOT return UUID
(zabbixAPI.post as jest.Mock).mockImplementation((path: string) => {
if (path === "templategroup.get") return Promise.resolve([{ groupid: "101", name: "TemplateGroup1", uuid: "uuid-tg-1" }]);
if (path === "hostgroup.get") return Promise.resolve([{ groupid: "201", name: "HostGroup1" }]); // NO UUID
if (path === "usergroup.get") return Promise.resolve([]);
if (path.startsWith("usergroup.create")) return Promise.resolve({ usrgrpids: ["301"] });
if (path === "module.get") return Promise.resolve([]);
if (path === "role.get") return Promise.resolve([]);
return Promise.resolve([]);
});
const args = {
input: {
userGroups: [{
name: "NewGroup",
hostgroup_rights: [{
name: "HostGroup1",
uuid: "some-uuid-from-export", // This UUID won't match anything in 6.0 mock
permission: Permission.Read
}]
}]
},
dryRun: false
};
const context = { zabbixAuthToken: "test-token" };
const result = await resolvers.Mutation.importUserRights(null, args, context);
expect(result.userGroups).toHaveLength(1);
expect(result.userGroups[0].name).toBe("NewGroup");
expect(result.userGroups[0].errors).toHaveLength(0); // Should succeed via name match
// Verify that usergroup.create was called with correct groupid from name match
const createCall = (zabbixAPI.post as jest.Mock).mock.calls.find(call => call[0].startsWith("usergroup.create"));
expect(createCall[1].body.params.rights).toContainEqual({
id: 201,
permission: Permission.Read
});
});
test("importUserRights performs manual expansion on Zabbix 6.0", async () => {
// Mock Zabbix 6.0 behavior
(zabbixAPI.post as jest.Mock).mockImplementation((path: string, options: any) => {
if (path === "templategroup.get") return Promise.resolve([]);
if (path === "hostgroup.get") {
// If searching for subgroups
if (options?.body?.params?.search?.name === "Parent/*") {
return Promise.resolve([{ groupid: "202", name: "Parent/Child" }]);
}
return Promise.resolve([{ groupid: "201", name: "Parent" }]);
}
if (path === "usergroup.get") return Promise.resolve([]);
if (path.startsWith("usergroup.create")) return Promise.resolve({ usrgrpids: ["301"] });
if (path === "module.get") return Promise.resolve([]);
if (path === "role.get") return Promise.resolve([]);
return Promise.resolve([]);
});
const args = {
input: {
userGroups: [{
name: "NewGroup",
hostgroup_rights: [{
name: "Parent",
uuid: "uuid-parent",
permission: Permission.Read
}]
}]
},
dryRun: false
};
const context = { zabbixAuthToken: "test-token" };
await resolvers.Mutation.importUserRights(null, args, context);
// Verify that usergroup.create was called with both parent and expanded child
const createCall = (zabbixAPI.post as jest.Mock).mock.calls.find(call => call[0].startsWith("usergroup.create"));
expect(createCall[1].body.params.rights).toHaveLength(2);
expect(createCall[1].body.params.rights).toContainEqual({ id: 201, permission: Permission.Read });
expect(createCall[1].body.params.rights).toContainEqual({ id: 202, permission: Permission.Read });
});
});

View file

@ -8,7 +8,6 @@ import {zabbixAPI} from '../datasources/zabbix-api.js';
jest.mock("../datasources/zabbix-api.js", () => ({
zabbixAPI: {
post: jest.fn(),
getVersion: jest.fn().mockResolvedValue("7.0.0"),
executeRequest: jest.fn(),
baseURL: 'http://localhost/zabbix',
getLocations: jest.fn(),