199 lines
7.4 KiB
Python
199 lines
7.4 KiB
Python
|
|
"""Run against an isolated acceptance Compose project; never use a personal data stack.
|
||
|
|
|
||
|
|
From the repository root:
|
||
|
|
python3 backend/tests/docker_acceptance.py --env-file .local/docker-test.env
|
||
|
|
Requires images already built and services started with -p wq-alpha-acceptance.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import http.cookiejar
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
import subprocess
|
||
|
|
import time
|
||
|
|
import urllib.error
|
||
|
|
import urllib.request
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
|
||
|
|
def main():
|
||
|
|
parser = argparse.ArgumentParser()
|
||
|
|
parser.add_argument("--env-file", required=True, type=Path)
|
||
|
|
parser.add_argument("--project", default="wq-alpha-acceptance")
|
||
|
|
args = parser.parse_args()
|
||
|
|
if not args.project.startswith("wq-alpha-acceptance"):
|
||
|
|
raise SystemExit("Only an isolated wq-alpha-acceptance* project is allowed")
|
||
|
|
values = dict(
|
||
|
|
line.split("=", 1)
|
||
|
|
for line in args.env_file.read_text().splitlines()
|
||
|
|
if "=" in line and not line.startswith("#")
|
||
|
|
)
|
||
|
|
compose = ["docker", "compose", "--env-file", str(args.env_file), "-p", args.project]
|
||
|
|
|
||
|
|
def run(arguments, data=None):
|
||
|
|
result = subprocess.run(compose + arguments, input=data, capture_output=True, check=True)
|
||
|
|
return result.stdout
|
||
|
|
|
||
|
|
# Assert that we are addressing the isolated project rather than an unrelated localhost app.
|
||
|
|
published = run(["port", "web", "80"]).decode().strip()
|
||
|
|
assert published == f"127.0.0.1:{values.get('LOCAL_PORT', '8080')}"
|
||
|
|
base = f"http://localhost:{values.get('LOCAL_PORT', '8080')}"
|
||
|
|
opener = urllib.request.build_opener(
|
||
|
|
urllib.request.ProxyHandler({}), urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())
|
||
|
|
)
|
||
|
|
|
||
|
|
def request(path, method="GET", payload=None):
|
||
|
|
body = json.dumps(payload).encode() if payload is not None else None
|
||
|
|
return opener.open(
|
||
|
|
urllib.request.Request(
|
||
|
|
base + path,
|
||
|
|
data=body,
|
||
|
|
method=method,
|
||
|
|
headers={"Content-Type": "application/json", "X-WQ-Request": "1", "Origin": base},
|
||
|
|
),
|
||
|
|
timeout=10,
|
||
|
|
)
|
||
|
|
|
||
|
|
assert json.load(request("/api/v1/health")) == {"status": "ok"}
|
||
|
|
html = request("/")
|
||
|
|
assert "frame-ancestors 'none'" in html.headers["Content-Security-Policy"]
|
||
|
|
assert b'<div id="root">' in html.read()
|
||
|
|
try:
|
||
|
|
request("/api/v1/alphas")
|
||
|
|
raise AssertionError("Unauthenticated data was exposed")
|
||
|
|
except urllib.error.HTTPError as error:
|
||
|
|
assert error.code == 401
|
||
|
|
json.load(
|
||
|
|
request(
|
||
|
|
"/api/v1/auth/login",
|
||
|
|
"POST",
|
||
|
|
{"username": values.get("ADMIN_USERNAME", "admin"), "password": values["ADMIN_PASSWORD"]},
|
||
|
|
)
|
||
|
|
)
|
||
|
|
code = b"""import asyncio
|
||
|
|
from app.config import Settings
|
||
|
|
from app.db import create_database
|
||
|
|
from app.alphas import upsert_alpha
|
||
|
|
async def seed():
|
||
|
|
engine, sessions = create_database(Settings().database_url)
|
||
|
|
async with sessions() as db:
|
||
|
|
await upsert_alpha(db, {"id":"DOCKER_ACCEPTANCE", "name":"Synthetic acceptance record", "type":"REGULAR", "stage":"IS", "status":"UNSUBMITTED", "regular":{"code":"rank(close)"}, "settings":{"region":"USA", "language":"FASTEXPR"}, "is":{"sharpe":2.0}})
|
||
|
|
await db.commit()
|
||
|
|
await engine.dispose()
|
||
|
|
asyncio.run(seed())
|
||
|
|
"""
|
||
|
|
run(["exec", "-T", "backend", "python", "-"], code)
|
||
|
|
json.load(
|
||
|
|
request(
|
||
|
|
"/api/v1/alphas/DOCKER_ACCEPTANCE/research",
|
||
|
|
"PATCH",
|
||
|
|
{
|
||
|
|
"note": "persistent local note",
|
||
|
|
"tags": ["docker-verified"],
|
||
|
|
"state": "candidate",
|
||
|
|
"favorite": True,
|
||
|
|
},
|
||
|
|
)
|
||
|
|
)
|
||
|
|
print("PASS: PostgreSQL migration, login, API authorization, local research write")
|
||
|
|
run(["up", "-d", "--force-recreate", "--wait"])
|
||
|
|
# A persisted server session and all database rows survive container replacement.
|
||
|
|
detail = json.load(request("/api/v1/alphas/DOCKER_ACCEPTANCE"))
|
||
|
|
assert detail["research"]["note"] == "persistent local note"
|
||
|
|
run(["exec", "-T", "backend", "python", "-"], code.replace(b'"sharpe":2.0', b'"sharpe":3.0'))
|
||
|
|
detail = json.load(request("/api/v1/alphas/DOCKER_ACCEPTANCE"))
|
||
|
|
assert detail["sharpe"] == 3 and detail["research"]["state"] == "candidate"
|
||
|
|
assert detail["research"]["tags"] == ["docker-verified"]
|
||
|
|
print("PASS: container replacement preserves session and data; snapshot update preserves research")
|
||
|
|
dump = run(["exec", "-T", "db", "pg_dump", "-U", "wq", "-d", "wq", "-Fc", "--no-owner"])
|
||
|
|
backup = Path(".local/docker-acceptance.dump")
|
||
|
|
fd = os.open(backup, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||
|
|
with os.fdopen(fd, "wb") as output:
|
||
|
|
output.write(dump)
|
||
|
|
# Only this script's scratch restore database is replaced on repeated acceptance runs.
|
||
|
|
run(["exec", "-T", "db", "dropdb", "-U", "wq", "--if-exists", "wq_acceptance_restore"])
|
||
|
|
run(["exec", "-T", "db", "createdb", "-U", "wq", "wq_acceptance_restore"])
|
||
|
|
run(
|
||
|
|
[
|
||
|
|
"exec",
|
||
|
|
"-T",
|
||
|
|
"db",
|
||
|
|
"pg_restore",
|
||
|
|
"-U",
|
||
|
|
"wq",
|
||
|
|
"-d",
|
||
|
|
"wq_acceptance_restore",
|
||
|
|
"--no-owner",
|
||
|
|
"--exit-on-error",
|
||
|
|
],
|
||
|
|
dump,
|
||
|
|
)
|
||
|
|
rows = (
|
||
|
|
run(
|
||
|
|
[
|
||
|
|
"exec",
|
||
|
|
"-T",
|
||
|
|
"db",
|
||
|
|
"psql",
|
||
|
|
"-U",
|
||
|
|
"wq",
|
||
|
|
"-d",
|
||
|
|
"wq_acceptance_restore",
|
||
|
|
"-At",
|
||
|
|
"-c",
|
||
|
|
"SELECT note || '|' || state FROM research WHERE alpha_id = 'DOCKER_ACCEPTANCE'",
|
||
|
|
]
|
||
|
|
)
|
||
|
|
.decode()
|
||
|
|
.strip()
|
||
|
|
)
|
||
|
|
assert rows == "persistent local note|candidate"
|
||
|
|
print("PASS: PostgreSQL custom-format backup restores records into independent database")
|
||
|
|
config = json.loads(run(["-f", "compose.public.yaml", "config", "--format", "json"]))
|
||
|
|
assert config["services"]["backend"]["environment"]["COOKIE_SECURE"] == "true"
|
||
|
|
assert config["services"]["backend"]["environment"]["PUBLIC_ORIGIN"].startswith("https://")
|
||
|
|
assert "ports" not in config["services"]["db"] and "ports" not in config["services"]["backend"]
|
||
|
|
run(
|
||
|
|
[
|
||
|
|
"exec",
|
||
|
|
"-T",
|
||
|
|
"web",
|
||
|
|
"caddy",
|
||
|
|
"validate",
|
||
|
|
"--config",
|
||
|
|
"/etc/caddy/Caddyfile",
|
||
|
|
"--adapter",
|
||
|
|
"caddyfile",
|
||
|
|
]
|
||
|
|
)
|
||
|
|
print("PASS: Caddy configuration and public HTTPS/Secure-cookie configuration checks")
|
||
|
|
# Keep the backend process alive while its database disappears and returns.
|
||
|
|
run(["stop", "db"])
|
||
|
|
run(["up", "-d", "--wait", "db"])
|
||
|
|
probe = b"""import asyncio
|
||
|
|
from app.config import Settings
|
||
|
|
from app.db import create_database
|
||
|
|
from app.jobs import create_job
|
||
|
|
async def enqueue():
|
||
|
|
engine, sessions = create_database(Settings().database_url)
|
||
|
|
async with sessions() as db:
|
||
|
|
job = await create_job(db, "profile")
|
||
|
|
print(job.id)
|
||
|
|
await engine.dispose()
|
||
|
|
asyncio.run(enqueue())
|
||
|
|
"""
|
||
|
|
job_id = run(["exec", "-T", "backend", "python", "-"], probe).decode().strip()
|
||
|
|
deadline = time.monotonic() + 15
|
||
|
|
while time.monotonic() < deadline:
|
||
|
|
status = json.load(request(f"/api/v1/sync-jobs/{job_id}"))["status"]
|
||
|
|
if status == "waiting_connection":
|
||
|
|
break
|
||
|
|
time.sleep(0.25)
|
||
|
|
else:
|
||
|
|
raise AssertionError("Scheduler did not resume after database restart")
|
||
|
|
print("PASS: live backend resumes task processing after database stop/start")
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|