feat: 接入 AnyTLS 节点托管和用户流量同步

This commit is contained in:
2026-09-29 13:35:44 +08:00
parent 586c2399d1
commit ac5fed695c
24 changed files with 1413 additions and 185 deletions
+178
View File
@@ -0,0 +1,178 @@
// Optional integration check: SING_BOX_TEST_BINARY=/path/to/build npx tsx tests/anytls-runtime.ts
import assert from "node:assert/strict";
import { spawn, execFileSync, type ChildProcess } from "node:child_process";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import path from "node:path";
import { tmpdir } from "node:os";
import { createServer } from "node:http";
import { certificateFixture } from "./certificate-fixture";
async function main() {
const binary = process.env.SING_BOX_TEST_BINARY;
const python = process.env.AGENT_TEST_PYTHON;
if (!binary || !python)
throw new Error("Set SING_BOX_TEST_BINARY and AGENT_TEST_PYTHON");
const dir = mkdtempSync(path.join(tmpdir(), "hy2-anytls-runtime-"));
process.env.DATABASE_PATH = path.join(dir, "db.sqlite");
process.env.APP_URL = "https://panel.example.com";
const { saveCertificate } = await import("../lib/certificates");
const { createInstallation, installationForDownload, provisionNode } =
await import("../lib/provisioning");
const { decryptText } = await import("../lib/crypto");
const { syncAnyTLSNode } = await import("../lib/node-agent");
const { ensureDb } = await import("../lib/db");
const fixture = certificateFixture();
const certId = Number(
await saveCertificate("Runtime", fixture.certificate, fixture.privateKey),
);
const id = await createInstallation({
slug: "runtime",
name: "Runtime",
certificateId: certId,
protocol: "anytls",
});
const token = decryptText(
(await installationForDownload(id)).token_encrypted,
)!;
const bundle = await provisionNode(token, "prepare", {
domain: "runtime.p.xn--876a.net",
port: 39194,
listenPort: 39194,
statsPort: 39196,
controlPort: 39197,
});
assert.ok(bundle.agent && bundle.config && bundle.statsProto);
writeFileSync(path.join(dir, "server.crt"), fixture.certificate);
writeFileSync(path.join(dir, "server.key"), fixture.privateKey, {
mode: 0o600,
});
const config = JSON.parse(
bundle.config.replaceAll("/etc/hy2-panel-anytls", dir),
);
const authId = config.inbounds[0].users[0].name;
writeFileSync(path.join(dir, "server.json"), JSON.stringify(config), {
mode: 0o600,
});
writeFileSync(
path.join(dir, "client.json"),
JSON.stringify({
log: { level: "error" },
inbounds: [{ type: "mixed", listen: "127.0.0.1", listen_port: 39193 }],
outbounds: [
{
type: "anytls",
server: "127.0.0.1",
server_port: 39194,
password: config.inbounds[0].users[0].password,
tls: {
enabled: true,
server_name: "runtime.p.xn--876a.net",
certificate_path: path.join(dir, "server.crt"),
},
},
],
}),
{ mode: 0o600 },
);
const target = createServer((_req, res) => res.end("A".repeat(65536)));
await new Promise<void>((resolve) =>
target.listen(39195, "127.0.0.1", resolve),
);
const start = (file: string) =>
spawn(binary, ["run", "-c", path.join(dir, file)], { stdio: "ignore" });
let server: ChildProcess | undefined, client: ChildProcess | undefined;
const stop = async (p?: ChildProcess) => {
if (!p || p.exitCode !== null) return;
p.kill();
await new Promise<void>((resolve) => p.once("exit", () => resolve()));
};
const transfer = () =>
new Promise<number>((resolve) => {
const p = spawn(
"curl",
[
"--noproxy",
"",
"-fsS",
"--max-time",
"4",
"--socks5-hostname",
"127.0.0.1:39193",
"http://127.0.0.1:39195",
"-o",
"/dev/null",
],
{ stdio: "ignore" },
);
p.once("exit", (code) => resolve(code ?? 1));
});
try {
execFileSync(binary, ["check", "-c", path.join(dir, "server.json")], {
stdio: "ignore",
});
server = start("server.json");
client = start("client.json");
await new Promise((r) => setTimeout(r, 800));
assert.equal(await transfer(), 0, "real AnyTLS transfer");
writeFileSync(path.join(dir, "stats.proto"), bundle.statsProto);
execFileSync(python, [
"-m",
"grpc_tools.protoc",
"-I",
dir,
`--python_out=${dir}`,
path.join(dir, "stats.proto"),
]);
writeFileSync(
path.join(dir, "query.py"),
`import grpc,json,stats_pb2\nwith grpc.insecure_channel('127.0.0.1:39196') as c:\n q=c.unary_unary('/v2ray.core.app.stats.command.StatsService/QueryStats',request_serializer=stats_pb2.QueryStatsRequest.SerializeToString,response_deserializer=stats_pb2.QueryStatsResponse.FromString)\n print(json.dumps({s.name:s.value for s in q(stats_pb2.QueryStatsRequest(pattern='user>>>'),timeout=5).stat}))\n`,
);
const counters = JSON.parse(
execFileSync(python, [path.join(dir, "query.py")], { encoding: "utf8" }),
);
const tx = counters[`user>>>${authId}>>>traffic>>>downlink`],
rx = counters[`user>>>${authId}>>>traffic>>>uplink`];
assert.ok(tx >= 65536 && rx > 0);
await provisionNode(token, "complete");
await syncAnyTLSNode("runtime", bundle.agent.token, {
epoch: "native:1",
traffic: { [authId]: { tx, rx } },
streamCount: 0,
});
const db = await ensureDb();
db.prepare("UPDATE hy2_users SET enabled=0 WHERE auth_id=?").run(authId);
const disabled = await syncAnyTLSNode("runtime", bundle.agent.token, {
epoch: "native:1",
traffic: { [authId]: { tx, rx } },
streamCount: 0,
});
await stop(server);
writeFileSync(
path.join(dir, "server.json"),
JSON.stringify(disabled.config).replaceAll("/etc/hy2-panel-anytls", dir),
{ mode: 0o600 },
);
server = start("server.json");
await new Promise((r) => setTimeout(r, 500));
assert.notEqual(await transfer(), 0, "disabled credentials must fail");
console.log(
JSON.stringify({
anytlsTransfer: true,
tlsVerified: true,
perUserTx: tx,
perUserRx: rx,
disabledUserRejected: true,
}),
);
} finally {
await stop(client);
await stop(server);
await new Promise<void>((resolve) => target.close(() => resolve()));
(await ensureDb()).close();
rmSync(dir, { recursive: true, force: true });
}
}
main().catch((e) => {
console.error(e.message);
process.exitCode = 1;
});
+179 -144
View File
@@ -3,148 +3,183 @@ import { certificateFixture } from "./certificate-fixture";
import { readFileSync } from "node:fs";
import { parse } from "yaml";
test("upload wildcard certificate, download script and complete NAT node registration", async ({
page,
request,
}) => {
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(e.message));
const fixture = certificateFixture();
await page.goto("/login");
await page.getByLabel("用户名", { exact: true }).fill("ui-review");
await page.getByLabel("密码", { exact: true }).fill("ui-review-local-2026");
await page.getByRole("button", { name: "登录", exact: true }).click();
await expect(page).toHaveURL(/dashboard/);
await page.getByRole("link", { name: "证书与安装", exact: true }).click();
const upload = page.locator("details").filter({
has: page.getByRole("heading", { name: "上传证书", exact: true }),
for (const protocol of ["hy2", "anytls"] as const)
test(`upload wildcard certificate and register ${protocol} NAT node`, async ({
page,
request,
}) => {
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(e.message));
const fixture = certificateFixture();
await page.goto("/login");
await page.getByLabel("用户名", { exact: true }).fill("ui-review");
await page.getByLabel("密码", { exact: true }).fill("ui-review-local-2026");
await page.getByRole("button", { name: "登录", exact: true }).click();
await expect(page).toHaveURL(/dashboard/);
await page.getByRole("link", { name: "证书与安装", exact: true }).click();
const upload = page.locator("details").filter({
has: page.getByRole("heading", { name: "上传证书", exact: true }),
});
await upload.getByLabel("证书名称").fill("测试通配符证书");
await upload.getByLabel("CRT / 完整证书链").setInputFiles({
name: "test.crt",
mimeType: "text/plain",
buffer: Buffer.from(fixture.certificate),
});
await upload.getByLabel("KEY / 私钥").setInputFiles({
name: "test.key",
mimeType: "text/plain",
buffer: Buffer.from("invalid"),
});
await upload.getByRole("button", { name: "上传证书", exact: true }).click();
await expect(upload.getByRole("alert")).toContainText("无法解析");
await upload.getByLabel("KEY / 私钥").setInputFiles({
name: "test.key",
mimeType: "text/plain",
buffer: Buffer.from(fixture.privateKey),
});
await upload.getByRole("button", { name: "上传证书", exact: true }).click();
await expect(
page.getByText("测试通配符证书", { exact: true }).first(),
).toBeVisible();
await page.reload();
const create = page.locator("details").filter({
has: page.getByRole("heading", {
name: "生成服务器安装脚本",
exact: true,
}),
});
if ((await create.getAttribute("open")) === null)
await create.locator("summary").click();
await create.getByLabel("节点名称", { exact: true }).fill("NAT 浏览器测试");
await create.getByLabel("节点 Slug", { exact: true }).fill("nat-browser");
await create.getByLabel("代理协议", { exact: true }).selectOption(protocol);
await create
.getByLabel("托管证书", { exact: true })
.selectOption({ label: "测试通配符证书" });
await create
.getByRole("button", { name: "生成安装脚本", exact: true })
.click();
const row = page
.locator(".provision-row")
.filter({ hasText: "nat-browser" });
await expect(row).toContainText("等待安装");
const downloadPath = await row
.getByRole("link", { name: "下载 SH" })
.getAttribute("href");
expect(
(await request.get(downloadPath!, { maxRedirects: 0 })).status(),
).toBe(307);
const [download] = await Promise.all([
page.waitForEvent("download"),
row.getByRole("link", { name: "下载 SH" }).click(),
]);
const script = readFileSync((await download.path())!, "utf8");
expect(script).not.toContain("PRIVATE KEY");
expect(script).toContain("--domain");
const token = script.match(/^TOKEN='([a-f0-9]+)'$/m)![1];
const ports =
protocol === "anytls"
? {
domain: "nat.p.xn--876a.net",
port: 23456,
listenPort: 23456,
statsPort: 19090,
controlPort: 19091,
}
: {
domain: "nat.p.xn--876a.net",
hy2Port: 23456,
statsPort: 23457,
hy2ListenPort: 443,
statsListenPort: 9999,
};
expect(
(
await request.post("/api/install", {
data: { action: "prepare", ports },
})
).status(),
).toBe(400);
expect(
(await request.post("/api/install", { data: "x".repeat(5000) })).status(),
).toBe(413);
const prepare = await request.post("/api/install", {
headers: { Authorization: `Bearer ${token}` },
data: { action: "prepare", ports },
});
expect(prepare.status()).toBe(200);
expect(prepare.headers()["cache-control"]).toBe("no-store");
const bundle = await prepare.json();
expect(bundle.privateKey).toBe(fixture.privateKey);
const config = parse(bundle.config);
if (protocol === "hy2") {
expect(config.listen).toBe(":443");
expect(config.trafficStats.listen).toBe(":9999");
} else {
expect(config.inbounds[0].type).toBe("anytls");
expect(config.experimental.v2ray_api.listen).toBe("127.0.0.1:19090");
}
expect(
(
await request.post("/api/install", {
headers: { Authorization: `Bearer ${token}` },
data: { action: "complete" },
})
).status(),
).toBe(200);
await page.reload();
await expect(row).toContainText("已安装");
await expect(row.getByRole("link", { name: "下载 SH" })).toHaveCount(0);
expect(
(
await request.post("/api/install", {
headers: { Authorization: `Bearer ${token}` },
data: { action: "prepare", ports },
})
).status(),
).toBe(400);
await page.screenshot({
path: "test-results/certificates-desktop.png",
fullPage: true,
});
await page.setViewportSize({ width: 390, height: 844 });
await page.screenshot({
path: "test-results/certificates-mobile.png",
fullPage: true,
});
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth + 1,
),
).toBe(true);
await page.getByRole("link", { name: "节点管理", exact: true }).click();
const node = page
.locator(".entity-table__item")
.filter({ hasText: "nat-browser" });
await expect(node).toContainText("nat.p.xn--876a.net");
await node.locator(":scope > summary").click();
if (protocol === "hy2") {
await expect(node.getByLabel("对外端口", { exact: true })).toHaveValue(
"23456",
);
await expect(node.getByLabel("SNI", { exact: true })).toHaveValue(
"nat.p.xn--876a.net",
);
} else {
await expect(node).toContainText("节点主动上报");
await expect(node.getByLabel("节点名称 / 订阅名")).toHaveValue(
"NAT 浏览器测试",
);
const sync = await request.post("/api/agent/nat-browser/sync", {
headers: { Authorization: `Bearer ${bundle.agent.token}` },
data: { epoch: "test:1", traffic: {}, streamCount: 1 },
});
expect(sync.status()).toBe(200);
expect(
(await sync.json()).config.inbounds[0].users.length,
).toBeGreaterThan(0);
}
await expect(node.getByLabel("启用节点", { exact: true })).toBeChecked();
expect(errors).toEqual([]);
});
await upload.getByLabel("证书名称").fill("测试通配符证书");
await upload.getByLabel("CRT / 完整证书链").setInputFiles({
name: "test.crt",
mimeType: "text/plain",
buffer: Buffer.from(fixture.certificate),
});
await upload.getByLabel("KEY / 私钥").setInputFiles({
name: "test.key",
mimeType: "text/plain",
buffer: Buffer.from("invalid"),
});
await upload.getByRole("button", { name: "上传证书", exact: true }).click();
await expect(upload.getByRole("alert")).toContainText("无法解析");
await upload.getByLabel("KEY / 私钥").setInputFiles({
name: "test.key",
mimeType: "text/plain",
buffer: Buffer.from(fixture.privateKey),
});
await upload.getByRole("button", { name: "上传证书", exact: true }).click();
await expect(
page.getByText("测试通配符证书", { exact: true }).first(),
).toBeVisible();
await page.reload();
const create = page.locator("details").filter({
has: page.getByRole("heading", {
name: "生成服务器安装脚本",
exact: true,
}),
});
if ((await create.getAttribute("open")) === null)
await create.locator("summary").click();
await create.getByLabel("节点名称", { exact: true }).fill("NAT 浏览器测试");
await create.getByLabel("节点 Slug", { exact: true }).fill("nat-browser");
await create
.getByLabel("托管证书", { exact: true })
.selectOption({ label: "测试通配符证书" });
await create
.getByRole("button", { name: "生成安装脚本", exact: true })
.click();
const row = page.locator(".provision-row").filter({ hasText: "nat-browser" });
await expect(row).toContainText("等待安装");
const downloadPath = await row
.getByRole("link", { name: "下载 SH" })
.getAttribute("href");
expect((await request.get(downloadPath!, { maxRedirects: 0 })).status()).toBe(
307,
);
const [download] = await Promise.all([
page.waitForEvent("download"),
row.getByRole("link", { name: "下载 SH" }).click(),
]);
const script = readFileSync((await download.path())!, "utf8");
expect(script).not.toContain("PRIVATE KEY");
expect(script).toContain("--domain");
const token = script.match(/^TOKEN='([a-f0-9]+)'$/m)![1];
const ports = {
domain: "nat.p.xn--876a.net",
hy2Port: 23456,
statsPort: 23457,
hy2ListenPort: 443,
statsListenPort: 9999,
};
expect(
(
await request.post("/api/install", { data: { action: "prepare", ports } })
).status(),
).toBe(400);
expect(
(await request.post("/api/install", { data: "x".repeat(5000) })).status(),
).toBe(413);
const prepare = await request.post("/api/install", {
headers: { Authorization: `Bearer ${token}` },
data: { action: "prepare", ports },
});
expect(prepare.status()).toBe(200);
expect(prepare.headers()["cache-control"]).toBe("no-store");
const bundle = await prepare.json();
expect(bundle.privateKey).toBe(fixture.privateKey);
const config = parse(bundle.config);
expect(config.listen).toBe(":443");
expect(config.trafficStats.listen).toBe(":9999");
expect(
(
await request.post("/api/install", {
headers: { Authorization: `Bearer ${token}` },
data: { action: "complete" },
})
).status(),
).toBe(200);
await page.reload();
await expect(row).toContainText("已安装");
await expect(row.getByRole("link", { name: "下载 SH" })).toHaveCount(0);
expect(
(
await request.post("/api/install", {
headers: { Authorization: `Bearer ${token}` },
data: { action: "prepare", ports },
})
).status(),
).toBe(400);
await page.screenshot({
path: "test-results/certificates-desktop.png",
fullPage: true,
});
await page.setViewportSize({ width: 390, height: 844 });
await page.screenshot({
path: "test-results/certificates-mobile.png",
fullPage: true,
});
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth + 1,
),
).toBe(true);
await page.getByRole("link", { name: "节点管理", exact: true }).click();
const node = page
.locator(".entity-table__item")
.filter({ hasText: "nat-browser" });
await expect(node).toContainText("nat.p.xn--876a.net");
await node.locator(":scope > summary").click();
await expect(node.getByLabel("对外端口", { exact: true })).toHaveValue(
"23456",
);
await expect(node.getByLabel("SNI", { exact: true })).toHaveValue(
"nat.p.xn--876a.net",
);
await expect(node.getByLabel("启用节点", { exact: true })).toBeChecked();
expect(errors).toEqual([]);
});
+162
View File
@@ -0,0 +1,162 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { mkdtempSync, rmSync } from "node:fs";
import path from "node:path";
import { tmpdir } from "node:os";
import { certificateFixture } from "../certificate-fixture";
import { parse } from "yaml";
test("AnyTLS provisioning, per-user credentials, cumulative accounting and revocation", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "hy2-anytls-"));
process.env.DATABASE_PATH = path.join(dir, "db.sqlite");
process.env.APP_URL = "https://panel.example.com";
const { ensureDb } = await import("../../lib/db");
const { encryptText, decryptText } = await import("../../lib/crypto");
const { saveCertificate } = await import("../../lib/certificates");
const { createInstallation, installationForDownload, provisionNode } =
await import("../../lib/provisioning");
const { syncAnyTLSNode } = await import("../../lib/node-agent");
const { anytlsPassword } = await import("../../lib/anytls");
const { buildInstallScript } = await import("../../lib/install-script");
const {
getNodeBySlug,
listUsers,
getSubscriptionSettings,
listEnabledNodesForPolling,
} = await import("../../lib/store");
const { buildSubscriptionConfig } = await import("../../lib/subscription");
const db = await ensureDb();
try {
const fixture = certificateFixture();
const certificateId = Number(
await saveCertificate(
"Wildcard",
fixture.certificate,
fixture.privateKey,
),
);
const id = await createInstallation({
slug: "anytls-test",
name: "AnyTLS",
certificateId,
protocol: "anytls",
});
const job = await installationForDownload(id);
assert.equal(job.protocol, "anytls");
assert.match(buildInstallScript(job), /--port/);
const installToken = decryptText(job.token_encrypted)!;
const ports = {
domain: "jp2.p.xn--876a.net",
port: 51046,
listenPort: 51046,
statsPort: 19090,
controlPort: 19091,
};
await assert.rejects(
() =>
provisionNode(installToken, "prepare", {
...ports,
controlPort: 19090,
}),
/端口/,
);
const bundle = await provisionNode(installToken, "prepare", ports);
assert.ok(bundle.agent && bundle.config && bundle.agentSource);
assert.equal(bundle.agent.slug, "anytls-test");
assert.match(bundle.agentSource, /reset=False/);
const initial = JSON.parse(bundle.config);
assert.equal(initial.inbounds[0].type, "anytls");
assert.equal(initial.inbounds[0].listen_port, 51046);
assert.equal(initial.experimental.v2ray_api.listen, "127.0.0.1:19090");
const user = (await listUsers())[0];
const password = anytlsPassword(user);
assert.equal(initial.inbounds[0].users[0].password, password);
assert.notEqual(password, decryptText(user.auth_secret_encrypted));
await provisionNode(installToken, "complete");
const node = (await getNodeBySlug(job.slug))!;
assert.equal(node.node_type, "anytls");
assert.equal((await listEnabledNodesForPolling()).length, 0);
const subscription = parse(
buildSubscriptionConfig({
user,
nodes: [node],
settings: await getSubscriptionSettings(),
}),
);
assert.equal(subscription.proxies[0].type, "anytls");
assert.equal(subscription.proxies[0].password, password);
assert.equal(subscription.proxies[0]["skip-cert-verify"], false);
const report = {
epoch: "boot:123:456",
traffic: { [user.auth_id]: { tx: 1000, rx: 500 } },
streamCount: 2,
};
await assert.rejects(
() => syncAnyTLSNode(job.slug, "0".repeat(64), report),
/凭据/,
);
await assert.rejects(
() =>
syncAnyTLSNode(job.slug, bundle.agent.token, {
...report,
traffic: { [user.auth_id]: { tx: -1, rx: 2 } },
}),
/无效/,
);
await syncAnyTLSNode(job.slug, bundle.agent.token, report);
await syncAnyTLSNode(job.slug, bundle.agent.token, report);
let refreshed = (await listUsers())[0];
assert.equal(refreshed.used_tx_bytes, 1000);
assert.equal(refreshed.used_rx_bytes, 500);
await syncAnyTLSNode(job.slug, bundle.agent.token, {
...report,
traffic: { [user.auth_id]: { tx: 900, rx: 400 } },
});
refreshed = (await listUsers())[0];
assert.equal(refreshed.used_tx_bytes, 1000);
await syncAnyTLSNode(job.slug, bundle.agent.token, {
...report,
epoch: "boot:124:500",
traffic: { [user.auth_id]: { tx: 100, rx: 50 } },
});
refreshed = (await listUsers())[0];
assert.equal(refreshed.used_tx_bytes, 1100);
// Make this account an ordinary user to exercise quota/expiry/disable rules.
db.prepare(
"UPDATE hy2_users SET is_admin=0,traffic_limit_bytes=1000 WHERE id=?",
).run(user.id);
const capped = await syncAnyTLSNode(job.slug, bundle.agent.token, report);
assert.equal(capped.config.inbounds[0].users.length, 0);
db.prepare(
"UPDATE hy2_users SET traffic_limit_bytes=NULL,expires_at='2000-01-01' WHERE id=?",
).run(user.id);
assert.equal(
(await syncAnyTLSNode(job.slug, bundle.agent.token, report)).config
.inbounds[0].users.length,
0,
);
db.prepare("UPDATE hy2_users SET expires_at=NULL,enabled=0 WHERE id=?").run(
user.id,
);
assert.equal(
(await syncAnyTLSNode(job.slug, bundle.agent.token, report)).config
.inbounds[0].users.length,
0,
);
db.prepare(
"UPDATE hy2_users SET enabled=1,auth_secret_encrypted=? WHERE id=?",
).run(encryptText("new-password"), user.id);
const updated = await syncAnyTLSNode(job.slug, bundle.agent.token, report);
assert.notEqual(updated.config.inbounds[0].users[0].password, password);
db.prepare("UPDATE hy2_nodes SET enabled=0 WHERE id=?").run(node.id);
assert.equal(
(await syncAnyTLSNode(job.slug, bundle.agent.token, report)).config
.inbounds[0].users.length,
0,
);
assert.deepEqual(db.pragma("foreign_key_check"), []);
} finally {
db.close();
rmSync(dir, { recursive: true, force: true });
}
});
+9 -5
View File
@@ -25,7 +25,11 @@ function snapshot(db: Database.Database) {
)
.filter(
({ name }) =>
!["managed_certificates", "node_installations"].includes(name),
![
"managed_certificates",
"node_installations",
"node_agent_counters",
].includes(name),
)
.map(({ name }) => [name, db.prepare(`SELECT * FROM "${name}"`).all()]),
);
@@ -45,7 +49,7 @@ test("fresh database exactly matches the inspected remote v4 column definitions"
const fresh = new Database(":memory:");
const remote = remoteDb();
migrateDatabase(fresh);
assert.equal(fresh.pragma("user_version", { simple: true }), 5);
assert.equal(fresh.pragma("user_version", { simple: true }), 6);
for (const { name } of remote
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
.all() as { name: string }[]) {
@@ -98,7 +102,7 @@ for (const existing of [
if (!existing.includes(field)) delete row[field];
}
assert.deepEqual(after, before);
assert.equal(db.pragma("user_version", { simple: true }), 5);
assert.equal(db.pragma("user_version", { simple: true }), 6);
db.close();
});
}
@@ -128,8 +132,8 @@ test("failed migrations roll back all statements and future schemas are not down
db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all(),
[{ name: "hy2_users" }],
);
db.pragma("user_version = 6");
db.pragma("user_version = 7");
assert.throws(() => migrateDatabase(db), /版本/);
assert.equal(db.pragma("user_version", { simple: true }), 6);
assert.equal(db.pragma("user_version", { simple: true }), 7);
db.close();
});