diff --git a/.github/workflows/acceptance-tests.yml b/.github/workflows/acceptance-tests.yml index fcd7fcfe1fd..4075baeeede 100644 --- a/.github/workflows/acceptance-tests.yml +++ b/.github/workflows/acceptance-tests.yml @@ -256,8 +256,60 @@ jobs: name: test-logs-${{ matrix.suite }} path: tests/acceptance/output/ + litmus: + name: litmus + needs: [build-and-test] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: Run litmus + run: python3 tests/acceptance/run-litmus.py + + cs3api: + name: cs3api + needs: [build-and-test] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: Run cs3api validator + run: python3 tests/acceptance/run-cs3api.py + + wopi-builtin: + name: wopi-builtin + needs: [build-and-test] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: Run WOPI validator (builtin) + run: python3 tests/acceptance/run-wopi.py --type builtin + + wopi-cs3: + name: wopi-cs3 + needs: [build-and-test] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: Run WOPI validator (cs3) + run: python3 tests/acceptance/run-wopi.py --type cs3 + all-acceptance-tests: - needs: [local-api-tests, cli-tests, core-api-tests] + needs: [local-api-tests, cli-tests, core-api-tests, litmus, cs3api, wopi-builtin, wopi-cs3] runs-on: ubuntu-latest if: always() steps: diff --git a/tests/acceptance/run-cs3api.py b/tests/acceptance/run-cs3api.py new file mode 100644 index 00000000000..9da46cb0888 --- /dev/null +++ b/tests/acceptance/run-cs3api.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +""" +Run CS3 API validator tests locally and in GitHub Actions CI. + +Config sourced from .drone.star cs3ApiTests() — single source of truth. +Usage: python3 tests/acceptance/run-cs3api.py +""" + +import os +import shutil +import signal +import subprocess +import sys +import time +from pathlib import Path + +# --------------------------------------------------------------------------- +# Constants (mirroring .drone.star) +# --------------------------------------------------------------------------- + +# HTTPS — matching drone: ocis init generates a self-signed cert; proxy uses TLS by default. +# Host-side curl calls use -k (insecure) to skip cert verification. +OCIS_URL = "https://127.0.0.1:9200" +CS3API_IMAGE = "owncloud/cs3api-validator:0.2.1" + + +def get_docker_bridge_ip() -> str: + """Return the Docker bridge gateway IP, reachable from host and Docker containers.""" + r = subprocess.run( + ["docker", "network", "inspect", "bridge", + "--format", "{{range .IPAM.Config}}{{.Gateway}}{{end}}"], + capture_output=True, text=True, check=True, + ) + return r.stdout.strip() + + +def base_server_env(repo_root: Path, ocis_config_dir: str, ocis_public_url: str) -> dict: + """OCIS server environment matching drone ocisServer(deploy_type='cs3api_validator').""" + return { + "OCIS_URL": ocis_public_url, + "OCIS_CONFIG_DIR": ocis_config_dir, + "STORAGE_USERS_DRIVER": "ocis", + "PROXY_ENABLE_BASIC_AUTH": "true", + # No PROXY_TLS override — drone lets ocis use its default TLS (self-signed cert from init) + # IDP excluded: its static assets are absent when running as a host process + "OCIS_EXCLUDE_RUN_SERVICES": "idp", + "OCIS_LOG_LEVEL": "error", + "IDM_CREATE_DEMO_USERS": "true", + "IDM_ADMIN_PASSWORD": "admin", + "FRONTEND_SEARCH_MIN_LENGTH": "2", + "OCIS_ASYNC_UPLOADS": "true", + "OCIS_EVENTS_ENABLE_TLS": "false", + "NATS_NATS_HOST": "0.0.0.0", + "NATS_NATS_PORT": "9233", + "OCIS_JWT_SECRET": "some-ocis-jwt-secret", + "EVENTHISTORY_STORE": "memory", + "WEB_UI_CONFIG_FILE": str(repo_root / "tests/config/drone/ocis-config.json"), + # cs3api_validator extras (drone ocisServer deploy_type="cs3api_validator") + "GATEWAY_GRPC_ADDR": "0.0.0.0:9142", + "OCIS_SHARING_PUBLIC_SHARE_MUST_HAVE_PASSWORD": "false", + } + + +def wait_for(condition_fn, timeout: int, label: str) -> None: + deadline = time.time() + timeout + while not condition_fn(): + if time.time() > deadline: + print(f"Timeout waiting for {label}", file=sys.stderr) + sys.exit(1) + time.sleep(1) + + +def ocis_healthy(ocis_url: str) -> bool: + r = subprocess.run( + ["curl", "-sk", "-uadmin:admin", + f"{ocis_url}/graph/v1.0/users/admin", + "-w", "%{http_code}", "-o", "/dev/null"], + capture_output=True, text=True, + ) + return r.stdout.strip() == "200" + + +def main() -> int: + repo_root = Path(__file__).resolve().parents[2] + ocis_bin = repo_root / "ocis/bin/ocis" + ocis_config_dir = Path.home() / ".ocis/config" + + subprocess.run(["make", "-C", str(repo_root / "ocis"), "build"], check=True) + + # Docker bridge gateway IP: reachable from both the host and Docker containers. + # cs3api-validator connects to the GRPC gateway at {bridge_ip}:9142. + bridge_ip = get_docker_bridge_ip() + print(f"Docker bridge IP: {bridge_ip}", flush=True) + + server_env = {**os.environ} + server_env.update(base_server_env(repo_root, str(ocis_config_dir), + f"https://{bridge_ip}:9200")) + + subprocess.run( + [str(ocis_bin), "init", "--insecure", "true"], + env=server_env, + check=True, + ) + shutil.copy( + repo_root / "tests/config/drone/app-registry.yaml", + ocis_config_dir / "app-registry.yaml", + ) + + print("Starting ocis...", flush=True) + ocis_proc = subprocess.Popen( + [str(ocis_bin), "server"], + env=server_env, + ) + + def cleanup(*_): + try: + ocis_proc.terminate() + except Exception: + pass + + signal.signal(signal.SIGTERM, cleanup) + signal.signal(signal.SIGINT, cleanup) + + try: + wait_for(lambda: ocis_healthy(OCIS_URL), 300, "ocis") + print("ocis ready.", flush=True) + + print(f"\nRunning cs3api-validator against {bridge_ip}:9142", flush=True) + result = subprocess.run( + ["docker", "run", "--rm", + "--entrypoint", "/usr/bin/cs3api-validator", + CS3API_IMAGE, + "/var/lib/cs3api-validator", + f"--endpoint={bridge_ip}:9142"], + ) + return result.returncode + + finally: + cleanup() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/acceptance/run-github.sh b/tests/acceptance/run-github.sh deleted file mode 100755 index 469fd5bb12e..00000000000 --- a/tests/acceptance/run-github.sh +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" -OCIS_BIN="$REPO_ROOT/ocis/bin/ocis" -WRAPPER_BIN="$REPO_ROOT/tests/ociswrapper/bin/ociswrapper" -OCIS_URL="https://localhost:9200" -OCIS_CONFIG_DIR="$HOME/.ocis/config" - -# suite(s) to run — set via env or passed from CI matrix -: "${BEHAT_SUITES:?BEHAT_SUITES is required, e.g. BEHAT_SUITES=apiGraph bash run-graph.sh}" - -# build -make -C "$REPO_ROOT/ocis" build -GOWORK=off make -C "$REPO_ROOT/tests/ociswrapper" build - -# php deps -cd "$REPO_ROOT" -composer install --no-progress -composer bin behat install --no-progress - -# init ocis config -"$OCIS_BIN" init --insecure true -cp "$REPO_ROOT/tests/config/drone/app-registry.yaml" "$OCIS_CONFIG_DIR/app-registry.yaml" - -# start ociswrapper in background, kill on exit -OCIS_URL=$OCIS_URL \ -OCIS_CONFIG_DIR=$OCIS_CONFIG_DIR \ -STORAGE_USERS_DRIVER=ocis \ -PROXY_ENABLE_BASIC_AUTH=true \ -OCIS_EXCLUDE_RUN_SERVICES=idp \ -OCIS_LOG_LEVEL=error \ -IDM_CREATE_DEMO_USERS=true \ -IDM_ADMIN_PASSWORD=admin \ -OCIS_ASYNC_UPLOADS=true \ -OCIS_EVENTS_ENABLE_TLS=false \ -NATS_NATS_HOST=0.0.0.0 \ -NATS_NATS_PORT=9233 \ -OCIS_JWT_SECRET=some-ocis-jwt-secret \ -WEB_UI_CONFIG_FILE="$REPO_ROOT/tests/config/drone/ocis-config.json" \ - "$WRAPPER_BIN" serve \ - --bin "$OCIS_BIN" \ - --url "$OCIS_URL" \ - --admin-username admin \ - --admin-password admin & -WRAPPER_PID=$! -trap "kill $WRAPPER_PID 2>/dev/null || true" EXIT - -# wait for ocis graph API to be ready -echo "Waiting for ocis..." -timeout 300 bash -c \ - "while [ \$(curl -sk -uadmin:admin $OCIS_URL/graph/v1.0/users/admin \ - -w %{http_code} -o /dev/null) != 200 ]; do sleep 1; done" -echo "ocis ready." - -# run acceptance tests for declared suites -echo "Running suites: $BEHAT_SUITES" -TEST_SERVER_URL=$OCIS_URL \ -OCIS_WRAPPER_URL=http://localhost:5200 \ -BEHAT_SUITES=$BEHAT_SUITES \ -BEHAT_FILTER_TAGS="~@skip&&~@skipOnGraph&&~@skipOnOcis-OCIS-Storage" \ -EXPECTED_FAILURES_FILE="$REPO_ROOT/tests/acceptance/expected-failures-localAPI-on-OCIS-storage.md" \ -STORAGE_DRIVER=ocis \ - make -C "$REPO_ROOT" test-acceptance-api diff --git a/tests/acceptance/run-litmus.py b/tests/acceptance/run-litmus.py new file mode 100644 index 00000000000..7f4dabea372 --- /dev/null +++ b/tests/acceptance/run-litmus.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +""" +Run litmus WebDAV compliance tests locally and in GitHub Actions CI. + +Config sourced from .drone.star litmus() / setupForLitmus() — single source of truth. +Usage: python3 tests/acceptance/run-litmus.py +""" + +import json +import os +import re +import shutil +import signal +import subprocess +import sys +import time +from pathlib import Path + +# --------------------------------------------------------------------------- +# Constants (mirroring .drone.star) +# --------------------------------------------------------------------------- + +# HTTPS — matching drone: ocis init generates a self-signed cert; proxy uses TLS by default. +# Host-side curl calls use -k (insecure) to skip cert verification. +OCIS_URL = "https://127.0.0.1:9200" +LITMUS_IMAGE = "owncloudci/litmus:latest" +LITMUS_TESTS = "basic copymove props http" +SHARE_ENDPOINT = "ocs/v2.php/apps/files_sharing/api/v1/shares" + + +def get_docker_bridge_ip() -> str: + """Return the Docker bridge gateway IP, reachable from host and Docker containers.""" + r = subprocess.run( + ["docker", "network", "inspect", "bridge", + "--format", "{{range .IPAM.Config}}{{.Gateway}}{{end}}"], + capture_output=True, text=True, check=True, + ) + return r.stdout.strip() + + +def base_server_env(repo_root: Path, ocis_config_dir: str, ocis_public_url: str) -> dict: + """OCIS server environment matching drone ocisServer() for litmus.""" + return { + "OCIS_URL": ocis_public_url, + "OCIS_CONFIG_DIR": ocis_config_dir, + "STORAGE_USERS_DRIVER": "ocis", + "PROXY_ENABLE_BASIC_AUTH": "true", + # No PROXY_TLS override — drone lets ocis use its default TLS (self-signed cert from init) + # IDP excluded: its static assets are absent when running as a host process + "OCIS_EXCLUDE_RUN_SERVICES": "idp", + "OCIS_LOG_LEVEL": "error", + "IDM_CREATE_DEMO_USERS": "true", + "IDM_ADMIN_PASSWORD": "admin", + "FRONTEND_SEARCH_MIN_LENGTH": "2", + "OCIS_ASYNC_UPLOADS": "true", + "OCIS_EVENTS_ENABLE_TLS": "false", + "NATS_NATS_HOST": "0.0.0.0", + "NATS_NATS_PORT": "9233", + "OCIS_JWT_SECRET": "some-ocis-jwt-secret", + "EVENTHISTORY_STORE": "memory", + "WEB_UI_CONFIG_FILE": str(repo_root / "tests/config/drone/ocis-config.json"), + } + + +def wait_for(condition_fn, timeout: int, label: str) -> None: + deadline = time.time() + timeout + while not condition_fn(): + if time.time() > deadline: + print(f"Timeout waiting for {label}", file=sys.stderr) + sys.exit(1) + time.sleep(1) + + +def ocis_healthy(ocis_url: str) -> bool: + r = subprocess.run( + ["curl", "-sk", "-uadmin:admin", + f"{ocis_url}/graph/v1.0/users/admin", + "-w", "%{http_code}", "-o", "/dev/null"], + capture_output=True, text=True, + ) + return r.stdout.strip() == "200" + + +def setup_for_litmus(ocis_url: str) -> tuple: + """ + Translate tests/config/drone/setup-for-litmus.sh to Python. + Returns (space_id, public_token). + """ + # get personal space ID + r = subprocess.run( + ["curl", "-sk", "-uadmin:admin", f"{ocis_url}/graph/v1.0/me/drives"], + capture_output=True, text=True, check=True, + ) + drives = json.loads(r.stdout) + space_id = "" + for drive in drives.get("value", []): + if drive.get("driveType") == "personal": + web_dav_url = drive.get("root", {}).get("webDavUrl", "") + # last non-empty path segment (same as cut -d"/" -f6 in bash) + space_id = [p for p in web_dav_url.split("/") if p][-1] + break + if not space_id: + print("ERROR: could not determine personal space ID", file=sys.stderr) + sys.exit(1) + print(f"SPACE_ID={space_id}") + + # create test folder as einstein + subprocess.run( + ["curl", "-sk", "-ueinstein:relativity", "-X", "MKCOL", + f"{ocis_url}/remote.php/webdav/new_folder"], + capture_output=True, check=True, + ) + + # create share from einstein to admin + r = subprocess.run( + ["curl", "-sk", "-ueinstein:relativity", + f"{ocis_url}/{SHARE_ENDPOINT}", + "-d", "path=/new_folder&shareType=0&permissions=15&name=new_folder&shareWith=admin"], + capture_output=True, text=True, check=True, + ) + share_id_match = re.search(r"(.+?)", r.stdout) + if share_id_match: + share_id = share_id_match.group(1) + # accept the share as admin + subprocess.run( + ["curl", "-X", "POST", "-sk", "-uadmin:admin", + f"{ocis_url}/{SHARE_ENDPOINT}/pending/{share_id}"], + capture_output=True, check=True, + ) + + # create public share as einstein + r = subprocess.run( + ["curl", "-sk", "-ueinstein:relativity", + f"{ocis_url}/{SHARE_ENDPOINT}", + "-d", "path=/new_folder&shareType=3&permissions=15&name=new_folder"], + capture_output=True, text=True, check=True, + ) + public_token = "" + token_match = re.search(r"(.+?)", r.stdout) + if token_match: + public_token = token_match.group(1) + print(f"PUBLIC_TOKEN={public_token}") + + return space_id, public_token + + +def run_litmus(name: str, endpoint: str) -> int: + print(f"\nTesting endpoint [{name}]: {endpoint}", flush=True) + result = subprocess.run( + ["docker", "run", "--rm", + "-e", f"LITMUS_URL={endpoint}", + "-e", "LITMUS_USERNAME=admin", + "-e", "LITMUS_PASSWORD=admin", + "-e", f"TESTS={LITMUS_TESTS}", + LITMUS_IMAGE, + # No extra CMD — ENTRYPOINT is already litmus-wrapper; passing it again + # would make the wrapper use the path as LITMUS_URL, overriding the env var. + ], + ) + return result.returncode + + +def main() -> int: + repo_root = Path(__file__).resolve().parents[2] + ocis_bin = repo_root / "ocis/bin/ocis" + ocis_config_dir = Path.home() / ".ocis/config" + + # build (matching drone: restores binary from cache, then runs ocis server directly) + subprocess.run(["make", "-C", str(repo_root / "ocis"), "build"], check=True) + + # Docker bridge gateway IP: reachable from both the host (via docker0 interface) + # and Docker containers (via bridge network default gateway). Use this as OCIS_URL + # so that any redirects OCIS generates stay on a hostname the litmus container + # can follow — matching how drone uses "ocis-server:9200" consistently. + # HTTPS: owncloudci/litmus accepts insecure (self-signed) certs, just like drone does. + bridge_ip = get_docker_bridge_ip() + litmus_base = f"https://{bridge_ip}:9200" + print(f"Docker bridge IP: {bridge_ip}", flush=True) + + # assemble server env first — same env vars drone sets on the container before + # running `ocis init`, so IDM_ADMIN_PASSWORD=admin is present during init and + # the config is written with the correct password (not a random one) + server_env = {**os.environ} + server_env.update(base_server_env(repo_root, str(ocis_config_dir), litmus_base)) + + # init ocis with full server env (mirrors drone: env is set before ocis init runs) + subprocess.run( + [str(ocis_bin), "init", "--insecure", "true"], + env=server_env, + check=True, + ) + shutil.copy( + repo_root / "tests/config/drone/app-registry.yaml", + ocis_config_dir / "app-registry.yaml", + ) + + # start ocis server directly (matching drone: no ociswrapper for litmus) + print("Starting ocis...", flush=True) + ocis_proc = subprocess.Popen( + [str(ocis_bin), "server"], + env=server_env, + ) + + def cleanup(*_): + try: + ocis_proc.terminate() + except Exception: + pass + + signal.signal(signal.SIGTERM, cleanup) + signal.signal(signal.SIGINT, cleanup) + + try: + wait_for(lambda: ocis_healthy(OCIS_URL), 300, "ocis") + print("ocis ready.", flush=True) + + space_id, _ = setup_for_litmus(OCIS_URL) + + endpoints = [ + ("old-endpoint", f"{litmus_base}/remote.php/webdav"), + ("new-endpoint", f"{litmus_base}/remote.php/dav/files/admin"), + ("new-shared", f"{litmus_base}/remote.php/dav/files/admin/Shares/new_folder/"), + ("old-shared", f"{litmus_base}/remote.php/webdav/Shares/new_folder/"), + ("spaces-endpoint", f"{litmus_base}/remote.php/dav/spaces/{space_id}"), + ] + + failed = [] + for name, endpoint in endpoints: + rc = run_litmus(name, endpoint) + if rc != 0: + failed.append(name) + + if failed: + print(f"\nFailed endpoints: {', '.join(failed)}", file=sys.stderr) + return 1 + print("\nAll litmus tests passed.") + return 0 + + finally: + cleanup() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/acceptance/run-wopi.py b/tests/acceptance/run-wopi.py new file mode 100644 index 00000000000..93607032bb2 --- /dev/null +++ b/tests/acceptance/run-wopi.py @@ -0,0 +1,375 @@ +#!/usr/bin/env python3 +""" +Run WOPI validator tests locally and in GitHub Actions CI. + +Config sourced from .drone.star wopiValidatorTests() — single source of truth. +Usage: python3 tests/acceptance/run-wopi.py --type builtin + python3 tests/acceptance/run-wopi.py --type cs3 +""" + +import argparse +import json +import os +import re +import shutil +import signal +import socket +import subprocess +import sys +import time +import urllib.parse +from pathlib import Path + +# --------------------------------------------------------------------------- +# Constants (mirroring .drone.star) +# --------------------------------------------------------------------------- + +# HTTPS — matching drone; host-side curl calls use -k. +OCIS_URL = "https://127.0.0.1:9200" +VALIDATOR_IMAGE = "owncloudci/wopi-validator" +CS3_WOPI_IMAGE = "cs3org/wopiserver:v10.4.0" +FAKEOFFICE_IMAGE = "owncloudci/alpine:latest" + +# Testgroups shared between both variants (drone: testgroups list) +SHARED_TESTGROUPS = [ + "BaseWopiViewing", + "CheckFileInfoSchema", + "EditFlows", + "Locks", + "AccessTokens", + "GetLock", + "ExtendedLockLength", + "FileVersion", + "Features", +] + +# Testgroups only run for builtin (drone: builtinOnlyTestGroups, with -s flag) +BUILTIN_ONLY_TESTGROUPS = [ + "PutRelativeFile", + "RenameFileIfCreateChildFileIsNotSupported", +] + + +def get_docker_bridge_ip() -> str: + r = subprocess.run( + ["docker", "network", "inspect", "bridge", + "--format", "{{range .IPAM.Config}}{{.Gateway}}{{end}}"], + capture_output=True, text=True, check=True, + ) + return r.stdout.strip() + + +def wait_for(condition_fn, timeout: int, label: str) -> None: + deadline = time.time() + timeout + while not condition_fn(): + if time.time() > deadline: + print(f"Timeout waiting for {label}", file=sys.stderr) + sys.exit(1) + time.sleep(1) + + +def ocis_healthy(ocis_url: str) -> bool: + r = subprocess.run( + ["curl", "-sk", "-uadmin:admin", + f"{ocis_url}/graph/v1.0/users/admin", + "-w", "%{http_code}", "-o", "/dev/null"], + capture_output=True, text=True, + ) + return r.stdout.strip() == "200" + + +def tcp_reachable(host: str, port: int) -> bool: + try: + with socket.create_connection((host, port), timeout=1): + return True + except Exception: + return False + + +def wopi_discovery_ready(url: str) -> bool: + r = subprocess.run( + ["curl", "-sk", "-o", "/dev/null", "-w", "%{http_code}", url], + capture_output=True, text=True, + ) + return r.stdout.strip() == "200" + + +def base_server_env(repo_root: Path, ocis_config_dir: str, ocis_public_url: str, + bridge_ip: str, wopi_type: str) -> dict: + """ + OCIS server environment matching drone ocisServer(deploy_type='wopi_validator'). + builtin: also excludes app-provider (collaboration service takes that role). + """ + exclude = "idp,app-provider" if wopi_type == "builtin" else "idp" + return { + "OCIS_URL": ocis_public_url, + "OCIS_CONFIG_DIR": ocis_config_dir, + "STORAGE_USERS_DRIVER": "ocis", + "PROXY_ENABLE_BASIC_AUTH": "true", + # No PROXY_TLS override — drone uses default TLS (self-signed cert from init) + # IDP excluded: static assets absent when running as host process + "OCIS_EXCLUDE_RUN_SERVICES": exclude, + "OCIS_LOG_LEVEL": "error", + "IDM_CREATE_DEMO_USERS": "true", + "IDM_ADMIN_PASSWORD": "admin", + "FRONTEND_SEARCH_MIN_LENGTH": "2", + "OCIS_ASYNC_UPLOADS": "true", + "OCIS_EVENTS_ENABLE_TLS": "false", + "NATS_NATS_HOST": "0.0.0.0", + "NATS_NATS_PORT": "9233", + "OCIS_JWT_SECRET": "some-ocis-jwt-secret", + "EVENTHISTORY_STORE": "memory", + "WEB_UI_CONFIG_FILE": str(repo_root / "tests/config/drone/ocis-config.json"), + # wopi_validator extras (drone ocisServer deploy_type="wopi_validator") + "GATEWAY_GRPC_ADDR": "0.0.0.0:9142", + "APP_PROVIDER_EXTERNAL_ADDR": "com.owncloud.api.app-provider", + "APP_PROVIDER_DRIVER": "wopi", + "APP_PROVIDER_WOPI_APP_NAME": "FakeOffice", + "APP_PROVIDER_WOPI_APP_URL": f"http://{bridge_ip}:8080", + "APP_PROVIDER_WOPI_INSECURE": "true", + "APP_PROVIDER_WOPI_WOPI_SERVER_EXTERNAL_URL": f"http://{bridge_ip}:9300", + "APP_PROVIDER_WOPI_FOLDER_URL_BASE_URL": ocis_public_url, + } + + +def collab_service_env(bridge_ip: str, ocis_config_dir: str) -> dict: + """ + Environment for 'ocis collaboration server' (builtin wopi-fakeoffice). + Mirrors drone wopiCollaborationService("fakeoffice"). + """ + return { + "OCIS_URL": f"https://{bridge_ip}:9200", + "OCIS_CONFIG_DIR": ocis_config_dir, + "MICRO_REGISTRY": "nats-js-kv", + "MICRO_REGISTRY_ADDRESS": "127.0.0.1:9233", + "COLLABORATION_LOG_LEVEL": "debug", + "COLLABORATION_GRPC_ADDR": "0.0.0.0:9301", + "COLLABORATION_HTTP_ADDR": "0.0.0.0:9300", + "COLLABORATION_DEBUG_ADDR": "0.0.0.0:9304", + "COLLABORATION_APP_PROOF_DISABLE": "true", + "COLLABORATION_APP_INSECURE": "true", + "COLLABORATION_CS3API_DATAGATEWAY_INSECURE": "true", + "OCIS_JWT_SECRET": "some-ocis-jwt-secret", + "COLLABORATION_WOPI_SECRET": "some-wopi-secret", + "COLLABORATION_APP_NAME": "FakeOffice", + "COLLABORATION_APP_PRODUCT": "Microsoft", + "COLLABORATION_APP_ADDR": f"http://{bridge_ip}:8080", + # COLLABORATION_WOPI_SRC is what OCIS tells clients to use — must be reachable + # from Docker validator containers (collaboration service runs as host process) + "COLLABORATION_WOPI_SRC": f"http://{bridge_ip}:9300", + } + + +def prepare_test_file(bridge_ip: str) -> tuple: + """ + Upload test.wopitest via WebDAV, open the WOPI app, extract credentials. + Mirrors the prepare-test-file step from drone.star. + Returns (access_token, access_token_ttl, wopi_src). + """ + headers_file = "/tmp/wopi-headers.txt" + + # PUT empty test file (--retry-connrefused/--retry-all-errors matching drone) + subprocess.run( + ["curl", "-sk", "-u", "admin:admin", "-X", "PUT", + "--fail", "--retry-connrefused", "--retry", "7", "--retry-all-errors", + f"{OCIS_URL}/remote.php/webdav/test.wopitest", + "-D", headers_file], + check=True, + ) + + # Extract Oc-Fileid from response headers + headers_text = Path(headers_file).read_text() + print("--- PUT headers ---", flush=True) + print(headers_text[:500], flush=True) + m = re.search(r"Oc-Fileid:\s*(\S+)", headers_text, re.IGNORECASE) + if not m: + print("ERROR: Oc-Fileid not found in PUT response headers", file=sys.stderr) + sys.exit(1) + file_id = m.group(1).strip() + print(f"FILE_ID={file_id}", flush=True) + + # POST to app/open to get WOPI access token and wopi src + url = f"{OCIS_URL}/app/open?app_name=FakeOffice&file_id={urllib.parse.quote(file_id, safe='')}" + r = subprocess.run( + ["curl", "-sk", "-u", "admin:admin", "-X", "POST", + "--fail", "--retry-connrefused", "--retry", "7", "--retry-all-errors", url], + capture_output=True, text=True, check=True, + ) + open_json = json.loads(r.stdout) + print(f"open.json: {r.stdout[:800]}", flush=True) + + access_token = open_json["form_parameters"]["access_token"] + access_token_ttl = str(open_json["form_parameters"]["access_token_ttl"]) + app_url = open_json.get("app_url", "") + + # Construct wopi_src: drone extracts file ID from app_url after 'files%2F', + # then prepends http://wopi-fakeoffice:9300/wopi/files/ — we use bridge_ip instead. + wopi_base = f"http://{bridge_ip}:9300/wopi/files/" + if "files%2F" in app_url: + file_id_encoded = app_url.split("files%2F")[-1].strip().strip('"') + elif "files/" in app_url: + file_id_encoded = app_url.split("files/")[-1].strip().strip('"') + else: + file_id_encoded = urllib.parse.quote(file_id, safe="") + wopi_src = wopi_base + file_id_encoded + print(f"WOPI_SRC={wopi_src}", flush=True) + + return access_token, access_token_ttl, wopi_src + + +def run_validator(group: str, token: str, wopi_src: str, ttl: str, + secure: bool = False) -> int: + print(f"\nRunning testgroup [{group}] secure={secure}", flush=True) + cmd = [ + "docker", "run", "--rm", + "--workdir", "/app", + "--entrypoint", "/app/Microsoft.Office.WopiValidator", + VALIDATOR_IMAGE, + ] + if secure: + cmd.append("-s") + cmd += ["-t", token, "-w", wopi_src, "-l", ttl, "--testgroup", group] + return subprocess.run(cmd).returncode + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--type", choices=["builtin", "cs3"], required=True, + help="WOPI server type: builtin (collaboration service) or cs3 (cs3org/wopiserver)") + args = parser.parse_args() + wopi_type = args.type + + repo_root = Path(__file__).resolve().parents[2] + ocis_bin = repo_root / "ocis/bin/ocis" + ocis_config_dir = Path.home() / ".ocis/config" + + subprocess.run(["make", "-C", str(repo_root / "ocis"), "build"], check=True) + + bridge_ip = get_docker_bridge_ip() + print(f"Docker bridge IP: {bridge_ip}", flush=True) + + procs = [] + containers = [] + + def cleanup(*_): + for p in procs: + try: + p.terminate() + except Exception: + pass + for name in containers: + subprocess.run(["docker", "rm", "-f", name], capture_output=True) + + signal.signal(signal.SIGTERM, cleanup) + signal.signal(signal.SIGINT, cleanup) + + try: + # --- fakeoffice: serves hosting-discovery.xml on :8080 --- + # Mirrors drone fakeOffice() — owncloudci/alpine running serve-hosting-discovery.sh. + # Repo is mounted at /drone/src (the path the script uses). + containers.append("wopi-fakeoffice-fake") + subprocess.run(["docker", "rm", "-f", "wopi-fakeoffice-fake"], capture_output=True) + subprocess.run([ + "docker", "run", "-d", "--name", "wopi-fakeoffice-fake", + "-p", "8080:8080", + "-v", f"{repo_root}:/drone/src", + FAKEOFFICE_IMAGE, + "sh", "/drone/src/tests/config/drone/serve-hosting-discovery.sh", + ], check=True) + + wait_for(lambda: tcp_reachable(bridge_ip, 8080), 60, "fakeoffice:8080") + print("fakeoffice ready.", flush=True) + + # --- Init and start OCIS --- + ocis_public_url = f"https://{bridge_ip}:9200" + server_env = {**os.environ} + server_env.update(base_server_env( + repo_root, str(ocis_config_dir), ocis_public_url, bridge_ip, wopi_type)) + + subprocess.run( + [str(ocis_bin), "init", "--insecure", "true"], + env=server_env, check=True, + ) + shutil.copy( + repo_root / "tests/config/drone/app-registry.yaml", + ocis_config_dir / "app-registry.yaml", + ) + + print("Starting ocis...", flush=True) + ocis_proc = subprocess.Popen([str(ocis_bin), "server"], env=server_env) + procs.append(ocis_proc) + + wait_for(lambda: ocis_healthy(OCIS_URL), 300, "ocis") + print("ocis ready.", flush=True) + + # --- Wait for fakeoffice discovery endpoint before starting WOPI service --- + # ocis collaboration server calls GetAppURLs synchronously at startup; + # if /hosting/discovery returns non-200, the process exits immediately. + wait_for(lambda: wopi_discovery_ready("http://127.0.0.1:8080/hosting/discovery"), + 300, "fakeoffice /hosting/discovery") + print("fakeoffice discovery ready.", flush=True) + + # --- Start wopi server (after OCIS is healthy so NATS/gRPC are up) --- + if wopi_type == "builtin": + # Run 'ocis collaboration server' as a host process. + # Mirrors drone wopiCollaborationService("fakeoffice") → startOcisService("collaboration"). + collab_env = {**os.environ} + collab_env.update(collab_service_env(bridge_ip, str(ocis_config_dir))) + print("Starting collaboration service...", flush=True) + collab_proc = subprocess.Popen( + [str(ocis_bin), "collaboration", "server"], + env=collab_env, + ) + procs.append(collab_proc) + else: + # cs3: patch wopiserver.conf (replace container hostname with bridge_ip), + # then run cs3org/wopiserver as a Docker container. + conf_text = (repo_root / "tests/config/drone/wopiserver.conf").read_text() + conf_text = conf_text.replace("ocis-server", bridge_ip) + conf_tmp = Path("/tmp/wopiserver-patched.conf") + conf_tmp.write_text(conf_text) + secret_tmp = Path("/tmp/wopisecret") + secret_tmp.write_text("123\n") + + containers.append("wopi-cs3server") + subprocess.run(["docker", "rm", "-f", "wopi-cs3server"], capture_output=True) + subprocess.run([ + "docker", "run", "-d", "--name", "wopi-cs3server", + "-p", "9300:9300", + "-v", f"{conf_tmp}:/etc/wopi/wopiserver.conf", + "-v", f"{secret_tmp}:/etc/wopi/wopisecret", + "--entrypoint", "/app/wopiserver.py", + CS3_WOPI_IMAGE, + ], check=True) + + wait_for(lambda: tcp_reachable(bridge_ip, 9300), 120, "wopi-fakeoffice:9300") + print("wopi server ready.", flush=True) + + # --- prepare-test-file: upload file, get WOPI credentials --- + access_token, ttl, wopi_src = prepare_test_file(bridge_ip) + + # --- Run validator for each testgroup --- + failed = [] + for group in SHARED_TESTGROUPS: + rc = run_validator(group, access_token, wopi_src, ttl, secure=False) + if rc != 0: + failed.append(group) + + if wopi_type == "builtin": + for group in BUILTIN_ONLY_TESTGROUPS: + rc = run_validator(group, access_token, wopi_src, ttl, secure=True) + if rc != 0: + failed.append(group) + + if failed: + print(f"\nFailed testgroups: {', '.join(failed)}", file=sys.stderr) + return 1 + print("\nAll WOPI validator tests passed.") + return 0 + + finally: + cleanup() + + +if __name__ == "__main__": + sys.exit(main())