From 33e6a5a7896309e35c4c0383401dfd5c2949c85c Mon Sep 17 00:00:00 2001 From: Oleksander Piskun Date: Tue, 28 Apr 2026 11:58:20 +0000 Subject: [PATCH] test: add PHPUnit controller and Python ExApp integration tests Signed-off-by: Oleksander Piskun --- tests/exapp_integration/.gitignore | 5 + tests/exapp_integration/__init__.py | 2 + tests/exapp_integration/_client.py | 60 ++++ tests/exapp_integration/_test_app.py | 78 +++++ tests/exapp_integration/conftest.py | 69 +++++ .../exapp_integration/register_test_exapp.sh | 59 ++++ tests/exapp_integration/test_app_cfg.py | 34 +++ tests/exapp_integration/test_lifecycle.py | 40 +++ tests/exapp_integration/test_security.py | 62 ++++ .../test_user_impersonation.py | 56 ++++ .../Controller/AppConfigControllerTest.php | 107 +++++++ .../php/Controller/OCSExAppControllerTest.php | 100 ++++++ .../Controller/OCSSettingsControllerTest.php | 122 ++++++++ tests/php/Controller/OCSUiControllerTest.php | 284 ++++++++++++++++++ .../Controller/OccCommandControllerTest.php | 116 +++++++ .../Controller/RegistersFakeExAppTrait.php | 76 +++++ .../php/Controller/TalkBotControllerTest.php | 141 +++++++++ .../TaskProcessingControllerTest.php | 118 ++++++++ tests/php/bootstrap.php | 1 + 19 files changed, 1530 insertions(+) create mode 100644 tests/exapp_integration/.gitignore create mode 100644 tests/exapp_integration/__init__.py create mode 100644 tests/exapp_integration/_client.py create mode 100644 tests/exapp_integration/_test_app.py create mode 100644 tests/exapp_integration/conftest.py create mode 100755 tests/exapp_integration/register_test_exapp.sh create mode 100644 tests/exapp_integration/test_app_cfg.py create mode 100644 tests/exapp_integration/test_lifecycle.py create mode 100644 tests/exapp_integration/test_security.py create mode 100644 tests/exapp_integration/test_user_impersonation.py create mode 100644 tests/php/Controller/AppConfigControllerTest.php create mode 100644 tests/php/Controller/OCSExAppControllerTest.php create mode 100644 tests/php/Controller/OCSSettingsControllerTest.php create mode 100644 tests/php/Controller/OCSUiControllerTest.php create mode 100644 tests/php/Controller/OccCommandControllerTest.php create mode 100644 tests/php/Controller/RegistersFakeExAppTrait.php create mode 100644 tests/php/Controller/TalkBotControllerTest.php create mode 100644 tests/php/Controller/TaskProcessingControllerTest.php diff --git a/tests/exapp_integration/.gitignore b/tests/exapp_integration/.gitignore new file mode 100644 index 00000000..89257053 --- /dev/null +++ b/tests/exapp_integration/.gitignore @@ -0,0 +1,5 @@ +# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +venv/ +__pycache__/ +.pytest_cache/ diff --git a/tests/exapp_integration/__init__.py b/tests/exapp_integration/__init__.py new file mode 100644 index 00000000..12a06277 --- /dev/null +++ b/tests/exapp_integration/__init__.py @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/tests/exapp_integration/_client.py b/tests/exapp_integration/_client.py new file mode 100644 index 00000000..5e80d0c2 --- /dev/null +++ b/tests/exapp_integration/_client.py @@ -0,0 +1,60 @@ +# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Tiny HTTP client wrapping AppAPI's auth contract. + +Replaces the parts of nc_py_api that the integration tests actually used: +the AppAPI auth headers and a `requests`-style call. + +No HMAC / request signing — AppAPI accepts the simple base64 auth header for +ExApp -> Nextcloud calls. See `tests/install_no_init.py` (the existing in-tree +test ExApp), which hits AppAPI's /log endpoint with these same headers. +""" + +from __future__ import annotations + +from base64 import b64encode +from dataclasses import dataclass, field +from typing import Any + +import requests + + +@dataclass +class AppAPIClient: + base_url: str + app_id: str + app_secret: str + app_version: str = "1.0.0" + user: str = "admin" + extra_headers: dict[str, str] = field(default_factory=dict) + timeout: float = 30.0 + + def auth_headers(self) -> dict[str, str]: + basic = b64encode(f"{self.user}:{self.app_secret}".encode()).decode() + h = { + "EX-APP-ID": self.app_id, + "EX-APP-VERSION": self.app_version, + "AUTHORIZATION-APP-API": basic, + "AA-VERSION": "2.0.0", + "OCS-APIRequest": "true", + "Accept": "application/json", + } + if self.user != "admin": + h["AA-USER-ID"] = self.user + h.update(self.extra_headers) + return h + + def request(self, method: str, path: str, **kwargs: Any) -> requests.Response: + """Hit `` with AppAPI auth headers. + + Caller-supplied `headers` win over the defaults. Pass `headers={"X": None}` + to drop a default header (used in negative-auth tests). + """ + headers = self.auth_headers() + for k, v in (kwargs.pop("headers", None) or {}).items(): + if v is None: + headers.pop(k, None) + else: + headers[k] = v + kwargs.setdefault("timeout", self.timeout) + return requests.request(method, f"{self.base_url}{path}", headers=headers, **kwargs) diff --git a/tests/exapp_integration/_test_app.py b/tests/exapp_integration/_test_app.py new file mode 100644 index 00000000..3e43a8a9 --- /dev/null +++ b/tests/exapp_integration/_test_app.py @@ -0,0 +1,78 @@ +# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Minimal test ExApp for AppAPI integration tests. + +Replaces the nc_py_api-based `tests/_install.py`. Implements only the +callbacks AppAPI invokes (`/init`, `/enabled`, `/heartbeat`) plus a few +introspection endpoints used by the integration tests. +""" + +import os +from base64 import b64decode + +from fastapi import FastAPI, HTTPException, Request as FastAPIRequest +from fastapi.responses import JSONResponse + +APP_ID = os.environ["APP_ID"] +APP_SECRET = os.environ["APP_SECRET"] +APP_VERSION = os.environ.get("APP_VERSION", "1.0.0") + +APP = FastAPI() + + +def verify_auth(request: FastAPIRequest) -> str: + """Validate AppAPI auth headers. Returns the username on success.""" + ex_app_id = request.headers.get("EX-APP-ID", "") + ex_app_version = request.headers.get("EX-APP-VERSION", "") + auth_app_api = request.headers.get("AUTHORIZATION-APP-API", "") + + if not ex_app_id or not ex_app_version or not auth_app_api: + raise HTTPException(status_code=401, detail="Missing AppAPI headers") + if ex_app_id != APP_ID: + raise HTTPException(status_code=401, detail=f"Invalid EX-APP-ID: {ex_app_id}") + try: + decoded = b64decode(auth_app_api).decode("UTF-8") + username, secret = decoded.split(":", maxsplit=1) + except Exception: + raise HTTPException(status_code=401, detail="Malformed AUTHORIZATION-APP-API") + if secret != APP_SECRET: + raise HTTPException(status_code=401, detail="Invalid app secret") + return username + + +@APP.post("/init") +async def init_callback(request: FastAPIRequest): + verify_auth(request) + return JSONResponse(content={}, status_code=200) + + +@APP.put("/enabled") +async def enabled_callback(enabled: bool, request: FastAPIRequest): + verify_auth(request) + return JSONResponse(content={"error": ""}, status_code=200) + + +@APP.get("/heartbeat") +async def heartbeat_callback(): + return JSONResponse(content={"status": "ok"}, status_code=200) + + +@APP.get("/cfg-echo") +async def cfg_echo(request: FastAPIRequest): + """Echo the env contract back. Used by test_app_cfg.py.""" + verify_auth(request) + return { + "app_id": APP_ID, + "app_version": APP_VERSION, + "app_secret_prefix": APP_SECRET[:6], + } + + +if __name__ == "__main__": + import uvicorn + uvicorn.run( + "_test_app:APP", + host=os.environ.get("APP_HOST", "0.0.0.0"), + port=int(os.environ.get("APP_PORT", "9009")), + log_level="info", + ) diff --git a/tests/exapp_integration/conftest.py b/tests/exapp_integration/conftest.py new file mode 100644 index 00000000..819dd84a --- /dev/null +++ b/tests/exapp_integration/conftest.py @@ -0,0 +1,69 @@ +# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Pytest fixtures for AppAPI integration tests. + +These tests assume: + 1. A Nextcloud instance reachable at NEXTCLOUD_URL. + 2. The test ExApp (`_test_app.py`) is running on TEST_APP_HOST:TEST_APP_PORT. + 3. The ExApp has been registered & enabled through OCC against the + manual_daemon (see register_test_exapp.sh). + +In the dev VM all three are arranged before running pytest. See README.md. +""" + +from __future__ import annotations + +import os +import subprocess + +import pytest + +from ._client import AppAPIClient + +NEXTCLOUD_URL = os.environ.get("NEXTCLOUD_URL", "http://nextcloud.appapi") +APP_ID = os.environ.get("APP_ID", "test_appapi") +APP_VERSION = os.environ.get("APP_VERSION", "1.0.0") +APP_SECRET = os.environ["APP_SECRET"] +TEST_APP_URL = os.environ.get("TEST_APP_URL", "http://127.0.0.1:9009") + +OCC = ["docker", "exec", "appapi-nextcloud-1", + "sudo", "-u", "www-data", "php", "occ"] + + +@pytest.fixture(scope="session") +def client() -> AppAPIClient: + return AppAPIClient( + base_url=NEXTCLOUD_URL, app_id=APP_ID, + app_secret=APP_SECRET, app_version=APP_VERSION, + ) + + +@pytest.fixture(scope="session") +def app_id() -> str: + return APP_ID + + +@pytest.fixture(scope="session") +def app_version() -> str: + return APP_VERSION + + +@pytest.fixture(scope="session") +def app_secret() -> str: + return APP_SECRET + + +@pytest.fixture(scope="session") +def test_app_url() -> str: + return TEST_APP_URL + + +@pytest.fixture(scope="session", autouse=True) +def _ensure_test_app_registered() -> None: + r = subprocess.run(OCC + ["app_api:app:list"], capture_output=True, text=True, check=True) + if APP_ID not in r.stdout: + raise RuntimeError( + f"ExApp '{APP_ID}' not registered. Run register_test_exapp.sh first." + ) + if "[enabled]" not in next((line for line in r.stdout.splitlines() if APP_ID in line), ""): + raise RuntimeError(f"ExApp '{APP_ID}' is registered but not enabled.") diff --git a/tests/exapp_integration/register_test_exapp.sh b/tests/exapp_integration/register_test_exapp.sh new file mode 100755 index 00000000..f2e3fa07 --- /dev/null +++ b/tests/exapp_integration/register_test_exapp.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +# Register and enable the integration-test ExApp via OCC against the live +# Nextcloud container. Idempotent: re-running unregisters first. +# +# Required env: APP_SECRET (32+ char random string). +# Optional env: NEXTCLOUD_CONTAINER (default appapi-nextcloud-1), +# DAEMON_NAME (default manual_daemon), +# APP_ID (default test_appapi), +# APP_VERSION (default 1.0.0), +# APP_PORT (default 9009), +# APP_HOST (default host.docker.internal — host as seen +# from inside the Nextcloud container). +# +# The ExApp itself (uvicorn _test_app:APP) must be running and reachable from +# the Nextcloud container at $APP_HOST:$APP_PORT BEFORE this script runs, +# because `app_api:app:enable` calls /init synchronously. +set -euo pipefail + +NEXTCLOUD_CONTAINER=${NEXTCLOUD_CONTAINER:-appapi-nextcloud-1} +DAEMON_NAME=${DAEMON_NAME:-manual_daemon} +APP_ID=${APP_ID:-test_appapi} +APP_VERSION=${APP_VERSION:-1.0.0} +APP_PORT=${APP_PORT:-9009} +APP_HOST=${APP_HOST:-host.docker.internal} + +if [[ -z "${APP_SECRET:-}" ]]; then + echo "APP_SECRET must be set (32+ random chars)" >&2 + exit 2 +fi + +OCC=(docker exec "$NEXTCLOUD_CONTAINER" sudo -u www-data php occ) + +# Wait for /heartbeat (max 30s) +for _ in $(seq 1 30); do + if docker exec "$NEXTCLOUD_CONTAINER" curl -fs "http://$APP_HOST:$APP_PORT/heartbeat" >/dev/null; then + break + fi + sleep 1 +done +docker exec "$NEXTCLOUD_CONTAINER" curl -fs "http://$APP_HOST:$APP_PORT/heartbeat" >/dev/null \ + || { echo "Test ExApp /heartbeat unreachable at $APP_HOST:$APP_PORT" >&2; exit 1; } + +# Best-effort unregister so the script is idempotent. +"${OCC[@]}" app_api:app:unregister "$APP_ID" --silent 2>/dev/null || true + +JSON=$(cat <&2; exit 1; } + +echo "Registered & enabled: $APP_ID (secret=${APP_SECRET:0:6}…)" diff --git a/tests/exapp_integration/test_app_cfg.py b/tests/exapp_integration/test_app_cfg.py new file mode 100644 index 00000000..21aa6285 --- /dev/null +++ b/tests/exapp_integration/test_app_cfg.py @@ -0,0 +1,34 @@ +# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Env contract: the running ExApp received APP_ID/APP_VERSION/APP_SECRET. + +Replaces nc_py_api/tests/actual_tests/nc_app_test.py::test_app_cfg. + +We do not assert from the AppAPI side — the contract is "the deploy daemon +populated these env vars in the ExApp container". Test by asking the test +ExApp to echo them back. +""" + +import requests + + +def test_test_app_received_env(test_app_url: str, app_id: str, app_version: str, app_secret: str) -> None: + # Use the same simple base64 auth the ExApp validates incoming requests with. + from base64 import b64encode + auth = b64encode(f"admin:{app_secret}".encode()).decode() + r = requests.get( + f"{test_app_url}/cfg-echo", + headers={ + "EX-APP-ID": app_id, + "EX-APP-VERSION": app_version, + "AUTHORIZATION-APP-API": auth, + }, + timeout=10, + ) + assert r.status_code == 200 + body = r.json() + assert body["app_id"] == app_id + assert body["app_version"] == app_version + # Don't transmit/log the full secret; first 6 chars is enough to prove + # the same value was injected. + assert body["app_secret_prefix"] == app_secret[:6] diff --git a/tests/exapp_integration/test_lifecycle.py b/tests/exapp_integration/test_lifecycle.py new file mode 100644 index 00000000..50d3601b --- /dev/null +++ b/tests/exapp_integration/test_lifecycle.py @@ -0,0 +1,40 @@ +# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Lifecycle callbacks: /init and /enabled fire on the registered ExApp. + +Replaces the implicit lifecycle coverage from nc_py_api/tests/_install.py and +its `set_handlers` indirection. We use AppAPI's /ex-app/{appId}/enabled OCS +endpoint to flip enabled state and verify the test ExApp returned 200 to the +callback (otherwise the OCC enable command itself would fail). +""" + +import subprocess + +OCC = ["docker", "exec", "appapi-nextcloud-1", + "sudo", "-u", "www-data", "php", "occ"] + + +def _is_enabled(app_id: str) -> bool: + r = subprocess.run(OCC + ["app_api:app:list"], capture_output=True, text=True, check=True) + line = next((ln for ln in r.stdout.splitlines() if app_id in ln), "") + return "[enabled]" in line + + +def test_disable_then_reenable_calls_callbacks(app_id: str) -> None: + """Toggle enabled state via OCC and confirm AppAPI invoked the ExApp's + /enabled callback (the OCC command would error out non-zero if the + callback returned anything other than 200).""" + assert _is_enabled(app_id), "test fixture must start enabled" + + try: + r = subprocess.run(OCC + ["app_api:app:disable", app_id], capture_output=True, text=True) + assert r.returncode == 0, f"disable failed: stdout={r.stdout!r} stderr={r.stderr!r}" + assert not _is_enabled(app_id) + + r = subprocess.run(OCC + ["app_api:app:enable", app_id], capture_output=True, text=True) + assert r.returncode == 0, f"enable failed: stdout={r.stdout!r} stderr={r.stderr!r}" + assert _is_enabled(app_id) + finally: + # Make sure we leave the fixture enabled even on test failure. + if not _is_enabled(app_id): + subprocess.run(OCC + ["app_api:app:enable", app_id], check=False) diff --git a/tests/exapp_integration/test_security.py b/tests/exapp_integration/test_security.py new file mode 100644 index 00000000..66678689 --- /dev/null +++ b/tests/exapp_integration/test_security.py @@ -0,0 +1,62 @@ +# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Auth contract tests against AppAPI's middleware. + +Replaces nc_py_api/tests/_app_security_checks.py. We pick a real +AppAPI-protected endpoint (`/api/v1/ui/top-menu`) and exercise the four header +permutations that nc_py_api covered. +""" + +import requests + +from ._client import AppAPIClient + +PROTECTED_PATH = "/ocs/v1.php/apps/app_api/api/v1/ui/top-menu" + + +def _get(client: AppAPIClient, **headers_override) -> requests.Response: + return client.request("GET", PROTECTED_PATH, + params={"name": "test_security_probe"}, + headers=headers_override) + + +def test_valid_headers_succeed(client: AppAPIClient) -> None: + """With proper auth, the endpoint reaches the controller. The probe row + does not exist, so we get OCS 404 — but HTTP is 200 and the OCS auth + layer (statuscode 997) is NOT triggered.""" + r = _get(client) + assert r.status_code == 200 + assert r.json()["ocs"]["meta"]["statuscode"] != 997 + + +def test_missing_authorization_header(client: AppAPIClient) -> None: + r = _get(client, **{"AUTHORIZATION-APP-API": None}) + assert r.status_code == 401 + assert r.json()["ocs"]["meta"]["statuscode"] == 997 + + +def test_missing_ex_app_id_header(client: AppAPIClient) -> None: + r = _get(client, **{"EX-APP-ID": None}) + assert r.status_code == 401 + + +def test_wrong_app_secret(client: AppAPIClient, app_id: str) -> None: + bad = AppAPIClient( + base_url=client.base_url, app_id=app_id, + app_secret="this_is_definitely_not_the_secret", + app_version=client.app_version, + ) + r = bad.request("GET", PROTECTED_PATH, params={"name": "x"}) + assert r.status_code == 401 + assert r.json()["ocs"]["meta"]["statuscode"] == 997 + + +def test_unknown_app_id(client: AppAPIClient) -> None: + bad = AppAPIClient( + base_url=client.base_url, + app_id="phpunit_does_not_exist_zzz", + app_secret=client.app_secret, + app_version=client.app_version, + ) + r = bad.request("GET", PROTECTED_PATH, params={"name": "x"}) + assert r.status_code == 401 diff --git a/tests/exapp_integration/test_user_impersonation.py b/tests/exapp_integration/test_user_impersonation.py new file mode 100644 index 00000000..584ed158 --- /dev/null +++ b/tests/exapp_integration/test_user_impersonation.py @@ -0,0 +1,56 @@ +# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +"""AA-USER-ID header round-trip through AppAPI's auth middleware. + +Replaces nc_py_api/tests/actual_tests/nc_app_test.py::test_change_user_async. +We send a request to a Nextcloud OCS endpoint that returns the current user +ID (`/cloud/user`), once with no AA-USER-ID and once with one explicitly set. +""" + +from ._client import AppAPIClient + + +def _whoami(client: AppAPIClient) -> str: + r = client.request( + "GET", "/ocs/v1.php/cloud/user", + headers={"OCS-APIRequest": "true"}, + ) + assert r.status_code == 200, r.text + payload = r.json()["ocs"] + assert payload["meta"]["statuscode"] == 100, payload["meta"] + return payload["data"]["id"] + + +def test_default_user_is_admin(client: AppAPIClient) -> None: + assert _whoami(client) == "admin" + + +def test_aa_user_id_changes_effective_user(client: AppAPIClient) -> None: + """When the ExApp passes AA-USER-ID, AppAPI's auth middleware impersonates + that user for the duration of the request — Nextcloud's /cloud/user then + reports the impersonated user, not admin.""" + target_user = _ensure_test_user_exists() + impersonated = AppAPIClient( + base_url=client.base_url, app_id=client.app_id, + app_secret=client.app_secret, app_version=client.app_version, + user=target_user, + ) + assert _whoami(impersonated) == target_user + + +def _ensure_test_user_exists() -> str: + """Make sure a non-admin user exists for the impersonation check.""" + import subprocess + user = "phpunit_user_impersonation" + OCC = ["docker", "exec", "appapi-nextcloud-1", + "sudo", "-u", "www-data", "php", "occ"] + r = subprocess.run(OCC + ["user:info", user], capture_output=True, text=True) + if r.returncode != 0: + env = "OC_PASS=phpunit_password_for_test" + subprocess.run( + ["docker", "exec", "-e", env, "appapi-nextcloud-1", + "sudo", "-u", "www-data", "-E", "php", "occ", + "user:add", "--password-from-env", user], + check=True, capture_output=True, + ) + return user diff --git a/tests/php/Controller/AppConfigControllerTest.php b/tests/php/Controller/AppConfigControllerTest.php new file mode 100644 index 00000000..6158cd86 --- /dev/null +++ b/tests/php/Controller/AppConfigControllerTest.php @@ -0,0 +1,107 @@ +request = $this->createMock(IRequest::class); + $this->request->method('getHeader')->willReturnCallback( + fn (string $name): string => $name === 'EX-APP-ID' ? self::TEST_APP_ID : '' + ); + $this->service = Server::get(ExAppConfigService::class); + $this->controller = new AppConfigController($this->request, $this->service); + $this->cleanup(); + } + + protected function tearDown(): void { + $this->cleanup(); + parent::tearDown(); + } + + private function cleanup(): void { + $this->service->deleteAppConfigValues([self::KEY_PLAIN, self::KEY_SECRET], self::TEST_APP_ID); + } + + public function testSensitiveFlagPersistsOnSet(): void { + $response = $this->controller->setAppConfigValue(self::KEY_SECRET, '123', sensitive: 1); + self::assertSame(Http::STATUS_OK, $response->getStatus()); + $entity = $response->getData(); + self::assertSame('123', $entity->getConfigvalue()); + self::assertSame(1, $entity->getSensitive()); + } + + public function testNonSensitiveDefaultsToZero(): void { + $response = $this->controller->setAppConfigValue(self::KEY_PLAIN, 'plain', sensitive: null); + self::assertSame(Http::STATUS_OK, $response->getStatus()); + // The persisted entity has sensitive=0 when null is passed (no flag set). + self::assertSame(0, $response->getData()->getSensitive()); + } + + public function testSensitiveFlagPreservedOnUpdateWithoutFlag(): void { + // First write: sensitive=1. + $this->controller->setAppConfigValue(self::KEY_SECRET, 'orig', sensitive: 1); + // Second write to the SAME key without specifying sensitive — the existing flag must stay at 1. + // ExAppConfigService only calls setSensitive() when $sensitive !== null. + $response = $this->controller->setAppConfigValue(self::KEY_SECRET, 'updated', sensitive: null); + self::assertSame(Http::STATUS_OK, $response->getStatus()); + self::assertSame(1, $response->getData()->getSensitive()); + } + + public function testGetReturnsAllKeys(): void { + $this->controller->setAppConfigValue(self::KEY_PLAIN, 'a', sensitive: 0); + $this->controller->setAppConfigValue(self::KEY_SECRET, 'b', sensitive: 1); + + $response = $this->controller->getAppConfigValues([self::KEY_PLAIN, self::KEY_SECRET]); + self::assertSame(Http::STATUS_OK, $response->getStatus()); + $rows = $response->getData(); + self::assertCount(2, $rows); + // Verify that getAppConfigValues decrypts sensitive values back to plaintext on read. + // Without decryption the caller would get the encrypted blob and not the original 'b'. + $byKey = array_column($rows, 'configvalue', 'configkey'); + self::assertSame('a', $byKey[self::KEY_PLAIN]); + self::assertSame('b', $byKey[self::KEY_SECRET]); + } + + public function testEmptyKeyRejected(): void { + $this->expectException(OCSBadRequestException::class); + $this->controller->setAppConfigValue('', 'x'); + } + + public function testDeleteMissingThrowsNotFound(): void { + $this->expectException(OCSNotFoundException::class); + $this->controller->deleteAppConfigValues(['no_such_key_phpunit_xyz']); + } +} diff --git a/tests/php/Controller/OCSExAppControllerTest.php b/tests/php/Controller/OCSExAppControllerTest.php new file mode 100644 index 00000000..f2bbc631 --- /dev/null +++ b/tests/php/Controller/OCSExAppControllerTest.php @@ -0,0 +1,100 @@ +insertFakeExApp(self::ENABLED_APP_ID, self::ENABLED_PORT, enabled: 1); + $this->insertFakeExApp(self::DISABLED_APP_ID, self::DISABLED_PORT, enabled: 0); + /** @var IRequest&MockObject $request */ + $request = $this->createMock(IRequest::class); + $this->controller = new OCSExAppController( + $request, + Server::get(AppAPIService::class), + Server::get(ExAppService::class), + Server::get(IURLGenerator::class), + ); + } + + protected function tearDown(): void { + $this->deleteFakeExApp(self::ENABLED_APP_ID); + $this->deleteFakeExApp(self::DISABLED_APP_ID); + parent::tearDown(); + } + + public function testGetExAppsListAllIncludesBoth(): void { + $response = $this->controller->getExAppsList('all'); + self::assertSame(Http::STATUS_OK, $response->getStatus()); + $ids = array_column($response->getData(), 'id'); + self::assertContains(self::ENABLED_APP_ID, $ids); + self::assertContains(self::DISABLED_APP_ID, $ids); + } + + public function testGetExAppsListEnabledFiltersDisabled(): void { + $response = $this->controller->getExAppsList('enabled'); + self::assertSame(Http::STATUS_OK, $response->getStatus()); + $ids = array_column($response->getData(), 'id'); + self::assertContains(self::ENABLED_APP_ID, $ids); + self::assertNotContains(self::DISABLED_APP_ID, $ids); + foreach ($response->getData() as $row) { + self::assertTrue($row['enabled'], 'list=enabled returned a disabled ExApp: ' . ($row['id'] ?? '?')); + } + } + + public function testGetExAppsListInvalidValueReturnsBadRequest(): void { + $response = $this->controller->getExAppsList('not_a_valid_value'); + self::assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus()); + } + + public function testGetExAppFormatsKnownEntry(): void { + $response = $this->controller->getExApp(self::ENABLED_APP_ID); + self::assertSame(Http::STATUS_OK, $response->getStatus()); + $data = $response->getData(); + self::assertSame(self::ENABLED_APP_ID, $data['id']); + self::assertSame('1.0.0', $data['version']); + self::assertTrue($data['enabled']); + } + + public function testGetExAppMissingReturns404(): void { + self::assertSame( + Http::STATUS_NOT_FOUND, + $this->controller->getExApp('phpunit_does_not_exist')->getStatus() + ); + } +} diff --git a/tests/php/Controller/OCSSettingsControllerTest.php b/tests/php/Controller/OCSSettingsControllerTest.php new file mode 100644 index 00000000..e39eb3ca --- /dev/null +++ b/tests/php/Controller/OCSSettingsControllerTest.php @@ -0,0 +1,122 @@ +request = $this->createMock(IRequest::class); + $this->request->method('getHeader')->willReturnCallback( + fn (string $name): string => $name === 'EX-APP-ID' ? self::TEST_APP_ID : '' + ); + $this->service = Server::get(SettingsService::class); + $this->controller = new OCSSettingsController($this->request, $this->service); + $this->service->unregisterForm(self::TEST_APP_ID, self::TEST_FORM_ID); + } + + protected function tearDown(): void { + $this->service->unregisterForm(self::TEST_APP_ID, self::TEST_FORM_ID); + parent::tearDown(); + } + + private function buildScheme(string $title = 'Test form'): array { + return [ + 'id' => self::TEST_FORM_ID, + 'priority' => 50, + 'section_type' => 'admin', + 'section_id' => 'phpunit_section', + 'title' => $title, + 'description' => 'PHPUnit description', + 'doc_url' => '', + 'fields' => [[ + 'id' => 'field_1', + 'title' => 'Multi-selection', + 'type' => 'multi-select', + 'default' => ['foo', 'bar'], + 'description' => 'pick some', + 'placeholder' => '', + 'label' => '', + 'notify' => false, + 'sensitive' => false, + 'options' => [ + ['name' => 'foo', 'value' => 'foo'], + ['name' => 'bar', 'value' => 'bar'], + ], + ]], + ]; + } + + public function testRegisterGetUnregister(): void { + $response = $this->controller->registerForm($this->buildScheme()); + self::assertSame(Http::STATUS_OK, $response->getStatus()); + + $response = $this->controller->getForm(self::TEST_FORM_ID); + self::assertSame(Http::STATUS_OK, $response->getStatus()); + $scheme = $response->getData(); + self::assertSame(self::TEST_FORM_ID, $scheme['id']); + self::assertSame('Test form', $scheme['title']); + self::assertSame('admin', $scheme['section_type']); + self::assertSame('multi-select', $scheme['fields'][0]['type']); + // Service decorates with storage_type=external to mark it as ExApp-owned. + self::assertSame('external', $scheme['storage_type']); + + $response = $this->controller->unregisterForm(self::TEST_FORM_ID); + self::assertSame(Http::STATUS_OK, $response->getStatus()); + + // After unregister, GET returns 404 status code (controller returns DataResponse([], 404), no exception). + self::assertSame( + Http::STATUS_NOT_FOUND, + $this->controller->getForm(self::TEST_FORM_ID)->getStatus() + ); + } + + public function testGetMissingFormReturns404(): void { + self::assertSame( + Http::STATUS_NOT_FOUND, + $this->controller->getForm('does_not_exist')->getStatus() + ); + } + + public function testUnregisterMissingFormReturns404(): void { + self::assertSame( + Http::STATUS_NOT_FOUND, + $this->controller->unregisterForm('does_not_exist')->getStatus() + ); + } + + public function testRegisterReplacesExistingForm(): void { + $this->controller->registerForm($this->buildScheme('First title')); + $this->controller->registerForm($this->buildScheme('Second title')); + + $scheme = $this->controller->getForm(self::TEST_FORM_ID)->getData(); + self::assertSame('Second title', $scheme['title']); + } +} diff --git a/tests/php/Controller/OCSUiControllerTest.php b/tests/php/Controller/OCSUiControllerTest.php new file mode 100644 index 00000000..ffbfa9e5 --- /dev/null +++ b/tests/php/Controller/OCSUiControllerTest.php @@ -0,0 +1,284 @@ +request = $this->createMock(IRequest::class); + $this->request->method('getHeader')->willReturnCallback( + fn (string $name): string => $name === 'EX-APP-ID' ? self::TEST_APP_ID : '' + ); + + $this->topMenuService = Server::get(TopMenuService::class); + $this->filesActionsService = Server::get(FilesActionsMenuService::class); + $this->initialStateService = Server::get(InitialStateService::class); + $this->scriptsService = Server::get(ScriptsService::class); + $this->stylesService = Server::get(StylesService::class); + + $this->controller = new OCSUiController( + $this->request, + $this->filesActionsService, + $this->topMenuService, + $this->initialStateService, + $this->scriptsService, + $this->stylesService, + ); + + $this->cleanupTestRows(); + } + + protected function tearDown(): void { + $this->cleanupTestRows(); + parent::tearDown(); + } + + private function cleanupTestRows(): void { + // Best-effort cleanup. Each service's delete methods take (appId, ...identity) and return false if the row + // does not exist — we don't care. + foreach (['main_menu', 'second_menu'] as $name) { + $this->topMenuService->unregisterExAppMenuEntry(self::TEST_APP_ID, $name); + } + foreach (['ui_action_v1', 'ui_action_v2'] as $name) { + $this->filesActionsService->unregisterFileActionMenu(self::TEST_APP_ID, $name); + } + foreach ([ + ['top_menu', 'page_a', 'state_a'], + ['top_menu', 'page_a', 'state_b'], + ] as [$type, $name, $key]) { + $this->initialStateService->deleteExAppInitialState(self::TEST_APP_ID, $type, $name, $key); + } + foreach ([ + ['top_menu', 'page_a', 'js/script_a.js'], + ['top_menu', 'page_a', 'js/script_b.js'], + ['top_menu', 'page_slash', 'js/script_slash.js'], + ] as [$type, $name, $path]) { + $this->scriptsService->deleteExAppScript(self::TEST_APP_ID, $type, $name, $path); + } + foreach ([ + ['top_menu', 'page_a', 'css/style_a.css'], + ['top_menu', 'page_slash', 'css/style_slash.css'], + ] as [$type, $name, $path]) { + $this->stylesService->deleteExAppStyle(self::TEST_APP_ID, $type, $name, $path); + } + } + + /** + * Controller methods return entities wrapped in DataResponse without serializing — JSON conversion happens at + * HTTP-render time. In unit tests we read the entity through jsonSerialize() to mirror the wire format. + * + * @return array + */ + private static function asArray(mixed $data): array { + if ($data instanceof \JsonSerializable) { + return $data->jsonSerialize(); + } + if (is_array($data)) { + return $data; + } + self::fail('Unexpected data type: ' . get_debug_type($data)); + } + + public function testTopMenuRegisterGetUnregister(): void { + $response = $this->controller->registerExAppMenuEntry( + name: 'main_menu', displayName: 'Main Menu', icon: '', adminRequired: 0 + ); + self::assertSame(Http::STATUS_OK, $response->getStatus()); + + $response = $this->controller->getExAppMenuEntry('main_menu'); + self::assertSame(Http::STATUS_OK, $response->getStatus()); + $data = self::asArray($response->getData()); + self::assertSame(self::TEST_APP_ID, $data['appid']); + self::assertSame('main_menu', $data['name']); + self::assertSame('Main Menu', $data['display_name']); + self::assertSame(0, $data['admin_required']); + + $response = $this->controller->unregisterExAppMenuEntry('main_menu'); + self::assertSame(Http::STATUS_OK, $response->getStatus()); + + // Verify gone + $this->expectException(OCSNotFoundException::class); + $this->controller->getExAppMenuEntry('main_menu'); + } + + public function testTopMenuGetMissingThrowsNotFound(): void { + $this->expectException(OCSNotFoundException::class); + $this->controller->getExAppMenuEntry('does_not_exist'); + } + + public function testTopMenuUnregisterMissingThrowsNotFound(): void { + $this->expectException(OCSNotFoundException::class); + $this->controller->unregisterExAppMenuEntry('does_not_exist'); + } + + public function testTopMenuRegisterIsIdempotentReplace(): void { + // Same name, different displayName — service uses insertOrUpdate semantics. + $this->controller->registerExAppMenuEntry('main_menu', 'First', '', 0); + $this->controller->registerExAppMenuEntry('main_menu', 'Second', '', 1); + $data = self::asArray($this->controller->getExAppMenuEntry('main_menu')->getData()); + self::assertSame('Second', $data['display_name']); + self::assertSame(1, $data['admin_required']); + } + + public function testFileActionsV1RegisterGetUnregister(): void { + // Pass leading-slash on action_handler — FilesActionsMenuService::registerFileActionMenu strips it via ltrim. + $response = $this->controller->registerFileActionMenu( + name: 'ui_action_v1', displayName: 'V1 Action', actionHandler: '/handler', + icon: '', mime: 'file', permissions: 1, order: 1 + ); + self::assertSame(Http::STATUS_OK, $response->getStatus()); + + $data = self::asArray($this->controller->getFileActionMenu('ui_action_v1')->getData()); + self::assertSame('ui_action_v1', $data['name']); + self::assertSame('V1 Action', $data['display_name']); + self::assertSame('handler', $data['action_handler'], 'leading slash should be stripped'); + self::assertSame('file', $data['mime']); + self::assertSame('1', (string)$data['permissions']); + self::assertSame(1, $data['order']); + self::assertSame('1.0', $data['version']); + + $this->controller->unregisterFileActionMenu('ui_action_v1'); + $this->expectException(OCSNotFoundException::class); + $this->controller->getFileActionMenu('ui_action_v1'); + } + + public function testFileActionsV2HasV2Version(): void { + $this->controller->registerFileActionMenuV2( + name: 'ui_action_v2', displayName: 'V2 Action', actionHandler: '/handler2', + icon: '', mime: 'image', permissions: 31, order: 0 + ); + $data = self::asArray($this->controller->getFileActionMenu('ui_action_v2')->getData()); + self::assertSame('image', $data['mime']); + self::assertSame('31', (string)$data['permissions']); + self::assertSame('2.0', $data['version']); + } + + public function testInitialStateRoundTrip(): void { + $value = ['key1' => 1, 'key2' => 'two', 'nested' => ['a', 'b']]; + $this->controller->setExAppInitialState('top_menu', 'page_a', 'state_a', $value); + + $data = self::asArray($this->controller->getExAppInitialState('top_menu', 'page_a', 'state_a')->getData()); + self::assertSame(self::TEST_APP_ID, $data['appid']); + self::assertSame('top_menu', $data['type']); + self::assertSame('page_a', $data['name']); + self::assertSame('state_a', $data['key']); + self::assertSame($value, $data['value']); + + $this->controller->deleteExAppInitialState('top_menu', 'page_a', 'state_a'); + $this->expectException(OCSNotFoundException::class); + $this->controller->getExAppInitialState('top_menu', 'page_a', 'state_a'); + } + + public function testInitialStateMultipleKeysCoexist(): void { + $this->controller->setExAppInitialState('top_menu', 'page_a', 'state_a', ['v' => 1]); + $this->controller->setExAppInitialState('top_menu', 'page_a', 'state_b', ['v' => 2]); + + $a = self::asArray($this->controller->getExAppInitialState('top_menu', 'page_a', 'state_a')->getData()); + $b = self::asArray($this->controller->getExAppInitialState('top_menu', 'page_a', 'state_b')->getData()); + self::assertSame(['v' => 1], $a['value']); + self::assertSame(['v' => 2], $b['value']); + } + + public function testScriptRoundTrip(): void { + $this->controller->setExAppScript('top_menu', 'page_a', 'js/script_a.js', ''); + $data = self::asArray($this->controller->getExAppScript('top_menu', 'page_a', 'js/script_a.js')->getData()); + self::assertSame('js/script_a.js', $data['path']); + self::assertSame('', $data['after_app_id']); + + $this->controller->deleteExAppScript('top_menu', 'page_a', 'js/script_a.js'); + $this->expectException(OCSNotFoundException::class); + $this->controller->getExAppScript('top_menu', 'page_a', 'js/script_a.js'); + } + + public function testScriptAfterAppIdPersisted(): void { + $this->controller->setExAppScript('top_menu', 'page_a', 'js/script_b.js', 'files'); + $data = self::asArray($this->controller->getExAppScript('top_menu', 'page_a', 'js/script_b.js')->getData()); + self::assertSame('files', $data['after_app_id']); + } + + public function testStyleRoundTrip(): void { + $this->controller->setExAppStyle('top_menu', 'page_a', 'css/style_a.css'); + $data = self::asArray($this->controller->getExAppStyle('top_menu', 'page_a', 'css/style_a.css')->getData()); + self::assertSame('css/style_a.css', $data['path']); + + $this->controller->deleteExAppStyle('top_menu', 'page_a', 'css/style_a.css'); + $this->expectException(OCSNotFoundException::class); + $this->controller->getExAppStyle('top_menu', 'page_a', 'css/style_a.css'); + } + + public function testScriptPathLeadingSlashNormalized(): void { + // Store with leading slash; service should ltrim it. Lookups must succeed both with and without it. + $this->controller->setExAppScript('top_menu', 'page_slash', '/js/script_slash.js', ''); + $withSlash = self::asArray( + $this->controller->getExAppScript('top_menu', 'page_slash', '/js/script_slash.js')->getData() + ); + $withoutSlash = self::asArray( + $this->controller->getExAppScript('top_menu', 'page_slash', 'js/script_slash.js')->getData() + ); + self::assertSame('js/script_slash.js', $withSlash['path']); + self::assertSame($withSlash, $withoutSlash); + + // Delete must also accept the slashed form. + $this->controller->deleteExAppScript('top_menu', 'page_slash', '/js/script_slash.js'); + $this->expectException(OCSNotFoundException::class); + $this->controller->getExAppScript('top_menu', 'page_slash', 'js/script_slash.js'); + } + + public function testStylePathLeadingSlashNormalized(): void { + $this->controller->setExAppStyle('top_menu', 'page_slash', '/css/style_slash.css'); + $withSlash = self::asArray( + $this->controller->getExAppStyle('top_menu', 'page_slash', '/css/style_slash.css')->getData() + ); + $withoutSlash = self::asArray( + $this->controller->getExAppStyle('top_menu', 'page_slash', 'css/style_slash.css')->getData() + ); + self::assertSame('css/style_slash.css', $withSlash['path']); + self::assertSame($withSlash, $withoutSlash); + + $this->controller->deleteExAppStyle('top_menu', 'page_slash', '/css/style_slash.css'); + $this->expectException(OCSNotFoundException::class); + $this->controller->getExAppStyle('top_menu', 'page_slash', 'css/style_slash.css'); + } +} diff --git a/tests/php/Controller/OccCommandControllerTest.php b/tests/php/Controller/OccCommandControllerTest.php new file mode 100644 index 00000000..2489d7d5 --- /dev/null +++ b/tests/php/Controller/OccCommandControllerTest.php @@ -0,0 +1,116 @@ +insertFakeExApp(self::TEST_APP_ID, self::TEST_PORT); + $this->request = $this->createMock(IRequest::class); + $this->request->method('getHeader')->willReturnCallback( + fn (string $name): string => $name === 'EX-APP-ID' ? self::TEST_APP_ID : '' + ); + $this->service = Server::get(ExAppOccService::class); + $this->controller = new OccCommandController($this->request, $this->service); + $this->service->unregisterCommand(self::TEST_APP_ID, self::CMD_NAME); + } + + protected function tearDown(): void { + $this->service->unregisterCommand(self::TEST_APP_ID, self::CMD_NAME); + $this->deleteFakeExApp(self::TEST_APP_ID); + parent::tearDown(); + } + + public function testRegisterMinimalGetUnregister(): void { + $response = $this->controller->registerCommand( + name: self::CMD_NAME, + description: '', + execute_handler: '/handler', + ); + self::assertSame(Http::STATUS_OK, $response->getStatus()); + + $response = $this->controller->getCommand(self::CMD_NAME); + self::assertSame(Http::STATUS_OK, $response->getStatus()); + $data = $response->getData(); + self::assertSame(self::CMD_NAME, $data->getName()); + // Service strips the leading slash from execute_handler. + self::assertSame('handler', $data->getExecuteHandler()); + + self::assertSame(Http::STATUS_OK, $this->controller->unregisterCommand(self::CMD_NAME)->getStatus()); + self::assertSame(Http::STATUS_NOT_FOUND, $this->controller->getCommand(self::CMD_NAME)->getStatus()); + } + + public function testRegisterFullPayloadPersistsArguments(): void { + $arguments = [[ + 'name' => 'argument_name', + 'mode' => 'required', + 'description' => 'Description', + 'default' => 'default_value', + ]]; + $this->controller->registerCommand( + name: self::CMD_NAME, + description: 'desc', + execute_handler: 'some_url2', + hidden: 0, + arguments: $arguments, + options: [], + usages: [], + ); + $data = $this->controller->getCommand(self::CMD_NAME)->getData(); + self::assertSame('desc', $data->getDescription()); + self::assertSame('some_url2', $data->getExecuteHandler()); + self::assertSame($arguments, $data->getArguments()); + } + + public function testRegisterReplacesExistingCommand(): void { + // ExAppOccService::registerCommand uses insertOrUpdate — re-registering the same name must update in place. + $this->controller->registerCommand(self::CMD_NAME, 'first desc', 'handler_v1'); + $this->controller->registerCommand(self::CMD_NAME, 'updated desc', 'handler_v2'); + $data = $this->controller->getCommand(self::CMD_NAME)->getData(); + self::assertSame('updated desc', $data->getDescription()); + self::assertSame('handler_v2', $data->getExecuteHandler()); + } + + public function testGetMissingReturns404(): void { + self::assertSame(Http::STATUS_NOT_FOUND, $this->controller->getCommand('does_not_exist')->getStatus()); + } + + public function testUnregisterMissingReturns404(): void { + self::assertSame(Http::STATUS_NOT_FOUND, $this->controller->unregisterCommand('does_not_exist')->getStatus()); + } +} diff --git a/tests/php/Controller/RegistersFakeExAppTrait.php b/tests/php/Controller/RegistersFakeExAppTrait.php new file mode 100644 index 00000000..1f365e7b --- /dev/null +++ b/tests/php/Controller/RegistersFakeExAppTrait.php @@ -0,0 +1,76 @@ +findByAppId($appId); + $mapper->delete($existing); + } catch (DoesNotExistException) { + } + + // Only set columns that actually exist in the ex_apps table — protocol, host, last_check_time, deploy_config, + // accepts_deploy_id, routes were dropped in Version2000Date20240120094952 and now live in ex_apps_daemons. + $entity = new ExApp(); + $entity->setAppid($appId); + $entity->setVersion('1.0.0'); + $entity->setName('PHPUnit fake ExApp'); + $entity->setDaemonConfigName('manual_install'); + $entity->setPort($port); + $entity->setSecret(str_repeat('a', 64)); + $entity->setStatus([ + 'progress' => 100, 'error' => '', 'type' => '', + 'action' => '', 'deploy' => 100, 'init' => 100, + ]); + $entity->setEnabled($enabled); + $entity->setCreatedTime(time()); + $mapper->insert($entity); + $this->invalidateExAppCache(); + } + + private function deleteFakeExApp(string $appId): void { + $mapper = Server::get(ExAppMapper::class); + try { + $mapper->delete($mapper->findByAppId($appId)); + $this->invalidateExAppCache(); + } catch (DoesNotExistException) { + } + } + + /** + * ExAppService caches getExApps() under '/ex_apps' on its own private $cache field. createDistributed() returns a + * fresh wrapper, so we reach into the singleton's actual property to invalidate the same instance it reads from. + */ + private function invalidateExAppCache(): void { + $service = Server::get(ExAppService::class); + $prop = new \ReflectionProperty($service, 'cache'); + $cache = $prop->getValue($service); + if ($cache !== null) { + $cache->remove('/ex_apps'); + } + } +} diff --git a/tests/php/Controller/TalkBotControllerTest.php b/tests/php/Controller/TalkBotControllerTest.php new file mode 100644 index 00000000..7b119e77 --- /dev/null +++ b/tests/php/Controller/TalkBotControllerTest.php @@ -0,0 +1,141 @@ +exAppService and the typed property must be initialized even if the rest of setUp aborts. + $this->exAppService = Server::get(ExAppService::class); + $this->talkBotsService = Server::get(TalkBotsService::class); + + if (!class_exists(\OCA\Talk\Events\BotInstallEvent::class)) { + self::markTestSkipped('Talk (spreed) BotInstallEvent class not available'); + } + + $this->insertFakeExApp(self::TEST_APP_ID, self::TEST_PORT); + $this->exAppInserted = true; + + $this->request = $this->createMock(IRequest::class); + $this->request->method('getHeader')->willReturnCallback( + fn (string $name): string => $name === 'EX-APP-ID' ? self::TEST_APP_ID : '' + ); + + $this->controller = new TalkBotController( + $this->request, + $this->exAppService, + Server::get(AppAPIService::class), + $this->talkBotsService, + Server::get(LoggerInterface::class), + Server::get(IThrottler::class), + ); + + $this->safeUnregisterBot(); + } + + protected function tearDown(): void { + if ($this->exAppInserted) { + $this->safeUnregisterBot(); + $this->deleteFakeExApp(self::TEST_APP_ID); + } + parent::tearDown(); + } + + private function safeUnregisterBot(): void { + $exApp = $this->exAppService->getExApp(self::TEST_APP_ID); + if ($exApp !== null) { + $this->talkBotsService->unregisterExAppBot($exApp, ltrim(self::ROUTE, '/')); + } + } + + public function testRegisterReturnsIdAndSecret(): void { + $response = $this->controller->registerExAppTalkBot( + name: 'PHPUnit Bot', + route: self::ROUTE, + description: 'Integration test bot', + ); + self::assertSame(Http::STATUS_OK, $response->getStatus()); + $data = $response->getData(); + self::assertArrayHasKey('id', $data); + self::assertArrayHasKey('secret', $data); + self::assertNotEmpty($data['id']); + self::assertNotEmpty($data['secret']); + + $stored = $this->talkBotsService->getTalkBotSecret(self::TEST_APP_ID, ltrim(self::ROUTE, '/')); + self::assertSame($data['secret'], $stored); + } + + public function testUnregisterRemovesSecret(): void { + $this->controller->registerExAppTalkBot('PHPUnit Bot', self::ROUTE, 'desc'); + self::assertNotNull( + $this->talkBotsService->getTalkBotSecret(self::TEST_APP_ID, ltrim(self::ROUTE, '/')) + ); + + $response = $this->controller->unregisterExAppTalkBot(self::ROUTE); + self::assertSame(Http::STATUS_OK, $response->getStatus()); + + self::assertNull( + $this->talkBotsService->getTalkBotSecret(self::TEST_APP_ID, ltrim(self::ROUTE, '/')) + ); + } + + public function testUnregisterMissingThrowsNotFound(): void { + $this->expectException(OCSNotFoundException::class); + $this->controller->unregisterExAppTalkBot('/never_registered_phpunit'); + } + + public function testRegisterTwiceReusesSecret(): void { + // TalkBotsService caches the secret keyed by the (appid, route) hash and reuses it on re-register + // (see getExAppTalkBotConfig: "Do not regenerate already registered bot secret"). Both register calls + // must succeed and return the SAME id/secret pair. + $first = $this->controller->registerExAppTalkBot('PHPUnit Bot', self::ROUTE, 'desc')->getData(); + $second = $this->controller->registerExAppTalkBot('PHPUnit Bot', self::ROUTE, 'desc')->getData(); + self::assertSame($first['id'], $second['id']); + self::assertSame($first['secret'], $second['secret']); + } +} diff --git a/tests/php/Controller/TaskProcessingControllerTest.php b/tests/php/Controller/TaskProcessingControllerTest.php new file mode 100644 index 00000000..81e2e10d --- /dev/null +++ b/tests/php/Controller/TaskProcessingControllerTest.php @@ -0,0 +1,118 @@ +request = $this->createMock(IRequest::class); + $this->request->method('getHeader')->willReturnCallback( + fn (string $name): string => $name === 'EX-APP-ID' ? self::TEST_APP_ID : '' + ); + $this->service = Server::get(TaskProcessingService::class); + $this->controller = new TaskProcessingController( + $this->request, + $this->service, + Server::get(AppAPIService::class), + Server::get(ExAppService::class), + ); + $this->service->unregisterTaskProcessingProvider(self::TEST_APP_ID, self::PROVIDER_ID); + } + + protected function tearDown(): void { + $this->service->unregisterTaskProcessingProvider(self::TEST_APP_ID, self::PROVIDER_ID); + parent::tearDown(); + } + + private function buildProvider(): array { + return [ + 'id' => self::PROVIDER_ID, + 'name' => 'Test Display Name', + 'task_type' => 'core:text2image', + 'expected_runtime' => 0, + 'optional_input_shape' => [], + 'optional_output_shape' => [], + 'input_shape_enum_values' => [], + 'input_shape_defaults' => [], + 'optional_input_shape_enum_values' => [], + 'optional_input_shape_defaults' => [], + 'output_shape_enum_values' => [], + 'optional_output_shape_enum_values' => [], + ]; + } + + public function testRegisterGetUnregister(): void { + $response = $this->controller->registerProvider($this->buildProvider(), null); + self::assertSame(Http::STATUS_OK, $response->getStatus()); + + $response = $this->controller->getProvider(self::PROVIDER_ID); + self::assertSame(Http::STATUS_OK, $response->getStatus()); + $data = $response->getData(); + self::assertSame(self::PROVIDER_ID, $data->getName()); + self::assertSame('Test Display Name', $data->getDisplayName()); + self::assertSame('core:text2image', $data->getTaskType()); + + $response = $this->controller->unregisterProvider(self::PROVIDER_ID); + self::assertSame(Http::STATUS_OK, $response->getStatus()); + + self::assertSame( + Http::STATUS_NOT_FOUND, + $this->controller->getProvider(self::PROVIDER_ID)->getStatus() + ); + } + + public function testGetMissingReturns404(): void { + self::assertSame( + Http::STATUS_NOT_FOUND, + $this->controller->getProvider('does_not_exist')->getStatus() + ); + } + + public function testUnregisterMissingReturns404(): void { + self::assertSame( + Http::STATUS_NOT_FOUND, + $this->controller->unregisterProvider('does_not_exist')->getStatus() + ); + } + + public function testRegisterRejectsMismatchedCustomTaskType(): void { + // Service raises if custom_task_type id differs from provider task_type; controller turns null into 400. + $customType = [ + 'id' => 'different:custom', 'name' => 'X', 'description' => '', + 'input_shape' => [], 'output_shape' => [], + ]; + $response = $this->controller->registerProvider($this->buildProvider(), $customType); + self::assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus()); + } +} diff --git a/tests/php/bootstrap.php b/tests/php/bootstrap.php index 13731ea2..3f1e6e3d 100644 --- a/tests/php/bootstrap.php +++ b/tests/php/bootstrap.php @@ -16,5 +16,6 @@ require_once __DIR__ . '/../../../../lib/base.php'; require_once __DIR__ . '/../../../../tests/autoload.php'; +require_once __DIR__ . '/Controller/RegistersFakeExAppTrait.php'; Server::get(IAppManager::class)->loadApp('app_api');