feat: [OCISDEV-741] integration

This commit is contained in:
Michal Klos
2026-03-30 17:53:13 +02:00
parent 8898eb4ef7
commit 0f3ce714f8
5 changed files with 815 additions and 65 deletions

View File

@@ -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:

View File

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

View File

@@ -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

View File

@@ -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"<id>(.+?)</id>", 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"<token>(.+?)</token>", 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())

View File

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