feat: 强化打包配置与服务安全
This commit is contained in:
+1
-1
@@ -51,7 +51,7 @@ def tmp_config(tmp_path, monkeypatch):
|
||||
}
|
||||
},
|
||||
"schemes": {
|
||||
"1": {"name": "testScheme", "ossFloder": "test"}
|
||||
"1": {"name": "readoor31", "ossFloder": "test"}
|
||||
},
|
||||
"servers": {
|
||||
"测试环境": {"api": "https://test.api.com", "assDom": "applinks:test.com", "universalLink": "https://test.com"}
|
||||
|
||||
+56
-1
@@ -1,4 +1,5 @@
|
||||
"""任务接口测试"""
|
||||
import json
|
||||
from unittest.mock import patch, AsyncMock
|
||||
|
||||
|
||||
@@ -22,7 +23,7 @@ def test_create_task(mock_queue, client, tmp_config):
|
||||
data = resp.json()
|
||||
assert data["status"] == "pending"
|
||||
assert data["app_name"] == "测试App"
|
||||
assert data["scheme_name"] == "testScheme"
|
||||
assert data["scheme_name"] == "readoor31"
|
||||
assert data["branch"] == "main"
|
||||
assert data["build_type"] == "Ad_Hoc"
|
||||
|
||||
@@ -61,6 +62,60 @@ def test_create_task_invalid_scheme(mock_queue, client, tmp_config):
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@patch("backend.services.build_queue.build_queue")
|
||||
def test_create_task_rejects_unconfigured_certificate_type(mock_queue, client, tmp_config):
|
||||
config = json.loads(tmp_config.read_text())
|
||||
config["apps"]["1"]["certificates"] = {
|
||||
"Ad_Hoc": {"name": "com.test.app"},
|
||||
}
|
||||
tmp_config.write_text(json.dumps(config))
|
||||
|
||||
resp = client.post("/api/tasks", json={
|
||||
"app_id": "1", "build_type": "App_Store", "scheme_id": "1", "branch": "main",
|
||||
})
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert "未配置 App_Store 证书" in resp.json()["detail"]
|
||||
|
||||
|
||||
@patch("backend.services.build_queue.build_queue")
|
||||
def test_create_task_rejects_disallowed_special_app_scheme(mock_queue, client, tmp_config):
|
||||
config = json.loads(tmp_config.read_text())
|
||||
config["apps"]["1"].update({
|
||||
"name": "英汉大词典",
|
||||
"certificates": {"Ad_Hoc": {"name": "com.dictionary.app"}},
|
||||
})
|
||||
config["schemes"] = {
|
||||
"1": {"name": "readoor31"},
|
||||
"2": {"name": "readoorDict"},
|
||||
}
|
||||
tmp_config.write_text(json.dumps(config))
|
||||
|
||||
resp = client.post("/api/tasks", json={
|
||||
"app_id": "1", "build_type": "Ad_Hoc", "scheme_id": "1", "branch": "main",
|
||||
})
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert "readoorDict" in resp.json()["detail"]
|
||||
|
||||
|
||||
@patch("backend.services.build_queue.build_queue")
|
||||
def test_create_task_rejects_non_default_scheme_for_regular_app(mock_queue, client, tmp_config):
|
||||
config = json.loads(tmp_config.read_text())
|
||||
config["schemes"] = {
|
||||
"1": {"name": "readoor31"},
|
||||
"2": {"name": "readoorDict"},
|
||||
}
|
||||
tmp_config.write_text(json.dumps(config))
|
||||
|
||||
resp = client.post("/api/tasks", json={
|
||||
"app_id": "1", "build_type": "Ad_Hoc", "scheme_id": "2", "branch": "main",
|
||||
})
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert "readoor31OtherPay" in resp.json()["detail"]
|
||||
|
||||
|
||||
@patch("backend.services.build_queue.build_queue")
|
||||
def test_list_tasks_after_create(mock_queue, client, tmp_config):
|
||||
mock_queue.submit = AsyncMock()
|
||||
|
||||
@@ -13,6 +13,8 @@ import pytest
|
||||
from backend.services.build_service import (
|
||||
update_source,
|
||||
copy_source_code,
|
||||
prepare_source_snapshot,
|
||||
build_project,
|
||||
generate_config,
|
||||
_cleanup_old_builds,
|
||||
)
|
||||
@@ -69,10 +71,8 @@ async def test_update_source_clone(tmp_path, log_streamer):
|
||||
with patch("backend.services.build_service.get_git_remote_url", return_value="git@github.com:test/repo.git"):
|
||||
with patch("asyncio.create_subprocess_exec", return_value=_make_mock_process()) as mock_exec:
|
||||
await update_source("t1", source_dir, "main")
|
||||
mock_exec.assert_called_once()
|
||||
args = mock_exec.call_args[0]
|
||||
assert "git" in args
|
||||
assert "clone" in args
|
||||
assert mock_exec.call_count == 6 # clone, set-url, fetch, checkout, reset, clean
|
||||
assert mock_exec.call_args_list[0].args[:2] == ("git", "clone")
|
||||
|
||||
|
||||
async def test_update_source_clone_no_remote(tmp_path, log_streamer):
|
||||
@@ -84,8 +84,8 @@ async def test_update_source_clone_no_remote(tmp_path, log_streamer):
|
||||
await update_source("t1", source_dir, "main")
|
||||
|
||||
|
||||
async def test_update_source_pull_existing(tmp_path, log_streamer):
|
||||
"""目录已存在时执行 fetch + checkout + pull"""
|
||||
async def test_update_source_existing(tmp_path, log_streamer):
|
||||
"""目录已存在时执行 fetch + checkout + reset + clean"""
|
||||
source_dir = tmp_path / "branches" / "dev"
|
||||
source_dir.parent.mkdir(parents=True)
|
||||
source_dir.mkdir()
|
||||
@@ -102,7 +102,7 @@ async def test_update_source_pull_existing(tmp_path, log_streamer):
|
||||
with patch("asyncio.create_subprocess_exec", side_effect=mock_exec):
|
||||
await update_source("t1", source_dir, "dev")
|
||||
|
||||
assert call_count == 3 # fetch, checkout, pull
|
||||
assert call_count == 4 # fetch, checkout, reset, clean
|
||||
|
||||
|
||||
async def test_update_source_fetch_failure(tmp_path, log_streamer):
|
||||
@@ -110,6 +110,7 @@ async def test_update_source_fetch_failure(tmp_path, log_streamer):
|
||||
source_dir = tmp_path / "branches" / "dev"
|
||||
source_dir.parent.mkdir(parents=True)
|
||||
source_dir.mkdir()
|
||||
(source_dir / ".git").mkdir()
|
||||
|
||||
async def mock_exec(*args, **kwargs):
|
||||
return _make_mock_process(returncode=1, output=b"error\n")
|
||||
@@ -120,6 +121,22 @@ async def test_update_source_fetch_failure(tmp_path, log_streamer):
|
||||
await update_source("t1", source_dir, "dev")
|
||||
|
||||
|
||||
async def test_prepare_source_snapshot_serializes_shared_source(tmp_dirs, log_streamer):
|
||||
"""共享源码模式下,更新和复制必须在同一把锁内完成。"""
|
||||
source_dir, build_dir_parent = tmp_dirs
|
||||
task = MagicMock(branch="main")
|
||||
|
||||
with patch("backend.services.build_service.get_git_remote_url", return_value="git@github.com:test/repo.git"), \
|
||||
patch("backend.services.build_service.get_shared_source_dir", return_value=source_dir), \
|
||||
patch("backend.services.build_service.BUILD_BASE_DIR", build_dir_parent), \
|
||||
patch("backend.services.build_service.update_source", new_callable=AsyncMock), \
|
||||
patch("backend.services.build_service.get_source_commit", new_callable=AsyncMock, return_value="abc123"):
|
||||
build_dir, commit = await prepare_source_snapshot("t1", task)
|
||||
|
||||
assert build_dir.exists()
|
||||
assert commit == "abc123"
|
||||
|
||||
|
||||
# ---- copy_source_code ----
|
||||
|
||||
async def test_copy_source_code(tmp_dirs, log_streamer):
|
||||
@@ -170,6 +187,24 @@ async def test_copy_source_code_overwrites_existing(tmp_dirs, log_streamer):
|
||||
assert (result / "readoor").exists()
|
||||
|
||||
|
||||
async def test_build_project_passes_scheme_as_exec_argument(tmp_path, log_streamer):
|
||||
"""Scheme 中的 shell 特殊字符只能作为 xcodebuild 参数,不能被执行。"""
|
||||
build_dir = tmp_path / "build"
|
||||
build_dir.mkdir()
|
||||
task = MagicMock()
|
||||
config_data = {"SCHEME": "App; touch /tmp/should-not-run"}
|
||||
|
||||
with patch("asyncio.create_subprocess_exec", return_value=_make_mock_process()) as mock_exec, \
|
||||
patch("asyncio.create_subprocess_shell") as mock_shell:
|
||||
with pytest.raises(Exception, match="未找到 IPA 文件"):
|
||||
await build_project("t1", task, config_data, build_dir)
|
||||
|
||||
assert mock_shell.call_count == 0
|
||||
archive_args = mock_exec.call_args_list[1].args
|
||||
assert archive_args[0:2] == ("xcodebuild", "archive")
|
||||
assert "App; touch /tmp/should-not-run" in archive_args
|
||||
|
||||
|
||||
# ---- generate_config ----
|
||||
|
||||
async def test_generate_config(tmp_path, log_streamer):
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""公网部署安全回归测试。"""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from backend import config
|
||||
from backend.main import app
|
||||
from backend.security import hash_password, verify_password
|
||||
|
||||
|
||||
def test_passwords_use_bcrypt_and_verify():
|
||||
password_hash = hash_password("a-strong-password")
|
||||
|
||||
assert password_hash.startswith("$2")
|
||||
assert verify_password("a-strong-password", password_hash) == (True, False)
|
||||
assert verify_password("wrong-password", password_hash) == (False, False)
|
||||
|
||||
|
||||
def test_websocket_rejects_connection_without_jwt():
|
||||
with TestClient(app) as unauthenticated_client:
|
||||
with pytest.raises(WebSocketDisconnect) as exc_info:
|
||||
with unauthenticated_client.websocket_connect("/ws/tasks/task-1"):
|
||||
pass
|
||||
|
||||
assert exc_info.value.code == 1008
|
||||
|
||||
|
||||
def test_production_rejects_default_security_settings(monkeypatch):
|
||||
monkeypatch.setattr(config, "APP_ENV", "production")
|
||||
monkeypatch.setattr(config, "JWT_SECRET", "ios-build-server-secret-key-change-in-production")
|
||||
monkeypatch.setattr(config, "ADMIN_PASSWORD", "admin123")
|
||||
monkeypatch.setattr(config, "CORS_ALLOWED_ORIGINS", ["*"])
|
||||
monkeypatch.setattr(config, "TRUSTED_HOSTS", ["*"])
|
||||
monkeypatch.setenv("TRUSTED_HOSTS", "*")
|
||||
|
||||
with pytest.raises(RuntimeError, match="生产环境安全配置不完整"):
|
||||
config.validate_production_security()
|
||||
|
||||
|
||||
def test_config_routes_limit_regular_users_to_apps(client, tmp_config):
|
||||
"""普通账号只能管理 Apps,不能读取或修改包含密钥的配置。"""
|
||||
response = client.post("/api/users", json={
|
||||
"username": "builder",
|
||||
"password": "builder-password-123",
|
||||
"is_admin": False,
|
||||
})
|
||||
assert response.status_code == 200
|
||||
|
||||
login = client.post("/api/auth/login", json={
|
||||
"username": "builder",
|
||||
"password": "builder-password-123",
|
||||
})
|
||||
token = login.json()["token"]
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
assert client.get("/api/config/apps", headers=headers).status_code == 200
|
||||
assert client.post("/api/config/apps", json={"name": "普通用户 App"}, headers=headers).status_code == 200
|
||||
assert client.get("/api/config", headers=headers).status_code == 403
|
||||
assert client.get("/api/config/servers", headers=headers).status_code == 403
|
||||
assert client.put("/api/config/upload", json={"mode": "oss"}, headers=headers).status_code == 403
|
||||
Reference in New Issue
Block a user