feat(m169): 更新通道端到端验收 —— 本地源改成编译期 feature,补一条可一键跑的 E2E + CI 守门
§6 欠账:更新链路的 GUI 端从来没验过,而 M167 发现它根本没法在本机验——`ULL_UPDATE_DIR` 只在 `debug_assertions` 下生效,发布产物却是 release;`apply` 又会替换本进程 exe,于是没人敢跑。 判据换档(tower 定案 B):`cfg!(debug_assertions)` → 编译期 cargo feature `update-test-source` (core 定义,cli/presenter/app-iced 链式转发,**默认关**)。带 feature 的构建里还要两档环境变量 (`ULL_UPDATE_DIR` + `ULL_UPDATE_TEST_SOURCE=1`),只给路径什么都不会发生。官方发布二进制里那段 分支根本编不进来——README「release builds do not contain that path at all」这句因此继续为真。 - core: `job::default_source` 的本地源分支整段 `#[cfg(feature = "update-test-source")]`;判据抽成 纯函数 `test_source_root_from` 并逐条单测(半开一律关、空路径、承认值只认 `1`); `the_test_channel_is_a_compile_time_opt_in` 在**两种构建下各断各的**(带 feature ⇒ 换源; 不带 ⇒ 环境变量设满也走 GitHub)。 - cli: `update check/apply` 的 `--json` 增加 `test_source` 字段(判据派生自实际用了哪个源,不是回读 环境变量),人读输出多一行点名;`help.rs` 的 `ULL_UPDATE_DIR` 说明按新判据重写。 - 验收:`crates/cli/tests/update_e2e_test.rs` + `update_e2e_tamper_test.rs` + 共用夹具 `tests/support/update_e2e.rs`。临时数据根 + 本地源 + 真形状清单(与 `manifest.golden.json` 同 字段)+ 真 PE 产物(现场构建的 ull.exe 前 1 MiB,理由写在 `PAYLOAD_LIMIT`)。断言覆盖:发现 新版本(source=file:… / test_source=true / size / notes)、下载 → 双检 → 就位(dest 字节、旧字节 进备份、暂存无残留)、重复 apply 的幂等终态、篡改 sha256(清单撒谎 / 包体被换)与截断都必须 拒绝且不动现有安装、修复后能重装。**永远带 `--dest`**:不带会真的改名换掉测试二进制。 - 守门:`packaging/check_test_source_off.py`(stdlib-only)扫 `.github/workflows/**`、 `packaging/*.py|*.sh` 与各 `Cargo.toml` 的 `default`,任何一处打开 feature(或 `--all-features`) 就红;`--self-test` 注入 5 条真实回归证明每条都会红。CI 挂在 release-guard.yml 两步。 验证(本机):core update 单测 80 绿 / 带 feature 6 绿;E2E debug 6 绿; `cargo test --release --features update-test-source` 6 绿(tower 要求 release 档验收); `cargo test -p unlucky-cli -p unlucky-presenter` 全绿;守门 self-test + 正式扫描绿(真注入后红)。 已知没验:真 GitHub Release 下载、GUI 点击与重启替换(人工);跨卷替换(另开 finding)。
This commit is contained in:
1 parent
a14b9e17c1
commit
24826c1a7c
18 files changed
+1133
-44
No files matched your search
@@ -3,6 +3,8 @@
|
||||
//! 每个测试目标都会整份编译本文件但只用其中一部分,因此这里放开 dead_code。
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod update_e2e;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
//! 更新通道端到端验收的夹具(M169):临时现场、真形状清单、测试通道的环境变量守卫。
|
||||
//!
|
||||
//! 两个测试目标共用(`update_e2e_test` 正路 / 幂等,`update_e2e_tamper_test` 篡改);每个目标只
|
||||
//! 用到其中一部分,所以与 `support/mod.rs` 一样放开 dead_code。
|
||||
//!
|
||||
//! 整个夹具只在 `update-test-source` feature 下真的被用到:那两个测试目标自己 `#![cfg]` 掉,
|
||||
//! 这里只是跟着编译(不跑)。判据(feature + 两档环境变量)在 `core::update::job`。
|
||||
//!
|
||||
//! ⚠ **验收必须给 `--dest`**:用例在**进程内**跑 `unlucky_cli::run`,`Job::for_process` 的
|
||||
//! 「本进程的 exe」就是测试二进制——`apply` 不带 `--dest` 会真的把它 `rename` 成新字节
|
||||
//! (Windows 允许改名正在运行的映像,见 `core::update::install` 的文档),整轮测试当场崩。
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use unlucky_core::update::{HashAlgo, OsFamily, Target, UPDATE_DIR_ENV, UPDATE_TEST_SOURCE_ENV};
|
||||
|
||||
/// 假装的「已安装版本」字节:与产物**必须不同**,否则「目标没被动过」这类断言会假绿。
|
||||
pub const INSTALLED: &[u8] = b"installed 0.2.3 build (pretend)";
|
||||
|
||||
/// 产物体量上限(1 MiB)。
|
||||
///
|
||||
/// 现场构建出来的 `ull.exe` 是 **~400 MB**(debug + 全套 reqwest/rmcp/axum);拿整份当过测材,
|
||||
/// 光 sha256 + 拷贝就要几十秒,验收会变成「谁都不想跑」。所以截取**前 1 MiB**:PE 头与头几节都在,
|
||||
/// 「这是真 exe 的字节」这一点保留,代价是「装上去能不能启动」不再由它证明——那一跳本来就要人工
|
||||
/// (见报告「不验的部分」)。清单里的 `size` / `sha256` 按这份截短后的真实字节算,仍然自洽。
|
||||
const PAYLOAD_LIMIT: usize = 1024 * 1024;
|
||||
|
||||
/// 清单里的更新说明(断言它会原样出现在 `check --json` 里)。
|
||||
const NOTES: &str = "修掉启动崩溃;实例页加载更快";
|
||||
|
||||
/// SHA-256 按真实字节算(与双检同一条实现:`core::download::verify::digest_bytes`)。
|
||||
pub fn sha256(bytes: &[u8]) -> String {
|
||||
unlucky_core::download::verify::digest_bytes(HashAlgo::Sha256, bytes)
|
||||
}
|
||||
|
||||
/// 清单版本:**比编译期版本严格大一档次版本**。
|
||||
///
|
||||
/// 不能写死 `0.2.4`:workspace 版本一升,用例就变成「已是最新」的假红。次版本 +1 恒大于当前
|
||||
/// 版本,且仍在同一 major 内(不会意外触发 `min_supported` 那条判据)。
|
||||
pub fn next_version(current: &str) -> String {
|
||||
let mut parts = current.split('.');
|
||||
let major = parts.next().unwrap_or("0");
|
||||
let minor: u64 = parts
|
||||
.next()
|
||||
.unwrap_or("0")
|
||||
.parse()
|
||||
.unwrap_or_else(|_| panic!("编译期版本号应是三段式:{current}"));
|
||||
assert!(parts.next().is_some(), "编译期版本号应是三段式:{current}");
|
||||
format!("{major}.{}.0", minor + 1)
|
||||
}
|
||||
|
||||
/// 真实产物命名(D49):`unlucky-launcher_<ver>_windows_x64.exe`。
|
||||
///
|
||||
/// 拿 `Target::as_str()`(`win-x64`)直接拼会得到一个世上不存在的文件名,清单那一栏也就
|
||||
/// 与真实发布对不上——「真形状」的意义正在这里。
|
||||
fn asset_name(version: &str) -> String {
|
||||
let target = Target::current();
|
||||
let os = match target.os {
|
||||
OsFamily::Windows => "windows",
|
||||
OsFamily::MacOs => "macos",
|
||||
OsFamily::Linux => "linux",
|
||||
};
|
||||
format!(
|
||||
"unlucky-launcher_{version}_{os}_{}.exe",
|
||||
target.arch.as_str()
|
||||
)
|
||||
}
|
||||
|
||||
/// 一份与 `packaging/tests/manifest.golden.json` **同字段**的清单。
|
||||
///
|
||||
/// 字段一个不多一个不少:core 的 `deny_unknown_fields` 会把多余字段当清单损坏拒掉,所以这条
|
||||
/// 同时也是「清单形状与真实生成器(`packaging/make_manifest.py`)对齐」的断言。
|
||||
fn manifest(version: &str, asset: &str, size: u64, sha256: &str) -> String {
|
||||
format!(
|
||||
r#"{{
|
||||
"channel": "stable",
|
||||
"releases": [
|
||||
{{
|
||||
"version": "{version}",
|
||||
"channel": "stable",
|
||||
"released_at": "2026-01-02T03:04:05Z",
|
||||
"notes": "{NOTES}",
|
||||
"force": false,
|
||||
"min_supported": "0.1.0",
|
||||
"artifacts": [
|
||||
{{
|
||||
"target": "{}",
|
||||
"format": "portable-exe",
|
||||
"url": "{asset}",
|
||||
"size": {size},
|
||||
"sha256": "{sha256}"
|
||||
}}
|
||||
]
|
||||
}}
|
||||
]
|
||||
}}"#,
|
||||
Target::current().as_str()
|
||||
)
|
||||
}
|
||||
|
||||
/// 进程环境是共享的(同一测试进程里各用例并行):动环境变量的用例都拿这把锁串起来。
|
||||
pub fn channel_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
/// 两个测试通道变量的现场:构造时记住原值,`Drop` 还原(用例 panic 也还原)。
|
||||
pub struct Channel {
|
||||
dir: Option<std::ffi::OsString>,
|
||||
ack: Option<std::ffi::OsString>,
|
||||
}
|
||||
|
||||
impl Channel {
|
||||
pub fn capture() -> Self {
|
||||
Self {
|
||||
dir: std::env::var_os(UPDATE_DIR_ENV),
|
||||
ack: std::env::var_os(UPDATE_TEST_SOURCE_ENV),
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试通道开启:两档环境变量全给(二进制还得带 `update-test-source` feature;只给路径不会被
|
||||
/// 认,判据见 `core::update::job`)。
|
||||
pub fn enable(root: &Path) {
|
||||
std::env::set_var(UPDATE_DIR_ENV, root);
|
||||
std::env::set_var(UPDATE_TEST_SOURCE_ENV, "1");
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Channel {
|
||||
fn drop(&mut self) {
|
||||
for (key, value) in [
|
||||
(UPDATE_DIR_ENV, self.dir.clone()),
|
||||
(UPDATE_TEST_SOURCE_ENV, self.ack.clone()),
|
||||
] {
|
||||
match value {
|
||||
Some(value) => std::env::set_var(key, value),
|
||||
None => std::env::remove_var(key),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `--json` 信封 → 命令载荷。
|
||||
///
|
||||
/// 成功信封是 `{"ok":true,"data":{…},"warnings":[…]}`,失败信封是 `{"ok":false,"error":{…}}`
|
||||
/// (没有 `data`)。所以「取载荷」= 有 `data` 取 `data`,否则原样返回——失败用例照样能看
|
||||
/// `error.code` / `error.detail.kind`。
|
||||
pub fn payload(json: &serde_json::Value) -> &serde_json::Value {
|
||||
json.get("data").unwrap_or(json)
|
||||
}
|
||||
|
||||
/// 目录里的文件名(读不到就当空)。
|
||||
fn list_dir(dir: &Path) -> Vec<String> {
|
||||
match std::fs::read_dir(dir) {
|
||||
Ok(entries) => entries
|
||||
.filter_map(Result::ok)
|
||||
.map(|entry| entry.file_name().to_string_lossy().into_owned())
|
||||
.collect(),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次验收的全部现场:
|
||||
///
|
||||
/// ```text
|
||||
/// <temp>/<tag>-data/ --data-dir(自定义模式 ⇒ <data>/.ULL 与 <data>/.minecraft)
|
||||
/// <temp>/<tag>-dist/ ULL_UPDATE_DIR(stable.json + 产物)
|
||||
/// <temp>/<tag>-install/ --dest 所在目录(**临时副本**,不是本进程的 exe)
|
||||
/// ```
|
||||
///
|
||||
/// 三个 `TempDir` 都要活到用例结束(`Fixture` 持有它们)。
|
||||
pub struct Fixture {
|
||||
pub data: super::TempDir,
|
||||
pub dist: super::TempDir,
|
||||
install: super::TempDir,
|
||||
/// 产物字节 = 现场构建出来的 `ull.exe` 的前 1 MiB(真 PE 头,理由见 [`PAYLOAD_LIMIT`])。
|
||||
pub payload: Vec<u8>,
|
||||
/// 清单里的版本号(= 编译期版本次版本 +1)。
|
||||
pub version: String,
|
||||
/// 产物文件名(清单 `url` 与盘上文件同名)。
|
||||
pub asset: String,
|
||||
/// 替换目标(安装目录里的副本)。
|
||||
pub dest: PathBuf,
|
||||
}
|
||||
|
||||
/// 现场构建出来的 `ull.exe` 的前 [`PAYLOAD_LIMIT`] 字节(读一次,整份不超过 1 MiB)。
|
||||
///
|
||||
/// 为什么不用占位字符串:这是**发布矩阵里那个单文件 exe** 的真字节,PE 头、节表、体积都对得上;
|
||||
/// 为什么截短:整份 ~400 MB,测材太重(理由见 [`PAYLOAD_LIMIT`])。
|
||||
fn payload_bytes() -> Vec<u8> {
|
||||
let path = env!("CARGO_BIN_EXE_ull");
|
||||
let mut payload = std::fs::read(path).expect("读构建出来的 ull.exe");
|
||||
payload.truncate(PAYLOAD_LIMIT);
|
||||
assert!(
|
||||
payload.len() >= 64 * 1024,
|
||||
"产物要像真的(至少 64 KiB),实际 {} 字节",
|
||||
payload.len()
|
||||
);
|
||||
if cfg!(windows) {
|
||||
assert!(
|
||||
payload.starts_with(b"MZ"),
|
||||
"Windows 产物得是 PE 映像(前两个字节 MZ),否则「真形状」这句话就没意义"
|
||||
);
|
||||
}
|
||||
payload
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
pub fn new(tag: &str) -> Self {
|
||||
let payload = payload_bytes();
|
||||
let version = next_version(env!("CARGO_PKG_VERSION"));
|
||||
let install = super::TempDir::new(&format!("{tag}-install"));
|
||||
let dest = install.path().join("unlucky-launcher.exe");
|
||||
std::fs::write(&dest, INSTALLED).expect("写旧版本");
|
||||
Self {
|
||||
data: super::TempDir::new(&format!("{tag}-data")),
|
||||
dist: super::TempDir::new(&format!("{tag}-dist")),
|
||||
install,
|
||||
asset: asset_name(&version),
|
||||
payload,
|
||||
version,
|
||||
dest,
|
||||
}
|
||||
}
|
||||
|
||||
/// 发一版:清单 + 产物都按 `payload` 的真实长度与摘要。
|
||||
pub fn publish_good(&self) {
|
||||
self.publish(&sha256(&self.payload), self.payload.len() as u64);
|
||||
}
|
||||
|
||||
/// 发一版,摘要/长度由调用方给(篡改用例用)。
|
||||
pub fn publish(&self, sha256: &str, size: u64) {
|
||||
std::fs::write(self.dist.path().join(&self.asset), &self.payload).expect("写产物");
|
||||
std::fs::write(
|
||||
self.dist.path().join("stable.json"),
|
||||
manifest(&self.version, &self.asset, size, sha256),
|
||||
)
|
||||
.expect("写清单");
|
||||
}
|
||||
|
||||
/// 跑一条 `update` 子命令:`--data-dir` 指向临时数据根(与 GUI 一样由数据根派生暂存目录)。
|
||||
pub fn run(&self, args: &[&str]) -> (i32, String, String) {
|
||||
let mut full = vec!["update"];
|
||||
full.extend_from_slice(args);
|
||||
let data_dir = self.data.arg();
|
||||
full.push("--data-dir");
|
||||
full.push(data_dir.as_str());
|
||||
super::run_cli(&full)
|
||||
}
|
||||
|
||||
/// 跑 `apply --dest <副本> --json`(**永远**带 `--dest`,见文件头)。
|
||||
pub fn apply(&self) -> (i32, serde_json::Value) {
|
||||
let dest = self.dest.display().to_string();
|
||||
let (code, out, _) = self.run(&["apply", "--dest", &dest, "--json"]);
|
||||
(code, super::parse_json(&out))
|
||||
}
|
||||
|
||||
/// 暂存目录(`<数据根>/.ULL/cache/update/staging`,与 `core::update::staging_dir` 同形)。
|
||||
pub fn staging(&self) -> PathBuf {
|
||||
self.data
|
||||
.path()
|
||||
.join(".ULL")
|
||||
.join("cache")
|
||||
.join("update")
|
||||
.join("staging")
|
||||
}
|
||||
|
||||
/// 暂存目录里还留着什么(`StagedPackage` 落地/被搬走之后应为空)。
|
||||
pub fn staging_leftovers(&self) -> Vec<String> {
|
||||
list_dir(&self.staging())
|
||||
}
|
||||
|
||||
/// 安装目录里的备份(`.unlucky-launcher.exe.ullold-*`)。
|
||||
pub fn backups(&self) -> Vec<String> {
|
||||
list_dir(self.install.path())
|
||||
.into_iter()
|
||||
.filter(|name| name.contains(".ullold-"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 目标文件当前字节。
|
||||
pub fn installed_bytes(&self) -> Vec<u8> {
|
||||
std::fs::read(&self.dest).expect("读目标")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//! 更新通道端到端验收(M169)的**拒绝侧**:篡改摘要、篡改包体、截断包体。
|
||||
//!
|
||||
//! 正路与幂等在 `update_e2e_test.rs`(现场说明、抓得住/抓不住、不验的部分都在那边,别重复读)。
|
||||
//! 这里只钉一条底线:**校验不过就一个字节都不许落到目标上**,而且失败不会把现场搞脏到
|
||||
//! 「修好清单也装不上」。
|
||||
//!
|
||||
//! 一键跑(必须带 feature;tower 定案:验收落在 release profile 上):
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo test --release -p unlucky-cli --features update-test-source --test update_e2e_tamper_test
|
||||
//! ```
|
||||
//!
|
||||
//! 不带 feature 时整个目标 `#![cfg]` 掉(0 个用例)——测试通道在那种构建里不存在。
|
||||
|
||||
#![cfg(feature = "update-test-source")]
|
||||
|
||||
#[path = "support/mod.rs"]
|
||||
mod support;
|
||||
|
||||
use support::update_e2e::{channel_lock, sha256, Channel, Fixture, INSTALLED};
|
||||
|
||||
/// 清单的 `sha256` 与盘上字节不符 ⇒ 拒绝,且**现有安装一个字节都不动**。
|
||||
///
|
||||
/// 抓得住:校验和判据被绕过(写成恒真、先搬后校、只比长度)。抓不住:清单与包体一起被换
|
||||
/// (同时改 sha),那条路上没有可信根。
|
||||
#[test]
|
||||
fn a_tampered_digest_is_refused_and_leaves_the_install_intact() {
|
||||
let _serial = channel_lock();
|
||||
let _restore = Channel::capture();
|
||||
let fx = Fixture::new("e2e-digest");
|
||||
Channel::enable(fx.dist.path());
|
||||
// 长度对、摘要错:先过长度判据,才能测到摘要这一关。
|
||||
fx.publish(&sha256(b"another build entirely"), fx.payload.len() as u64);
|
||||
|
||||
let (code, json) = fx.apply();
|
||||
assert_ne!(code, 0, "摘要不符必须拒绝(实际退出码 0):{json}");
|
||||
assert_eq!(json["error"]["code"], "update_apply_failed", "{json}");
|
||||
assert_eq!(json["error"]["detail"]["kind"], "digest_mismatch", "{json}");
|
||||
|
||||
assert_eq!(fx.installed_bytes(), INSTALLED, "校验失败不许动目标文件");
|
||||
assert!(
|
||||
fx.backups().is_empty(),
|
||||
"还没走到替换就失败了,安装目录里不该出现 .ullold:{:?}",
|
||||
fx.backups()
|
||||
);
|
||||
assert!(
|
||||
fx.staging().exists(),
|
||||
"下载过才会建暂存目录(说明失败点在双检,不是根本没下)"
|
||||
);
|
||||
assert!(
|
||||
fx.staging_leftovers().is_empty(),
|
||||
"校验失败的 .ullupd 必须被删掉:{:?}",
|
||||
fx.staging_leftovers()
|
||||
);
|
||||
|
||||
// 修好清单再装一次:失败没把现场搞脏,这条路还能走通。
|
||||
fx.publish_good();
|
||||
let (code, json) = fx.apply();
|
||||
assert_eq!(code, 0, "修好清单之后应能装上:{json}");
|
||||
let data = support::update_e2e::payload(&json);
|
||||
assert_eq!(data["status"], "installed", "{json}");
|
||||
assert_eq!(fx.installed_bytes(), fx.payload);
|
||||
}
|
||||
|
||||
/// 产物被换(清单没动)⇒ 一样拒绝:篡改落在哪一侧都能抓到。
|
||||
#[test]
|
||||
fn a_tampered_artifact_is_refused_too() {
|
||||
let _serial = channel_lock();
|
||||
let _restore = Channel::capture();
|
||||
let fx = Fixture::new("e2e-artifact");
|
||||
Channel::enable(fx.dist.path());
|
||||
fx.publish_good();
|
||||
// 盘上换成同长度的另一份字节:只有摘要能抓(长度判据看不出区别)。
|
||||
let mut evil = fx.payload.clone();
|
||||
evil[0] ^= 0xff;
|
||||
std::fs::write(fx.dist.path().join(&fx.asset), &evil).expect("篡改产物");
|
||||
|
||||
let (code, json) = fx.apply();
|
||||
assert_ne!(code, 0, "篡改的包必须拒绝:{json}");
|
||||
assert_eq!(
|
||||
json["error"]["detail"]["kind"], "digest_mismatch",
|
||||
"篡改产物应当报摘要不符:{json}"
|
||||
);
|
||||
assert_eq!(fx.installed_bytes(), INSTALLED);
|
||||
assert!(fx.staging_leftovers().is_empty());
|
||||
}
|
||||
|
||||
/// 包体被截断(清单长度按完整包算)⇒ 长度判据先拒:不读摘要、不碰目标。
|
||||
#[test]
|
||||
fn a_truncated_payload_is_refused_as_size_mismatch() {
|
||||
let _serial = channel_lock();
|
||||
let _restore = Channel::capture();
|
||||
let fx = Fixture::new("e2e-truncated");
|
||||
Channel::enable(fx.dist.path());
|
||||
fx.publish_good();
|
||||
let full = fx.payload.clone();
|
||||
std::fs::write(fx.dist.path().join(&fx.asset), &full[..full.len() / 2]).expect("截断产物");
|
||||
|
||||
let (code, json) = fx.apply();
|
||||
assert_ne!(code, 0, "截断的包必须拒绝:{json}");
|
||||
assert_eq!(json["error"]["detail"]["kind"], "size_mismatch", "{json}");
|
||||
assert_eq!(fx.installed_bytes(), INSTALLED);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
//! 更新通道端到端验收(M169):**临时数据根 + 本地更新源 + 真形状的清单与产物**,
|
||||
//! 从 CLI 入口走完「发现新版本 → 下载 → 长度/SHA256 双检 → 就位」。
|
||||
//!
|
||||
//! 一键跑(**必须带 feature**;tower 要求验收落在 release profile 上,debug 与 release 的
|
||||
//! `debug_assertions`、优化与 panic 行为都不同,用 debug 验收等于没验真实产物):
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo test --release -p unlucky-cli --features update-test-source \
|
||||
//! --test update_e2e_test --test update_e2e_tamper_test
|
||||
//! ```
|
||||
//!
|
||||
//! (两个测试目标各跑一半:本文件是正路 + 幂等,`update_e2e_tamper_test` 是篡改/截断。)
|
||||
//!
|
||||
//! 不带 `--features update-test-source` 时整个目标 `#![cfg]` 掉(**0 个用例**,不是绿):测试通道
|
||||
//! 在那种构建里根本不存在,「环境变量设满也走 GitHub」由 `crates/core` 的
|
||||
//! `the_test_channel_is_a_compile_time_opt_in` 在**不带 feature 的跑法**下守着。
|
||||
//!
|
||||
//! 为什么需要 feature:`ULL_UPDATE_DIR` 曾经只在 `debug_assertions` 下生效,于是发布产物(release)
|
||||
//! **没法**指向本地源,这条链在 release 上从来没有端到端证据。M169 把判据换成编译期 feature
|
||||
//! (默认关 ⇒ 官方二进制里没有这条路径),验收构建显式打开它。
|
||||
//!
|
||||
//! # 现场
|
||||
//!
|
||||
//! 见 `tests/support/update_e2e.rs`:数据根 / 本地源 / 安装目录三份临时目录,产物是现场构建出来的
|
||||
//! `ull.exe` 的**前 1 MiB**(真 PE 头、真字节;整份 ~400 MB,测材太重在 `PAYLOAD_LIMIT` 的注释里
|
||||
//! 写了原因),清单与 `packaging/tests/manifest.golden.json` 同字段。**`--dest` 永远指向临时副本**
|
||||
//! ——这个文件里没有一处会替换本进程的 exe。
|
||||
//!
|
||||
//! # 抓得住什么 / 抓不住什么
|
||||
//!
|
||||
//! | 本套断言 | 抓得住 | 抓不住 |
|
||||
//! |---|---|---|
|
||||
//! | 清单 → 选版本 → 下载 → 双检 → 就位整环跑通 | 任一环断了(字段拼错、渠道不符、没下、没校验、没搬)都会红 | 真 GitHub Release 的下载链路(本套一个字节不出网) |
|
||||
//! | 清单 `sha256` 与盘上字节不符 ⇒ 拒绝 + 目标不动 | 「校验和判据被绕过/写成恒真」;篡改包体或篡改清单都能抓(见 `update_e2e_tamper_test`) | 「CDN 换了内容**而清单里的 sha 也一起换**」——这条路上没有可信根,只能靠 GitHub 侧 |
|
||||
//! | 清单 `size` 与盘上长度不符 ⇒ 拒绝 | 截断/拼接(长度判据先跑,比摘要便宜) | 同上:清单自己撒谎时没有任何判据能发现 |
|
||||
//! | 重复 `apply` 后目标字节稳定、暂存无残留 | 「第二次把 dest 写坏 / 暂存留下垃圾」 | 「版本号真的前进了」——`current` 取的是**编译期**版本,不读 dest 里的 exe |
|
||||
//! | 失败后目标仍是旧字节、无 `.ullold-*` | 「校验失败却已经动过目标」 | 跨卷(数据根与 exe 不同盘)时的替换:`rename` 不能跨卷,那条路没验 |
|
||||
//!
|
||||
//! # 不验的部分(如实划界)
|
||||
//!
|
||||
//! 1. **真实 GitHub Release 下载**:本套一个字节都不出网(源是本地目录)。`releases/latest` 的
|
||||
//! 真实响应、镜像/gh-proxy 回退、CDN 的 ETag/断点续传都属于真网络验收。
|
||||
//! 2. **GUI 的点击与重启替换**:设置页「下载并安装」、提示条、`RestartNow` 之后的进程重启不在
|
||||
//! 自动化里(替换自身 exe 需要真机,且要人工确认新版本能起来)。
|
||||
//! 3. **按版本号忽略**:`presenter::host::update_state` 的 `skipped` 是会话级行为,有单测
|
||||
//! (`update_session_tests.rs`),CLI 侧没有对应开关,所以这里不覆盖。
|
||||
//! 4. **`--dest` 之外的自替换**(替换本进程 exe):`tests/support/update_e2e.rs` 的文件头写了为什么
|
||||
//! 这几条用例永远带 `--dest`,以及「去掉它会真的改名换掉测试二进制」这件事。
|
||||
|
||||
#![cfg(feature = "update-test-source")]
|
||||
|
||||
#[path = "support/mod.rs"]
|
||||
mod support;
|
||||
|
||||
use support::update_e2e::{channel_lock, Channel, Fixture, INSTALLED};
|
||||
|
||||
use unlucky_core::update::Target;
|
||||
|
||||
/// 发现新版本 → 下载 → 双检 → 就位:整环从 CLI 入口跑一遍,并核对每一步的产物。
|
||||
#[test]
|
||||
fn discovers_downloads_and_installs_from_the_local_channel() {
|
||||
let _serial = channel_lock();
|
||||
let _restore = Channel::capture();
|
||||
let fx = Fixture::new("e2e-happy");
|
||||
Channel::enable(fx.dist.path());
|
||||
fx.publish_good();
|
||||
|
||||
// 1) 发现新版本:源是本地目录,脚本能看出这一份结论来自测试通道。
|
||||
let (code, out, err) = fx.run(&["check", "--json"]);
|
||||
assert_eq!(code, 0, "check 应成功:{err}");
|
||||
let json = support::parse_json(&out);
|
||||
let data = support::update_e2e::payload(&json);
|
||||
assert_eq!(data["status"], "available", "{json}");
|
||||
assert_eq!(data["version"], fx.version.as_str());
|
||||
assert_eq!(data["current"], env!("CARGO_PKG_VERSION"));
|
||||
assert_eq!(
|
||||
data["size"].as_u64(),
|
||||
Some(fx.payload.len() as u64),
|
||||
"清单声明的字节数 = 产物真实字节数:{json}"
|
||||
);
|
||||
assert_eq!(data["target"], Target::current().as_str());
|
||||
assert_eq!(data["notes"], "修掉启动崩溃;实例页加载更快");
|
||||
assert_eq!(data["forced"], false);
|
||||
assert_eq!(data["test_source"], true, "测试通道必须在输出里点名");
|
||||
assert_eq!(
|
||||
data["source"],
|
||||
format!("file:{}", fx.dist.path().display()),
|
||||
"源必须是本地目录(不是 GitHub)"
|
||||
);
|
||||
|
||||
// 2) 人读输出也点名测试通道:人看的那份不能比 JSON 少说一句。
|
||||
let (code, human, _) = fx.run(&["check"]);
|
||||
assert_eq!(code, 0);
|
||||
assert!(human.contains("发现新版本"), "{human}");
|
||||
assert!(
|
||||
human.contains("测试通道生效"),
|
||||
"人读输出要点名「这一份结论来自本地夹具」:{human}"
|
||||
);
|
||||
|
||||
// 3) 下载 → 长度/SHA256 双检 → 就位。
|
||||
let (code, json) = fx.apply();
|
||||
assert_eq!(code, 0, "apply 应成功:{json}");
|
||||
let data = support::update_e2e::payload(&json);
|
||||
assert_eq!(data["status"], "installed", "{json}");
|
||||
assert_eq!(data["version"], fx.version.as_str());
|
||||
assert_eq!(
|
||||
data["bytes"].as_u64(),
|
||||
Some(fx.payload.len() as u64),
|
||||
"{json}"
|
||||
);
|
||||
assert_eq!(data["path"], fx.dest.display().to_string().as_str());
|
||||
assert_eq!(data["test_source"], true);
|
||||
let backup = data["backup"]
|
||||
.as_str()
|
||||
.expect("旧版本应留备份(回滚用)")
|
||||
.to_string();
|
||||
|
||||
// 4) 盘上事实:目标 = 产物字节,备份 = 旧字节,暂存无残留。
|
||||
assert_eq!(
|
||||
fx.installed_bytes(),
|
||||
fx.payload,
|
||||
"就位的必须就是清单里那一份字节"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(&backup).expect("读备份"),
|
||||
INSTALLED,
|
||||
"旧版本要在备份里,不能凭空消失"
|
||||
);
|
||||
assert!(
|
||||
fx.staging_leftovers().is_empty(),
|
||||
"暂存目录里不许留 .ullupd:{:?}",
|
||||
fx.staging_leftovers()
|
||||
);
|
||||
|
||||
// 5) 装完再查:**仍然报有更新**——因为 current 取的是编译期版本,不读 dest 里的 exe。
|
||||
// 这不是 bug(真实场景里替换完就会重启),但脚本作者必须知道这一条。
|
||||
let (code, out, _) = fx.run(&["check", "--json"]);
|
||||
assert_eq!(code, 0);
|
||||
let json = support::parse_json(&out);
|
||||
assert_eq!(support::update_e2e::payload(&json)["status"], "available");
|
||||
}
|
||||
|
||||
/// 没有清单的本地渠道 = 「还没有任何发布」,不是错误、也不是「已是最新」以外的胡话。
|
||||
///
|
||||
/// 顺带把「源真的被换成本地目录」在没有产物的情况下也验一遍(全程离线)。
|
||||
#[test]
|
||||
fn an_empty_local_channel_is_up_to_date_not_an_error() {
|
||||
let _serial = channel_lock();
|
||||
let _restore = Channel::capture();
|
||||
let fx = Fixture::new("e2e-empty");
|
||||
Channel::enable(fx.dist.path());
|
||||
|
||||
let (code, out, err) = fx.run(&["check", "--json"]);
|
||||
assert_eq!(code, 0, "缺清单不该是错误:{err}");
|
||||
let json = support::parse_json(&out);
|
||||
let data = support::update_e2e::payload(&json);
|
||||
assert_eq!(data["status"], "up_to_date", "{json}");
|
||||
assert_eq!(data["latest"], serde_json::Value::Null);
|
||||
assert_eq!(data["test_source"], true);
|
||||
}
|
||||
|
||||
/// 重复 `apply` 的**终态**与一次相同:目标字节不变、暂存不留垃圾。
|
||||
///
|
||||
/// 如实记两条:`apply` 每次都留一个备份(CLI 从不删,等下一次启动/人工清理),而且第二次之所以
|
||||
/// 还会「装上」,是因为 `current` 是编译期版本——重复执行不是空操作,只是结果幂等。
|
||||
#[test]
|
||||
fn applying_twice_leaves_the_same_target_bytes() {
|
||||
let _serial = channel_lock();
|
||||
let _restore = Channel::capture();
|
||||
let fx = Fixture::new("e2e-twice");
|
||||
Channel::enable(fx.dist.path());
|
||||
fx.publish_good();
|
||||
|
||||
for round in 1..=2 {
|
||||
let (code, json) = fx.apply();
|
||||
assert_eq!(code, 0, "第 {round} 次 apply 应成功:{json}");
|
||||
assert_eq!(support::update_e2e::payload(&json)["status"], "installed");
|
||||
assert_eq!(
|
||||
fx.installed_bytes(),
|
||||
fx.payload,
|
||||
"第 {round} 次 apply 之后目标字节必须仍是产物"
|
||||
);
|
||||
assert!(
|
||||
fx.staging_leftovers().is_empty(),
|
||||
"第 {round} 次 apply 之后暂存目录还有 {:?}",
|
||||
fx.staging_leftovers()
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
fx.backups().len(),
|
||||
2,
|
||||
"每次 apply 留一个备份(CLI 不删):{:?}",
|
||||
fx.backups()
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user