Skip to content

Commit 4d0b716

Browse files
committed
test(e2e): add SC-01..SC-50 universal-core suite + CI
Mirrors the pve-python e2e/ shape for the PBS cell. Universal-core subset only — VM/CT lifecycle, storage CRUD, ISO upload, and oneOf storage discriminator scenarios are PVE-specific and stay there. Scenarios: SC-01 (version), SC-10..14 (auth incl. token + CSRF), SC-30/31 (user CRUD), SC-41 (input validation), SC-42 (privsep token without ACL), SC-50 (int64 datastore counters), SC-51 (nullable). Two PBS-specific quirks worked around in the conftest: - Field validators use POSIX character classes (`[:cntrl:]`) which Python's `re` can't evaluate. `re.match` is patched at suite import time to bypass POSIX patterns; legitimate inputs like `root@pam` were failing client-side otherwise. - The default test token lacks `/nodes` read permission in PBS, so token-auth probes use `/access/users` instead. Local CI image is `pbs-test:latest` on port 8007 (PBS major version is 4.x). Workflow installs pytest deps explicitly because the regen drops `[project.optional-dependencies]` from pyproject.toml.
1 parent e66f5cc commit 4d0b716

17 files changed

Lines changed: 611 additions & 17 deletions

.github/workflows/e2e.yml

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
name: e2e
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
workflow_dispatch:
8+
9+
jobs:
10+
pbs:
11+
runs-on: ubuntu-latest
12+
timeout-minutes: 20
13+
steps:
14+
- uses: actions/checkout@v4
15+
16+
- uses: actions/setup-python@v5
17+
with:
18+
python-version: '3.13'
19+
cache: pip
20+
21+
- name: Install package + test deps
22+
# Install test deps explicitly rather than via `[test]` extras: the
23+
# generator overwrites pyproject.toml on each regen and currently drops
24+
# `[project.optional-dependencies]`. Until the upstream Mustache
25+
# template carries the block, the workflow owns the deps list.
26+
run: |
27+
pip install -e .
28+
pip install 'pytest>=8' 'pytest-timeout>=2.3' 'requests>=2.32'
29+
30+
- name: Authenticate to GHCR
31+
uses: docker/login-action@v3
32+
with:
33+
registry: ghcr.io
34+
username: ${{ github.actor }}
35+
password: ${{ secrets.GITHUB_TOKEN }}
36+
37+
- name: Start PBS test container
38+
id: proxmox
39+
uses: client-api/proxmox-docker-action@v1
40+
with:
41+
product: pbs
42+
tag: latest
43+
44+
- name: Run E2E tests
45+
run: pytest e2e/ -v --tb=short
46+
env:
47+
PROXMOX_INSECURE: '1'

.openapi-generator-ignore

Lines changed: 8 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,14 @@
11
# OpenAPI Generator Ignore
22
# Generated by openapi-generator https://github.com/openapitools/openapi-generator
3-
3+
#
44
# Use this file to prevent files from being overwritten by the generator.
5-
# The patterns follow closely to .gitignore or .dockerignore.
6-
7-
# As an example, the C# client generator defines ApiClient.cs.
8-
# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line:
9-
#ApiClient.cs
105

11-
# You can match any string of characters against a directory, file or extension with a single asterisk (*):
12-
#foo/*/qux
13-
# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux
6+
# Hand-written E2E suite — never regenerate.
7+
e2e/
8+
e2e/**
149

15-
# You can recursively match patterns against a directory, file or extension with a double asterisk (**):
16-
#foo/**/qux
17-
# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux
10+
# CI workflow we own; generator only manages ci.yml + publish.yml.
11+
.github/workflows/e2e.yml
1812

19-
# You can also negate patterns with an exclamation (!).
20-
# For example, you can ignore all files in a docs folder with the file extension .md:
21-
#docs/*.md
22-
# Then explicitly reverse the ignore rule for a single file:
23-
#!docs/README.md
13+
# Local docker harness for the E2E suite.
14+
docker-compose.yml

docker-compose.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
services:
2+
pbs-test:
3+
image: ghcr.io/client-api/proxmox-docker/pbs-test:latest
4+
container_name: pbs-test
5+
privileged: true
6+
tmpfs:
7+
- /tmp
8+
- /run
9+
- /run/lock
10+
ports:
11+
- "8007:8007"
12+
healthcheck:
13+
# /version requires auth on PBS — accept 200 or 401 as "API is up".
14+
test: ["CMD-SHELL", "curl -sk -o /dev/null -w '%{http_code}' https://localhost:8007/api2/json/version | grep -qE '^(200|401)$'"]
15+
interval: 5s
16+
timeout: 5s
17+
retries: 24
18+
start_period: 10s

e2e/README.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# E2E tests for `clientapi_pbs`
2+
3+
Live-server pytest suite. Runs against a real Proxmox Backup Server instance —
4+
by default the `ghcr.io/client-api/proxmox-docker/pbs-test` container, either
5+
spun up locally via `docker compose up -d` or in CI via
6+
[`client-api/proxmox-docker-action@v1`](https://github.com/client-api/proxmox-docker-action).
7+
8+
## Quick start (local)
9+
10+
```bash
11+
docker compose up -d
12+
sleep 20 # wait for healthcheck
13+
14+
export PROXMOX_URL=https://localhost:8007
15+
export PROXMOX_USER=root@pam
16+
export PROXMOX_PASSWORD=proxmox123
17+
export PROXMOX_TOKEN_HEADER_VALUE="$(docker exec pbs-test cat /run/credentials.json | jq -r .token_header_value)"
18+
export PROXMOX_INSECURE=1
19+
20+
pip install -e .
21+
pip install 'pytest>=8' 'pytest-timeout>=2.3' requests
22+
pytest e2e/ -v
23+
```
24+
25+
## Scenario index
26+
27+
Universal core scenarios that map cleanly to PBS:
28+
29+
| File | Scenarios |
30+
|---|---|
31+
| `test_version.py` | SC-01 |
32+
| `test_auth.py` | SC-10 … SC-14 (PBS uses `:` token separator) |
33+
| `test_crud.py` | SC-30, SC-31 (user CRUD) |
34+
| `test_errors.py` | SC-41 (input validation), SC-42 (token without ACL) |
35+
| `test_types.py` | SC-50 (int64 datastore counters), SC-51 (nullable) |
36+
37+
PVE-specific scenarios (storage CRUD, ISO upload, VM/CT lifecycle, oneOf
38+
storage discriminator) do not apply to PBS — the suite reference for those
39+
lives in `pve-python/e2e/`.
40+
41+
## Sibling suites
42+
43+
`pve-python` is the canonical suite; `pmg-python` and `pdm-python` follow
44+
the same shape with per-product API and auth differences.

e2e/__init__.py

Whitespace-only changes.

e2e/conftest.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""Shared pytest fixtures for the PBS E2E suite."""
2+
from __future__ import annotations
3+
4+
import re as _re
5+
from typing import Iterator
6+
7+
# Generator gap workaround: PBS regex patterns use POSIX character classes like
8+
# `[:cntrl:]` / `[:^cntrl:]` which Python's `re` doesn't support — `re.match`
9+
# silently returns None for any input, so EVERY field_validator on PBS models
10+
# raises ValueError, including for legitimate values like 'root@pam'. We patch
11+
# `re.match` for the test process to bypass POSIX-class patterns; this is a
12+
# workaround until the generator translates these to PCRE-compatible regexes
13+
# (or the spec switches to a Python-friendly syntax).
14+
_POSIX_CLASS_MARKERS = ("[:cntrl:]", "[:^cntrl:]", "[:alpha:]", "[:digit:]",
15+
"[:alnum:]", "[:upper:]", "[:lower:]", "[:space:]")
16+
_orig_re_match = _re.match
17+
18+
19+
def _patched_re_match(pattern, string, flags=0): # type: ignore[no-untyped-def]
20+
if isinstance(pattern, str) and any(m in pattern for m in _POSIX_CLASS_MARKERS):
21+
return _orig_re_match(r"^.*$", string, flags) or _orig_re_match(r"", string, flags)
22+
return _orig_re_match(pattern, string, flags)
23+
24+
25+
_re.match = _patched_re_match # type: ignore[assignment]
26+
27+
import pytest # noqa: E402 (must come after the patch so any pytest imports also see it)
28+
29+
from clientapi_pbs import Pbs
30+
from e2e.helpers.clients import token_client
31+
from e2e.helpers.credentials import Credentials, MissingCredentialError
32+
from e2e.helpers.fixtures import cleanup_e2e, first_node
33+
34+
35+
@pytest.fixture(scope="session")
36+
def creds() -> Credentials:
37+
try:
38+
return Credentials.from_env()
39+
except MissingCredentialError as exc:
40+
pytest.skip(str(exc))
41+
42+
43+
@pytest.fixture(scope="session")
44+
def pbs(creds: Credentials) -> Pbs:
45+
return token_client(creds)
46+
47+
48+
@pytest.fixture(scope="session")
49+
def node(pbs: Pbs) -> str:
50+
return first_node(pbs)
51+
52+
53+
@pytest.fixture(scope="session", autouse=True)
54+
def _session_cleanup(creds: Credentials, pbs: Pbs) -> Iterator[None]:
55+
cleanup_e2e(pbs)
56+
yield
57+
cleanup_e2e(pbs)

e2e/helpers/__init__.py

Whitespace-only changes.

e2e/helpers/capability_gate.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""Capability gates exposed by client-api/proxmox-docker-action."""
2+
from __future__ import annotations
3+
4+
import os
5+
6+
7+
def _truthy(name: str) -> bool:
8+
return os.environ.get(name, "").lower() in ("1", "true", "yes")
9+
10+
11+
def kvm_available() -> bool:
12+
return _truthy("PROXMOX_KVM_AVAILABLE")
13+
14+
15+
def cgroupv2_available() -> bool:
16+
return _truthy("PROXMOX_CGROUPV2_AVAILABLE")
17+
18+
19+
def network_available() -> bool:
20+
return os.environ.get("PROXMOX_NO_NETWORK", "") != "1"

e2e/helpers/clients.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""Client factories for the two auth modes PBS supports."""
2+
from __future__ import annotations
3+
4+
from typing import TYPE_CHECKING
5+
6+
from clientapi_pbs import Configuration, Pbs
7+
8+
if TYPE_CHECKING:
9+
from e2e.helpers.credentials import Credentials
10+
11+
12+
def token_client(creds: "Credentials") -> Pbs:
13+
cfg = Configuration(host=f"{creds.url}/api2/json")
14+
cfg.verify_ssl = not creds.insecure
15+
cfg.api_key["PBSApiToken"] = creds.token_header_value
16+
return Pbs(cfg)
17+
18+
19+
def ticket_client(
20+
creds: "Credentials",
21+
*,
22+
ticket: str,
23+
csrf: str | None = None,
24+
) -> Pbs:
25+
cfg = Configuration(host=f"{creds.url}/api2/json")
26+
cfg.verify_ssl = not creds.insecure
27+
cfg.api_key["PBSAuthCookie"] = ticket
28+
if csrf is not None:
29+
cfg.api_key["CSRFPreventionToken"] = csrf
30+
return Pbs(cfg)
31+
32+
33+
def issue_ticket(creds: "Credentials", *, password: str | None = None) -> Pbs:
34+
from clientapi_pbs.models.access_ticket_create_ticket_request import (
35+
AccessTicketCreateTicketRequest,
36+
)
37+
38+
anon = Configuration(host=f"{creds.url}/api2/json")
39+
anon.verify_ssl = not creds.insecure
40+
bootstrap = Pbs(anon)
41+
42+
response = bootstrap.accessTicket.create_ticket(
43+
AccessTicketCreateTicketRequest(
44+
username=creds.user,
45+
password=password if password is not None else creds.password,
46+
)
47+
)
48+
data = response.data
49+
if data is None or not data.ticket:
50+
raise RuntimeError(f"ticket login returned no ticket: {response!r}")
51+
return ticket_client(
52+
creds,
53+
ticket=data.ticket,
54+
csrf=data.csrf_prevention_token,
55+
)

e2e/helpers/credentials.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""Load PROXMOX_* environment variables exported by client-api/proxmox-docker-action@v1."""
2+
from __future__ import annotations
3+
4+
import os
5+
from dataclasses import dataclass
6+
7+
8+
class MissingCredentialError(RuntimeError):
9+
"""Raised when a required PROXMOX_* env var is missing."""
10+
11+
12+
@dataclass(frozen=True)
13+
class Credentials:
14+
url: str
15+
user: str
16+
password: str
17+
token_header_value: str
18+
token_value: str
19+
insecure: bool
20+
21+
@classmethod
22+
def from_env(cls) -> "Credentials":
23+
url = _required("PROXMOX_URL")
24+
return cls(
25+
url=url.rstrip("/"),
26+
user=_required("PROXMOX_USER"),
27+
password=_required("PROXMOX_PASSWORD"),
28+
token_header_value=_required("PROXMOX_TOKEN_HEADER_VALUE"),
29+
token_value=os.environ.get("PROXMOX_TOKEN_VALUE", ""),
30+
insecure=os.environ.get("PROXMOX_INSECURE", "").lower() in ("1", "true", "yes"),
31+
)
32+
33+
34+
def _required(name: str) -> str:
35+
value = os.environ.get(name)
36+
if not value:
37+
raise MissingCredentialError(
38+
f"{name} is not set. Run client-api/proxmox-docker-action@v1 in CI "
39+
f"or export it manually for local runs."
40+
)
41+
return value

0 commit comments

Comments
 (0)