fix: 收口融合 QA 构建与验收安全基线

This commit is contained in:
wangzhibiao committed 2026-10-14 11:32:31 +08:00
1 parent 47bc6604e5
commit 5560a3ac04
28 files changed
+22947 -352

No files matched your search

+5 -5
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
readonly EXPECTED_SHA256="346d5602477ed472b19c5e25729fff5a58b1d83cec56162a56ac4b86d9546c32"
readonly INSTALLED_JAR="${HOME}/.m2/repository/com/kingbase/kingbase8/8.6.0/kingbase8-8.6.0.jar"
readonly EXPECTED_SHA256="e439bd1c48ce654748c4f333b874a61465702ceb18f29ee017c3f9bc47738c23"
readonly INSTALLED_JAR="${HOME}/.m2/repository/cn/com/kingbase/kingbase8/9.0.0/kingbase8-9.0.0.jar"
sha256_file() {
if command -v sha256sum >/dev/null 2>&1; then
@@ -16,7 +16,7 @@ sha256_file() {
}
if [[ ! -f "${INSTALLED_JAR}" ]]; then
echo "Kingbase JDBC 8.6.0 is not provisioned." >&2
echo "Kingbase JDBC 9.0.0 is not provisioned." >&2
echo "Run scripts/bootstrap/install-kingbase-jdbc.sh" >&2
echo "with KINGBASE_JDBC_JAR pointing to the authorized vendor driver." >&2
exit 1
@@ -24,8 +24,8 @@ fi
installed_sha256="$(sha256_file "${INSTALLED_JAR}")"
if [[ "${installed_sha256}" != "${EXPECTED_SHA256}" ]]; then
echo "Kingbase JDBC 8.6.0 checksum mismatch." >&2
echo "Kingbase JDBC 9.0.0 checksum mismatch." >&2
exit 1
fi
echo "Kingbase JDBC 8.6.0 preflight PASS."
echo "Kingbase JDBC 9.0.0 preflight PASS."
+10 -10
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
readonly EXPECTED_SHA256="346d5602477ed472b19c5e25729fff5a58b1d83cec56162a56ac4b86d9546c32"
readonly EXPECTED_SHA256="e439bd1c48ce654748c4f333b874a61465702ceb18f29ee017c3f9bc47738c23"
readonly MAVEN_REPOSITORY="${HOME}/.m2/repository"
readonly INSTALLED_JAR="${MAVEN_REPOSITORY}/com/kingbase/kingbase8/8.6.0/kingbase8-8.6.0.jar"
readonly INSTALLED_JAR="${MAVEN_REPOSITORY}/cn/com/kingbase/kingbase8/9.0.0/kingbase8-9.0.0.jar"
sha256_file() {
if command -v sha256sum >/dev/null 2>&1; then
@@ -18,7 +18,7 @@ sha256_file() {
if [[ -z "${KINGBASE_JDBC_JAR:-}" ]]; then
echo "KINGBASE_JDBC_JAR is not set." >&2
echo "Set it to the absolute path of the authorized Kingbase JDBC 8.6.0 vendor driver." >&2
echo "Set it to the absolute path of the authorized Kingbase JDBC 9.0.0 vendor driver." >&2
exit 1
fi
@@ -29,28 +29,28 @@ fi
source_sha256="$(sha256_file "${KINGBASE_JDBC_JAR}")"
if [[ "${source_sha256}" != "${EXPECTED_SHA256}" ]]; then
echo "Kingbase JDBC 8.6.0 source checksum mismatch." >&2
echo "Kingbase JDBC 9.0.0 source checksum mismatch." >&2
exit 1
fi
mvn install:install-file \
mvn org.apache.maven.plugins:maven-install-plugin:3.1.2:install-file \
-Dfile="${KINGBASE_JDBC_JAR}" \
-DgroupId=com.kingbase \
-DgroupId=cn.com.kingbase \
-DartifactId=kingbase8 \
-Dversion=8.6.0 \
-Dversion=9.0.0 \
-Dpackaging=jar \
-DgeneratePom=true \
-Dmaven.repo.local="${MAVEN_REPOSITORY}"
if [[ ! -f "${INSTALLED_JAR}" ]]; then
echo "Kingbase JDBC 8.6.0 installation failed: local Maven artifact is missing." >&2
echo "Kingbase JDBC 9.0.0 installation failed: local Maven artifact is missing." >&2
exit 1
fi
installed_sha256="$(sha256_file "${INSTALLED_JAR}")"
if [[ "${installed_sha256}" != "${EXPECTED_SHA256}" ]]; then
echo "Kingbase JDBC 8.6.0 installed artifact checksum mismatch." >&2
echo "Kingbase JDBC 9.0.0 installed artifact checksum mismatch." >&2
exit 1
fi
echo "Kingbase JDBC 8.6.0 provisioning PASS."
echo "Kingbase JDBC 9.0.0 provisioning PASS."
+87
View File
@@ -0,0 +1,87 @@
import java.sql.*;
import java.util.*;
import java.nio.file.*;
import java.nio.file.attribute.PosixFilePermissions;
import javax.sql.rowset.*;
/** Read-only identity/schema probe. Credentials only from process environment. */
public class QaDatabaseProbe {
static final String DATABASE = "railwaysystem_fusion_qa_20261013";
static void query(Connection c, String label, String sql) throws SQLException {
System.out.println("[" + label + "]");
try (Statement s = c.createStatement()) {
s.setQueryTimeout(15);
try (ResultSet r = s.executeQuery(sql)) {
int n = r.getMetaData().getColumnCount();
while (r.next()) {
List<String> row = new ArrayList<>();
for (int i = 1; i <= n; i++) row.add(String.valueOf(r.getObject(i)));
System.out.println(String.join(" | ", row));
}
}
}
}
public static void main(String[] args) throws Exception {
String url = System.getenv("QA_DB_URL");
// Validate target before even opening a connection; no URL credentials permitted.
if (url == null || !url.matches("jdbc:kingbase8://[A-Za-z0-9.:-]+/" + DATABASE + "(?:\\?.*)?")
|| url.matches("(?i).*[?&](user|password)=.*"))
throw new SecurityException("Refusing non-target URL or URL credentials");
if (!"fusion_qa_app".equals(System.getenv("QA_DB_USER")))
throw new SecurityException("Read-only probe requires fusion_qa_app");
Class.forName("com.kingbase8.Driver");
Properties p = new Properties(); p.setProperty("user", System.getenv("QA_DB_USER"));
p.setProperty("password", Objects.requireNonNull(System.getenv("QA_DB_PASSWORD")));
try (Connection c = DriverManager.getConnection(url, p)) {
c.setAutoCommit(false);
c.setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ);
try (Statement s = c.createStatement()) { s.execute("SET TRANSACTION READ ONLY"); }
try (Statement s = c.createStatement(); ResultSet r = s.executeQuery("SELECT current_database()")) {
r.next(); if (!DATABASE.equals(r.getString(1))) throw new SecurityException("Database mismatch");
}
query(c,"identity", "SELECT current_database(), current_user, current_schema(), version()");
query(c,"search_path", "SHOW search_path");
query(c,"schemas", "SELECT schema_name FROM information_schema.schemata ORDER BY schema_name");
query(c,"columns", "SELECT table_schema,table_name,column_name,data_type,is_nullable,column_default "
+ "FROM information_schema.columns WHERE (table_schema='iotlocks' AND table_name IN ('lock_auth','lock_command')) ORDER BY table_name,ordinal_position");
query(c,"constraints", "SELECT table_schema,table_name,constraint_name,constraint_type FROM information_schema.table_constraints "
+ "WHERE table_schema IN ('iotlocks','public') AND table_name IN ('lock_auth','lock_command') ORDER BY table_name,constraint_name");
query(c,"counts", "SELECT 'lock_auth',count(*) FROM iotlocks.lock_auth UNION ALL SELECT 'lock_command',count(*) FROM iotlocks.lock_command");
query(c,"indexes", "SELECT schemaname,tablename,indexname,indexdef FROM pg_indexes WHERE schemaname='iotlocks' AND tablename IN ('lock_auth','lock_command') ORDER BY tablename,indexname");
query(c,"runtime_account", "SELECT rolname,rolsuper,rolcreatedb,rolcreaterole FROM pg_roles WHERE rolname=current_user");
query(c,"schema_privileges", "SELECT nspname,has_schema_privilege(current_user,oid,'USAGE'),has_schema_privilege(current_user,oid,'CREATE') FROM pg_namespace WHERE nspname IN ('public','iotlocks') ORDER BY nspname");
query(c,"table_owners", "SELECT schemaname,tablename,tableowner FROM pg_tables WHERE schemaname IN ('public','iotlocks') AND tablename IN ('sys_user','lock_auth','lock_command') ORDER BY schemaname,tablename");
query(c,"table_grants", "SELECT table_schema,privilege_type,count(*) FROM information_schema.role_table_grants WHERE grantee=current_user AND table_schema IN ('public','iotlocks') GROUP BY table_schema,privilege_type ORDER BY table_schema,privilege_type");
query(c,"extra_grant_review", "SELECT table_schema,table_name,privilege_type FROM information_schema.role_table_grants WHERE grantee=current_user AND table_schema IN ('public','iotlocks') AND privilege_type IN ('TRUNCATE','TRIGGER','REFERENCES') ORDER BY table_schema,table_name,privilege_type");
query(c,"target_sessions", "SELECT usename,application_name,state,count(*) FROM pg_stat_activity WHERE datname=current_database() GROUP BY usename,application_name,state ORDER BY usename,application_name,state");
if (args.length == 2 && "--backup".equals(args[0])) {
Path directory = Paths.get(args[1]);
Files.createDirectory(directory, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------")));
for (String table : List.of("lock_auth", "lock_command")) {
Path destination = directory.resolve(table + ".xml");
Files.createFile(destination, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")));
try (Statement s = c.createStatement(); ResultSet rows = s.executeQuery("SELECT * FROM iotlocks." + table + " ORDER BY 1");
WebRowSet backup = RowSetProvider.newFactory().createWebRowSet();
java.io.Writer writer = Files.newBufferedWriter(destination)) {
backup.populate(rows); backup.writeXml(writer);
System.out.println("PRIVATE_TABLE_SNAPSHOT " + table + " rows=" + backup.size());
}
}
System.out.println("TABLE_SNAPSHOT_ONLY: no native full-database dump or restore drill claimed");
}
if (Arrays.asList(args).contains("--require-migrated")) {
String common = "'execution_mode','environment_id','intent_key','intent_version','execution_state','intent_action','intent_at'";
try (Statement s = c.createStatement(); ResultSet r = s.executeQuery("SELECT count(*) FROM information_schema.columns WHERE table_schema='iotlocks' AND ((table_name='lock_auth' AND column_name IN (" + common + ")) OR (table_name='lock_command' AND column_name IN (" + common + ",'device_sn','attempt_started_at')))")) {
r.next(); if (r.getInt(1) != 16) throw new SecurityException("MIGRATION_REQUIRED: runtime refused");
}
try (Statement s = c.createStatement(); ResultSet r = s.executeQuery("SELECT count(*) FROM pg_indexes WHERE schemaname='iotlocks' AND indexname IN ('uq_round5s_command_intent','uq_round5s_auth_intent','ix_round5s_command_recovery','ix_round5s_auth_recovery')")) {
r.next(); if (r.getInt(1) != 4) throw new SecurityException("MIGRATION_INDEXES_REQUIRED: runtime refused");
}
}
c.rollback(); System.out.println("READ_ONLY_ROLLBACK_COMPLETE");
} catch (SQLException e) {
// Server exceptions can contain values; expose SQLSTATE only.
System.err.println("QA_DATABASE_PROBE_FAILED SQLSTATE=" + e.getSQLState()); System.exit(2);
}
}
}
+226
View File
@@ -0,0 +1,226 @@
#!/usr/bin/env python3
"""Local QA only. No DDL, hardware, account provisioning or shared-service management."""
import argparse
import base64
import hashlib
import json
import os
from pathlib import Path
import secrets
import shlex
import signal
import socket
import stat
import subprocess
import sys
import urllib.parse
import xml.etree.ElementTree as ET
ROOT = Path(__file__).resolve().parents[2]
DATABASE = 'railwaysystem_fusion_qa_20261013'
RUNTIME = ROOT / '.qa-runtime'
JAR = Path.home() / '.m2/repository/cn/com/kingbase/kingbase8/9.0.0/kingbase8-9.0.0.jar'
def load_config(path):
path = path.expanduser()
info = path.stat()
if info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) & 0o077 or not stat.S_ISREG(info.st_mode):
raise ValueError('Config must be an owner-only regular file (0600)')
values = {}
for line in path.read_text().splitlines():
line = line.strip()
if not line or line.startswith('#'):
continue
key, value = line.removeprefix('export ').split('=', 1)
tokens = shlex.split(value, comments=True)
if len(tokens) != 1:
raise ValueError('Config requires literal KEY=value; no shell expansion')
values[key] = tokens[0]
return values
def environment(config):
# Legacy QA_DB_URL deliberately NOT reused: it may point to a different QA database.
url = config.get('FUSION_QA_DB_URL', 'jdbc:kingbase8://192.168.139.218:4321/' + DATABASE + '?connectTimeout=5&socketTimeout=15')
parsed = urllib.parse.urlsplit(url.removeprefix('jdbc:'))
if parsed.scheme != 'kingbase8' or parsed.path != '/' + DATABASE or parsed.username or parsed.password or parsed.fragment:
raise ValueError('Refusing non-target database URL')
if any(k.lower() in ('user', 'password') for k in urllib.parse.parse_qs(parsed.query)):
raise ValueError('URL credentials forbidden')
password = config.get('FUSION_QA_APP_PASSWORD', '')
if not password:
raise ValueError('Missing FUSION_QA_APP_PASSWORD')
inherited = {key: value for key, value in os.environ.items()
if not key.startswith(('SPRING_', 'IOT_', 'DMS_', 'QA_', 'TOKEN_', 'TRAIN_'))
and key not in ['JAVA_TOOL_OPTIONS', 'JDK_JAVA_OPTIONS', '_JAVA_OPTIONS']}
return dict(inherited, QA_DB_URL=url, QA_DB_USER='fusion_qa_app', QA_DB_PASSWORD=password)
def verify_snapshot(directory):
directory = directory.resolve()
if not directory.is_relative_to(RUNTIME.resolve()):
raise ValueError('Snapshot must stay inside this task private runtime directory')
expected = json.loads((directory / 'sha256.json').read_text())
for name in ['lock_auth.xml', 'lock_command.xml']:
raw = (directory / name).read_bytes()
if hashlib.sha256(raw).hexdigest() != expected[name]:
raise ValueError('Snapshot checksum mismatch')
tree = ET.fromstring(raw)
rows = sum(1 for node in tree.iter() if node.tag.rsplit('}', 1)[-1] in ['currentRow', 'insertRow'])
print('SNAPSHOT_PARSE_SHA256_VERIFIED ' + name + ' rows=' + str(rows))
print('NATIVE_DATABASE_RESTORE_NOT_VERIFIED')
def private_directory():
RUNTIME.mkdir(mode=0o700, exist_ok=True)
if RUNTIME.is_symlink() or RUNTIME.stat().st_uid != os.getuid() or stat.S_IMODE(RUNTIME.stat().st_mode) & 0o077:
raise ValueError('Unsafe runtime directory')
def inspect(env, extra):
command = ['java', '-cp', str(JAR), str(ROOT / 'scripts/qa/QaDatabaseProbe.java'), *extra]
result = subprocess.run(command, env=env, text=True, capture_output=True, timeout=60)
private_directory()
path = RUNTIME / 'database-inspection.log'
with open(path, 'w', opener=lambda p, f: os.open(p, f, 0o600)) as stream:
stream.write(result.stdout + result.stderr)
# Probe reports only metadata/counts; SQL errors are reduced to SQLSTATE.
print(result.stdout + result.stderr, end='')
return result.returncode
def available(port):
with socket.socket() as probe:
probe.bind(('127.0.0.1', port))
def launch(name, command, env):
private_directory()
ledger = RUNTIME / (name + '.json')
if ledger.exists():
raise ValueError('Runtime ledger exists; inspect/stop this task-owned process first')
log = open(RUNTIME / (name + '.log'), 'w', opener=lambda p, f: os.open(p, f, 0o600))
child = subprocess.Popen(command, cwd=ROOT, env=env, stdout=log, stderr=subprocess.STDOUT, start_new_session=True)
log.close()
started = subprocess.run(['ps', '-p', str(child.pid), '-o', 'lstart='], text=True, capture_output=True).stdout.strip()
ledger.write_text(json.dumps({'pid': child.pid, 'command': command, 'cwd': str(ROOT), 'started': started}))
print(name + ' PID=' + str(child.pid) + ' LOG=' + str(RUNTIME / (name + '.log')))
def start(config, env, backend_port, redis_port):
if inspect(env, ['--require-migrated']):
raise ValueError('Backend not started: QA migration preflight failed')
available(backend_port)
available(redis_port)
private_directory()
secret_path = RUNTIME / 'runtime-secrets.env'
if not secret_path.exists():
with open(secret_path, 'x', opener=lambda p, f: os.open(p, f, 0o600)) as f:
f.write('TOKEN_SECRET=' + base64.b64encode(secrets.token_bytes(64)).decode() + '\n')
f.write('QA_REDIS_PASSWORD=' + secrets.token_urlsafe(40) + '\n')
local_secrets = load_config(secret_path)
env.update(local_secrets)
for key in ['TRAIN_STUDENT_IDENTITY_ENCRYPTION_KEY', 'TRAIN_STUDENT_IDENTITY_HASH_KEY']:
if not config.get(key):
raise ValueError('Missing required protected identity configuration')
env[key] = config[key]
env.update(SPRING_DATASOURCE_DRUID_MASTER_URL=env['QA_DB_URL'],
SPRING_DATASOURCE_DRUID_MASTER_USERNAME='fusion_qa_app',
SPRING_DATASOURCE_DRUID_MASTER_PASSWORD=env['QA_DB_PASSWORD'],
SPRING_DATA_REDIS_PASSWORD=env['QA_REDIS_PASSWORD'])
redis_config = RUNTIME / 'redis.conf'
with open(redis_config, 'w', opener=lambda p, f: os.open(p, f, 0o600)) as f:
f.write('bind 127.0.0.1\nprotected-mode yes\nport ' + str(redis_port) + '\nsave ""\nappendonly no\nrequirepass ' + env['QA_REDIS_PASSWORD'] + '\n')
command = ['java', '-jar', str(ROOT / 'RuoYi-Vue/ruoyi-admin/target/ruoyi-admin.jar'),
'--spring.profiles.active=druid',
'--spring.datasource.druid.master.url=' + env['QA_DB_URL'],
'--spring.datasource.druid.master.username=fusion_qa_app',
'--server.address=127.0.0.1', '--server.port=' + str(backend_port),
'--spring.data.redis.host=127.0.0.1', '--spring.data.redis.port=' + str(redis_port),
'--spring.data.redis.database=0', '--spring.quartz.auto-startup=false',
'--ruoyi.profile=' + str(RUNTIME / 'upload'), '--logging.file.path=' + str(RUNTIME / 'logs'),
'--logging.level.com.ruoyi=INFO', '--iot.mqtt.mock=true', '--iot.mqtt.enabled=false',
'--iot.mqtt.real-device-write-enabled=false', '--iot.mqtt.device-whitelist=',
'--iot.mqtt.environment-id=round5v-local-mock', '--iot.mqtt.retry-task-enabled=false',
'--iot.mqtt.protocol-verified=false', '--iot.qa-api-enabled=false', '--iot.openapi.enabled=false',
'--iot.face.mock=true', '--iot.face.enabled=false', '--iot.face.real-http-enabled=false',
'--iot.face.allowed-endpoints=', '--dms.enabled=false', '--dms.mock=true',
'--spring.datasource.druid.stat-view-servlet.enabled=false']
if not Path(command[2]).is_file():
raise ValueError('Build backend jar first')
launch('redis', ['redis-server', str(redis_config)], env)
launch('backend', command, env)
def stop():
private_directory()
for name in ['frontend', 'backend', 'redis']:
path = RUNTIME / (name + '.json')
if not path.exists():
continue
item = json.loads(path.read_text())
actual = subprocess.run(['ps', '-p', str(item['pid']), '-o', 'command='], text=True, capture_output=True).stdout.strip()
if actual:
# macOS Python wrappers exec Python.app; Redis rewrites its process title.
# Check task arguments + cwd + birth time, never select by port/name alone.
suffix = ' '.join(item['command'][1:])
cwd = subprocess.run(['lsof', '-a', '-p', str(item['pid']), '-d', 'cwd', '-Fn'], text=True, capture_output=True).stdout
started = subprocess.run(['ps', '-p', str(item['pid']), '-o', 'lstart='], text=True, capture_output=True).stdout.strip()
comm = subprocess.run(['ps', '-p', str(item['pid']), '-o', 'comm='], text=True, capture_output=True).stdout.strip()
identity = actual.endswith(suffix) and suffix != ''
if name == 'redis':
identity = bool(item.get('started')) and Path(comm).name.startswith('redis-server')
if not identity or ('n' + str(ROOT)) not in cwd.splitlines() or (item.get('started') and item['started'] != started):
raise ValueError('PID identity mismatch; refusing to signal ' + name)
os.kill(item['pid'], signal.SIGTERM)
path.unlink()
print('Stopped task-owned ' + name)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('action', choices=['inspect', 'snapshot', 'verify-snapshot', 'start', 'frontend', 'stop'])
parser.add_argument('--config', type=Path, default=Path.home() / '.railwaysystem-qa.env')
parser.add_argument('--backend-port', type=int, default=18096)
parser.add_argument('--frontend-port', type=int, default=18088)
parser.add_argument('--redis-port', type=int, default=16389)
parser.add_argument('--snapshot-dir', type=Path)
args = parser.parse_args()
if args.action == 'verify-snapshot':
if not args.snapshot_dir:
raise ValueError('Specify --snapshot-dir')
verify_snapshot(args.snapshot_dir)
return 0
if args.action == 'stop':
stop()
return 0
if args.action == 'frontend':
available(args.frontend_port)
launch('frontend', [sys.executable, str(ROOT / 'scripts/qa/serve_frontend.py'),
'--port', str(args.frontend_port), '--backend-port', str(args.backend_port)], os.environ.copy())
return 0
config = load_config(args.config)
env = environment(config)
if args.action == 'inspect':
return inspect(env, [])
if args.action == 'snapshot':
private_directory()
path = RUNTIME / ('snapshot-' + secrets.token_hex(6))
rc = inspect(env, ['--backup', str(path)])
if rc == 0:
hashes = {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in path.glob('*.xml')}
(path / 'sha256.json').write_text(json.dumps(hashes, indent=2))
print('SNAPSHOT_DIRECTORY=' + str(path))
return rc
start(config, env, args.backend_port, args.redis_port)
return 0
if __name__ == '__main__':
try:
sys.exit(main())
except (ValueError, OSError, subprocess.TimeoutExpired) as error:
# Do not echo environment or configuration values.
print('QA_OPERATION_REFUSED: ' + (str(error) if isinstance(error, ValueError) else type(error).__name__), file=sys.stderr)
sys.exit(2)
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Serve production SPA and proxy only /prod-api to the task-owned loopback backend."""
import argparse
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import urllib.error
import urllib.request
DIST = Path(__file__).resolve().parents[2] / 'build_ui/dist'
class Handler(SimpleHTTPRequestHandler):
backend_port = 18096
def proxy(self):
path = self.path[len('/prod-api'):]
if not path.startswith('/') or path.startswith('//'):
self.send_error(400)
return
headers = {k: v for k, v in self.headers.items()
if k.lower() not in ['host', 'connection', 'content-length', 'transfer-encoding']}
length = int(self.headers.get('Content-Length', '0'))
if length < 0 or length > 10 * 1024 * 1024:
self.send_error(413)
return
request = urllib.request.Request('http://127.0.0.1:' + str(self.backend_port) + path,
data=self.rfile.read(length) if length else None, headers=headers, method=self.command)
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
return None
# Ignore all system/environment HTTP proxies, never follow a backend redirect.
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect())
try:
response = opener.open(request, timeout=30)
except urllib.error.HTTPError as error:
response = error
except (urllib.error.URLError, TimeoutError):
self.send_error(502, 'QA backend unavailable; no substituted API response')
return
with response:
data = response.read()
self.send_response(response.code)
for key, value in response.headers.items():
if key.lower() not in ['connection', 'transfer-encoding', 'content-length']:
self.send_header(key, value)
self.send_header('Content-Length', str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_GET(self):
if self.path.startswith('/prod-api/'):
return self.proxy()
requested = self.path.split('?', 1)[0]
if requested != '/' and not (DIST / requested.lstrip('/')).is_file() and '.' not in requested.rsplit('/', 1)[-1]:
self.path = '/index.html'
return super().do_GET()
def do_POST(self):
return self.proxy() if self.path.startswith('/prod-api/') else self.send_error(405)
do_PUT = do_POST
do_DELETE = do_POST
def log_message(self, *args):
# No query strings, headers, payloads, tokens or personal identifiers in access logs.
pass
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--port', type=int, default=18088)
parser.add_argument('--backend-port', type=int, default=18096)
args = parser.parse_args()
if not (DIST / 'index.html').is_file():
raise SystemExit('Build frontend before serving')
Handler.backend_port = args.backend_port
ThreadingHTTPServer(('127.0.0.1', args.port), partial(Handler, directory=str(DIST))).serve_forever()
+106
View File
@@ -0,0 +1,106 @@
import importlib.util
import json
import os
from pathlib import Path
import stat
import tempfile
import unittest
from types import SimpleNamespace
from unittest.mock import patch
spec = importlib.util.spec_from_file_location('qa', Path(__file__).with_name('qa.py'))
qa = importlib.util.module_from_spec(spec)
spec.loader.exec_module(qa)
class QaToolsTest(unittest.TestCase):
def test_defaults_never_use_old_database_url(self):
env = qa.environment({'QA_DB_URL': 'jdbc:kingbase8://localhost/training', 'FUSION_QA_APP_PASSWORD': 'synthetic'})
self.assertIn('/' + qa.DATABASE, env['QA_DB_URL'])
self.assertEqual('fusion_qa_app', env['QA_DB_USER'])
def test_wrong_database_rejected(self):
for database in ['training', 'iotlocks', 'railwaysystem_integrated_qa_20260916']:
with self.assertRaises(ValueError):
qa.environment({'FUSION_QA_DB_URL': 'jdbc:kingbase8://localhost/' + database, 'FUSION_QA_APP_PASSWORD': 'synthetic'})
def test_url_credentials_rejected(self):
for url in ['jdbc:kingbase8://user:secret@localhost/' + qa.DATABASE,
'jdbc:kingbase8://localhost/' + qa.DATABASE + '?password=synthetic']:
with self.assertRaises(ValueError):
qa.environment({'FUSION_QA_DB_URL': url, 'FUSION_QA_APP_PASSWORD': 'synthetic'})
def test_missing_password_rejected(self):
with self.assertRaises(ValueError):
qa.environment({})
def test_unsafe_inherited_overrides_removed(self):
with patch.dict(os.environ, {'SPRING_APPLICATION_JSON': '{"danger":true}', 'IOT_MQTT_ENABLED': 'true', 'JAVA_TOOL_OPTIONS': '-Dunsafe=true'}):
env = qa.environment({'FUSION_QA_APP_PASSWORD': 'synthetic'})
self.assertNotIn('SPRING_APPLICATION_JSON', env)
self.assertNotIn('IOT_MQTT_ENABLED', env)
self.assertNotIn('JAVA_TOOL_OPTIONS', env)
def test_private_config_required(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'config.env'
path.write_text('FUSION_QA_APP_PASSWORD=synthetic\n')
path.chmod(0o644)
with self.assertRaises(ValueError):
qa.load_config(path)
path.chmod(0o600)
self.assertEqual('synthetic', qa.load_config(path)['FUSION_QA_APP_PASSWORD'])
def test_config_does_not_execute_shell(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'config.env'
path.write_text("KEY='$(touch should-not-exist)'\n")
path.chmod(0o600)
self.assertEqual('$(touch should-not-exist)', qa.load_config(path)['KEY'])
def test_migration_preflight_prevents_all_processes(self):
with patch.object(qa, 'inspect', return_value=2), patch.object(qa, 'launch') as launch:
with self.assertRaises(ValueError):
qa.start({}, {}, 18096, 16389)
launch.assert_not_called()
def test_start_flags_and_secret_handling(self):
with tempfile.TemporaryDirectory() as directory, patch.object(qa, 'RUNTIME', Path(directory)), \
patch.object(qa, 'inspect', return_value=0), patch.object(qa, 'available'), patch.object(qa, 'launch') as launch:
config = {'FUSION_QA_APP_PASSWORD':'synthetic', 'TRAIN_STUDENT_IDENTITY_ENCRYPTION_KEY':'synthetic', 'TRAIN_STUDENT_IDENTITY_HASH_KEY':'synthetic'}
qa.start(config, qa.environment(config), 18096, 16389)
self.assertEqual(2,launch.call_count)
name, command, env = launch.call_args.args
self.assertEqual('backend',name)
for flag in ['--iot.mqtt.enabled=false','--iot.mqtt.mock=true', '--iot.mqtt.real-device-write-enabled=false',
'--iot.mqtt.device-whitelist=', '--iot.mqtt.retry-task-enabled=false', '--iot.qa-api-enabled=false',
'--iot.openapi.enabled=false','--iot.face.real-http-enabled=false','--dms.enabled=false',
'--spring.quartz.auto-startup=false','--spring.datasource.druid.master.username=fusion_qa_app']:
self.assertIn(flag,command)
self.assertNotIn(env['TOKEN_SECRET'],' '.join(command))
self.assertEqual(0o600,stat.S_IMODE((Path(directory)/'runtime-secrets.env').stat().st_mode))
def test_stop_rejects_reused_pid(self):
with tempfile.TemporaryDirectory() as directory, patch.object(qa, 'RUNTIME', Path(directory)), \
patch.object(qa.subprocess, 'run') as run, patch.object(qa.os, 'kill') as kill:
(Path(directory)/'backend.json').write_text(json.dumps({'pid':123,'command':['task-owned-java']}))
run.return_value.stdout='unrelated-user-process'
with self.assertRaises(ValueError):
qa.stop()
kill.assert_not_called()
def test_stop_accepts_python_wrapper_only_with_task_identity(self):
with tempfile.TemporaryDirectory() as directory, patch.object(qa, 'RUNTIME', Path(directory)), \
patch.object(qa.subprocess, 'run') as run, patch.object(qa.os, 'kill') as kill:
script = str(qa.ROOT / 'scripts/qa/serve_frontend.py')
command = ['/usr/local/bin/python3', script, '--port', '18088']
(Path(directory)/'frontend.json').write_text(json.dumps({'pid':123,'command':command,'started':'same-birth-time'}))
run.side_effect = [SimpleNamespace(stdout='/Frameworks/Python.app/Python ' + ' '.join(command[1:])),
SimpleNamespace(stdout='p123\nfcwd\nn' + str(qa.ROOT) + '\n'),
SimpleNamespace(stdout='same-birth-time'), SimpleNamespace(stdout='/Frameworks/Python.app/Python')]
qa.stop()
kill.assert_called_once_with(123,qa.signal.SIGTERM)
if __name__ == '__main__':
unittest.main()