diff --git a/.github/scripts/glpi_it_setup.sh b/.github/scripts/glpi_it_setup.sh new file mode 100755 index 0000000..b133b05 --- /dev/null +++ b/.github/scripts/glpi_it_setup.sh @@ -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 +# 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 diff --git a/.github/scripts/glpi_oauth_client.php b/.github/scripts/glpi_oauth_client.php new file mode 100644 index 0000000..d1f3ce5 --- /dev/null +++ b/.github/scripts/glpi_oauth_client.php @@ -0,0 +1,28 @@ + ". 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; diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml new file mode 100644 index 0000000..976d6bf --- /dev/null +++ b/.github/workflows/integration.yaml @@ -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 diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..cf0ce63 --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2025-2026 Giovanny Rodriguez, Imagunet diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..97b04f5 --- /dev/null +++ b/tests/integration/conftest.py @@ -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() diff --git a/tests/integration/test_legacy_live.py b/tests/integration/test_legacy_live.py new file mode 100644 index 0000000..99a2ec5 --- /dev/null +++ b/tests/integration/test_legacy_live.py @@ -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)) diff --git a/tests/integration/test_oauth_live.py b/tests/integration/test_oauth_live.py new file mode 100644 index 0000000..d876658 --- /dev/null +++ b/tests/integration/test_oauth_live.py @@ -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))