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())