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 # dotenv environment variable files
.env .env
.env.* .env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/) # parcel-bundler cache (https://parceljs.org/)
.cache .cache

121
.idea/workspace.xml generated
View file

@ -5,21 +5,30 @@
</component> </component>
<component name="ChangeListManager"> <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."> <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$/.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$/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-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-items.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/datasources/zabbix-items.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/datasources/zabbix-request.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/datasources/zabbix-request.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/execution/host_exporter.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/execution/host_exporter.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/execution/regression_test_executor.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/execution/regression_test_executor.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/schema/generated/graphql.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/schema/generated/graphql.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/testdata/templates/zbx_default_templates_vcr.yaml" beforeDir="false" afterPath="$PROJECT_DIR$/src/testdata/templates/zbx_default_templates_vcr.yaml" 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" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@ -30,7 +39,7 @@
<execution /> <execution />
</component> </component>
<component name="EmbeddingIndexingInfo"> <component name="EmbeddingIndexingInfo">
<option name="cachedIndexableFilesCount" value="168" /> <option name="cachedIndexableFilesCount" value="169" />
<option name="fileBasedEmbeddingIndicesEnabled" value="true" /> <option name="fileBasedEmbeddingIndicesEnabled" value="true" />
</component> </component>
<component name="Git.Settings"> <component name="Git.Settings">
@ -78,50 +87,45 @@
<option name="openDirectoriesWithSingleClick" value="true" /> <option name="openDirectoriesWithSingleClick" value="true" />
<option name="showLibraryContents" value="true" /> <option name="showLibraryContents" value="true" />
</component> </component>
<component name="PropertiesComponent">{ <component name="PropertiesComponent"><![CDATA[{
&quot;keyToString&quot;: { "keyToString": {
&quot;NIXITCH_NIXPKGS_CONFIG&quot;: &quot;&quot;, "NIXITCH_NIXPKGS_CONFIG": "",
&quot;NIXITCH_NIX_CONF_DIR&quot;: &quot;&quot;, "NIXITCH_NIX_CONF_DIR": "",
&quot;NIXITCH_NIX_OTHER_STORES&quot;: &quot;&quot;, "NIXITCH_NIX_OTHER_STORES": "",
&quot;NIXITCH_NIX_PATH&quot;: &quot;&quot;, "NIXITCH_NIX_PATH": "",
&quot;NIXITCH_NIX_PROFILES&quot;: &quot;&quot;, "NIXITCH_NIX_PROFILES": "",
&quot;NIXITCH_NIX_REMOTE&quot;: &quot;&quot;, "NIXITCH_NIX_REMOTE": "",
&quot;NIXITCH_NIX_USER_PROFILE_DIR&quot;: &quot;&quot;, "NIXITCH_NIX_USER_PROFILE_DIR": "",
&quot;Node.js.index.ts.executor&quot;: &quot;Run&quot;, "Node.js.index.ts.executor": "Run",
&quot;RunOnceActivity.MCP Project settings loaded&quot;: &quot;true&quot;, "RunOnceActivity.MCP Project settings loaded": "true",
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;, "RunOnceActivity.ShowReadmeOnStart": "true",
&quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;, "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;, "RunOnceActivity.git.unshallow": "true",
&quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;, "RunOnceActivity.typescript.service.memoryLimit.init": "true",
&quot;com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultModelSelectionForGA.v1&quot;: &quot;true&quot;, "com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultModelSelectionForGA.v1": "true",
&quot;git-widget-placeholder&quot;: &quot;main&quot;, "git-widget-placeholder": "main",
&quot;go.import.settings.migrated&quot;: &quot;true&quot;, "go.import.settings.migrated": "true",
&quot;javascript.preferred.runtime.type.id&quot;: &quot;node&quot;, "javascript.preferred.runtime.type.id": "node",
&quot;junie.onboarding.icon.badge.shown&quot;: &quot;true&quot;, "junie.onboarding.icon.badge.shown": "true",
&quot;last_opened_file_path&quot;: &quot;//wsl.localhost/Ubuntu/home/ahilbig/git/vcr/zabbix-graphql-api/docs/use-cases&quot;, "last_opened_file_path": "//wsl.localhost/Ubuntu/home/ahilbig/git/vcr/zabbix-graphql-api/docs/use-cases",
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;, "node.js.detected.package.eslint": "true",
&quot;node.js.detected.package.tslint&quot;: &quot;true&quot;, "node.js.detected.package.tslint": "true",
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;, "node.js.selected.package.eslint": "(autodetect)",
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;, "node.js.selected.package.tslint": "(autodetect)",
&quot;nodejs_interpreter_path&quot;: &quot;wsl://Ubuntu@/home/ahilbig/.nvm/versions/node/v24.12.0/bin/node&quot;, "nodejs_interpreter_path": "wsl://Ubuntu@/home/ahilbig/.nvm/versions/node/v24.12.0/bin/node",
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;, "nodejs_package_manager_path": "npm",
&quot;npm.codegen.executor&quot;: &quot;Run&quot;, "npm.codegen.executor": "Run",
&quot;npm.compile.executor&quot;: &quot;Run&quot;, "npm.compile.executor": "Run",
&quot;npm.copy-schema.executor&quot;: &quot;Run&quot;, "npm.copy-schema.executor": "Run",
&quot;npm.prod.executor&quot;: &quot;Run&quot;, "npm.prod.executor": "Run",
&quot;npm.test.executor&quot;: &quot;Run&quot;, "npm.test.executor": "Run",
&quot;settings.editor.selected.configurable&quot;: &quot;junie.mcp&quot;, "settings.editor.selected.configurable": "junie.mcp",
&quot;settings.editor.splitter.proportion&quot;: &quot;0.23751687&quot;, "settings.editor.splitter.proportion": "0.23751687",
&quot;to.speed.mode.migration.done&quot;: &quot;true&quot;, "to.speed.mode.migration.done": "true",
&quot;ts.external.directory.path&quot;: &quot;\\\\wsl.localhost\\Ubuntu\\home\\ahilbig\\git\\vcr\\zabbix-graphql-api\\node_modules\\typescript\\lib&quot;, "ts.external.directory.path": "\\\\wsl.localhost\\Ubuntu\\home\\ahilbig\\git\\vcr\\zabbix-graphql-api\\node_modules\\typescript\\lib",
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot; "vue.rearranger.settings.migration": "true"
},
&quot;keyToStringList&quot;: {
&quot;com.intellij.ide.scratch.ScratchImplUtil$2/New Scratch File&quot;: [
&quot;TEXT&quot;
]
} }
}</component> }]]></component>
<component name="RecapSpentCounter"> <component name="RecapSpentCounter">
<option name="endsOfQuotaMs" value="1772398800000" /> <option name="endsOfQuotaMs" value="1772398800000" />
<option name="spentUsd" value="0.0915201" /> <option name="spentUsd" value="0.0915201" />
@ -227,10 +231,7 @@
<workItem from="1769789496322" duration="14281000" /> <workItem from="1769789496322" duration="14281000" />
<workItem from="1769849767328" duration="18404000" /> <workItem from="1769849767328" duration="18404000" />
<workItem from="1769955114366" duration="3276000" /> <workItem from="1769955114366" duration="3276000" />
<workItem from="1770107035156" duration="4817000" /> <workItem from="1770107035156" duration="3830000" />
<workItem from="1770129804879" duration="13000" />
<workItem from="1770129846593" duration="5283000" />
<workItem from="1770167580486" duration="1386000" />
</task> </task>
<task id="LOCAL-00001" summary="chore: Update IntelliJ workspace settings and add GitHub Actions workflow for Docker deployment"> <task id="LOCAL-00001" summary="chore: Update IntelliJ workspace settings and add GitHub Actions workflow for Docker deployment">
<option name="closed" value="true" /> <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. - **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`). - **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 Super Admin Token** (for full functionality / privilege escalation).
- **Zabbix User Access** (groups and roles depending on your use case). - **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 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/`. The API will be available at `http://localhost:4000/`.
## ⚙️ Configuration ## ⚙️ Configuration
@ -250,20 +236,7 @@ The API version is automatically set during the Docker build process based on th
### 🔧 Zabbix Version Compatibility ### 🔧 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: 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.
- **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.
## 🛠️ Technical Maintenance ## 🛠️ Technical Maintenance

View file

@ -43,50 +43,5 @@ services:
- mcp-shared:/mcp-data - mcp-shared:/mcp-data
command: sh -c "cat /schema/*.graphql > /mcp-data/schema.graphql" 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: volumes:
mcp-shared: 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) ### 🛠️ [Technical Maintenance](./maintenance.md)
Guide on code generation (GraphQL Codegen), running Jest tests, and local Docker builds. 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) ### 🧪 [Test Specification](../tests.md)
Detailed list of test cases, categories (Unit, Integration, E2E), and coverage checklist. 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 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 #### Adding New Tests
- **Location**: Place new test files in `src/test/` with the `.test.ts` extension. - **Location**: Place new test files in `src/test/` with the `.test.ts` extension.
- **Coverage**: Ensure you cover both successful operations and error scenarios. - **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**. - **🎯 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**. - *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. - **🔓 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. - *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 ## 📅 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. - **🏗️ 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). - *Analysis*: Analysis of "Trade Fair Logistics" and derived [requirements document](docs/use-cases/trade-fair-logistics-requirements.md).
- *Simulation*: - *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, zabbixAuthToken,
cookie, dataSources cookie, dataSources
}: any, info: any) => { }: any, info: any) => {
let params: any = {}
if (args.hostids) { if (args.hostids) {
// @ts-ignore params.templateids = args.hostids
args.templateids = args.hostids }
delete args.hostids if (args.name_pattern) {
params.search = {
name: args.name_pattern
}
} }
const output = GraphqlParamsToNeededZabbixOutput.mapTemplates(info); const output = GraphqlParamsToNeededZabbixOutput.mapTemplates(info);
return await new ZabbixQueryTemplatesRequest(zabbixAuthToken, cookie) 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, { allTemplateGroups: async (_parent: any, args: any, {
zabbixAuthToken, zabbixAuthToken,
cookie cookie
}: any) => { }: any) => {
let params: any = {}
if (args.name_pattern) {
params.search = {
name: args.name_pattern
}
}
return await new ZabbixQueryTemplateGroupRequest(zabbixAuthToken, cookie) return await new ZabbixQueryTemplateGroupRequest(zabbixAuthToken, cookie)
.executeRequestThrowError(zabbixAPI, new ParsedArgs(args)); .executeRequestThrowError(zabbixAPI, new ParsedArgs(params));
} }
}, },
Mutation: { Mutation: {

View file

@ -80,20 +80,6 @@ export class ZabbixAPI
return super.post(path, request); 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> { 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); 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> { async prepare(zabbixAPI: ZabbixAPI, args?: ZabbixHistoryPushParams): Promise<ZabbixHistoryPushResult | ZabbixErrorResult | undefined> {
if (!args) return 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)) { if (!args.itemid && (!args.key || !args.host)) {
throw new GraphQLError("if itemid is empty both key and host must be filled"); 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 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"]); 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 { createZabbixParams(args?: A, output?: string[]): ZabbixParams {
const params: any = { return this.optimizeZabbixParams({
...super.createZabbixParams(args), ...super.createZabbixParams(args),
selectParentTemplates: [ selectParentTemplates: [
"templateid", "templateid",
@ -60,15 +45,11 @@ export class ZabbixQueryHostsGenericRequest<T extends ZabbixResult, A extends Pa
"hostid", "hostid",
"host", "host",
"name", "name",
"hostgroups",
"description", "description",
"parentTemplates"
] ]
}; }, output);
// 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);
} }
} }
@ -121,7 +102,10 @@ export class ZabbixQueryHostsGenericRequestWithItems<T extends ZabbixResult, A e
"hostid", "hostid",
"host", "host",
"name", "name",
"hostgroups",
"items",
"description", "description",
"parentTemplates"
], ],
}, output); }, 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 {ZabbixAPI} from "./zabbix-api.js";
import {InputMaybe, Permission, QueryHasPermissionsArgs, UserPermission} from "../schema/generated/graphql.js"; import {InputMaybe, Permission, QueryHasPermissionsArgs, UserPermission} from "../schema/generated/graphql.js";
import {ApiErrorCode, PermissionNumber} from "../model/model_enum_values.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; return this.prepResult;
} }
async assureUserPermissions(zabbixAPI: ZabbixAPI) { 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 && if (this.permissionsNeeded &&
!await ZabbixPermissionsHelper.hasUserPermissions(zabbixAPI, this.permissionsNeeded, this.authToken, this.cookie)) { !await ZabbixPermissionsHelper.hasUserPermissions(zabbixAPI, this.permissionsNeeded, this.authToken, this.cookie)) {
return { return {
@ -79,31 +75,7 @@ class ZabbixQueryUserGroupPermissionsRequest extends ZabbixRequest<ZabbixUserGro
super("usergroup.get.permissions", authToken, cookie); super("usergroup.get.permissions", authToken, cookie);
} }
async executeRequestReturnError(zabbixAPI: ZabbixAPI, args?: ParsedArgs, output?: string[]): Promise<ZabbixUserGroupResponse[] | ZabbixErrorResult> { createZabbixParams(args?: ParsedArgs) {
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.
return { return {
...super.createZabbixParams(args), ...super.createZabbixParams(args),
"output": [ "output": [
@ -115,8 +87,7 @@ class ZabbixQueryUserGroupPermissionsRequest extends ZabbixRequest<ZabbixUserGro
"selectTemplateGroupRights": [ "selectTemplateGroupRights": [
"id", "id",
"permission" "permission"
], ]
"selectRights": "extend"
}; };
} }
} }
@ -139,7 +110,7 @@ export class ZabbixPermissionsHelper {
const userGroupPermissions = await new ZabbixQueryUserGroupPermissionsRequest(zabbixAuthToken, cookie).executeRequestThrowError(zabbixAPI) const userGroupPermissions = await new ZabbixQueryUserGroupPermissionsRequest(zabbixAuthToken, cookie).executeRequestThrowError(zabbixAPI)
// Prepare the list of templateIds that are not loaded yet // 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 // Remove all templateIds that are already in the permissionObjectNameCache
templateIdsToLoad.forEach(id => { templateIdsToLoad.forEach(id => {
@ -174,7 +145,7 @@ export class ZabbixPermissionsHelper {
let objectNamesFilter = this.createMatcherFromWildcardArray(objectNames); let objectNamesFilter = this.createMatcherFromWildcardArray(objectNames);
(usergroup.templategroup_rights || []).forEach(templateGroupPermission => { usergroup.templategroup_rights.forEach(templateGroupPermission => {
const objectName = this.permissionObjectNameCache.get(templateGroupPermission.id); const objectName = this.permissionObjectNameCache.get(templateGroupPermission.id);
if (objectName && (objectNamesFilter == undefined || objectNamesFilter.test(objectName))) { if (objectName && (objectNamesFilter == undefined || objectNamesFilter.test(objectName))) {
const permissionValue = Number(templateGroupPermission.permission) as PermissionNumber; const permissionValue = Number(templateGroupPermission.permission) as PermissionNumber;

View file

@ -9,7 +9,6 @@ class ZabbixRequestBody {
public method public method
public id = 1 public id = 1
public params?: ZabbixParams public params?: ZabbixParams
public auth?: string
constructor(method: string) { constructor(method: string) {
this.method = method; this.method = method;
@ -139,7 +138,6 @@ export class ParsedArgs {
(<any>result).search.name = this.name_pattern; (<any>result).search.name = this.name_pattern;
(<any>result).search.host = this.name_pattern; (<any>result).search.host = this.name_pattern;
(<any>result).searchByAny = true; (<any>result).searchByAny = true;
(<any>result).searchWildcardsEnabled = true;
} }
return result return result
@ -179,12 +177,6 @@ export class ZabbixRequest<T extends ZabbixResult, A extends ParsedArgs = Parsed
if (params.output) { if (params.output) {
if (Array.isArray(params.output)) { if (Array.isArray(params.output)) {
params.output = params.output.filter(field => topLevelOutput.includes(field)); 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") { } else if (params.output === "extend") {
params.output = topLevelOutput; params.output = topLevelOutput;
} }
@ -219,15 +211,10 @@ export class ZabbixRequest<T extends ZabbixResult, A extends ParsedArgs = Parsed
const p = zabbixParams ?? this.createZabbixParams(args, output); const p = zabbixParams ?? this.createZabbixParams(args, output);
params = Array.isArray(p) ? p : {...this.requestBodyTemplate.params, ...p} params = Array.isArray(p) ? p : {...this.requestBodyTemplate.params, ...p}
} }
const body: ZabbixRequestBody = params ? { return params ? {
...this.requestBodyTemplate, ...this.requestBodyTemplate,
params: params params: params
} : {...this.requestBodyTemplate} } : this.requestBodyTemplate
if (this.authToken) {
body.auth = this.authToken
}
return body
}; };
headers() { 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> { 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); let prepareResult = await this.prepare(zabbixAPI, args);
if (prepareResult) { if (prepareResult) {
return prepareResult; return prepareResult;

View file

@ -17,9 +17,8 @@ import {
ZabbixGroupRightInput ZabbixGroupRightInput
} from "../schema/generated/graphql.js"; } from "../schema/generated/graphql.js";
import {ZabbixAPI} from "./zabbix-api.js"; import {ZabbixAPI} from "./zabbix-api.js";
import {logger} from "../logging/logger.js";
import {ZabbixQueryTemplateGroupRequest, ZabbixQueryTemplateGroupResponse} from "./zabbix-templates.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"; import {ApiErrorCode} from "../model/model_enum_values.js";
@ -78,30 +77,13 @@ export class ZabbixExportUserGroupsRequest extends ZabbixPrepareGetTemplatesAndH
...super.createZabbixParams(args), ...super.createZabbixParams(args),
output: "extend", output: "extend",
selectTemplateGroupRights: "extend", selectTemplateGroupRights: "extend",
selectHostGroupRights: "extend", selectHostGroupRights: "extend"
selectRights: "extend"
}; };
} }
async executeRequestReturnError(zabbixAPI: ZabbixAPI, args?: ZabbixExportUserGroupArgs): Promise<ZabbixErrorResult | UserGroup[]> { async executeRequestReturnError(zabbixAPI: ZabbixAPI, args?: ZabbixExportUserGroupArgs): Promise<ZabbixErrorResult | UserGroup[]> {
const version = await zabbixAPI.getVersion();
let result = await super.executeRequestReturnError(zabbixAPI, args); let result = await super.executeRequestReturnError(zabbixAPI, args);
if (!isZabbixErrorResult(result)) { 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 userGroup of result) {
for (let template_permission of userGroup.templategroup_rights || []) { for (let template_permission of userGroup.templategroup_rights || []) {
for (let templategroup of this.templategroups) { for (let templategroup of this.templategroups) {
@ -171,55 +153,19 @@ export class ZabbixImportUserGroupsRequest
let createGroupRequest = new ZabbixCreateOrUpdateRequest< let createGroupRequest = new ZabbixCreateOrUpdateRequest<
ZabbixCreateUserGroupResponse, ZabbixQueryUserGroupsRequest, ZabbixCreateOrUpdateParams>( ZabbixCreateUserGroupResponse, ZabbixQueryUserGroupsRequest, ZabbixCreateOrUpdateParams>(
"usergroup", "usrgrpid", ZabbixQueryUserGroupsRequest, this.authToken, this.cookie); "usergroup", "usrgrpid", ZabbixQueryUserGroupsRequest, this.authToken, this.cookie);
const version = await zabbixAPI.getVersion();
const needsManualPropagation = version < "6.2.0";
for (let userGroup of args?.usergroups || []) { for (let userGroup of args?.usergroups || []) {
let templategroup_rights = this.calc_templategroup_rights(userGroup); let templategroup_rights = this.calc_templategroup_rights(userGroup);
let hostgroup_rights = this.calc_hostgroup_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 errors: ApiError[] = [];
let paramsObj: any = { let params = new ZabbixCreateOrUpdateParams({
name: userGroup.name, name: userGroup.name,
gui_access: userGroup.gui_access, gui_access: userGroup.gui_access,
users_status: userGroup.users_status, users_status: userGroup.users_status,
}; hostgroup_rights: hostgroup_rights.hostgroup_rights,
templategroup_rights: templategroup_rights.templategroup_rights,
if (version < "6.2.0") { }, args?.dryRun)
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)
let result = await createGroupRequest.executeRequestReturnError(zabbixAPI, params); let result = await createGroupRequest.executeRequestReturnError(zabbixAPI, params);
if (isZabbixErrorResult(result)) { if (isZabbixErrorResult(result)) {
@ -268,52 +214,34 @@ export class ZabbixImportUserGroupsRequest
for (let hostgroup_right of usergroup.hostgroup_rights || []) { for (let hostgroup_right of usergroup.hostgroup_rights || []) {
let success = false; let success = false;
let matchedName = ""; let matchedName = "";
let matchedId: number | undefined = undefined;
// Try matching by UUID first
for (let hostgroup of this.hostgroups) { for (let hostgroup of this.hostgroups) {
if (hostgroup.uuid && hostgroup_right.uuid && hostgroup.uuid === hostgroup_right.uuid) { if (hostgroup.uuid == hostgroup_right.uuid) {
matchedId = Number(hostgroup.groupid); result.push(
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(
{ {
code: ApiErrorCode.OK, id: Number(hostgroup.groupid),
message: `WARNING: Hostgroup found and permissions set, but target name=${matchedName} does not match provided name=${hostgroup_right.name}`, permission: hostgroup_right.permission,
data: hostgroup_right,
} }
) )
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( errors.push(
{ {
code: ApiErrorCode.ZABBIX_HOSTGROUP_NOT_FOUND, 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, data: hostgroup_right,
} }
) )
@ -334,52 +262,33 @@ export class ZabbixImportUserGroupsRequest
for (let templategroup_right of usergroup.templategroup_rights || []) { for (let templategroup_right of usergroup.templategroup_rights || []) {
let success = false; let success = false;
let matchedName = ""; let matchedName = "";
let matchedId: number | undefined = undefined;
// Try matching by UUID first
for (let templategroup of this.templategroups) { for (let templategroup of this.templategroups) {
if (templategroup.uuid && templategroup_right.uuid && templategroup.uuid === templategroup_right.uuid) { if (templategroup.uuid == templategroup_right.uuid) {
matchedId = Number(templategroup.groupid); result.push(
matchedName = templategroup.name; {
id: Number(templategroup.groupid),
permission: templategroup_right.permission,
}
)
success = true; success = true;
matchedName = templategroup.name
break; break;
} }
} }
if (success && templategroup_right.name && templategroup_right.name != matchedName) {
// Fallback to matching by name errors.push(
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(
{ {
id: matchedId!, code: ApiErrorCode.OK,
permission: templategroup_right.permission, 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) { if (!success) {
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 {
errors.push( errors.push(
{ {
code: ApiErrorCode.ZABBIX_TEMPLATEGROUP_NOT_FOUND, 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, data: templategroup_right,
} }
) )
@ -408,17 +317,6 @@ export class ZabbixPropagateHostGroupsRequest extends ZabbixRequest<ZabbixCreate
super("hostgroup.propagate", authToken, cookie); 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 { createZabbixParams(args?: ZabbixPropagateHostGroupsParams): ZabbixParams {
return { return {
groups: [...new Set(args?.groups || [])].map(value => { 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 hostName = "REG_HOST_" + Math.random().toString(36).substring(7);
const groupName = "REG_GROUP_" + 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 { try {
// Regression 1: Locations query argument order // Regression 1: Locations query argument order
// This verifies the fix where getLocations was called with (authToken, args) instead of (args, authToken) // 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 // Regression 2: Template lookup by technical name
// Verifies that importHosts can link templates using their technical name (host) // 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 regGroupName = "Templates/Roadwork/Devices";
const hostGroupName = "Roadwork/Devices"; const hostGroupName = "Roadwork/Devices";
@ -67,7 +55,7 @@ export class RegressionTestExecutor {
const tempResult = await TemplateImporter.importTemplates([{ const tempResult = await TemplateImporter.importTemplates([{
host: regTemplateName, host: regTemplateName,
name: "Regression Test Template " + regTemplateName, name: "Regression Test Template",
groupNames: [regGroupName] groupNames: [regGroupName]
}], zabbixAuthToken, cookie); }], zabbixAuthToken, cookie);
@ -81,9 +69,10 @@ export class RegressionTestExecutor {
// Regression 3: HTTP Agent URL support // Regression 3: HTTP Agent URL support
// Verifies that templates with HTTP Agent items (including URL) can be imported // 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([{ const httpTempResult = await TemplateImporter.importTemplates([{
host: httpTempName, host: httpTempName,
name: "Regression HTTP Template " + httpTempName, name: "Regression HTTP Template",
groupNames: [regGroupName], groupNames: [regGroupName],
items: [{ items: [{
name: "HTTP Master", name: "HTTP Master",
@ -105,9 +94,12 @@ export class RegressionTestExecutor {
if (!httpSuccess) success = false; if (!httpSuccess) success = false;
// Regression 4: User Macro assignment for host and template creation // 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([{ const macroTempResult = await TemplateImporter.importTemplates([{
host: macroTemplateName, host: macroTemplateName,
name: "Regression Macro Template " + macroTemplateName, name: "Regression Macro Template",
groupNames: [regGroupName], groupNames: [regGroupName],
macros: [ macros: [
{ macro: "{$TEMP_MACRO}", value: "temp_value" } { macro: "{$TEMP_MACRO}", value: "temp_value" }
@ -221,9 +213,12 @@ export class RegressionTestExecutor {
} }
// Regression 6: Item Metadata (preprocessing, units, description, error) // 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([{ const metaTempResult = await TemplateImporter.importTemplates([{
host: metaTempName, host: metaTempName,
name: "Regression Meta Template " + metaTempName, name: "Regression Meta Template",
groupNames: [regGroupName], groupNames: [regGroupName],
items: [{ items: [{
name: "Meta Item", name: "Meta Item",
@ -314,17 +309,19 @@ export class RegressionTestExecutor {
// 3. Test indirect dependencies: state implies items // 3. Test indirect dependencies: state implies items
const testParams3 = optRequest.createZabbixParams(new ParsedArgs({}), ["hostid", "state"]); const testParams3 = optRequest.createZabbixParams(new ParsedArgs({}), ["hostid", "state"]);
const hasSelectItems3 = "selectItems" in testParams3; 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 // 4. Test indirect dependencies: deviceType implies tags
const testParams4 = optRequest.createZabbixParams(new ParsedArgs({}), ["hostid", "deviceType"]); const testParams4 = optRequest.createZabbixParams(new ParsedArgs({}), ["hostid", "deviceType"]);
const hasSelectTags4 = "selectTags" in testParams4; const hasSelectTags4 = "selectTags" in testParams4;
const hasOutput4 = Array.isArray(testParams4.output) && testParams4.output.includes("hostid");
optSuccess = optSuccess && hasSelectTags4; optSuccess = optSuccess && hasSelectTags4 && hasOutput4;
if (!optSuccess) { 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) { } catch (error) {
logger.error(`REG-OPT: Error during optimization test: ${error}`); logger.error(`REG-OPT: Error during optimization test: ${error}`);
@ -360,9 +357,10 @@ export class RegressionTestExecutor {
if (!emptySuccess) success = false; if (!emptySuccess) success = false;
// Regression 9: Dependent Items in Templates // Regression 9: Dependent Items in Templates
const depTempName = "REG_DEP_TEMP_" + Math.random().toString(36).substring(7);
const depTempResult = await TemplateImporter.importTemplates([{ const depTempResult = await TemplateImporter.importTemplates([{
host: depTempName, host: depTempName,
name: "Regression Dependent Template " + depTempName, name: "Regression Dependent Template",
groupNames: [regGroupName], groupNames: [regGroupName],
items: [ items: [
{ {
@ -392,9 +390,12 @@ export class RegressionTestExecutor {
if (!depSuccess) success = false; if (!depSuccess) success = false;
// Regression 10: State sub-properties retrieval (Optimization indirect dependency) // 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([{ const stateTempResult = await TemplateImporter.importTemplates([{
host: stateTempName, host: stateTempName,
name: "Regression State Template " + stateTempName, name: "Regression State Template",
groupNames: [regGroupName], groupNames: [regGroupName],
tags: [{ tag: "deviceType", value: "GenericDevice" }], tags: [{ tag: "deviceType", value: "GenericDevice" }],
items: [{ items: [{
@ -481,6 +482,9 @@ export class RegressionTestExecutor {
// Regression 12: allDevices deviceType filter // Regression 12: allDevices deviceType filter
// Verifies that allDevices only returns hosts with a deviceType tag // 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 // Get groupid for hostGroupName
const groupQuery: any = await new ZabbixRequest("hostgroup.get", zabbixAuthToken, cookie) const groupQuery: any = await new ZabbixRequest("hostgroup.get", zabbixAuthToken, cookie)
.executeRequestReturnError(zabbixAPI, new ParsedArgs({ filter_name: hostGroupName })); .executeRequestReturnError(zabbixAPI, new ParsedArgs({ filter_name: hostGroupName }));
@ -528,59 +532,48 @@ export class RegressionTestExecutor {
} }
// Regression 13: pushHistory mutation // Regression 13: pushHistory mutation
let pushSuccess = false; const pushHostName = "REG_PUSH_HOST_" + Math.random().toString(36).substring(7);
const version = await zabbixAPI.getVersion(); const pushItemKey = "trap.json";
if (version < "7.0.0") { // Create host
logger.info(`REG-PUSH: Skipping pushHistory test as it is not supported on Zabbix version ${version}`); const pushHostResult = await HostImporter.importHosts([{
pushSuccess = true; // Mark as success for old versions to allow overall test success deviceKey: pushHostName,
} else { deviceType: "RegressionHost",
const pushItemKey = "trap.json"; groupNames: [hostGroupName],
templateNames: []
}], zabbixAuthToken, cookie);
let pushSuccess = false;
if (pushHostResult?.length && pushHostResult[0].hostid) {
const pushHostId = pushHostResult[0].hostid;
// Create host // Add trapper item to host
const pushHostResult = await HostImporter.importHosts([{ const pushItemResult = await new ZabbixRequest("item.create", zabbixAuthToken, cookie).executeRequestReturnError(zabbixAPI, new ParsedArgs({
deviceKey: pushHostName, name: "Trapper JSON Item",
deviceType: "RegressionHost", key_: pushItemKey,
groupNames: [hostGroupName], hostid: pushHostId,
templateNames: [] type: 2, // Zabbix trapper
}], zabbixAuthToken, cookie); value_type: 4, // Text
history: "1d"
}));
if (pushHostResult?.length && pushHostResult[0].hostid) { if (!isZabbixErrorResult(pushItemResult)) {
const pushHostId = pushHostResult[0].hostid; // 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 pushDataResult = await pushRequest.executeRequestReturnError(zabbixAPI, pushParams);
const pushItemResult = await new ZabbixRequest("item.create", zabbixAuthToken, cookie).executeRequestReturnError(zabbixAPI, new ParsedArgs({ pushSuccess = !isZabbixErrorResult(pushDataResult) && pushDataResult.response === "success";
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);
} }
} }
steps.push({ steps.push({
name: "REG-PUSH: pushHistory mutation", name: "REG-PUSH: pushHistory mutation",
success: pushSuccess, success: pushSuccess,
message: version < "7.0.0" message: pushSuccess ? "Successfully pushed history data to trapper item" : "Failed to push history data"
? `Skipped (not supported on ${version})`
: (pushSuccess ? "Successfully pushed history data to trapper item" : "Failed to push history data")
}); });
if (!pushSuccess) success = false; if (!pushSuccess) success = false;
@ -620,22 +613,6 @@ export class RegressionTestExecutor {
success: false, success: false,
message: error.message || String(error) 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 { return {

View file

@ -66,11 +66,11 @@ export class SmoketestExecutor {
templateNames: [templateName] templateNames: [templateName]
}], zabbixAuthToken, cookie); }], zabbixAuthToken, cookie);
const hostSuccess = !!hostResult?.length && !hostResult[0].error && !!hostResult[0].hostid; const hostSuccess = !!hostResult?.length && !hostResult[0].error;
steps.push({ steps.push({
name: "Create and Link Host", name: "Create and Link Host",
success: hostSuccess, 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; if (!hostSuccess) success = false;
} else { } else {

View file

@ -6,7 +6,6 @@ import {GraphQLError} from "graphql";
jest.mock("../datasources/zabbix-api.js", () => ({ jest.mock("../datasources/zabbix-api.js", () => ({
zabbixAPI: { zabbixAPI: {
post: jest.fn(), 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"); 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", () => ({ jest.mock("../datasources/zabbix-api.js", () => ({
zabbixAPI: { zabbixAPI: {
post: jest.fn(), post: jest.fn(),
getVersion: jest.fn().mockResolvedValue("7.0.0"),
} }
})); }));

View file

@ -6,7 +6,6 @@ jest.mock("../datasources/zabbix-api.js", () => ({
zabbixAPI: { zabbixAPI: {
executeRequest: jest.fn(), executeRequest: jest.fn(),
post: jest.fn(), post: jest.fn(),
getVersion: jest.fn().mockResolvedValue("7.0.0"),
requestByPath: jest.fn() requestByPath: jest.fn()
}, },
ZABBIX_EDGE_DEVICE_BASE_GROUP: "Roadwork" 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", () => ({ jest.mock("../datasources/zabbix-api.js", () => ({
zabbixAPI: { zabbixAPI: {
post: jest.fn(), post: jest.fn(),
getVersion: jest.fn().mockResolvedValue("7.0.0"),
executeRequest: jest.fn(), executeRequest: jest.fn(),
baseURL: 'http://localhost/zabbix', baseURL: 'http://localhost/zabbix',
getLocations: jest.fn(), getLocations: jest.fn(),

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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