完善单数据库识别并对齐统一配置迁移验收
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-22 10:25:53 +08:00
parent f9540ca843
commit b71fbc49fe
8 changed files with 168 additions and 33 deletions

View File

@ -14,6 +14,9 @@ import deploy_review
import internal_smtp_check import internal_smtp_check
import restore_review import restore_review
import verify_review import verify_review
import runtime_review
from database_inventory import sqlite_inventory
from unittest.mock import patch
class FormalReviewDatabaseTest(unittest.TestCase): class FormalReviewDatabaseTest(unittest.TestCase):
@ -63,5 +66,86 @@ class FormalReviewDatabaseTest(unittest.TestCase):
self.assertEqual(list((root / 'data').iterdir()), []) self.assertEqual(list((root / 'data').iterdir()), [])
def test_extra_sqlite_files_cannot_hide_by_name_or_location(self):
for name in ('extra.db', 'STATE.DB', '.hidden.sqlite3', 'nested/extra.sqlite',
'../config/other.db', '../renamed.bin', 'old.db-wal', 'old.sqlite-shm'):
with self.subTest(name=name), tempfile.TemporaryDirectory() as directory:
root = Path(directory).resolve()
self.make_database(root)
path = root / 'data' / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b'SQLite format 3\x00')
for module in self.modules:
with self.subTest(module=module.__name__), self.assertRaises(AssertionError):
module.database_path(root)
with self.assertRaises(AssertionError):
runtime_review.database_integrity(root)
def test_expected_sqlite_sidecars_are_not_second_databases(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory).resolve()
self.make_database(root)
for suffix in ('-wal', '-shm', '-journal'):
(root / 'data' / ('smail.db' + suffix)).touch()
for module in self.modules:
self.assertEqual(module.database_path(root), root / 'data/smail.db')
def test_sql_mode_rejects_renamed_database_and_orphan_sidecars(self):
for name in ('renamed.bin', 'data/.hidden.sqlite', 'data/old.db-wal'):
with self.subTest(name=name), tempfile.TemporaryDirectory() as directory:
root = Path(directory).resolve()
(root / 'data').mkdir()
self.assertEqual(sqlite_inventory(root, []), [])
(root / name).write_bytes(b'SQLite format 3\x00')
with self.assertRaises(AssertionError):
sqlite_inventory(root, [])
def test_directory_links_are_rejected_before_reading_their_contents(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory).resolve()
self.make_database(root)
linked = root / 'data' / 'linked'
linked.mkdir()
original = Path.is_symlink
with patch.object(Path, 'is_symlink', lambda path: path == linked or original(path)):
with self.assertRaises(AssertionError):
sqlite_inventory(root, [root / 'data/smail.db'])
def test_snapshot_and_restore_counts_close_their_connections(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory).resolve()
self.make_database(root)
source = root / 'data/smail.db'
target = root / 'snapshot.db'
deploy_review.snapshot(source, target)
moved = root / 'snapshot-moved.db'
target.rename(moved)
with closing(sqlite3.connect(moved)) as database:
self.assertEqual(database.execute('PRAGMA integrity_check').fetchone(), ('ok',))
isolated = root / 'isolated'
isolated.mkdir()
self.make_database(isolated)
self.assertEqual(restore_review.database_counts(isolated), {'smail.db': {'marker': 0}})
(isolated / 'data/smail.db').rename(isolated / 'data/moved.db')
def test_deployed_schema_requires_unified_configuration_history(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory).resolve()
self.make_database(root)
with closing(sqlite3.connect(root / 'data/smail.db')) as database:
database.executescript('CREATE TABLE schema_migration (version INTEGER); '
'INSERT INTO schema_migration VALUES (23); '
'CREATE TABLE outbound_delivery (state TEXT); '
"INSERT INTO outbound_delivery VALUES ('pending');")
with self.assertRaises(AssertionError):
deploy_review.database_state(root)
database.executescript('INSERT INTO schema_migration VALUES (24);')
with self.assertRaises(sqlite3.OperationalError):
deploy_review.database_state(root)
database.executescript('CREATE TABLE config_records (id TEXT);')
self.assertEqual(deploy_review.database_state(root), (24, {'pending': 1}))
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()

View File

@ -0,0 +1,31 @@
from pathlib import Path
import stat
def sqlite_inventory(root, expected):
root = Path(root)
assert root.is_absolute() and root.resolve(strict=True) == root
assert (root / 'data').is_dir()
expected = set(expected)
assert all(path.is_relative_to(root) for path in expected)
seen = set()
pending = [root]
while pending:
path = pending.pop()
assert not path.is_symlink() and not getattr(path, 'is_junction', lambda: False)()
mode = path.stat().st_mode
if stat.S_ISDIR(mode):
pending.extend(path.iterdir())
continue
assert stat.S_ISREG(mode)
name = path.name.lower()
if name.endswith(('-wal', '-shm', '-journal')):
assert path.with_name(path.name.rsplit('-', 1)[0]) in expected
continue
with path.open('rb') as stream:
header = stream.read(16)
if header == b'SQLite format 3\x00' or name.endswith('.db') or '.sqlite' in name:
assert path in expected
seen.add(path)
assert seen == expected
return sorted(seen)

View File

@ -7,9 +7,12 @@ import os
from pathlib import Path from pathlib import Path
import shutil import shutil
import sqlite3 import sqlite3
from contextlib import closing
import subprocess import subprocess
import time import time
from database_inventory import sqlite_inventory
ROOT = Path('/opt/smail') ROOT = Path('/opt/smail')
STAGING = Path('/var/tmp/smail-review-20260917') STAGING = Path('/var/tmp/smail-review-20260917')
@ -27,8 +30,7 @@ def digest(path):
def database_path(root): def database_path(root):
data = root / 'data' data = root / 'data'
files = sorted(path for path in data.rglob('*.db') files = sqlite_inventory(root, [data / 'smail.db'])
if path.is_file() or path.is_symlink())
expected = data / DATABASE_NAME expected = data / DATABASE_NAME
assert files == [expected], '正式验收要求运行目录仅保留 data/smail.db' assert files == [expected], '正式验收要求运行目录仅保留 data/smail.db'
assert expected.is_file() and not expected.is_symlink(), '活动数据库不能是符号链接' assert expected.is_file() and not expected.is_symlink(), '活动数据库不能是符号链接'
@ -36,19 +38,31 @@ def database_path(root):
def snapshot(source, target): def snapshot(source, target):
with sqlite3.connect(f'file:{source}?mode=ro', uri=True) as original: with closing(sqlite3.connect(source.as_uri() + '?mode=ro', uri=True)) as original:
with sqlite3.connect(target) as copied: with closing(sqlite3.connect(target)) as copied:
original.backup(copied) original.backup(copied)
assert copied.execute('PRAGMA integrity_check').fetchone()[0] == 'ok' assert copied.execute('PRAGMA integrity_check').fetchone()[0] == 'ok'
target.chmod(0o600) target.chmod(0o600)
def database_state(root):
database = database_path(root)
with closing(sqlite3.connect(database.as_uri() + '?mode=ro', uri=True)) as connection:
assert connection.execute('PRAGMA integrity_check').fetchall() == [('ok',)]
version = connection.execute('SELECT MAX(version) FROM schema_migration').fetchone()[0]
assert version == 24
connection.execute('SELECT COUNT(*) FROM config_records').fetchone()
states = dict(connection.execute('SELECT state,COUNT(*) FROM outbound_delivery GROUP BY state'))
return version, states
def wait_ready(): def wait_ready():
for _ in range(60): for _ in range(60):
result = subprocess.run([ result = subprocess.run([
'curl', '--silent', '--fail', '--max-time', '2', '--noproxy', '*', 'curl', '--silent', '--fail', '--max-time', '2', '--noproxy', '*',
'--cacert', str(ROOT / 'ssl/custom.crt'), '--resolve', '--cacert', str(ROOT / 'ssl/custom.crt'), '--resolve',
'mail.solit.cn:443:127.0.0.1', 'https://mail.solit.cn/api/ping', 'web.solit.cn:443:127.0.0.1', 'https://web.solit.cn/api/ping',
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if result.returncode == 0 and run('systemctl', 'is-active', 'smail.service') == 'active': if result.returncode == 0 and run('systemctl', 'is-active', 'smail.service') == 'active':
return return
@ -98,12 +112,7 @@ def main():
assert digest(ROOT / name) == checksum assert digest(ROOT / name) == checksum
subprocess.run(['systemctl', 'start', 'smail.service'], check=True) subprocess.run(['systemctl', 'start', 'smail.service'], check=True)
wait_ready() wait_ready()
database_path(ROOT) version, states = database_state(ROOT)
with sqlite3.connect(f'file:{database}?mode=ro', uri=True) as connection:
assert connection.execute('PRAGMA integrity_check').fetchone()[0] == 'ok'
version = connection.execute('SELECT MAX(version) FROM schema_migration').fetchone()[0]
assert version == 23
states = dict(connection.execute('SELECT state,COUNT(*) FROM outbound_delivery GROUP BY state'))
result = {'backup': str(backup), 'archive_sha256': digest(archive), result = {'backup': str(backup), 'archive_sha256': digest(archive),
'artifacts': expected, 'migration': version, 'queue_states': states, 'artifacts': expected, 'migration': version, 'queue_states': states,
'service': run('systemctl', 'is-active', 'smail.service'), 'service': run('systemctl', 'is-active', 'smail.service'),

View File

@ -5,6 +5,7 @@ import hashlib
import json import json
import smtplib import smtplib
import sqlite3 import sqlite3
from contextlib import closing
import ssl import ssl
import subprocess import subprocess
import time import time
@ -13,6 +14,8 @@ from email.policy import SMTP
from email.utils import formatdate from email.utils import formatdate
from pathlib import Path from pathlib import Path
from database_inventory import sqlite_inventory
ROOT = Path('/opt/smail') ROOT = Path('/opt/smail')
DATABASE_NAME = 'smail.db' DATABASE_NAME = 'smail.db'
@ -24,8 +27,7 @@ ATTACHMENT = '中文附件内容,内部收信验收。'.encode('utf-8')
def database_path(root): def database_path(root):
data = root / 'data' data = root / 'data'
files = sorted(path for path in data.rglob('*.db') files = sqlite_inventory(root, [data / 'smail.db'])
if path.is_file() or path.is_symlink())
expected = data / DATABASE_NAME expected = data / DATABASE_NAME
assert files == [expected], '正式验收要求运行目录仅保留 data/smail.db' assert files == [expected], '正式验收要求运行目录仅保留 data/smail.db'
assert expected.is_file() and not expected.is_symlink(), '活动数据库不能是符号链接' assert expected.is_file() and not expected.is_symlink(), '活动数据库不能是符号链接'
@ -79,12 +81,12 @@ def main():
backup = Path('/var/backups') / f'smail-internal-smtp-{RUN}' backup = Path('/var/backups') / f'smail-internal-smtp-{RUN}'
backup.mkdir(mode=0o700) backup.mkdir(mode=0o700)
database_path_value = database_path(ROOT) database_path_value = database_path(ROOT)
with sqlite3.connect(f'file:{database_path_value}?mode=ro', uri=True) as source: with closing(sqlite3.connect(database_path_value.as_uri() + '?mode=ro', uri=True)) as source:
with sqlite3.connect(backup / DATABASE_NAME) as target: with closing(sqlite3.connect(backup / DATABASE_NAME)) as target:
source.backup(target) source.backup(target)
assert target.execute('PRAGMA integrity_check').fetchone()[0] == 'ok' assert target.execute('PRAGMA integrity_check').fetchone()[0] == 'ok'
(backup / DATABASE_NAME).chmod(0o600) (backup / DATABASE_NAME).chmod(0o600)
with sqlite3.connect(f'file:{database_path_value}?mode=ro', uri=True) as database: with closing(sqlite3.connect(database_path_value.as_uri() + '?mode=ro', uri=True)) as database:
database.row_factory = sqlite3.Row database.row_factory = sqlite3.Row
policy = json.loads((ROOT / 'config/email.json').read_text(encoding='utf-8')).get('policy', {}) policy = json.loads((ROOT / 'config/email.json').read_text(encoding='utf-8')).get('policy', {})
assert policy.get('spamFilterLevel', 0) == 0, 'This fixture requires the current unfiltered policy' assert policy.get('spamFilterLevel', 0) == 0, 'This fixture requires the current unfiltered policy'

View File

@ -7,10 +7,13 @@ import os
from pathlib import Path from pathlib import Path
import shutil import shutil
import sqlite3 import sqlite3
from contextlib import closing
import subprocess import subprocess
import tarfile import tarfile
import time import time
from database_inventory import sqlite_inventory
DATABASE_NAME = 'smail.db' DATABASE_NAME = 'smail.db'
@ -21,8 +24,7 @@ def run(*args):
def database_path(root): def database_path(root):
data = root / 'data' data = root / 'data'
files = sorted(path for path in data.rglob('*.db') files = sqlite_inventory(root, [data / 'smail.db'])
if path.is_file() or path.is_symlink())
expected = data / DATABASE_NAME expected = data / DATABASE_NAME
assert files == [expected], '正式验收要求运行目录仅保留 data/smail.db' assert files == [expected], '正式验收要求运行目录仅保留 data/smail.db'
assert expected.is_file() and not expected.is_symlink(), '活动数据库不能是符号链接' assert expected.is_file() and not expected.is_symlink(), '活动数据库不能是符号链接'
@ -32,7 +34,7 @@ def database_path(root):
def database_counts(root): def database_counts(root):
counts = {} counts = {}
database_path_value = database_path(root) database_path_value = database_path(root)
with sqlite3.connect(f'file:{database_path_value}?mode=ro', uri=True) as database: with closing(sqlite3.connect(database_path_value.as_uri() + '?mode=ro', uri=True)) as database:
assert database.execute('PRAGMA integrity_check').fetchone()[0] == 'ok' assert database.execute('PRAGMA integrity_check').fetchone()[0] == 'ok'
tables = database.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").fetchall() tables = database.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").fetchall()
counts[DATABASE_NAME] = { counts[DATABASE_NAME] = {
@ -95,7 +97,7 @@ def main():
probe = subprocess.run([ probe = subprocess.run([
'nsenter', '--target', pid, '--net', 'curl', '--silent', '--fail', '--max-time', '2', 'nsenter', '--target', pid, '--net', 'curl', '--silent', '--fail', '--max-time', '2',
'--noproxy', '*', '--cacert', str(restored / 'ssl/custom.crt'), '--noproxy', '*', '--cacert', str(restored / 'ssl/custom.crt'),
'--resolve', 'mail.solit.cn:443:127.0.0.1', 'https://mail.solit.cn/api/ping', '--resolve', 'web.solit.cn:443:127.0.0.1', 'https://web.solit.cn/api/ping',
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if probe.returncode == 0: if probe.returncode == 0:
ready = True ready = True
@ -124,9 +126,9 @@ with smtplib.SMTP('127.0.0.1',25,timeout=20) as client:
''' '''
subprocess.run(['nsenter', '--target', pid, '--net', 'python3', '-c', probe, subprocess.run(['nsenter', '--target', pid, '--net', 'python3', '-c', probe,
str(restored / 'ssl/custom.crt'), stamp], check=True) str(restored / 'ssl/custom.crt'), stamp], check=True)
with sqlite3.connect(f'file:{restored / "data/smail.db"}?mode=ro', uri=True) as database: with closing(sqlite3.connect(f'file:{restored / "data/smail.db"}?mode=ro', uri=True)) as database:
assert database.execute('SELECT COUNT(*) FROM email WHERE msg_id=?', ('restore-' + stamp + '@review.invalid',)).fetchone()[0] == 1 assert database.execute('SELECT COUNT(*) FROM email WHERE msg_id=?', ('restore-' + stamp + '@review.invalid',)).fetchone()[0] == 1
with sqlite3.connect('file:/opt/smail/data/smail.db?mode=ro', uri=True) as database: with closing(sqlite3.connect('file:/opt/smail/data/smail.db?mode=ro', uri=True)) as database:
assert database.execute('SELECT COUNT(*) FROM email WHERE msg_id=?', ('restore-' + stamp + '@review.invalid',)).fetchone()[0] == 0 assert database.execute('SELECT COUNT(*) FROM email WHERE msg_id=?', ('restore-' + stamp + '@review.invalid',)).fetchone()[0] == 0
result.update(ready=True, isolated=True, restored_smtp=True, live_pid_unchanged=live_pid == run( result.update(ready=True, isolated=True, restored_smtp=True, live_pid_unchanged=live_pid == run(
'systemctl', 'show', 'smail.service', '--property=MainPID', '--value')) 'systemctl', 'show', 'smail.service', '--property=MainPID', '--value'))

View File

@ -13,6 +13,8 @@ import sqlite3
import subprocess import subprocess
import time import time
from database_inventory import sqlite_inventory
def run(*args): def run(*args):
return subprocess.check_output(args, text=True).strip() return subprocess.check_output(args, text=True).strip()
@ -28,8 +30,7 @@ def database_integrity(root):
# 验收器不能创建数据库,也不能允许第二个状态库。 # 验收器不能创建数据库,也不能允许第二个状态库。
data = root / 'data' data = root / 'data'
assert not (data / 'smail-state.db').exists(), '单数据库实例生成了旧状态库' assert not (data / 'smail-state.db').exists(), '单数据库实例生成了旧状态库'
files = sorted(path for path in data.rglob('*.db') files = sqlite_inventory(root, [data / 'smail.db'])
if path.is_file() or path.is_symlink())
assert files == [data / 'smail.db'] assert files == [data / 'smail.db']
assert (data / 'smail.db').is_file() and not (data / 'smail.db').is_symlink() assert (data / 'smail.db').is_file() and not (data / 'smail.db').is_symlink()
with closing(sqlite3.connect((data / 'smail.db').as_uri() + '?mode=ro', uri=True)) as database: with closing(sqlite3.connect((data / 'smail.db').as_uri() + '?mode=ro', uri=True)) as database:
@ -173,6 +174,9 @@ def main():
shutil.chown(directory, user='smail', group='smail') shutil.chown(directory, user='smail', group='smail')
shutil.copy2(binary, root / 'smail') shutil.copy2(binary, root / 'smail')
(root / 'smail').chmod(0o755) (root / 'smail').chmod(0o755)
shutil.copy2(Path(__file__).with_name('database_inventory.py'), target / 'database_inventory.py')
(target / 'database_inventory.py').chmod(0o640)
shutil.chown(target / 'database_inventory.py', user='root', group='smail')
shutil.copy2(Path(__file__).with_name('runtime_probe.py'), target / 'runtime_probe.py') shutil.copy2(Path(__file__).with_name('runtime_probe.py'), target / 'runtime_probe.py')
(target / 'runtime_probe.py').chmod(0o640) (target / 'runtime_probe.py').chmod(0o640)
shutil.chown(target / 'runtime_probe.py', user='root', group='smail') shutil.chown(target / 'runtime_probe.py', user='root', group='smail')
@ -351,7 +355,7 @@ def main():
result['probe'] = json.loads((target / 'probe-result.json').read_text()) result['probe'] = json.loads((target / 'probe-result.json').read_text())
assert probe.returncode == 0, '隔离验收失败,请检查保留的 probe.log' assert probe.returncode == 0, '隔离验收失败,请检查保留的 probe.log'
assert result['probe']['passed'] assert result['probe']['passed']
with sqlite3.connect(f'file:{live / "data/smail.db"}?mode=ro', uri=True) as database: with closing(sqlite3.connect(f'file:{live / "data/smail.db"}?mode=ro', uri=True)) as database:
message_id = result['probe']['smtp']['message_id'] message_id = result['probe']['smtp']['message_id']
assert database.execute('SELECT COUNT(*) FROM email WHERE msg_id=?', (message_id,)).fetchone()[0] == 0 assert database.execute('SELECT COUNT(*) FROM email WHERE msg_id=?', (message_id,)).fetchone()[0] == 0
result['live_message_absent'] = True result['live_message_absent'] = True

View File

@ -7,6 +7,8 @@ import re
import secrets import secrets
import subprocess import subprocess
from database_inventory import sqlite_inventory
def command(args, sql, env=None): def command(args, sql, env=None):
result = subprocess.run(args, input=sql, text=True, capture_output=True, result = subprocess.run(args, input=sql, text=True, capture_output=True,
@ -75,8 +77,7 @@ class SQLDatabase:
def integrity(self, root): def integrity(self, root):
# 配置历史和业务表必须位于所选 SQL 库,运行目录不能悄悄创建 SQLite。 # 配置历史和业务表必须位于所选 SQL 库,运行目录不能悄悄创建 SQLite。
assert not list((root / 'data').rglob('*.db')) sqlite_inventory(root, [])
assert not list((root / 'data').rglob('*.sqlite*'))
count = int(self.query('SELECT COUNT(*) FROM config_records')) count = int(self.query('SELECT COUNT(*) FROM config_records'))
assert count > 0 assert count > 0
return {'type': self.kind, 'database': self.settings['name'], return {'type': self.kind, 'database': self.settings['name'],

View File

@ -6,9 +6,12 @@ import json
import os import os
from pathlib import Path from pathlib import Path
import sqlite3 import sqlite3
from contextlib import closing
import stat import stat
import subprocess import subprocess
from database_inventory import sqlite_inventory
DATABASE_NAME = 'smail.db' DATABASE_NAME = 'smail.db'
@ -19,8 +22,7 @@ def run(*args):
def database_path(root): def database_path(root):
data = root / 'data' data = root / 'data'
files = sorted(path for path in data.rglob('*.db') files = sqlite_inventory(root, [data / 'smail.db'])
if path.is_file() or path.is_symlink())
expected = data / DATABASE_NAME expected = data / DATABASE_NAME
assert files == [expected], '正式验收要求运行目录仅保留 data/smail.db' assert files == [expected], '正式验收要求运行目录仅保留 data/smail.db'
assert expected.is_file() and not expected.is_symlink(), '活动数据库不能是符号链接' assert expected.is_file() and not expected.is_symlink(), '活动数据库不能是符号链接'
@ -72,7 +74,7 @@ def main():
summary['admin_version'] = run(str(root / 'smail-admin'), 'version') summary['admin_version'] = run(str(root / 'smail-admin'), 'version')
summary['database_integrity'] = {} summary['database_integrity'] = {}
database = database_path(root) database = database_path(root)
with sqlite3.connect(f'file:{database}?mode=ro', uri=True) as connection: with closing(sqlite3.connect(database.as_uri() + '?mode=ro', uri=True)) as connection:
integrity = connection.execute('PRAGMA integrity_check').fetchall() integrity = connection.execute('PRAGMA integrity_check').fetchall()
assert integrity == [('ok',)] assert integrity == [('ok',)]
summary['database_integrity'][DATABASE_NAME] = 'ok' summary['database_integrity'][DATABASE_NAME] = 'ok'
@ -83,11 +85,11 @@ def main():
assert control.st_uid == database.stat().st_uid assert control.st_uid == database.stat().st_uid
summary['control_socket_mode'] = oct(stat.S_IMODE(control.st_mode)) summary['control_socket_mode'] = oct(stat.S_IMODE(control.st_mode))
curl = ['curl', '--silent', '--show-error', '--fail', '--max-time', '10', '--noproxy', '*', curl = ['curl', '--silent', '--show-error', '--fail', '--max-time', '10', '--noproxy', '*',
'--cacert', str(root / 'ssl/custom.crt'), '--resolve', 'mail.solit.cn:443:127.0.0.1'] '--cacert', str(root / 'ssl/custom.crt'), '--resolve', 'web.solit.cn:443:127.0.0.1']
run(*curl, 'https://mail.solit.cn/api/ping') run(*curl, 'https://web.solit.cn/api/ping')
summary['unauthenticated'] = {} summary['unauthenticated'] = {}
for endpoint in ('/api/v1/user/info', '/api/v1/email/list'): for endpoint in ('/api/v1/user/info', '/api/v1/email/list'):
response = json.loads(run(*curl, '-X', 'POST', 'https://mail.solit.cn' + endpoint)) response = json.loads(run(*curl, '-X', 'POST', 'https://web.solit.cn' + endpoint))
assert response['errorNo'] == 403 and not response.get('data') assert response['errorNo'] == 403 and not response.get('data')
summary['unauthenticated'][endpoint] = response['errorNo'] summary['unauthenticated'][endpoint] = response['errorNo']
entries = run('journalctl', '-u', 'smail.service', '--since', args.since, '--output=json', '--no-pager') entries = run('journalctl', '-u', 'smail.service', '--since', args.since, '--output=json', '--no-pager')