@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,805 @@
|
|
|
1
|
+
import * as request from "supertest";
|
|
2
|
+
import { generateUniqueIdForObject } from "../../src/api";
|
|
3
|
+
import { buildAppForManager } from "../helpers/app";
|
|
4
|
+
import { baseConfig, fruitView, EDITOR, VIEWER } 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
|
+
import axios from "axios";
|
|
17
|
+
|
|
18
|
+
const mockedAxios = axios as any;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* CRUD routes against real engines. The former unit suite asserted the exact
|
|
22
|
+
* SQL text + bound params emitted for a scripted FakeDb; here every property
|
|
23
|
+
* it protected (identifier quoting, NULL normalization, injection safety,
|
|
24
|
+
* error mapping, callbacks) is verified by what the engine actually did.
|
|
25
|
+
*/
|
|
26
|
+
describeIntegration("crud routes against real databases", () => {
|
|
27
|
+
const harnesses: Record<BackendName, DbHarness> = {} as any;
|
|
28
|
+
|
|
29
|
+
beforeAll(async () => {
|
|
30
|
+
harnesses.mysql = await getMysqlHarness();
|
|
31
|
+
harnesses.mysql2 = await getMysql2Harness();
|
|
32
|
+
harnesses.postgresql = await getPgHarness();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterAll(async () => {
|
|
36
|
+
await harnesses.mysql?.stop();
|
|
37
|
+
await harnesses.mysql2?.stop();
|
|
38
|
+
await harnesses.postgresql?.stop();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe.each([
|
|
42
|
+
["mysql", () => harnesses.mysql],
|
|
43
|
+
["mysql2", () => harnesses.mysql2],
|
|
44
|
+
["postgresql", () => harnesses.postgresql],
|
|
45
|
+
])("backend: %s", (_backend, getHarness) => {
|
|
46
|
+
let harness: DbHarness;
|
|
47
|
+
|
|
48
|
+
beforeEach(async () => {
|
|
49
|
+
harness = getHarness();
|
|
50
|
+
await resetTables(harness);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const app = (
|
|
54
|
+
overrides: any = {},
|
|
55
|
+
user: any = EDITOR,
|
|
56
|
+
) =>
|
|
57
|
+
buildAppForManager(
|
|
58
|
+
harness.manager,
|
|
59
|
+
baseConfig({ views: [fruitView({ primaryId: "id", ...overrides })] }),
|
|
60
|
+
user,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const createFruit = async (data: any, instance = app()) => {
|
|
64
|
+
const res = await request(instance)
|
|
65
|
+
.post("/api/createRecord")
|
|
66
|
+
.send({ viewIndex: 0, data })
|
|
67
|
+
.expect(200);
|
|
68
|
+
return res;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const fruits = async () => (await harness.query("SELECT * FROM fruits")).rows;
|
|
72
|
+
|
|
73
|
+
// MySQL/mysql2 drivers expose the AUTO_INCREMENT id; pg results carry no
|
|
74
|
+
// insertId, so created ids stay undefined in responses (GAP 11).
|
|
75
|
+
const insertIdIsExposed = () => harness.dbType !== "POSTGRESQL";
|
|
76
|
+
|
|
77
|
+
describe("POST /api/updateRecord", () => {
|
|
78
|
+
it("updates the record and persists the new values", async () => {
|
|
79
|
+
await createFruit({ fruitName: "pomme", acidity: 3 });
|
|
80
|
+
await request(app())
|
|
81
|
+
.post("/api/updateRecord")
|
|
82
|
+
.send({
|
|
83
|
+
viewIndex: 0,
|
|
84
|
+
primaryValue: 1,
|
|
85
|
+
data: { fruitName: "poire", acidity: 9 },
|
|
86
|
+
})
|
|
87
|
+
.expect(200);
|
|
88
|
+
const [row] = await fruits();
|
|
89
|
+
expect(row.fruitName).toBe("poire");
|
|
90
|
+
expect(Number(row.acidity)).toBe(9);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("normalizes 'NULL', empty string and 'Invalid date' to null", async () => {
|
|
94
|
+
await createFruit({ fruitName: "pomme", acidity: 3, note: "x" });
|
|
95
|
+
await request(app())
|
|
96
|
+
.post("/api/updateRecord")
|
|
97
|
+
.send({
|
|
98
|
+
viewIndex: 0,
|
|
99
|
+
primaryValue: 1,
|
|
100
|
+
data: { acidity: "NULL", fruitName: "", note: "Invalid date" },
|
|
101
|
+
})
|
|
102
|
+
.expect(200);
|
|
103
|
+
const [row] = await fruits();
|
|
104
|
+
expect(row.acidity).toBeNull();
|
|
105
|
+
expect(row.fruitName).toBeNull();
|
|
106
|
+
expect(row.note).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("is a no-op returning 200 when data is empty", async () => {
|
|
110
|
+
await createFruit({ fruitName: "pomme", acidity: 3 });
|
|
111
|
+
await request(app())
|
|
112
|
+
.post("/api/updateRecord")
|
|
113
|
+
.send({ viewIndex: 0, primaryValue: 1, data: {} })
|
|
114
|
+
.expect(200);
|
|
115
|
+
const [row] = await fruits();
|
|
116
|
+
expect(row.fruitName).toBe("pomme");
|
|
117
|
+
expect(Number(row.acidity)).toBe(3);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("rejects unsafe column names with 400 and leaves the row untouched", async () => {
|
|
121
|
+
await createFruit({ fruitName: "pomme" });
|
|
122
|
+
const res = await request(app())
|
|
123
|
+
.post("/api/updateRecord")
|
|
124
|
+
.send({
|
|
125
|
+
viewIndex: 0,
|
|
126
|
+
primaryValue: 1,
|
|
127
|
+
data: { "evil`; DROP TABLE fruits;--": "x" },
|
|
128
|
+
})
|
|
129
|
+
.expect(400);
|
|
130
|
+
expect(res.body).toEqual({ error: "Invalid column name" });
|
|
131
|
+
const [row] = await fruits();
|
|
132
|
+
expect(row.fruitName).toBe("pomme");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("returns 500 when the engine rejects the statement", async () => {
|
|
136
|
+
await createFruit({ fruitName: "pomme" });
|
|
137
|
+
// "notAColumn" passes the identifier-format check but does not exist:
|
|
138
|
+
// the failure surfaces from the real engine (unknown column), not a
|
|
139
|
+
// scripted queue.
|
|
140
|
+
await request(app())
|
|
141
|
+
.post("/api/updateRecord")
|
|
142
|
+
.send({
|
|
143
|
+
viewIndex: 0,
|
|
144
|
+
primaryValue: 1,
|
|
145
|
+
data: { notAColumn: "x" },
|
|
146
|
+
})
|
|
147
|
+
.expect(500);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("calls the HTTP editCallback with id, data and previousData", async () => {
|
|
151
|
+
mockedAxios.post.mockResolvedValue({});
|
|
152
|
+
const config = {
|
|
153
|
+
views: [
|
|
154
|
+
fruitView({
|
|
155
|
+
primaryId: "id",
|
|
156
|
+
editCallback: "https://api.example.com/edited",
|
|
157
|
+
}),
|
|
158
|
+
],
|
|
159
|
+
};
|
|
160
|
+
await request(buildAppForManager(harness.manager, baseConfig(config)))
|
|
161
|
+
.post("/api/updateRecord")
|
|
162
|
+
.send({
|
|
163
|
+
viewIndex: 0,
|
|
164
|
+
primaryValue: 1,
|
|
165
|
+
data: { fruitName: "pomme" },
|
|
166
|
+
previousData: { fruitName: "poire" },
|
|
167
|
+
})
|
|
168
|
+
.expect(200);
|
|
169
|
+
expect(mockedAxios.post).toHaveBeenCalledWith(
|
|
170
|
+
"https://api.example.com/edited",
|
|
171
|
+
{
|
|
172
|
+
id: 1,
|
|
173
|
+
data: { fruitName: "pomme" },
|
|
174
|
+
previousData: { fruitName: "poire" },
|
|
175
|
+
},
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("swallows editCallback HTTP errors", async () => {
|
|
180
|
+
mockedAxios.post.mockRejectedValue(new Error("network down"));
|
|
181
|
+
const config = {
|
|
182
|
+
views: [
|
|
183
|
+
fruitView({
|
|
184
|
+
primaryId: "id",
|
|
185
|
+
editCallback: "https://api.example.com/edited",
|
|
186
|
+
}),
|
|
187
|
+
],
|
|
188
|
+
};
|
|
189
|
+
const instance = buildAppForManager(
|
|
190
|
+
harness.manager,
|
|
191
|
+
baseConfig(config),
|
|
192
|
+
);
|
|
193
|
+
await createFruit({ fruitName: "poire" }, instance);
|
|
194
|
+
await request(instance)
|
|
195
|
+
.post("/api/updateRecord")
|
|
196
|
+
.send({ viewIndex: 0, primaryValue: 1, data: { fruitName: "pomme" } })
|
|
197
|
+
.expect(200);
|
|
198
|
+
const [row] = await fruits();
|
|
199
|
+
expect(row.fruitName).toBe("pomme");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("calls editCallbackFunction after a successful update", async () => {
|
|
203
|
+
const callback = jest.fn();
|
|
204
|
+
const config = {
|
|
205
|
+
views: [
|
|
206
|
+
fruitView({ primaryId: "id", editCallbackFunction: callback }),
|
|
207
|
+
],
|
|
208
|
+
};
|
|
209
|
+
await request(buildAppForManager(harness.manager, baseConfig(config)))
|
|
210
|
+
.post("/api/updateRecord")
|
|
211
|
+
.send({ viewIndex: 0, primaryValue: 1, data: { fruitName: "pomme" } })
|
|
212
|
+
.expect(200);
|
|
213
|
+
expect(callback).toHaveBeenCalledWith({
|
|
214
|
+
id: 1,
|
|
215
|
+
data: { fruitName: "pomme" },
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe("POST /api/createRecord", () => {
|
|
221
|
+
it("inserts the row and returns the insert id", async () => {
|
|
222
|
+
const res = await createFruit({ fruitName: "pomme", acidity: 3 });
|
|
223
|
+
if (insertIdIsExposed()) {
|
|
224
|
+
expect(res.body).toEqual({ id: 1 });
|
|
225
|
+
} else {
|
|
226
|
+
// CHARACTERIZATION (GAP 11): pg results carry no insertId.
|
|
227
|
+
expect(res.body.id).toBeUndefined();
|
|
228
|
+
}
|
|
229
|
+
const [row] = await fruits();
|
|
230
|
+
expect(row.fruitName).toBe("pomme");
|
|
231
|
+
expect(Number(row.acidity)).toBe(3);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("normalizes 'NULL' and 'Invalid date' to null", async () => {
|
|
235
|
+
await createFruit({
|
|
236
|
+
fruitName: "pomme",
|
|
237
|
+
acidity: "NULL",
|
|
238
|
+
note: "Invalid date",
|
|
239
|
+
});
|
|
240
|
+
const [row] = await fruits();
|
|
241
|
+
expect(row.fruitName).toBe("pomme");
|
|
242
|
+
expect(row.acidity).toBeNull();
|
|
243
|
+
expect(row.note).toBeNull();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("generates a uuid for uuid-typed primary columns", async () => {
|
|
247
|
+
const config = {
|
|
248
|
+
views: [
|
|
249
|
+
fruitView({
|
|
250
|
+
tableName: "uuid_fruits",
|
|
251
|
+
primaryId: "id",
|
|
252
|
+
columns: [
|
|
253
|
+
{ label: "Id", name: "id", dataType: "uuid" },
|
|
254
|
+
{ label: "Nom", name: "fruitName" },
|
|
255
|
+
],
|
|
256
|
+
}),
|
|
257
|
+
],
|
|
258
|
+
};
|
|
259
|
+
const res = await request(
|
|
260
|
+
buildAppForManager(harness.manager, baseConfig(config)),
|
|
261
|
+
)
|
|
262
|
+
.post("/api/createRecord")
|
|
263
|
+
.send({ viewIndex: 0, data: { fruitName: "pomme" } })
|
|
264
|
+
.expect(200);
|
|
265
|
+
const { rows } = await harness.query("SELECT * FROM uuid_fruits");
|
|
266
|
+
expect(rows).toHaveLength(1);
|
|
267
|
+
expect(rows[0].fruitName).toBe("pomme");
|
|
268
|
+
// A server-generated uuid v4 became the primary key.
|
|
269
|
+
expect(rows[0].id).toMatch(
|
|
270
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
|
271
|
+
);
|
|
272
|
+
if (insertIdIsExposed()) {
|
|
273
|
+
// CHARACTERIZATION: no AUTO_INCREMENT column, so the legacy driver
|
|
274
|
+
// reports insertId 0 instead of the generated uuid.
|
|
275
|
+
expect(res.body.id).toBe(0);
|
|
276
|
+
} else {
|
|
277
|
+
expect(res.body.id).toBeUndefined();
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("strips columns with includedInCreatePayload === false", async () => {
|
|
282
|
+
const config = {
|
|
283
|
+
views: [
|
|
284
|
+
fruitView({
|
|
285
|
+
primaryId: "id",
|
|
286
|
+
columns: [
|
|
287
|
+
{ label: "Nom", name: "fruitName" },
|
|
288
|
+
{
|
|
289
|
+
label: "Slug",
|
|
290
|
+
name: "slug",
|
|
291
|
+
includedInCreatePayload: false,
|
|
292
|
+
},
|
|
293
|
+
],
|
|
294
|
+
}),
|
|
295
|
+
],
|
|
296
|
+
};
|
|
297
|
+
await request(
|
|
298
|
+
buildAppForManager(harness.manager, baseConfig(config)),
|
|
299
|
+
)
|
|
300
|
+
.post("/api/createRecord")
|
|
301
|
+
.send({ viewIndex: 0, data: { fruitName: "pomme", slug: "auto" } })
|
|
302
|
+
.expect(200);
|
|
303
|
+
const [row] = await fruits();
|
|
304
|
+
expect(row.fruitName).toBe("pomme");
|
|
305
|
+
expect(row.slug).toBeNull();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const panelConfig = () => ({
|
|
309
|
+
views: [
|
|
310
|
+
fruitView({
|
|
311
|
+
primaryId: "id",
|
|
312
|
+
recordViewPanels: [
|
|
313
|
+
{
|
|
314
|
+
label: "Notes",
|
|
315
|
+
tableName: "fruit_notes",
|
|
316
|
+
primaryId: "id",
|
|
317
|
+
createConfig: {
|
|
318
|
+
label: "New note",
|
|
319
|
+
recordIdKey: "fruitId",
|
|
320
|
+
columns: [{ label: "Texte", name: "text" }],
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
],
|
|
324
|
+
}),
|
|
325
|
+
],
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("injects the parent recordId for panel creations", async () => {
|
|
329
|
+
const instance = buildAppForManager(
|
|
330
|
+
harness.manager,
|
|
331
|
+
baseConfig(panelConfig()),
|
|
332
|
+
);
|
|
333
|
+
// The front sends indexes as strings, but a numeric panelIndex 0 must
|
|
334
|
+
// target the panel too.
|
|
335
|
+
const res = await request(instance)
|
|
336
|
+
.post("/api/createRecord")
|
|
337
|
+
.send({
|
|
338
|
+
viewIndex: "0",
|
|
339
|
+
panelIndex: "0",
|
|
340
|
+
recordId: "7",
|
|
341
|
+
data: { text: "hi" },
|
|
342
|
+
})
|
|
343
|
+
.expect(200);
|
|
344
|
+
if (insertIdIsExposed()) {
|
|
345
|
+
expect(res.body).toEqual({ id: 1 });
|
|
346
|
+
} else {
|
|
347
|
+
expect(res.body.id).toBeUndefined();
|
|
348
|
+
}
|
|
349
|
+
const { rows } = await harness.query("SELECT * FROM fruit_notes");
|
|
350
|
+
expect(rows).toHaveLength(1);
|
|
351
|
+
expect(rows[0].text).toBe("hi");
|
|
352
|
+
expect(Number(rows[0].fruitId)).toBe(7);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("targets the panel table when panelIndex is the number 0", async () => {
|
|
356
|
+
// A numeric 0 must not be treated as "no panel" (falsy) — otherwise
|
|
357
|
+
// the insert silently targets the view's table without the parent id.
|
|
358
|
+
const instance = buildAppForManager(
|
|
359
|
+
harness.manager,
|
|
360
|
+
baseConfig(panelConfig()),
|
|
361
|
+
);
|
|
362
|
+
await request(instance)
|
|
363
|
+
.post("/api/createRecord")
|
|
364
|
+
.send({
|
|
365
|
+
viewIndex: 0,
|
|
366
|
+
panelIndex: 0,
|
|
367
|
+
recordId: 7,
|
|
368
|
+
data: { text: "a" },
|
|
369
|
+
})
|
|
370
|
+
.expect(200);
|
|
371
|
+
const { rows } = await harness.query("SELECT * FROM fruit_notes");
|
|
372
|
+
expect(rows).toHaveLength(1);
|
|
373
|
+
expect(rows[0].text).toBe("a");
|
|
374
|
+
expect(Number(rows[0].fruitId)).toBe(7);
|
|
375
|
+
expect(await fruits()).toHaveLength(0);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("rejects unsafe column names with 400", async () => {
|
|
379
|
+
const res = await request(app())
|
|
380
|
+
.post("/api/createRecord")
|
|
381
|
+
.send({ viewIndex: 0, data: { "bad;col": "x" } })
|
|
382
|
+
.expect(400);
|
|
383
|
+
expect(res.body).toEqual({ error: "Invalid column name" });
|
|
384
|
+
expect(await fruits()).toHaveLength(0);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("maps a real duplicate entry to a friendly message on MySQL", async () => {
|
|
388
|
+
const config = {
|
|
389
|
+
views: [
|
|
390
|
+
fruitView({
|
|
391
|
+
tableName: "unique_fruits",
|
|
392
|
+
primaryId: "id",
|
|
393
|
+
columns: [{ label: "Nom", name: "fruitName" }],
|
|
394
|
+
}),
|
|
395
|
+
],
|
|
396
|
+
};
|
|
397
|
+
const instance = buildAppForManager(
|
|
398
|
+
harness.manager,
|
|
399
|
+
baseConfig(config),
|
|
400
|
+
);
|
|
401
|
+
await request(instance)
|
|
402
|
+
.post("/api/createRecord")
|
|
403
|
+
.send({ viewIndex: 0, data: { fruitName: "pomme" } })
|
|
404
|
+
.expect(200);
|
|
405
|
+
const res = await request(instance)
|
|
406
|
+
.post("/api/createRecord")
|
|
407
|
+
.send({ viewIndex: 0, data: { fruitName: "pomme" } })
|
|
408
|
+
.expect(500);
|
|
409
|
+
if (harness.dbType === "POSTGRESQL") {
|
|
410
|
+
// CHARACTERIZATION (GAP): the uniqueness violation arrives with pg's
|
|
411
|
+
// SQLSTATE 23505, not the MySQL ER_DUP_ENTRY code, so the friendly
|
|
412
|
+
// French duplicate message is not shown on PostgreSQL.
|
|
413
|
+
expect(res.body.message).toBe("Une erreur est survenue.");
|
|
414
|
+
} else {
|
|
415
|
+
expect(res.body.message).toBe("Cet élément existe déjà.");
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it("maps other db errors to a generic message", async () => {
|
|
420
|
+
const res = await request(app())
|
|
421
|
+
.post("/api/createRecord")
|
|
422
|
+
.send({ viewIndex: 0, data: { notAColumn: "x" } })
|
|
423
|
+
.expect(500);
|
|
424
|
+
expect(res.body.message).toBe("Une erreur est survenue.");
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it("calls createCallback (HTTP, swallowed on error) and createCallbackFunction", async () => {
|
|
428
|
+
const callbackFn = jest.fn();
|
|
429
|
+
const config = {
|
|
430
|
+
views: [
|
|
431
|
+
fruitView({
|
|
432
|
+
primaryId: "id",
|
|
433
|
+
createCallback: "https://api.example.com/created",
|
|
434
|
+
createCallbackFunction: callbackFn,
|
|
435
|
+
}),
|
|
436
|
+
],
|
|
437
|
+
};
|
|
438
|
+
mockedAxios.post.mockRejectedValue(new Error("down"));
|
|
439
|
+
const res = await request(
|
|
440
|
+
buildAppForManager(harness.manager, baseConfig(config)),
|
|
441
|
+
)
|
|
442
|
+
.post("/api/createRecord")
|
|
443
|
+
.send({ viewIndex: 0, data: { fruitName: "pomme" } })
|
|
444
|
+
.expect(200);
|
|
445
|
+
expect(mockedAxios.post).toHaveBeenCalledWith(
|
|
446
|
+
"https://api.example.com/created",
|
|
447
|
+
{
|
|
448
|
+
id: insertIdIsExposed() ? 1 : undefined,
|
|
449
|
+
data: { fruitName: "pomme" },
|
|
450
|
+
},
|
|
451
|
+
);
|
|
452
|
+
expect(callbackFn).toHaveBeenCalledWith({
|
|
453
|
+
id: insertIdIsExposed() ? 1 : undefined,
|
|
454
|
+
data: { fruitName: "pomme" },
|
|
455
|
+
});
|
|
456
|
+
expect(await fruits()).toHaveLength(1);
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
describe("POST /api/deleteRecord", () => {
|
|
461
|
+
it("selects then deletes the record, passing deletedRecord to callbacks", async () => {
|
|
462
|
+
await createFruit({ fruitName: "pomme", acidity: 3 });
|
|
463
|
+
const callbackFn = jest.fn();
|
|
464
|
+
const config = {
|
|
465
|
+
views: [
|
|
466
|
+
fruitView({
|
|
467
|
+
primaryId: "id",
|
|
468
|
+
deleteCallback: "https://api.example.com/deleted",
|
|
469
|
+
deleteCallbackFunction: callbackFn,
|
|
470
|
+
}),
|
|
471
|
+
],
|
|
472
|
+
};
|
|
473
|
+
mockedAxios.post.mockResolvedValue({});
|
|
474
|
+
const instance = buildAppForManager(
|
|
475
|
+
harness.manager,
|
|
476
|
+
baseConfig(config),
|
|
477
|
+
);
|
|
478
|
+
await request(instance)
|
|
479
|
+
.post("/api/deleteRecord")
|
|
480
|
+
.send({ viewIndex: 0, primaryValue: 1 })
|
|
481
|
+
.expect(200);
|
|
482
|
+
expect(mockedAxios.post).toHaveBeenCalledWith(
|
|
483
|
+
"https://api.example.com/deleted",
|
|
484
|
+
{ id: 1 },
|
|
485
|
+
);
|
|
486
|
+
expect(callbackFn).toHaveBeenCalledWith({
|
|
487
|
+
id: 1,
|
|
488
|
+
deletedRecord: expect.objectContaining({ fruitName: "pomme" }),
|
|
489
|
+
});
|
|
490
|
+
expect(await fruits()).toHaveLength(0);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it("returns 404 when the record does not exist", async () => {
|
|
494
|
+
const res = await request(app())
|
|
495
|
+
.post("/api/deleteRecord")
|
|
496
|
+
.send({ viewIndex: 0, primaryValue: 999 })
|
|
497
|
+
.expect(404);
|
|
498
|
+
expect(res.text).toBe("Record not found");
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it("returns 500 when the select fails", async () => {
|
|
502
|
+
// The view targets a table that does not exist: the engine error
|
|
503
|
+
// surfaces as a 500 (previously scripted via a queued error).
|
|
504
|
+
const config = {
|
|
505
|
+
views: [fruitView({ tableName: "table_does_not_exist", primaryId: "id" })],
|
|
506
|
+
};
|
|
507
|
+
await request(buildAppForManager(harness.manager, baseConfig(config)))
|
|
508
|
+
.post("/api/deleteRecord")
|
|
509
|
+
.send({ viewIndex: 0, primaryValue: 1 })
|
|
510
|
+
.expect(500);
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
describe("POST /api/deleteRecordFromPanel", () => {
|
|
515
|
+
const panelView = (panelOverrides: any = {}) => ({
|
|
516
|
+
views: [
|
|
517
|
+
fruitView({
|
|
518
|
+
primaryId: "id",
|
|
519
|
+
recordViewPanels: [
|
|
520
|
+
{
|
|
521
|
+
label: "Notes",
|
|
522
|
+
tableName: "fruit_notes",
|
|
523
|
+
primaryId: "id",
|
|
524
|
+
...panelOverrides,
|
|
525
|
+
},
|
|
526
|
+
],
|
|
527
|
+
}),
|
|
528
|
+
],
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
const insertNote = async (text: string) => {
|
|
532
|
+
await harness.query(
|
|
533
|
+
"INSERT INTO fruit_notes (text, fruitId) VALUES (?, ?)",
|
|
534
|
+
[text, 3],
|
|
535
|
+
);
|
|
536
|
+
const { rows } = await harness.query(
|
|
537
|
+
"SELECT * FROM fruit_notes WHERE text = ?",
|
|
538
|
+
[text],
|
|
539
|
+
);
|
|
540
|
+
return rows[0];
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
it("deletes from the panel table by its primaryId", async () => {
|
|
544
|
+
const config = panelView();
|
|
545
|
+
const panel = config.views[0].recordViewPanels[0];
|
|
546
|
+
const panelId = generateUniqueIdForObject(panel);
|
|
547
|
+
const note = await insertNote("a");
|
|
548
|
+
await request(buildAppForManager(harness.manager, baseConfig(config)))
|
|
549
|
+
.post("/api/deleteRecordFromPanel")
|
|
550
|
+
.send({ viewIndex: 0, panelId, primaryValue: note.id })
|
|
551
|
+
.expect(200);
|
|
552
|
+
const { rows } = await harness.query("SELECT * FROM fruit_notes");
|
|
553
|
+
expect(rows).toHaveLength(0);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
it("supports panel delete callbacks", async () => {
|
|
557
|
+
const callbackFn = jest.fn();
|
|
558
|
+
const config = panelView({
|
|
559
|
+
deleteCallback: "https://api.example.com/panel-deleted",
|
|
560
|
+
deleteCallbackFunction: callbackFn,
|
|
561
|
+
});
|
|
562
|
+
const panel = config.views[0].recordViewPanels[0];
|
|
563
|
+
const panelId = generateUniqueIdForObject(panel);
|
|
564
|
+
const note = await insertNote("hello");
|
|
565
|
+
mockedAxios.post.mockResolvedValue({});
|
|
566
|
+
await request(buildAppForManager(harness.manager, baseConfig(config)))
|
|
567
|
+
.post("/api/deleteRecordFromPanel")
|
|
568
|
+
.send({ viewIndex: 0, panelId, primaryValue: note.id })
|
|
569
|
+
.expect(200);
|
|
570
|
+
expect(mockedAxios.post).toHaveBeenCalledWith(
|
|
571
|
+
"https://api.example.com/panel-deleted",
|
|
572
|
+
{ id: note.id },
|
|
573
|
+
);
|
|
574
|
+
expect(callbackFn).toHaveBeenCalledWith({
|
|
575
|
+
id: note.id,
|
|
576
|
+
deletedRecord: expect.objectContaining({ text: "hello" }),
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
describe("POST /api/deleteRecords", () => {
|
|
582
|
+
it("bulk-deletes by primary values", async () => {
|
|
583
|
+
const instance = app();
|
|
584
|
+
for (const name of ["a", "b", "c"]) {
|
|
585
|
+
await createFruit({ fruitName: name }, instance);
|
|
586
|
+
}
|
|
587
|
+
await request(instance)
|
|
588
|
+
.post("/api/deleteRecords")
|
|
589
|
+
.send({ viewIndex: 0, primaryValues: ["1", "2", "3"] })
|
|
590
|
+
.expect(200);
|
|
591
|
+
expect(await fruits()).toHaveLength(0);
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
it("never breaks out of the IN() list with quote-bearing primary values", async () => {
|
|
595
|
+
const instance = app();
|
|
596
|
+
await createFruit({ fruitName: "survivor" }, instance);
|
|
597
|
+
const payload = "1'); DROP TABLE fruits;--";
|
|
598
|
+
// Both engines refuse to cast the payload to the integer id (MySQL
|
|
599
|
+
// strict mode raises ER_TRUNCATED_WRONG_VALUE, pg a 22P02 cast
|
|
600
|
+
// error) — the old scripted test claimed a 200, the engine is
|
|
601
|
+
// stricter. Either way the injection never executes.
|
|
602
|
+
const res = await request(instance)
|
|
603
|
+
.post("/api/deleteRecords")
|
|
604
|
+
.send({ viewIndex: 0, primaryValues: [payload] })
|
|
605
|
+
.expect(500);
|
|
606
|
+
// The security property holds: the table survives and the untouched
|
|
607
|
+
// row is still there.
|
|
608
|
+
const rows = await fruits();
|
|
609
|
+
expect(rows).toHaveLength(1);
|
|
610
|
+
expect(rows[0].fruitName).toBe("survivor");
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it("returns 500 when the engine rejects the statement", async () => {
|
|
614
|
+
const config = {
|
|
615
|
+
views: [fruitView({ tableName: "table_does_not_exist", primaryId: "id" })],
|
|
616
|
+
};
|
|
617
|
+
await request(buildAppForManager(harness.manager, baseConfig(config)))
|
|
618
|
+
.post("/api/deleteRecords")
|
|
619
|
+
.send({ viewIndex: 0, primaryValues: ["1"] })
|
|
620
|
+
.expect(500);
|
|
621
|
+
});
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
// INSERT IGNORE ... SET ? is MySQL-specific syntax; the PG conversion
|
|
625
|
+
// pipeline does not translate it, so duplicateRecord only works on the
|
|
626
|
+
// MySQL backends today.
|
|
627
|
+
(_backend === "postgresql" ? describe.skip : describe)(
|
|
628
|
+
"POST /api/duplicateRecord",
|
|
629
|
+
() => {
|
|
630
|
+
const duplicateConfig = () => ({
|
|
631
|
+
primaryId: "id",
|
|
632
|
+
fields: ["fruitName", "acidity"],
|
|
633
|
+
foreignTables: [{ table: "fruit_tags", fields: ["tag"] }],
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
const dupApp = (overrides: any = {}, user: any = EDITOR) =>
|
|
637
|
+
app({
|
|
638
|
+
recordViewPanels: [{ label: "Tags", tableName: "fruit_tags" }],
|
|
639
|
+
...overrides,
|
|
640
|
+
}, user);
|
|
641
|
+
|
|
642
|
+
const seedParent = async () => {
|
|
643
|
+
// Three fruits so the parent's id (3) cannot collide with the
|
|
644
|
+
// auto-generated fruit_tags ids (1, 2).
|
|
645
|
+
await createFruit({ fruitName: "pomme", acidity: 3 });
|
|
646
|
+
await createFruit({ fruitName: "poire" });
|
|
647
|
+
await createFruit({ fruitName: "banane", acidity: 5 });
|
|
648
|
+
await harness.query(
|
|
649
|
+
"INSERT INTO fruit_tags (tag, fruitId) VALUES (?, ?)",
|
|
650
|
+
["bio", 3],
|
|
651
|
+
);
|
|
652
|
+
await harness.query(
|
|
653
|
+
"INSERT INTO fruit_tags (tag, fruitId) VALUES (?, ?)",
|
|
654
|
+
["local", 3],
|
|
655
|
+
);
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
it("copies whitelisted fields", async () => {
|
|
659
|
+
await seedParent();
|
|
660
|
+
await request(dupApp())
|
|
661
|
+
.post("/api/duplicateRecord")
|
|
662
|
+
.send({
|
|
663
|
+
table: "fruits",
|
|
664
|
+
primaryValue: 3,
|
|
665
|
+
duplicateConfig: duplicateConfig(),
|
|
666
|
+
})
|
|
667
|
+
.expect(200);
|
|
668
|
+
const rows = await fruits();
|
|
669
|
+
expect(rows).toHaveLength(4);
|
|
670
|
+
const copy = rows.find((r: any) => r.id === 4);
|
|
671
|
+
expect(copy.fruitName).toBe("banane");
|
|
672
|
+
expect(Number(copy.acidity)).toBe(5);
|
|
673
|
+
// Only the whitelisted fields are copied.
|
|
674
|
+
expect(copy.note).toBeNull();
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
it("CHARACTERIZATION: foreign children are selected by the parent's PK value, so real child rows are never copied", async () => {
|
|
678
|
+
await seedParent();
|
|
679
|
+
await request(dupApp())
|
|
680
|
+
.post("/api/duplicateRecord")
|
|
681
|
+
.send({
|
|
682
|
+
table: "fruits",
|
|
683
|
+
primaryValue: 3,
|
|
684
|
+
duplicateConfig: duplicateConfig(),
|
|
685
|
+
})
|
|
686
|
+
.expect(200);
|
|
687
|
+
// The route looks up children with WHERE id = <parent id> (3) —
|
|
688
|
+
// fruit_tags ids are 1 and 2, so nothing is found and no tag is
|
|
689
|
+
// re-pointed to the copy. The scripted fake previously masked this
|
|
690
|
+
// by queueing matching rows.
|
|
691
|
+
const { rows } = await harness.query(
|
|
692
|
+
"SELECT * FROM fruit_tags WHERE fruitId = 4",
|
|
693
|
+
);
|
|
694
|
+
expect(rows).toHaveLength(0);
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
it("rejects tables the role may not write (403)", async () => {
|
|
698
|
+
await seedParent();
|
|
699
|
+
await request(dupApp({ roles: ["editor"] }, VIEWER))
|
|
700
|
+
.post("/api/duplicateRecord")
|
|
701
|
+
.send({
|
|
702
|
+
table: "fruits",
|
|
703
|
+
primaryValue: 3,
|
|
704
|
+
duplicateConfig: duplicateConfig(),
|
|
705
|
+
})
|
|
706
|
+
.expect(403);
|
|
707
|
+
expect(await fruits()).toHaveLength(3);
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
it("rejects foreign tables outside the writable set (403)", async () => {
|
|
711
|
+
await seedParent();
|
|
712
|
+
const cfg = duplicateConfig();
|
|
713
|
+
cfg.foreignTables.push({ table: "secret_table", fields: ["x"] });
|
|
714
|
+
await request(dupApp())
|
|
715
|
+
.post("/api/duplicateRecord")
|
|
716
|
+
.send({ table: "fruits", primaryValue: 3, duplicateConfig: cfg })
|
|
717
|
+
.expect(403);
|
|
718
|
+
expect(await fruits()).toHaveLength(3);
|
|
719
|
+
const { rows } = await harness.query("SELECT * FROM fruit_tags");
|
|
720
|
+
expect(rows).toHaveLength(2);
|
|
721
|
+
});
|
|
722
|
+
},
|
|
723
|
+
);
|
|
724
|
+
|
|
725
|
+
describe("POST /api/customAction", () => {
|
|
726
|
+
it("runs the action for every selected record", async () => {
|
|
727
|
+
const action = jest.fn().mockResolvedValue(null);
|
|
728
|
+
const config = {
|
|
729
|
+
views: [
|
|
730
|
+
fruitView({
|
|
731
|
+
primaryId: "id",
|
|
732
|
+
customActions: [
|
|
733
|
+
{ label: "Publish", action, successMessage: "done" },
|
|
734
|
+
],
|
|
735
|
+
}),
|
|
736
|
+
],
|
|
737
|
+
};
|
|
738
|
+
const instance = buildAppForManager(
|
|
739
|
+
harness.manager,
|
|
740
|
+
baseConfig(config),
|
|
741
|
+
EDITOR,
|
|
742
|
+
);
|
|
743
|
+
await createFruit({ fruitName: "a" }, instance);
|
|
744
|
+
await createFruit({ fruitName: "b" }, instance);
|
|
745
|
+
const res = await request(instance)
|
|
746
|
+
.post("/api/customAction")
|
|
747
|
+
.send({ viewIndex: 0, actionIndex: 0, selectedRecordIds: [1, 2] })
|
|
748
|
+
.expect(200);
|
|
749
|
+
expect(res.body).toEqual({ success: true, message: "done" });
|
|
750
|
+
expect(action).toHaveBeenCalledTimes(2);
|
|
751
|
+
expect(action).toHaveBeenNthCalledWith(
|
|
752
|
+
1,
|
|
753
|
+
expect.objectContaining({ id: 1, fruitName: "a" }),
|
|
754
|
+
2,
|
|
755
|
+
);
|
|
756
|
+
expect(action).toHaveBeenNthCalledWith(
|
|
757
|
+
2,
|
|
758
|
+
expect.objectContaining({ id: 2, fruitName: "b" }),
|
|
759
|
+
2,
|
|
760
|
+
);
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
it("returns the action result early when truthy", async () => {
|
|
764
|
+
const action = jest.fn().mockResolvedValue("published 3 rows");
|
|
765
|
+
const config = {
|
|
766
|
+
views: [
|
|
767
|
+
fruitView({ primaryId: "id", customActions: [{ label: "P", action }] }),
|
|
768
|
+
],
|
|
769
|
+
};
|
|
770
|
+
const instance = buildAppForManager(
|
|
771
|
+
harness.manager,
|
|
772
|
+
baseConfig(config),
|
|
773
|
+
EDITOR,
|
|
774
|
+
);
|
|
775
|
+
await createFruit({ fruitName: "a" }, instance);
|
|
776
|
+
const res = await request(instance)
|
|
777
|
+
.post("/api/customAction")
|
|
778
|
+
.send({ viewIndex: 0, actionIndex: 0, selectedRecordIds: [1, 2] })
|
|
779
|
+
.expect(200);
|
|
780
|
+
expect(res.body).toEqual({ success: true, message: "published 3 rows" });
|
|
781
|
+
expect(action).toHaveBeenCalledTimes(1);
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
it("returns { success: false } when the action throws", async () => {
|
|
785
|
+
const action = jest.fn().mockRejectedValue(new Error("kaput"));
|
|
786
|
+
const config = {
|
|
787
|
+
views: [
|
|
788
|
+
fruitView({ primaryId: "id", customActions: [{ label: "P", action }] }),
|
|
789
|
+
],
|
|
790
|
+
};
|
|
791
|
+
const instance = buildAppForManager(
|
|
792
|
+
harness.manager,
|
|
793
|
+
baseConfig(config),
|
|
794
|
+
EDITOR,
|
|
795
|
+
);
|
|
796
|
+
await createFruit({ fruitName: "a" }, instance);
|
|
797
|
+
const res = await request(instance)
|
|
798
|
+
.post("/api/customAction")
|
|
799
|
+
.send({ viewIndex: 0, actionIndex: 0, selectedRecordIds: [1] })
|
|
800
|
+
.expect(200);
|
|
801
|
+
expect(res.body.success).toBe(false);
|
|
802
|
+
});
|
|
803
|
+
});
|
|
804
|
+
});
|
|
805
|
+
});
|