@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,425 @@
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 { generateUniqueIdForObject } from "../../src/api";
6
+ import { buildAppForManager } from "../helpers/app";
7
+ import { baseConfig, fruitView } from "../helpers/fixtures";
8
+ import {
9
+ BackendName,
10
+ DbHarness,
11
+ describeIntegration,
12
+ getMysql2Harness,
13
+ getMysqlHarness,
14
+ getPgHarness,
15
+ resetTables,
16
+ } from "./dbHarness";
17
+
18
+ jest.mock("axios");
19
+ // Explicit factories: the file-based manual mocks in tests/__mocks__/ are not
20
+ // on the integration config's roots. They replicate the same CJS shapes.
21
+ jest.mock("@aws-sdk/client-s3", () => {
22
+ const send = jest.fn().mockResolvedValue({});
23
+ return {
24
+ S3Client: jest.fn(() => ({ send })),
25
+ PutObjectCommand: jest.fn((input: any) => ({ input })),
26
+ __send: send,
27
+ };
28
+ });
29
+ jest.mock("@aws-sdk/s3-request-presigner", () => ({
30
+ getSignedUrl: jest
31
+ .fn()
32
+ .mockResolvedValue("https://signed.example.com/put-object"),
33
+ }));
34
+ jest.mock("@google-cloud/storage", () => {
35
+ const upload = jest.fn().mockResolvedValue([{}]);
36
+ const bucket = jest.fn(() => ({ upload }));
37
+ return {
38
+ Storage: jest.fn(() => ({ bucket })),
39
+ __bucket: bucket,
40
+ __upload: upload,
41
+ };
42
+ });
43
+ jest.mock("ssh2-sftp-client", () => {
44
+ const connect = jest.fn().mockResolvedValue(undefined);
45
+ const fastPut = jest.fn().mockResolvedValue(undefined);
46
+ const end = jest.fn().mockResolvedValue(undefined);
47
+ const SftpClient: any = function () {
48
+ return { connect, fastPut, end };
49
+ };
50
+ SftpClient.__sftp = { connect, fastPut, end };
51
+ return SftpClient;
52
+ });
53
+
54
+ const PNG_BUFFER = Buffer.from(
55
+ "89504e470d0a1a0a0000000d49484452000000010000000108" + "0".repeat(64),
56
+ "hex",
57
+ );
58
+
59
+ function fileColumn(overrides: any = {}) {
60
+ return {
61
+ label: "Image",
62
+ name: "imageUrl",
63
+ dataType: "file",
64
+ ...overrides,
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Upload routes against real engines. The uploads themselves are external
70
+ * systems (S3 / GCP / SFTP / local fs), so those stay mocked; the real
71
+ * database provides the schema enrichment and the write-guard context.
72
+ */
73
+ describeIntegration("upload routes against real databases", () => {
74
+ const harnesses: Record<BackendName, DbHarness> = {} as any;
75
+
76
+ beforeAll(async () => {
77
+ harnesses.mysql = await getMysqlHarness();
78
+ harnesses.mysql2 = await getMysql2Harness();
79
+ harnesses.postgresql = await getPgHarness();
80
+ });
81
+
82
+ afterAll(async () => {
83
+ await harnesses.mysql?.stop();
84
+ await harnesses.mysql2?.stop();
85
+ await harnesses.postgresql?.stop();
86
+ });
87
+
88
+ describe.each([
89
+ ["mysql", () => harnesses.mysql],
90
+ ["mysql2", () => harnesses.mysql2],
91
+ ["postgresql", () => harnesses.postgresql],
92
+ ])("backend: %s", (_backend, getHarness) => {
93
+ let harness: DbHarness;
94
+
95
+ beforeEach(async () => {
96
+ harness = getHarness();
97
+ await resetTables(harness);
98
+ });
99
+
100
+ const appWithColumn = (column: any, role: any = { role: "root", userId: 1 }) => {
101
+ const config = baseConfig({
102
+ views: [
103
+ fruitView({
104
+ primaryId: "id",
105
+ columns: [{ label: "Nom", name: "fruitName" }, column],
106
+ }),
107
+ ],
108
+ });
109
+ const colId = generateUniqueIdForObject(column);
110
+ return { app: buildAppForManager(harness.manager, config, role), colId };
111
+ };
112
+
113
+ const s3Request = (app: any, colId: string) =>
114
+ request(app)
115
+ .post("/api/uploadFile")
116
+ .query({ viewIndex: 0, columnId: colId })
117
+ .attach("file", PNG_BUFFER, {
118
+ filename: "photo.png",
119
+ contentType: "image/png",
120
+ });
121
+
122
+ describe("S3 backend", () => {
123
+ it("uploads with a public-read PutObjectCommand and returns the public URL", async () => {
124
+ const s3Mock = jest.requireMock("@aws-sdk/client-s3");
125
+ const column = fileColumn({
126
+ s3Config: {
127
+ host: "s3.example.com",
128
+ accessKeyId: "AKIA",
129
+ secretAccessKey: "SECRET",
130
+ bucket: "my-bucket",
131
+ },
132
+ });
133
+ const { app, colId } = appWithColumn(column);
134
+ const res = await s3Request(app, colId).expect(200);
135
+
136
+ expect(res.body.originalFilename).toBe("photo.png");
137
+ expect(res.body.aspectRatio).toBe(1); // PNG_BUFFER is a real 1x1 PNG header
138
+ expect(res.body.path).toMatch(/^photo-\d+\.png$/);
139
+ expect(res.body.url).toMatch(
140
+ /^https:\/\/s3\.example\.com\/my-bucket\/photo-\d+\.png$/,
141
+ );
142
+ expect(res.body.url).toBe(
143
+ `https://s3.example.com/my-bucket/${res.body.path}`,
144
+ );
145
+
146
+ expect(s3Mock.S3Client).toHaveBeenCalledWith(
147
+ expect.objectContaining({
148
+ endpoint: "https://s3.example.com",
149
+ region: undefined,
150
+ credentials: { accessKeyId: "AKIA", secretAccessKey: "SECRET" },
151
+ }),
152
+ );
153
+ expect(s3Mock.PutObjectCommand).toHaveBeenCalledWith(
154
+ expect.objectContaining({
155
+ Bucket: "my-bucket",
156
+ ContentType: "image/png",
157
+ ACL: "public-read",
158
+ Key: res.body.path,
159
+ }),
160
+ );
161
+ const sentCommand = s3Mock.PutObjectCommand.mock.calls[0][0];
162
+ expect(Buffer.isBuffer(sentCommand.Body)).toBe(true);
163
+ });
164
+
165
+ it("prefixes https:// to hosts missing a scheme", async () => {
166
+ const s3Mock = jest.requireMock("@aws-sdk/client-s3");
167
+ const column = fileColumn({
168
+ s3Config: {
169
+ host: "s3.example.com",
170
+ accessKeyId: "k",
171
+ secretAccessKey: "s",
172
+ bucket: "b",
173
+ },
174
+ });
175
+ const { app, colId } = appWithColumn(column);
176
+ await s3Request(app, colId).expect(200);
177
+ expect(s3Mock.S3Client).toHaveBeenCalledWith(
178
+ expect.objectContaining({ endpoint: "https://s3.example.com" }),
179
+ );
180
+ });
181
+
182
+ it("keeps explicit http schemes untouched", async () => {
183
+ const s3Mock = jest.requireMock("@aws-sdk/client-s3");
184
+ const column = fileColumn({
185
+ s3Config: {
186
+ host: "http://minio.local:9000",
187
+ accessKeyId: "k",
188
+ secretAccessKey: "s",
189
+ bucket: "b",
190
+ },
191
+ });
192
+ const { app, colId } = appWithColumn(column);
193
+ await s3Request(app, colId).expect(200);
194
+ expect(s3Mock.S3Client).toHaveBeenCalledWith(
195
+ expect.objectContaining({ endpoint: "http://minio.local:9000" }),
196
+ );
197
+ });
198
+
199
+ it("uses the signed-URL flow when signedUrlNeeded is set", async () => {
200
+ const presignerMock = jest.requireMock("@aws-sdk/s3-request-presigner");
201
+ const axiosMock = jest.requireMock("axios");
202
+ const s3Mock = jest.requireMock("@aws-sdk/client-s3");
203
+ const column = fileColumn({
204
+ s3Config: {
205
+ host: "s3.example.com",
206
+ accessKeyId: "k",
207
+ secretAccessKey: "s",
208
+ bucket: "b",
209
+ signedUrlNeeded: true,
210
+ region: "eu-west-3",
211
+ },
212
+ });
213
+ const { app, colId } = appWithColumn(column);
214
+ const res = await s3Request(app, colId).expect(200);
215
+
216
+ expect(presignerMock.getSignedUrl).toHaveBeenCalledWith(
217
+ s3Mock.S3Client.mock.results[0].value,
218
+ expect.objectContaining({
219
+ input: expect.objectContaining({ Bucket: "b" }),
220
+ }),
221
+ { expiresIn: 150 },
222
+ );
223
+ expect(axiosMock.put).toHaveBeenCalledWith(
224
+ "https://signed.example.com/put-object",
225
+ expect.any(Buffer),
226
+ expect.objectContaining({
227
+ headers: { "Content-Type": "image/png" },
228
+ }),
229
+ );
230
+ // signed flow does not expose a public url
231
+ expect(res.body.url).toBeUndefined();
232
+ expect(res.body.path).toMatch(/^photo-\d+\.png$/);
233
+ });
234
+
235
+ it("applies pathTransform when provided", async () => {
236
+ const column = fileColumn({
237
+ s3Config: {
238
+ host: "s3.example.com",
239
+ accessKeyId: "k",
240
+ secretAccessKey: "s",
241
+ bucket: "b",
242
+ pathTransform: (p: string) => `transformed/${p}`,
243
+ },
244
+ });
245
+ const { app, colId } = appWithColumn(column);
246
+ const res = await s3Request(app, colId).expect(200);
247
+ expect(res.body.path).toMatch(/^transformed\/photo-\d+\.png$/);
248
+ });
249
+
250
+ it("supports a subfolder", async () => {
251
+ const column = fileColumn({
252
+ s3Config: {
253
+ host: "s3.example.com",
254
+ accessKeyId: "k",
255
+ secretAccessKey: "s",
256
+ bucket: "b",
257
+ subfolder: "images",
258
+ },
259
+ });
260
+ const { app, colId } = appWithColumn(column);
261
+ const res = await s3Request(app, colId).expect(200);
262
+ expect(res.body.path).toMatch(/^images\/photo-\d+\.png$/);
263
+ });
264
+
265
+ it("returns 500 when the S3 upload fails", async () => {
266
+ const s3Mock = jest.requireMock("@aws-sdk/client-s3");
267
+ s3Mock.__send.mockRejectedValueOnce(new Error("s3 down"));
268
+ const column = fileColumn({
269
+ s3Config: {
270
+ host: "s3.example.com",
271
+ accessKeyId: "k",
272
+ secretAccessKey: "s",
273
+ bucket: "b",
274
+ },
275
+ });
276
+ const { app, colId } = appWithColumn(column);
277
+ await s3Request(app, colId).expect(500);
278
+ });
279
+ });
280
+
281
+ describe("GCP backend", () => {
282
+ it("uploads to the bucket and applies pathTransform", async () => {
283
+ const gcpMock = jest.requireMock("@google-cloud/storage");
284
+ const column = fileColumn({
285
+ gcpConfig: {
286
+ host: "storage.googleapis.com",
287
+ bucket: "gcp-bucket",
288
+ pathTransform: (p: string) => `cdn/${p}`,
289
+ },
290
+ });
291
+ const { app, colId } = appWithColumn(column);
292
+ const res = await s3Request(app, colId).expect(200);
293
+
294
+ expect(gcpMock.Storage).toHaveBeenCalled();
295
+ expect(gcpMock.__bucket).toHaveBeenCalledWith("gcp-bucket");
296
+ expect(gcpMock.__upload).toHaveBeenCalledWith(
297
+ expect.any(String),
298
+ expect.objectContaining({
299
+ destination: expect.stringMatching(/^photo-\d+\.png$/),
300
+ }),
301
+ );
302
+ expect(res.body.path).toMatch(/^cdn\/photo-\d+\.png$/);
303
+ expect(res.body.aspectRatio).toBe(1); // PNG_BUFFER is a real 1x1 PNG header
304
+ });
305
+
306
+ it("returns 500 when the upload fails", async () => {
307
+ const gcpMock = jest.requireMock("@google-cloud/storage");
308
+ gcpMock.__upload.mockRejectedValueOnce(new Error("gcp down"));
309
+ const column = fileColumn({
310
+ gcpConfig: { host: "storage.googleapis.com", bucket: "gcp-bucket" },
311
+ });
312
+ const { app, colId } = appWithColumn(column);
313
+ await s3Request(app, colId).expect(500);
314
+ });
315
+ });
316
+
317
+ describe("SFTP backend", () => {
318
+ it("requires publicPath", async () => {
319
+ const column = fileColumn({
320
+ sftpConfig: { host: "h", user: "u", password: "p" },
321
+ });
322
+ const { app, colId } = appWithColumn(column);
323
+ const res = await s3Request(app, colId).expect(500);
324
+ expect(res.text).toContain("publicPath");
325
+ });
326
+
327
+ it("fastPuts the temp file to the remote path and cleans it up", async () => {
328
+ const sftpMock = jest.requireMock("ssh2-sftp-client");
329
+ const column = fileColumn({
330
+ publicPath: "/srv/public",
331
+ subdirectory: "img",
332
+ sftpConfig: { host: "sftp.example.com", user: "u", password: "p" },
333
+ });
334
+ const { app, colId } = appWithColumn(column);
335
+ const res = await s3Request(app, colId).expect(200);
336
+
337
+ expect(sftpMock.__sftp.connect).toHaveBeenCalledWith({
338
+ host: "sftp.example.com",
339
+ port: 22,
340
+ username: "u",
341
+ password: "p",
342
+ });
343
+ const [localPath, remotePath] = sftpMock.__sftp.fastPut.mock.calls[0];
344
+ expect(path.dirname(remotePath)).toBe("/srv/public/img");
345
+ expect(path.basename(remotePath)).toBe(path.basename(localPath));
346
+ expect(res.body).toEqual({
347
+ path: path.basename(remotePath),
348
+ originalFilename: "photo.png",
349
+ });
350
+ // the temp file is removed after a successful transfer
351
+ expect(fs.existsSync(localPath)).toBe(false);
352
+ expect(sftpMock.__sftp.end).toHaveBeenCalled();
353
+ });
354
+ });
355
+
356
+ describe("local fs backend", () => {
357
+ let uploadRoot: string;
358
+
359
+ beforeEach(() => {
360
+ uploadRoot = fs.mkdtempSync(path.join(os.tmpdir(), "galadmin-upload-"));
361
+ fs.mkdirSync(path.join(uploadRoot, "sub"));
362
+ });
363
+
364
+ afterEach(() => {
365
+ fs.rmSync(uploadRoot, { recursive: true, force: true });
366
+ });
367
+
368
+ it("stores the file under publicPath + subdirectory and returns the relative path", async () => {
369
+ const column = fileColumn({
370
+ publicPath: uploadRoot,
371
+ subdirectory: "/sub",
372
+ });
373
+ const { app, colId } = appWithColumn(column);
374
+ const res = await s3Request(app, colId).expect(200);
375
+ // the local backend keeps multiparty's random temp filename (not the
376
+ // original one) — documented behavior
377
+ expect(res.body.path).toMatch(/^\/sub\/[A-Za-z0-9_-]+\.png$/);
378
+ expect(res.body.originalFilename).toBe("photo.png");
379
+ const stored = path.join(uploadRoot, res.body.path);
380
+ expect(fs.existsSync(stored)).toBe(true);
381
+ });
382
+
383
+ it("fails without a subdirectory (publicPath + undefined concat — documented quirk)", async () => {
384
+ // uploadDir becomes "<publicPath>undefined", which never exists, so
385
+ // the multipart parse fails with 500. A subdirectory is effectively
386
+ // required for the local backend.
387
+ const column = fileColumn({ publicPath: uploadRoot });
388
+ const { app, colId } = appWithColumn(column);
389
+ await s3Request(app, colId).expect(500);
390
+ });
391
+ });
392
+
393
+ describe("POST /api/uploadFileFromBetterRichTextInput", () => {
394
+ it("takes the upload config from the file-upload-properties header", async () => {
395
+ const s3Mock = jest.requireMock("@aws-sdk/client-s3");
396
+ const app = buildAppForManager(
397
+ harness.manager,
398
+ baseConfig({ views: [fruitView({ primaryId: "id" })] }),
399
+ );
400
+ const res = await request(app)
401
+ .post("/api/uploadFileFromBetterRichTextInput")
402
+ .set(
403
+ "file-upload-properties",
404
+ JSON.stringify({
405
+ s3Config: {
406
+ host: "s3.example.com",
407
+ accessKeyId: "k",
408
+ secretAccessKey: "s",
409
+ bucket: "b",
410
+ },
411
+ }),
412
+ )
413
+ .attach("upload", PNG_BUFFER, {
414
+ filename: "photo.png",
415
+ contentType: "image/png",
416
+ })
417
+ .expect(200);
418
+ expect(s3Mock.PutObjectCommand).toHaveBeenCalledWith(
419
+ expect.objectContaining({ Bucket: "b" }),
420
+ );
421
+ expect(res.body.path).toMatch(/^photo-\d+\.png$/);
422
+ });
423
+ });
424
+ });
425
+ });
@@ -0,0 +1,15 @@
1
+ const noop = () => {};
2
+
3
+ const originalError = console.error;
4
+ const originalLog = console.log;
5
+
6
+ beforeEach(() => {
7
+ if (process.env.TEST_VERBOSE) return;
8
+ console.error = noop;
9
+ console.log = noop;
10
+ });
11
+
12
+ afterEach(() => {
13
+ console.error = originalError;
14
+ console.log = originalLog;
15
+ });