diff --git a/.gitignore b/.gitignore
index 78ded7b..a612f5d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -74,10 +74,7 @@ web_modules/
# dotenv environment variable files
.env
-.env.development.local
-.env.test.local
-.env.production.local
-.env.local
+.env.*
# parcel-bundler cache (https://parceljs.org/)
.cache
diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index d1b9822..efd7416 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -5,30 +5,21 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
@@ -39,7 +30,7 @@
-
+
@@ -87,45 +78,50 @@
- {
+ "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"
+ },
+ "keyToStringList": {
+ "com.intellij.ide.scratch.ScratchImplUtil$2/New Scratch File": [
+ "TEXT"
+ ]
}
-}]]>
+}
@@ -231,7 +227,10 @@
-
+
+
+
+
diff --git a/README.md b/README.md
index 9fd837f..023f0e2 100644
--- a/README.md
+++ b/README.md
@@ -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.
+- **Zabbix**: A running Zabbix instance (compatible with Zabbix 6.0+) with API access. See [Zabbix Version Compatibility](#-zabbix-version-compatibility) for details.
- **Zabbix Super Admin Token** (for full functionality / privilege escalation).
- **Zabbix User Access** (groups and roles depending on your use case).
@@ -81,6 +81,20 @@ 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
@@ -236,7 +250,20 @@ The API version is automatically set during the Docker build process based on th
### ๐ง Zabbix Version Compatibility
-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.
+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.
## ๐ ๏ธ Technical Maintenance
diff --git a/docker-compose.yml b/docker-compose.yml
index 4a56b5c..c938d1f 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -43,5 +43,50 @@ 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:
diff --git a/docs/howtos/README.md b/docs/howtos/README.md
index 43ed518..d619569 100644
--- a/docs/howtos/README.md
+++ b/docs/howtos/README.md
@@ -22,6 +22,9 @@ 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.
diff --git a/docs/howtos/local_development.md b/docs/howtos/local_development.md
new file mode 100644
index 0000000..e8c47d4
--- /dev/null
+++ b/docs/howtos/local_development.md
@@ -0,0 +1,88 @@
+# ๐ป 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)
diff --git a/docs/howtos/maintenance.md b/docs/howtos/maintenance.md
index 2d9b7cd..617d76f 100644
--- a/docs/howtos/maintenance.md
+++ b/docs/howtos/maintenance.md
@@ -34,6 +34,9 @@ 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.
diff --git a/roadmap.md b/roadmap.md
index 4abf77e..88f6341 100644
--- a/roadmap.md
+++ b/roadmap.md
@@ -6,12 +6,21 @@ 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.
-## ๐
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.
+- **๐ณ 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
- **๐๏ธ 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*:
diff --git a/samples/zabbix-local.env b/samples/zabbix-local.env
new file mode 100644
index 0000000..b304fc5
--- /dev/null
+++ b/samples/zabbix-local.env
@@ -0,0 +1,10 @@
+# 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
diff --git a/src/api/resolvers.ts b/src/api/resolvers.ts
index 44e38d0..d0d3bc4 100644
--- a/src/api/resolvers.ts
+++ b/src/api/resolvers.ts
@@ -166,32 +166,22 @@ export function createResolvers(): Resolvers {
zabbixAuthToken,
cookie, dataSources
}: any, info: any) => {
- let params: any = {}
if (args.hostids) {
- params.templateids = args.hostids
- }
- if (args.name_pattern) {
- params.search = {
- name: args.name_pattern
- }
+ // @ts-ignore
+ args.templateids = args.hostids
+ delete args.hostids
}
const output = GraphqlParamsToNeededZabbixOutput.mapTemplates(info);
return await new ZabbixQueryTemplatesRequest(zabbixAuthToken, cookie)
- .executeRequestThrowError(dataSources?.zabbixAPI || zabbixAPI, new ParsedArgs(params), output);
+ .executeRequestThrowError(dataSources?.zabbixAPI || zabbixAPI, new ParsedArgs(args), 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(params));
+ .executeRequestThrowError(zabbixAPI, new ParsedArgs(args));
}
},
Mutation: {
diff --git a/src/datasources/zabbix-api.ts b/src/datasources/zabbix-api.ts
index 17ed27c..1ecaa73 100644
--- a/src/datasources/zabbix-api.ts
+++ b/src/datasources/zabbix-api.ts
@@ -80,6 +80,20 @@ export class ZabbixAPI
return super.post(path, request);
}
+ private static version: string | undefined
+
+ async getVersion(): Promise {
+ if (!ZabbixAPI.version) {
+ const response = await this.requestByPath("apiinfo.version")
+ if (typeof response === "string") {
+ ZabbixAPI.version = response
+ } else {
+ return "0.0.0"
+ }
+ }
+ return ZabbixAPI.version
+ }
+
async executeRequest(zabbixRequest: ZabbixRequest, args?: A, throwApiError: boolean = true, output?: string[]): Promise {
return throwApiError ? zabbixRequest.executeRequestThrowError(this, args, output) : zabbixRequest.executeRequestReturnError(this, args, output);
}
diff --git a/src/datasources/zabbix-history.ts b/src/datasources/zabbix-history.ts
index c3d35b7..db57459 100644
--- a/src/datasources/zabbix-history.ts
+++ b/src/datasources/zabbix-history.ts
@@ -81,6 +81,10 @@ export class ZabbixHistoryPushRequest extends ZabbixRequest {
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");
diff --git a/src/datasources/zabbix-hostgroups.ts b/src/datasources/zabbix-hostgroups.ts
index 67a3d22..a517f7b 100644
--- a/src/datasources/zabbix-hostgroups.ts
+++ b/src/datasources/zabbix-hostgroups.ts
@@ -100,4 +100,33 @@ export class GroupHelper {
}
return result
}
+
+ public static async findSubgroupIds(groupids: number[], zabbixApi: ZabbixAPI, zabbixAuthToken?: string, cookie?: string): Promise {
+ 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)];
+ }
}
diff --git a/src/datasources/zabbix-hosts.ts b/src/datasources/zabbix-hosts.ts
index 4c18c6e..4232f0e 100644
--- a/src/datasources/zabbix-hosts.ts
+++ b/src/datasources/zabbix-hosts.ts
@@ -25,8 +25,23 @@ export class ZabbixQueryHostsGenericRequest {
+ 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 {
- return this.optimizeZabbixParams({
+ const params: any = {
...super.createZabbixParams(args),
selectParentTemplates: [
"templateid",
@@ -45,11 +60,15 @@ export class ZabbixQueryHostsGenericRequest {
+ 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 {
...super.createZabbixParams(args),
"output": [
@@ -87,7 +115,8 @@ class ZabbixQueryUserGroupPermissionsRequest extends ZabbixRequest 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 => {
@@ -145,7 +174,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;
diff --git a/src/datasources/zabbix-request.ts b/src/datasources/zabbix-request.ts
index fb54110..343ef62 100644
--- a/src/datasources/zabbix-request.ts
+++ b/src/datasources/zabbix-request.ts
@@ -9,6 +9,7 @@ class ZabbixRequestBody {
public method
public id = 1
public params?: ZabbixParams
+ public auth?: string
constructor(method: string) {
this.method = method;
@@ -138,6 +139,7 @@ export class ParsedArgs {
(result).search.name = this.name_pattern;
(result).search.host = this.name_pattern;
(result).searchByAny = true;
+ (result).searchWildcardsEnabled = true;
}
return result
@@ -177,6 +179,12 @@ export class ZabbixRequest 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;
}
@@ -211,10 +219,15 @@ export class ZabbixRequest {
+ 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;
diff --git a/src/datasources/zabbix-usergroups.ts b/src/datasources/zabbix-usergroups.ts
index 5c61a52..bd576a3 100644
--- a/src/datasources/zabbix-usergroups.ts
+++ b/src/datasources/zabbix-usergroups.ts
@@ -17,8 +17,9 @@ 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 {ZabbixQueryHostgroupsRequest, ZabbixQueryHostgroupsResult} from "./zabbix-hostgroups.js";
+import {GroupHelper, ZabbixQueryHostgroupsRequest, ZabbixQueryHostgroupsResult} from "./zabbix-hostgroups.js";
import {ApiErrorCode} from "../model/model_enum_values.js";
@@ -77,13 +78,30 @@ export class ZabbixExportUserGroupsRequest extends ZabbixPrepareGetTemplatesAndH
...super.createZabbixParams(args),
output: "extend",
selectTemplateGroupRights: "extend",
- selectHostGroupRights: "extend"
+ selectHostGroupRights: "extend",
+ selectRights: "extend"
};
}
async executeRequestReturnError(zabbixAPI: ZabbixAPI, args?: ZabbixExportUserGroupArgs): Promise {
+ 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) {
@@ -153,19 +171,55 @@ 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 params = new ZabbixCreateOrUpdateParams({
+ let paramsObj: any = {
name: userGroup.name,
gui_access: userGroup.gui_access,
users_status: userGroup.users_status,
- hostgroup_rights: hostgroup_rights.hostgroup_rights,
- templategroup_rights: templategroup_rights.templategroup_rights,
- }, args?.dryRun)
+ };
+
+ 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)
let result = await createGroupRequest.executeRequestReturnError(zabbixAPI, params);
if (isZabbixErrorResult(result)) {
@@ -214,34 +268,52 @@ 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) {
- result.push(
- {
- id: Number(hostgroup.groupid),
- permission: hostgroup_right.permission,
- }
- )
- success = true;
+ if (hostgroup.uuid && hostgroup_right.uuid && hostgroup.uuid === hostgroup_right.uuid) {
+ matchedId = Number(hostgroup.groupid);
matchedName = hostgroup.name;
+ success = true;
break;
}
-
}
- if (success && hostgroup_right.name && hostgroup_right.name != matchedName) {
- errors.push(
+
+ // 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(
{
- code: ApiErrorCode.OK,
- message: `WARNING: Hostgroup found and permissions set, but target name=${matchedName} does not match`,
- data: hostgroup_right,
+ id: matchedId!,
+ permission: hostgroup_right.permission,
}
)
- }
- if (!success) {
+
+ if (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 provided name=${hostgroup_right.name}`,
+ data: hostgroup_right,
+ }
+ )
+ }
+ } else {
errors.push(
{
code: ApiErrorCode.ZABBIX_HOSTGROUP_NOT_FOUND,
- message: `Hostgroup with UUID ${hostgroup_right.uuid} not found`,
+ message: `Hostgroup with UUID ${hostgroup_right.uuid} ${hostgroup_right.name ? "or name " + hostgroup_right.name : ""} not found`,
data: hostgroup_right,
}
)
@@ -262,33 +334,52 @@ 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) {
- result.push(
- {
- id: Number(templategroup.groupid),
- permission: templategroup_right.permission,
- }
- )
+ if (templategroup.uuid && templategroup_right.uuid && templategroup.uuid === templategroup_right.uuid) {
+ matchedId = Number(templategroup.groupid);
+ matchedName = templategroup.name;
success = true;
- matchedName = templategroup.name
break;
}
}
- if (success && templategroup_right.name && templategroup_right.name != matchedName) {
- errors.push(
+
+ // 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(
{
- code: ApiErrorCode.OK,
- message: `WARNING: Templategroup found and permissions set, but target name=${matchedName} does not match`,
- data: templategroup_right,
+ id: matchedId!,
+ permission: templategroup_right.permission,
}
)
- }
- if (!success) {
+
+ 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 {
errors.push(
{
code: ApiErrorCode.ZABBIX_TEMPLATEGROUP_NOT_FOUND,
- message: `Templategroup with UUID ${templategroup_right.uuid} not found`,
+ message: `Templategroup with UUID ${templategroup_right.uuid} ${templategroup_right.name ? "or name " + templategroup_right.name : ""} not found`,
data: templategroup_right,
}
)
@@ -317,6 +408,17 @@ export class ZabbixPropagateHostGroupsRequest extends ZabbixRequest {
+ 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 => {
diff --git a/src/execution/regression_test_executor.ts b/src/execution/regression_test_executor.ts
index da89787..d3c49db 100644
--- a/src/execution/regression_test_executor.ts
+++ b/src/execution/regression_test_executor.ts
@@ -23,6 +23,19 @@ 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)
@@ -44,7 +57,6 @@ 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";
@@ -55,7 +67,7 @@ export class RegressionTestExecutor {
const tempResult = await TemplateImporter.importTemplates([{
host: regTemplateName,
- name: "Regression Test Template",
+ name: "Regression Test Template " + regTemplateName,
groupNames: [regGroupName]
}], zabbixAuthToken, cookie);
@@ -69,10 +81,9 @@ 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",
+ name: "Regression HTTP Template " + httpTempName,
groupNames: [regGroupName],
items: [{
name: "HTTP Master",
@@ -94,12 +105,9 @@ 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",
+ name: "Regression Macro Template " + macroTemplateName,
groupNames: [regGroupName],
macros: [
{ macro: "{$TEMP_MACRO}", value: "temp_value" }
@@ -213,12 +221,9 @@ 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",
+ name: "Regression Meta Template " + metaTempName,
groupNames: [regGroupName],
items: [{
name: "Meta Item",
@@ -309,19 +314,17 @@ 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 && hasOutput3;
+ optSuccess = optSuccess && hasSelectItems3;
// 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 && hasOutput4;
+ optSuccess = optSuccess && hasSelectTags4;
if (!optSuccess) {
- logger.error(`REG-OPT: Optimization verification failed. hasSelectItems1: ${hasSelectItems1}, hasOutput1: ${hasOutput1}, hasSelectItems2: ${hasSelectItems2}, hasSelectTags2: ${hasSelectTags2}, hasSelectItems3: ${hasSelectItems3}, hasOutput3: ${hasOutput3}, hasSelectTags4: ${hasSelectTags4}, hasOutput4: ${hasOutput4}`);
+ logger.error(`REG-OPT: Optimization verification failed. hasSelectItems1: ${hasSelectItems1}, hasOutput1: ${hasOutput1}, hasSelectItems2: ${hasSelectItems2}, hasSelectTags2: ${hasSelectTags2}, hasSelectItems3: ${hasSelectItems3}, hasSelectTags4: ${hasSelectTags4}`);
}
} catch (error) {
logger.error(`REG-OPT: Error during optimization test: ${error}`);
@@ -357,10 +360,9 @@ 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",
+ name: "Regression Dependent Template " + depTempName,
groupNames: [regGroupName],
items: [
{
@@ -390,12 +392,9 @@ 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",
+ name: "Regression State Template " + stateTempName,
groupNames: [regGroupName],
tags: [{ tag: "deviceType", value: "GenericDevice" }],
items: [{
@@ -482,9 +481,6 @@ 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 }));
@@ -532,48 +528,59 @@ export class RegressionTestExecutor {
}
// Regression 13: pushHistory mutation
- const pushHostName = "REG_PUSH_HOST_" + Math.random().toString(36).substring(7);
- 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;
+ const version = await zabbixAPI.getVersion();
+
+ 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";
- // 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"
- }));
+ // Create host
+ const pushHostResult = await HostImporter.importHosts([{
+ deviceKey: pushHostName,
+ deviceType: "RegressionHost",
+ groupNames: [hostGroupName],
+ templateNames: []
+ }], zabbixAuthToken, cookie);
- 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
- );
+ if (pushHostResult?.length && pushHostResult[0].hostid) {
+ const pushHostId = pushHostResult[0].hostid;
- const pushDataResult = await pushRequest.executeRequestReturnError(zabbixAPI, pushParams);
- pushSuccess = !isZabbixErrorResult(pushDataResult) && pushDataResult.response === "success";
+ // 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);
}
}
steps.push({
name: "REG-PUSH: pushHistory mutation",
success: pushSuccess,
- message: pushSuccess ? "Successfully pushed history data to trapper item" : "Failed to push history data"
+ message: version < "7.0.0"
+ ? `Skipped (not supported on ${version})`
+ : (pushSuccess ? "Successfully pushed history data to trapper item" : "Failed to push history data")
});
if (!pushSuccess) success = false;
@@ -613,6 +620,22 @@ 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 {
diff --git a/src/execution/smoketest_executor.ts b/src/execution/smoketest_executor.ts
index 497789f..f2a3598 100644
--- a/src/execution/smoketest_executor.ts
+++ b/src/execution/smoketest_executor.ts
@@ -66,11 +66,11 @@ export class SmoketestExecutor {
templateNames: [templateName]
}], zabbixAuthToken, cookie);
- const hostSuccess = !!hostResult?.length && !hostResult[0].error;
+ const hostSuccess = !!hostResult?.length && !hostResult[0].error && !!hostResult[0].hostid;
steps.push({
name: "Create and Link Host",
success: hostSuccess,
- message: hostSuccess ? `Host ${hostName} created and linked to ${templateName}` : `Failed: ${hostResult?.[0]?.error?.message || "Unknown error"}`
+ message: hostSuccess ? `Host ${hostName} created and linked to ${templateName}` : `Failed: ${hostResult?.[0]?.error?.message || hostResult?.[0]?.message || "Unknown error"}`
});
if (!hostSuccess) success = false;
} else {
diff --git a/src/test/history_push.test.ts b/src/test/history_push.test.ts
index a6fbb31..0882e04 100644
--- a/src/test/history_push.test.ts
+++ b/src/test/history_push.test.ts
@@ -6,6 +6,7 @@ import {GraphQLError} from "graphql";
jest.mock("../datasources/zabbix-api.js", () => ({
zabbixAPI: {
post: jest.fn(),
+ getVersion: jest.fn().mockResolvedValue("7.0.0")
}
}));
@@ -63,4 +64,12 @@ 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");
+ });
});
diff --git a/src/test/history_push_integration.test.ts b/src/test/history_push_integration.test.ts
index 607c69c..b1dbcd6 100644
--- a/src/test/history_push_integration.test.ts
+++ b/src/test/history_push_integration.test.ts
@@ -6,6 +6,7 @@ import {zabbixAPI} from '../datasources/zabbix-api.js';
jest.mock("../datasources/zabbix-api.js", () => ({
zabbixAPI: {
post: jest.fn(),
+ getVersion: jest.fn().mockResolvedValue("7.0.0"),
}
}));
diff --git a/src/test/host_importer.test.ts b/src/test/host_importer.test.ts
index 14fb94f..c2781d4 100644
--- a/src/test/host_importer.test.ts
+++ b/src/test/host_importer.test.ts
@@ -6,6 +6,7 @@ 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"
diff --git a/src/test/host_integration.test.ts b/src/test/host_integration.test.ts
index 00d1544..8b3fad4 100644
--- a/src/test/host_integration.test.ts
+++ b/src/test/host_integration.test.ts
@@ -8,6 +8,7 @@ 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(),
diff --git a/src/test/host_query.test.ts b/src/test/host_query.test.ts
index 0762084..d50e5aa 100644
--- a/src/test/host_query.test.ts
+++ b/src/test/host_query.test.ts
@@ -7,6 +7,7 @@ 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()
},
diff --git a/src/test/indirect_dependencies.test.ts b/src/test/indirect_dependencies.test.ts
index 19d82cd..8e9c49a 100644
--- a/src/test/indirect_dependencies.test.ts
+++ b/src/test/indirect_dependencies.test.ts
@@ -7,6 +7,7 @@ jest.mock("../datasources/zabbix-api.js", () => ({
zabbixAPI: {
executeRequest: jest.fn(),
post: jest.fn(),
+ getVersion: jest.fn().mockResolvedValue("7.0.0"),
baseURL: "http://mock-zabbix",
}
}));
diff --git a/src/test/misc_resolvers.test.ts b/src/test/misc_resolvers.test.ts
index 4c6f588..90a5609 100644
--- a/src/test/misc_resolvers.test.ts
+++ b/src/test/misc_resolvers.test.ts
@@ -5,7 +5,8 @@ import {zabbixAPI} from "../datasources/zabbix-api.js";
jest.mock("../datasources/zabbix-api.js", () => ({
zabbixAPI: {
executeRequest: jest.fn(),
- post: jest.fn()
+ post: jest.fn(),
+ getVersion: jest.fn().mockResolvedValue("7.0.0"),
}
}));
diff --git a/src/test/query_optimization.test.ts b/src/test/query_optimization.test.ts
index a7765ac..2fa6259 100644
--- a/src/test/query_optimization.test.ts
+++ b/src/test/query_optimization.test.ts
@@ -7,6 +7,7 @@ jest.mock("../datasources/zabbix-api.js", () => ({
zabbixAPI: {
executeRequest: jest.fn(),
post: jest.fn(),
+ getVersion: jest.fn().mockResolvedValue("7.0.0"),
baseURL: "http://mock-zabbix",
}
}));
@@ -90,7 +91,7 @@ describe("Query Optimization", () => {
expect(zabbixAPI.post).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
body: expect.objectContaining({
params: expect.objectContaining({
- output: ["hostid"],
+ output: expect.arrayContaining(["hostid", "tags"]),
selectTags: expect.any(Array)
})
})
@@ -187,7 +188,7 @@ describe("Query Optimization", () => {
expect(zabbixAPI.post).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
body: expect.objectContaining({
params: expect.objectContaining({
- output: ["hostid"],
+ output: expect.arrayContaining(["hostid", "tags"]),
selectTags: expect.any(Array)
})
})
diff --git a/src/test/template_deleter.test.ts b/src/test/template_deleter.test.ts
index 68e4164..b1d09e9 100644
--- a/src/test/template_deleter.test.ts
+++ b/src/test/template_deleter.test.ts
@@ -5,7 +5,8 @@ import {zabbixAPI} from "../datasources/zabbix-api.js";
jest.mock("../datasources/zabbix-api.js", () => ({
zabbixAPI: {
executeRequest: jest.fn(),
- post: jest.fn()
+ post: jest.fn(),
+ getVersion: jest.fn().mockResolvedValue("7.0.0"),
}
}));
diff --git a/src/test/template_importer.test.ts b/src/test/template_importer.test.ts
index 6258c0e..976ef88 100644
--- a/src/test/template_importer.test.ts
+++ b/src/test/template_importer.test.ts
@@ -5,7 +5,8 @@ import {zabbixAPI} from "../datasources/zabbix-api.js";
jest.mock("../datasources/zabbix-api.js", () => ({
zabbixAPI: {
executeRequest: jest.fn(),
- post: jest.fn()
+ post: jest.fn(),
+ getVersion: jest.fn().mockResolvedValue("7.0.0"),
}
}));
diff --git a/src/test/template_integration.test.ts b/src/test/template_integration.test.ts
index 22858bb..8c87cc2 100644
--- a/src/test/template_integration.test.ts
+++ b/src/test/template_integration.test.ts
@@ -8,6 +8,7 @@ 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'
}
diff --git a/src/test/template_link.test.ts b/src/test/template_link.test.ts
index ead11e1..c5acff8 100644
--- a/src/test/template_link.test.ts
+++ b/src/test/template_link.test.ts
@@ -6,6 +6,7 @@ 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()
diff --git a/src/test/template_query.test.ts b/src/test/template_query.test.ts
index f0a065e..22c5e97 100644
--- a/src/test/template_query.test.ts
+++ b/src/test/template_query.test.ts
@@ -7,6 +7,7 @@ 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"
@@ -82,8 +83,11 @@ describe("Template Resolver", () => {
method: "template.get",
params: expect.objectContaining({
search: {
- name: "Template"
- }
+ name: "Template",
+ host: "Template"
+ },
+ searchByAny: true,
+ searchWildcardsEnabled: true
})
})
}));
@@ -105,8 +109,11 @@ describe("Template Resolver", () => {
method: "template.get",
params: expect.objectContaining({
search: {
- name: "Temp%1"
- }
+ name: "Temp%1",
+ host: "Temp%1"
+ },
+ searchByAny: true,
+ searchWildcardsEnabled: true
})
})
}));
diff --git a/src/test/user_rights.test.ts b/src/test/user_rights.test.ts
index 0ac3732..99a30eb 100644
--- a/src/test/user_rights.test.ts
+++ b/src/test/user_rights.test.ts
@@ -6,6 +6,7 @@ jest.mock("../datasources/zabbix-api.js", () => ({
zabbixAPI: {
executeRequest: jest.fn(),
post: jest.fn(),
+ getVersion: jest.fn().mockResolvedValue("7.0.0"),
baseURL: "http://mock-zabbix"
}
}));
diff --git a/src/test/user_rights_integration.test.ts b/src/test/user_rights_integration.test.ts
index a7e401c..ea987b4 100644
--- a/src/test/user_rights_integration.test.ts
+++ b/src/test/user_rights_integration.test.ts
@@ -8,6 +8,7 @@ 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(),
@@ -43,7 +44,8 @@ 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: ["1"] }) // usergroup.update
+ .mockResolvedValueOnce({ usrgrpids: [] }); // hostgroup.propagate
const response = await server.executeOperation({
query: mutation,
diff --git a/src/test/zabbix_6_0_compatibility.test.ts b/src/test/zabbix_6_0_compatibility.test.ts
new file mode 100644
index 0000000..6535d81
--- /dev/null
+++ b/src/test/zabbix_6_0_compatibility.test.ts
@@ -0,0 +1,106 @@
+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 });
+ });
+});
diff --git a/src/test/zabbix_docs_samples.test.ts b/src/test/zabbix_docs_samples.test.ts
index 87c189f..7a5dafd 100644
--- a/src/test/zabbix_docs_samples.test.ts
+++ b/src/test/zabbix_docs_samples.test.ts
@@ -8,6 +8,7 @@ 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(),