mirror of
https://github.com/owncloud/ocis
synced 2026-04-25 17:25:21 +02:00
feat: [OCISDEV-741] integration
This commit is contained in:
54
.github/workflows/acceptance-tests.yml
vendored
54
.github/workflows/acceptance-tests.yml
vendored
@@ -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:
|
||||
|
||||
143
tests/acceptance/run-cs3api.py
Normal file
143
tests/acceptance/run-cs3api.py
Normal 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())
|
||||
@@ -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
|
||||
244
tests/acceptance/run-litmus.py
Normal file
244
tests/acceptance/run-litmus.py
Normal 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())
|
||||
375
tests/acceptance/run-wopi.py
Normal file
375
tests/acceptance/run-wopi.py
Normal 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())
|
||||
Reference in New Issue
Block a user