@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,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
+ });
@@ -0,0 +1,265 @@
1
+ import {
2
+ convertQuery,
3
+ quotePostgresQuery,
4
+ quoteIdentifier,
5
+ } from "../src/utils/dbManager";
6
+
7
+ /**
8
+ * Pure string-transformation helpers of dbManager. Pool wiring and driver
9
+ * interaction run against real engines in tests/integration/
10
+ * dbManager.integration.test.ts.
11
+ */
12
+
13
+ describe("convertQuery (MySQL -> PostgreSQL placeholder conversion)", () => {
14
+ it("converts ? placeholders to $N in order", () => {
15
+ const { pgQuery, pgParams } = convertQuery(
16
+ "SELECT * FROM t WHERE a = ? AND b = ?",
17
+ ["x", 2],
18
+ );
19
+ expect(pgQuery).toBe("SELECT * FROM t WHERE a = $1 AND b = $2");
20
+ expect(pgParams).toEqual(["x", 2]);
21
+ });
22
+
23
+ it("substitutes ?? identifiers inline (unquoted)", () => {
24
+ const { pgQuery, pgParams } = convertQuery("SELECT * FROM ?? WHERE id = ?", [
25
+ "fruits",
26
+ 7,
27
+ ]);
28
+ expect(pgQuery).toBe("SELECT * FROM fruits WHERE id = $1");
29
+ expect(pgParams).toEqual([7]);
30
+ });
31
+
32
+ it("handles mixed ?? and ? sequences", () => {
33
+ const { pgQuery, pgParams } = convertQuery(
34
+ "UPDATE ?? SET `a` = ? WHERE ?? = ?",
35
+ ["fruits", 1, "fruits", 2],
36
+ );
37
+ expect(pgQuery).toBe("UPDATE fruits SET `a` = $1 WHERE fruits = $2");
38
+ expect(pgParams).toEqual([1, 2]);
39
+ });
40
+
41
+ it("handles a query without placeholders", () => {
42
+ const { pgQuery, pgParams } = convertQuery("SELECT 1", []);
43
+ expect(pgQuery).toBe("SELECT 1");
44
+ expect(pgParams).toEqual([]);
45
+ });
46
+ });
47
+
48
+ describe("quotePostgresQuery", () => {
49
+ it("converts backticks to double quotes", () => {
50
+ expect(quotePostgresQuery("SELECT * FROM `fruits`")).toBe(
51
+ 'SELECT * FROM "fruits"',
52
+ );
53
+ });
54
+
55
+ it("quotes bare identifiers", () => {
56
+ expect(quotePostgresQuery("SELECT fruitName FROM fruits")).toBe(
57
+ 'SELECT "fruitName" FROM "fruits"',
58
+ );
59
+ });
60
+
61
+ it("quotes dotted identifiers", () => {
62
+ expect(quotePostgresQuery("SELECT fruits.id FROM fruits")).toBe(
63
+ 'SELECT "fruits"."id" FROM "fruits"',
64
+ );
65
+ });
66
+
67
+ it("leaves reserved keywords unquoted", () => {
68
+ const query = "SELECT COUNT(*) FROM fruits WHERE a = 1 ORDER BY a DESC";
69
+ const result = quotePostgresQuery(query);
70
+ expect(result).toContain("COUNT(*)");
71
+ expect(result).toContain("ORDER BY");
72
+ expect(result).toContain("DESC");
73
+ expect(result).not.toContain('"ORDER"');
74
+ expect(result).not.toContain('"COUNT"');
75
+ expect(result).not.toContain('"DESC"');
76
+ });
77
+
78
+ it("leaves the upsert keywords (ON CONFLICT / DO / EXCLUDED / RETURNING) unquoted", () => {
79
+ // Input as it arrives after convertQuery (no `??` placeholders left).
80
+ const query =
81
+ 'INSERT INTO fruits ("id", "fruitName") VALUES($1, $2) ON CONFLICT ("id") DO UPDATE SET "fruitName" = EXCLUDED."fruitName" RETURNING id';
82
+ const result = quotePostgresQuery(query);
83
+ expect(result).toContain("ON CONFLICT");
84
+ expect(result).toContain("DO UPDATE");
85
+ expect(result).toContain('EXCLUDED."fruitName"');
86
+ expect(result).toContain("RETURNING");
87
+ expect(result).not.toContain('"CONFLICT"');
88
+ expect(result).not.toContain('"DO"');
89
+ expect(result).not.toContain('"EXCLUDED"');
90
+ expect(result).not.toContain('"RETURNING"');
91
+ });
92
+
93
+ it("leaves string literals unquoted but quotes bare identifiers around them", () => {
94
+ expect(quotePostgresQuery("SELECT * FROM fruits WHERE name = 'pomme'")).toBe(
95
+ "SELECT * FROM \"fruits\" WHERE \"name\" = 'pomme'",
96
+ );
97
+ });
98
+
99
+ it("leaves $N placeholders unquoted", () => {
100
+ expect(quotePostgresQuery("SELECT * FROM fruits WHERE id = $1")).toBe(
101
+ 'SELECT * FROM "fruits" WHERE "id" = $1',
102
+ );
103
+ });
104
+
105
+ it("leaves numbers unquoted", () => {
106
+ expect(quotePostgresQuery("SELECT * FROM fruits LIMIT 10")).toContain(
107
+ "LIMIT 10",
108
+ );
109
+ });
110
+
111
+ it("converts LIKE to ::text ILIKE", () => {
112
+ expect(
113
+ quotePostgresQuery("SELECT * FROM fruits WHERE name LIKE '%abc%'"),
114
+ ).toBe(
115
+ "SELECT * FROM \"fruits\" WHERE \"name\"::text ILIKE '%abc%'",
116
+ );
117
+ });
118
+
119
+ it("converts ) LIKE to ) ILIKE", () => {
120
+ expect(
121
+ quotePostgresQuery("SELECT * FROM fruits WHERE (name) LIKE '%abc%'"),
122
+ ).toBe(
123
+ "SELECT * FROM \"fruits\" WHERE (\"name\") ILIKE '%abc%'",
124
+ );
125
+ });
126
+
127
+ it("quotes identifiers inside a count subquery", () => {
128
+ const result = quotePostgresQuery(
129
+ "SELECT COUNT(*) AS count FROM (SELECT * FROM fruits) s",
130
+ );
131
+ expect(result).toContain('FROM (SELECT * FROM "fruits")');
132
+ expect(result).toContain("COUNT(*)");
133
+ });
134
+ });
135
+
136
+ describe("quoteIdentifier", () => {
137
+ it("uses backticks for MySQL", () => {
138
+ expect(quoteIdentifier("fruits", "MYSQL")).toBe("`fruits`");
139
+ });
140
+
141
+ it("uses double quotes for PostgreSQL", () => {
142
+ expect(quoteIdentifier("fruits", "POSTGRESQL")).toBe('"fruits"');
143
+ });
144
+ });
145
+
146
+ describe("convertQuery / quotePostgresQuery (adversarial cases)", () => {
147
+ it("quotes identifiers containing digits and underscores", () => {
148
+ expect(quotePostgresQuery("SELECT address_2, col1 FROM t1")).toBe(
149
+ 'SELECT "address_2", "col1" FROM "t1"',
150
+ );
151
+ });
152
+
153
+ it("does not quote numeric-only words", () => {
154
+ expect(quotePostgresQuery("SELECT * FROM t LIMIT 123")).not.toContain(
155
+ '"123"',
156
+ );
157
+ });
158
+
159
+ it("keeps placeholders for parameters arriving after identifier interpolation", () => {
160
+ const { pgQuery, pgParams } = convertQuery(
161
+ "UPDATE ?? SET `a` = ?, `b` = ? WHERE ?? IN(?, ?)",
162
+ ["fruits", 1, "x", "fruits", 2, 3],
163
+ );
164
+ expect(pgQuery).toBe(
165
+ "UPDATE fruits SET `a` = $1, `b` = $2 WHERE fruits IN($3, $4)",
166
+ );
167
+ expect(pgParams).toEqual([1, "x", 2, 3]);
168
+ });
169
+
170
+ it("keeps a literal '?' inside a string literal out of the placeholder conversion", () => {
171
+ // Placeholders only carry meaning outside '…' literals: the LIKE pattern
172
+ // below is interpolated before conversion and its '?' must survive verbatim.
173
+ // (`??` consumes its parameter inline and adds nothing to pgParams.)
174
+ const { pgQuery, pgParams } = convertQuery(
175
+ "SELECT * FROM ?? WHERE fruitName::text ILIKE '%what?%'",
176
+ ["fruits"],
177
+ );
178
+ expect(pgQuery).toBe(
179
+ "SELECT * FROM fruits WHERE fruitName::text ILIKE '%what?%'",
180
+ );
181
+ expect(pgParams).toEqual([]);
182
+ });
183
+
184
+ it("keeps doubled quotes inside string literals without leaving string mode", () => {
185
+ const { pgQuery, pgParams } = convertQuery(
186
+ "SELECT * FROM ?? WHERE a = 'l''orange?' AND b = ?",
187
+ ["fruits", 5],
188
+ );
189
+ expect(pgQuery).toBe(
190
+ "SELECT * FROM fruits WHERE a = 'l''orange?' AND b = $1",
191
+ );
192
+ expect(pgParams).toEqual([5]);
193
+ });
194
+
195
+ it("treats a backslash as an ordinary character in standard string literals", () => {
196
+ const { pgQuery, pgParams } = convertQuery(
197
+ "SELECT * FROM t WHERE path = 'C:\\' AND id = ?",
198
+ [7],
199
+ );
200
+ expect(pgQuery).toBe("SELECT * FROM t WHERE path = 'C:\\' AND id = $1");
201
+ expect(pgParams).toEqual([7]);
202
+ });
203
+
204
+ it("honours backslash escapes in E'…' string literals", () => {
205
+ const { pgQuery, pgParams } = convertQuery(
206
+ "SELECT * FROM t WHERE a = E'l\\'orange?' AND b = e'C:\\\\' AND c = ?",
207
+ [7],
208
+ );
209
+ expect(pgQuery).toBe(
210
+ "SELECT * FROM t WHERE a = E'l\\'orange?' AND b = e'C:\\\\' AND c = $1",
211
+ );
212
+ expect(pgParams).toEqual([7]);
213
+ });
214
+
215
+ it("does not treat an identifier ending in 'e' as an E'…' prefix", () => {
216
+ const { pgQuery, pgParams } = convertQuery(
217
+ "SELECT * FROM t WHERE code = some'C:\\' AND id = ?",
218
+ [7],
219
+ );
220
+ expect(pgQuery).toBe("SELECT * FROM t WHERE code = some'C:\\' AND id = $1");
221
+ expect(pgParams).toEqual([7]);
222
+ });
223
+
224
+ it("copies comments verbatim, ignoring quotes and placeholders inside them", () => {
225
+ const { pgQuery, pgParams } = convertQuery(
226
+ "-- l'utilisateur ?\nSELECT * FROM t /* l'id ? */ WHERE id = ? -- fin ?",
227
+ [7],
228
+ );
229
+ expect(pgQuery).toBe(
230
+ "-- l'utilisateur ?\nSELECT * FROM t /* l'id ? */ WHERE id = $1 -- fin ?",
231
+ );
232
+ expect(pgParams).toEqual([7]);
233
+ });
234
+
235
+ it("ignores reserved words written in mixed case", () => {
236
+ const result = quotePostgresQuery(
237
+ "select count(*) from fruits where a = 1 group by a order by a desc",
238
+ );
239
+ expect(result).not.toContain('"select"');
240
+ expect(result).not.toContain('"group"');
241
+ expect(result).toContain('"fruits"');
242
+ });
243
+
244
+ it("quotes dotted (table-qualified) identifiers segment by segment", () => {
245
+ expect(
246
+ quotePostgresQuery("SELECT * FROM fruits WHERE fruits.id = $1"),
247
+ ).toBe('SELECT * FROM "fruits" WHERE "fruits"."id" = $1');
248
+ });
249
+
250
+ it("keeps $N placeholders and IN lists intact while quoting identifiers", () => {
251
+ expect(
252
+ quotePostgresQuery("DELETE FROM fruits WHERE id IN($1, $2)"),
253
+ ).toBe('DELETE FROM "fruits" WHERE "id" IN($1, $2)');
254
+ });
255
+
256
+ it("leaves doubled quote escapes inside literals untouched while quoting", () => {
257
+ expect(
258
+ quotePostgresQuery(
259
+ "SELECT * FROM fruits WHERE fruitName LIKE '%l''orange%'",
260
+ ),
261
+ ).toBe(
262
+ "SELECT * FROM \"fruits\" WHERE \"fruitName\"::text ILIKE '%l''orange%'",
263
+ );
264
+ });
265
+ });
@@ -0,0 +1,167 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+ import * as request from "supertest";
5
+ import { buildApiApp } from "./helpers/app";
6
+ import { FakeDb } from "./helpers/fakeDb";
7
+ import {
8
+ baseConfig,
9
+ fruitSchema,
10
+ fruitView,
11
+ } from "./helpers/fixtures";
12
+ import { generateUniqueIdForObject } from "../src/api";
13
+
14
+ jest.mock("axios");
15
+ jest.mock("uuid", () => ({ v4: jest.fn(() => "test-uuid-1234-5678") }));
16
+
17
+ describe("column-name validation through the routes", () => {
18
+ let db: FakeDb;
19
+
20
+ beforeEach(() => {
21
+ db = new FakeDb();
22
+ db.schemaRows = fruitSchema();
23
+ });
24
+
25
+ it.each([
26
+ ["semicolon", { "a;b": "x" }],
27
+ ["space", { "a b": "x" }],
28
+ ["dash", { "a-b": "x" }],
29
+ ["backtick", { "`a`": "x" }],
30
+ ["quote", { "a'": "x" }],
31
+ ["unicode", { "café": "x" }],
32
+ ["empty key", { "": "x" }],
33
+ ["newline", { "a\nb": "x" }],
34
+ ])("POST /api/updateRecord rejects a %s column name with 400", async (
35
+ _label,
36
+ data,
37
+ ) => {
38
+ const app = buildApiApp(db, baseConfig());
39
+ await request(app)
40
+ .post("/api/updateRecord")
41
+ .send({ viewIndex: 0, primaryValue: 5, data })
42
+ .expect(400);
43
+ expect(db.dataQueries().length).toBe(0);
44
+ });
45
+
46
+ it("POST /api/createRecord rejects unsafe column names with 400", async () => {
47
+ const app = buildApiApp(db, baseConfig());
48
+ await request(app)
49
+ .post("/api/createRecord")
50
+ .send({ viewIndex: 0, data: { "a;b": "x" } })
51
+ .expect(400);
52
+ expect(db.dataQueries().length).toBe(0);
53
+ });
54
+
55
+ it("accepts long but plain identifiers", async () => {
56
+ const longName = "c".repeat(64);
57
+ db.queueRows({ insertId: 1 });
58
+ const app = buildApiApp(db, baseConfig());
59
+ await request(app)
60
+ .post("/api/updateRecord")
61
+ .send({ viewIndex: 0, primaryValue: 5, data: { [longName]: "x" } })
62
+ .expect(200);
63
+ expect(db.lastQuery().params).toEqual(["fruits", "x", "id", 5]);
64
+ });
65
+ });
66
+
67
+ describe("upload edge cases (local fs backend)", () => {
68
+ let db: FakeDb;
69
+ let uploadRoot: string;
70
+ let fileColumn: any;
71
+
72
+ beforeEach(() => {
73
+ db = new FakeDb();
74
+ db.schemaRows = fruitSchema();
75
+ uploadRoot = fs.mkdtempSync(path.join(os.tmpdir(), "galadmin-upload-"));
76
+ fs.mkdirSync(path.join(uploadRoot, "up"));
77
+ fileColumn = {
78
+ label: "Fichier",
79
+ name: "file",
80
+ publicPath: uploadRoot,
81
+ subdirectory: "/up",
82
+ };
83
+ });
84
+
85
+ afterEach(() => {
86
+ fs.rmSync(uploadRoot, { recursive: true, force: true });
87
+ });
88
+
89
+ const configFor = () =>
90
+ baseConfig({ views: [fruitView({ columns: [fileColumn] })] });
91
+
92
+ it("returns 500 for a malformed multipart body instead of hanging", async () => {
93
+ const app = buildApiApp(db, configFor());
94
+ await request(app)
95
+ .post("/api/uploadFile")
96
+ .query({
97
+ viewIndex: 0,
98
+ columnId: generateUniqueIdForObject(fileColumn),
99
+ })
100
+ .set("content-type", "multipart/form-data; boundary=XXBOUNDARYXX")
101
+ .send("this is not multipart data")
102
+ .expect(500);
103
+ });
104
+
105
+ it("returns 500 (ENOENT) when the target subdirectory does not exist", async () => {
106
+ // No mkdir: documents that a traversal-bearing subdirectory is not
107
+ // validated but fails closed because multiparty requires the dir.
108
+ fileColumn.subdirectory = "/../escape";
109
+ const app = buildApiApp(db, configFor());
110
+ await request(app)
111
+ .post("/api/uploadFile")
112
+ .query({
113
+ viewIndex: 0,
114
+ columnId: generateUniqueIdForObject(fileColumn),
115
+ })
116
+ .attach("file", Buffer.from("png"), { filename: "x.png" })
117
+ .expect(500);
118
+ expect(fs.existsSync(path.join(uploadRoot, "escape"))).toBe(false);
119
+ });
120
+
121
+ it("silently accepts an empty file", async () => {
122
+ const app = buildApiApp(db, configFor());
123
+ const res = await request(app)
124
+ .post("/api/uploadFile")
125
+ .query({
126
+ viewIndex: 0,
127
+ columnId: generateUniqueIdForObject(fileColumn),
128
+ })
129
+ .attach("file", Buffer.alloc(0), { filename: "empty.png" })
130
+ .expect(200);
131
+ expect(res.body.originalFilename).toBe("empty.png");
132
+ // The temp file was written into the target directory.
133
+ expect(fs.readdirSync(path.join(uploadRoot, "up")).length).toBe(1);
134
+ });
135
+ });
136
+
137
+ describe("CHARACTERIZATION: multi-query operations are not transactional", () => {
138
+ let db: FakeDb;
139
+
140
+ beforeEach(() => {
141
+ db = new FakeDb();
142
+ db.schemaRows = fruitSchema();
143
+ });
144
+
145
+ it("createRecord: a throwing createCallbackFunction does not fail the request", async () => {
146
+ // The INSERT is committed before the callback runs; reporting a 500 would
147
+ // invite a client retry that duplicates the row, so the callback error is
148
+ // logged and the request still answers 200.
149
+ const config = baseConfig({
150
+ views: [
151
+ fruitView({
152
+ createCallbackFunction: () => {
153
+ throw new Error("callback exploded");
154
+ },
155
+ }),
156
+ ],
157
+ });
158
+ db.queueRows({ insertId: 42 });
159
+ const app = buildApiApp(db, config);
160
+ await request(app)
161
+ .post("/api/createRecord")
162
+ .send({ viewIndex: 0, data: { fruitName: "pomme" } })
163
+ .expect(200);
164
+ expect(db.dataQueries().length).toBe(1);
165
+ expect(db.dataQueries()[0].sql).toContain("INSERT INTO ??");
166
+ });
167
+ });
@@ -0,0 +1,69 @@
1
+ import * as express from "express";
2
+ import api from "../../src/api";
3
+ import dbManager from "../../src/utils/dbManager";
4
+ import { FakeDb } from "./fakeDb";
5
+ import { FakePool } from "./fakePool";
6
+
7
+ export type SessionUser = { role: string; userId?: number } | null;
8
+
9
+ export type SessionMode =
10
+ | SessionUser // a user object, or null for "logged out" (session without user)
11
+ | "none"; // no session object at all (MW1 would throw on a bare router)
12
+
13
+ function appWithSession(sessionUser: SessionMode, router: any) {
14
+ const app = express();
15
+ app.use(express.json({ limit: "50mb" }));
16
+ app.use((req: any, _res, next) => {
17
+ if (sessionUser === "none") {
18
+ req.session = undefined;
19
+ } else {
20
+ req.session = { user: sessionUser };
21
+ }
22
+ next();
23
+ });
24
+ app.use("/api", router);
25
+ return app;
26
+ }
27
+
28
+ /**
29
+ * Builds an express app exposing the api router under /api, exactly like
30
+ * src/index.ts does, but with an injectable session stub so tests can pick
31
+ * the caller's role without a real session store.
32
+ */
33
+ export function buildApiApp(
34
+ db: FakeDb,
35
+ config: any,
36
+ sessionUser: SessionMode = { role: "root", userId: 1 },
37
+ ) {
38
+ return appWithSession(sessionUser, api(db, config));
39
+ }
40
+
41
+ /**
42
+ * Builds the same app on top of an externally provided db manager (real
43
+ * driver pool or FakePool). Used by the PostgreSQL pipeline unit tests and
44
+ * by the real-database integration suite.
45
+ */
46
+ export function buildAppForManager(
47
+ manager: any,
48
+ config: any,
49
+ sessionUser: SessionMode = { role: "root", userId: 1 },
50
+ ) {
51
+ return appWithSession(sessionUser, api(manager, config));
52
+ }
53
+
54
+ /**
55
+ * Builds the same app on top of a REAL dbManager whose driver pool is the
56
+ * scripted FakePool. The dialect conversion pipeline (MySQL -> PostgreSQL
57
+ * placeholder conversion + identifier quoting) therefore runs for real and
58
+ * the recorded SQL is what the driver would receive.
59
+ */
60
+ export function buildManagerApp(
61
+ pool: FakePool,
62
+ config: any,
63
+ sessionUser: SessionMode = { role: "root", userId: 1 },
64
+ ) {
65
+ const manager = new dbManager();
66
+ manager.dbType = pool.dbType;
67
+ (manager as any)._db = pool;
68
+ return buildAppForManager(manager, config, sessionUser);
69
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Scripted fake for the dbManager abstraction used by the galadmin routes.
3
+ *
4
+ * Mirrors the contract produced by src/utils/dbManager.ts:
5
+ * db.dbType: "MYSQL" | "POSTGRESQL"
6
+ * db.query(sql, params) -> Promise<{ err, rows, fields }>
7
+ *
8
+ * - Every executed query is recorded in `queries` so tests can assert the
9
+ * exact SQL and bound parameters.
10
+ * - `information_schema` queries (config enrichment) are answered from
11
+ * `schemaRows` and never consume the scripted queue.
12
+ * - Everything else consumes the FIFO `queue`; an empty queue yields
13
+ * `{ err: null, rows: [] }`.
14
+ */
15
+ export type FakeQueryResult = { err?: any; rows?: any; fields?: any };
16
+ export type RecordedQuery = { sql: string; params: any[] };
17
+
18
+ export class FakeDb {
19
+ dbType: "MYSQL" | "POSTGRESQL" = "MYSQL";
20
+ schemaRows: any[] = [];
21
+ queries: RecordedQuery[] = [];
22
+ private queue: FakeQueryResult[] = [];
23
+ defaultResult: FakeQueryResult = { err: null, rows: [] };
24
+
25
+ queueResult(result: FakeQueryResult) {
26
+ this.queue.push({ err: null, rows: [], ...result });
27
+ return this;
28
+ }
29
+
30
+ queueRows(rows: any) {
31
+ return this.queueResult({ err: null, rows });
32
+ }
33
+
34
+ queueErr(err: any) {
35
+ return this.queueResult({ err, rows: null });
36
+ }
37
+
38
+ async query(sql: string, params: any[] = []): Promise<FakeQueryResult> {
39
+ this.queries.push({ sql, params });
40
+ if (typeof sql === "string" && sql.includes("information_schema")) {
41
+ return { err: null, rows: this.schemaRows };
42
+ }
43
+ if (this.queue.length > 0) {
44
+ return this.queue.shift()!;
45
+ }
46
+ return this.defaultResult;
47
+ }
48
+
49
+ reset() {
50
+ this.queue = [];
51
+ this.queries = [];
52
+ }
53
+
54
+ lastQuery(): RecordedQuery {
55
+ return this.queries[this.queries.length - 1];
56
+ }
57
+
58
+ /** Queries excluding the information_schema enrichment probe. */
59
+ dataQueries(): RecordedQuery[] {
60
+ return this.queries.filter((q) => !q.sql.includes("information_schema"));
61
+ }
62
+
63
+ queriesMatching(substring: string): RecordedQuery[] {
64
+ return this.dataQueries().filter((q) => q.sql.includes(substring));
65
+ }
66
+ }