@galadrim/galadmin 0.1.236 → 0.2.6
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/.github/workflows/main.yml +33 -2
- package/README.md +65 -0
- package/dist/api.d.ts +16 -0
- package/dist/api.js +240 -189
- package/dist/api.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/utils/dbManager.d.ts +5 -0
- package/dist/utils/dbManager.js +146 -11
- package/dist/utils/dbManager.js.map +1 -1
- package/dist/utils/imageDimensions.d.ts +5 -0
- package/dist/utils/imageDimensions.js +98 -0
- package/dist/utils/imageDimensions.js.map +1 -0
- package/jest.config.js +43 -0
- package/jest.integration.config.js +16 -0
- package/package.json +30 -10
- package/src/api.ts +284 -217
- package/src/front/src/App.test.js +28 -0
- package/src/front/src/components/OutsideClickHandler.test.js +53 -0
- package/src/front/src/components/RedirectToFirstView.test.js +38 -0
- package/src/front/src/utils/checkRequiredColumns.test.js +46 -0
- package/src/front/src/utils/evalExpression.test.js +29 -0
- package/src/index.ts +1 -1
- package/src/utils/dbManager.ts +243 -102
- package/src/utils/imageDimensions.ts +112 -0
- package/tests/__mocks__/@aws-sdk/client-s3.js +6 -0
- package/tests/__mocks__/@aws-sdk/s3-request-presigner.js +3 -0
- package/tests/__mocks__/@google-cloud/storage.js +7 -0
- package/tests/__mocks__/argon2.js +3 -0
- package/tests/__mocks__/axios.js +7 -0
- package/tests/__mocks__/connect-pg-simple.js +24 -0
- package/tests/__mocks__/express-mysql-session.js +24 -0
- package/tests/__mocks__/mysql2/promise.js +3 -0
- package/tests/__mocks__/pg.js +3 -0
- package/tests/__mocks__/rollup.js +5 -0
- package/tests/__mocks__/ssh2-sftp-client.js +10 -0
- package/tests/api-helpers.test.ts +376 -0
- package/tests/dbManager.init.test.ts +129 -0
- package/tests/dbManager.test.ts +265 -0
- package/tests/edge-cases.test.ts +167 -0
- package/tests/helpers/app.ts +69 -0
- package/tests/helpers/fakeDb.ts +66 -0
- package/tests/helpers/fakePool.ts +76 -0
- package/tests/helpers/fixtures.ts +68 -0
- package/tests/imageDimensions.test.ts +111 -0
- package/tests/index.sessionStore.test.ts +19 -0
- package/tests/index.test.ts +363 -0
- package/tests/integration/__mocks__/axios.js +3 -0
- package/tests/integration/dbHarness.ts +381 -0
- package/tests/integration/dbManager.integration.test.ts +69 -0
- package/tests/integration/globalSetup.ts +43 -0
- package/tests/integration/globalTeardown.ts +42 -0
- package/tests/integration/routes.authorization.integration.test.ts +319 -0
- package/tests/integration/routes.config.integration.test.ts +189 -0
- package/tests/integration/routes.crud.integration.test.ts +805 -0
- package/tests/integration/routes.custom.integration.test.ts +376 -0
- package/tests/integration/routes.foreign.integration.test.ts +306 -0
- package/tests/integration/routes.importExport.integration.test.ts +404 -0
- package/tests/integration/routes.integration.test.ts +366 -0
- package/tests/integration/routes.postgresql.integration.test.ts +88 -0
- package/tests/integration/routes.records.integration.test.ts +634 -0
- package/tests/integration/routes.upload.integration.test.ts +425 -0
- package/tests/setup/silence-console.ts +15 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// use the REAL express-session Store base class (provides createSession,
|
|
2
|
+
// EventEmitter, regenerate) and only stub the storage round-trip
|
|
3
|
+
const expressSession = require("express-session");
|
|
4
|
+
|
|
5
|
+
function createStore() {
|
|
6
|
+
const sessions = {};
|
|
7
|
+
const store = new expressSession.Store();
|
|
8
|
+
store.get = (sid, cb) => cb(null, sessions[sid]);
|
|
9
|
+
store.set = (sid, sess, cb) => {
|
|
10
|
+
sessions[sid] = sess;
|
|
11
|
+
if (cb) cb();
|
|
12
|
+
};
|
|
13
|
+
store.destroy = (sid, cb) => {
|
|
14
|
+
delete sessions[sid];
|
|
15
|
+
if (cb) cb();
|
|
16
|
+
};
|
|
17
|
+
return store;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const storeConstructor = jest.fn(() => createStore());
|
|
21
|
+
const PgSessionStore = jest.fn(() => storeConstructor);
|
|
22
|
+
module.exports = PgSessionStore;
|
|
23
|
+
module.exports.__PgSessionStore = PgSessionStore;
|
|
24
|
+
module.exports.__storeConstructor = storeConstructor;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// use the REAL express-session Store base class (provides createSession,
|
|
2
|
+
// EventEmitter, regenerate) and only stub the storage round-trip
|
|
3
|
+
const expressSession = require("express-session");
|
|
4
|
+
|
|
5
|
+
function createStore() {
|
|
6
|
+
const sessions = {};
|
|
7
|
+
const store = new expressSession.Store();
|
|
8
|
+
store.get = (sid, cb) => cb(null, sessions[sid]);
|
|
9
|
+
store.set = (sid, sess, cb) => {
|
|
10
|
+
sessions[sid] = sess;
|
|
11
|
+
if (cb) cb();
|
|
12
|
+
};
|
|
13
|
+
store.destroy = (sid, cb) => {
|
|
14
|
+
delete sessions[sid];
|
|
15
|
+
if (cb) cb();
|
|
16
|
+
};
|
|
17
|
+
return store;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const storeConstructor = jest.fn(() => createStore());
|
|
21
|
+
const MySQLSessionStore = jest.fn(() => storeConstructor);
|
|
22
|
+
module.exports = MySQLSessionStore;
|
|
23
|
+
module.exports.__MySQLSessionStore = MySQLSessionStore;
|
|
24
|
+
module.exports.__storeConstructor = storeConstructor;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const connect = jest.fn().mockResolvedValue(undefined);
|
|
2
|
+
const fastPut = jest.fn().mockResolvedValue(undefined);
|
|
3
|
+
const end = jest.fn().mockResolvedValue(undefined);
|
|
4
|
+
|
|
5
|
+
function SftpClient() {
|
|
6
|
+
return { connect, fastPut, end };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
module.exports = SftpClient;
|
|
10
|
+
module.exports.__sftp = { connect, fastPut, end };
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import {
|
|
2
|
+
generateUniqueIdForObject,
|
|
3
|
+
roleOf,
|
|
4
|
+
canWriteView,
|
|
5
|
+
viewFromRequest,
|
|
6
|
+
MUTATING_VIEW_ROUTES,
|
|
7
|
+
areSafeColumnNames,
|
|
8
|
+
writableTables,
|
|
9
|
+
looksLikeSqlBreakout,
|
|
10
|
+
sqlAnd,
|
|
11
|
+
evalExpression,
|
|
12
|
+
getFormattedValue,
|
|
13
|
+
formatValueForInsertion,
|
|
14
|
+
collectAllowedQueries,
|
|
15
|
+
enrichConfig,
|
|
16
|
+
enrichColumn,
|
|
17
|
+
} from "../src/api";
|
|
18
|
+
import { FakeDb } from "./helpers/fakeDb";
|
|
19
|
+
import { baseConfig, schemaRow } from "./helpers/fixtures";
|
|
20
|
+
|
|
21
|
+
describe("generateUniqueIdForObject", () => {
|
|
22
|
+
it("returns a stable 64-char sha256 hex id", () => {
|
|
23
|
+
const obj = { label: "Fruits", type: "table" };
|
|
24
|
+
const id = generateUniqueIdForObject(obj);
|
|
25
|
+
expect(id).toMatch(/^[a-f0-9]{64}$/);
|
|
26
|
+
expect(generateUniqueIdForObject(obj)).toBe(id);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("differs for different objects", () => {
|
|
30
|
+
expect(generateUniqueIdForObject({ a: 1 })).not.toBe(
|
|
31
|
+
generateUniqueIdForObject({ a: 2 }),
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("roleOf", () => {
|
|
37
|
+
it("reads the session user role", () => {
|
|
38
|
+
expect(roleOf({ session: { user: { role: "editor" } } })).toBe("editor");
|
|
39
|
+
});
|
|
40
|
+
it("returns undefined without a session", () => {
|
|
41
|
+
expect(roleOf({})).toBeUndefined();
|
|
42
|
+
expect(roleOf({ session: {} })).toBeUndefined();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("canWriteView", () => {
|
|
47
|
+
it("always allows root", () => {
|
|
48
|
+
expect(canWriteView({ roles: ["editor"] }, "root")).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
it("allows views without roles", () => {
|
|
51
|
+
expect(canWriteView({}, "editor")).toBe(true);
|
|
52
|
+
expect(canWriteView(undefined, "editor")).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
it("allows roles listed in view.roles", () => {
|
|
55
|
+
expect(canWriteView({ roles: ["editor", "viewer"] }, "editor")).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
it("rejects roles not listed", () => {
|
|
58
|
+
expect(canWriteView({ roles: ["editor"] }, "viewer")).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("viewFromRequest", () => {
|
|
63
|
+
it("resolves the view from body.viewIndex", () => {
|
|
64
|
+
const req = { body: { viewIndex: 1 }, config: { views: ["a", "b"] } };
|
|
65
|
+
expect(viewFromRequest(req)).toBe("b");
|
|
66
|
+
});
|
|
67
|
+
it("resolves the view from query.viewIndex", () => {
|
|
68
|
+
const req = { query: { viewIndex: "0" }, config: { views: ["a", "b"] } };
|
|
69
|
+
expect(viewFromRequest(req)).toBe("a");
|
|
70
|
+
});
|
|
71
|
+
it("returns undefined without a config or index", () => {
|
|
72
|
+
expect(viewFromRequest({ body: {} })).toBeUndefined();
|
|
73
|
+
expect(viewFromRequest({ body: { viewIndex: 0 }, config: {} })).toBeUndefined();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("MUTATING_VIEW_ROUTES", () => {
|
|
78
|
+
it("covers every mutating view route", () => {
|
|
79
|
+
expect([...MUTATING_VIEW_ROUTES].sort()).toEqual(
|
|
80
|
+
[
|
|
81
|
+
"/customAction",
|
|
82
|
+
"/createRecord",
|
|
83
|
+
"/deleteRecord",
|
|
84
|
+
"/deleteRecordFromPanel",
|
|
85
|
+
"/deleteRecords",
|
|
86
|
+
"/updateRecord",
|
|
87
|
+
"/uploadFile",
|
|
88
|
+
].sort(),
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("areSafeColumnNames", () => {
|
|
94
|
+
it("accepts plain identifiers", () => {
|
|
95
|
+
expect(areSafeColumnNames({ fruitName: "a", acidity2: 1 })).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
it("accepts empty/missing data", () => {
|
|
98
|
+
expect(areSafeColumnNames({})).toBe(true);
|
|
99
|
+
expect(areSafeColumnNames(undefined)).toBe(true);
|
|
100
|
+
expect(areSafeColumnNames(null)).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
it("rejects SQL-breaking keys", () => {
|
|
103
|
+
expect(areSafeColumnNames({ "a;b": 1 })).toBe(false);
|
|
104
|
+
expect(areSafeColumnNames({ "a b": 1 })).toBe(false);
|
|
105
|
+
expect(areSafeColumnNames({ "a-b": 1 })).toBe(false);
|
|
106
|
+
expect(areSafeColumnNames({ "`a`": 1 })).toBe(false);
|
|
107
|
+
expect(areSafeColumnNames({ "a'": 1 })).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("writableTables", () => {
|
|
112
|
+
const config = {
|
|
113
|
+
views: [
|
|
114
|
+
{
|
|
115
|
+
tableName: "fruits",
|
|
116
|
+
roles: ["editor"],
|
|
117
|
+
recordViewPanels: [
|
|
118
|
+
{ tableName: "fruit_notes" },
|
|
119
|
+
{ createConfig: { tableName: "fruit_tags" } },
|
|
120
|
+
],
|
|
121
|
+
},
|
|
122
|
+
{ tableName: "open_table" },
|
|
123
|
+
],
|
|
124
|
+
};
|
|
125
|
+
it("collects writable view + panel tables for the role", () => {
|
|
126
|
+
const req = { config, session: { user: { role: "editor" } } };
|
|
127
|
+
expect([...writableTables(req)].sort()).toEqual([
|
|
128
|
+
"fruit_notes",
|
|
129
|
+
"fruit_tags",
|
|
130
|
+
"fruits",
|
|
131
|
+
"open_table",
|
|
132
|
+
]);
|
|
133
|
+
});
|
|
134
|
+
it("only collects unrole'd tables for an unknown role", () => {
|
|
135
|
+
const req = { config, session: { user: { role: "stranger" } } };
|
|
136
|
+
expect([...writableTables(req)]).toEqual(["open_table"]);
|
|
137
|
+
});
|
|
138
|
+
it("an unrole'd view is writable by any role", () => {
|
|
139
|
+
const req = { config, session: { user: { role: "editor" } } };
|
|
140
|
+
expect(writableTables(req).has("open_table")).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("looksLikeSqlBreakout", () => {
|
|
145
|
+
it("rejects every blocked pattern", () => {
|
|
146
|
+
const attacks = [
|
|
147
|
+
"1=1; DROP TABLE fruits",
|
|
148
|
+
"1=1 -- comment",
|
|
149
|
+
"1=1 /* c */",
|
|
150
|
+
"a UNION SELECT b",
|
|
151
|
+
"x INTO OUTFILE '/tmp/f'",
|
|
152
|
+
"x INTO DUMPFILE '/tmp/f'",
|
|
153
|
+
"SLEEP(10)",
|
|
154
|
+
"BENCHMARK(1000, MD5('a'))",
|
|
155
|
+
"LOAD_FILE('/etc/passwd')",
|
|
156
|
+
];
|
|
157
|
+
for (const a of attacks) {
|
|
158
|
+
expect(looksLikeSqlBreakout(a)).toBe(true);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
it("accepts ordinary filter fragments", () => {
|
|
162
|
+
expect(looksLikeSqlBreakout("age < 50 AND name = 'pomme'")).toBe(false);
|
|
163
|
+
expect(looksLikeSqlBreakout("price > 10.5")).toBe(false);
|
|
164
|
+
});
|
|
165
|
+
it("ignores non-strings", () => {
|
|
166
|
+
expect(looksLikeSqlBreakout(undefined)).toBe(false);
|
|
167
|
+
expect(looksLikeSqlBreakout(42)).toBe(false);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("sqlAnd", () => {
|
|
172
|
+
it("joins non-empty conditions with AND, each parenthesized", () => {
|
|
173
|
+
expect(sqlAnd(["a = 1", "b = 2"])).toBe("(a = 1) AND (b = 2)");
|
|
174
|
+
});
|
|
175
|
+
it("returns the single condition as-is", () => {
|
|
176
|
+
expect(sqlAnd(["a = 1"])).toBe("a = 1");
|
|
177
|
+
});
|
|
178
|
+
it("skips falsy/empty conditions", () => {
|
|
179
|
+
expect(sqlAnd([null, "", "a = 1", undefined])).toBe("a = 1");
|
|
180
|
+
});
|
|
181
|
+
it("returns null when nothing remains", () => {
|
|
182
|
+
expect(sqlAnd([null, ""])).toBeNull();
|
|
183
|
+
expect(sqlAnd([])).toBeNull();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("evalExpression", () => {
|
|
188
|
+
it("evaluates an expression against record fields", () => {
|
|
189
|
+
expect(
|
|
190
|
+
evalExpression("weight + ' kg'", { weight: 42 }),
|
|
191
|
+
).toBe("42 kg");
|
|
192
|
+
});
|
|
193
|
+
it("handles object fields via JSON", () => {
|
|
194
|
+
expect(evalExpression("user.name", { user: { name: "bob" } })).toBe("bob");
|
|
195
|
+
});
|
|
196
|
+
it("escapes quotes and strips newlines in strings", () => {
|
|
197
|
+
expect(
|
|
198
|
+
evalExpression("text", { text: "l'orange" }),
|
|
199
|
+
).toBe("l'orange");
|
|
200
|
+
expect(evalExpression("text", { text: "a\nb" })).toBe("ab");
|
|
201
|
+
});
|
|
202
|
+
it("returns 'Error' when evaluation fails", () => {
|
|
203
|
+
expect(evalExpression("notAVar + 1", {})).toBe("Error");
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("getFormattedValue", () => {
|
|
208
|
+
it("prefers formatValueForExport", async () => {
|
|
209
|
+
const column = {
|
|
210
|
+
name: "a",
|
|
211
|
+
formatValueForExport: () => "export",
|
|
212
|
+
formatValue: () => "display",
|
|
213
|
+
};
|
|
214
|
+
await expect(getFormattedValue(column, { a: 1 })).resolves.toBe("export");
|
|
215
|
+
});
|
|
216
|
+
it("then formattedValue (evaluated)", async () => {
|
|
217
|
+
const column = { name: "a", formattedValue: "a + '!'" };
|
|
218
|
+
await expect(getFormattedValue(column, { a: "x" })).resolves.toBe("x!");
|
|
219
|
+
});
|
|
220
|
+
it("then formatValue", async () => {
|
|
221
|
+
const column = { name: "a", formatValue: (r: any) => r.a + "?" };
|
|
222
|
+
await expect(getFormattedValue(column, { a: "x" })).resolves.toBe("x?");
|
|
223
|
+
});
|
|
224
|
+
it("tinyint renders Oui/Non", async () => {
|
|
225
|
+
await expect(getFormattedValue({ dataType: "tinyint", name: "a" }, { a: 1 })).resolves.toBe("Oui");
|
|
226
|
+
await expect(getFormattedValue({ dataType: "tinyint", name: "a" }, { a: 0 })).resolves.toBe("Non");
|
|
227
|
+
});
|
|
228
|
+
it("falls back to the raw value", async () => {
|
|
229
|
+
await expect(getFormattedValue({ name: "a" }, { a: "raw" })).resolves.toBe("raw");
|
|
230
|
+
});
|
|
231
|
+
it("renders null/undefined as empty string", async () => {
|
|
232
|
+
await expect(getFormattedValue({ name: "a" }, {})).resolves.toBe("");
|
|
233
|
+
await expect(getFormattedValue({ name: "a" }, { a: null })).resolves.toBe("");
|
|
234
|
+
});
|
|
235
|
+
it("exposes column.options on the record (documented mutation)", async () => {
|
|
236
|
+
const record: any = {};
|
|
237
|
+
await getFormattedValue({ name: "a", options: ["x"] }, record);
|
|
238
|
+
expect(record.options).toEqual(["x"]);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe("formatValueForInsertion", () => {
|
|
243
|
+
it("maps falsy (but not 0) to NULL", () => {
|
|
244
|
+
expect(formatValueForInsertion(null, "MYSQL")).toBe("NULL");
|
|
245
|
+
expect(formatValueForInsertion(undefined, "MYSQL")).toBe("NULL");
|
|
246
|
+
expect(formatValueForInsertion("", "MYSQL")).toBe("NULL");
|
|
247
|
+
expect(formatValueForInsertion(false, "MYSQL")).toBe("NULL");
|
|
248
|
+
expect(formatValueForInsertion(0, "MYSQL")).toBe(0);
|
|
249
|
+
});
|
|
250
|
+
it("serializes dates as ISO", () => {
|
|
251
|
+
const d = new Date("2024-01-02T03:04:05.000Z");
|
|
252
|
+
expect(formatValueForInsertion(d, "MYSQL")).toBe("2024-01-02T03:04:05.000Z");
|
|
253
|
+
});
|
|
254
|
+
it("quotes strings with dialect-appropriate escaping", () => {
|
|
255
|
+
expect(formatValueForInsertion("l'orange", "MYSQL")).toBe("'l\\'orange'");
|
|
256
|
+
expect(formatValueForInsertion("l'orange", "POSTGRESQL")).toBe("'l''orange'");
|
|
257
|
+
});
|
|
258
|
+
it("serializes objects as JSON literals", () => {
|
|
259
|
+
expect(formatValueForInsertion({ a: "b'c" }, "MYSQL")).toBe(
|
|
260
|
+
'\'{"a":"b\\\'c"}\'',
|
|
261
|
+
);
|
|
262
|
+
});
|
|
263
|
+
it("serializes booleans as TRUE (false is swallowed as NULL — documented quirk)", () => {
|
|
264
|
+
expect(formatValueForInsertion(true, "MYSQL")).toBe("TRUE");
|
|
265
|
+
expect(formatValueForInsertion(false, "POSTGRESQL")).toBe("NULL");
|
|
266
|
+
});
|
|
267
|
+
it("passes numbers through", () => {
|
|
268
|
+
expect(formatValueForInsertion(12.5, "MYSQL")).toBe(12.5);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe("collectAllowedQueries", () => {
|
|
273
|
+
it("collects `query` and *Query string props recursively", () => {
|
|
274
|
+
const config = {
|
|
275
|
+
views: [
|
|
276
|
+
{
|
|
277
|
+
recordViewPanels: [
|
|
278
|
+
{ query: " SELECT 1 " },
|
|
279
|
+
{ notificationBadge: { query: "SELECT 2" } },
|
|
280
|
+
],
|
|
281
|
+
},
|
|
282
|
+
{ columns: [{ autocompleteListQuery: "SELECT 3" }] },
|
|
283
|
+
],
|
|
284
|
+
};
|
|
285
|
+
expect(collectAllowedQueries(config)).toEqual(
|
|
286
|
+
new Set(["SELECT 1", "SELECT 2", "SELECT 3"]),
|
|
287
|
+
);
|
|
288
|
+
});
|
|
289
|
+
it("ignores non-string and non-matching values", () => {
|
|
290
|
+
expect(collectAllowedQueries({ query: 42, queryString: "no" })).toEqual(
|
|
291
|
+
new Set(),
|
|
292
|
+
);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe("enrichConfig", () => {
|
|
297
|
+
it("resolves primaryId from PRI columns and flags the config enriched", async () => {
|
|
298
|
+
const db = new FakeDb();
|
|
299
|
+
db.schemaRows = [
|
|
300
|
+
schemaRow("fruits", "id", { primary: true, dataType: "int" }),
|
|
301
|
+
];
|
|
302
|
+
const config = baseConfig();
|
|
303
|
+
await enrichConfig(config, db);
|
|
304
|
+
expect(config.views[0].primaryId).toBe("id");
|
|
305
|
+
expect(config.enriched).toBe(true);
|
|
306
|
+
expect(config.views[0].__id__).toMatch(/^[a-f0-9]{64}$/);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("does nothing on db error", async () => {
|
|
310
|
+
const db = new FakeDb();
|
|
311
|
+
// information_schema is intercepted, so simulate the error there
|
|
312
|
+
const origQuery = db.query.bind(db);
|
|
313
|
+
db.query = async (sql: string) => {
|
|
314
|
+
if (sql.includes("information_schema")) {
|
|
315
|
+
return { err: new Error("boom"), rows: null };
|
|
316
|
+
}
|
|
317
|
+
return origQuery(sql);
|
|
318
|
+
};
|
|
319
|
+
const config = baseConfig();
|
|
320
|
+
await enrichConfig(config, db);
|
|
321
|
+
expect(config.enriched).toBeUndefined();
|
|
322
|
+
expect(config.views[0].primaryId).toBeUndefined();
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
describe("enrichColumn", () => {
|
|
327
|
+
const info = [
|
|
328
|
+
schemaRow("fruits", "title", { nullable: false, dataType: "varchar" }),
|
|
329
|
+
schemaRow("fruits", "state", { dataType: "enum", columnType: "enum('a','b')" }),
|
|
330
|
+
schemaRow("fruits", "body", { nullable: true, dataType: "text" }),
|
|
331
|
+
];
|
|
332
|
+
|
|
333
|
+
it("assigns an __id__ and infers dataType + required from the schema", () => {
|
|
334
|
+
const column: any = { label: "Titre", name: "title" };
|
|
335
|
+
enrichColumn(column, "fruits", info);
|
|
336
|
+
expect(column.__id__).toMatch(/^[a-f0-9]{64}$/);
|
|
337
|
+
expect(column.dataType).toBe("varchar");
|
|
338
|
+
expect(column.required).toBe(true);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("does not force required on nullable/text columns", () => {
|
|
342
|
+
const column: any = { label: "Body", name: "body" };
|
|
343
|
+
enrichColumn(column, "fruits", info);
|
|
344
|
+
expect(column.required).toBeUndefined();
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("converts enum columns to select with parsed options", () => {
|
|
348
|
+
const column: any = { label: "State", name: "state" };
|
|
349
|
+
enrichColumn(column, "fruits", info);
|
|
350
|
+
expect(column.dataType).toBe("select");
|
|
351
|
+
expect(column.options).toEqual(["a", "b"]);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("rewrites richText column names to the content field", () => {
|
|
355
|
+
const column: any = { label: "Body", dataType: "richText", htmlField: "html", contentField: "body" };
|
|
356
|
+
enrichColumn(column, "fruits", info);
|
|
357
|
+
expect(column.name).toBe("body");
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("adds a baseUrl for file columns with s3/gcp config", () => {
|
|
361
|
+
const column: any = {
|
|
362
|
+
label: "Img",
|
|
363
|
+
dataType: "file",
|
|
364
|
+
name: "img",
|
|
365
|
+
s3Config: { host: "s3.example.com", bucket: "b" },
|
|
366
|
+
};
|
|
367
|
+
enrichColumn(column, "fruits", info);
|
|
368
|
+
expect(column.baseUrl).toBe("https://s3.example.com/b/");
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("keeps explicit dataType when the schema has an unknown column", () => {
|
|
372
|
+
const column: any = { label: "X", name: "ghost", dataType: "select" };
|
|
373
|
+
enrichColumn(column, "fruits", info);
|
|
374
|
+
expect(column.dataType).toBe("select");
|
|
375
|
+
});
|
|
376
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import dbManager from "../src/utils/dbManager";
|
|
2
|
+
|
|
3
|
+
jest.mock("mysql", () => ({ createPool: jest.fn() }));
|
|
4
|
+
jest.mock("mysql2", () => ({ createPool: jest.fn() }));
|
|
5
|
+
jest.mock("pg", () => ({ Pool: jest.fn() }));
|
|
6
|
+
|
|
7
|
+
import { createPool as mysqlCreatePool } from "mysql";
|
|
8
|
+
import { createPool as mysql2CreatePool } from "mysql2";
|
|
9
|
+
import { Pool as PgPool } from "pg";
|
|
10
|
+
|
|
11
|
+
const mockMysqlCreatePool = mysqlCreatePool as unknown as jest.Mock;
|
|
12
|
+
const mockMysql2CreatePool = mysql2CreatePool as unknown as jest.Mock;
|
|
13
|
+
const mockPgPool = PgPool as unknown as jest.Mock;
|
|
14
|
+
|
|
15
|
+
// Pool-construction wiring only; driver interaction is covered in
|
|
16
|
+
// tests/integration/dbManager.integration.test.ts.
|
|
17
|
+
describe("dbManager.init (pool wiring)", () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
mockMysqlCreatePool.mockClear();
|
|
20
|
+
mockMysql2CreatePool.mockClear();
|
|
21
|
+
mockPgPool.mockClear();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const mysqlConfig = {
|
|
25
|
+
mysql: {
|
|
26
|
+
host: "db.local",
|
|
27
|
+
user: "root",
|
|
28
|
+
password: "secret",
|
|
29
|
+
database: "galadmin",
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
it("creates a mysql pool with the expected options", () => {
|
|
34
|
+
const manager = new dbManager();
|
|
35
|
+
manager.init(mysqlConfig);
|
|
36
|
+
expect(mockMysqlCreatePool).toHaveBeenCalledTimes(1);
|
|
37
|
+
expect(mockMysqlCreatePool).toHaveBeenCalledWith(
|
|
38
|
+
expect.objectContaining({
|
|
39
|
+
host: "db.local",
|
|
40
|
+
user: "root",
|
|
41
|
+
password: "secret",
|
|
42
|
+
database: "galadmin",
|
|
43
|
+
port: 3306,
|
|
44
|
+
charset: "UTF8MB4_GENERAL_CI",
|
|
45
|
+
dateStrings: true,
|
|
46
|
+
multipleStatements: false,
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
expect(manager.dbType).toBe("MYSQL");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("honours a custom mysql port", () => {
|
|
53
|
+
const manager = new dbManager();
|
|
54
|
+
manager.init({
|
|
55
|
+
mysql: { ...mysqlConfig.mysql, port: 3307 },
|
|
56
|
+
});
|
|
57
|
+
expect(mockMysqlCreatePool).toHaveBeenCalledWith(
|
|
58
|
+
expect.objectContaining({ port: 3307 }),
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("keeps multipleStatements OFF unless explicitly opted in", () => {
|
|
63
|
+
const manager = new dbManager();
|
|
64
|
+
manager.init({
|
|
65
|
+
mysql: { ...mysqlConfig.mysql, multipleStatements: true },
|
|
66
|
+
});
|
|
67
|
+
expect(mockMysqlCreatePool).toHaveBeenCalledWith(
|
|
68
|
+
expect.objectContaining({ multipleStatements: true }),
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("switches to the mysql2 driver with useMysql2", () => {
|
|
73
|
+
const manager = new dbManager();
|
|
74
|
+
manager.init({ ...mysqlConfig, useMysql2: true });
|
|
75
|
+
expect(mockMysqlCreatePool).not.toHaveBeenCalled();
|
|
76
|
+
expect(mockMysql2CreatePool).toHaveBeenCalledTimes(1);
|
|
77
|
+
expect(manager.dbType).toBe("MYSQL");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("creates a pg pool with port and ssl defaults", () => {
|
|
81
|
+
const manager = new dbManager();
|
|
82
|
+
manager.init({
|
|
83
|
+
postgresql: {
|
|
84
|
+
host: "pg.local",
|
|
85
|
+
user: "admin",
|
|
86
|
+
password: "secret",
|
|
87
|
+
database: "galadmin",
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
expect(mockPgPool).toHaveBeenCalledTimes(1);
|
|
91
|
+
expect(mockPgPool).toHaveBeenCalledWith(
|
|
92
|
+
expect.objectContaining({
|
|
93
|
+
host: "pg.local",
|
|
94
|
+
user: "admin",
|
|
95
|
+
password: "secret",
|
|
96
|
+
database: "galadmin",
|
|
97
|
+
port: 5432,
|
|
98
|
+
ssl: undefined,
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
expect(manager.dbType).toBe("POSTGRESQL");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("passes the ssl option through to the pg pool", () => {
|
|
105
|
+
const manager = new dbManager();
|
|
106
|
+
const ssl = { rejectUnauthorized: false };
|
|
107
|
+
manager.init({
|
|
108
|
+
postgresql: {
|
|
109
|
+
host: "pg.local",
|
|
110
|
+
user: "admin",
|
|
111
|
+
password: "secret",
|
|
112
|
+
database: "galadmin",
|
|
113
|
+
ssl,
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
expect(mockPgPool).toHaveBeenCalledWith(
|
|
117
|
+
expect.objectContaining({ ssl }),
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("leaves the manager uninitialised without a database config", () => {
|
|
122
|
+
const manager = new dbManager();
|
|
123
|
+
manager.init({});
|
|
124
|
+
expect(mockMysqlCreatePool).not.toHaveBeenCalled();
|
|
125
|
+
expect(mockMysql2CreatePool).not.toHaveBeenCalled();
|
|
126
|
+
expect(mockPgPool).not.toHaveBeenCalled();
|
|
127
|
+
expect(manager.dbType).toBeUndefined();
|
|
128
|
+
});
|
|
129
|
+
});
|