Files
ChuBlog/scripts/smoke.py
T

283 lines
16 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Exercise real Docker, SSH, Git, HTTP authorization, database outage, and restart recovery.
Uses an isolated Compose project and removes only that project's containers/volumes.
2026-10-17 13:13:26 +08:00
Initializes no dependencies; Docker builds the images. Ports 18080, 18443, 12222 must be free.
"""
import argparse
import http.cookiejar
import json
import os
from pathlib import Path
import secrets
import subprocess
2026-10-16 23:36:56 +08:00
import sys
import tempfile
import time
import urllib.error
import urllib.request
ROOT = Path(__file__).resolve().parents[1]
ORIGIN = 'http://localhost:18080'
parser = argparse.ArgumentParser(description=__doc__)
2026-10-17 13:13:26 +08:00
parser.add_argument('--sources-env-file', type=Path, help='Compose build source overrides, e.g. deploy/sources.public.env')
2026-10-16 23:36:56 +08:00
parser.add_argument('--browser-test', action='store_true', help='also run Python Playwright frontend journeys')
parser.add_argument('--key-type', choices=['ed25519', 'rsa'], default='ed25519')
args = parser.parse_args()
def run(args, **kwargs):
return subprocess.run(args, check=True, text=True, **kwargs)
def wait_for(check, timeout=90):
end = time.monotonic() + timeout
last = None
while time.monotonic() < end:
try:
value = check()
if value:
return value
except (OSError, ValueError, urllib.error.HTTPError, AssertionError) as error:
last = error
time.sleep(1)
raise AssertionError(f'Timed out: {last}')
class Client:
def __init__(self):
self.opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
self.csrf = None
def call(self, method, path, data=None, expected=200):
headers = {'Origin': ORIGIN, 'Content-Type': 'application/json'}
if self.csrf:
headers['X-CSRF-Token'] = self.csrf
req = urllib.request.Request(ORIGIN + path, method=method, headers=headers,
data=json.dumps(data).encode() if data is not None else None)
try:
response = self.opener.open(req, timeout=10)
except urllib.error.HTTPError as error:
assert error.code == expected, (path, error.code, error.read())
return None
assert response.status == expected, (path, response.status)
body = response.read()
return json.loads(body) if body else None
with tempfile.TemporaryDirectory(prefix='chublog-smoke-') as temporary:
tmp = Path(temporary)
password = secrets.token_urlsafe(24)
envfile = tmp / '.env'
2026-10-17 14:21:52 +08:00
envfile.write_text(f"PUBLIC_ORIGIN={ORIGIN}\nSITE_ADDRESS=http://localhost:80\n"
f"CHUSQL_ROOT_PASSWORD={secrets.token_urlsafe(24)}\nCHUSQL_PASSWORD={secrets.token_urlsafe(24)}\n"
2026-10-16 23:36:56 +08:00
f"REGISTRY_PREFIX={os.environ.get('REGISTRY_PREFIX', '')}\n"
'DEBIAN_MIRROR=mirrors.ustc.edu.cn\nALLOW_REGISTRATION=true\nHTTP_BIND=127.0.0.1\nHTTP_PORT=18080\nHTTPS_PORT=18443\nSSH_BIND=127.0.0.1\nSSH_PORT=12222\n')
envfile.chmod(0o600)
project = 'chublog-test-' + secrets.token_hex(4)
2026-10-17 13:13:26 +08:00
compose = ['docker', 'compose', '--env-file', str(envfile)]
if args.sources_env_file:
if not args.sources_env_file.is_file():
parser.error(f'Source configuration does not exist: {args.sources_env_file}')
compose.extend(['--env-file', str(args.sources_env_file.resolve())])
compose.extend(['-p', project, '-f', str(ROOT / 'deploy/compose.yaml')])
2026-10-16 18:09:11 +08:00
def database_articles():
# Query the private DB from inside its container; no host database port is opened.
probe = r'''set -eu
exec 3<>/dev/tcp/127.0.0.1/7777
printf '%s\n' '{"method":"hello","protocol":1}' >&3
read -r reply <&3
printf '%s\n' "{\"method\":\"login\",\"user\":\"root\",\"password\":\"${CHUSQL_ROOT_PASSWORD}\"}" >&3
read -r reply <&3
printf '%s\n' '{"method":"query","sql":"USE chublog"}' >&3
read -r reply <&3
2026-10-16 23:36:56 +08:00
printf '%s\n' '{"method":"query","sql":"SELECT id, author, article_id, path, created_at, published_at, updated_at, entry, cover FROM blog_articles"}' >&3
2026-10-16 18:09:11 +08:00
read -r reply <&3
printf '%s\n' "$reply"
'''
result = run(compose + ['exec', '-T', 'db', 'bash', '-c', probe], capture_output=True)
response = json.loads(result.stdout)
assert response['status'] == 'result', response
return response['rows']
try:
2026-10-17 13:13:26 +08:00
run(compose + ['build', 'init', 'api', 'ssh', 'db', 'db-init', 'web'])
hashed = run(compose + ['run', '--rm', '--no-deps', '-T', 'api', 'hash-password'],
input=password, capture_output=True).stdout.strip()
assert hashed.startswith('$argon2id$'), 'Container did not produce an Argon2id hash'
2026-10-17 14:21:52 +08:00
# Isolated development fixture; production deployment never creates an administrator.
run(compose + ['run', '--rm', '--no-deps', '-T', 'init'])
for path, data, mode in [
('/data/users.json', {'admin': {'hash': hashed}}, '600'),
('/auth/publishers.json', {'repository': '/srv/ingress/authors', 'publishers': {
'admin': {'enabled': True, 'role': 'admin', 'prefixes': [], 'ssh_key': None}}}, '644'),
]:
run(compose + ['run', '--rm', '--no-deps', '-T', '--entrypoint', 'sh', 'api', '-c',
f'umask 077; cat > {path} && chmod {mode} {path}'],
input=json.dumps(data), capture_output=True)
2026-10-17 13:13:26 +08:00
run(compose + ['up', '-d', '--no-build'])
admin = Client()
wait_for(lambda: admin.call('GET', '/api/health'))
wait_for(lambda: subprocess.run(compose + ['run', '--rm', 'db-init'], capture_output=True).returncode == 0)
admin.csrf = admin.call('POST', '/api/auth/login', {'username': 'admin', 'password': password})['csrf']
publisher = Client()
publisher.call('POST', '/api/auth/register', {'username': 'alice', 'password': password})
publisher.csrf = publisher.call('POST', '/api/auth/login', {'username': 'alice', 'password': password})['csrf']
run(['ssh-keygen', '-q', '-t', args.key_type, '-b', '3072', '-N', '', '-f', str(tmp / 'key')])
publisher.call('PUT', '/api/account/ssh-key', {'public_key': (tmp / 'key.pub').read_text().strip()})
publisher.call('GET', '/api/admin/users', expected=403)
env = os.environ.copy()
env['GIT_SSH_COMMAND'] = f'ssh -i {tmp / "key"} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile={tmp / "known_hosts"} -p 12222'
local = tmp / 'articles'
run(['git', 'init', '--initial-branch=main', str(local)], capture_output=True)
run(['git', '-C', str(local), 'config', 'user.name', 'Alice'])
run(['git', '-C', str(local), 'config', 'user.email', 'alice@example.invalid'])
run(['git', '-C', str(local), 'remote', 'add', 'origin', 'git@localhost:content.git'])
2026-10-16 23:36:56 +08:00
article = local / 'posts/alice-first/main.md'
article.parent.mkdir(parents=True)
2026-10-16 23:36:56 +08:00
original = (ROOT / 'content/posts/welcome/main.md').read_text()
article.write_text(original)
2026-10-16 23:36:56 +08:00
manifest = local / 'index.toml'
original_manifest = (ROOT / 'content/index.toml').read_text().replace('id = "welcome"', 'id = "alice-first"').replace('posts/welcome', 'posts/alice-first')
manifest.write_text(original_manifest)
chapter = article.parent / 'chapter.md'
chapter.write_text((ROOT / 'content/posts/welcome/chapter.md').read_text())
def commit():
run(['git', '-C', str(local), 'add', '.'])
run(['git', '-C', str(local), 'commit', '-m', 'Update article'], capture_output=True)
def push(success=True, *extra):
result = subprocess.run(['git', '-C', str(local), 'push', 'origin', *extra] if extra else ['git', '-C', str(local), 'push', '-u', 'origin', 'main'], env=env, capture_output=True, text=True)
assert (result.returncode == 0) == success, result.stderr
return result
commit()
push(False)
2026-10-16 23:36:56 +08:00
admin.call('PATCH', '/api/admin/users/alice', {'enabled': True, 'role': 'publisher'})
push()
2026-10-16 18:09:11 +08:00
wait_for(lambda: Client().call('GET', '/api/posts/alice~alice-first')['meta']['slug'] == 'alice~alice-first')
wait_for(lambda: (lambda s: s['indexed_commit'] == s['commit'])(admin.call('GET', '/api/admin/status')), timeout=120)
2026-10-16 23:36:56 +08:00
metadata_rows = database_articles()
assert len(metadata_rows) == 1
assert metadata_rows[0][:4] == ['alice~alice-first', 'alice', 'alice-first', 'posts/alice-first/main.md']
2026-10-16 18:09:11 +08:00
print('PASS: registration, independent grant, real SSH push, publication, real ChuSQL metadata')
2026-10-16 23:36:56 +08:00
first_article = Client().call('GET', '/api/posts/alice~alice-first')
assert first_article['author'] == 'alice'
assert {p['name'] for p in first_article['pages']} == {'main.md', 'chapter.md'}
assert Client().call('GET', '/api/posts/alice~alice-first/pages/chapter.md')['markdown'] == chapter.read_text()
original_dates = first_article['meta']
assert metadata_rows[0][4:7] == [original_dates['created_at'], original_dates['date'], original_dates['updated']]
assert metadata_rows[0][7:] == ['main.md', '']
# Malformed TOML is staged and survives restart without replacing the live version.
manifest.write_text('[[articles')
article.write_text(original + '\nPending change\n')
commit()
push()
wait_for(lambda: 'alice' in admin.call('GET', '/api/admin/status')['pending'])
assert Client().call('GET', '/api/posts/alice~alice-first')['markdown'] == original
run(compose + ['restart', 'api'])
wait_for(lambda: Client().call('GET', '/api/posts/alice~alice-first')['markdown'] == original)
admin.csrf = admin.call('POST', '/api/auth/login', {'username': 'admin', 'password': password})['csrf']
assert 'alice' in admin.call('GET', '/api/admin/status')['pending']
manifest.write_text(original_manifest)
commit()
push()
wait_for(lambda: 'Pending change' in Client().call('GET', '/api/posts/alice~alice-first')['markdown'])
updated = Client().call('GET', '/api/posts/alice~alice-first')['meta']
assert updated['date'] == original_dates['date']
assert updated['created_at'] == original_dates['created_at']
assert updated['updated'] > original_dates['updated']
print('PASS: linked Markdown pages, staged broken TOML across restart, repaired import and server timestamps')
article.write_text(original)
commit()
push()
ssh = ['ssh', '-i', str(tmp / 'key'), '-o', 'IdentitiesOnly=yes', '-o', 'StrictHostKeyChecking=accept-new', '-o', f'UserKnownHostsFile={tmp / "known_hosts"}', '-p', '12222', 'git@localhost']
result = subprocess.run(ssh + ['id'], capture_output=True)
assert result.returncode != 0, 'SSH shell command unexpectedly succeeded'
# Invalid commits must not advance the remote branch. Reset only this disposable local test clone.
2026-10-16 23:36:56 +08:00
for kind in ['script', 'symlink', 'traversal', 'certificate', 'gitignore', 'oversize', 'pack']:
if kind == 'script':
article.write_text(original + '\n<script>alert(1)</script>\n')
elif kind == 'symlink':
article.unlink()
article.symlink_to('/etc/passwd')
2026-10-16 23:36:56 +08:00
elif kind == 'traversal':
article.write_text(original + '\n[escape](../other/main.md)\n')
2026-10-16 18:09:11 +08:00
elif kind == 'certificate':
(article.parent / 'certificate.txt').write_text('-----BEGIN CERTIFICATE-----\ninvalid\n')
elif kind == 'gitignore':
(local / '.gitignore').write_text('*.pem\n')
elif kind == 'oversize':
article.write_text(original + 'x' * (257 * 1024))
else:
(local / 'oversized.bin').write_bytes(os.urandom(9 * 1024 * 1024))
commit()
push(False)
run(['git', '-C', str(local), 'reset', '--hard', 'origin/main'], capture_output=True)
push(False, 'main:refs/heads/other')
push(False, '--delete', 'main')
2026-10-16 23:36:56 +08:00
print('PASS: arbitrary command, HTML, symlink, cross-article link, disguised certificate, gitignore, oversize, branch and deletion rejected')
2026-10-16 18:09:11 +08:00
# A second authorized key maps the same remote alias to a separate object store.
bob = Client()
bob.call('POST', '/api/auth/register', {'username': 'bob', 'password': password})
bob.csrf = bob.call('POST', '/api/auth/login', {'username': 'bob', 'password': password})['csrf']
run(['ssh-keygen', '-q', '-t', 'ed25519', '-N', '', '-f', str(tmp / 'bob-key')])
bob.call('PUT', '/api/account/ssh-key', {'public_key': (tmp / 'bob-key.pub').read_text().strip()})
admin.call('PATCH', '/api/admin/users/bob', {'enabled': True, 'role': 'publisher'})
bob_env = env.copy()
bob_env['GIT_SSH_COMMAND'] = env['GIT_SSH_COMMAND'].replace(str(tmp / 'key'), str(tmp / 'bob-key'))
bob_local = tmp / 'bob-articles'
run(['git', 'clone', 'git@localhost:content.git', str(bob_local)], env=bob_env, capture_output=True)
2026-10-16 23:36:56 +08:00
assert not (bob_local / 'posts/alice-first/main.md').exists(), 'Alice content leaked into Bob clone'
2026-10-16 18:09:11 +08:00
alice_commit = run(['git', '-C', str(local), 'rev-parse', 'HEAD'], capture_output=True).stdout.strip()
result = subprocess.run(['git', '-C', str(bob_local), 'fetch', 'origin', alice_commit], env=bob_env, capture_output=True)
assert result.returncode != 0, 'Bob fetched Alice commit by hash'
result = subprocess.run(['git', 'ls-remote', 'git@localhost:alice.git'], env=bob_env, capture_output=True)
assert result.returncode != 0, 'Bob selected another repository path'
print('PASS: separate author clone, inaccessible foreign commit and forbidden repository selection')
# Removing only an index entry deletes the live article, while retaining its files.
2026-10-16 23:36:56 +08:00
manifest.write_text('articles = []\n')
2026-10-16 18:09:11 +08:00
commit()
push()
wait_for(lambda: Client().call('GET', '/api/stats')['published_posts'] == 0)
wait_for(lambda: (lambda s: s['indexed_commit'] == s['commit'])(admin.call('GET', '/api/admin/status')), timeout=120)
Client().call('GET', '/api/posts/alice~alice-first', expected=404)
assert database_articles() == [], 'Index removal did not delete database metadata'
print('PASS: index-only deletion removes HTTP article and database metadata')
2026-10-16 23:36:56 +08:00
manifest.write_text(original_manifest)
# A valid index whose entry disappeared denotes no article, not a staged update.
article.unlink()
commit()
push()
wait_for(lambda: not admin.call('GET', '/api/admin/status')['pending'])
assert Client().call('GET', '/api/stats')['published_posts'] == 0
article.write_text(original)
manifest.write_text(original_manifest.replace('public = true', 'public = false'))
commit()
push()
wait_for(lambda: Client().call('GET', '/api/stats')['published_posts'] == 0)
2026-10-16 18:09:11 +08:00
Client().call('GET', '/api/posts/alice~alice-first', expected=404)
2026-10-16 23:36:56 +08:00
manifest.write_text(original_manifest)
article.write_text(original)
commit()
push()
wait_for(lambda: Client().call('GET', '/api/stats')['published_posts'] == 1)
run(compose + ['stop', 'db'])
2026-10-16 18:09:11 +08:00
assert Client().call('GET', '/api/posts/alice~alice-first')['meta']['slug'] == 'alice~alice-first'
run(compose + ['restart', 'api'])
2026-10-16 18:09:11 +08:00
wait_for(lambda: Client().call('GET', '/api/posts/alice~alice-first')['meta']['slug'] == 'alice~alice-first')
admin.csrf = admin.call('POST', '/api/auth/login', {'username': 'admin', 'password': password})['csrf']
admin.call('PATCH', '/api/admin/users/alice', {'enabled': True, 'role': 'reader', 'prefixes': []})
article.write_text(original + '\nRevoked publisher\n')
commit()
push(False)
run(compose + ['start', 'db'])
wait_for(lambda: (lambda s: s['indexed_commit'] == s['commit'])(admin.call('GET', '/api/admin/status')), timeout=120)
print('PASS: draft privacy, database outage, API restart recovery, revocation, automatic index recovery')
2026-10-16 23:36:56 +08:00
if args.browser_test:
run([sys.executable, str(ROOT / 'scripts/test-web.py'), '--origin', ORIGIN], input=password)
except Exception:
run(compose + ['logs', '--tail', '40'])
raise
finally:
run(compose + ['down', '--volumes', '--remove-orphans'])