TallyNote release / linux-x64 (push) Failing after 3m0s
- manual admin password no longer forces first-login change - --generate still requires password change on first login - add --mark-password-configured to repair legacy flag with current password - echo interactive username/password input in SSH terminal - installer prints absolute admin-init path (sudo secure_path compat) release: 1.2.9
192 lines
7.9 KiB
TypeScript
192 lines
7.9 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
import { spawnSync } from "node:child_process";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import Database from "better-sqlite3";
|
|
|
|
const root = path.resolve(process.cwd());
|
|
const cli = path.join(root, "server", "cli", "admin-init.ts");
|
|
const tsx = path.join(root, "node_modules", "tsx", "dist", "cli.mjs");
|
|
|
|
function runAdmin(dataDir: string, args: string[]) {
|
|
return spawnSync(process.execPath, [tsx, cli, ...args], {
|
|
cwd: root,
|
|
env: {
|
|
...process.env,
|
|
NODE_ENV: "test",
|
|
TALLYNOTE_DATA_DIR: dataDir,
|
|
TALLYNOTE_PUBLIC_ORIGIN: "http://127.0.0.1:3999",
|
|
TALLYNOTE_COOKIE_SECURE: "false",
|
|
TALLYNOTE_UPDATE_STRATEGY: "disabled",
|
|
},
|
|
encoding: "utf8",
|
|
});
|
|
}
|
|
|
|
describe("生产管理员初始化 CLI", () => {
|
|
it("--check 是只读的,空数据目录不会被创建", () => {
|
|
const parent = mkdtempSync(path.join(tmpdir(), "tallynote-admin-check-"));
|
|
const dataDir = path.join(parent, "data");
|
|
try {
|
|
const result = runAdmin(dataDir, ["--check"]);
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout.trim()).toBe("empty");
|
|
expect(existsSync(dataDir)).toBe(false);
|
|
expect(existsSync(path.join(dataDir, "tallynote.db"))).toBe(false);
|
|
} finally {
|
|
rmSync(parent, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("--check 不会执行迁移或创建 schema_migrations", () => {
|
|
const dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-admin-check-"));
|
|
const database = new Database(path.join(dataDir, "tallynote.db"));
|
|
database.exec("CREATE TABLE admins (id TEXT PRIMARY KEY)");
|
|
database.close();
|
|
try {
|
|
const result = runAdmin(dataDir, ["--check"]);
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout.trim()).toBe("empty");
|
|
const verify = new Database(path.join(dataDir, "tallynote.db"), { readonly: true });
|
|
const schemaMigrations = verify
|
|
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'")
|
|
.get();
|
|
expect(schemaMigrations).toBeUndefined();
|
|
verify.close();
|
|
} finally {
|
|
rmSync(dataDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("只允许初始化首位管理员,并写入一次性密码和审计记录", () => {
|
|
const dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-admin-init-"));
|
|
try {
|
|
const first = runAdmin(dataDir, ["--username", "admin", "--display-name", "管理员", "--generate"]);
|
|
expect(first.status).toBe(0);
|
|
expect(first.stdout).toMatch(/已创建首位管理员。一次性密码:\S+/);
|
|
|
|
const database = new Database(path.join(dataDir, "tallynote.db"));
|
|
const admin = database.prepare("SELECT username, display_name, must_change_password FROM admins").get() as { username: string; display_name: string; must_change_password: number };
|
|
const audit = database.prepare("SELECT action, actor_username FROM audit_events ORDER BY occurred_at DESC LIMIT 1").get() as { action: string; actor_username: string };
|
|
expect(admin).toEqual({ username: "admin", display_name: "管理员", must_change_password: 1 });
|
|
expect(audit).toEqual({ action: "admin.initialized", actor_username: "cli" });
|
|
database.close();
|
|
|
|
const check = runAdmin(dataDir, ["--check"]);
|
|
expect(check.status).toBe(0);
|
|
expect(check.stdout.trim()).toBe("initialized");
|
|
|
|
const second = runAdmin(dataDir, ["--username", "other", "--display-name", "其他", "--generate"]);
|
|
expect(second.status).not.toBe(0);
|
|
expect(`${second.stdout}${second.stderr}`).toContain("INITIAL_ADMIN_EXISTS");
|
|
} finally {
|
|
rmSync(dataDir, { recursive: true, force: true });
|
|
}
|
|
}, 15_000);
|
|
|
|
it("交互式输入正式密码后不会强制首次改密", () => {
|
|
const dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-admin-init-"));
|
|
try {
|
|
const expectScript = [
|
|
"set timeout 15",
|
|
`spawn ${process.execPath} ${tsx} ${cli}`,
|
|
'expect "用户名: "',
|
|
'send "manual-admin\\r"',
|
|
'expect "显示名称: "',
|
|
'send "手动管理员\\r"',
|
|
'expect "密码(至少 12 个字符): "',
|
|
'send "Strong-password-2026!\\r"',
|
|
'expect "再次输入密码: "',
|
|
'send "Strong-password-2026!\\r"',
|
|
'expect eof',
|
|
].join("\n");
|
|
const result = spawnSync("expect", ["-c", expectScript], {
|
|
cwd: root,
|
|
env: {
|
|
...process.env,
|
|
NODE_ENV: "test",
|
|
TALLYNOTE_DATA_DIR: dataDir,
|
|
TALLYNOTE_PUBLIC_ORIGIN: "http://127.0.0.1:3999",
|
|
TALLYNOTE_COOKIE_SECURE: "false",
|
|
TALLYNOTE_UPDATE_STRATEGY: "disabled",
|
|
},
|
|
encoding: "utf8",
|
|
});
|
|
expect(result.status).toBe(0);
|
|
expect(`${result.stdout}${result.stderr}`).toContain("已创建首位管理员");
|
|
expect(`${result.stdout}${result.stderr}`).toContain("Strong-password-2026!");
|
|
|
|
const database = new Database(path.join(dataDir, "tallynote.db"));
|
|
const admin = database.prepare("SELECT username, must_change_password FROM admins").get() as { username: string; must_change_password: number };
|
|
expect(admin).toEqual({ username: "manual-admin", must_change_password: 0 });
|
|
database.close();
|
|
} finally {
|
|
rmSync(dataDir, { recursive: true, force: true });
|
|
}
|
|
}, 30_000);
|
|
|
|
it("可以验证当前密码并清除旧版本遗留的首次改密标志", () => {
|
|
const dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-admin-init-"));
|
|
try {
|
|
const first = runAdmin(dataDir, ["--username", "legacy-admin", "--display-name", "旧版管理员", "--generate"]);
|
|
expect(first.status).toBe(0);
|
|
const generated = first.stdout.match(/一次性密码:([^\s]+)/)?.[1];
|
|
expect(generated).toBeTruthy();
|
|
|
|
const expectScript = [
|
|
"set timeout 15",
|
|
`spawn ${process.execPath} ${tsx} ${cli} --mark-password-configured --username legacy-admin`,
|
|
'expect "当前密码: "',
|
|
`send "${generated}\\r"`,
|
|
'expect eof',
|
|
].join("\n");
|
|
const result = spawnSync("expect", ["-c", expectScript], {
|
|
cwd: root,
|
|
env: {
|
|
...process.env,
|
|
NODE_ENV: "test",
|
|
TALLYNOTE_DATA_DIR: dataDir,
|
|
TALLYNOTE_PUBLIC_ORIGIN: "http://127.0.0.1:3999",
|
|
TALLYNOTE_COOKIE_SECURE: "false",
|
|
TALLYNOTE_UPDATE_STRATEGY: "disabled",
|
|
},
|
|
encoding: "utf8",
|
|
});
|
|
expect(result.status).toBe(0);
|
|
expect(`${result.stdout}${result.stderr}`).toContain("已确认当前密码为正式密码");
|
|
|
|
const database = new Database(path.join(dataDir, "tallynote.db"));
|
|
const admin = database.prepare("SELECT must_change_password FROM admins WHERE username_norm='legacy-admin'").get() as { must_change_password: number };
|
|
expect(admin.must_change_password).toBe(0);
|
|
database.close();
|
|
} finally {
|
|
rmSync(dataDir, { recursive: true, force: true });
|
|
}
|
|
}, 30_000);
|
|
|
|
it("密码输入不是 TTY 时明确拒绝通过管道传入", () => {
|
|
const dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-admin-init-"));
|
|
try {
|
|
const result = spawnSync(process.execPath, [tsx, cli], {
|
|
cwd: root,
|
|
input: "admin\n管理员\npassword-password\npassword-password\n",
|
|
env: {
|
|
...process.env,
|
|
NODE_ENV: "test",
|
|
TALLYNOTE_DATA_DIR: dataDir,
|
|
TALLYNOTE_PUBLIC_ORIGIN: "http://127.0.0.1:3999",
|
|
TALLYNOTE_COOKIE_SECURE: "false",
|
|
TALLYNOTE_UPDATE_STRATEGY: "disabled",
|
|
},
|
|
encoding: "utf8",
|
|
});
|
|
expect(result.status).not.toBe(0);
|
|
expect(`${result.stdout}${result.stderr}`).toContain("交互式 TTY");
|
|
expect(readFileSync(path.join(dataDir, "tallynote.db"))).toBeTruthy();
|
|
} finally {
|
|
rmSync(dataDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|