@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.
- package/.github/workflows/main.yml +34 -3
- 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 +25 -3
- package/dist/index.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types/config.d.ts +6 -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 +26 -3
- package/src/types/config.ts +13 -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 +412 -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,376 @@
|
|
|
1
|
+
import * as request from "supertest";
|
|
2
|
+
import { buildAppForManager } from "../helpers/app";
|
|
3
|
+
import { baseConfig, fruitView } 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
|
+
// Explicit factory: the file-based manual mock in tests/__mocks__/ is not on
|
|
16
|
+
// the integration config's roots, and a bare jest.mock("rollup") automock
|
|
17
|
+
// would leave rollup() returning undefined.
|
|
18
|
+
jest.mock("rollup", () => ({
|
|
19
|
+
rollup: jest.fn().mockResolvedValue({
|
|
20
|
+
generate: jest.fn().mockResolvedValue({ output: [{ code: "// mocked bundle" }] }),
|
|
21
|
+
}),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* /customQuery, /notificationBadges, /searchCompanies and /customComponent.
|
|
26
|
+
* The two DB-backed routes run against real engines; the SIRENE proxy and the
|
|
27
|
+
* rollup bundler keep their service mocks (external systems).
|
|
28
|
+
*/
|
|
29
|
+
describeIntegration("custom routes against real databases", () => {
|
|
30
|
+
const harnesses: Record<BackendName, DbHarness> = {} as any;
|
|
31
|
+
|
|
32
|
+
beforeAll(async () => {
|
|
33
|
+
harnesses.mysql = await getMysqlHarness();
|
|
34
|
+
harnesses.mysql2 = await getMysql2Harness();
|
|
35
|
+
harnesses.postgresql = await getPgHarness();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterAll(async () => {
|
|
39
|
+
await harnesses.mysql?.stop();
|
|
40
|
+
await harnesses.mysql2?.stop();
|
|
41
|
+
await harnesses.postgresql?.stop();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe.each([
|
|
45
|
+
["mysql", () => harnesses.mysql],
|
|
46
|
+
["mysql2", () => harnesses.mysql2],
|
|
47
|
+
["postgresql", () => harnesses.postgresql],
|
|
48
|
+
])("backend: %s", (_backend, getHarness) => {
|
|
49
|
+
let harness: DbHarness;
|
|
50
|
+
|
|
51
|
+
beforeEach(async () => {
|
|
52
|
+
harness = getHarness();
|
|
53
|
+
await resetTables(harness);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const app = (viewOverrides: any = {}) =>
|
|
57
|
+
buildAppForManager(
|
|
58
|
+
harness.manager,
|
|
59
|
+
baseConfig({ views: [fruitView({ primaryId: "id", ...viewOverrides })] }),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const seedNote = async (fruitId: number, text: string) => {
|
|
63
|
+
const { err } = await harness.query(
|
|
64
|
+
"INSERT INTO fruit_notes (fruitId, text) VALUES (?, ?)",
|
|
65
|
+
[fruitId, text],
|
|
66
|
+
);
|
|
67
|
+
expect(err).toBeFalsy();
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
describe("GET /api/customQuery", () => {
|
|
71
|
+
const PANEL_QUERY = "SELECT * FROM fruit_notes WHERE fruitId = ?";
|
|
72
|
+
|
|
73
|
+
it("is replaced by a 404 stub when the config declares no SQL templates", async () => {
|
|
74
|
+
const res = await request(app())
|
|
75
|
+
.get("/api/customQuery")
|
|
76
|
+
.query({ query: "SELECT * FROM fruits", primaryValue: 1 });
|
|
77
|
+
expect(res.status).toBe(404);
|
|
78
|
+
// The stub never touches the database.
|
|
79
|
+
const { rows } = await harness.query("SELECT * FROM fruits");
|
|
80
|
+
expect(rows).toHaveLength(0);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("runs a whitelisted read-only template with the bound primaryValue", async () => {
|
|
84
|
+
await seedNote(7, "hello");
|
|
85
|
+
const config = baseConfig({
|
|
86
|
+
views: [
|
|
87
|
+
fruitView({
|
|
88
|
+
recordViewPanels: [{ label: "Notes", query: PANEL_QUERY }],
|
|
89
|
+
}),
|
|
90
|
+
],
|
|
91
|
+
});
|
|
92
|
+
const res = await request(
|
|
93
|
+
buildAppForManager(harness.manager, config),
|
|
94
|
+
)
|
|
95
|
+
.get("/api/customQuery")
|
|
96
|
+
.query({ query: PANEL_QUERY, primaryValue: 7 })
|
|
97
|
+
.expect(200);
|
|
98
|
+
expect(res.body).toEqual([{ id: 1, fruitId: 7, text: "hello" }]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("rejects templates not present in the config with 403", async () => {
|
|
102
|
+
const config = baseConfig({
|
|
103
|
+
views: [
|
|
104
|
+
fruitView({
|
|
105
|
+
recordViewPanels: [{ label: "Notes", query: "SELECT 1" }],
|
|
106
|
+
}),
|
|
107
|
+
],
|
|
108
|
+
});
|
|
109
|
+
const res = await request(
|
|
110
|
+
buildAppForManager(harness.manager, config),
|
|
111
|
+
)
|
|
112
|
+
.get("/api/customQuery")
|
|
113
|
+
.query({ query: "SELECT * FROM users", primaryValue: 1 })
|
|
114
|
+
.expect(403);
|
|
115
|
+
expect(res.body).toEqual({ error: "Query not allowed" });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("rejects non-read-only templates (400) even when whitelisted", async () => {
|
|
119
|
+
const config = baseConfig({
|
|
120
|
+
views: [
|
|
121
|
+
fruitView({
|
|
122
|
+
recordViewPanels: [
|
|
123
|
+
{ label: "Bad", query: "DELETE FROM fruits WHERE id = ?" },
|
|
124
|
+
],
|
|
125
|
+
}),
|
|
126
|
+
],
|
|
127
|
+
});
|
|
128
|
+
const res = await request(
|
|
129
|
+
buildAppForManager(harness.manager, config),
|
|
130
|
+
)
|
|
131
|
+
.get("/api/customQuery")
|
|
132
|
+
.query({ query: "DELETE FROM fruits WHERE id = ?", primaryValue: 1 })
|
|
133
|
+
.expect(400);
|
|
134
|
+
expect(res.body).toEqual({ error: "Only read-only queries are allowed" });
|
|
135
|
+
// Nothing was actually deleted.
|
|
136
|
+
const { rows } = await harness.query("SELECT * FROM fruits");
|
|
137
|
+
expect(rows).toHaveLength(0);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("rejects whitelisted templates containing a semicolon", async () => {
|
|
141
|
+
const config = baseConfig({
|
|
142
|
+
views: [
|
|
143
|
+
fruitView({
|
|
144
|
+
recordViewPanels: [
|
|
145
|
+
{ label: "Two", query: "SELECT 1; DROP TABLE fruits" },
|
|
146
|
+
],
|
|
147
|
+
}),
|
|
148
|
+
],
|
|
149
|
+
});
|
|
150
|
+
await request(buildAppForManager(harness.manager, config))
|
|
151
|
+
.get("/api/customQuery")
|
|
152
|
+
.query({ query: "SELECT 1; DROP TABLE fruits", primaryValue: 1 })
|
|
153
|
+
.expect(400);
|
|
154
|
+
// Fruits survived the rejected template.
|
|
155
|
+
const { err, rows } = await harness.query("SELECT * FROM fruits");
|
|
156
|
+
expect(err).toBeFalsy();
|
|
157
|
+
expect(rows).toHaveLength(0);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("accepts WITH templates", async () => {
|
|
161
|
+
const config = baseConfig({
|
|
162
|
+
views: [
|
|
163
|
+
fruitView({
|
|
164
|
+
recordViewPanels: [
|
|
165
|
+
{
|
|
166
|
+
label: "Agg",
|
|
167
|
+
query: "WITH t AS (SELECT 1 AS n) SELECT * FROM t WHERE n = ?",
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
}),
|
|
171
|
+
],
|
|
172
|
+
});
|
|
173
|
+
const res = await request(
|
|
174
|
+
buildAppForManager(harness.manager, config),
|
|
175
|
+
)
|
|
176
|
+
.get("/api/customQuery")
|
|
177
|
+
.query({
|
|
178
|
+
query: "WITH t AS (SELECT 1 AS n) SELECT * FROM t WHERE n = ?",
|
|
179
|
+
primaryValue: 1,
|
|
180
|
+
})
|
|
181
|
+
.expect(200);
|
|
182
|
+
expect(Number(res.body[0].n)).toBe(1);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("applies the panel formatValue post-processing", async () => {
|
|
186
|
+
await seedNote(7, "hello");
|
|
187
|
+
const panel = {
|
|
188
|
+
label: "Notes",
|
|
189
|
+
query: PANEL_QUERY,
|
|
190
|
+
formatValue: {
|
|
191
|
+
text: (row: any) => row.text.toUpperCase(),
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
const config = baseConfig({
|
|
195
|
+
views: [fruitView({ recordViewPanels: [panel] })],
|
|
196
|
+
});
|
|
197
|
+
const res = await request(
|
|
198
|
+
buildAppForManager(harness.manager, config),
|
|
199
|
+
)
|
|
200
|
+
.get("/api/customQuery")
|
|
201
|
+
.query({
|
|
202
|
+
query: PANEL_QUERY,
|
|
203
|
+
primaryValue: 7,
|
|
204
|
+
viewIndex: 0,
|
|
205
|
+
panelIndex: 0,
|
|
206
|
+
})
|
|
207
|
+
.expect(200);
|
|
208
|
+
expect(res.body[0].text).toBe("HELLO");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("returns 500 on engine error", async () => {
|
|
212
|
+
const config = baseConfig({
|
|
213
|
+
views: [
|
|
214
|
+
fruitView({
|
|
215
|
+
recordViewPanels: [{ label: "Notes", query: "SELECT broken" }],
|
|
216
|
+
}),
|
|
217
|
+
],
|
|
218
|
+
});
|
|
219
|
+
await request(buildAppForManager(harness.manager, config))
|
|
220
|
+
.get("/api/customQuery")
|
|
221
|
+
.query({ query: "SELECT broken", primaryValue: 1 })
|
|
222
|
+
.expect(500);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe("GET /api/notificationBadges", () => {
|
|
227
|
+
it("computes badges per view and subview", async () => {
|
|
228
|
+
const view = fruitView({
|
|
229
|
+
notificationBadge: {
|
|
230
|
+
query: "SELECT 3 AS count",
|
|
231
|
+
attribute: "count",
|
|
232
|
+
},
|
|
233
|
+
subviews: [
|
|
234
|
+
{
|
|
235
|
+
label: "Old",
|
|
236
|
+
filters: "age > 50",
|
|
237
|
+
notificationBadge: {
|
|
238
|
+
query: "SELECT 1 AS total",
|
|
239
|
+
attribute: "total",
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
],
|
|
243
|
+
});
|
|
244
|
+
const res = await request(app({ ...view })).get("/api/notificationBadges").expect(200);
|
|
245
|
+
// MW1 serves a {...view} copy for table views, so enrichment stamps
|
|
246
|
+
// the __id__ on the copy — match the view badge by value, the subview
|
|
247
|
+
// exactly (subview objects are shared, not copied).
|
|
248
|
+
const values = Object.values(res.body);
|
|
249
|
+
expect(values).toContain(3);
|
|
250
|
+
expect(res.body[(view.subviews[0] as any).__id__]).toBe(1);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("honors displayCondition", async () => {
|
|
254
|
+
const view = fruitView({
|
|
255
|
+
notificationBadge: {
|
|
256
|
+
query: "SELECT 5 AS count",
|
|
257
|
+
attribute: "count",
|
|
258
|
+
displayCondition: (badge: any) => badge > 10,
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
const res = await request(app({ ...view })).get("/api/notificationBadges").expect(200);
|
|
262
|
+
expect(res.body).toEqual({});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("skips badges whose query fails", async () => {
|
|
266
|
+
const view = fruitView({
|
|
267
|
+
notificationBadge: { query: "SELECT broken", attribute: "x" },
|
|
268
|
+
});
|
|
269
|
+
const res = await request(app({ ...view })).get("/api/notificationBadges").expect(200);
|
|
270
|
+
expect(res.body).toEqual({});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("omits falsy badge values", async () => {
|
|
274
|
+
const view = fruitView({
|
|
275
|
+
notificationBadge: { query: "SELECT 0 AS count", attribute: "count" },
|
|
276
|
+
});
|
|
277
|
+
const res = await request(app({ ...view })).get("/api/notificationBadges").expect(200);
|
|
278
|
+
expect(res.body).toEqual({});
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe("GET /api/searchCompanies", () => {
|
|
283
|
+
const opendatasoft = () =>
|
|
284
|
+
jest.requireMock("axios") as { get: jest.Mock; post: jest.Mock };
|
|
285
|
+
|
|
286
|
+
it("proxies the SIRENE search and maps active establishments", async () => {
|
|
287
|
+
const axiosMock = opendatasoft();
|
|
288
|
+
axiosMock.get.mockResolvedValueOnce({
|
|
289
|
+
data: {
|
|
290
|
+
results: [
|
|
291
|
+
{
|
|
292
|
+
denominationunitelegale: "GALADRIM",
|
|
293
|
+
etatadministratifetablissement: "Actif",
|
|
294
|
+
siret: "12345678900011",
|
|
295
|
+
siren: "123456789",
|
|
296
|
+
numerovoieetablissement: "12",
|
|
297
|
+
typevoieetablissement: "RUE",
|
|
298
|
+
libellevoieetablissement: "DES FRUITS",
|
|
299
|
+
libellecommuneetablissement: "LYON",
|
|
300
|
+
codepostaletablissement: "69001",
|
|
301
|
+
activiteprincipaleunitelegale: "62.01Z",
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
denominationunitelegale: "CLOSED CO",
|
|
305
|
+
etatadministratifetablissement: "Fermé",
|
|
306
|
+
},
|
|
307
|
+
],
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
const res = await request(app())
|
|
311
|
+
.get("/api/searchCompanies")
|
|
312
|
+
.query({ query: "galadrim fr" })
|
|
313
|
+
.expect(200);
|
|
314
|
+
expect(axiosMock.get).toHaveBeenCalledWith(
|
|
315
|
+
"https://public.opendatasoft.com/api/explore/v2.1/catalog/datasets/economicref-france-sirene-v3/records",
|
|
316
|
+
{
|
|
317
|
+
params: {
|
|
318
|
+
where:
|
|
319
|
+
"denominationunitelegale LIKE 'galadrim-fr*' OR siret LIKE 'galadrim-fr*'",
|
|
320
|
+
limit: 100,
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
);
|
|
324
|
+
expect(res.body).toHaveLength(1);
|
|
325
|
+
expect(res.body[0]).toMatchObject({
|
|
326
|
+
companyName: "GALADRIM",
|
|
327
|
+
siret: "12345678900011",
|
|
328
|
+
siren: "123456789",
|
|
329
|
+
address: "12 RUE DES FRUITS",
|
|
330
|
+
city: "LYON",
|
|
331
|
+
zipCode: "69001",
|
|
332
|
+
activityNumber: "62.01Z",
|
|
333
|
+
});
|
|
334
|
+
expect(res.body[0].id).toBeDefined();
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("returns [] (with 200) when the external API fails", async () => {
|
|
338
|
+
const axiosMock = opendatasoft();
|
|
339
|
+
axiosMock.get.mockRejectedValueOnce(new Error("external down"));
|
|
340
|
+
const res = await request(app())
|
|
341
|
+
.get("/api/searchCompanies")
|
|
342
|
+
.query({ query: "whatever" })
|
|
343
|
+
.expect(200);
|
|
344
|
+
expect(res.body).toEqual([]);
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
describe("GET /api/customComponent", () => {
|
|
349
|
+
it("bundles the component with rollup and serves the generated code", async () => {
|
|
350
|
+
const rollupMock = jest.requireMock("rollup") as {
|
|
351
|
+
rollup: jest.Mock;
|
|
352
|
+
};
|
|
353
|
+
const res = await request(app())
|
|
354
|
+
.get("/api/customComponent")
|
|
355
|
+
.query({ componentPath: "src/front/src/pages/CustomView.js" })
|
|
356
|
+
.expect(200);
|
|
357
|
+
expect(res.text).toBe("// mocked bundle");
|
|
358
|
+
expect(rollupMock.rollup).toHaveBeenCalled();
|
|
359
|
+
const call = rollupMock.rollup.mock.calls[0][0];
|
|
360
|
+
expect(call.input).toContain("CustomView.js");
|
|
361
|
+
expect(call.external).toContain("react");
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("returns 500 when the bundling fails", async () => {
|
|
365
|
+
const rollupMock = jest.requireMock("rollup") as {
|
|
366
|
+
rollup: jest.Mock;
|
|
367
|
+
};
|
|
368
|
+
rollupMock.rollup.mockRejectedValueOnce(new Error("bundle failure"));
|
|
369
|
+
await request(app())
|
|
370
|
+
.get("/api/customComponent")
|
|
371
|
+
.query({ componentPath: "src/front/src/pages/CustomView.js" })
|
|
372
|
+
.expect(500);
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
});
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import * as request from "supertest";
|
|
2
|
+
import { generateUniqueIdForObject } from "../../src/api";
|
|
3
|
+
import { buildAppForManager } from "../helpers/app";
|
|
4
|
+
import { baseConfig, fruitView, EDITOR } from "../helpers/fixtures";
|
|
5
|
+
import {
|
|
6
|
+
BackendName,
|
|
7
|
+
DbHarness,
|
|
8
|
+
describeIntegration,
|
|
9
|
+
getMysql2Harness,
|
|
10
|
+
getMysqlHarness,
|
|
11
|
+
getPgHarness,
|
|
12
|
+
resetTables,
|
|
13
|
+
} from "./dbHarness";
|
|
14
|
+
|
|
15
|
+
jest.mock("axios");
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* /foreignSelectRecords against real engines. The scripted suite asserted the
|
|
19
|
+
* generated SQL strings; here the LIKE composition, escaping, limits, filters
|
|
20
|
+
* and label/value resolution are verified by what the engine returns.
|
|
21
|
+
*/
|
|
22
|
+
describeIntegration("foreignSelectRecords against real databases", () => {
|
|
23
|
+
const harnesses: Record<BackendName, DbHarness> = {} as any;
|
|
24
|
+
|
|
25
|
+
beforeAll(async () => {
|
|
26
|
+
harnesses.mysql = await getMysqlHarness();
|
|
27
|
+
harnesses.mysql2 = await getMysql2Harness();
|
|
28
|
+
harnesses.postgresql = await getPgHarness();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterAll(async () => {
|
|
32
|
+
await harnesses.mysql?.stop();
|
|
33
|
+
await harnesses.mysql2?.stop();
|
|
34
|
+
await harnesses.postgresql?.stop();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe.each([
|
|
38
|
+
["mysql", () => harnesses.mysql],
|
|
39
|
+
["mysql2", () => harnesses.mysql2],
|
|
40
|
+
["postgresql", () => harnesses.postgresql],
|
|
41
|
+
])("backend: %s", (_backend, getHarness) => {
|
|
42
|
+
let harness: DbHarness;
|
|
43
|
+
|
|
44
|
+
beforeEach(async () => {
|
|
45
|
+
harness = getHarness();
|
|
46
|
+
await resetTables(harness);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const foreignColumn = (overrides: any = {}) => ({
|
|
50
|
+
label: "Famille",
|
|
51
|
+
name: "familyId",
|
|
52
|
+
dataType: "foreign",
|
|
53
|
+
table: "families",
|
|
54
|
+
foreignName: "id",
|
|
55
|
+
searchableName: "name",
|
|
56
|
+
...overrides,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const configWithForeign = (
|
|
60
|
+
columnOverrides: any = {},
|
|
61
|
+
viewOverrides: any = {},
|
|
62
|
+
) =>
|
|
63
|
+
baseConfig({
|
|
64
|
+
views: [
|
|
65
|
+
fruitView({
|
|
66
|
+
primaryId: "id",
|
|
67
|
+
columns: [
|
|
68
|
+
{ label: "Nom", name: "fruitName" },
|
|
69
|
+
foreignColumn(columnOverrides),
|
|
70
|
+
],
|
|
71
|
+
...viewOverrides,
|
|
72
|
+
}),
|
|
73
|
+
],
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const app = (config: any = configWithForeign(), user: any = EDITOR) =>
|
|
77
|
+
buildAppForManager(harness.manager, config, user);
|
|
78
|
+
|
|
79
|
+
const insertFamilies = async (
|
|
80
|
+
rows: Array<Record<string, any>>,
|
|
81
|
+
): Promise<void> => {
|
|
82
|
+
const keys = [
|
|
83
|
+
"name",
|
|
84
|
+
"familyId",
|
|
85
|
+
"uuid",
|
|
86
|
+
"active",
|
|
87
|
+
"ownerId",
|
|
88
|
+
"f_id",
|
|
89
|
+
] as const;
|
|
90
|
+
// The manager's query path expects MySQL-style ? placeholders on every
|
|
91
|
+
// dialect: convertQuery maps them to $n for PG.
|
|
92
|
+
for (let i = 0; i < rows.length; i += 500) {
|
|
93
|
+
const chunk = rows.slice(i, i + 500);
|
|
94
|
+
const placeholders = chunk
|
|
95
|
+
.map(() => `(${keys.map(() => "?").join(", ")})`)
|
|
96
|
+
.join(", ");
|
|
97
|
+
const args = chunk.flatMap((row) => keys.map((k) => row[k] ?? null));
|
|
98
|
+
const { err } = await harness.query(
|
|
99
|
+
`INSERT INTO families (name, familyId, uuid, active, ownerId, f_id) VALUES ${placeholders}`,
|
|
100
|
+
args,
|
|
101
|
+
);
|
|
102
|
+
expect(err).toBeFalsy();
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const postForeign = (
|
|
107
|
+
instance: any,
|
|
108
|
+
column: any,
|
|
109
|
+
body: any = {},
|
|
110
|
+
) =>
|
|
111
|
+
request(instance)
|
|
112
|
+
.post("/api/foreignSelectRecords")
|
|
113
|
+
.send({ viewIndex: 0, columnId: generateUniqueIdForObject(column), ...body });
|
|
114
|
+
|
|
115
|
+
const byValue = (options: any[]) =>
|
|
116
|
+
[...options].sort((a, b) => Number(a.value) - Number(b.value));
|
|
117
|
+
|
|
118
|
+
describe("POST /api/foreignSelectRecords", () => {
|
|
119
|
+
it("returns { value, label } options from the foreign table", async () => {
|
|
120
|
+
await insertFamilies([
|
|
121
|
+
{ name: "Rutaceae", familyId: 11 },
|
|
122
|
+
{ name: "Rosaceae", familyId: 22 },
|
|
123
|
+
]);
|
|
124
|
+
const column = foreignColumn();
|
|
125
|
+
// Without a query every family is listed; LIKE comparison is
|
|
126
|
+
// case-insensitive on MySQL's default collation and ILIKE on PG.
|
|
127
|
+
const all = await postForeign(app(), column).expect(200);
|
|
128
|
+
expect(byValue(all.body)).toEqual([
|
|
129
|
+
{ value: 1, label: 11 },
|
|
130
|
+
{ value: 2, label: 22 },
|
|
131
|
+
]);
|
|
132
|
+
// The default label falls back to record[column.name].
|
|
133
|
+
const filtered = await postForeign(app(), column, {
|
|
134
|
+
query: "ros",
|
|
135
|
+
}).expect(200);
|
|
136
|
+
expect(filtered.body).toEqual([{ value: 2, label: 22 }]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("escapes apostrophes in the search query", async () => {
|
|
140
|
+
await insertFamilies([
|
|
141
|
+
{ name: "l'agrume du sud", familyId: 1 },
|
|
142
|
+
{ name: "Rutaceae", familyId: 2 },
|
|
143
|
+
]);
|
|
144
|
+
const column = foreignColumn();
|
|
145
|
+
// MySQL escapes with \' and PostgreSQL with '': both must find the
|
|
146
|
+
// row without an engine error.
|
|
147
|
+
const res = await postForeign(app(), column, {
|
|
148
|
+
query: "l'agrume",
|
|
149
|
+
}).expect(200);
|
|
150
|
+
expect(res.body).toEqual([{ value: 1, label: 1 }]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("wholeSearch matches anywhere, default only the prefix", async () => {
|
|
154
|
+
await insertFamilies([
|
|
155
|
+
{ name: "rosehip", familyId: 1 },
|
|
156
|
+
{ name: "wildrose", familyId: 2 },
|
|
157
|
+
]);
|
|
158
|
+
const column = foreignColumn();
|
|
159
|
+
const prefix = await postForeign(app(), column, {
|
|
160
|
+
query: "ros",
|
|
161
|
+
}).expect(200);
|
|
162
|
+
expect(prefix.body).toEqual([{ value: 1, label: 1 }]);
|
|
163
|
+
const anywhere = await postForeign(
|
|
164
|
+
app(configWithForeign({ wholeSearch: true })),
|
|
165
|
+
foreignColumn({ wholeSearch: true }),
|
|
166
|
+
{ query: "ros" },
|
|
167
|
+
).expect(200);
|
|
168
|
+
expect(byValue(anywhere.body)).toEqual([
|
|
169
|
+
{ value: 1, label: 1 },
|
|
170
|
+
{ value: 2, label: 2 },
|
|
171
|
+
]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("respects column.limit and defaults to 1000", async () => {
|
|
175
|
+
await insertFamilies(
|
|
176
|
+
Array.from({ length: 1005 }, (_, i) => ({
|
|
177
|
+
name: `family${i + 1}`,
|
|
178
|
+
familyId: i + 1,
|
|
179
|
+
})),
|
|
180
|
+
);
|
|
181
|
+
const column = foreignColumn();
|
|
182
|
+
const defaulted = await postForeign(app(), column).expect(200);
|
|
183
|
+
expect(defaulted.body).toHaveLength(1000);
|
|
184
|
+
const limited = await postForeign(
|
|
185
|
+
app(configWithForeign({ limit: 5 })),
|
|
186
|
+
foreignColumn({ limit: 5 }),
|
|
187
|
+
).expect(200);
|
|
188
|
+
expect(limited.body).toHaveLength(5);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("applies column.filters and column.filtersFromUser", async () => {
|
|
192
|
+
await insertFamilies([
|
|
193
|
+
{ name: "rose", familyId: 21, active: 1, ownerId: 2 },
|
|
194
|
+
{ name: "apple", familyId: 22, active: 0, ownerId: 2 },
|
|
195
|
+
{ name: "pear", familyId: 23, active: 1, ownerId: 1 },
|
|
196
|
+
]);
|
|
197
|
+
const overrides = {
|
|
198
|
+
filters: "active = 1",
|
|
199
|
+
filtersFromUser: (userId: number) => `ownerId = ${userId}`,
|
|
200
|
+
};
|
|
201
|
+
// EDITOR.userId === 2: only the active family owned by user 2.
|
|
202
|
+
const res = await postForeign(
|
|
203
|
+
app(configWithForeign(overrides)),
|
|
204
|
+
foreignColumn(overrides),
|
|
205
|
+
{ query: undefined },
|
|
206
|
+
).expect(200);
|
|
207
|
+
expect(res.body).toEqual([{ value: 1, label: 21 }]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("composes selectExpression, join and orderBy from the column", async () => {
|
|
211
|
+
const { err } = await harness.query(
|
|
212
|
+
`INSERT INTO family_labels (id, label) VALUES (1, 'Zeta'), (2, 'Aria')`,
|
|
213
|
+
);
|
|
214
|
+
expect(err).toBeFalsy();
|
|
215
|
+
await insertFamilies([
|
|
216
|
+
{ name: "Apple", familyId: 11, f_id: 1 },
|
|
217
|
+
{ name: "Banana", familyId: 22, f_id: 2 },
|
|
218
|
+
]);
|
|
219
|
+
const overrides = {
|
|
220
|
+
selectExpression: "*, fl.label AS fullName",
|
|
221
|
+
join: "JOIN family_labels fl ON families.f_id = fl.id",
|
|
222
|
+
orderBy: "fullName",
|
|
223
|
+
};
|
|
224
|
+
const res = await postForeign(
|
|
225
|
+
app(configWithForeign(overrides)),
|
|
226
|
+
foreignColumn(overrides),
|
|
227
|
+
).expect(200);
|
|
228
|
+
// Ordered by the joined label: Aria (Banana) before Zeta (Apple).
|
|
229
|
+
expect(res.body).toEqual([
|
|
230
|
+
{ value: 2, label: 22 },
|
|
231
|
+
{ value: 1, label: 11 },
|
|
232
|
+
]);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("labels via formatForeignValue first, then formatValue, then formattedValue", async () => {
|
|
236
|
+
await insertFamilies([{ name: "Rutaceae", familyId: 7 }]);
|
|
237
|
+
const cases: Array<[any, string]> = [
|
|
238
|
+
[
|
|
239
|
+
{ formatForeignValue: (r: any) => `FFV:${r.name}` },
|
|
240
|
+
"FFV:Rutaceae",
|
|
241
|
+
],
|
|
242
|
+
[{ formatValue: (r: any) => `FV:${r.name}` }, "FV:Rutaceae"],
|
|
243
|
+
[{ formattedValue: "'fmt-' + name" }, "fmt-Rutaceae"],
|
|
244
|
+
];
|
|
245
|
+
for (const [overrides, expected] of cases) {
|
|
246
|
+
const res = await postForeign(
|
|
247
|
+
app(configWithForeign(overrides)),
|
|
248
|
+
foreignColumn(overrides),
|
|
249
|
+
).expect(200);
|
|
250
|
+
expect(res.body).toEqual([{ value: 1, label: expected }]);
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("value prefers foreignName over name", async () => {
|
|
255
|
+
await insertFamilies([
|
|
256
|
+
{ name: "Rutaceae", familyId: 9, uuid: "abc" },
|
|
257
|
+
]);
|
|
258
|
+
const overrides = { foreignName: "uuid" };
|
|
259
|
+
const res = await postForeign(
|
|
260
|
+
app(configWithForeign(overrides)),
|
|
261
|
+
foreignColumn(overrides),
|
|
262
|
+
).expect(200);
|
|
263
|
+
expect(res.body).toEqual([{ value: "abc", label: 9 }]);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("returns 500 when the engine rejects the query", async () => {
|
|
267
|
+
await insertFamilies([{ name: "Rutaceae", familyId: 1 }]);
|
|
268
|
+
const overrides = { searchableName: "nope" };
|
|
269
|
+
// The LIKE references a non-existent column: ER_BAD_FIELD_ERROR on
|
|
270
|
+
// MySQL, 42703 on PG.
|
|
271
|
+
await postForeign(
|
|
272
|
+
app(configWithForeign(overrides)),
|
|
273
|
+
foreignColumn(overrides),
|
|
274
|
+
{ query: "x" },
|
|
275
|
+
).expect(500);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("also finds columns inside recordViewPanels createConfig", async () => {
|
|
279
|
+
await insertFamilies([{ name: "Rosaceae", familyId: 33 }]);
|
|
280
|
+
const panelColumn = foreignColumn();
|
|
281
|
+
const config = baseConfig({
|
|
282
|
+
views: [
|
|
283
|
+
fruitView({
|
|
284
|
+
primaryId: "id",
|
|
285
|
+
columns: [{ label: "Nom", name: "fruitName" }],
|
|
286
|
+
recordViewPanels: [
|
|
287
|
+
{
|
|
288
|
+
label: "Notes",
|
|
289
|
+
tableName: "fruit_notes",
|
|
290
|
+
primaryId: "id",
|
|
291
|
+
createConfig: {
|
|
292
|
+
label: "New note",
|
|
293
|
+
recordIdKey: "fruitId",
|
|
294
|
+
columns: [panelColumn],
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
],
|
|
298
|
+
}),
|
|
299
|
+
],
|
|
300
|
+
});
|
|
301
|
+
const res = await postForeign(app(config), panelColumn).expect(200);
|
|
302
|
+
expect(res.body).toEqual([{ value: 1, label: 33 }]);
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
});
|