befly 3.9.40 → 3.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -8
- package/befly.config.ts +19 -2
- package/checks/checkApi.ts +79 -77
- package/checks/checkHook.ts +48 -0
- package/checks/checkMenu.ts +168 -0
- package/checks/checkPlugin.ts +48 -0
- package/checks/checkTable.ts +137 -183
- package/docs/README.md +1 -1
- package/docs/api/api.md +1 -1
- package/docs/guide/quickstart.md +16 -9
- package/docs/hooks/hook.md +2 -2
- package/docs/hooks/rateLimit.md +1 -1
- package/docs/infra/redis.md +7 -7
- package/docs/plugins/plugin.md +23 -21
- package/docs/quickstart.md +16 -9
- package/docs/reference/addon.md +12 -1
- package/docs/reference/config.md +13 -30
- package/docs/reference/sync.md +62 -193
- package/docs/reference/table.md +27 -29
- package/hooks/auth.ts +3 -4
- package/hooks/cors.ts +4 -6
- package/hooks/parser.ts +3 -4
- package/hooks/permission.ts +3 -4
- package/hooks/validator.ts +3 -4
- package/lib/cacheHelper.ts +89 -153
- package/lib/cacheKeys.ts +1 -1
- package/lib/connect.ts +9 -13
- package/lib/dbDialect.ts +285 -0
- package/lib/dbHelper.ts +179 -507
- package/lib/dbUtils.ts +450 -0
- package/lib/logger.ts +41 -5
- package/lib/redisHelper.ts +1 -0
- package/lib/sqlBuilder.ts +358 -58
- package/lib/sqlCheck.ts +136 -0
- package/lib/validator.ts +1 -1
- package/loader/loadApis.ts +23 -126
- package/loader/loadHooks.ts +31 -46
- package/loader/loadPlugins.ts +37 -52
- package/main.ts +58 -19
- package/package.json +24 -25
- package/paths.ts +14 -14
- package/plugins/cache.ts +12 -6
- package/plugins/cipher.ts +2 -2
- package/plugins/config.ts +6 -8
- package/plugins/db.ts +14 -19
- package/plugins/jwt.ts +6 -7
- package/plugins/logger.ts +7 -9
- package/plugins/redis.ts +8 -10
- package/plugins/tool.ts +3 -4
- package/router/api.ts +3 -2
- package/router/static.ts +7 -5
- package/sync/syncApi.ts +80 -235
- package/sync/syncCache.ts +16 -0
- package/sync/syncDev.ts +167 -202
- package/sync/syncMenu.ts +230 -444
- package/sync/syncTable.ts +1247 -0
- package/tests/_mocks/mockSqliteDb.ts +204 -0
- package/tests/addonHelper-cache.test.ts +32 -0
- package/tests/apiHandler-routePath-only.test.ts +32 -0
- package/tests/cacheHelper.test.ts +16 -51
- package/tests/checkApi-routePath-strict.test.ts +166 -0
- package/tests/checkMenu.test.ts +346 -0
- package/tests/checkTable-smoke.test.ts +157 -0
- package/tests/dbDialect-cache.test.ts +23 -0
- package/tests/dbDialect.test.ts +46 -0
- package/tests/dbHelper-advanced.test.ts +1 -1
- package/tests/dbHelper-all-array-types.test.ts +15 -15
- package/tests/dbHelper-batch-write.test.ts +90 -0
- package/tests/dbHelper-columns.test.ts +36 -54
- package/tests/dbHelper-execute.test.ts +26 -26
- package/tests/dbHelper-joins.test.ts +85 -176
- package/tests/fixtures/scanFilesAddon/node_modules/@befly-addon/demo/apis/sub/b.ts +3 -0
- package/tests/fixtures/scanFilesApis/a.ts +3 -0
- package/tests/fixtures/scanFilesApis/sub/b.ts +3 -0
- package/tests/loadPlugins-order-smoke.test.ts +75 -0
- package/tests/logger.test.ts +6 -6
- package/tests/redisHelper.test.ts +6 -1
- package/tests/scanFiles-routePath.test.ts +46 -0
- package/tests/smoke-sql.test.ts +24 -0
- package/tests/sqlBuilder-advanced.test.ts +18 -5
- package/tests/sqlBuilder.test.ts +24 -0
- package/tests/sync-init-guard.test.ts +105 -0
- package/tests/syncApi-insBatch-fields-consistent.test.ts +61 -0
- package/tests/syncApi-obsolete-records.test.ts +69 -0
- package/tests/syncApi-type-compat.test.ts +72 -0
- package/tests/syncDev-permissions.test.ts +81 -0
- package/tests/syncMenu-disableMenus-hard-delete.test.ts +88 -0
- package/tests/syncMenu-duplicate-path.test.ts +122 -0
- package/tests/syncMenu-obsolete-records.test.ts +161 -0
- package/tests/syncMenu-parentPath-from-tree.test.ts +75 -0
- package/tests/syncMenu-paths.test.ts +0 -9
- package/tests/{syncDb-apply.test.ts → syncTable-apply.test.ts} +14 -24
- package/tests/{syncDb-array-number.test.ts → syncTable-array-number.test.ts} +31 -31
- package/tests/syncTable-constants.test.ts +101 -0
- package/tests/syncTable-db-integration.test.ts +237 -0
- package/tests/{syncDb-ddl.test.ts → syncTable-ddl.test.ts} +67 -53
- package/tests/{syncDb-helpers.test.ts → syncTable-helpers.test.ts} +12 -26
- package/tests/syncTable-schema.test.ts +99 -0
- package/tests/syncTable-testkit.test.ts +25 -0
- package/tests/syncTable-types.test.ts +122 -0
- package/tests/tableRef-and-deserialize.test.ts +67 -0
- package/tsconfig.json +1 -1
- package/types/api.d.ts +1 -1
- package/types/befly.d.ts +13 -12
- package/types/cache.d.ts +2 -2
- package/types/context.d.ts +1 -1
- package/types/database.d.ts +0 -5
- package/types/hook.d.ts +1 -10
- package/types/plugin.d.ts +2 -96
- package/types/sync.d.ts +19 -25
- package/utils/convertBigIntFields.ts +38 -0
- package/utils/disableMenusGlob.ts +85 -0
- package/utils/importDefault.ts +21 -0
- package/utils/isDirentDirectory.ts +23 -0
- package/utils/loadMenuConfigs.ts +145 -0
- package/utils/processFields.ts +25 -0
- package/utils/scanAddons.ts +72 -0
- package/utils/scanFiles.ts +129 -21
- package/utils/scanSources.ts +64 -0
- package/utils/sortModules.ts +137 -0
- package/checks/checkApp.ts +0 -55
- package/hooks/rateLimit.ts +0 -276
- package/sync/syncAll.ts +0 -35
- package/sync/syncDb/apply.ts +0 -192
- package/sync/syncDb/constants.ts +0 -119
- package/sync/syncDb/ddl.ts +0 -251
- package/sync/syncDb/helpers.ts +0 -84
- package/sync/syncDb/schema.ts +0 -202
- package/sync/syncDb/sqlite.ts +0 -48
- package/sync/syncDb/table.ts +0 -207
- package/sync/syncDb/tableCreate.ts +0 -163
- package/sync/syncDb/types.ts +0 -132
- package/sync/syncDb/version.ts +0 -69
- package/sync/syncDb.ts +0 -168
- package/tests/rateLimit-hook.test.ts +0 -477
- package/tests/syncDb-constants.test.ts +0 -130
- package/tests/syncDb-schema.test.ts +0 -179
- package/tests/syncDb-types.test.ts +0 -139
- package/utils/addonHelper.ts +0 -90
- package/utils/modules.ts +0 -98
- package/utils/route.ts +0 -23
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
export type MockColumn = { name: string; type: string; notnull: 0 | 1; dflt_value: any };
|
|
2
|
+
|
|
3
|
+
export type MockSqliteState = {
|
|
4
|
+
executedSql: string[];
|
|
5
|
+
tables: Record<
|
|
6
|
+
string,
|
|
7
|
+
{
|
|
8
|
+
columns: Record<string, MockColumn>;
|
|
9
|
+
indexes: Record<string, string[]>;
|
|
10
|
+
}
|
|
11
|
+
>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function normalizeQuotedIdent(input: string): string {
|
|
15
|
+
return String(input).trim().replace(/^`/, "").replace(/`$/, "").replace(/^"/, "").replace(/"$/, "");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function parseCreateTable(sql: string): { tableName: string; columnDefs: string } | null {
|
|
19
|
+
const m = /^CREATE\s+TABLE\s+(.+?)\s*\((.*)\)\s*$/is.exec(sql.trim());
|
|
20
|
+
if (!m) return null;
|
|
21
|
+
return {
|
|
22
|
+
tableName: normalizeQuotedIdent(m[1].trim()),
|
|
23
|
+
columnDefs: m[2]
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseAlterAddColumn(sql: string): { tableName: string; colName: string; colType: string; notnull: 0 | 1; dflt: any } | null {
|
|
28
|
+
const m = /^ALTER\s+TABLE\s+(.+?)\s+ADD\s+COLUMN\s+(?:IF\s+NOT\s+EXISTS\s+)?(.+?)\s+(.*)$/i.exec(sql.trim());
|
|
29
|
+
if (!m) return null;
|
|
30
|
+
|
|
31
|
+
const tableName = normalizeQuotedIdent(m[1].trim());
|
|
32
|
+
const colName = normalizeQuotedIdent(m[2].trim());
|
|
33
|
+
const rest = m[3];
|
|
34
|
+
|
|
35
|
+
const upper = rest.toUpperCase();
|
|
36
|
+
const colType = upper.includes("INTEGER") ? "INTEGER" : "TEXT";
|
|
37
|
+
const notnull: 0 | 1 = upper.includes("NOT NULL") ? 1 : 0;
|
|
38
|
+
|
|
39
|
+
let dflt: any = null;
|
|
40
|
+
const dfltMatch = /\bDEFAULT\s+(.+?)(\s|$)/i.exec(rest);
|
|
41
|
+
if (dfltMatch) {
|
|
42
|
+
const raw = dfltMatch[1].trim();
|
|
43
|
+
if (raw === "''") dflt = "";
|
|
44
|
+
else if (raw.startsWith("'") && raw.endsWith("'")) dflt = raw.slice(1, -1).replace(/''/g, "'");
|
|
45
|
+
else if (/^\d+$/.test(raw)) dflt = Number(raw);
|
|
46
|
+
else dflt = raw;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { tableName: tableName, colName: colName, colType: colType, notnull: notnull, dflt: dflt };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseCreateIndex(sql: string): { indexName: string; tableName: string; columns: string[] } | null {
|
|
53
|
+
const m = /^CREATE\s+INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+NOT\s+EXISTS\s+)?(.+?)\s+ON\s+(.+?)\s*\((.+)\)\s*$/is.exec(sql.trim());
|
|
54
|
+
if (!m) return null;
|
|
55
|
+
|
|
56
|
+
const indexName = normalizeQuotedIdent(m[1].trim());
|
|
57
|
+
const tableName = normalizeQuotedIdent(m[2].trim());
|
|
58
|
+
const columns = m[3]
|
|
59
|
+
.split(",")
|
|
60
|
+
.map((s) => normalizeQuotedIdent(s.trim()))
|
|
61
|
+
.filter((s) => s.length > 0);
|
|
62
|
+
|
|
63
|
+
return { indexName: indexName, tableName: tableName, columns: columns };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseDropIndex(sql: string): { indexName: string } | null {
|
|
67
|
+
const m = /^DROP\s+INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+EXISTS\s+)?(.+?)\s*$/i.exec(sql.trim());
|
|
68
|
+
if (!m) return null;
|
|
69
|
+
return { indexName: normalizeQuotedIdent(m[1].trim()) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function extractPragmaIdent(sqlStr: string): string {
|
|
73
|
+
const m = /\((.+)\)\s*$/i.exec(String(sqlStr).trim());
|
|
74
|
+
return normalizeQuotedIdent(m ? m[1] : "");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function createMockSqliteDb(state: MockSqliteState) {
|
|
78
|
+
return {
|
|
79
|
+
unsafe: async (sqlStr: string, params?: unknown[]) => {
|
|
80
|
+
const sql = String(sqlStr);
|
|
81
|
+
state.executedSql.push(sql);
|
|
82
|
+
|
|
83
|
+
if (sql.includes("sqlite_version()")) {
|
|
84
|
+
return [{ version: "3.50.1" }];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (sql.includes("sqlite_master")) {
|
|
88
|
+
const tableName = String(params?.[0] || "");
|
|
89
|
+
return [{ count: state.tables[tableName] ? 1 : 0 }];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (/^PRAGMA\s+table_info\s*\(/i.test(sql)) {
|
|
93
|
+
const tableName = extractPragmaIdent(sql);
|
|
94
|
+
const t = state.tables[tableName];
|
|
95
|
+
if (!t) return [];
|
|
96
|
+
return Object.values(t.columns).map((c) => {
|
|
97
|
+
return {
|
|
98
|
+
name: c.name,
|
|
99
|
+
type: c.type,
|
|
100
|
+
notnull: c.notnull,
|
|
101
|
+
dflt_value: c.dflt_value
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (/^PRAGMA\s+index_list\s*\(/i.test(sql)) {
|
|
107
|
+
const tableName = extractPragmaIdent(sql);
|
|
108
|
+
const t = state.tables[tableName];
|
|
109
|
+
if (!t) return [];
|
|
110
|
+
return Object.keys(t.indexes).map((name) => {
|
|
111
|
+
return { name: name };
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (/^PRAGMA\s+index_info\s*\(/i.test(sql)) {
|
|
116
|
+
const indexName = extractPragmaIdent(sql);
|
|
117
|
+
for (const table of Object.values(state.tables)) {
|
|
118
|
+
if (table.indexes[indexName]) {
|
|
119
|
+
return table.indexes[indexName].map((col) => {
|
|
120
|
+
return { name: col };
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const createTable = parseCreateTable(sql);
|
|
128
|
+
if (createTable) {
|
|
129
|
+
const colMap: Record<string, MockColumn> = {};
|
|
130
|
+
const parts = createTable.columnDefs
|
|
131
|
+
.split(",")
|
|
132
|
+
.map((s) => s.trim())
|
|
133
|
+
.filter((s) => s.length > 0);
|
|
134
|
+
|
|
135
|
+
for (const p of parts) {
|
|
136
|
+
const colMatch = /^(.+?)\s+(.+)$/s.exec(p);
|
|
137
|
+
if (!colMatch) continue;
|
|
138
|
+
const colName = normalizeQuotedIdent(colMatch[1].trim());
|
|
139
|
+
const rest = colMatch[2];
|
|
140
|
+
|
|
141
|
+
const upper = rest.toUpperCase();
|
|
142
|
+
const colType = upper.includes("INTEGER") ? "INTEGER" : upper.includes("BIGINT") ? "INTEGER" : "TEXT";
|
|
143
|
+
const notnull: 0 | 1 = upper.includes("NOT NULL") || upper.includes("PRIMARY KEY") ? 1 : 0;
|
|
144
|
+
|
|
145
|
+
let dflt: any = null;
|
|
146
|
+
const dfltMatch = /\bDEFAULT\s+(.+?)(\s|$)/i.exec(rest);
|
|
147
|
+
if (dfltMatch) {
|
|
148
|
+
const raw = dfltMatch[1].trim();
|
|
149
|
+
if (raw === "''") dflt = "";
|
|
150
|
+
else if (raw.startsWith("'") && raw.endsWith("'")) dflt = raw.slice(1, -1).replace(/''/g, "'");
|
|
151
|
+
else if (/^\d+$/.test(raw)) dflt = Number(raw);
|
|
152
|
+
else dflt = raw;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
colMap[colName] = { name: colName, type: colType, notnull: notnull, dflt_value: dflt };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
state.tables[createTable.tableName] = {
|
|
159
|
+
columns: colMap,
|
|
160
|
+
indexes: {}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
return [];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const addColumn = parseAlterAddColumn(sql);
|
|
167
|
+
if (addColumn) {
|
|
168
|
+
const t = state.tables[addColumn.tableName];
|
|
169
|
+
if (!t) throw new Error(`mock sqlite db: 表不存在,无法 ADD COLUMN: ${addColumn.tableName}`);
|
|
170
|
+
if (!t.columns[addColumn.colName]) {
|
|
171
|
+
t.columns[addColumn.colName] = {
|
|
172
|
+
name: addColumn.colName,
|
|
173
|
+
type: addColumn.colType,
|
|
174
|
+
notnull: addColumn.notnull,
|
|
175
|
+
dflt_value: addColumn.dflt
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const createIndex = parseCreateIndex(sql);
|
|
182
|
+
if (createIndex) {
|
|
183
|
+
const t = state.tables[createIndex.tableName];
|
|
184
|
+
if (!t) throw new Error(`mock sqlite db: 表不存在,无法 CREATE INDEX: ${createIndex.tableName}`);
|
|
185
|
+
t.indexes[createIndex.indexName] = createIndex.columns;
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const dropIndex = parseDropIndex(sql);
|
|
190
|
+
if (dropIndex) {
|
|
191
|
+
for (const t of Object.values(state.tables)) {
|
|
192
|
+
delete t.indexes[dropIndex.indexName];
|
|
193
|
+
}
|
|
194
|
+
return [];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (/^DROP\s+TABLE/i.test(sql)) {
|
|
198
|
+
return [];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
throw new Error(`mock sqlite db: 未处理的 SQL: ${sql}`);
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { test, expect } from "bun:test";
|
|
2
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
import { join } from "pathe";
|
|
5
|
+
|
|
6
|
+
test("scanAddons - should scan node_modules @befly-addon", async () => {
|
|
7
|
+
const originalCwd = process.cwd();
|
|
8
|
+
const projectDir = join(originalCwd, "temp", `addonHelper-cache-test-${Date.now()}`);
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
// 构造一个最小可扫描的项目结构
|
|
12
|
+
mkdirSync(join(projectDir, "node_modules", "@befly-addon", "demo"), { recursive: true });
|
|
13
|
+
mkdirSync(join(projectDir, "node_modules", "@befly-addon", "_ignore"), { recursive: true });
|
|
14
|
+
|
|
15
|
+
// 放一个非目录项,确保扫描逻辑不会误判
|
|
16
|
+
writeFileSync(join(projectDir, "node_modules", "@befly-addon", "README.md"), "x", { encoding: "utf8" });
|
|
17
|
+
|
|
18
|
+
// scanAddons 依赖 appDir=process.cwd()(模块初始化时取值),所以先切 cwd 再动态 import
|
|
19
|
+
process.chdir(projectDir);
|
|
20
|
+
|
|
21
|
+
const mod = await import(`../utils/scanAddons.js?cacheTest=${Date.now()}`);
|
|
22
|
+
const scanAddons = mod.scanAddons as () => any[];
|
|
23
|
+
|
|
24
|
+
const addons = scanAddons();
|
|
25
|
+
expect(addons.map((a) => a.name)).toEqual(["demo"]);
|
|
26
|
+
expect(addons[0].fullPath.endsWith("node_modules/@befly-addon/demo")).toBe(true);
|
|
27
|
+
expect(addons[0].camelName).toBe("demo");
|
|
28
|
+
} finally {
|
|
29
|
+
process.chdir(originalCwd);
|
|
30
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
31
|
+
}
|
|
32
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { apiHandler } from "../router/api.js";
|
|
4
|
+
|
|
5
|
+
describe("apiHandler - route matching", () => {
|
|
6
|
+
test("接口存在性判断应仅使用 url.pathname(不附加 method)", async () => {
|
|
7
|
+
const apis = new Map<string, any>();
|
|
8
|
+
|
|
9
|
+
apis.set("/api/hello", {
|
|
10
|
+
name: "hello",
|
|
11
|
+
method: "POST",
|
|
12
|
+
handler: async () => {
|
|
13
|
+
return {
|
|
14
|
+
msg: "ok"
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const hooks: any[] = [];
|
|
20
|
+
const context: any = {};
|
|
21
|
+
|
|
22
|
+
const handler = apiHandler(apis as any, hooks as any, context as any);
|
|
23
|
+
|
|
24
|
+
// 如果 key 里拼了 method,这里会变成 "POST /api/hello" 之类,从而导致接口不存在
|
|
25
|
+
const res = await handler(new Request("http://localhost/api/hello", { method: "POST" }));
|
|
26
|
+
expect(res.status).toBe(200);
|
|
27
|
+
|
|
28
|
+
const body = await res.json();
|
|
29
|
+
expect(body.code).toBe(0);
|
|
30
|
+
expect(body.msg).toBe("ok");
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
* CacheHelper 单元测试
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import type { BeflyContext } from "../types/befly.js";
|
|
6
|
-
|
|
7
5
|
import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test";
|
|
8
6
|
|
|
9
7
|
import { CacheHelper } from "../lib/cacheHelper.js";
|
|
@@ -25,7 +23,6 @@ const mockPino = {
|
|
|
25
23
|
|
|
26
24
|
describe("CacheHelper", () => {
|
|
27
25
|
let cacheHelper: CacheHelper;
|
|
28
|
-
let mockBefly: BeflyContext;
|
|
29
26
|
let mockDb: any;
|
|
30
27
|
let mockRedis: any;
|
|
31
28
|
|
|
@@ -52,13 +49,7 @@ describe("CacheHelper", () => {
|
|
|
52
49
|
exists: mock(() => Promise.resolve(true))
|
|
53
50
|
};
|
|
54
51
|
|
|
55
|
-
|
|
56
|
-
mockBefly = {
|
|
57
|
-
db: mockDb,
|
|
58
|
-
redis: mockRedis
|
|
59
|
-
} as unknown as BeflyContext;
|
|
60
|
-
|
|
61
|
-
cacheHelper = new CacheHelper(mockBefly);
|
|
52
|
+
cacheHelper = new CacheHelper({ db: mockDb, redis: mockRedis });
|
|
62
53
|
});
|
|
63
54
|
|
|
64
55
|
afterEach(() => {
|
|
@@ -78,8 +69,8 @@ describe("CacheHelper", () => {
|
|
|
78
69
|
|
|
79
70
|
it("正常缓存接口列表", async () => {
|
|
80
71
|
const apis = [
|
|
81
|
-
{ id: 1, name: "登录",
|
|
82
|
-
{ id: 2, name: "用户列表",
|
|
72
|
+
{ id: 1, name: "登录", routePath: "/api/login" },
|
|
73
|
+
{ id: 2, name: "用户列表", routePath: "/api/user/list" }
|
|
83
74
|
];
|
|
84
75
|
mockDb.getAll = mock(() => Promise.resolve({ lists: apis, total: apis.length }));
|
|
85
76
|
|
|
@@ -131,7 +122,6 @@ describe("CacheHelper", () => {
|
|
|
131
122
|
describe("rebuildRoleApiPermissions", () => {
|
|
132
123
|
it("表不存在时跳过缓存", async () => {
|
|
133
124
|
mockDb.tableExists = mock((table: string) => {
|
|
134
|
-
if (table === "addon_admin_api") return Promise.resolve(true);
|
|
135
125
|
if (table === "addon_admin_role") return Promise.resolve(false);
|
|
136
126
|
return Promise.resolve(false);
|
|
137
127
|
});
|
|
@@ -143,21 +133,12 @@ describe("CacheHelper", () => {
|
|
|
143
133
|
|
|
144
134
|
it("正常重建角色权限(覆盖更新)", async () => {
|
|
145
135
|
const roles = [
|
|
146
|
-
{ id: 1, code: "admin", apis: [
|
|
147
|
-
{ id: 2, code: "user", apis: [
|
|
148
|
-
];
|
|
149
|
-
const apis = [
|
|
150
|
-
{ id: 1, path: "/api/login", method: "POST" },
|
|
151
|
-
{ id: 2, path: "/api/user/list", method: "GET" },
|
|
152
|
-
{ id: 3, path: "/api/user/del", method: "POST" }
|
|
136
|
+
{ id: 1, code: "admin", apis: ["/api/login", "/api/user/list", "/api/user/del"] },
|
|
137
|
+
{ id: 2, code: "user", apis: ["/api/login"] }
|
|
153
138
|
];
|
|
154
139
|
|
|
155
140
|
mockDb.getAll = mock((opts: any) => {
|
|
156
141
|
if (opts.table === "addon_admin_role") return Promise.resolve({ lists: roles, total: roles.length });
|
|
157
|
-
if (opts.table === "addon_admin_api") {
|
|
158
|
-
// rebuildRoleApiPermissions 会通过 where: { id$in: [...] } 分块查询
|
|
159
|
-
return Promise.resolve({ lists: apis, total: apis.length });
|
|
160
|
-
}
|
|
161
142
|
return Promise.resolve({ lists: [], total: 0 });
|
|
162
143
|
});
|
|
163
144
|
|
|
@@ -177,19 +158,17 @@ describe("CacheHelper", () => {
|
|
|
177
158
|
expect(saddBatchArgs).toEqual([
|
|
178
159
|
{
|
|
179
160
|
key: CacheKeys.roleApis("admin"),
|
|
180
|
-
members: ["
|
|
161
|
+
members: ["/api/login", "/api/user/del", "/api/user/list"]
|
|
181
162
|
},
|
|
182
|
-
{ key: CacheKeys.roleApis("user"), members: ["
|
|
163
|
+
{ key: CacheKeys.roleApis("user"), members: ["/api/login"] }
|
|
183
164
|
]);
|
|
184
165
|
});
|
|
185
166
|
|
|
186
167
|
it("无权限时仍会清理旧缓存,但不写入成员", async () => {
|
|
187
168
|
const roles = [{ id: 1, code: "empty", apis: [] }];
|
|
188
|
-
const apis = [{ id: 1, path: "/api/login", method: "POST" }];
|
|
189
169
|
|
|
190
170
|
mockDb.getAll = mock((opts: any) => {
|
|
191
171
|
if (opts.table === "addon_admin_role") return Promise.resolve({ lists: roles, total: roles.length });
|
|
192
|
-
if (opts.table === "addon_admin_api") return Promise.resolve({ lists: apis, total: apis.length });
|
|
193
172
|
return Promise.resolve({ lists: [], total: 0 });
|
|
194
173
|
});
|
|
195
174
|
|
|
@@ -201,34 +180,20 @@ describe("CacheHelper", () => {
|
|
|
201
180
|
});
|
|
202
181
|
|
|
203
182
|
describe("refreshRoleApiPermissions", () => {
|
|
204
|
-
it("
|
|
183
|
+
it("apiPaths 为空数组时只清理缓存,不查询 DB", async () => {
|
|
205
184
|
mockDb.getAll = mock(() => Promise.resolve({ lists: [], total: 0 }));
|
|
206
185
|
|
|
207
186
|
await cacheHelper.refreshRoleApiPermissions("admin", []);
|
|
208
187
|
|
|
209
188
|
expect(mockRedis.del).toHaveBeenCalledWith(CacheKeys.roleApis("admin"));
|
|
210
|
-
expect(mockDb.getAll).not.
|
|
211
|
-
expect.objectContaining({
|
|
212
|
-
table: "addon_admin_api"
|
|
213
|
-
})
|
|
214
|
-
);
|
|
189
|
+
expect(mockDb.getAll).not.toHaveBeenCalled();
|
|
215
190
|
});
|
|
216
191
|
|
|
217
|
-
it("
|
|
218
|
-
|
|
219
|
-
{ id: 1, path: "/api/login", method: "POST" },
|
|
220
|
-
{ id: 2, path: "/api/user/list", method: "GET" }
|
|
221
|
-
];
|
|
222
|
-
|
|
223
|
-
mockDb.getAll = mock((opts: any) => {
|
|
224
|
-
if (opts.table === "addon_admin_api") return Promise.resolve({ lists: apis, total: apis.length });
|
|
225
|
-
return Promise.resolve({ lists: [], total: 0 });
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
await cacheHelper.refreshRoleApiPermissions("admin", [1, 2]);
|
|
192
|
+
it("apiPaths 非空时直接覆盖更新该角色缓存(DEL + SADD)", async () => {
|
|
193
|
+
await cacheHelper.refreshRoleApiPermissions("admin", ["/api/login", "/api/user/list"]);
|
|
229
194
|
|
|
230
195
|
expect(mockRedis.del).toHaveBeenCalledWith(CacheKeys.roleApis("admin"));
|
|
231
|
-
expect(mockRedis.sadd).toHaveBeenCalledWith(CacheKeys.roleApis("admin"), ["
|
|
196
|
+
expect(mockRedis.sadd).toHaveBeenCalledWith(CacheKeys.roleApis("admin"), ["/api/login", "/api/user/list"]);
|
|
232
197
|
});
|
|
233
198
|
});
|
|
234
199
|
|
|
@@ -272,7 +237,7 @@ describe("CacheHelper", () => {
|
|
|
272
237
|
|
|
273
238
|
describe("getRolePermissions", () => {
|
|
274
239
|
it("返回角色的权限列表", async () => {
|
|
275
|
-
const permissions = ["
|
|
240
|
+
const permissions = ["/api/login", "/api/user/list"];
|
|
276
241
|
mockRedis.smembers = mock(() => Promise.resolve(permissions));
|
|
277
242
|
|
|
278
243
|
const result = await cacheHelper.getRolePermissions("admin");
|
|
@@ -294,16 +259,16 @@ describe("CacheHelper", () => {
|
|
|
294
259
|
it("有权限时返回 true", async () => {
|
|
295
260
|
mockRedis.sismember = mock(() => Promise.resolve(true));
|
|
296
261
|
|
|
297
|
-
const result = await cacheHelper.checkRolePermission("admin", "
|
|
262
|
+
const result = await cacheHelper.checkRolePermission("admin", "/api/login");
|
|
298
263
|
|
|
299
|
-
expect(mockRedis.sismember).toHaveBeenCalledWith(CacheKeys.roleApis("admin"), "
|
|
264
|
+
expect(mockRedis.sismember).toHaveBeenCalledWith(CacheKeys.roleApis("admin"), "/api/login");
|
|
300
265
|
expect(result).toBe(true);
|
|
301
266
|
});
|
|
302
267
|
|
|
303
268
|
it("无权限时返回 false", async () => {
|
|
304
269
|
mockRedis.sismember = mock(() => Promise.resolve(false));
|
|
305
270
|
|
|
306
|
-
const result = await cacheHelper.checkRolePermission("user", "
|
|
271
|
+
const result = await cacheHelper.checkRolePermission("user", "/api/admin/del");
|
|
307
272
|
|
|
308
273
|
expect(result).toBe(false);
|
|
309
274
|
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { checkApi } from "../checks/checkApi.js";
|
|
4
|
+
|
|
5
|
+
describe("checkApi - routePath strict", () => {
|
|
6
|
+
test("合法 routePath 应通过", async () => {
|
|
7
|
+
let thrown: any = null;
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
await checkApi([
|
|
11
|
+
{
|
|
12
|
+
name: "hello",
|
|
13
|
+
handler: () => {
|
|
14
|
+
return null;
|
|
15
|
+
},
|
|
16
|
+
routePath: "/api/hello",
|
|
17
|
+
routePrefix: "/app"
|
|
18
|
+
}
|
|
19
|
+
]);
|
|
20
|
+
} catch (error: any) {
|
|
21
|
+
thrown = error;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
expect(thrown).toBeNull();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("routePath 为空字符串应阻断启动", async () => {
|
|
28
|
+
let thrown: any = null;
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
await checkApi([
|
|
32
|
+
{
|
|
33
|
+
name: "hello",
|
|
34
|
+
handler: () => null,
|
|
35
|
+
routePath: "",
|
|
36
|
+
routePrefix: "/app"
|
|
37
|
+
}
|
|
38
|
+
]);
|
|
39
|
+
} catch (error: any) {
|
|
40
|
+
thrown = error;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
expect(thrown).toBeTruthy();
|
|
44
|
+
expect(thrown.message).toBe("接口结构检查失败");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("routePath 非字符串应阻断启动", async () => {
|
|
48
|
+
let thrown: any = null;
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
await checkApi([
|
|
52
|
+
{
|
|
53
|
+
name: "hello",
|
|
54
|
+
handler: () => null,
|
|
55
|
+
routePath: 123,
|
|
56
|
+
routePrefix: "/app"
|
|
57
|
+
} as any
|
|
58
|
+
]);
|
|
59
|
+
} catch (error: any) {
|
|
60
|
+
thrown = error;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
expect(thrown).toBeTruthy();
|
|
64
|
+
expect(thrown.message).toBe("接口结构检查失败");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("routePath 不允许 method 前缀(POST/api/...)", async () => {
|
|
68
|
+
let thrown: any = null;
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
await checkApi([
|
|
72
|
+
{
|
|
73
|
+
name: "hello",
|
|
74
|
+
handler: () => null,
|
|
75
|
+
routePath: "POST/api/hello",
|
|
76
|
+
routePrefix: "/app"
|
|
77
|
+
}
|
|
78
|
+
]);
|
|
79
|
+
} catch (error: any) {
|
|
80
|
+
thrown = error;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
expect(thrown).toBeTruthy();
|
|
84
|
+
expect(thrown.message).toBe("接口结构检查失败");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("routePath 不允许 method + 空格(POST /api/...)", async () => {
|
|
88
|
+
let thrown: any = null;
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
await checkApi([
|
|
92
|
+
{
|
|
93
|
+
name: "hello",
|
|
94
|
+
handler: () => null,
|
|
95
|
+
routePath: "POST /api/hello",
|
|
96
|
+
routePrefix: "/app"
|
|
97
|
+
}
|
|
98
|
+
]);
|
|
99
|
+
} catch (error: any) {
|
|
100
|
+
thrown = error;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
expect(thrown).toBeTruthy();
|
|
104
|
+
expect(thrown.message).toBe("接口结构检查失败");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("routePath 必须以 /api/ 开头", async () => {
|
|
108
|
+
let thrown: any = null;
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
await checkApi([
|
|
112
|
+
{
|
|
113
|
+
name: "hello",
|
|
114
|
+
handler: () => null,
|
|
115
|
+
routePath: "/app/hello",
|
|
116
|
+
routePrefix: "/app"
|
|
117
|
+
}
|
|
118
|
+
]);
|
|
119
|
+
} catch (error: any) {
|
|
120
|
+
thrown = error;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
expect(thrown).toBeTruthy();
|
|
124
|
+
expect(thrown.message).toBe("接口结构检查失败");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("routePath 不允许包含空格", async () => {
|
|
128
|
+
let thrown: any = null;
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
await checkApi([
|
|
132
|
+
{
|
|
133
|
+
name: "hello",
|
|
134
|
+
handler: () => null,
|
|
135
|
+
routePath: "/api/hello world",
|
|
136
|
+
routePrefix: "/app"
|
|
137
|
+
}
|
|
138
|
+
]);
|
|
139
|
+
} catch (error: any) {
|
|
140
|
+
thrown = error;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
expect(thrown).toBeTruthy();
|
|
144
|
+
expect(thrown.message).toBe("接口结构检查失败");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("routePath 不允许出现 /api//(重复斜杠)", async () => {
|
|
148
|
+
let thrown: any = null;
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
await checkApi([
|
|
152
|
+
{
|
|
153
|
+
name: "hello",
|
|
154
|
+
handler: () => null,
|
|
155
|
+
routePath: "/api//hello",
|
|
156
|
+
routePrefix: "/app"
|
|
157
|
+
}
|
|
158
|
+
]);
|
|
159
|
+
} catch (error: any) {
|
|
160
|
+
thrown = error;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
expect(thrown).toBeTruthy();
|
|
164
|
+
expect(thrown.message).toBe("接口结构检查失败");
|
|
165
|
+
});
|
|
166
|
+
});
|