@galadrim/galadmin 0.1.236 → 0.2.7

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.
Files changed (66) hide show
  1. package/.github/workflows/main.yml +34 -3
  2. package/README.md +65 -0
  3. package/dist/api.d.ts +16 -0
  4. package/dist/api.js +240 -189
  5. package/dist/api.js.map +1 -1
  6. package/dist/index.js +25 -3
  7. package/dist/index.js.map +1 -1
  8. package/dist/tsconfig.tsbuildinfo +1 -1
  9. package/dist/types/config.d.ts +6 -1
  10. package/dist/utils/dbManager.d.ts +5 -0
  11. package/dist/utils/dbManager.js +146 -11
  12. package/dist/utils/dbManager.js.map +1 -1
  13. package/dist/utils/imageDimensions.d.ts +5 -0
  14. package/dist/utils/imageDimensions.js +98 -0
  15. package/dist/utils/imageDimensions.js.map +1 -0
  16. package/jest.config.js +43 -0
  17. package/jest.integration.config.js +16 -0
  18. package/package.json +30 -10
  19. package/src/api.ts +284 -217
  20. package/src/front/src/App.test.js +28 -0
  21. package/src/front/src/components/OutsideClickHandler.test.js +53 -0
  22. package/src/front/src/components/RedirectToFirstView.test.js +38 -0
  23. package/src/front/src/utils/checkRequiredColumns.test.js +46 -0
  24. package/src/front/src/utils/evalExpression.test.js +29 -0
  25. package/src/index.ts +26 -3
  26. package/src/types/config.ts +13 -1
  27. package/src/utils/dbManager.ts +243 -102
  28. package/src/utils/imageDimensions.ts +112 -0
  29. package/tests/__mocks__/@aws-sdk/client-s3.js +6 -0
  30. package/tests/__mocks__/@aws-sdk/s3-request-presigner.js +3 -0
  31. package/tests/__mocks__/@google-cloud/storage.js +7 -0
  32. package/tests/__mocks__/argon2.js +3 -0
  33. package/tests/__mocks__/axios.js +7 -0
  34. package/tests/__mocks__/connect-pg-simple.js +24 -0
  35. package/tests/__mocks__/express-mysql-session.js +24 -0
  36. package/tests/__mocks__/mysql2/promise.js +3 -0
  37. package/tests/__mocks__/pg.js +3 -0
  38. package/tests/__mocks__/rollup.js +5 -0
  39. package/tests/__mocks__/ssh2-sftp-client.js +10 -0
  40. package/tests/api-helpers.test.ts +376 -0
  41. package/tests/dbManager.init.test.ts +129 -0
  42. package/tests/dbManager.test.ts +265 -0
  43. package/tests/edge-cases.test.ts +167 -0
  44. package/tests/helpers/app.ts +69 -0
  45. package/tests/helpers/fakeDb.ts +66 -0
  46. package/tests/helpers/fakePool.ts +76 -0
  47. package/tests/helpers/fixtures.ts +68 -0
  48. package/tests/imageDimensions.test.ts +111 -0
  49. package/tests/index.sessionStore.test.ts +19 -0
  50. package/tests/index.test.ts +412 -0
  51. package/tests/integration/__mocks__/axios.js +3 -0
  52. package/tests/integration/dbHarness.ts +381 -0
  53. package/tests/integration/dbManager.integration.test.ts +69 -0
  54. package/tests/integration/globalSetup.ts +43 -0
  55. package/tests/integration/globalTeardown.ts +42 -0
  56. package/tests/integration/routes.authorization.integration.test.ts +319 -0
  57. package/tests/integration/routes.config.integration.test.ts +189 -0
  58. package/tests/integration/routes.crud.integration.test.ts +805 -0
  59. package/tests/integration/routes.custom.integration.test.ts +376 -0
  60. package/tests/integration/routes.foreign.integration.test.ts +306 -0
  61. package/tests/integration/routes.importExport.integration.test.ts +404 -0
  62. package/tests/integration/routes.integration.test.ts +366 -0
  63. package/tests/integration/routes.postgresql.integration.test.ts +88 -0
  64. package/tests/integration/routes.records.integration.test.ts +634 -0
  65. package/tests/integration/routes.upload.integration.test.ts +425 -0
  66. package/tests/setup/silence-console.ts +15 -0
@@ -0,0 +1,112 @@
1
+ import { promises as fsPromises } from "fs";
2
+
3
+ const PNG_SIGNATURE = Buffer.from([
4
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
5
+ ]);
6
+
7
+ // Enough to hold a PNG IHDR, a GIF logical screen descriptor or the JPEG
8
+ // segments (APPn/EXIF can be large) preceding the SOFn frame header.
9
+ const HEADER_LIMIT = 2 * 1024 * 1024;
10
+
11
+ export type ImageDimensions = { width: number; height: number };
12
+
13
+ async function readHeader(filePath: string): Promise<Buffer> {
14
+ const handle = await fsPromises.open(filePath, "r");
15
+ try {
16
+ const length = Math.min(HEADER_LIMIT, (await handle.stat()).size);
17
+ const buffer = Buffer.alloc(length);
18
+ await handle.read(buffer, 0, length, 0);
19
+ return buffer;
20
+ } finally {
21
+ await handle.close();
22
+ }
23
+ }
24
+
25
+ function parsePngDimensions(header: Buffer): ImageDimensions | null {
26
+ if (header.length < 24 || header.toString("latin1", 12, 16) !== "IHDR") {
27
+ return null;
28
+ }
29
+ const width = header.readUInt32BE(16);
30
+ const height = header.readUInt32BE(20);
31
+ return width > 0 && height > 0 ? { width, height } : null;
32
+ }
33
+
34
+ function parseGifDimensions(header: Buffer): ImageDimensions | null {
35
+ const signature = header.toString("latin1", 0, 6);
36
+ if (signature !== "GIF87a" && signature !== "GIF89a") {
37
+ return null;
38
+ }
39
+ const width = header.readUInt16LE(6);
40
+ const height = header.readUInt16LE(8);
41
+ return width > 0 && height > 0 ? { width, height } : null;
42
+ }
43
+
44
+ function parseJpegDimensions(header: Buffer): ImageDimensions | null {
45
+ let offset = 2;
46
+ while (offset + 4 <= header.length) {
47
+ if (header[offset] !== 0xff) {
48
+ return null;
49
+ }
50
+ const marker = header[offset + 1];
51
+ // Standalone markers (SOI, TEM, RST, EOI) carry no length field
52
+ if (
53
+ marker === 0x01 ||
54
+ marker === 0xd8 ||
55
+ marker === 0xd9 ||
56
+ (marker >= 0xd0 && marker <= 0xd7)
57
+ ) {
58
+ offset += 2;
59
+ continue;
60
+ }
61
+ // Start of scan: frame header must have appeared before this point
62
+ if (marker === 0xda || marker === 0xd9) {
63
+ return null;
64
+ }
65
+ const segmentLength = header.readUInt16BE(offset + 2);
66
+ if (segmentLength < 2) {
67
+ return null;
68
+ }
69
+ const isFrameHeader =
70
+ marker >= 0xc0 &&
71
+ marker <= 0xcf &&
72
+ marker !== 0xc4 && // DHT
73
+ marker !== 0xc8 && // JPG
74
+ marker !== 0xcc; // DAC
75
+ if (isFrameHeader) {
76
+ if (offset + 9 > header.length) {
77
+ return null;
78
+ }
79
+ const height = header.readUInt16BE(offset + 5);
80
+ const width = header.readUInt16BE(offset + 7);
81
+ return width > 0 && height > 0 ? { width, height } : null;
82
+ }
83
+ offset += 2 + segmentLength;
84
+ }
85
+ return null;
86
+ }
87
+
88
+ export async function getImageDimensions(
89
+ filePath: string,
90
+ ): Promise<ImageDimensions | null> {
91
+ let header: Buffer;
92
+ try {
93
+ header = await readHeader(filePath);
94
+ } catch {
95
+ return null;
96
+ }
97
+ if (header.length >= 8 && header.subarray(0, 8).equals(PNG_SIGNATURE)) {
98
+ return parsePngDimensions(header);
99
+ }
100
+ if (header.length >= 10 && header.toString("latin1", 0, 3) === "GIF") {
101
+ return parseGifDimensions(header);
102
+ }
103
+ if (
104
+ header.length >= 3 &&
105
+ header[0] === 0xff &&
106
+ header[1] === 0xd8 &&
107
+ header[2] === 0xff
108
+ ) {
109
+ return parseJpegDimensions(header);
110
+ }
111
+ return null;
112
+ }
@@ -0,0 +1,6 @@
1
+ const send = jest.fn().mockResolvedValue({});
2
+ module.exports = {
3
+ S3Client: jest.fn(() => ({ send })),
4
+ PutObjectCommand: jest.fn((input) => ({ input })),
5
+ __send: send,
6
+ };
@@ -0,0 +1,3 @@
1
+ module.exports = {
2
+ getSignedUrl: jest.fn().mockResolvedValue("https://signed.example.com/put-object"),
3
+ };
@@ -0,0 +1,7 @@
1
+ const upload = jest.fn().mockResolvedValue([{}]);
2
+ const bucket = jest.fn(() => ({ upload }));
3
+ module.exports = {
4
+ Storage: jest.fn(() => ({ bucket })),
5
+ __bucket: bucket,
6
+ __upload: upload,
7
+ };
@@ -0,0 +1,3 @@
1
+ module.exports = {
2
+ verify: jest.fn().mockResolvedValue(true),
3
+ };
@@ -0,0 +1,7 @@
1
+ const axios = {
2
+ get: jest.fn(),
3
+ post: jest.fn(),
4
+ put: jest.fn(),
5
+ };
6
+ module.exports = axios;
7
+ module.exports.default = axios;
@@ -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,3 @@
1
+ module.exports = {
2
+ createPool: jest.fn(() => ({})),
3
+ };
@@ -0,0 +1,3 @@
1
+ module.exports = {
2
+ Pool: jest.fn(() => ({})),
3
+ };
@@ -0,0 +1,5 @@
1
+ const generate = jest
2
+ .fn()
3
+ .mockResolvedValue({ output: [{ code: "// mocked bundle" }] });
4
+ const rollup = jest.fn().mockResolvedValue({ generate });
5
+ module.exports = { rollup, __generate: generate };
@@ -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
+ });