Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .github/scripts/glpi_it_setup.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (C) 2025-2026 Giovanny Rodriguez, Imagunet
#
# Prepare a fresh glpi/glpi container for the integration tests:
# wait for the auto-install, enable the APIs, open the default legacy API
# client to any IP, and (GLPI 11+) create an OAuth client.
# Prints "export GLPI_IT_*=..." lines on stdout.
#
# Usage: glpi_it_setup.sh <glpi_container> <db_container> <base_url>
# Env: DOCKER (default: docker; use podman locally)
set -euo pipefail

GLPI=$1 DB=$2 URL=$3
DOCKER=${DOCKER:-docker}
HERE=$(cd "$(dirname "$0")" && pwd)
console() { $DOCKER exec -u www-data -w /var/www/glpi "$GLPI" php bin/console "$@"; }

# The image installs GLPI on first start; console commands fail until done.
for _ in $(seq 1 120); do
if console config:set enable_api 1 >/dev/null 2>&1 \
|| console glpi:config:set enable_api 1 >/dev/null 2>&1; then
break
fi
sleep 5
done
version=$($DOCKER exec "$DB" mariadb -uglpi -pglpi glpi -N -e \
"SELECT value FROM glpi_configs WHERE context='core' AND name='version'")
[ -n "$version" ] || { echo "GLPI did not finish installing" >&2; exit 1; }
major=${version%%.*}

for key in enable_api_login_credentials enable_api_login_external_token enable_hlapi; do
console config:set "$key" 1 >/dev/null 2>&1 || console glpi:config:set "$key" 1 >/dev/null 2>&1 || true
done
# The default API client only accepts 127.0.0.1; tests reach it via the bridge IP.
$DOCKER exec "$DB" mariadb -uglpi -pglpi glpi -e \
"UPDATE glpi_apiclients SET ipv4_range_start=NULL, ipv4_range_end=NULL WHERE id=1"

# Wait until the legacy API answers a login.
for _ in $(seq 1 60); do
curl -sf -u glpi:glpi "$URL/apirest.php/initSession" >/dev/null && break
sleep 2
done

echo "export GLPI_IT_URL=$URL"
echo "export GLPI_IT_VERSION=$version"
if [ "$major" -ge 11 ]; then
$DOCKER cp "$HERE/glpi_oauth_client.php" "$GLPI:/tmp/glpi_oauth_client.php"
read -r cid secret < <($DOCKER exec -u www-data -w /var/www/glpi "$GLPI" php /tmp/glpi_oauth_client.php | tail -1)
echo "export GLPI_IT_CLIENT_ID=$cid"
echo "export GLPI_IT_CLIENT_SECRET=$secret"
fi
28 changes: 28 additions & 0 deletions .github/scripts/glpi_oauth_client.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2025-2026 Giovanny Rodriguez, Imagunet
//
// Creates an OAuth client for integration tests (GLPI 11+) and prints
// "<client_id> <client_secret>". Run inside the GLPI container from the
// GLPI root as the web server user.
//
// redirect_uri, grants and scopes are passed as PHP arrays: add() encodes
// them; passing JSON strings double-encodes them and breaks /api.php/token.

require './vendor/autoload.php';

$kernel = new \Glpi\Kernel\Kernel('production');
$kernel->boot();
\Session::initEntityProfiles(2);

$client = new OAuthClient();
$id = $client->add([
'name' => 'glpi-utils-integration',
'redirect_uri' => ['/api.php/oauth2/redirection'],
'grants' => ['password', 'client_credentials', 'authorization_code'],
'scopes' => ['api', 'user', 'email', 'status', 'graphql'],
'is_active' => 1,
'is_confidential' => 1,
]);
$client->getFromDB($id);
echo $client->fields['identifier'], ' ', (new GLPIKey())->decrypt($client->fields['secret']), PHP_EOL;
64 changes: 64 additions & 0 deletions .github/workflows/integration.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
name: Integration

# Runs the client against real GLPI servers (official glpi/glpi images).
# Floating tags pick up each branch's latest patch release; the weekly run
# catches GLPI releases that break the client before users do.
on:
pull_request:
push:
branches: [main]
schedule:
- cron: "0 6 * * 1"
workflow_dispatch:

permissions:
contents: read

jobs:
glpi:
name: GLPI ${{ matrix.glpi }}
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
glpi: ["10.0", "11.0", "12.0"]

steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install package
run: pip install -e ".[async,dev]"

- name: Start GLPI ${{ matrix.glpi }}
run: |
docker network create glpi
docker run -d --name db --network glpi \
-e MARIADB_ROOT_PASSWORD=root -e MARIADB_DATABASE=glpi \
-e MARIADB_USER=glpi -e MARIADB_PASSWORD=glpi \
mariadb:11.4
docker run -d --name glpi --network glpi -p 8080:80 \
-e GLPI_DB_HOST=db -e GLPI_DB_PORT=3306 -e GLPI_DB_NAME=glpi \
-e GLPI_DB_USER=glpi -e GLPI_DB_PASSWORD=glpi \
glpi/glpi:${{ matrix.glpi }}

- name: Prepare GLPI (APIs, OAuth client)
run: |
.github/scripts/glpi_it_setup.sh glpi db http://localhost:8080 > it.env
grep -v SECRET it.env

- name: Integration tests
run: |
source it.env
echo "Testing against GLPI $GLPI_IT_VERSION"
pytest tests/integration -v

- name: GLPI logs on failure
if: failure()
run: |
docker logs glpi 2>&1 | tail -50
docker exec glpi sh -c 'tail -50 /var/glpi/logs/php-errors.log' || true
2 changes: 2 additions & 0 deletions tests/integration/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (C) 2025-2026 Giovanny Rodriguez, Imagunet
92 changes: 92 additions & 0 deletions tests/integration/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (C) 2025-2026 Giovanny Rodriguez, Imagunet
"""
Integration tests against a real GLPI server.

Skipped unless GLPI_IT_URL is set. .github/scripts/glpi_it_setup.sh prepares
a glpi/glpi container and prints the variables:

GLPI_IT_URL base URL, e.g. http://localhost:8080
GLPI_IT_VERSION GLPI version, e.g. 12.0.0
GLPI_IT_CLIENT_ID OAuth client (GLPI 11+ only)
GLPI_IT_CLIENT_SECRET

The tests log in as the default glpi/glpi super-admin and create data; only
point them at a throwaway instance.
"""

from __future__ import annotations

import os
import uuid

import pytest

from glpi_utils import GlpiAPI, GLPIVersion
from glpi_utils.oauth import GlpiOAuthClient

URL = os.environ.get("GLPI_IT_URL", "")
USER, PASSWORD = "glpi", "glpi"
#: More than the High-Level API's default page (100), to catch pagination bugs.
MIN_LOCATIONS = 130


def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None:
if URL:
return
skip = pytest.mark.skip(reason="GLPI_IT_URL not set (integration tests need a real GLPI)")
for item in items:
if "integration" in item.nodeid:
item.add_marker(skip)


@pytest.fixture(scope="session")
def glpi_version() -> GLPIVersion:
return GLPIVersion(os.environ["GLPI_IT_VERSION"])


@pytest.fixture(scope="session")
def run_id() -> str:
return uuid.uuid4().hex[:8]


@pytest.fixture(scope="session")
def legacy():
api = GlpiAPI(url=URL)
api.login(username=USER, password=PASSWORD)
yield api
api.logout()


@pytest.fixture(scope="session")
def many_locations(legacy: GlpiAPI, run_id: str) -> int:
"""Make sure at least MIN_LOCATIONS locations exist; return the count."""
have = len(legacy.location.get_all_pages())
if have < MIN_LOCATIONS:
legacy.location.create([{"name": f"it-{run_id}-{i}"} for i in range(MIN_LOCATIONS - have)])
return len(legacy.location.get_all_pages())


def _hl_versions() -> list:
major = GLPIVersion(os.environ.get("GLPI_IT_VERSION", "0")).major
if major >= 12:
return [None, "2.3", "3.0"]
if major >= 11:
return [None, "2.3"]
return []


@pytest.fixture(scope="session", params=_hl_versions() or [pytest.param(None, marks=pytest.mark.skip(reason="High-Level API needs GLPI 11+"))],
ids=lambda v: f"hlapi-{v or 'default'}")
def oauth(request):
api = GlpiOAuthClient(
url=URL,
client_id=os.environ.get("GLPI_IT_CLIENT_ID"),
client_secret=os.environ.get("GLPI_IT_CLIENT_SECRET"),
username=USER,
password=PASSWORD,
api_version=request.param,
)
api.authenticate()
yield api
api.close()
64 changes: 64 additions & 0 deletions tests/integration/test_legacy_live.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (C) 2025-2026 Giovanny Rodriguez, Imagunet
"""Legacy REST API (GlpiAPI / AsyncGlpiAPI) against a real GLPI."""

from __future__ import annotations

import os

import pytest

from glpi_utils import AsyncGlpiAPI, GlpiAPI, GlpiNotFoundError

from .conftest import PASSWORD, URL, USER


def test_version_matches_server(legacy: GlpiAPI, glpi_version) -> None:
assert legacy.version == glpi_version


def test_session_helpers(legacy: GlpiAPI) -> None:
assert legacy.get_my_profiles()
assert legacy.get_my_entities()
assert "glpiID" in legacy.get_full_session()


@pytest.mark.parametrize("page_size", [7, 50])
def test_get_all_pages_returns_every_item_once(legacy: GlpiAPI, many_locations: int, page_size: int) -> None:
items = legacy.location.get_all_pages(page_size=page_size)
ids = [i["id"] for i in items]
assert len(ids) == many_locations
assert len(set(ids)) == len(ids)


def test_ticket_lifecycle_with_sub_items(legacy: GlpiAPI, run_id: str) -> None:
name = f"it-legacy-{run_id}"
tid = legacy.ticket.create({"name": name, "content": "integration test"})["id"]
try:
legacy.ticket.update({"id": tid, "status": 2})
assert legacy.ticket.get(tid)["status"] == 2

# GLPI does not take the parent from the URL; add_sub_item fills it in.
legacy.ticket.add_sub_item(tid, "ITILFollowup", {"content": "followup"})
legacy.ticket.add_sub_item(tid, "TicketTask", {"content": "task"})
legacy.ticket.add_sub_item(tid, "ITILSolution", {"content": "solution"})
assert len(legacy.ticket.get_sub_items(tid, "ITILFollowup")) == 1
assert len(legacy.ticket.get_sub_items(tid, "TicketTask")) == 1

found = legacy.ticket.search(
criteria=[{"field": 1, "searchtype": "contains", "value": name}],
forcedisplay=[1, 2],
)
assert found["totalcount"] == 1
finally:
legacy.ticket.delete({"id": tid}, force_purge=True)
with pytest.raises(GlpiNotFoundError):
legacy.ticket.get(tid)


async def test_async_client(many_locations: int, glpi_version) -> None:
async with AsyncGlpiAPI(url=URL) as api:
await api.login(username=USER, password=PASSWORD)
assert await api.get_version() == glpi_version
ids = [i["id"] for i in await api.location.get_all_pages(page_size=50)]
assert len(ids) == many_locations == len(set(ids))
68 changes: 68 additions & 0 deletions tests/integration/test_oauth_live.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (C) 2025-2026 Giovanny Rodriguez, Imagunet
"""High-Level API (GlpiOAuthClient / AsyncGlpiOAuthClient) against a real GLPI 11+."""

from __future__ import annotations

import os

import pytest

from glpi_utils.oauth import AsyncGlpiOAuthClient, GlpiOAuthClient

from .conftest import PASSWORD, URL, USER


def test_server_version(oauth: GlpiOAuthClient, glpi_version) -> None:
assert oauth.get_version() == glpi_version


def test_api_versions_offered(oauth: GlpiOAuthClient, glpi_version) -> None:
versions = {v["version"] for v in oauth.get_api_versions()}
assert "2.3.0" in versions
if glpi_version.major >= 12:
assert "3.0.0" in versions


def test_pagination_past_default_limit(oauth: GlpiOAuthClient, many_locations: int) -> None:
# The HL API ignores "range" and defaults to 100 items: 1.4.5 duplicated
# the first 100 and never returned the rest.
ids = [i["id"] for i in oauth.location.get_all_pages(page_size=50)]
assert len(ids) == many_locations
assert len(set(ids)) == len(ids)


def test_get_all_translates_range(oauth: GlpiOAuthClient, many_locations: int) -> None:
assert len(oauth.location.get_all(range="0-9")) == 10


def test_ticket_lifecycle(oauth: GlpiOAuthClient, run_id: str) -> None:
name = f"it-hl-{run_id}-{oauth.api_version or 'default'}"
tid = oauth.ticket.create({"name": name, "content": "integration test"})["id"]
try:
oauth.update_item("Ticket", {"id": tid, "name": name + "-upd"})
assert oauth.get_item("Ticket", tid)["name"] == name + "-upd"
oauth.add_sub_item("Ticket", tid, "ITILFollowup", {"content": "followup"})
assert len(oauth.get_sub_items("Ticket", tid, "ITILFollowup")) == 1
assert [t["id"] for t in oauth.search("Ticket", filter=f"name=={name}-upd")] == [tid]
finally:
oauth.delete_item("Ticket", {"id": tid})


def test_software_license_route(oauth: GlpiOAuthClient) -> None:
assert isinstance(oauth.item("SoftwareLicense").get_all(), list)


async def test_async_client(oauth: GlpiOAuthClient, glpi_version, many_locations: int) -> None:
async with AsyncGlpiOAuthClient(
url=URL,
client_id=os.environ.get("GLPI_IT_CLIENT_ID"),
client_secret=os.environ.get("GLPI_IT_CLIENT_SECRET"),
username=USER,
password=PASSWORD,
api_version=oauth.api_version,
) as api:
await api.authenticate()
assert await api.get_version() == glpi_version
ids = [i["id"] for i in await api.location.get_all_pages(page_size=50)]
assert len(ids) == many_locations == len(set(ids))
Loading