Discovery is the three PROPFINDs RFC 4791 describes rather than a walk through likely URLs. Queries use time-range, which v1 never did -- it fetched whole calendars and filtered in the browser on every view change. Every write states a precondition, so a stale ETag produces a Conflict a caller can act on instead of silently destroying somebody's edit. XML goes through quick-xml with namespace resolution. v1 matched prefixes with six regexes tried in sequence and recompiled inside the loop; there is a fixture here that is the same document under different prefixes, and it parses identically. Protocol parsing is split from transport so it can be tested against responses recorded from a real Baikal -- including the second propstat carrying 404s, which is what makes "this calendar has no colour" different from "this calendar has an empty colour". Live tests run against a real server, never a mock. tests/baikal/run.sh starts a container, walks Baikal's install wizard, and runs them; each test builds and destroys its own collection, so pointing it at a real server touches nothing that was already there. They cover discovery, round-trip, stale-ETag conflict, duplicate create, delete, time-range filtering, a series returned whole with its override, and writing every synthetic golden fixture to the server and reading it back. libdav was evaluated first, as planned. Not adopted: its HttpClient trait is defined over hyper::body::Incoming, so using it means replacing reqwest everywhere, plus a DNS resolver for service discovery we do not do and a second XML parser. Its precondition design is where Precondition's shape comes from. Reasons are recorded in the crate docs.
155 lines
5.2 KiB
Python
155 lines
5.2 KiB
Python
#!/usr/bin/env python3
|
|
"""Drive a fresh Baikal container through its install wizard.
|
|
|
|
Baikal has no unattended-install path, so the integration tests would otherwise
|
|
need a hand-prepared image. This walks the same web forms a person would,
|
|
leaving a server with Basic authentication, one user, and one calendar.
|
|
|
|
Usage:
|
|
podman run -d --rm --name runway-baikal -p 8800:80 docker.io/ckulka/baikal:nginx
|
|
python3 setup.py http://localhost:8800 testuser testpassword
|
|
"""
|
|
|
|
import re
|
|
import sys
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
import http.cookiejar
|
|
|
|
ADMIN_PASSWORD = "runway-integration-admin"
|
|
|
|
|
|
def make_opener():
|
|
jar = http.cookiejar.CookieJar()
|
|
return urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
|
|
|
|
|
def get(opener, url):
|
|
with opener.open(url, timeout=30) as response:
|
|
return response.read().decode("utf-8", "replace")
|
|
|
|
|
|
def post(opener, url, fields):
|
|
body = urllib.parse.urlencode(fields).encode()
|
|
request = urllib.request.Request(
|
|
url, data=body, headers={"Content-Type": "application/x-www-form-urlencoded"}
|
|
)
|
|
with opener.open(request, timeout=30) as response:
|
|
return response.read().decode("utf-8", "replace")
|
|
|
|
|
|
def csrf(html):
|
|
"""The page's CSRF token. Not every form carries one -- the admin login
|
|
does not -- so callers that may see either use `maybe_csrf`."""
|
|
token = maybe_csrf(html)
|
|
if token is None:
|
|
raise SystemExit("no CSRF token in page; Baikal's forms have changed")
|
|
return token
|
|
|
|
|
|
def maybe_csrf(html):
|
|
match = re.search(r'name="CSRF_TOKEN"\s+value="([^"]+)"', html)
|
|
return match.group(1) if match else None
|
|
|
|
|
|
def install(base, username, password):
|
|
"""Walk the wizard until it stops offering forms.
|
|
|
|
The wizard serves every step from the same URL and decides which form to
|
|
show from its own stored state, so this is driven by what comes back rather
|
|
than by a fixed sequence of URLs -- which is also what keeps it working
|
|
across Baikal versions that add or reorder a step.
|
|
"""
|
|
opener = make_opener()
|
|
|
|
for _ in range(6):
|
|
page = get(opener, f"{base}/admin/install/")
|
|
if "Baikal_Model_Config_Standard::submitted" in page:
|
|
post(opener, f"{base}/admin/install/", system_fields(csrf(page)))
|
|
elif "Baikal_Model_Config_Database::submitted" in page:
|
|
post(opener, f"{base}/admin/install/", database_fields(csrf(page)))
|
|
else:
|
|
break
|
|
else:
|
|
raise SystemExit("the install wizard did not finish")
|
|
|
|
# Log in to the admin interface. This form has no CSRF token of its own.
|
|
page = get(opener, f"{base}/admin/")
|
|
if 'name="auth"' in page:
|
|
fields = {"auth": "1", "login": "admin", "password": ADMIN_PASSWORD}
|
|
token = maybe_csrf(page)
|
|
if token:
|
|
fields["CSRF_TOKEN"] = token
|
|
post(opener, f"{base}/admin/", fields)
|
|
|
|
# Create the test user. Baikal gives every new user a default calendar.
|
|
page = get(opener, f"{base}/admin/?/users/new/1/")
|
|
post(
|
|
opener,
|
|
f"{base}/admin/?/users/new/1/",
|
|
{
|
|
"Baikal_Model_User::submitted": "1",
|
|
"refreshed": "0",
|
|
"CSRF_TOKEN": csrf(page),
|
|
"data[username]": username,
|
|
"witness[username]": "1",
|
|
"data[displayname]": "Integration Test",
|
|
"witness[displayname]": "1",
|
|
"data[email]": f"{username}@example.org",
|
|
"witness[email]": "1",
|
|
"data[password]": password,
|
|
"witness[password]": "1",
|
|
"data[passwordconfirm]": password,
|
|
"witness[passwordconfirm]": "1",
|
|
},
|
|
)
|
|
|
|
users = get(opener, f"{base}/admin/?/users/")
|
|
if username not in users:
|
|
raise SystemExit(f"user {username} was not created")
|
|
print(f"ready: {base}/dav.php/ as {username}")
|
|
|
|
|
|
def system_fields(token):
|
|
"""Step one. Basic authentication, because that is what the client sends;
|
|
Baikal defaults to Digest."""
|
|
return {
|
|
"Baikal_Model_Config_Standard::submitted": "1",
|
|
"refreshed": "0",
|
|
"CSRF_TOKEN": token,
|
|
"data[timezone]": "UTC",
|
|
"witness[timezone]": "1",
|
|
"data[card_enabled]": "1",
|
|
"witness[card_enabled]": "1",
|
|
"data[cal_enabled]": "1",
|
|
"witness[cal_enabled]": "1",
|
|
"data[invite_from]": "noreply@example.org",
|
|
"witness[invite_from]": "1",
|
|
"data[dav_auth_type]": "Basic",
|
|
"witness[dav_auth_type]": "1",
|
|
"data[admin_passwordhash]": ADMIN_PASSWORD,
|
|
"witness[admin_passwordhash]": "1",
|
|
"data[admin_passwordhash_confirm]": ADMIN_PASSWORD,
|
|
"witness[admin_passwordhash_confirm]": "1",
|
|
}
|
|
|
|
|
|
def database_fields(token):
|
|
"""Step two. SQLite, at the path the form arrives pre-filled with."""
|
|
return {
|
|
"Baikal_Model_Config_Database::submitted": "1",
|
|
"refreshed": "0",
|
|
"CSRF_TOKEN": token,
|
|
"data[backend]": "sqlite",
|
|
"witness[backend]": "1",
|
|
"data[sqlite_file]": "/var/www/baikal/Specific/db/db.sqlite",
|
|
"witness[sqlite_file]": "1",
|
|
}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) != 4:
|
|
raise SystemExit(__doc__)
|
|
install(sys.argv[1].rstrip("/"), sys.argv[2], sys.argv[3])
|