diff --git a/README.md b/README.md index 4b56bdf..2a70425 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/ra curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash ``` -监听方式有两个选项:`127.0.0.1` 仅本机访问(默认、更安全),或 `0.0.0.0` 允许通过局域网/公网 IP 访问。安装时可输入自定义端口(直接回车使用默认端口),安装器会检查 TCP 端口是否已被占用;选择 `0.0.0.0` 时会尝试通过 HTTPS 自动获取公网 IPv4,并将 `http://公网IP:端口` 作为默认访问地址,也可以改填域名。不能填写 `http://0.0.0.0:3000`。直连 HTTP 未加密,安装器会要求明确确认,只适合受控网络。绑定域名后应改为 HTTPS 反向代理,设置真实的 `TALLYNOTE_PUBLIC_ORIGIN`、`TALLYNOTE_COOKIE_SECURE=true`、`TALLYNOTE_ALLOW_INSECURE_HTTP=false`,然后执行 `sudo systemctl restart tallynote.service`。服务启动成功后,安装日志会输出最终访问链接。 +监听方式有两个选项:`127.0.0.1` 仅本机访问(默认、更安全),或 `0.0.0.0` 允许通过局域网/公网 IP 访问。安装时可输入自定义端口(直接回车使用默认端口),安装器会检查 TCP 端口是否已被占用;选择 `0.0.0.0` 时会尝试通过 HTTPS 自动获取公网 IPv4,并将 `http://公网IP:端口` 作为默认访问地址,也可以改填域名。不能填写 `http://0.0.0.0:3000`。直连 HTTP 未加密,安装器会要求明确确认,只适合受控网络。绑定域名后应改为 HTTPS 反向代理,设置真实的 `TALLYNOTE_PUBLIC_ORIGIN`、`TALLYNOTE_COOKIE_SECURE=true`、`TALLYNOTE_ALLOW_INSECURE_HTTP=false`,然后执行 `sudo systemctl restart tallynote.service`。服务启动后,安装器会先请求本机 `/health`;只有健康检查通过才会报告安装完成并输出最终访问链接。监听 `127.0.0.1` 时该链接只对服务器本机有效;需要公网或其他设备访问时请选择 `0.0.0.0`。健康检查失败时会输出 systemd 状态和最近日志并回滚本次切换。 安装器不会在已有安装的升级过程中反复询问网络配置,并会保留现有环境文件。自动化或无终端环境可使用 `--non-interactive`(默认安全配置 `127.0.0.1:3000`),也可以显式传入 `TALLYNOTE_HOST`、`TALLYNOTE_PORT`、`TALLYNOTE_PUBLIC_ORIGIN` 和 `TALLYNOTE_ALLOW_INSECURE_HTTP` 覆盖配置。 diff --git a/docs/release.md b/docs/release.md index cabb6e0..57c7652 100644 --- a/docs/release.md +++ b/docs/release.md @@ -38,7 +38,7 @@ GITEA_TOKEN=... \ curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash ``` -首次在交互式 SSH/终端中执行时,安装器会在下载前询问监听方式和端口(端口可直接回车使用默认值),并检查所选 TCP 端口是否已被占用。可选择仅本机监听 `127.0.0.1`,或监听 `0.0.0.0` 以允许通过真实服务器 IP/域名访问;选择公网监听时会尝试通过 HTTPS 自动获取公网 IPv4,将 `http://公网IP:端口` 作为默认访问地址,也可以手动改填域名。公网 HTTP 必须在提示中明确确认,公开地址不能填写通配监听地址。服务启动成功后,安装日志会输出最终访问链接。已有安装升级时不会重复询问,并保留现有环境文件。无终端或 CI 使用 `--non-interactive`(默认 `127.0.0.1:3000`),也可通过 `TALLYNOTE_HOST`、`TALLYNOTE_PORT`、`TALLYNOTE_PUBLIC_ORIGIN` 和 `TALLYNOTE_ALLOW_INSECURE_HTTP` 显式配置。 +首次在交互式 SSH/终端中执行时,安装器会在下载前询问监听方式和端口(端口可直接回车使用默认值),并检查所选 TCP 端口是否已被占用。可选择仅本机监听 `127.0.0.1`,或监听 `0.0.0.0` 以允许通过真实服务器 IP/域名访问;选择公网监听时会尝试通过 HTTPS 自动获取公网 IPv4,将 `http://公网IP:端口` 作为默认访问地址,也可以手动改填域名。公网 HTTP 必须在提示中明确确认,公开地址不能填写通配监听地址。服务启动后,安装器会先请求本机 `/health`,只有健康检查通过才会报告安装完成并输出最终访问链接。监听 `127.0.0.1` 时链接只对服务器本机有效;需要公网或其他设备访问时请选择 `0.0.0.0`。健康检查失败时会输出 systemd 状态和最近日志并回滚本次切换。已有安装升级时不会重复询问,并保留现有环境文件。无终端或 CI 使用 `--non-interactive`(默认 `127.0.0.1:3000`),也可通过 `TALLYNOTE_HOST`、`TALLYNOTE_PORT`、`TALLYNOTE_PUBLIC_ORIGIN` 和 `TALLYNOTE_ALLOW_INSECURE_HTTP` 显式配置。 非交互安装命令: diff --git a/install.sh b/install.sh index 89a7be2..f5c8400 100755 --- a/install.sh +++ b/install.sh @@ -60,6 +60,10 @@ INSTALL_BACKUP_DIR='' INSTALL_WAS_ACTIVE=0 INSTALL_PATH_WAS_ACTIVE=0 INSTALL_UPDATE_WAS_ACTIVE=0 +INSTALL_WAS_ENABLED=0 +INSTALL_PATH_WAS_ENABLED=0 +INSTALL_UPDATE_WAS_ENABLED=0 +INSTALL_SYSTEMD_TOUCHED=0 DATA_DIR_TEMP_ROOT=0 DATA_DIR_ORIGINAL_OWNER='' @@ -218,6 +222,24 @@ check_requested_port() { esac } +wait_for_service_health() { + local host=$1 port=$2 health_host health_url attempt + health_host=$host + case "$health_host" in + 0.0.0.0) health_host=127.0.0.1 ;; + ::) health_host=::1 ;; + esac + if [[ "$health_host" == *:* && "$health_host" != \[* ]]; then health_host="[$health_host]"; fi + health_url="http://${health_host}:${port}/health" + for attempt in 1 2 3 4 5 6 7 8 9 10 11 12; do + if curl --proto '=http' --connect-timeout 2 --max-time 3 --fail --silent "$health_url" >/dev/null 2>&1; then + return 0 + fi + (( attempt < 12 )) && sleep 1 + done + return 1 +} + has_network_environment() { [[ -n "${TALLYNOTE_HOST+x}" || -n "${TALLYNOTE_PORT+x}" || -n "${TALLYNOTE_PUBLIC_ORIGIN+x}" || -n "${TALLYNOTE_ALLOW_INSECURE_HTTP+x}" ]] } @@ -770,6 +792,17 @@ stop_existing_services() { # Stop the path trigger first so it cannot launch the privileged updater while # the data tree is being repaired. for unit in tallynote-update.path tallynote-update.service tallynote.service; do + case "$unit" in + tallynote.service) + if systemctl is-enabled --quiet "$unit"; then INSTALL_WAS_ENABLED=1; fi + ;; + tallynote-update.path) + if systemctl is-enabled --quiet "$unit"; then INSTALL_PATH_WAS_ENABLED=1; fi + ;; + tallynote-update.service) + if systemctl is-enabled --quiet "$unit"; then INSTALL_UPDATE_WAS_ENABLED=1; fi + ;; + esac if systemctl is-active --quiet "$unit"; then case "$unit" in tallynote.service) INSTALL_WAS_ACTIVE=1 ;; @@ -783,6 +816,17 @@ stop_existing_services() { rollback_install_if_needed() { local result=$? rollback_tmp + if (( INSTALL_COMMITTED == 0 && INSTALL_SYSTEMD_TOUCHED == 1 )) && command -v systemctl >/dev/null 2>&1; then + # The failed install may have started units that were inactive before the + # attempt. Stop them before restoring files so systemd never keeps running + # code from a release directory that rollback is about to remove. + for unit in tallynote-update.path tallynote-update.service tallynote.service; do + systemctl stop "$unit" >/dev/null 2>&1 || true + done + if (( INSTALL_WAS_ENABLED == 0 )); then systemctl disable tallynote.service >/dev/null 2>&1 || true; fi + if (( INSTALL_PATH_WAS_ENABLED == 0 )); then systemctl disable tallynote-update.path >/dev/null 2>&1 || true; fi + if (( INSTALL_UPDATE_WAS_ENABLED == 0 )); then systemctl disable tallynote-update.service >/dev/null 2>&1 || true; fi + fi if (( INSTALL_SWITCHED == 1 && INSTALL_COMMITTED == 0 )); then if [[ -n "$INSTALL_PREVIOUS_TARGET" && -d "$INSTALL_PREVIOUS_TARGET" ]]; then rollback_tmp="$PREFIX/.current-rollback-$$-${RANDOM}.tmp" @@ -821,6 +865,7 @@ rollback_install_if_needed() { done fi if command -v systemctl >/dev/null 2>&1; then + if (( INSTALL_SYSTEMD_TOUCHED == 1 )); then systemctl daemon-reload >/dev/null 2>&1 || true; fi if (( INSTALL_WAS_ACTIVE == 1 )); then systemctl start tallynote.service 2>/dev/null || true; fi if (( INSTALL_UPDATE_WAS_ACTIVE == 1 )); then systemctl start tallynote-update.service 2>/dev/null || true; fi if (( INSTALL_PATH_WAS_ACTIVE == 1 )); then systemctl start tallynote-update.path 2>/dev/null || true; fi @@ -1038,7 +1083,10 @@ validate_existing_env() { install_release() { local archive=$1 version=$2 tmp release_dir current_tmp='' tmp=$(mktemp -d) - trap 'rm -rf "$tmp" "$current_tmp" 2>/dev/null || true' RETURN + # RETURN traps survive the function that installs them. Clear the trap from + # inside its first invocation so a later function cannot evaluate the local + # temporary path after it has gone out of scope under `set -u`. + trap 'trap - RETURN; if [[ -n "${tmp-}" ]]; then rm -rf -- "$tmp" 2>/dev/null || true; fi; if [[ -n "${current_tmp-}" ]]; then rm -f -- "$current_tmp" 2>/dev/null || true; fi' RETURN safe_extract "$archive" "$tmp/unpacked" normalize_release_tree "$tmp/unpacked" [[ -d "$tmp/unpacked/dist" ]] || die 'release archive must contain dist/ at its root' @@ -1364,7 +1412,28 @@ main() { chown root:root "$CONFIG_DIR/tallynote.env" chmod 640 "$CONFIG_DIR/tallynote.env" systemctl daemon-reload + INSTALL_SYSTEMD_TOUCHED=1 systemctl enable --now tallynote.service tallynote-update.path + local health_host health_port + health_host=$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_HOST 2>/dev/null || true) + health_port=$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_PORT 2>/dev/null || true) + health_host=${health_host:-$INSTALL_HOST} + health_port=${health_port:-$INSTALL_PORT} + stage "检查本机健康接口(${health_host}:${health_port})" + if ! wait_for_service_health "$health_host" "$health_port"; then + log "本机健康检查失败:http://${health_host}:${health_port}/health" + systemctl status tallynote.service --no-pager -l || true + if command -v journalctl >/dev/null 2>&1; then + journalctl -u tallynote.service -n 30 --no-pager || true + fi + die 'TallyNote 服务未通过健康检查;安装未完成,请根据上面的 systemd 日志修复后重试' + fi + stage_done '本机健康检查通过,服务正在监听' + if [[ "$health_host" == 127.0.0.1 || "$health_host" == localhost || "$health_host" == ::1 ]]; then + log '当前监听仅限本机;公网或其他设备无法直接访问,请重新安装并选择 0.0.0.0,或配置 HTTPS 反向代理' + else + log '当前监听已绑定非本机地址;若外部仍无法连接,请检查云安全组、主机防火墙和公网 IP/NAT' + fi stage_done 'TallyNote 服务已启用并启动' stage '清理旧版本并完成安装' prune_releases diff --git a/package.json b/package.json index 08814d9..addd2c8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tallynote", - "version": "1.1.8", + "version": "1.1.9", "private": true, "type": "module", "packageManager": "pnpm@9.0.6", diff --git a/scripts/test-installer.sh b/scripts/test-installer.sh index 8541218..fd1f01a 100755 --- a/scripts/test-installer.sh +++ b/scripts/test-installer.sh @@ -141,6 +141,52 @@ bash -c ' fi ' _ "$installer_lib" +# The service health probe maps wildcard listeners to loopback and must return +# promptly when the local endpoint is healthy. +bash -c ' + script=$1 + set -- + source "$script" + curl() { [[ "$*" == *"http://127.0.0.1:3011/health"* ]] || return 1; } + wait_for_service_health 0.0.0.0 3011 +' _ "$installer_lib" + +# A RETURN trap installed by install_release must be cleared while its local +# temporary variables still exist; otherwise set -u fails at the end of main. +release_fixture="$tmp/release-fixture" +mkdir -p "$release_fixture/dist/server" "$release_fixture/dist/web" "$release_fixture/bin" \ + "$release_fixture/scripts" "$release_fixture/runtime/bin" "$release_fixture/systemd" +printf '%s\n' '{"version":"1.0.0"}' > "$release_fixture/package.json" +printf '%s\n' server > "$release_fixture/dist/server/index.js" +printf '%s\n' web > "$release_fixture/dist/web/index.html" +printf '%s\n' '#!/bin/sh' > "$release_fixture/bin/tallynote" +printf '%s\n' '#!/bin/sh' > "$release_fixture/scripts/tallynote-update.sh" +printf '%s\n' '#!/bin/sh' > "$release_fixture/scripts/tallynote-update-runner.sh" +printf '%s\n' '#!/bin/sh' > "$release_fixture/uninstall.sh" +printf '%s\n' '[Unit]' > "$release_fixture/systemd/tallynote.service" +printf '%s\n' '[Unit]' > "$release_fixture/systemd/tallynote-update.service" +printf '%s\n' '[Unit]' > "$release_fixture/systemd/tallynote-update.path" +printf '%s\n' 'TALLYNOTE_HOST=127.0.0.1' > "$release_fixture/systemd/tallynote.env.example" +chmod 755 "$release_fixture/bin/tallynote" "$release_fixture/scripts"/*.sh "$release_fixture/uninstall.sh" +release_archive="$tmp/release-fixture.tar.gz" +tar -C "$release_fixture" -czf "$release_archive" . +bash -c ' + script=$1 + archive=$2 + destination=$3 + set -- + source "$script" + PREFIX="$destination/prefix" + ensure_root_directory() { mkdir -p "$1"; } + chown() { :; } + mv() { + if [[ "${1:-}" == -Tf ]]; then shift; /bin/mv -f "$@"; else /bin/mv "$@"; fi + } + install_release "$archive" 1.0.0 + set_env_key() { local key=$1 value=$2 escaped; :; } + set_env_key test value +' _ "$installer_lib" "$release_archive" "$tmp/install-release" + # Duplicate security-sensitive EnvironmentFile assignments are rejected even # when the first value looks valid (systemd uses the later value). duplicate_env="$tmp/duplicate.env" diff --git a/tests/update-api.test.ts b/tests/update-api.test.ts index daf12d9..3aedb7d 100644 --- a/tests/update-api.test.ts +++ b/tests/update-api.test.ts @@ -59,10 +59,10 @@ describe("更新 API", () => { function mockRelease() { const digest = "c".repeat(64); - const asset = `tallynote-1.1.9-${detectPlatform().target}-glibc.tar.gz`; + const asset = `tallynote-1.2.0-${detectPlatform().target}-glibc.tar.gz`; globalThis.fetch = (async (input: string | URL) => input.toString().endsWith("SHA256SUMS") ? new Response(`${digest} ${asset}\n`, { status: 200 }) - : new Response(JSON.stringify({ tag_name: "v1.1.9", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: asset, browser_download_url: `https://updates.example/${asset}` }] }), { status: 200 })) as typeof fetch; + : new Response(JSON.stringify({ tag_name: "v1.2.0", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: asset, browser_download_url: `https://updates.example/${asset}` }] }), { status: 200 })) as typeof fetch; } it("检查 release、创建受保护请求文件并拒绝重复任务", async () => { @@ -70,21 +70,21 @@ describe("更新 API", () => { mockRelease(); const checked = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} }); expect(checked.statusCode).toBe(200); - expect(checked.json().latest).toMatchObject({ version: "1.1.9", compatible: true, integrityReady: true, isNewer: true }); + expect(checked.json().latest).toMatchObject({ version: "1.2.0", compatible: true, integrityReady: true, isNewer: true }); expect(checked.headers["cache-control"]).toBe("no-store"); const tooSoon = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} }); expect(tooSoon.statusCode).toBe(429); expect(tooSoon.headers["retry-after"]).toBeDefined(); - const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.9", confirm: true } }); + const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.2.0", confirm: true } }); expect(applied.statusCode).toBe(202); const jobId = applied.json().job.id as string; const request = JSON.parse(readFileSync(config.updateRequestPath, "utf8")) as { jobId: string; expectedSha256: string; currentLink: string }; - expect(request).toMatchObject({ jobId, version: "1.1.9", expectedSha256: "c".repeat(64), currentLink: config.currentLink }); + expect(request).toMatchObject({ jobId, version: "1.2.0", expectedSha256: "c".repeat(64), currentLink: config.currentLink }); expect(statSync(config.updateRequestPath).mode & 0o777).toBe(0o600); mockRelease(); - const duplicate = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.9", confirm: true } }); + const duplicate = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.2.0", confirm: true } }); expect(duplicate.statusCode).toBe(409); expect(duplicate.json().error.code).toBe("UPDATE_IN_PROGRESS"); const status = await app.inject({ method: "GET", url: "/api/update/status", headers: { cookie: session.cookies } }); @@ -98,10 +98,10 @@ describe("更新 API", () => { mockRelease(); const checked = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} }); expect(checked.statusCode).toBe(200); - const downloaded = await app.inject({ method: "POST", url: "/api/update/download", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.9", confirm: true } }); + const downloaded = await app.inject({ method: "POST", url: "/api/update/download", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.2.0", confirm: true } }); expect(downloaded.statusCode).toBe(202); const downloadJobId = downloaded.json().job.id as string; - expect(downloaded.json().job).toMatchObject({ operation: "download", status: "queued", version: "1.1.9" }); + expect(downloaded.json().job).toMatchObject({ operation: "download", status: "queued", version: "1.2.0" }); const downloadRequest = JSON.parse(readFileSync(config.updateRequestPath, "utf8")) as { jobId: string; operation: string }; expect(downloadRequest).toMatchObject({ jobId: downloadJobId, operation: "download" }); expect(database.sqlite.prepare("SELECT operation, status FROM update_jobs WHERE id=?").get(downloadJobId)).toEqual({ operation: "download", status: "queued" }); @@ -110,21 +110,21 @@ describe("更新 API", () => { const stagedId = randomUUID(); const now = Date.now(); database.sqlite.prepare(`INSERT INTO update_jobs(id, admin_id, session_hash, request_id, requested_at, operation, status, version, platform, release_url, asset_name, asset_url, expected_sha256, actual_sha256, download_path, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'download', 'staged', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) - .run(stagedId, (await database.sqlite.prepare("SELECT id FROM admins WHERE username=?").get("update-staged") as { id: string }).id, "session", "staged-request", now, "1.1.9", detectPlatform().target, config.updateMetadataUrl, "release.tar.gz", "https://updates.example/release.tar.gz", "c".repeat(64), "c".repeat(64), path.join(config.dataDir, "staged-workspace"), now, now); - const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { jobId: stagedId, version: "1.1.9", confirm: true } }); + .run(stagedId, (await database.sqlite.prepare("SELECT id FROM admins WHERE username=?").get("update-staged") as { id: string }).id, "session", "staged-request", now, "1.2.0", detectPlatform().target, config.updateMetadataUrl, "release.tar.gz", "https://updates.example/release.tar.gz", "c".repeat(64), "c".repeat(64), path.join(config.dataDir, "staged-workspace"), now, now); + const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { jobId: stagedId, version: "1.2.0", confirm: true } }); expect(applied.statusCode).toBe(202); expect(applied.json().job).toMatchObject({ id: stagedId, operation: "apply", status: "staged" }); expect(database.sqlite.prepare("SELECT operation, status FROM update_jobs WHERE id=?").get(stagedId)).toEqual({ operation: "apply", status: "staged" }); const applyRequest = JSON.parse(readFileSync(config.updateRequestPath, "utf8")) as { jobId: string; operation: string; assetUrl: string; expectedSha256: string }; expect(applyRequest).toMatchObject({ jobId: stagedId, operation: "apply", assetUrl: "https://updates.example/release.tar.gz", expectedSha256: "c".repeat(64) }); - const duplicate = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { jobId: stagedId, version: "1.1.9", confirm: true } }); + const duplicate = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { jobId: stagedId, version: "1.2.0", confirm: true } }); expect(duplicate.statusCode).toBe(409); expect(duplicate.json().error.code).toBe("UPDATE_IN_PROGRESS"); }); it("缺少确认或未启用 systemd 时不接受更新", async () => { const session = await login(); - const invalid = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.9" } }); + const invalid = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.2.0" } }); expect(invalid.statusCode).toBe(400); process.env.TALLYNOTE_UPDATE_STRATEGY = "disabled"; const disabledConfig = loadConfig(); @@ -137,7 +137,7 @@ describe("更新 API", () => { mockRelease(); const checked = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: owner.cookies, "x-csrf-token": owner.csrf }, payload: {} }); expect(checked.statusCode).toBe(200); - const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: owner.cookies, "x-csrf-token": owner.csrf }, payload: { version: "1.1.9", confirm: true } }); + const applied = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: owner.cookies, "x-csrf-token": owner.csrf }, payload: { version: "1.2.0", confirm: true } }); expect(applied.statusCode).toBe(202); const jobId = applied.json().job.id as string; database.sqlite.prepare("UPDATE update_jobs SET error_message=? WHERE id=?").run("/var/lib/tallynote/secret-command-output", jobId); @@ -155,7 +155,7 @@ describe("更新 API", () => { it("应用前重新校验失败时写入失败审计", async () => { const session = await login("update-audit"); globalThis.fetch = (async () => new Response("upstream unavailable", { status: 503 })) as typeof fetch; - const response = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.1.9", confirm: true } }); + const response = await app.inject({ method: "POST", url: "/api/update/apply", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.2.0", confirm: true } }); expect(response.statusCode).toBe(502); const audit = database.sqlite.prepare("SELECT outcome FROM audit_events WHERE action='update.apply_requested' ORDER BY id DESC LIMIT 1").get() as { outcome: string } | undefined; expect(audit?.outcome).toBe("failure"); diff --git a/tests/update.test.ts b/tests/update.test.ts index 53a1e40..6616742 100644 --- a/tests/update.test.ts +++ b/tests/update.test.ts @@ -273,17 +273,17 @@ describe("更新元数据缓存", () => { prepareDataDirectories(config); const database = openDatabase(config); const digest = "b".repeat(64); - const platformAsset = `tallynote-1.1.9-${detectPlatform().target}-glibc.tar.gz`; + const platformAsset = `tallynote-1.2.0-${detectPlatform().target}-glibc.tar.gz`; const sums = `${digest} ${platformAsset}\n`; const signature = sign(null, Buffer.from(sums), privateKey); globalThis.fetch = (async (input: string | URL) => input.toString().endsWith("SHA256SUMS.sig") ? new Response(signature) : input.toString().endsWith("SHA256SUMS") ? new Response(sums) - : new Response(JSON.stringify({ tag_name: "v1.1.9", published_at: "2026-08-28T00:00:00Z", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: "SHA256SUMS.sig", browser_download_url: "https://updates.example/SHA256SUMS.sig" }, { name: platformAsset, browser_download_url: `https://updates.example/${platformAsset}` }] }), { status: 200 })) as typeof fetch; + : new Response(JSON.stringify({ tag_name: "v1.2.0", published_at: "2026-08-28T00:00:00Z", assets: [{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" }, { name: "SHA256SUMS.sig", browser_download_url: "https://updates.example/SHA256SUMS.sig" }, { name: platformAsset, browser_download_url: `https://updates.example/${platformAsset}` }] }), { status: 200 })) as typeof fetch; try { const result = await checkForUpdate(database.sqlite, config); - expect(result.latest).toMatchObject({ version: "1.1.9", compatible: true, integrityReady: true, signatureReady: true, isNewer: true }); + expect(result.latest).toMatchObject({ version: "1.2.0", compatible: true, integrityReady: true, signatureReady: true, isNewer: true }); const cached = database.sqlite.prepare("SELECT value FROM system_settings WHERE key='update.release.v1'").get() as { value: string }; expect(JSON.parse(cached.value).asset.sha256).toBe(digest); } finally {