This commit is contained in:
parent
a74e8100d3
commit
22d4a84db7
126
test/deployment/test_runtime_sql.py
Normal file
126
test/deployment/test_runtime_sql.py
Normal file
@ -0,0 +1,126 @@
|
||||
"""SQL 实机验收器的范围限制、敏感信息和命名空间恢复回归。"""
|
||||
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'tools'))
|
||||
import runtime_sql
|
||||
import runtime_sql_bridge
|
||||
|
||||
|
||||
def settings(kind='mysql'):
|
||||
return {'kind': kind, 'name': 'smail_runtime_test_123456abcdef',
|
||||
'user': 'smail_rt_123456abcdef', 'password': 'a' * 40}
|
||||
|
||||
|
||||
class RuntimeSQLTest(unittest.TestCase):
|
||||
def test_rejects_other_projects_before_any_command(self):
|
||||
for key, value in [('name', 'smail'), ('name', 'production'),
|
||||
('name', "smail_runtime_test_123456abcdef'; DROP TABLE user"),
|
||||
('user', 'root'), ('kind', 'sqlite'), ('password', "a';SELECT 1")]:
|
||||
with self.subTest(key=key), patch.object(runtime_sql, 'command') as command:
|
||||
candidate = settings()
|
||||
candidate[key] = value
|
||||
with self.assertRaises(AssertionError):
|
||||
runtime_sql.SQLDatabase(candidate)
|
||||
command.assert_not_called()
|
||||
|
||||
def test_password_is_not_in_command_arguments(self):
|
||||
for kind, key in [('mysql', 'MYSQL_PWD'), ('postgres', 'PGPASSWORD')]:
|
||||
with self.subTest(kind=kind), patch.object(runtime_sql, 'command', return_value='0') as command:
|
||||
database = runtime_sql.SQLDatabase(settings(kind))
|
||||
database.assert_empty_users()
|
||||
args, sql, env = command.call_args.args
|
||||
self.assertNotIn(settings()['password'], ' '.join(args))
|
||||
self.assertEqual(env[key], settings()['password'])
|
||||
self.assertIn('COUNT(*)', sql)
|
||||
|
||||
def test_driver_error_details_are_not_exposed(self):
|
||||
failure = subprocess.CompletedProcess([], 1, '', 'sensitive-test-value')
|
||||
with patch.object(runtime_sql.subprocess, 'run', return_value=failure):
|
||||
with self.assertRaises(RuntimeError) as caught:
|
||||
runtime_sql.command(['mysql'], 'SELECT 1')
|
||||
self.assertNotIn('sensitive-test-value', str(caught.exception))
|
||||
|
||||
def test_fixture_grants_only_exact_database_and_locks_account(self):
|
||||
for kind in ('mysql', 'postgres'):
|
||||
with self.subTest(kind=kind):
|
||||
fixture = runtime_sql.SQLFixture(kind)
|
||||
with patch.object(fixture, 'admin', return_value='0') as admin:
|
||||
fixture.create()
|
||||
fixture.lock()
|
||||
statements = '\n'.join(item.args[0] for item in admin.call_args_list)
|
||||
self.assertNotIn('DROP ', statements)
|
||||
self.assertNotIn('*.*', statements)
|
||||
self.assertNotIn('CREATEDB', statements)
|
||||
if kind == 'mysql':
|
||||
self.assertIn(fixture.settings['name'].replace('_', r'\_'), statements)
|
||||
self.assertIn('ACCOUNT LOCK', statements)
|
||||
else:
|
||||
self.assertIn('NOLOGIN', statements)
|
||||
|
||||
def test_uncreated_account_does_not_trigger_admin_command(self):
|
||||
fixture = runtime_sql.SQLFixture('mysql')
|
||||
with patch.object(fixture, 'admin') as admin:
|
||||
fixture.lock()
|
||||
admin.assert_not_called()
|
||||
|
||||
def test_nonempty_database_fails_first_install_check(self):
|
||||
fixture = runtime_sql.SQLFixture('postgres')
|
||||
with patch.object(fixture, 'admin', return_value='3'):
|
||||
with self.assertRaises(AssertionError):
|
||||
fixture.create()
|
||||
|
||||
def test_sql_mode_rejects_hidden_second_sqlite_database(self):
|
||||
database = runtime_sql.SQLDatabase(settings())
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
(root / 'data/nested').mkdir(parents=True)
|
||||
(root / 'data/nested/smail-state.db').touch()
|
||||
with patch.object(database, 'query') as query:
|
||||
with self.assertRaises(AssertionError):
|
||||
database.integrity(root)
|
||||
query.assert_not_called()
|
||||
|
||||
def test_sql_mode_requires_configuration_history_in_same_database(self):
|
||||
database = runtime_sql.SQLDatabase(settings())
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
(root / 'data').mkdir()
|
||||
with patch.object(database, 'query', return_value='0'):
|
||||
with self.assertRaises(AssertionError):
|
||||
database.integrity(root)
|
||||
with patch.object(database, 'query', return_value='5'):
|
||||
self.assertEqual(database.integrity(root)['sqlite_files'], 0)
|
||||
|
||||
def test_bridge_rejects_non_database_port_before_opening_socket(self):
|
||||
with patch.object(runtime_sql_bridge.socket, 'socket') as socket:
|
||||
with self.assertRaises(AssertionError):
|
||||
runtime_sql_bridge.DatabaseBridge(1234, 22)
|
||||
socket.assert_not_called()
|
||||
|
||||
def test_bridge_restores_host_namespace_when_bind_fails(self):
|
||||
handles = []
|
||||
for descriptor in (11, 12):
|
||||
handle = MagicMock()
|
||||
handle.__enter__.return_value = handle
|
||||
handle.fileno.return_value = descriptor
|
||||
handles.append(handle)
|
||||
with patch.object(runtime_sql_bridge.Path, 'readlink', side_effect=[Path('net2'), Path('net1')]), \
|
||||
patch('builtins.open', side_effect=handles), \
|
||||
patch.object(runtime_sql_bridge.os, 'setns', create=True) as setns, \
|
||||
patch.object(runtime_sql_bridge.socketserver.ThreadingTCPServer, '__init__', side_effect=OSError()), \
|
||||
patch.object(runtime_sql_bridge.threading, 'Thread') as thread:
|
||||
with self.assertRaises(OSError):
|
||||
runtime_sql_bridge.DatabaseBridge(1234, 3306)
|
||||
self.assertEqual(setns.call_args_list, [call(12, 0), call(11, 0)])
|
||||
thread.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@ -185,6 +185,38 @@ def protocol_operation(protocol, context, code):
|
||||
return invoke
|
||||
|
||||
|
||||
def verify_imap_append(context, code, message_id, body, attachment):
|
||||
appended_id = 'append-' + message_id
|
||||
message = EmailMessage()
|
||||
message['From'] = ADDRESS
|
||||
message['To'] = ADDRESS
|
||||
message['Subject'] = '邮件协议追加和删除验收'
|
||||
message['Message-ID'] = '<' + appended_id + '>'
|
||||
message.set_content(body)
|
||||
message.add_attachment(attachment, maintype='text', subtype='plain', filename='验收附件.txt')
|
||||
client = LocalIMAP(HOST, 993, ssl_context=context, timeout=30)
|
||||
try:
|
||||
assert client.login(ADDRESS, code)[0] == 'OK'
|
||||
assert client.select('INBOX')[0] == 'OK'
|
||||
assert client.append('INBOX', None, None, message.as_bytes(policy=email_policy.clone(linesep='\r\n')))[0] == 'OK'
|
||||
status, values = client.response('APPENDUID')
|
||||
assert status == 'APPENDUID' and len(values) == 1 and values[0]
|
||||
validity, uid = map(int, values[0].split())
|
||||
assert 0 < validity <= 2**32 - 1 and 0 < uid <= 2**32 - 1
|
||||
assert client.noop()[0] == 'OK'
|
||||
status, contents = client.uid('FETCH', str(uid), '(BODY.PEEK[])')
|
||||
assert status == 'OK'
|
||||
raw = next(item[1] for item in contents if isinstance(item, tuple))
|
||||
verify_message_content(raw, appended_id, body, attachment)
|
||||
assert client.uid('STORE', str(uid), '+FLAGS.SILENT', r'(\Deleted)')[0] == 'OK'
|
||||
assert client.uid('EXPUNGE', str(uid))[0] == 'OK'
|
||||
status, matches = client.uid('SEARCH', None, 'HEADER', 'Message-ID', '"<' + appended_id + '>"')
|
||||
assert status == 'OK' and not matches[0].split()
|
||||
finally:
|
||||
client.logout()
|
||||
return {'append_uid': True, 'body_attachment': True, 'uid_expunge': True}
|
||||
|
||||
|
||||
def verify_restored(root, context, password, message_id, port):
|
||||
client = Client(context, port=port)
|
||||
for _ in range(60):
|
||||
@ -229,12 +261,18 @@ def main():
|
||||
parser.add_argument('--proxy', action='store_true')
|
||||
parser.add_argument('--password-stdin', action='store_true')
|
||||
parser.add_argument('--restore-message-id')
|
||||
parser.add_argument('--sql-stdin', action='store_true')
|
||||
args = parser.parse_args()
|
||||
assert str(Path('/proc/self/ns/net').readlink()) != args.live_net, '禁止在宿主网络执行测试'
|
||||
root = args.root.resolve(strict=True)
|
||||
assert root.parent.name.startswith(('smail-review-runtime-', 'smail-review-restore-')) and root.name == 'instance'
|
||||
password = sys.stdin.readline().strip() if args.password_stdin else secrets.token_urlsafe(9)
|
||||
assert len(password) == 12
|
||||
sql_database = None
|
||||
if args.sql_stdin:
|
||||
assert args.password_stdin and not args.restore_message_id
|
||||
from runtime_sql import SQLDatabase
|
||||
sql_database = SQLDatabase(json.loads(sys.stdin.readline()))
|
||||
if args.restore_message_id:
|
||||
assert args.password_stdin
|
||||
context = ssl.create_default_context(cafile=str(root / 'ssl/runtime.crt'))
|
||||
@ -258,9 +296,14 @@ def main():
|
||||
assert Client(port=setup_port).request('/api/v1/setup', {'step': 'database', 'action': 'get'})[0] == 403
|
||||
assert not (root / 'data/smail.db').exists(), '业务库必须从空数据开始'
|
||||
setup = '/api/v1/setup'
|
||||
client.success(setup, {'step': 'database', 'action': 'set', 'db_type': 'sqlite', 'db_dsn': ''})
|
||||
with sqlite3.connect(f'file:{root / "data/smail.db"}?mode=ro', uri=True) as database:
|
||||
assert database.execute('SELECT COUNT(*) FROM user').fetchone()[0] == 0
|
||||
if sql_database:
|
||||
client.success(setup, sql_database.setup_payload())
|
||||
sql_database.assert_empty_users()
|
||||
assert not (root / 'data/smail.db').exists()
|
||||
else:
|
||||
client.success(setup, {'step': 'database', 'action': 'set', 'db_type': 'sqlite', 'db_dsn': ''})
|
||||
with sqlite3.connect(f'file:{root / "data/smail.db"}?mode=ro', uri=True) as database:
|
||||
assert database.execute('SELECT COUNT(*) FROM user').fetchone()[0] == 0
|
||||
client.success(setup, {'step': 'password', 'action': 'set', 'account': ACCOUNT, 'password': password})
|
||||
client.success(setup, {'step': 'domain', 'action': 'set', 'smtp_domain': 'runtime.invalid',
|
||||
'web_domain': HOST, 'multi_domain': ''})
|
||||
@ -320,18 +363,22 @@ def main():
|
||||
message.add_attachment(attachment, maintype='text', subtype='plain', filename='验收附件.txt')
|
||||
with smtp_connect(context) as smtp:
|
||||
assert not smtp.send_message(message)
|
||||
with sqlite3.connect(f'file:{root / "data/smail.db"}?mode=ro', uri=True) as database:
|
||||
rows = database.execute('SELECT id,text,attachments FROM email WHERE msg_id=?', (message_id,)).fetchall()
|
||||
assert len(rows) == 1 and body in rows[0][1]
|
||||
attachments = json.loads(rows[0][2])
|
||||
assert len(attachments) == 1 and attachments[0]['Filename'] == '验收附件.txt'
|
||||
assert base64.b64decode(attachments[0]['Content']) == attachment
|
||||
owners = database.execute('SELECT u.account FROM user_email e JOIN user u ON u.id=e.user_id WHERE e.email_id=?',
|
||||
(rows[0][0],)).fetchall()
|
||||
assert owners == [(ACCOUNT,)]
|
||||
if sql_database:
|
||||
sql_database.verify_message(message_id, ACCOUNT, body, attachment)
|
||||
else:
|
||||
with sqlite3.connect(f'file:{root / "data/smail.db"}?mode=ro', uri=True) as database:
|
||||
rows = database.execute('SELECT id,text,attachments FROM email WHERE msg_id=?', (message_id,)).fetchall()
|
||||
assert len(rows) == 1 and body in rows[0][1]
|
||||
attachments = json.loads(rows[0][2])
|
||||
assert len(attachments) == 1 and attachments[0]['Filename'] == '验收附件.txt'
|
||||
assert base64.b64decode(attachments[0]['Content']) == attachment
|
||||
owners = database.execute('SELECT u.account FROM user_email e JOIN user u ON u.id=e.user_id WHERE e.email_id=?',
|
||||
(rows[0][0],)).fetchall()
|
||||
assert owners == [(ACCOUNT,)]
|
||||
result['smtp'] = {'message_id': message_id, 'stored': True, 'owners': 1, 'attachment': True}
|
||||
result['stage'] = 'protocol_read'
|
||||
result['protocol_read'] = verify_protocol_read(context, codes, message_id, body, attachment)
|
||||
result['imap_append'] = verify_imap_append(context, codes['imap'], message_id, body, attachment)
|
||||
result['stage'] = 'overload'
|
||||
rounds = []
|
||||
result['overload'] = rounds
|
||||
|
||||
@ -20,7 +20,8 @@ def run(*args):
|
||||
|
||||
def service_state():
|
||||
return {name: run('systemctl', 'show', name, '--property=MainPID,NRestarts,ActiveState,InvocationID')
|
||||
for name in ('smail.service', 'nginx.service', 'mysql.service', 'Scert.service')}
|
||||
for name in ('smail.service', 'nginx.service', 'mysql.service', 'Scert.service',
|
||||
'fdm-studio.service', 'postgresql@18-main.service')}
|
||||
|
||||
|
||||
def database_integrity(root):
|
||||
@ -78,7 +79,10 @@ def main():
|
||||
parser.add_argument('--nginx-template', type=Path)
|
||||
parser.add_argument('--memory-max-mib', type=int, choices=(128, 192, 256), default=128)
|
||||
parser.add_argument('--restore', action='store_true')
|
||||
parser.add_argument('--database-type', choices=('sqlite', 'mysql', 'postgres'), default='sqlite')
|
||||
args = parser.parse_args()
|
||||
if args.restore and args.database_type != 'sqlite':
|
||||
parser.error('SQL 数据库备份恢复需要独立验收,不能套用 SQLite 冷备流程')
|
||||
assert os.geteuid() == 0 and run('hostname') == 'ikun2'
|
||||
os.umask(0o027)
|
||||
live = Path('/opt/smail')
|
||||
@ -112,6 +116,10 @@ def main():
|
||||
shutil.copy2(Path(__file__).with_name('runtime_probe.py'), target / 'runtime_probe.py')
|
||||
(target / 'runtime_probe.py').chmod(0o640)
|
||||
shutil.chown(target / 'runtime_probe.py', user='root', group='smail')
|
||||
if args.database_type != 'sqlite':
|
||||
shutil.copy2(Path(__file__).with_name('runtime_sql.py'), target / 'runtime_sql.py')
|
||||
(target / 'runtime_sql.py').chmod(0o640)
|
||||
shutil.chown(target / 'runtime_sql.py', user='root', group='smail')
|
||||
if args.nginx_template:
|
||||
shutil.copy2(Path(__file__).with_name('runtime_proxy.py'), target / 'runtime_proxy.py')
|
||||
(target / 'runtime_proxy.py').chmod(0o640)
|
||||
@ -136,6 +144,13 @@ def main():
|
||||
'topology': 'nginx-8001-8002' if args.nginx_template else 'direct-80-443'}
|
||||
service_env = []
|
||||
probe_args = []
|
||||
sql_fixture = None
|
||||
bridge = None
|
||||
result['database_type'] = args.database_type
|
||||
if args.database_type != 'sqlite':
|
||||
from runtime_sql import SQLFixture
|
||||
sql_fixture = SQLFixture(args.database_type)
|
||||
result['sql_database'] = sql_fixture.settings['name']
|
||||
if args.nginx_template:
|
||||
from runtime_proxy import prepare_nginx
|
||||
prepare_nginx(target, root, args.nginx_template)
|
||||
@ -146,6 +161,9 @@ def main():
|
||||
started = time.strftime('%Y-%m-%dT%H:%M:%S')
|
||||
password = secrets.token_urlsafe(9)
|
||||
try:
|
||||
if sql_fixture:
|
||||
sql_fixture.create()
|
||||
probe_args.append('--sql-stdin')
|
||||
subprocess.run([
|
||||
'systemd-run', '--unit=' + unit, '--property=Type=exec',
|
||||
'--property=User=smail', '--property=Group=smail',
|
||||
@ -168,6 +186,9 @@ def main():
|
||||
assert str(Path('/proc/' + pid + '/ns/net').readlink()) != live_net
|
||||
assert Path('/proc/' + pid + '/root/opt/smail/smail').stat().st_ino == (root / 'smail').stat().st_ino
|
||||
assert (root / 'smail').stat().st_ino != (live / 'smail').stat().st_ino
|
||||
if sql_fixture:
|
||||
from runtime_sql_bridge import DatabaseBridge
|
||||
bridge = DatabaseBridge(int(pid), sql_fixture.port)
|
||||
if args.nginx_template:
|
||||
subprocess.run([
|
||||
'systemd-run', '--unit=' + proxy_unit, '--property=Type=exec',
|
||||
@ -198,7 +219,8 @@ def main():
|
||||
'--property=KillMode=control-group', '--property=TimeoutStopSec=10s',
|
||||
'python3', str(target / 'runtime_probe.py'), str(root), '--live-net', live_net,
|
||||
'--password-stdin', *probe_args,
|
||||
], input=password + '\n', text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=270)
|
||||
], input=password + '\n' + (json.dumps(sql_fixture.settings) + '\n' if sql_fixture else ''),
|
||||
text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=270)
|
||||
(target / 'probe.log').write_text(probe.stdout, encoding='utf-8')
|
||||
result['probe_code'] = probe.returncode
|
||||
if (target / 'probe-result.json').exists():
|
||||
@ -210,15 +232,30 @@ def main():
|
||||
assert database.execute('SELECT COUNT(*) FROM email WHERE msg_id=?', (message_id,)).fetchone()[0] == 0
|
||||
result['live_message_absent'] = True
|
||||
finally:
|
||||
collect_and_stop(result, unit, probe_unit, proxy_unit if args.nginx_template else None)
|
||||
result['after'] = service_state()
|
||||
with (live / 'smail').open('rb') as stream:
|
||||
result['live_unchanged'] = (result['after'] == before
|
||||
and hashlib.file_digest(stream, 'sha256').hexdigest() == live_sha)
|
||||
try:
|
||||
result['integrity'] = database_integrity(root)
|
||||
except (AssertionError, sqlite3.Error) as error:
|
||||
result['integrity'] = {'error': type(error).__name__}
|
||||
collect_and_stop(result, unit, probe_unit, proxy_unit if args.nginx_template else None)
|
||||
result['after'] = service_state()
|
||||
with (live / 'smail').open('rb') as stream:
|
||||
result['live_unchanged'] = (result['after'] == before
|
||||
and hashlib.file_digest(stream, 'sha256').hexdigest() == live_sha)
|
||||
try:
|
||||
result['integrity'] = sql_fixture.integrity(root) if sql_fixture else database_integrity(root)
|
||||
except (AssertionError, sqlite3.Error, RuntimeError, OSError, ValueError, subprocess.SubprocessError) as error:
|
||||
result['integrity'] = {'error': type(error).__name__}
|
||||
finally:
|
||||
result.setdefault('cleanup_errors', [])
|
||||
if bridge:
|
||||
try:
|
||||
bridge.close()
|
||||
result['database_bridge_stopped'] = True
|
||||
except Exception as error:
|
||||
result['cleanup_errors'].append({'action': 'database_bridge', 'error': type(error).__name__})
|
||||
if sql_fixture:
|
||||
try:
|
||||
sql_fixture.lock()
|
||||
result['test_database_account_locked'] = True
|
||||
except Exception as error:
|
||||
result['cleanup_errors'].append({'action': 'database_account', 'error': type(error).__name__})
|
||||
(target / 'service.log').write_text(run('journalctl', '-u', unit, '--no-pager'), encoding='utf-8')
|
||||
kernel = run('journalctl', '-k', '--since', started, '--no-pager')
|
||||
result['oom_events'] = sum('oom-kill:constraint=' in line for line in kernel.splitlines())
|
||||
@ -227,7 +264,10 @@ def main():
|
||||
assert result['live_unchanged'] and result['oom_events'] == 0
|
||||
assert not result['cleanup_errors'], '测试单元未全部停止,需要核查保留的结果'
|
||||
assert not result['resource_errors'], '资源证据不完整,需要核查保留的结果'
|
||||
assert result['integrity'] == {'smail.db': [('ok',)]}
|
||||
if sql_fixture:
|
||||
assert result['integrity'].get('sqlite_files') == 0 and result['integrity']['config_records'] > 0
|
||||
else:
|
||||
assert result['integrity'] == {'smail.db': [('ok',)]}
|
||||
if args.restore:
|
||||
from runtime_restore import verify_backup_restore
|
||||
restored = verify_backup_restore(root, result, password, args.nginx_template is not None)
|
||||
|
||||
128
test/tools/runtime_sql.py
Normal file
128
test/tools/runtime_sql.py
Normal file
@ -0,0 +1,128 @@
|
||||
"""隔离实机验收的 SQL 数据库适配;不删除数据库,不记录连接凭据。"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import subprocess
|
||||
|
||||
|
||||
def command(args, sql, env=None):
|
||||
result = subprocess.run(args, input=sql, text=True, capture_output=True,
|
||||
timeout=30, env=env, encoding='utf-8')
|
||||
if result.returncode:
|
||||
# 驱动错误可能包含连接参数,不能直接传播到验收日志。
|
||||
raise RuntimeError('隔离测试数据库操作失败,请在受保护环境核查')
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
class SQLDatabase:
|
||||
def __init__(self, settings):
|
||||
assert set(settings) == {'kind', 'name', 'user', 'password'}
|
||||
assert settings['kind'] in ('mysql', 'postgres')
|
||||
assert re.fullmatch(r'smail_runtime_test_[0-9a-f]{12}', settings['name'])
|
||||
assert re.fullmatch(r'smail_rt_[0-9a-f]{12}', settings['user'])
|
||||
assert re.fullmatch(r'[A-Za-z0-9_-]{32,64}', settings['password'])
|
||||
self.settings = settings.copy()
|
||||
self.kind = settings['kind']
|
||||
self.port = 3306 if self.kind == 'mysql' else 5432
|
||||
|
||||
def setup_payload(self):
|
||||
return {'step': 'database', 'action': 'set', 'db_type': self.kind,
|
||||
'db_mode': 'fields', 'db_host': '127.0.0.1', 'db_port': str(self.port),
|
||||
'db_name': self.settings['name'], 'db_user': self.settings['user'],
|
||||
'db_password': self.settings['password']}
|
||||
|
||||
def query(self, sql):
|
||||
env = os.environ.copy()
|
||||
if self.kind == 'mysql':
|
||||
env['MYSQL_PWD'] = self.settings['password']
|
||||
args = ['mysql', '--protocol=tcp', '--host=127.0.0.1', '--port=3306',
|
||||
'--user=' + self.settings['user'], '--database=' + self.settings['name'],
|
||||
'--default-character-set=utf8mb4', '--batch', '--raw', '--skip-column-names']
|
||||
else:
|
||||
env['PGPASSWORD'] = self.settings['password']
|
||||
env['PGCLIENTENCODING'] = 'UTF8'
|
||||
args = ['psql', '-X', '-v', 'ON_ERROR_STOP=1', '-At', '--host=127.0.0.1',
|
||||
'--port=5432', '--username=' + self.settings['user'],
|
||||
'--dbname=' + self.settings['name']]
|
||||
return command(args, sql, env)
|
||||
|
||||
def assert_empty_users(self):
|
||||
table = '`user`' if self.kind == 'mysql' else '"user"'
|
||||
assert self.query('SELECT COUNT(*) FROM ' + table) == '0'
|
||||
|
||||
def verify_message(self, message_id, account, body, attachment):
|
||||
assert re.fullmatch(r'[a-zA-Z0-9@._-]+', message_id)
|
||||
constructor = 'JSON_OBJECT' if self.kind == 'mysql' else 'json_build_object'
|
||||
content = self.query("SELECT " + constructor + "('id',id,'text',text,'attachments',attachments)"
|
||||
" FROM email WHERE msg_id='" + message_id + "'")
|
||||
rows = content.splitlines()
|
||||
assert len(rows) == 1
|
||||
record = json.loads(rows[0])
|
||||
assert body in record['text']
|
||||
attachments = record['attachments']
|
||||
if isinstance(attachments, str):
|
||||
attachments = json.loads(attachments)
|
||||
assert len(attachments) == 1 and attachments[0]['Filename'] == '验收附件.txt'
|
||||
assert base64.b64decode(attachments[0]['Content']) == attachment
|
||||
table = '`user`' if self.kind == 'mysql' else '"user"'
|
||||
assert isinstance(record['id'], int) and record['id'] > 0
|
||||
owners = self.query('SELECT u.account FROM user_email e JOIN ' + table +
|
||||
' u ON u.id=e.user_id WHERE e.email_id=' + str(record['id']))
|
||||
assert owners.splitlines() == [account]
|
||||
|
||||
def integrity(self, root):
|
||||
# 配置历史和业务表必须位于所选 SQL 库,运行目录不能悄悄创建 SQLite。
|
||||
assert not list((root / 'data').rglob('*.db'))
|
||||
assert not list((root / 'data').rglob('*.sqlite*'))
|
||||
count = int(self.query('SELECT COUNT(*) FROM config_records'))
|
||||
assert count > 0
|
||||
return {'type': self.kind, 'database': self.settings['name'],
|
||||
'config_records': count, 'sqlite_files': 0}
|
||||
|
||||
|
||||
class SQLFixture(SQLDatabase):
|
||||
def __init__(self, kind):
|
||||
token = secrets.token_hex(6)
|
||||
super().__init__({'kind': kind, 'name': 'smail_runtime_test_' + token,
|
||||
'user': 'smail_rt_' + token, 'password': secrets.token_urlsafe(30)})
|
||||
self.account_created = False
|
||||
|
||||
def admin(self, sql, database=None):
|
||||
if self.kind == 'mysql':
|
||||
args = ['mysql', '--protocol=socket', '--batch', '--skip-column-names']
|
||||
else:
|
||||
args = ['runuser', '-u', 'postgres', '--', 'psql', '-X', '-v', 'ON_ERROR_STOP=1', '-At']
|
||||
if database:
|
||||
assert database == self.settings['name']
|
||||
args += ['--dbname=' + database]
|
||||
return command(args, sql)
|
||||
|
||||
def create(self):
|
||||
name, user, password = (self.settings[key] for key in ('name', 'user', 'password'))
|
||||
if self.kind == 'mysql':
|
||||
self.admin("CREATE USER '" + user + "'@'localhost' IDENTIFIED BY '" + password +
|
||||
"' WITH MAX_USER_CONNECTIONS 24;")
|
||||
self.account_created = True
|
||||
self.admin('CREATE DATABASE `' + name + '` CHARACTER SET utf8mb4;')
|
||||
# 转义授权模式中的下划线,只授权本轮唯一数据库。
|
||||
self.admin('GRANT ALL ON `' + name.replace('_', r'\_') + "`.* TO '" + user + "'@'localhost';")
|
||||
count = self.admin("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='" + name + "';")
|
||||
else:
|
||||
self.admin('CREATE ROLE ' + user + " LOGIN CONNECTION LIMIT 24 PASSWORD '" + password + "';")
|
||||
self.account_created = True
|
||||
self.admin('CREATE DATABASE ' + name + ' OWNER ' + user + ';')
|
||||
self.admin('GRANT ALL ON SCHEMA public TO ' + user + ';', name)
|
||||
count = self.admin("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public';", name)
|
||||
assert count == '0', '首次安装必须使用空数据库'
|
||||
|
||||
def lock(self):
|
||||
if not self.account_created:
|
||||
return
|
||||
user = self.settings['user']
|
||||
if self.kind == 'mysql':
|
||||
self.admin("ALTER USER '" + user + "'@'localhost' ACCOUNT LOCK;")
|
||||
else:
|
||||
self.admin('ALTER ROLE ' + user + ' NOLOGIN;')
|
||||
64
test/tools/runtime_sql_bridge.py
Normal file
64
test/tools/runtime_sql_bridge.py
Normal file
@ -0,0 +1,64 @@
|
||||
"""只向隔离网络提供单个本机数据库端口,不向宿主网络新增监听端口。"""
|
||||
|
||||
from contextlib import ExitStack
|
||||
import os
|
||||
from pathlib import Path
|
||||
import select
|
||||
import socket
|
||||
import socketserver
|
||||
import threading
|
||||
|
||||
|
||||
class DatabaseBridge:
|
||||
def __init__(self, pid, port):
|
||||
assert isinstance(pid, int) and pid > 1
|
||||
assert port in (3306, 5432)
|
||||
namespace = Path('/proc') / str(pid) / 'ns/net'
|
||||
assert namespace.readlink() != Path('/proc/self/ns/net').readlink()
|
||||
limit = threading.BoundedSemaphore(24)
|
||||
|
||||
class Handler(socketserver.BaseRequestHandler):
|
||||
def handle(self):
|
||||
if not limit.acquire(blocking=False):
|
||||
return
|
||||
try:
|
||||
with socket.create_connection(('127.0.0.1', port), timeout=15) as upstream:
|
||||
streams = (self.request, upstream)
|
||||
for stream in streams:
|
||||
stream.settimeout(30)
|
||||
while True:
|
||||
ready, _, _ = select.select(streams, [], [], 30)
|
||||
if not ready:
|
||||
return
|
||||
for source in ready:
|
||||
data = source.recv(65536)
|
||||
if not data:
|
||||
return
|
||||
target = upstream if source is self.request else self.request
|
||||
target.sendall(data)
|
||||
except (OSError, TimeoutError):
|
||||
return
|
||||
finally:
|
||||
limit.release()
|
||||
|
||||
class Server(socketserver.ThreadingTCPServer):
|
||||
daemon_threads = True
|
||||
allow_reuse_address = False
|
||||
|
||||
with ExitStack() as stack:
|
||||
host = stack.enter_context(open('/proc/self/ns/net', 'rb'))
|
||||
isolated = stack.enter_context(open(namespace, 'rb'))
|
||||
try:
|
||||
os.setns(isolated.fileno(), 0)
|
||||
self.server = Server(('127.0.0.1', port), Handler)
|
||||
finally:
|
||||
os.setns(host.fileno(), 0)
|
||||
# 监听套接字属于隔离网络,后续工作线程在恢复后的宿主网络连接数据库。
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
def close(self):
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(timeout=5)
|
||||
assert not self.thread.is_alive(), '数据库验收桥接未停止'
|
||||
Loading…
Reference in New Issue
Block a user