feat: 支持自定义订阅节点并兼容现有数据库
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
import { test, expect } from "./e2e-fixtures";
|
||||
import { parse } from "yaml";
|
||||
|
||||
test("custom YAML nodes use compact forms, persist edits and integrate with subscription groups", async ({
|
||||
page,
|
||||
}) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
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: "节点管理" }).click();
|
||||
const create = page
|
||||
.locator(".compact-create-panel details")
|
||||
.filter({ has: page.getByRole("heading", { name: "新增自定义订阅节点" }) });
|
||||
await create.locator(":scope > summary").click();
|
||||
await create.getByLabel("Slug", { exact: true }).fill("custom-browser");
|
||||
await create.getByLabel("显示名", { exact: true }).fill("香港自定义节点");
|
||||
await create.getByRole("textbox", { name: "节点 YAML", exact: true }).fill("name: [invalid");
|
||||
await create.getByRole("button", { name: "创建自定义节点" }).click();
|
||||
await expect(create.getByRole("alert")).toContainText("YAML 格式错误");
|
||||
await expect(create.getByRole("textbox", { name: "节点 YAML", exact: true })).toHaveValue(
|
||||
"name: [invalid",
|
||||
);
|
||||
const source =
|
||||
'# keep this comment\nproxies:\n - name: \'HK "AnyTLS": #1\'\n type: anytls\n server: hk.example.com\n port: 443\n password: "test-only:password"\n udp: true\n';
|
||||
await create.getByRole("textbox", { name: "节点 YAML", exact: true }).fill(source);
|
||||
await create.getByRole("button", { name: "创建自定义节点" }).click();
|
||||
const row = page
|
||||
.locator(".entity-table__item")
|
||||
.filter({ hasText: "custom-browser" });
|
||||
await expect(row).toBeVisible();
|
||||
await expect(row).toContainText("不轮询");
|
||||
await row.locator(":scope > summary").click();
|
||||
await expect(row.getByRole("textbox", { name: "节点 YAML", exact: true })).toHaveValue(
|
||||
source,
|
||||
);
|
||||
await expect(row.getByLabel("客户端地址", { exact: true })).toHaveCount(0);
|
||||
const updated = source.replace('HK "AnyTLS": #1', 'HK "AnyTLS": #2');
|
||||
await row.getByRole("textbox", { name: "节点 YAML", exact: true }).fill(updated);
|
||||
await row.getByRole("button", { name: "保存自定义节点" }).click();
|
||||
await expect(row.locator("p[role=status]")).toHaveText("已保存");
|
||||
await page.reload();
|
||||
await row.locator(":scope > summary").click();
|
||||
await expect(
|
||||
row.getByRole("textbox", { name: "节点 YAML", exact: true }),
|
||||
).toHaveValue(updated);
|
||||
await page.screenshot({
|
||||
path: "test-results/custom-node-desktop.png",
|
||||
fullPage: true,
|
||||
});
|
||||
const config = await page.request.get(
|
||||
"/api/admin/nodes/custom-browser/config",
|
||||
);
|
||||
expect(config.status()).toBe(409);
|
||||
const auth = await page.request.post("/api/hy2/nodes/custom-browser/auth", {
|
||||
data: { auth: "test:test" },
|
||||
});
|
||||
expect(await auth.json()).toMatchObject({ ok: false });
|
||||
|
||||
await page.getByRole("link", { name: "订阅配置" }).click();
|
||||
const main = page.locator(".proxy-group-card").first();
|
||||
await expect(main.locator(".proxy-sort-item")).toContainText(
|
||||
'HK "AnyTLS": #2',
|
||||
);
|
||||
const createGroup = page
|
||||
.locator("details")
|
||||
.filter({ has: page.locator("summary", { hasText: "新建分组" }) });
|
||||
await createGroup.locator(":scope > summary").click();
|
||||
await createGroup.getByLabel("新建分组名").fill("Custom Work");
|
||||
await createGroup.getByRole("button", { name: /HK.*添加/ }).click();
|
||||
await createGroup
|
||||
.getByRole("button", { name: "新建分组", exact: true })
|
||||
.click();
|
||||
await expect(
|
||||
page.locator("summary strong", { hasText: "Custom Work" }),
|
||||
).toBeVisible();
|
||||
const url = await page.locator(".copy-line code").first().textContent();
|
||||
const response = await page.request.get(url!);
|
||||
expect(response.status()).toBe(200);
|
||||
const subscription = parse(await response.text());
|
||||
const proxy = subscription.proxies.find(
|
||||
(p: { type: string }) => p.type === "anytls",
|
||||
);
|
||||
expect(proxy).toMatchObject({
|
||||
name: 'HK "AnyTLS": #2',
|
||||
server: "hk.example.com",
|
||||
password: "test-only:password",
|
||||
udp: true,
|
||||
});
|
||||
expect(
|
||||
subscription["proxy-groups"].find(
|
||||
(g: { name: string }) => g.name === "Custom Work",
|
||||
).proxies,
|
||||
).toEqual([proxy.name]);
|
||||
const group = page
|
||||
.locator("details")
|
||||
.filter({
|
||||
has: page.locator("summary strong", { hasText: "Custom Work" }),
|
||||
});
|
||||
await group.locator(":scope > summary").click();
|
||||
await group.getByRole("button", { name: "删除分组" }).click();
|
||||
await page.getByRole("button", { name: "确认删除分组" }).click();
|
||||
await expect(group).toHaveCount(0);
|
||||
|
||||
await page.getByRole("link", { name: "节点管理" }).click();
|
||||
await page.getByRole("searchbox").fill("custom");
|
||||
await expect(page.locator(".entity-table__item:visible")).toHaveCount(1);
|
||||
await row.locator(":scope > summary").click();
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
|
||||
)
|
||||
.toBe(true);
|
||||
await page.screenshot({
|
||||
path: "test-results/custom-node-mobile.png",
|
||||
fullPage: true,
|
||||
});
|
||||
await row.getByLabel("启用节点", { exact: true }).uncheck();
|
||||
await row.getByRole("button", { name: "保存自定义节点" }).click();
|
||||
await expect(row.locator("p[role=status]")).toHaveText("已保存");
|
||||
const disabled = parse(await (await page.request.get(url!)).text());
|
||||
expect(
|
||||
disabled.proxies.some((p: { type: string }) => p.type === "anytls"),
|
||||
).toBe(false);
|
||||
await row.getByRole("button", { name: "删除节点" }).click();
|
||||
await page.getByRole("button", { name: "确认删除节点" }).click();
|
||||
await expect(row).toHaveCount(0);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { test as base, expect } from "@playwright/test";
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
export const test = base.extend<{ isolatedData: void }>({
|
||||
isolatedData: [async ({}, use) => {
|
||||
const file = process.env.HY2_E2E_DATABASE;
|
||||
if (!file || !/^\/tmp\/hy2-panel-ui-test-\d+\.db$/.test(file)) throw new Error("Refusing to reset a non-test database");
|
||||
const db = new Database(file, { fileMustExist: true });
|
||||
db.pragma("foreign_keys = ON");
|
||||
db.transaction(() => {
|
||||
db.exec("DELETE FROM hy2_nodes; DELETE FROM hy2_users WHERE is_admin = 0;");
|
||||
db.prepare("UPDATE app_settings SET value = '[]' WHERE key IN ('main_proxy_group_member_slugs_json','proxy_groups_json')").run();
|
||||
})();
|
||||
db.close();
|
||||
await use();
|
||||
}, { auto: true }],
|
||||
});
|
||||
export { expect };
|
||||
Vendored
+124
@@ -0,0 +1,124 @@
|
||||
-- Schema only, inspected on us3 on 2026-09-29. No production data.
|
||||
CREATE TABLE admins (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE admin_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
admin_id INTEGER NOT NULL,
|
||||
session_hash TEXT NOT NULL UNIQUE,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
last_seen_at TEXT NOT NULL,
|
||||
FOREIGN KEY (admin_id) REFERENCES admins(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE hy2_users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
auth_id TEXT NOT NULL UNIQUE,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
expires_at TEXT,
|
||||
traffic_limit_bytes INTEGER,
|
||||
used_tx_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
used_rx_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
, is_admin INTEGER NOT NULL DEFAULT 0, auth_secret_encrypted TEXT, subscription_token TEXT, subscription_label TEXT NOT NULL DEFAULT '');
|
||||
CREATE TABLE hy2_nodes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
auth_token TEXT NOT NULL,
|
||||
traffic_stats_url TEXT NOT NULL,
|
||||
traffic_stats_secret TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
poll_interval_seconds INTEGER NOT NULL DEFAULT 15,
|
||||
last_polled_at TEXT,
|
||||
last_sync_ok_at TEXT,
|
||||
last_error_at TEXT,
|
||||
last_error_message TEXT,
|
||||
last_online_users INTEGER NOT NULL DEFAULT 0,
|
||||
last_stream_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
, server_host TEXT NOT NULL DEFAULT '', server_port INTEGER NOT NULL DEFAULT 443, client_name TEXT NOT NULL DEFAULT '', bandwidth_up_mbps INTEGER NOT NULL DEFAULT 30, bandwidth_down_mbps INTEGER NOT NULL DEFAULT 30, skip_cert_verify INTEGER NOT NULL DEFAULT 0, sni TEXT, traffic_stats_listen TEXT NOT NULL DEFAULT ':9999', hy2_listen TEXT NOT NULL DEFAULT ':443', node_type TEXT NOT NULL DEFAULT 'hy2', custom_config_yaml TEXT NOT NULL DEFAULT '');
|
||||
CREATE TABLE traffic_ledgers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
node_id INTEGER NOT NULL,
|
||||
user_id INTEGER,
|
||||
auth_id TEXT NOT NULL,
|
||||
tx_bytes INTEGER NOT NULL,
|
||||
rx_bytes INTEGER NOT NULL,
|
||||
window_started_at TEXT NOT NULL,
|
||||
window_ended_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (node_id) REFERENCES hy2_nodes(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES hy2_users(id) ON DELETE SET NULL
|
||||
);
|
||||
CREATE TABLE node_sync_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
node_id INTEGER NOT NULL,
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT NOT NULL,
|
||||
ok INTEGER NOT NULL,
|
||||
traffic_entries INTEGER NOT NULL DEFAULT 0,
|
||||
online_users INTEGER NOT NULL DEFAULT 0,
|
||||
stream_count INTEGER NOT NULL DEFAULT 0,
|
||||
kicked_auth_ids TEXT NOT NULL DEFAULT '',
|
||||
error_message TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (node_id) REFERENCES hy2_nodes(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE auth_audits (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
node_id INTEGER,
|
||||
user_id INTEGER,
|
||||
auth_id TEXT,
|
||||
username TEXT,
|
||||
addr TEXT,
|
||||
requested_tx INTEGER,
|
||||
ok INTEGER NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (node_id) REFERENCES hy2_nodes(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES hy2_users(id) ON DELETE SET NULL
|
||||
);
|
||||
CREATE TABLE node_user_presence (
|
||||
node_id INTEGER NOT NULL,
|
||||
user_id INTEGER,
|
||||
auth_id TEXT NOT NULL,
|
||||
connections INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (node_id, auth_id),
|
||||
FOREIGN KEY (node_id) REFERENCES hy2_nodes(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES hy2_users(id) ON DELETE SET NULL
|
||||
);
|
||||
CREATE INDEX idx_hy2_users_auth_id ON hy2_users(auth_id);
|
||||
CREATE INDEX idx_hy2_users_username ON hy2_users(username);
|
||||
CREATE INDEX idx_hy2_nodes_enabled ON hy2_nodes(enabled);
|
||||
CREATE INDEX idx_traffic_ledgers_auth_id ON traffic_ledgers(auth_id);
|
||||
CREATE INDEX idx_traffic_ledgers_created_at ON traffic_ledgers(created_at);
|
||||
CREATE INDEX idx_node_sync_records_node_id ON node_sync_records(node_id, created_at DESC);
|
||||
CREATE INDEX idx_auth_audits_created_at ON auth_audits(created_at DESC);
|
||||
CREATE TABLE user_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
session_hash TEXT NOT NULL UNIQUE,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
last_seen_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES hy2_users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_hy2_users_subscription_token ON hy2_users(subscription_token);
|
||||
CREATE INDEX idx_user_sessions_user_id ON user_sessions(user_id);
|
||||
PRAGMA user_version = 4;
|
||||
+11
-9
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { test, expect } from "./e2e-fixtures";
|
||||
|
||||
test("compact management flows, drag persistence, filters and mobile layout", async ({
|
||||
page,
|
||||
@@ -22,7 +22,11 @@ test("compact management flows, drag persistence, filters and mobile layout", as
|
||||
["singapore", "Singapore"],
|
||||
["frankfurt", "Frankfurt"],
|
||||
]) {
|
||||
const create = page.locator(".compact-create-panel details");
|
||||
const create = page
|
||||
.locator(".compact-create-panel details")
|
||||
.filter({
|
||||
has: page.getByRole("heading", { name: "新增 HY2 节点", exact: true }),
|
||||
});
|
||||
if ((await create.getAttribute("open")) === null)
|
||||
await create.locator("summary").click();
|
||||
await create.getByLabel("Slug", { exact: true }).fill(slug);
|
||||
@@ -158,13 +162,11 @@ test("compact management flows, drag persistence, filters and mobile layout", as
|
||||
"dashboard",
|
||||
]) {
|
||||
await page.goto(`/admin/${route}`);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
await expect.poll(() => page.evaluate(() => ({
|
||||
width: document.documentElement.scrollWidth,
|
||||
viewport: innerWidth,
|
||||
overflowing: [...document.querySelectorAll("body *")].filter(el => el.getBoundingClientRect().right > innerWidth && getComputedStyle(el).position !== "absolute").slice(0, 6).map(el => ({ tag: el.tagName, class: el.className, right: el.getBoundingClientRect().right })),
|
||||
}))).toMatchObject({ width: 390, viewport: 390 });
|
||||
await page.screenshot({
|
||||
path: `test-results/${route}-mobile.png`,
|
||||
fullPage: true,
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { parse, stringify } from "yaml";
|
||||
import {
|
||||
parseCustomProxyYaml,
|
||||
renderCustomProxyYaml,
|
||||
extractProxyNameFromYaml,
|
||||
} from "../../lib/custom-proxy";
|
||||
import { buildSubscriptionConfig } from "../../lib/subscription";
|
||||
import { encryptText } from "../../lib/crypto";
|
||||
import type { Hy2NodeRow, Hy2UserRow } from "../../lib/store";
|
||||
|
||||
const proxy = {
|
||||
name: 'HK "one": #香港',
|
||||
type: "anytls",
|
||||
server: "hk.example.com",
|
||||
port: 443,
|
||||
password: 'test:p"ass#word',
|
||||
udp: true,
|
||||
alpn: ["h2", "http/1.1"],
|
||||
};
|
||||
for (const [name, source] of Object.entries({
|
||||
object: stringify(proxy),
|
||||
sequence: stringify([proxy]),
|
||||
wrapper: stringify({ proxies: [proxy] }),
|
||||
flow: JSON.stringify(proxy),
|
||||
})) {
|
||||
test(`accepts ${name} and preserves protocol fields and quoted names on output`, () => {
|
||||
assert.deepEqual(parseCustomProxyYaml(source), proxy);
|
||||
assert.equal(extractProxyNameFromYaml(source), proxy.name);
|
||||
assert.deepEqual(parse(renderCustomProxyYaml(source)), [proxy]);
|
||||
});
|
||||
}
|
||||
for (const value of [
|
||||
"",
|
||||
"[]",
|
||||
"[a, b]",
|
||||
"- name: [bad",
|
||||
"name: X\nname: Y\ntype: anytls",
|
||||
"name: 123\ntype: anytls",
|
||||
"name: X",
|
||||
"name: X\ntype: anytls\npassword: *missing",
|
||||
"proxies:\n - name: X\n type: anytls\n - name: Y\n type: anytls",
|
||||
]) {
|
||||
test(`rejects malformed or multiple proxies: ${JSON.stringify(value).slice(0, 40)}`, () =>
|
||||
assert.throws(() => parseCustomProxyYaml(value)));
|
||||
}
|
||||
|
||||
test("mixed subscriptions preserve order, names, group references, credentials and disabled filtering", () => {
|
||||
const custom = {
|
||||
id: 2,
|
||||
slug: "custom",
|
||||
node_type: "custom",
|
||||
name: "Display name",
|
||||
client_name: "Old fallback",
|
||||
custom_config_yaml: stringify({ proxies: [proxy] }),
|
||||
enabled: 1,
|
||||
} as Hy2NodeRow;
|
||||
const hy2 = {
|
||||
id: 1,
|
||||
slug: "hy2",
|
||||
node_type: "hy2",
|
||||
name: "Tokyo",
|
||||
client_name: 'Tokyo "HY2"',
|
||||
server_host: "jp.example.com",
|
||||
server_port: 443,
|
||||
enabled: 1,
|
||||
custom_config_yaml: "",
|
||||
bandwidth_up_mbps: 30,
|
||||
bandwidth_down_mbps: 30,
|
||||
} as Hy2NodeRow;
|
||||
const user = {
|
||||
username: "member",
|
||||
auth_secret_encrypted: encryptText('test:abc"def'),
|
||||
} as Hy2UserRow;
|
||||
const settings = {
|
||||
template: "mode: rule",
|
||||
profileName: 'Profile: "1"',
|
||||
extraProxyGroups: "",
|
||||
mainProxyGroupMemberSlugs: ["custom", "hy2"],
|
||||
proxyGroups: [
|
||||
{
|
||||
id: "work",
|
||||
name: 'Work: "1"',
|
||||
type: "select" as const,
|
||||
memberSlugs: ["custom", "disabled", "hy2"],
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = parse(
|
||||
buildSubscriptionConfig({
|
||||
user,
|
||||
nodes: [hy2, custom, { ...custom, slug: "disabled", enabled: 0 }],
|
||||
settings,
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(result.proxies[0], proxy);
|
||||
assert.equal(result.proxies[1].password, 'member:test:abc"def');
|
||||
assert.equal(result.proxies[1].name, hy2.client_name);
|
||||
assert.deepEqual(
|
||||
result["proxy-groups"].map((group: { proxies: string[] }) => group.proxies),
|
||||
[
|
||||
[proxy.name, hy2.client_name],
|
||||
[proxy.name, hy2.client_name],
|
||||
],
|
||||
);
|
||||
assert.equal(result["proxy-groups"][0].name, settings.profileName);
|
||||
assert.equal(result["proxy-groups"][1].name, settings.proxyGroups[0].name);
|
||||
assert.equal(result.proxies.length, 2);
|
||||
// Custom-only subscriptions do not require HY2 credentials.
|
||||
assert.deepEqual(
|
||||
parse(
|
||||
buildSubscriptionConfig({
|
||||
user: { ...user, auth_secret_encrypted: null },
|
||||
nodes: [custom],
|
||||
settings,
|
||||
}),
|
||||
).proxies,
|
||||
[proxy],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
import Database from "better-sqlite3";
|
||||
import { migrateDatabase } from "../../lib/db-migrations";
|
||||
|
||||
const remoteSchema = readFileSync(
|
||||
"tests/fixtures/remote-v4-schema.sql",
|
||||
"utf8",
|
||||
);
|
||||
function remoteDb() {
|
||||
const db = new Database(":memory:");
|
||||
db.pragma("foreign_keys = ON");
|
||||
db.exec(remoteSchema);
|
||||
return db;
|
||||
}
|
||||
function snapshot(db: Database.Database) {
|
||||
return Object.fromEntries(
|
||||
(
|
||||
db
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name",
|
||||
)
|
||||
.all() as { name: string }[]
|
||||
).map(({ name }) => [name, db.prepare(`SELECT * FROM "${name}"`).all()]),
|
||||
);
|
||||
}
|
||||
function seed(db: Database.Database) {
|
||||
db.exec(`INSERT INTO hy2_users (id,auth_id,username,password_hash,notes,created_at,updated_at,subscription_token,auth_secret_encrypted,traffic_limit_bytes,used_tx_bytes,used_rx_bytes)
|
||||
VALUES (42,'original-auth','member','test-hash','keep notes','2026-01-01','2026-01-01','test-sub-token','test-encrypted-value',4096,128,256);
|
||||
INSERT INTO hy2_nodes (id,slug,name,auth_token,traffic_stats_url,traffic_stats_secret,created_at,updated_at,server_host)
|
||||
VALUES (17,'existing-hy2','Existing HY2','test-auth-token','http://stats.example:9999','test-stats-secret','2026-01-01','2026-01-01','existing.example');
|
||||
INSERT INTO traffic_ledgers (node_id,user_id,auth_id,tx_bytes,rx_bytes,window_started_at,window_ended_at,created_at) VALUES (17,42,'original-auth',128,256,'2026-01-01','2026-01-01','2026-01-01');
|
||||
INSERT INTO user_sessions (id,user_id,session_hash,expires_at,created_at,last_seen_at) VALUES ('session',42,'test-session-hash','2030-01-01','2026-01-01','2026-01-01');
|
||||
INSERT INTO app_settings VALUES ('main_proxy_group_member_slugs_json','["legacy-custom","existing-hy2"]','2026-01-01');
|
||||
INSERT INTO app_settings VALUES ('proxy_groups_json','[{"id":"group-id","name":"Work","type":"select","memberSlugs":["legacy-custom"]}]','2026-01-01');`);
|
||||
}
|
||||
|
||||
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 }), 4);
|
||||
for (const { name } of remote
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
.all() as { name: string }[]) {
|
||||
assert.deepEqual(
|
||||
fresh.pragma(`table_info(${name})`),
|
||||
remote.pragma(`table_info(${name})`),
|
||||
);
|
||||
}
|
||||
fresh.close();
|
||||
remote.close();
|
||||
});
|
||||
|
||||
test("remote v4: repeated migration preserves every row, ID, secret, quota, session and group", () => {
|
||||
const db = remoteDb();
|
||||
seed(db);
|
||||
const source = `# legacy source stays byte-for-byte\n- name: "AnyTLS #1"\n type: anytls\n password: "test: password"\n server: hk.example.com\n port: 443\n`;
|
||||
db.prepare(
|
||||
`INSERT INTO hy2_nodes (id,slug,name,auth_token,traffic_stats_url,traffic_stats_secret,created_at,updated_at,node_type,custom_config_yaml,client_name) VALUES (18,'legacy-custom','Legacy','test-token','','test-secret','2026-01-01','2026-01-01','custom',?,'Old fallback')`,
|
||||
).run(source);
|
||||
const before = snapshot(db);
|
||||
migrateDatabase(db);
|
||||
migrateDatabase(db);
|
||||
assert.deepEqual(snapshot(db), before);
|
||||
assert.deepEqual(db.pragma("foreign_key_check"), []);
|
||||
db.close();
|
||||
});
|
||||
|
||||
for (const existing of [
|
||||
[],
|
||||
["node_type"],
|
||||
["custom_config_yaml"],
|
||||
["node_type", "custom_config_yaml"],
|
||||
]) {
|
||||
test(`v3 upgrade with existing columns [${existing.join(", ")}] preserves data`, () => {
|
||||
const db = remoteDb();
|
||||
seed(db);
|
||||
for (const field of ["node_type", "custom_config_yaml"])
|
||||
if (!existing.includes(field))
|
||||
db.exec(`ALTER TABLE hy2_nodes DROP COLUMN ${field}`);
|
||||
db.pragma("user_version = 3");
|
||||
const before = snapshot(db);
|
||||
migrateDatabase(db);
|
||||
migrateDatabase(db);
|
||||
const after = snapshot(db);
|
||||
// New fields have compatible defaults; every pre-existing field is unchanged.
|
||||
for (const row of after.hy2_nodes as Record<string, unknown>[]) {
|
||||
assert.equal(row.node_type, "hy2");
|
||||
assert.equal(row.custom_config_yaml, "");
|
||||
for (const field of ["node_type", "custom_config_yaml"])
|
||||
if (!existing.includes(field)) delete row[field];
|
||||
}
|
||||
assert.deepEqual(after, before);
|
||||
assert.equal(db.pragma("user_version", { simple: true }), 4);
|
||||
db.close();
|
||||
});
|
||||
}
|
||||
|
||||
test("repairs interrupted v4 without duplicate-column errors", () => {
|
||||
const db = remoteDb();
|
||||
seed(db);
|
||||
db.exec("ALTER TABLE hy2_nodes DROP COLUMN custom_config_yaml");
|
||||
migrateDatabase(db);
|
||||
assert.equal(
|
||||
(
|
||||
db.prepare("SELECT custom_config_yaml FROM hy2_nodes").get() as {
|
||||
custom_config_yaml: string;
|
||||
}
|
||||
).custom_config_yaml,
|
||||
"",
|
||||
);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test("failed migrations roll back all statements and future schemas are not downgraded", () => {
|
||||
const db = new Database(":memory:");
|
||||
db.exec("CREATE TABLE hy2_users(id INTEGER)");
|
||||
assert.throws(() => migrateDatabase(db));
|
||||
assert.equal(db.pragma("user_version", { simple: true }), 0);
|
||||
assert.deepEqual(
|
||||
db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all(),
|
||||
[{ name: "hy2_users" }],
|
||||
);
|
||||
db.pragma("user_version = 5");
|
||||
assert.throws(() => migrateDatabase(db), /版本/);
|
||||
assert.equal(db.pragma("user_version", { simple: true }), 5);
|
||||
db.close();
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { test } from "node:test";
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
// Start with the real deployed schema rather than the new migration output.
|
||||
test("existing v4 boots and custom nodes stay outside HY2 polling/auth while retaining credentials", async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "hy2-custom-store-"));
|
||||
process.env.DATABASE_PATH = path.join(dir, "remote-v4.db");
|
||||
process.env.ADMIN_USERNAME = "test-admin";
|
||||
process.env.ADMIN_PASSWORD = "test-admin-local-only";
|
||||
process.env.POLLER_ENABLED = "false";
|
||||
const legacy = new Database(process.env.DATABASE_PATH);
|
||||
legacy.exec(readFileSync("tests/fixtures/remote-v4-schema.sql", "utf8"));
|
||||
legacy.exec(`INSERT INTO hy2_nodes (id,slug,name,auth_token,traffic_stats_url,traffic_stats_secret,created_at,updated_at,server_host)
|
||||
VALUES (91,'existing-hy2','Existing HY2','test-auth','http://stats.example:9999','test-stats','2026-01-01','2026-01-01','hy2.example');`);
|
||||
const yaml =
|
||||
'# original comment\n- name: "Remote Custom"\n type: anytls\n password: "test-pass"\n server: custom.example\n port: 443\n';
|
||||
legacy
|
||||
.prepare(
|
||||
`INSERT INTO hy2_nodes (id,slug,name,auth_token,traffic_stats_url,traffic_stats_secret,created_at,updated_at,node_type,custom_config_yaml,client_name)
|
||||
VALUES (92,'existing-custom','Display','test-original-auth','','test-original-stats','2026-01-01','2026-01-01','custom',?,'Old alias')`,
|
||||
)
|
||||
.run(yaml);
|
||||
legacy.exec(
|
||||
`INSERT INTO app_settings VALUES ('main_proxy_group_member_slugs_json','["existing-custom","existing-hy2"]','2026-01-01')`,
|
||||
);
|
||||
const before = legacy.prepare("SELECT * FROM hy2_nodes ORDER BY id").all();
|
||||
legacy.close();
|
||||
const { ensureDb } = await import("../../lib/db");
|
||||
const store = await import("../../lib/store");
|
||||
const { verifyNodeToken } = await import("../../lib/hy2");
|
||||
const db = await ensureDb();
|
||||
try {
|
||||
assert.deepEqual(
|
||||
db.prepare("SELECT * FROM hy2_nodes ORDER BY id").all(),
|
||||
before,
|
||||
);
|
||||
assert.deepEqual(
|
||||
(await store.getSubscriptionSettings()).mainProxyGroupMemberSlugs,
|
||||
["existing-custom", "existing-hy2"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
(await store.listEnabledNodesForPolling()).map((node) => node.id),
|
||||
[91],
|
||||
);
|
||||
assert.equal((await store.listSubscriptionNodes()).length, 2);
|
||||
assert.equal(
|
||||
(await verifyNodeToken("existing-custom", "test-original-auth")).reason,
|
||||
"node_not_hy2",
|
||||
);
|
||||
assert.equal((await verifyNodeToken("existing-hy2", "test-auth")).ok, true);
|
||||
const custom = (await store.getNodeBySlug("existing-custom"))!;
|
||||
assert.equal(store.getNodeProxyName(custom), "Remote Custom");
|
||||
const input = {
|
||||
nodeType: "custom" as const,
|
||||
slug: "existing-custom",
|
||||
name: "Display",
|
||||
authToken: "",
|
||||
hy2Listen: "",
|
||||
serverHost: "",
|
||||
serverPort: 443,
|
||||
clientName: "",
|
||||
bandwidthUpMbps: 30,
|
||||
bandwidthDownMbps: 30,
|
||||
skipCertVerify: false,
|
||||
sni: null,
|
||||
customConfigYaml: yaml.replace("Remote Custom", "Updated Custom"),
|
||||
trafficStatsUrl: "",
|
||||
trafficStatsListen: ":9999",
|
||||
trafficStatsSecret: "",
|
||||
enabled: true,
|
||||
pollIntervalSeconds: 15,
|
||||
};
|
||||
await store.updateNode({ ...input, id: custom.id });
|
||||
const saved = (await store.getNodeBySlug("existing-custom"))!;
|
||||
assert.equal(saved.id, 92);
|
||||
assert.equal(saved.custom_config_yaml, input.customConfigYaml);
|
||||
assert.equal(saved.auth_token, "test-original-auth");
|
||||
assert.equal(saved.traffic_stats_secret, "test-original-stats");
|
||||
assert.equal(store.getNodeProxyName(saved), "Updated Custom");
|
||||
await assert.rejects(() =>
|
||||
store.updateNode({
|
||||
...input,
|
||||
id: 92,
|
||||
customConfigYaml: "name: [invalid",
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(await store.getNodeBySlug("existing-custom"), saved);
|
||||
await store.updateNode({ ...input, id: 92, enabled: false });
|
||||
assert.deepEqual(
|
||||
(await store.listSubscriptionNodes()).map((node) => node.id),
|
||||
[91],
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
global.__hy2PanelDb = undefined;
|
||||
global.__hy2PanelDbInit = undefined;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user