Skip to content

Commit 33e6a5a

Browse files
committed
test: add PHPUnit controller and Python ExApp integration tests
Signed-off-by: Oleksander Piskun <oleksandr2088@icloud.com>
1 parent 8aabcbc commit 33e6a5a

19 files changed

Lines changed: 1530 additions & 0 deletions

tests/exapp_integration/.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
2+
# SPDX-License-Identifier: AGPL-3.0-or-later
3+
venv/
4+
__pycache__/
5+
.pytest_cache/
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
2+
# SPDX-License-Identifier: AGPL-3.0-or-later

tests/exapp_integration/_client.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
2+
# SPDX-License-Identifier: AGPL-3.0-or-later
3+
"""Tiny HTTP client wrapping AppAPI's auth contract.
4+
5+
Replaces the parts of nc_py_api that the integration tests actually used:
6+
the AppAPI auth headers and a `requests`-style call.
7+
8+
No HMAC / request signing — AppAPI accepts the simple base64 auth header for
9+
ExApp -> Nextcloud calls. See `tests/install_no_init.py` (the existing in-tree
10+
test ExApp), which hits AppAPI's /log endpoint with these same headers.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
from base64 import b64encode
16+
from dataclasses import dataclass, field
17+
from typing import Any
18+
19+
import requests
20+
21+
22+
@dataclass
23+
class AppAPIClient:
24+
base_url: str
25+
app_id: str
26+
app_secret: str
27+
app_version: str = "1.0.0"
28+
user: str = "admin"
29+
extra_headers: dict[str, str] = field(default_factory=dict)
30+
timeout: float = 30.0
31+
32+
def auth_headers(self) -> dict[str, str]:
33+
basic = b64encode(f"{self.user}:{self.app_secret}".encode()).decode()
34+
h = {
35+
"EX-APP-ID": self.app_id,
36+
"EX-APP-VERSION": self.app_version,
37+
"AUTHORIZATION-APP-API": basic,
38+
"AA-VERSION": "2.0.0",
39+
"OCS-APIRequest": "true",
40+
"Accept": "application/json",
41+
}
42+
if self.user != "admin":
43+
h["AA-USER-ID"] = self.user
44+
h.update(self.extra_headers)
45+
return h
46+
47+
def request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
48+
"""Hit `<base_url><path>` with AppAPI auth headers.
49+
50+
Caller-supplied `headers` win over the defaults. Pass `headers={"X": None}`
51+
to drop a default header (used in negative-auth tests).
52+
"""
53+
headers = self.auth_headers()
54+
for k, v in (kwargs.pop("headers", None) or {}).items():
55+
if v is None:
56+
headers.pop(k, None)
57+
else:
58+
headers[k] = v
59+
kwargs.setdefault("timeout", self.timeout)
60+
return requests.request(method, f"{self.base_url}{path}", headers=headers, **kwargs)
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
2+
# SPDX-License-Identifier: AGPL-3.0-or-later
3+
"""Minimal test ExApp for AppAPI integration tests.
4+
5+
Replaces the nc_py_api-based `tests/_install.py`. Implements only the
6+
callbacks AppAPI invokes (`/init`, `/enabled`, `/heartbeat`) plus a few
7+
introspection endpoints used by the integration tests.
8+
"""
9+
10+
import os
11+
from base64 import b64decode
12+
13+
from fastapi import FastAPI, HTTPException, Request as FastAPIRequest
14+
from fastapi.responses import JSONResponse
15+
16+
APP_ID = os.environ["APP_ID"]
17+
APP_SECRET = os.environ["APP_SECRET"]
18+
APP_VERSION = os.environ.get("APP_VERSION", "1.0.0")
19+
20+
APP = FastAPI()
21+
22+
23+
def verify_auth(request: FastAPIRequest) -> str:
24+
"""Validate AppAPI auth headers. Returns the username on success."""
25+
ex_app_id = request.headers.get("EX-APP-ID", "")
26+
ex_app_version = request.headers.get("EX-APP-VERSION", "")
27+
auth_app_api = request.headers.get("AUTHORIZATION-APP-API", "")
28+
29+
if not ex_app_id or not ex_app_version or not auth_app_api:
30+
raise HTTPException(status_code=401, detail="Missing AppAPI headers")
31+
if ex_app_id != APP_ID:
32+
raise HTTPException(status_code=401, detail=f"Invalid EX-APP-ID: {ex_app_id}")
33+
try:
34+
decoded = b64decode(auth_app_api).decode("UTF-8")
35+
username, secret = decoded.split(":", maxsplit=1)
36+
except Exception:
37+
raise HTTPException(status_code=401, detail="Malformed AUTHORIZATION-APP-API")
38+
if secret != APP_SECRET:
39+
raise HTTPException(status_code=401, detail="Invalid app secret")
40+
return username
41+
42+
43+
@APP.post("/init")
44+
async def init_callback(request: FastAPIRequest):
45+
verify_auth(request)
46+
return JSONResponse(content={}, status_code=200)
47+
48+
49+
@APP.put("/enabled")
50+
async def enabled_callback(enabled: bool, request: FastAPIRequest):
51+
verify_auth(request)
52+
return JSONResponse(content={"error": ""}, status_code=200)
53+
54+
55+
@APP.get("/heartbeat")
56+
async def heartbeat_callback():
57+
return JSONResponse(content={"status": "ok"}, status_code=200)
58+
59+
60+
@APP.get("/cfg-echo")
61+
async def cfg_echo(request: FastAPIRequest):
62+
"""Echo the env contract back. Used by test_app_cfg.py."""
63+
verify_auth(request)
64+
return {
65+
"app_id": APP_ID,
66+
"app_version": APP_VERSION,
67+
"app_secret_prefix": APP_SECRET[:6],
68+
}
69+
70+
71+
if __name__ == "__main__":
72+
import uvicorn
73+
uvicorn.run(
74+
"_test_app:APP",
75+
host=os.environ.get("APP_HOST", "0.0.0.0"),
76+
port=int(os.environ.get("APP_PORT", "9009")),
77+
log_level="info",
78+
)
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
2+
# SPDX-License-Identifier: AGPL-3.0-or-later
3+
"""Pytest fixtures for AppAPI integration tests.
4+
5+
These tests assume:
6+
1. A Nextcloud instance reachable at NEXTCLOUD_URL.
7+
2. The test ExApp (`_test_app.py`) is running on TEST_APP_HOST:TEST_APP_PORT.
8+
3. The ExApp has been registered & enabled through OCC against the
9+
manual_daemon (see register_test_exapp.sh).
10+
11+
In the dev VM all three are arranged before running pytest. See README.md.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import os
17+
import subprocess
18+
19+
import pytest
20+
21+
from ._client import AppAPIClient
22+
23+
NEXTCLOUD_URL = os.environ.get("NEXTCLOUD_URL", "http://nextcloud.appapi")
24+
APP_ID = os.environ.get("APP_ID", "test_appapi")
25+
APP_VERSION = os.environ.get("APP_VERSION", "1.0.0")
26+
APP_SECRET = os.environ["APP_SECRET"]
27+
TEST_APP_URL = os.environ.get("TEST_APP_URL", "http://127.0.0.1:9009")
28+
29+
OCC = ["docker", "exec", "appapi-nextcloud-1",
30+
"sudo", "-u", "www-data", "php", "occ"]
31+
32+
33+
@pytest.fixture(scope="session")
34+
def client() -> AppAPIClient:
35+
return AppAPIClient(
36+
base_url=NEXTCLOUD_URL, app_id=APP_ID,
37+
app_secret=APP_SECRET, app_version=APP_VERSION,
38+
)
39+
40+
41+
@pytest.fixture(scope="session")
42+
def app_id() -> str:
43+
return APP_ID
44+
45+
46+
@pytest.fixture(scope="session")
47+
def app_version() -> str:
48+
return APP_VERSION
49+
50+
51+
@pytest.fixture(scope="session")
52+
def app_secret() -> str:
53+
return APP_SECRET
54+
55+
56+
@pytest.fixture(scope="session")
57+
def test_app_url() -> str:
58+
return TEST_APP_URL
59+
60+
61+
@pytest.fixture(scope="session", autouse=True)
62+
def _ensure_test_app_registered() -> None:
63+
r = subprocess.run(OCC + ["app_api:app:list"], capture_output=True, text=True, check=True)
64+
if APP_ID not in r.stdout:
65+
raise RuntimeError(
66+
f"ExApp '{APP_ID}' not registered. Run register_test_exapp.sh first."
67+
)
68+
if "[enabled]" not in next((line for line in r.stdout.splitlines() if APP_ID in line), ""):
69+
raise RuntimeError(f"ExApp '{APP_ID}' is registered but not enabled.")
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
#!/bin/bash
2+
# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
# SPDX-License-Identifier: AGPL-3.0-or-later
4+
# Register and enable the integration-test ExApp via OCC against the live
5+
# Nextcloud container. Idempotent: re-running unregisters first.
6+
#
7+
# Required env: APP_SECRET (32+ char random string).
8+
# Optional env: NEXTCLOUD_CONTAINER (default appapi-nextcloud-1),
9+
# DAEMON_NAME (default manual_daemon),
10+
# APP_ID (default test_appapi),
11+
# APP_VERSION (default 1.0.0),
12+
# APP_PORT (default 9009),
13+
# APP_HOST (default host.docker.internal — host as seen
14+
# from inside the Nextcloud container).
15+
#
16+
# The ExApp itself (uvicorn _test_app:APP) must be running and reachable from
17+
# the Nextcloud container at $APP_HOST:$APP_PORT BEFORE this script runs,
18+
# because `app_api:app:enable` calls /init synchronously.
19+
set -euo pipefail
20+
21+
NEXTCLOUD_CONTAINER=${NEXTCLOUD_CONTAINER:-appapi-nextcloud-1}
22+
DAEMON_NAME=${DAEMON_NAME:-manual_daemon}
23+
APP_ID=${APP_ID:-test_appapi}
24+
APP_VERSION=${APP_VERSION:-1.0.0}
25+
APP_PORT=${APP_PORT:-9009}
26+
APP_HOST=${APP_HOST:-host.docker.internal}
27+
28+
if [[ -z "${APP_SECRET:-}" ]]; then
29+
echo "APP_SECRET must be set (32+ random chars)" >&2
30+
exit 2
31+
fi
32+
33+
OCC=(docker exec "$NEXTCLOUD_CONTAINER" sudo -u www-data php occ)
34+
35+
# Wait for /heartbeat (max 30s)
36+
for _ in $(seq 1 30); do
37+
if docker exec "$NEXTCLOUD_CONTAINER" curl -fs "http://$APP_HOST:$APP_PORT/heartbeat" >/dev/null; then
38+
break
39+
fi
40+
sleep 1
41+
done
42+
docker exec "$NEXTCLOUD_CONTAINER" curl -fs "http://$APP_HOST:$APP_PORT/heartbeat" >/dev/null \
43+
|| { echo "Test ExApp /heartbeat unreachable at $APP_HOST:$APP_PORT" >&2; exit 1; }
44+
45+
# Best-effort unregister so the script is idempotent.
46+
"${OCC[@]}" app_api:app:unregister "$APP_ID" --silent 2>/dev/null || true
47+
48+
JSON=$(cat <<EOF
49+
{"id":"$APP_ID","name":"AppAPI Integration Test ExApp","version":"$APP_VERSION","secret":"$APP_SECRET","port":$APP_PORT,"host":"$APP_HOST","protocol":"http","system_app":0}
50+
EOF
51+
)
52+
"${OCC[@]}" app_api:app:register "$APP_ID" "$DAEMON_NAME" --json-info="$JSON" --silent
53+
"${OCC[@]}" app_api:app:enable "$APP_ID" || true
54+
55+
# Confirm
56+
"${OCC[@]}" app_api:app:list | grep -q "^$APP_ID .* \[enabled\]$" \
57+
|| { echo "ExApp not in enabled state after register" >&2; exit 1; }
58+
59+
echo "Registered & enabled: $APP_ID (secret=${APP_SECRET:0:6}…)"
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
2+
# SPDX-License-Identifier: AGPL-3.0-or-later
3+
"""Env contract: the running ExApp received APP_ID/APP_VERSION/APP_SECRET.
4+
5+
Replaces nc_py_api/tests/actual_tests/nc_app_test.py::test_app_cfg.
6+
7+
We do not assert from the AppAPI side — the contract is "the deploy daemon
8+
populated these env vars in the ExApp container". Test by asking the test
9+
ExApp to echo them back.
10+
"""
11+
12+
import requests
13+
14+
15+
def test_test_app_received_env(test_app_url: str, app_id: str, app_version: str, app_secret: str) -> None:
16+
# Use the same simple base64 auth the ExApp validates incoming requests with.
17+
from base64 import b64encode
18+
auth = b64encode(f"admin:{app_secret}".encode()).decode()
19+
r = requests.get(
20+
f"{test_app_url}/cfg-echo",
21+
headers={
22+
"EX-APP-ID": app_id,
23+
"EX-APP-VERSION": app_version,
24+
"AUTHORIZATION-APP-API": auth,
25+
},
26+
timeout=10,
27+
)
28+
assert r.status_code == 200
29+
body = r.json()
30+
assert body["app_id"] == app_id
31+
assert body["app_version"] == app_version
32+
# Don't transmit/log the full secret; first 6 chars is enough to prove
33+
# the same value was injected.
34+
assert body["app_secret_prefix"] == app_secret[:6]
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
2+
# SPDX-License-Identifier: AGPL-3.0-or-later
3+
"""Lifecycle callbacks: /init and /enabled fire on the registered ExApp.
4+
5+
Replaces the implicit lifecycle coverage from nc_py_api/tests/_install.py and
6+
its `set_handlers` indirection. We use AppAPI's /ex-app/{appId}/enabled OCS
7+
endpoint to flip enabled state and verify the test ExApp returned 200 to the
8+
callback (otherwise the OCC enable command itself would fail).
9+
"""
10+
11+
import subprocess
12+
13+
OCC = ["docker", "exec", "appapi-nextcloud-1",
14+
"sudo", "-u", "www-data", "php", "occ"]
15+
16+
17+
def _is_enabled(app_id: str) -> bool:
18+
r = subprocess.run(OCC + ["app_api:app:list"], capture_output=True, text=True, check=True)
19+
line = next((ln for ln in r.stdout.splitlines() if app_id in ln), "")
20+
return "[enabled]" in line
21+
22+
23+
def test_disable_then_reenable_calls_callbacks(app_id: str) -> None:
24+
"""Toggle enabled state via OCC and confirm AppAPI invoked the ExApp's
25+
/enabled callback (the OCC command would error out non-zero if the
26+
callback returned anything other than 200)."""
27+
assert _is_enabled(app_id), "test fixture must start enabled"
28+
29+
try:
30+
r = subprocess.run(OCC + ["app_api:app:disable", app_id], capture_output=True, text=True)
31+
assert r.returncode == 0, f"disable failed: stdout={r.stdout!r} stderr={r.stderr!r}"
32+
assert not _is_enabled(app_id)
33+
34+
r = subprocess.run(OCC + ["app_api:app:enable", app_id], capture_output=True, text=True)
35+
assert r.returncode == 0, f"enable failed: stdout={r.stdout!r} stderr={r.stderr!r}"
36+
assert _is_enabled(app_id)
37+
finally:
38+
# Make sure we leave the fixture enabled even on test failure.
39+
if not _is_enabled(app_id):
40+
subprocess.run(OCC + ["app_api:app:enable", app_id], check=False)

0 commit comments

Comments
 (0)