feat: 增加操作审计与受控下载

This commit is contained in:
shenlei 2026-07-22 18:12:19 +09:00
parent 624f534b0f
commit 4dccc4d96b
15 changed files with 323 additions and 9 deletions

View File

@ -51,6 +51,7 @@ BUILD_DIR_RETENTION_HOURS=24
# 打包超时时间(小时),超时自动标记失败
BUILD_TIMEOUT_HOURS=1
DOWNLOAD_URL_EXPIRE_SECONDS=600
# ---- Watchdog 健康监测 ----
# 健康检查间隔(秒)

View File

@ -146,6 +146,8 @@ ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin123")
JWT_SECRET = os.getenv("JWT_SECRET", "ios-build-server-secret-key-change-in-production")
JWT_ALGORITHM = "HS256"
JWT_EXPIRE_HOURS = 24
# 受控 IPA 下载链接的 OSS 签名有效期(秒)。
DOWNLOAD_URL_EXPIRE_SECONDS = int(os.getenv("DOWNLOAD_URL_EXPIRE_SECONDS", "600"))
def validate_production_security() -> None:

View File

@ -8,7 +8,7 @@ from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from .database import init_db
from .routers import config, apps, tasks, auth, users
from .routers import config, apps, tasks, auth, users, operations
from .services.log_streamer import log_streamer
from .config import BACKEND_PORT, CORS_ALLOWED_ORIGINS, TRUSTED_HOSTS, validate_production_security
from .deps import decode_current_user
@ -48,6 +48,7 @@ app.include_router(users.router)
app.include_router(config.router)
app.include_router(apps.router)
app.include_router(tasks.router)
app.include_router(operations.router)
@app.websocket("/ws/tasks/{task_id}")

View File

@ -60,3 +60,22 @@ class User(Base):
password_hash = Column(String, nullable=False)
is_admin = Column(Boolean, default=False)
created_at = Column(TIMESTAMP, default=datetime.utcnow)
class OperationLog(Base):
"""用户关键操作的审计与统计记录。"""
__tablename__ = "operation_logs"
id = Column(String, primary_key=True)
created_at = Column(TIMESTAMP, default=datetime.utcnow, nullable=False)
username = Column(String, nullable=False)
is_admin = Column(Boolean, default=False, nullable=False)
action = Column(String, nullable=False)
task_id = Column(String)
app_id = Column(String)
app_name = Column(String)
resource_type = Column(String)
resource_name = Column(String)
detail_json = Column(Text)
client_ip = Column(String)
user_agent = Column(String)

View File

@ -0,0 +1,65 @@
"""操作日志与统计 API仅管理员"""
import csv
import io
import json
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy import func
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user
from ..models import OperationLog
router = APIRouter(prefix="/api/operations", tags=["operations"])
def _require_admin(user: dict):
if not user.get("is_admin"):
raise HTTPException(status_code=403, detail="需要管理员权限")
def _query_logs(db: Session, days: int, username: str = "", action: str = ""):
since = datetime.utcnow() - timedelta(days=max(1, min(days, 365)))
query = db.query(OperationLog).filter(OperationLog.created_at >= since)
if username:
query = query.filter(OperationLog.username == username)
if action:
query = query.filter(OperationLog.action == action)
return query
@router.get("/summary")
async def operation_summary(days: int = Query(30, ge=1, le=365), user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
_require_admin(user)
query = _query_logs(db, days)
total = query.count()
action_counts = dict(query.with_entities(OperationLog.action, func.count()).group_by(OperationLog.action).all())
users = query.with_entities(OperationLog.username, func.count()).group_by(OperationLog.username).order_by(func.count().desc()).limit(10).all()
apps = query.filter(OperationLog.app_name.isnot(None)).with_entities(OperationLog.app_name, func.count()).group_by(OperationLog.app_name).order_by(func.count().desc()).limit(10).all()
return {"days": days, "total": total, "action_counts": action_counts,
"top_users": [{"name": name, "count": count} for name, count in users],
"top_apps": [{"name": name, "count": count} for name, count in apps]}
@router.get("")
async def list_operations(days: int = Query(30, ge=1, le=365), username: str = "", action: str = "", limit: int = Query(100, ge=1, le=500), user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
_require_admin(user)
logs = _query_logs(db, days, username, action).order_by(OperationLog.created_at.desc()).limit(limit).all()
return [{"id": log.id, "created_at": log.created_at, "username": log.username,
"action": log.action, "app_name": log.app_name, "task_id": log.task_id,
"resource_type": log.resource_type, "resource_name": log.resource_name,
"detail": json.loads(log.detail_json or "{}"), "client_ip": log.client_ip} for log in logs]
@router.get("/export")
async def export_operations(days: int = Query(30, ge=1, le=365), user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
_require_admin(user)
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["时间", "用户", "操作", "App", "资源类型", "资源名称", "IP", "详情"])
for log in _query_logs(db, days).order_by(OperationLog.created_at.desc()).all():
writer.writerow([log.created_at, log.username, log.action, log.app_name or "", log.resource_type or "", log.resource_name or "", log.client_ip or "", log.detail_json or ""])
return StreamingResponse(iter(["\ufeff" + output.getvalue()]), media_type="text/csv", headers={"Content-Disposition": "attachment; filename=operation-logs.csv"})

View File

@ -6,7 +6,7 @@ import tempfile
import uuid
from datetime import datetime
from typing import List
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse
from starlette.background import BackgroundTask
from sqlalchemy.orm import Session
@ -20,7 +20,7 @@ router = APIRouter(prefix="/api/tasks", tags=["tasks"], dependencies=[Depends(ge
@router.post("", response_model=TaskResponse)
async def create_task(task: TaskCreate, db: Session = Depends(get_db)):
async def create_task(task: TaskCreate, request: Request, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
"""创建打包任务"""
from .config import load_config
@ -63,6 +63,9 @@ async def create_task(task: TaskCreate, db: Session = Depends(get_db)):
status="pending",
)
db.add(db_task)
from ..services.audit_log import record_operation
record_operation(db, user, "build_created", request=request, task=db_task,
detail={"branch": task.branch, "build_type": task.build_type})
db.commit()
db.refresh(db_task)
@ -122,7 +125,7 @@ async def get_task(task_id: str, db: Session = Depends(get_db)):
@router.delete("/{task_id}")
async def cancel_task(task_id: str, db: Session = Depends(get_db)):
async def cancel_task(task_id: str, request: Request, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
"""取消任务"""
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
@ -132,6 +135,8 @@ async def cancel_task(task_id: str, db: Session = Depends(get_db)):
raise HTTPException(status_code=400, detail="任务已完成或已取消")
task.status = "cancelled"
from ..services.audit_log import record_operation
record_operation(db, user, "build_cancelled", request=request, task=task)
db.commit()
# 尝试取消运行中的任务
@ -153,7 +158,7 @@ async def cancel_task(task_id: str, db: Session = Depends(get_db)):
@router.delete("/{task_id}/delete")
async def delete_task(task_id: str, db: Session = Depends(get_db)):
async def delete_task(task_id: str, request: Request, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
"""删除打包记录"""
import shutil
from pathlib import Path
@ -199,6 +204,8 @@ async def delete_task(task_id: str, db: Session = Depends(get_db)):
if build_path.exists():
shutil.rmtree(build_path, ignore_errors=True)
from ..services.audit_log import record_operation
record_operation(db, user, "task_deleted", request=request, task=task)
db.delete(task)
db.commit()
@ -243,8 +250,35 @@ async def get_build_log(task_id: str, db: Session = Depends(get_db)):
return {"log": ""}
@router.get("/{task_id}/download-link")
async def get_download_link(task_id: str, request: Request, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
"""记录已登录用户发起 IPA 下载,并返回 OSS 公开链接。"""
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
if not task.oss_url:
raise HTTPException(status_code=404, detail="下载文件不存在")
try:
from .config import load_config
from ..config import DOWNLOAD_URL_EXPIRE_SECONDS
from ..services.distribution import DistributionError, get_published_download_url
download_url = get_published_download_url(
json.loads(task.config_json or "{}"),
load_config().get("upload", {}),
task.oss_url,
)
except (json.JSONDecodeError, DistributionError) as exc:
raise HTTPException(status_code=502, detail=f"生成下载链接失败:{exc}") from exc
from ..services.audit_log import record_operation
record_operation(db, user, "ipa_download_requested", request=request, task=task,
resource_type="ipa", resource_name=task.oss_url,
detail={"controlled": True})
db.commit()
return {"url": download_url, "expires_in": DOWNLOAD_URL_EXPIRE_SECONDS}
@router.get("/{task_id}/dsym")
async def download_dsym(task_id: str, db: Session = Depends(get_db)):
async def download_dsym(task_id: str, request: Request, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
"""下载 dSYM 文件"""
import shutil
import zipfile
@ -255,6 +289,10 @@ async def download_dsym(task_id: str, db: Session = Depends(get_db)):
raise HTTPException(status_code=404, detail="任务不存在")
if not task.dsym_path:
raise HTTPException(status_code=404, detail="dSYM 文件不存在")
from ..services.audit_log import record_operation
record_operation(db, user, "dsym_downloaded", request=request, task=task,
resource_type="dsym", resource_name=os.path.basename(task.dsym_path))
db.commit()
dsym_path = task.dsym_path
if not os.path.exists(dsym_path):
@ -275,13 +313,17 @@ async def download_dsym(task_id: str, db: Session = Depends(get_db)):
@router.get("/{task_id}/obfuscation-maps")
async def download_obfuscation_maps(task_id: str, db: Session = Depends(get_db)):
async def download_obfuscation_maps(task_id: str, request: Request, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
"""下载混淆映射表"""
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
if not task.obfuscation_maps_path:
raise HTTPException(status_code=404, detail="混淆映射表不存在")
from ..services.audit_log import record_operation
record_operation(db, user, "obfuscation_maps_downloaded", request=request, task=task,
resource_type="obfuscation_maps", resource_name=os.path.basename(task.obfuscation_maps_path))
db.commit()
maps_path = task.obfuscation_maps_path
if not os.path.exists(maps_path):

View File

@ -0,0 +1,23 @@
"""操作审计日志。"""
import json
import uuid
from datetime import datetime
from ..models import OperationLog
def record_operation(db, user: dict, action: str, *, request=None, task=None,
resource_type: str = "", resource_name: str = "", detail: dict | None = None):
"""将已登录用户的关键操作写入当前数据库事务。"""
client_ip = request.client.host if request and request.client else ""
user_agent = request.headers.get("user-agent", "")[:500] if request else ""
db.add(OperationLog(
id=str(uuid.uuid4()), created_at=datetime.utcnow(), username=user.get("username", ""),
is_admin=bool(user.get("is_admin")), action=action,
task_id=task.id if task else None,
app_id=task.app_id if task else None,
app_name=task.app_name if task else None,
resource_type=resource_type, resource_name=resource_name,
detail_json=json.dumps(detail or {}, ensure_ascii=False),
client_ip=client_ip, user_agent=user_agent,
))

View File

@ -9,6 +9,8 @@ from urllib.parse import quote, unquote, urlparse
import httpx
from ..config import DOWNLOAD_URL_EXPIRE_SECONDS
class DistributionError(Exception):
"""分发配置或上传过程失败。"""
@ -167,6 +169,30 @@ def delete_published_artifacts(
deleter(upload_config, remote_paths)
def get_published_download_url(build_config: dict, upload_config: dict, published_url: str) -> str:
"""返回用于受控下载的短时链接WebDAV 保持原公开链接。"""
if upload_config.get("mode") != "oss":
return published_url
remote_paths = _remote_paths_from_published_url(build_config, upload_config, published_url)
if not remote_paths:
raise DistributionError("无法识别 OSS 文件路径,无法生成受控下载链接")
oss = upload_config.get("oss", {})
required = ["access_key_id", "access_key_secret", "endpoint", "bucket_name"]
if any(not oss.get(key) for key in required):
raise DistributionError("OSS 配置不完整")
try:
import oss2
except ImportError as exc:
raise DistributionError("未安装 oss2请重新执行 ./deploy.sh build") from exc
bucket = oss2.Bucket(
oss2.Auth(oss["access_key_id"], oss["access_key_secret"]),
oss["endpoint"], oss["bucket_name"], connect_timeout=30,
)
return bucket.sign_url("GET", remote_paths[0], DOWNLOAD_URL_EXPIRE_SECONDS, slash_safe=True)
def _ensure_webdav_dirs(client: httpx.Client, server_url: str, remote_path: str):
path = ""
for segment in remote_path.strip("/").split("/")[:-1]:

View File

@ -7,6 +7,7 @@
<router-link to="/build">打包</router-link>
<router-link to="/history">历史</router-link>
<router-link to="/admin">管理</router-link>
<router-link v-if="isAdmin" to="/operations">操作日志</router-link>
</nav>
<div v-if="!isLoggedIn" class="user-info">
<button class="btn-login" @click="showLogin = true">登录</button>

View File

@ -0,0 +1,30 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { ref } from 'vue'
import OperationLogView from '../views/OperationLogView.vue'
global.fetch = vi.fn()
describe('OperationLogView.vue', () => {
beforeEach(() => {
vi.clearAllMocks()
fetch
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ total: 4, action_counts: { build_created: 2, ipa_download_requested: 1 }, top_users: [{ name: 'admin', count: 4 }], top_apps: [{ name: '测试App', count: 3 }] }) })
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([{ id: '1', created_at: '2026-07-22T08:00:00', username: 'admin', action: 'build_created', app_name: '测试App', resource_type: '', client_ip: '127.0.0.1' }]) })
})
it('管理员可查看统计和操作明细', async () => {
const wrapper = mount(OperationLogView, { global: { provide: { isAdmin: ref(true), getToken: () => 'token' } } })
await flushPromises()
expect(wrapper.text()).toContain('操作日志')
expect(wrapper.text()).toContain('操作总数')
expect(wrapper.text()).toContain('发起打包')
expect(wrapper.text()).toContain('测试App')
})
it('普通用户不渲染操作日志页面', async () => {
const wrapper = mount(OperationLogView, { global: { provide: { isAdmin: ref(false) } } })
await flushPromises()
expect(wrapper.find('.panel').exists()).toBe(false)
})
})

View File

@ -2,12 +2,14 @@ import { createRouter, createWebHistory } from 'vue-router'
import BuildView from '../views/BuildView.vue'
import HistoryView from '../views/HistoryView.vue'
import ConfigView from '../views/ConfigView.vue'
import OperationLogView from '../views/OperationLogView.vue'
const routes = [
{ path: '/', redirect: '/build' },
{ path: '/build', name: 'Build', component: BuildView },
{ path: '/history', name: 'History', component: HistoryView },
{ path: '/admin', name: 'Admin', component: ConfigView },
{ path: '/operations', name: 'Operations', component: OperationLogView },
]
const router = createRouter({

View File

@ -55,7 +55,7 @@
<img v-if="task.build_type === 'Ad_Hoc' && task.qr_code_path"
:src="task.qr_code_path" alt="QR" class="qr-thumb"
@click="showQrPreview(task)">
<a v-if="task.build_type === 'App_Store'" :href="task.oss_url" target="_blank" class="download-link">下载 IPA</a>
<button v-if="task.build_type === 'App_Store'" class="download-link" @click="downloadIpa(task.id)">下载 IPA</button>
</template>
<span v-else class="text-muted">-</span>
</td>
@ -308,6 +308,17 @@ const downloadDsym = (taskId) => {
window.open(`/api/tasks/${taskId}/dsym`)
}
const downloadIpa = async (taskId) => {
try {
const res = await authFetch(`/api/tasks/${taskId}/download-link`)
if (!res.ok) throw new Error()
const { url } = await res.json()
window.open(url, '_blank')
} catch {
alert('下载失败,请稍后重试')
}
}
const downloadObfMaps = (taskId) => {
window.open(`/api/tasks/${taskId}/obfuscation-maps`)
}
@ -506,7 +517,7 @@ const errorCategoryLabel = (cat) => {
transition: all 0.2s;
}
.btn-close-log:hover { border-color: #1890ff; color: #1890ff; }
.download-link { color: #1890ff; text-decoration: none; font-size: 13px; }
.download-link { color: #1890ff; text-decoration: none; font-size: 13px; border: none; background: none; cursor: pointer; padding: 0; }
.download-link:hover { text-decoration: underline; }
.text-muted { color: #999; font-size: 13px; }
.qr-thumb { width: 40px; height: 40px; cursor: pointer; border-radius: 4px; border: 1px solid #e8e8e8; }

View File

@ -0,0 +1,24 @@
<template>
<div v-if="isAdmin" class="container">
<div class="panel">
<div class="header-row"><h2>操作日志</h2><div><select v-model.number="days" @change="load"><option :value="7"> 7 </option><option :value="30"> 30 </option><option :value="90"> 90 </option></select><button @click="exportCsv">导出 CSV</button></div></div>
<div class="cards"><div><strong>{{ summary.total || 0 }}</strong><span>操作总数</span></div><div><strong>{{ summary.action_counts?.build_created || 0 }}</strong><span>发起打包</span></div><div><strong>{{ summary.action_counts?.ipa_download_requested || 0 }}</strong><span>发起 IPA 下载</span></div></div>
<div class="columns"><section><h3>活跃用户</h3><p v-for="item in summary.top_users || []" :key="item.name">{{ item.name }} <b>{{ item.count }}</b></p></section><section><h3>App 操作量</h3><p v-for="item in summary.top_apps || []" :key="item.name">{{ item.name }} <b>{{ item.count }}</b></p></section></div>
<table><thead><tr><th>时间</th><th>用户</th><th>操作</th><th>App</th><th>资源</th><th>IP</th></tr></thead><tbody><tr v-for="log in logs" :key="log.id"><td>{{ formatTime(log.created_at) }}</td><td>{{ log.username }}</td><td>{{ actionText(log.action) }}</td><td>{{ log.app_name || '-' }}</td><td>{{ log.resource_type || '-' }}</td><td>{{ log.client_ip || '-' }}</td></tr><tr v-if="!logs.length"><td colspan="6">暂无操作记录</td></tr></tbody></table>
</div>
</div>
</template>
<script setup>
import { inject, ref, onMounted } from 'vue'
const isAdmin = inject('isAdmin', ref(false)); const getToken = inject('getToken', () => '')
const days = ref(30); const summary = ref({}); const logs = ref([])
const authFetch = (url) => fetch(url, { headers: { Authorization: `Bearer ${getToken()}` } })
const load = async () => { const [summaryRes, logsRes] = await Promise.all([authFetch(`/api/operations/summary?days=${days.value}`), authFetch(`/api/operations?days=${days.value}`)]); if (summaryRes.ok) summary.value = await summaryRes.json(); if (logsRes.ok) logs.value = await logsRes.json() }
const exportCsv = async () => { const res = await authFetch(`/api/operations/export?days=${days.value}`); if (!res.ok) return; const blob = await res.blob(); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = 'operation-logs.csv'; link.click(); URL.revokeObjectURL(url) }
const formatTime = value => value ? new Date(value.endsWith('Z') ? value : `${value}Z`).toLocaleString('zh-CN', { hour12: false }) : '-'
const actionText = action => ({ build_created: '发起打包', build_cancelled: '取消打包', task_deleted: '删除记录', ipa_download_requested: '发起 IPA 下载', dsym_downloaded: '下载 dSYM', obfuscation_maps_downloaded: '下载混淆映射' }[action] || action)
onMounted(load)
</script>
<style scoped>
.container{max-width:1200px;margin:0 auto;padding:24px}.panel{background:#fff;border-radius:12px;padding:20px;box-shadow:0 2px 8px #00000014}.header-row{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px}.header-row h2{margin:0}.header-row select,.header-row button{padding:8px 12px;margin-left:8px;border:1px solid #d9d9d9;border-radius:6px;background:#fff}.header-row button{color:#1890ff;cursor:pointer}.cards,.columns{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-bottom:20px}.cards div,.columns section{padding:16px;border-radius:8px;background:#fafafa}.cards strong{display:block;font-size:24px;color:#1890ff}.cards span{font-size:13px;color:#666}.columns{grid-template-columns:1fr 1fr}.columns h3{margin:0 0 10px;font-size:14px}.columns p{display:flex;justify-content:space-between;margin:8px 0;color:#555}table{width:100%;border-collapse:collapse}th,td{padding:11px;border-bottom:1px solid #eee;text-align:left;font-size:13px}th{color:#666;background:#fafafa}@media(max-width:700px){.cards{grid-template-columns:1fr}.columns{grid-template-columns:1fr}.header-row{align-items:flex-start;gap:12px;flex-direction:column}}
</style>

View File

@ -0,0 +1,47 @@
"""操作审计日志接口测试。"""
from unittest.mock import AsyncMock, patch
@patch("backend.services.build_queue.build_queue")
def test_build_creation_is_recorded_and_visible_to_admin(mock_queue, client, tmp_config):
mock_queue.submit = AsyncMock()
response = client.post("/api/tasks", json={"app_id": "1", "scheme_id": "1", "build_type": "Ad_Hoc"})
assert response.status_code == 200
summary = client.get("/api/operations/summary")
assert summary.status_code == 200
assert summary.json()["action_counts"]["build_created"] == 1
logs = client.get("/api/operations").json()
assert logs[0]["username"] == "admin"
assert logs[0]["action"] == "build_created"
def test_download_link_is_recorded(client, tmp_config):
from backend.database import SessionLocal
from backend.models import Task
db = SessionLocal()
db.add(Task(id="download-task", app_id="1", app_name="测试App", build_type="App_Store", scheme_id="1", scheme_name="readoor31", status="completed", oss_url="https://files.example.com/app.ipa"))
db.commit()
db.close()
with patch("backend.services.distribution.get_published_download_url", return_value="https://signed.example.com/app.ipa?signature=1"):
response = client.get("/api/tasks/download-task/download-link")
assert response.status_code == 200
assert "signature=1" in response.json()["url"]
assert client.get("/api/operations").json()[0]["action"] == "ipa_download_requested"
def test_regular_user_cannot_view_operation_logs(client):
from backend.database import SessionLocal
from backend.models import User
from backend.security import hash_password
db = SessionLocal()
db.add(User(id="regular-user", username="reader", password_hash=hash_password("reader-password"), is_admin=False))
db.commit()
db.close()
login = client.post("/api/auth/login", json={"username": "reader", "password": "reader-password"})
client.headers.update({"Authorization": f"Bearer {login.json()['token']}"})
assert client.get("/api/operations").status_code == 403
assert client.get("/api/operations/summary").status_code == 403

View File

@ -9,6 +9,7 @@ from backend.services.distribution import (
_write_download_page,
_write_manifest,
publish_ipa,
get_published_download_url,
)
@ -119,3 +120,22 @@ def test_delete_uses_saved_url_for_legacy_artifact_name():
"readoor/iOS/100_2_0_0_0.html",
"readoor/iOS/100_2_0_0_0.png",
]
def test_oss_download_url_is_short_lived_and_uses_actual_object_key(monkeypatch):
class FakeBucket:
def __init__(self, *_args, **_kwargs): pass
def sign_url(self, method, key, expires, **kwargs):
assert (method, key, expires, kwargs) == ("GET", "readoor/iOS/100_2_0_0_0.ipa", 600, {"slash_safe": True})
return "https://signed.example.com/app.ipa?signature=1"
class FakeOss:
Auth = staticmethod(lambda *_args: object())
Bucket = FakeBucket
monkeypatch.setitem(__import__("sys").modules, "oss2", FakeOss)
url = get_published_download_url(
{"BUILD_TYPE": "App_Store"},
{"mode": "oss", "oss": {"access_key_id": "id", "access_key_secret": "secret", "endpoint": "oss.example.com", "bucket_name": "bucket", "base_url": "https://files.example.com"}},
"https://files.example.com/readoor/iOS/100_2_0_0_0.ipa",
)
assert "signature=1" in url