@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,366 @@
1
+ import * as request from "supertest";
2
+ import { buildAppForManager } from "../helpers/app";
3
+ import { baseConfig, fruitView } from "../helpers/fixtures";
4
+ import {
5
+ DbHarness,
6
+ describeIntegration,
7
+ getMysql2Harness,
8
+ getMysqlHarness,
9
+ getPgHarness,
10
+ resetTables,
11
+ } from "./dbHarness";
12
+
13
+ jest.mock("axios");
14
+
15
+ const CONTAINER_TIMEOUT = 300000;
16
+
17
+ // The primaryId is declared explicitly in every view config below because
18
+ // config enrichment silently no-ops on PostgreSQL (see the enrichment
19
+ // characterization test at the bottom — GAP 10).
20
+
21
+ describeIntegration("routes against real databases", () => {
22
+ let mysqlHarness: DbHarness;
23
+ let mysql2Harness: DbHarness;
24
+ let pgHarness: DbHarness;
25
+ const harnesses: Record<string, DbHarness> = {};
26
+
27
+ beforeAll(async () => {
28
+ mysqlHarness = await getMysqlHarness();
29
+ harnesses.mysql = mysqlHarness;
30
+ mysql2Harness = await getMysql2Harness();
31
+ harnesses.mysql2 = mysql2Harness;
32
+ pgHarness = await getPgHarness();
33
+ harnesses.postgresql = pgHarness;
34
+ }, CONTAINER_TIMEOUT);
35
+
36
+ afterAll(async () => {
37
+ // Close the pools; the containers themselves live until globalTeardown.
38
+ await mysqlHarness?.stop();
39
+ await mysql2Harness?.stop();
40
+ await pgHarness?.stop();
41
+ });
42
+
43
+ describe.each([
44
+ ["mysql", () => harnesses.mysql],
45
+ ["mysql2", () => harnesses.mysql2],
46
+ ["postgresql", () => harnesses.postgresql],
47
+ ])("backend: %s", (_backend, getHarness) => {
48
+ let harness: DbHarness;
49
+
50
+ beforeEach(async () => {
51
+ harness = getHarness();
52
+ await resetTables(harness);
53
+ });
54
+
55
+ const app = () =>
56
+ buildAppForManager(
57
+ harness.manager,
58
+ baseConfig({ views: [fruitView({ primaryId: "id" })] }),
59
+ );
60
+
61
+ it("POST /createRecord persists the row", async () => {
62
+ const res = await request(app())
63
+ .post("/api/createRecord")
64
+ .send({ viewIndex: 0, data: { fruitName: "pomme", acidity: 3 } })
65
+ .expect(200);
66
+ if (harness.dbType === "MYSQL") {
67
+ expect(res.body.id).toBe(1);
68
+ } else {
69
+ // CHARACTERIZATION (GAP 11): pg results carry no insertId, so the
70
+ // created id is undefined in the response on PostgreSQL.
71
+ expect(res.body.id).toBeUndefined();
72
+ }
73
+ const { err, rows } = await harness.query("SELECT * FROM fruits");
74
+ expect(err ?? null).toBeNull();
75
+ expect(rows).toHaveLength(1);
76
+ expect(rows[0].fruitName ?? rows[0].fruitname).toBe("pomme");
77
+ expect(Number(rows[0].acidity)).toBe(3);
78
+ });
79
+
80
+ it("POST /updateRecord updates the persisted row", async () => {
81
+ await request(app())
82
+ .post("/api/createRecord")
83
+ .send({ viewIndex: 0, data: { fruitName: "pomme", acidity: 3 } })
84
+ .expect(200);
85
+ await request(app())
86
+ .post("/api/updateRecord")
87
+ .send({ viewIndex: 0, primaryValue: 1, data: { fruitName: "poire" } })
88
+ .expect(200);
89
+ const { rows } = await harness.query("SELECT * FROM fruits");
90
+ expect(rows).toHaveLength(1);
91
+ expect(rows[0].fruitName ?? rows[0].fruitname).toBe("poire");
92
+ });
93
+
94
+ it("POST /deleteRecord removes the row, then answers 404 once gone", async () => {
95
+ await request(app())
96
+ .post("/api/createRecord")
97
+ .send({ viewIndex: 0, data: { fruitName: "pomme" } })
98
+ .expect(200);
99
+ await request(app())
100
+ .post("/api/deleteRecord")
101
+ .send({ viewIndex: 0, primaryValue: 1 })
102
+ .expect(200);
103
+ const { rows } = await harness.query("SELECT * FROM fruits");
104
+ expect(rows).toHaveLength(0);
105
+ await request(app())
106
+ .post("/api/deleteRecord")
107
+ .send({ viewIndex: 0, primaryValue: 1 })
108
+ .expect(404);
109
+ });
110
+
111
+ it("POST /deleteRecords removes several rows by primary value", async () => {
112
+ const appInstance = app();
113
+ await request(appInstance)
114
+ .post("/api/createRecord")
115
+ .send({ viewIndex: 0, data: { fruitName: "pomme" } })
116
+ .expect(200);
117
+ await request(appInstance)
118
+ .post("/api/createRecord")
119
+ .send({ viewIndex: 0, data: { fruitName: "poire" } })
120
+ .expect(200);
121
+ await request(appInstance)
122
+ .post("/api/deleteRecords")
123
+ .send({ viewIndex: 0, primaryValues: ["1", "2"] })
124
+ .expect(200);
125
+ const { rows } = await harness.query("SELECT * FROM fruits");
126
+ expect(rows).toHaveLength(0);
127
+ });
128
+
129
+ it("POST /records lists rows with pagination, search and count", async () => {
130
+ const appInstance = app();
131
+ for (const name of ["pomme", "poire", "banane"]) {
132
+ await request(appInstance)
133
+ .post("/api/createRecord")
134
+ .send({ viewIndex: 0, data: { fruitName: name } })
135
+ .expect(200);
136
+ }
137
+ const res = await request(appInstance)
138
+ .post("/api/records")
139
+ .send({ viewIndex: 0, page: 1, filters: [] })
140
+ .expect(200);
141
+ expect(res.body.records).toHaveLength(3);
142
+ // PG returns COUNT(*) as a bigint, which node-pg surfaces as a string.
143
+ expect(String(res.body.count)).toBe("3");
144
+
145
+ // The LIKE -> ::text ILIKE rewrite must actually run on the engine.
146
+ const search = await request(appInstance)
147
+ .post("/api/records")
148
+ .send({ viewIndex: 0, page: 1, filters: [], query: "pomme" })
149
+ .expect(200);
150
+ expect(search.body.records).toHaveLength(1);
151
+ expect(search.body.records[0].fruitName).toBe("pomme");
152
+
153
+ const noMatch = await request(appInstance)
154
+ .post("/api/records")
155
+ .send({ viewIndex: 0, page: 1, filters: [], query: "cerise" })
156
+ .expect(200);
157
+ expect(noMatch.body.records).toHaveLength(0);
158
+ });
159
+
160
+ it("GET /record returns the persisted record", async () => {
161
+ const appInstance = app();
162
+ await request(appInstance)
163
+ .post("/api/createRecord")
164
+ .send({ viewIndex: 0, data: { fruitName: "pomme", acidity: 3 } })
165
+ .expect(200);
166
+ const res = await request(appInstance)
167
+ .get("/api/record")
168
+ .query({ viewIndex: 0, recordId: 1 })
169
+ .expect(200);
170
+ expect(res.body.fruitName).toBe("pomme");
171
+ });
172
+
173
+ it("a read-only role cannot write, and the row stays untouched", async () => {
174
+ const viewerApp = buildAppForManager(
175
+ harness.manager,
176
+ baseConfig({
177
+ views: [
178
+ fruitView({
179
+ primaryId: "id",
180
+ roles: ["editor"],
181
+ readRoles: ["viewer"],
182
+ }),
183
+ ],
184
+ }),
185
+ { role: "viewer", userId: 3 },
186
+ );
187
+ await request(app())
188
+ .post("/api/createRecord")
189
+ .send({ viewIndex: 0, data: { fruitName: "pomme" } })
190
+ .expect(200);
191
+ await request(viewerApp)
192
+ .post("/api/updateRecord")
193
+ .send({ viewIndex: 0, primaryValue: 1, data: { fruitName: "hack" } })
194
+ .expect(403);
195
+ const { rows } = await harness.query("SELECT * FROM fruits");
196
+ expect(rows[0].fruitName ?? rows[0].fruitname).toBe("pomme");
197
+ });
198
+
199
+ it("POST /recordsImport upserts rows (idempotent re-import)", async () => {
200
+ const parseImportFile = jest
201
+ .fn()
202
+ .mockResolvedValueOnce([
203
+ { id: 1, fruitName: "pomme" },
204
+ { id: 2, fruitName: "poire" },
205
+ ])
206
+ .mockResolvedValueOnce([{ id: 1, fruitName: "POMME-IMPORTED" }]);
207
+ const appInstance = buildAppForManager(
208
+ harness.manager,
209
+ baseConfig({
210
+ views: [
211
+ fruitView({
212
+ primaryId: "id",
213
+ importFileUniqueField: "id",
214
+ parseImportFile,
215
+ }),
216
+ ],
217
+ }),
218
+ );
219
+
220
+ if (harness.dbType === "POSTGRESQL") {
221
+ // quotePostgresQuery now knows the ON CONFLICT / EXCLUDED keywords,
222
+ // so the upsert runs on a real engine too (GAP 12 fixed): same
223
+ // assertions as the MySQL branch.
224
+ await request(appInstance)
225
+ .post("/api/recordsImport")
226
+ .field("viewIndex", "0")
227
+ .attach("data", Buffer.from("csv"), "import.csv")
228
+ .expect(200);
229
+ let { rows } = await harness.query(
230
+ "SELECT * FROM fruits ORDER BY id",
231
+ );
232
+ expect(rows).toHaveLength(2);
233
+
234
+ // Re-import the same id: upsert, not duplicate.
235
+ await request(appInstance)
236
+ .post("/api/recordsImport")
237
+ .field("viewIndex", "0")
238
+ .attach("data", Buffer.from("csv"), "import.csv")
239
+ .expect(200);
240
+ ({ rows } = await harness.query("SELECT * FROM fruits ORDER BY id"));
241
+ expect(rows).toHaveLength(2);
242
+ expect(rows[0].fruitName ?? rows[0].fruitname).toBe("POMME-IMPORTED");
243
+ } else {
244
+ await request(appInstance)
245
+ .post("/api/recordsImport")
246
+ .field("viewIndex", "0")
247
+ .attach("data", Buffer.from("csv"), "import.csv")
248
+ .expect(200);
249
+ let { rows } = await harness.query(
250
+ "SELECT * FROM fruits ORDER BY id",
251
+ );
252
+ expect(rows).toHaveLength(2);
253
+
254
+ // Re-import the same id: upsert, not duplicate.
255
+ await request(appInstance)
256
+ .post("/api/recordsImport")
257
+ .field("viewIndex", "0")
258
+ .attach("data", Buffer.from("csv"), "import.csv")
259
+ .expect(200);
260
+ ({ rows } = await harness.query("SELECT * FROM fruits ORDER BY id"));
261
+ expect(rows).toHaveLength(2);
262
+ expect(rows[0].fruitName ?? rows[0].fruitname).toBe("POMME-IMPORTED");
263
+ }
264
+ });
265
+
266
+ it("POST /foreignSelectRecords searches with a quoted term", async () => {
267
+ const searchColumn = {
268
+ label: "Nom",
269
+ name: "fruitName",
270
+ table: "fruits",
271
+ searchableName: "fruitName",
272
+ __id__: "foreign-col",
273
+ };
274
+ const appInstance = buildAppForManager(
275
+ harness.manager,
276
+ baseConfig({
277
+ views: [
278
+ fruitView({
279
+ primaryId: "id",
280
+ columns: [
281
+ searchColumn,
282
+ { label: "Acidité", name: "acidity" },
283
+ ],
284
+ }),
285
+ ],
286
+ }),
287
+ );
288
+ await request(appInstance)
289
+ .post("/api/createRecord")
290
+ .send({ viewIndex: 0, data: { fruitName: "l'orange" } })
291
+ .expect(200);
292
+ if (harness.dbType === "POSTGRESQL") {
293
+ // The PG branch of the route escapes quotes with doubling (''),
294
+ // which is valid under standard_conforming_strings — the search
295
+ // works on a real engine (unlike the /records search, GAP 7).
296
+ const res = await request(appInstance)
297
+ .post("/api/foreignSelectRecords")
298
+ .send({ viewIndex: 0, columnId: "foreign-col", query: "l'orange" })
299
+ .expect(200);
300
+ expect(res.body).toHaveLength(1);
301
+ expect(res.body[0].value).toBe("l'orange");
302
+ } else {
303
+ const res = await request(appInstance)
304
+ .post("/api/foreignSelectRecords")
305
+ .send({ viewIndex: 0, columnId: "foreign-col", query: "l'orange" })
306
+ .expect(200);
307
+ expect(res.body).toHaveLength(1);
308
+ // The label/value default to the column's own value.
309
+ expect(res.body[0].value).toBe("l'orange");
310
+ expect(res.body[0].label).toBe("l'orange");
311
+ }
312
+ });
313
+ });
314
+ });
315
+
316
+ describeIntegration("config enrichment against real information_schema", () => {
317
+ let mysqlHarness: DbHarness;
318
+ let pgHarness: DbHarness;
319
+
320
+ beforeAll(async () => {
321
+ mysqlHarness = await getMysqlHarness();
322
+ pgHarness = await getPgHarness();
323
+ }, CONTAINER_TIMEOUT);
324
+
325
+ afterAll(async () => {
326
+ await mysqlHarness?.stop();
327
+ await pgHarness?.stop();
328
+ });
329
+
330
+ it("resolves the primary key on MySQL without a declared primaryId", async () => {
331
+ const { rows } = await mysqlHarness.query(
332
+ "SELECT * FROM information_schema.columns WHERE table_schema = ?",
333
+ ["galadmin_test"],
334
+ );
335
+ const pri = rows.find(
336
+ (r: any) => r.TABLE_NAME === "fruits" && r.COLUMN_KEY === "PRI",
337
+ );
338
+ expect(pri.COLUMN_NAME).toBe("id");
339
+ });
340
+
341
+ it("CHARACTERIZATION (GAP 10): pg information_schema returns lowercase keys, so enrichment no-ops", async () => {
342
+ // 1) The probe enrichConfig actually runs filters by DATABASE name —
343
+ // correct on MySQL, but on PostgreSQL table_schema is the SCHEMA
344
+ // ("public"), so the probe matches nothing at all.
345
+ const { rows: probeRows } = await pgHarness.query(
346
+ "SELECT * FROM information_schema.columns WHERE table_schema = ?",
347
+ ["galadmin_test"],
348
+ );
349
+ expect(probeRows).toHaveLength(0);
350
+
351
+ // 2) Even probing the right schema, the keys are lowercase and there is
352
+ // no COLUMN_KEY — enrichConfig only looks for TABLE_NAME/COLUMN_KEY/
353
+ // COLUMN_NAME/COLUMN_TYPE, so primaryId detection, `required` flags
354
+ // and enum->select mapping silently never fire on PostgreSQL.
355
+ const { err, rows } = await pgHarness.query(
356
+ "SELECT * FROM information_schema.columns WHERE table_schema = ?",
357
+ ["public"],
358
+ );
359
+ if (err) throw err;
360
+ expect(rows.length).toBeGreaterThan(0);
361
+ const first = rows[0];
362
+ expect(first.TABLE_NAME).toBeUndefined();
363
+ expect(first.table_name).toBeDefined();
364
+ expect(first.column_key).toBeUndefined();
365
+ });
366
+ });
@@ -0,0 +1,88 @@
1
+ import * as request from "supertest";
2
+ import { buildAppForManager } from "../helpers/app";
3
+ import { baseConfig, fruitView, EDITOR } from "../helpers/fixtures";
4
+ import {
5
+ BackendName,
6
+ DbHarness,
7
+ describeIntegration,
8
+ getMysql2Harness,
9
+ getMysqlHarness,
10
+ getPgHarness,
11
+ resetTables,
12
+ } from "./dbHarness";
13
+
14
+ jest.mock("axios");
15
+
16
+ /**
17
+ * The MySQL -> PostgreSQL conversion pipeline (convertQuery ->
18
+ * quotePostgresQuery) checked against the real engines. Its other behaviors —
19
+ * identifier quoting, $N placeholders, NULL normalization, doubled-quote
20
+ * escaping, ILIKE rewriting, the count subquery — are pinned behaviorally by
21
+ * the crud/records/authorization suites running on the PostgreSQL backend.
22
+ * This suite keeps only what nothing else exercises.
23
+ */
24
+ describeIntegration("SQL conversion pipeline against real databases", () => {
25
+ const harnesses: Record<BackendName, DbHarness> = {} as any;
26
+
27
+ beforeAll(async () => {
28
+ harnesses.mysql = await getMysqlHarness();
29
+ harnesses.mysql2 = await getMysql2Harness();
30
+ harnesses.postgresql = await getPgHarness();
31
+ });
32
+
33
+ afterAll(async () => {
34
+ await harnesses.mysql?.stop();
35
+ await harnesses.mysql2?.stop();
36
+ await harnesses.postgresql?.stop();
37
+ });
38
+
39
+ describe.each([
40
+ ["mysql", () => harnesses.mysql],
41
+ ["mysql2", () => harnesses.mysql2],
42
+ ["postgresql", () => harnesses.postgresql],
43
+ ])("backend: %s", (_backend, getHarness) => {
44
+ let harness: DbHarness;
45
+
46
+ beforeEach(async () => {
47
+ harness = getHarness();
48
+ await resetTables(harness);
49
+ });
50
+
51
+ const app = () =>
52
+ buildAppForManager(
53
+ harness.manager,
54
+ baseConfig({ views: [fruitView({ primaryId: "id" })] }),
55
+ EDITOR,
56
+ );
57
+
58
+ const postRecords = (instance: any, body: any) =>
59
+ request(instance).post("/api/records").send({
60
+ viewIndex: 0,
61
+ page: 1,
62
+ filters: [],
63
+ ...body,
64
+ });
65
+
66
+ const idsOf = (records: any[]) => records.map((r: any) => Number(r.id));
67
+
68
+ it("keeps a literal '?' in the search out of the placeholder conversion", async () => {
69
+ // The search term is interpolated as a string literal BEFORE the
70
+ // MySQL -> PostgreSQL conversion runs; a '?' inside it must survive as
71
+ // text instead of becoming a $N placeholder that consumes a parameter
72
+ // slot and shifts every real parameter (PG would fail the statement).
73
+ const instance = app();
74
+ await request(instance)
75
+ .post("/api/createRecord")
76
+ .send({ viewIndex: 0, data: { fruitName: "what? pomme" } })
77
+ .expect(200);
78
+ await request(instance)
79
+ .post("/api/createRecord")
80
+ .send({ viewIndex: 0, data: { fruitName: "pomme" } })
81
+ .expect(200);
82
+ const res = await postRecords(instance, { query: "what?" }).expect(200);
83
+ expect(idsOf(res.body.records)).toEqual([1]);
84
+ // The count subquery wraps the same literal-bearing query.
85
+ expect(String(res.body.count)).toBe("1");
86
+ });
87
+ });
88
+ });