release: 1.1.0
TallyNote release / linux-x64 (push) Successful in 6m24s

This commit is contained in:
Qiufeng
2026-08-31 20:11:34 +08:00
parent cbe0d65320
commit 12495fb6a4
66 changed files with 4315 additions and 77 deletions
+94
View File
@@ -392,4 +392,98 @@ describe("TallyNote API", () => {
expect(deleted.json().expense.invoiceCount).toBe(0);
expect(deleted.json().expense.invoiceMissingReason).toBe("原始发票文件已丢失,无法重新取得");
});
it("组合 multipart 编辑一次提交字段和附件,并只递增一次版本", async () => {
const session = await login();
const initial = multipart([
{ name: "paidAt", value: "2026-08-27T12:00:00.000Z" },
{ name: "amount", value: "12.34" },
{ name: "note", value: "组合编辑前" },
{ name: "invoiceMissingReason", value: "供应商暂未开票" },
{ name: "paymentProofs", filename: "proof-a.png", contentType: "image/png", data: tinyPng },
]);
const created = await app.inject({
method: "POST",
url: "/api/expenses",
headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf, "content-type": initial.contentType },
payload: initial.body,
});
expect(created.statusCode).toBe(201);
const before = created.json().expense as { id: string; version: number; paymentProofCount: number; invoiceCount: number };
const edit = multipart([
{ name: "paidAt", value: "2026-08-28T13:30:00.000Z" },
{ name: "amount", value: "18.90" },
{ name: "note", value: "组合编辑后" },
{ name: "version", value: String(before.version) },
{ name: "paymentProofs", filename: "proof-b.png", contentType: "image/png", data: tinyPng },
{ name: "invoices", filename: "invoice.xml", contentType: "application/xml", data: Buffer.from("<invoice />") },
]);
const updated = await app.inject({
method: "PATCH",
url: `/api/expenses/${before.id}`,
headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf, "content-type": edit.contentType },
payload: edit.body,
});
expect(updated.statusCode).toBe(200);
const result = updated.json().expense as { version: number; amountCents: number; note: string; invoiceMissingReason: string | null; paymentProofCount: number; invoiceCount: number; attachments: Array<{ originalName: string }> };
expect(result).toMatchObject({ version: before.version + 1, amountCents: 1890, note: "组合编辑后", invoiceMissingReason: null, paymentProofCount: 2, invoiceCount: 1 });
expect(result.attachments.map((item) => item.originalName)).toEqual(expect.arrayContaining(["proof-a.png", "proof-b.png", "invoice.xml"]));
const auditCount = (database.sqlite.prepare("SELECT COUNT(*) AS count FROM audit_events WHERE target_id=? AND action='expense.updated'").get(before.id) as { count: number }).count;
expect(auditCount).toBe(1);
});
it("组合编辑版本冲突或金额非法时不落附件也不改变账目", async () => {
const session = await login();
const initial = multipart([
{ name: "paidAt", value: "2026-08-27T12:00:00.000Z" },
{ name: "amount", value: "12.34" },
{ name: "invoiceMissingReason", value: "暂时无法取得" },
{ name: "paymentProofs", filename: "proof.png", contentType: "image/png", data: tinyPng },
]);
const created = await app.inject({
method: "POST",
url: "/api/expenses",
headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf, "content-type": initial.contentType },
payload: initial.body,
});
const before = created.json().expense as { id: string; version: number; amountCents: number; paymentProofCount: number };
const conflictForm = multipart([
{ name: "paidAt", value: "2026-08-29T12:00:00.000Z" },
{ name: "amount", value: "20.00" },
{ name: "note", value: "不应保存" },
{ name: "invoiceMissingReason", value: "暂时无法取得" },
{ name: "version", value: String(before.version + 1) },
{ name: "paymentProofs", filename: "orphan.png", contentType: "image/png", data: tinyPng },
]);
const conflictResponse = await app.inject({
method: "PATCH",
url: `/api/expenses/${before.id}`,
headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf, "content-type": conflictForm.contentType },
payload: conflictForm.body,
});
expect(conflictResponse.statusCode).toBe(409);
const afterConflict = app.inject({ method: "GET", url: `/api/expenses/${before.id}`, headers: { cookie: session.cookies } });
const current = (await afterConflict).json().expense;
expect(current).toMatchObject({ version: before.version, amountCents: before.amountCents, paymentProofCount: before.paymentProofCount });
expect(current.attachments.some((item: { originalName: string }) => item.originalName === "orphan.png")).toBe(false);
const invalidForm = multipart([
{ name: "paidAt", value: "2026-08-29T12:00:00.000Z" },
{ name: "amount", value: "1000000000000.00" },
{ name: "invoiceMissingReason", value: "暂时无法取得" },
{ name: "version", value: String(before.version) },
{ name: "paymentProofs", filename: "invalid.png", contentType: "image/png", data: tinyPng },
]);
const invalidResponse = await app.inject({
method: "PATCH",
url: `/api/expenses/${before.id}`,
headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf, "content-type": invalidForm.contentType },
payload: invalidForm.body,
});
expect(invalidResponse.statusCode).toBe(400);
const afterInvalid = (await app.inject({ method: "GET", url: `/api/expenses/${before.id}`, headers: { cookie: session.cookies } })).json().expense;
expect(afterInvalid).toMatchObject({ version: before.version, amountCents: before.amountCents, paymentProofCount: before.paymentProofCount });
expect(afterInvalid.attachments.some((item: { originalName: string }) => item.originalName === "invalid.png")).toBe(false);
});
});
+8 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { amountToCents, centsToAmount, exportRequestSchema } from "../shared/contracts.js";
import { amountToCents, centsToAmount, exportRequestSchema, updateApplySchema } from "../shared/contracts.js";
import { zonedMonthBounds } from "../server/app.js";
import { safeExcelText } from "../server/exporter.js";
import { safeStoragePath, sanitizeOriginalName } from "../server/files.js";
@@ -30,6 +30,13 @@ describe("导出选项", () => {
});
});
describe("更新版本", () => {
it("接受合法的 prerelease 和 build metadata", () => {
expect(updateApplySchema.parse({ version: "1.2.3+build.5", confirm: true }).version).toBe("1.2.3+build.5");
expect(updateApplySchema.parse({ version: "v1.2.3-alpha.1+build.5", confirm: true }).version).toBe("v1.2.3-alpha.1+build.5");
});
});
describe("文件和导出安全", () => {
it("不让用户文件名参与路径", () => {
expect(sanitizeOriginalName("../../秘密\u0000.png")).toBe("秘密.png");
+24 -3
View File
@@ -2,8 +2,29 @@ import { expect, test } from "@playwright/test";
test("未登录时显示中文登录入口", async ({ page }) => {
await page.goto("/");
await expect(page.getByText("TallyNote")).toBeVisible();
await expect(page.getByLabel("用户名")).toBeVisible();
await expect(page.getByLabel("密码")).toBeVisible();
await expect(page.getByRole("heading", { name: "登录到 TallyNote", exact: true })).toBeVisible();
await expect(page.locator(".tn-login-header")).toHaveCount(0);
await expect(page.getByLabel("用户名", { exact: true })).toBeVisible();
await expect(page.getByLabel("密码", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "登录" })).toBeVisible();
});
for (const viewport of [
{ width: 320, height: 800 },
{ width: 375, height: 812 },
{ width: 768, height: 1024 },
]) {
test(`未登录入口适配 ${viewport.width}px`, async ({ page }) => {
await page.setViewportSize(viewport);
await page.goto("/");
await expect(page.getByRole("heading", { name: "登录到 TallyNote", exact: true })).toBeVisible();
await expect(page.getByLabel("用户名", { exact: true })).toBeVisible();
await expect(page.getByLabel("密码", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "登录" })).toBeVisible();
const width = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
}));
expect(width.scrollWidth).toBeLessThanOrEqual(width.clientWidth);
});
}
+9 -9
View File
@@ -59,10 +59,10 @@ describe("更新 API", () => {
function mockRelease() {
const digest = "c".repeat(64);
const asset = `tallynote-1.1.0-${detectPlatform().target}-glibc.tar.gz`;
const asset = `tallynote-1.1.1-${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.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;
: new Response(JSON.stringify({ tag_name: "v1.1.1", 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.0", compatible: true, integrityReady: true, isNewer: true });
expect(checked.json().latest).toMatchObject({ version: "1.1.1", 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.0", 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.1.1", 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, expectedSha256: "c".repeat(64), currentLink: config.currentLink });
expect(request).toMatchObject({ jobId, version: "1.1.1", 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.0", 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.1.1", 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 } });
@@ -95,7 +95,7 @@ describe("更新 API", () => {
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.0" } });
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.1" } });
expect(invalid.statusCode).toBe(400);
process.env.TALLYNOTE_UPDATE_STRATEGY = "disabled";
const disabledConfig = loadConfig();
@@ -108,7 +108,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.0", 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.1.1", 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);
@@ -126,7 +126,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.0", 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.1.1", 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");
+3 -3
View File
@@ -273,17 +273,17 @@ describe("更新元数据缓存", () => {
prepareDataDirectories(config);
const database = openDatabase(config);
const digest = "b".repeat(64);
const platformAsset = `tallynote-1.1.0-${detectPlatform().target}-glibc.tar.gz`;
const platformAsset = `tallynote-1.1.1-${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.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;
: new Response(JSON.stringify({ tag_name: "v1.1.1", 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.0", compatible: true, integrityReady: true, signatureReady: true, isNewer: true });
expect(result.latest).toMatchObject({ version: "1.1.1", 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 {
+67
View File
@@ -0,0 +1,67 @@
import { describe, expect, it, vi } from "vitest";
import { ApiError, api } from "../web-next/src/services/api";
import { DEFAULT_ROUTE_ID, routeIdFromPath, routePath } from "../web-next/src/router";
import sessionReducer, { bootstrapSession, isSessionBootstrapping, loginThunk } from "../web-next/src/store/sessionSlice";
import type { Admin } from "../web-next/src/types/auth";
describe("前端基础契约", () => {
it("将页面路由映射为稳定的 URL,并容忍尾斜杠", () => {
expect(routePath(DEFAULT_ROUTE_ID)).toBe("/");
expect(routeIdFromPath("/")).toBe("dashboard");
expect(routeIdFromPath("/dashboard")).toBe("dashboard");
expect(routeIdFromPath("/expenses/")).toBe("expenses");
expect(routeIdFromPath("/unknown")).toBeUndefined();
});
it("请求超时会中止并返回可识别的错误", async () => {
vi.stubGlobal("fetch", vi.fn((_url: string, init?: RequestInit) => new Promise((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")), { once: true });
})));
await expect(api("/api/slow", { timeoutMs: 10 })).rejects.toMatchObject<ApiError>({ status: 408, code: "REQUEST_TIMEOUT" });
vi.unstubAllGlobals();
});
it("忽略过期的会话初始化结果,避免覆盖较新的认证状态", () => {
const admin: Admin = {
id: "11111111-1111-4111-8111-111111111111",
username: "admin",
displayName: "管理员",
status: "active",
mustChangePassword: false,
version: 1,
createdAt: 1,
lastLoginAt: null,
disabledAt: null,
};
let state = sessionReducer(undefined, bootstrapSession.pending("older", undefined));
state = sessionReducer(state, bootstrapSession.pending("newer", undefined));
state = sessionReducer(state, bootstrapSession.fulfilled({ initialized: true, timezone: "Asia/Shanghai", admin: null }, "older", undefined));
expect(state.status).toBe("loading");
expect(state.bootstrapRequestId).toBe("newer");
state = sessionReducer(state, loginThunk.fulfilled(admin, "login", { username: "admin", password: "test" }));
expect(state.status).toBe("authenticated");
expect(state.admin?.id).toBe(admin.id);
state = sessionReducer(state, bootstrapSession.fulfilled({ initialized: true, timezone: "Asia/Shanghai", admin: null }, "newer", undefined));
expect(state.status).toBe("authenticated");
expect(state.admin?.id).toBe(admin.id);
});
it("登录提交时保留登录页面,不误显示启动连接占位", () => {
let state = sessionReducer(undefined, bootstrapSession.pending("boot", undefined));
expect(isSessionBootstrapping(state)).toBe(true);
state = sessionReducer(state, bootstrapSession.fulfilled({ initialized: true, timezone: "Asia/Shanghai", admin: null }, "boot", undefined));
state = sessionReducer(state, loginThunk.pending("login", { username: "admin", password: "test" }));
expect(state.status).toBe("loading");
expect(state.initialized).toBe(true);
expect(state.bootstrapRequestId).toBeNull();
expect(isSessionBootstrapping(state)).toBe(false);
});
it("启动连接失败时显示可恢复的错误状态", () => {
let state = sessionReducer(undefined, bootstrapSession.pending("boot", undefined));
state = sessionReducer(state, bootstrapSession.rejected(new Error("服务不可用"), "boot", undefined));
expect(state.status).toBe("error");
expect(state.initialized).toBeNull();
expect(isSessionBootstrapping(state)).toBe(false);
});
});