@galadrim/galadmin 0.2.12 → 0.3.0-alpha.2.1
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/.agents/report/blockers.json +1 -0
- package/.agents/report/failures/2026-09-23-api-user-filter-contract.md +31 -0
- package/.agents/report/failures/2026-09-23-api-values-contract.md +25 -0
- package/.agents/report/failures/2026-10-01-cycle-02-create-callback-contract.md +17 -0
- package/.agents/report/failures/2026-10-01-cycle-06-delete-callback-id-contract.md +17 -0
- package/.agents/report/failures/2026-10-01-cycle-07-panel-delete-callback-id-contract.md +24 -0
- package/.agents/report/failures/2026-10-01-cycle-13-update-callback-id-contract.md +17 -0
- package/.agents/report/failures/2026-10-01-cycle-15-rich-text-upload-boundary-contract.md +24 -0
- package/.agents/report/failures/2026-10-03-cycle-06-record-import-parser-contract.md +11 -0
- package/.agents/report/failures/2026-10-03-cycle-07-panel-import-parser-contract.md +24 -0
- package/.agents/report/failures/2026-10-07-resume-cycle-02-api-assembly-contract.md +46 -0
- package/.agents/report/port/2026-09-23-api-config-enrichment.md +13 -0
- package/.agents/report/port/2026-09-23-api-file-upload.md +11 -0
- package/.agents/report/port/2026-09-23-api-query-authorization.md +14 -0
- package/.agents/report/port/2026-09-23-api-request-configuration.md +11 -0
- package/.agents/report/port/2026-09-23-database-manager.md +13 -0
- package/.agents/report/port/2026-10-01-api-adjacent-record-id.md +9 -0
- package/.agents/report/port/2026-10-01-cycle-01-api-config-route.md +9 -0
- package/.agents/report/port/2026-10-01-cycle-03-custom-action.md +9 -0
- package/.agents/report/port/2026-10-01-cycle-04-custom-component.md +7 -0
- package/.agents/report/port/2026-10-01-cycle-05-custom-query.md +7 -0
- package/.agents/report/port/2026-10-01-cycle-08-bulk-delete.md +9 -0
- package/.agents/report/port/2026-10-01-cycle-09-duplicate-record.md +9 -0
- package/.agents/report/port/2026-10-01-cycle-10-notification-badges.md +9 -0
- package/.agents/report/port/2026-10-01-cycle-11-record.md +7 -0
- package/.agents/report/port/2026-10-01-cycle-12-search-companies.md +11 -0
- package/.agents/report/port/2026-10-01-cycle-14-upload-file.md +7 -0
- package/.agents/report/port/2026-10-03-cycle-01-create-record.md +11 -0
- package/.agents/report/port/2026-10-03-cycle-02-delete-record.md +11 -0
- package/.agents/report/port/2026-10-03-cycle-03-panel-delete-record.md +11 -0
- package/.agents/report/port/2026-10-03-cycle-04-update-record.md +11 -0
- package/.agents/report/port/2026-10-03-cycle-05-api-values.md +11 -0
- package/.agents/report/port/2026-10-06-cycle-01-record-import.md +9 -0
- package/.agents/report/port/2026-10-06-cycle-03-rich-text-upload.md +7 -0
- package/.agents/report/port/2026-10-06-cycle-04-user-filters.md +11 -0
- package/.agents/report/port/2026-10-06-resume-cycle-01-panel-import.md +9 -0
- package/.agents/report/port/2026-10-07-cycle-01-api-assembly.md +16 -0
- package/.agents/report/port/2026-10-07-cycle-02-index-entry-point.md +13 -0
- package/.agents/report/remediation/2026-10-02-api-user-filter-remediation.md +71 -0
- package/.agents/report/remediation/2026-10-02-api-values-remediation.md +17 -0
- package/.agents/report/remediation/2026-10-03-callback-id-contract-remediation.md +36 -0
- package/.agents/report/remediation/2026-10-06-import-parser-remediation.md +41 -0
- package/.agents/report/remediation/2026-10-06-rich-text-upload-remediation.md +33 -0
- package/.agents/report/remediation/2026-10-07-api-assembly-remediation.md +32 -0
- package/.agents/skills/port-javascript-to-typescript/SKILL.md +42 -0
- package/.agents/skills/port-javascript-to-typescript/agents/openai.yaml +3 -0
- package/.agents/skills/port-javascript-to-typescript/references/blockers.md +31 -0
- package/.agents/skills/port-javascript-to-typescript/references/planning.md +43 -0
- package/.agents/skills/port-javascript-to-typescript/references/porting.md +9 -0
- package/.agents/skills/port-javascript-to-typescript/references/reporting.md +24 -0
- package/.agents/skills/port-javascript-to-typescript/references/rules.md +8 -0
- package/.agents/skills/port-javascript-to-typescript/references/selection.md +16 -0
- package/.agents/skills/port-javascript-to-typescript/references/tools.md +20 -0
- package/.agents/skills/port-javascript-to-typescript/scripts/check.sh +17 -0
- package/.agents/skills/port-javascript-to-typescript/scripts/graph.sh +30 -0
- package/.agents/skills/port-javascript-to-typescript/scripts/madge.json +12 -0
- package/.agents/skills/port-javascript-to-typescript/scripts/oxlint.json +14 -0
- package/.agents/skills/port-javascript-to-typescript/scripts/test-tools.cjs +76 -0
- package/AGENTS.md +5 -0
- package/dist/api/authorization.d.ts +38 -0
- package/dist/api/authorization.js +74 -0
- package/dist/api/authorization.js.map +1 -0
- package/dist/api/config.d.ts +42 -0
- package/dist/api/config.js +105 -0
- package/dist/api/config.js.map +1 -0
- package/dist/api/filterUser.d.ts +2 -0
- package/dist/api/filterUser.js +24 -0
- package/dist/api/filterUser.js.map +1 -0
- package/dist/api/importRecords.d.ts +1 -0
- package/dist/api/importRecords.js +16 -0
- package/dist/api/importRecords.js.map +1 -0
- package/dist/api/queries.d.ts +48 -0
- package/dist/api/queries.js +139 -0
- package/dist/api/queries.js.map +1 -0
- package/dist/api/request.d.ts +29 -0
- package/dist/api/request.js +3 -0
- package/dist/api/request.js.map +1 -0
- package/dist/api/routes/adjacentRecordId.d.ts +10 -0
- package/dist/api/routes/adjacentRecordId.js +13 -0
- package/dist/api/routes/adjacentRecordId.js.map +1 -0
- package/dist/api/routes/config.d.ts +3 -0
- package/dist/api/routes/config.js +27 -0
- package/dist/api/routes/config.js.map +1 -0
- package/dist/api/routes/createRecord.d.ts +19 -0
- package/dist/api/routes/createRecord.js +93 -0
- package/dist/api/routes/createRecord.js.map +1 -0
- package/dist/api/routes/customAction.d.ts +17 -0
- package/dist/api/routes/customAction.js +35 -0
- package/dist/api/routes/customAction.js.map +1 -0
- package/dist/api/routes/customComponent.d.ts +3 -0
- package/dist/api/routes/customComponent.js +34 -0
- package/dist/api/routes/customComponent.js.map +1 -0
- package/dist/api/routes/customQuery.d.ts +7 -0
- package/dist/api/routes/customQuery.js +33 -0
- package/dist/api/routes/customQuery.js.map +1 -0
- package/dist/api/routes/deleteRecord.d.ts +9 -0
- package/dist/api/routes/deleteRecord.js +46 -0
- package/dist/api/routes/deleteRecord.js.map +1 -0
- package/dist/api/routes/deleteRecordFromPanel.d.ts +10 -0
- package/dist/api/routes/deleteRecordFromPanel.js +36 -0
- package/dist/api/routes/deleteRecordFromPanel.js.map +1 -0
- package/dist/api/routes/deleteRecords.d.ts +9 -0
- package/dist/api/routes/deleteRecords.js +12 -0
- package/dist/api/routes/deleteRecords.js.map +1 -0
- package/dist/api/routes/duplicateRecord.d.ts +17 -0
- package/dist/api/routes/duplicateRecord.js +55 -0
- package/dist/api/routes/duplicateRecord.js.map +1 -0
- package/dist/api/routes/foreignSelectRecords.d.ts +10 -0
- package/dist/api/routes/foreignSelectRecords.js +61 -0
- package/dist/api/routes/foreignSelectRecords.js.map +1 -0
- package/dist/api/routes/notificationBadges.d.ts +6 -0
- package/dist/api/routes/notificationBadges.js +31 -0
- package/dist/api/routes/notificationBadges.js.map +1 -0
- package/dist/api/routes/record.d.ts +7 -0
- package/dist/api/routes/record.js +32 -0
- package/dist/api/routes/record.js.map +1 -0
- package/dist/api/routes/records.d.ts +14 -0
- package/dist/api/routes/records.js +57 -0
- package/dist/api/routes/records.js.map +1 -0
- package/dist/api/routes/recordsExport.d.ts +13 -0
- package/dist/api/routes/recordsExport.js +67 -0
- package/dist/api/routes/recordsExport.js.map +1 -0
- package/dist/api/routes/recordsImport.d.ts +4 -0
- package/dist/api/routes/recordsImport.js +100 -0
- package/dist/api/routes/recordsImport.js.map +1 -0
- package/dist/api/routes/searchCompanies.d.ts +17 -0
- package/dist/api/routes/searchCompanies.js +72 -0
- package/dist/api/routes/searchCompanies.js.map +1 -0
- package/dist/api/routes/subrecordsImport.d.ts +4 -0
- package/dist/api/routes/subrecordsImport.js +94 -0
- package/dist/api/routes/subrecordsImport.js.map +1 -0
- package/dist/api/routes/updateRecord.d.ts +13 -0
- package/dist/api/routes/updateRecord.js +55 -0
- package/dist/api/routes/updateRecord.js.map +1 -0
- package/dist/api/routes/uploadFile.d.ts +3 -0
- package/dist/api/routes/uploadFile.js +13 -0
- package/dist/api/routes/uploadFile.js.map +1 -0
- package/dist/api/routes/uploadFileFromBetterRichTextInput.d.ts +3 -0
- package/dist/api/routes/uploadFileFromBetterRichTextInput.js +33 -0
- package/dist/api/routes/uploadFileFromBetterRichTextInput.js.map +1 -0
- package/dist/api/uploadFile.d.ts +4 -0
- package/dist/api/uploadFile.js +209 -0
- package/dist/api/uploadFile.js.map +1 -0
- package/dist/api/values.d.ts +6 -0
- package/dist/api/values.js +88 -0
- package/dist/api/values.js.map +1 -0
- package/dist/api.d.ts +8 -27
- package/dist/api.js +80 -1387
- package/dist/api.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +8 -2
- package/dist/index.js.map +1 -1
- package/dist/tsconfig.strict.tsbuildinfo +1 -0
- package/dist/types/config.d.ts +15 -5
- package/dist/types/config.js +4 -0
- package/dist/types/config.js.map +1 -1
- package/dist/types/database.d.ts +1 -0
- package/dist/types/database.js +3 -0
- package/dist/types/database.js.map +1 -0
- package/dist/utils/dbManager.d.ts +32 -7
- package/dist/utils/dbManager.js +59 -7
- package/dist/utils/dbManager.js.map +1 -1
- package/package.json +12 -3
- package/src/api/authorization.ts +129 -0
- package/src/api/config.ts +152 -0
- package/src/api/filterUser.ts +28 -0
- package/src/api/importRecords.ts +16 -0
- package/src/api/queries.ts +194 -0
- package/src/api/request.ts +31 -0
- package/src/api/routes/adjacentRecordId.ts +20 -0
- package/src/api/routes/config.ts +15 -0
- package/src/api/routes/createRecord.ts +131 -0
- package/src/api/routes/customAction.ts +60 -0
- package/src/api/routes/customComponent.ts +44 -0
- package/src/api/routes/customQuery.ts +53 -0
- package/src/api/routes/deleteRecord.ts +59 -0
- package/src/api/routes/deleteRecordFromPanel.ts +58 -0
- package/src/api/routes/deleteRecords.ts +28 -0
- package/src/api/routes/duplicateRecord.ts +73 -0
- package/src/api/routes/foreignSelectRecords.ts +80 -0
- package/src/api/routes/notificationBadges.ts +47 -0
- package/src/api/routes/record.ts +48 -0
- package/src/api/routes/records.ts +91 -0
- package/src/api/routes/recordsExport.ts +92 -0
- package/src/api/routes/recordsImport.ts +115 -0
- package/src/api/routes/searchCompanies.ts +96 -0
- package/src/api/routes/subrecordsImport.ts +114 -0
- package/src/api/routes/updateRecord.ts +74 -0
- package/src/api/routes/uploadFile.ts +16 -0
- package/src/api/routes/uploadFileFromBetterRichTextInput.ts +41 -0
- package/src/api/uploadFile.ts +249 -0
- package/src/api/values.ts +96 -0
- package/src/api.ts +94 -1580
- package/src/index.ts +42 -25
- package/src/types/config.ts +15 -5
- package/src/types/database.ts +2 -0
- package/src/utils/dbManager.ts +99 -14
- package/tests/api-helpers.test.ts +59 -0
- package/tests/dbManager.test.ts +16 -1
- package/tests/helpers/fakeDb.ts +9 -7
- package/tests/integration/authorizationBoundary.integration.test.ts +57 -0
- package/tests/integration/configEnrichment.integration.test.ts +79 -0
- package/tests/integration/customComponentBoundary.integration.test.ts +58 -0
- package/tests/integration/dbManager.integration.test.ts +105 -3
- package/tests/integration/importParserChild.cjs +55 -0
- package/tests/integration/indexRuntime.integration.test.ts +22 -0
- package/tests/integration/indexRuntimeChild.cjs +110 -0
- package/tests/integration/queries.integration.test.ts +88 -0
- package/tests/integration/requestConfig.integration.test.ts +118 -0
- package/tests/integration/routes.authorization.integration.test.ts +1 -0
- package/tests/integration/routes.config.integration.test.ts +39 -1
- package/tests/integration/routes.crud.integration.test.ts +233 -1
- package/tests/integration/routes.custom.integration.test.ts +119 -0
- package/tests/integration/routes.foreign.integration.test.ts +3 -2
- package/tests/integration/routes.importExport.integration.test.ts +308 -6
- package/tests/integration/routes.integration.test.ts +4 -0
- package/tests/integration/routes.records.integration.test.ts +82 -6
- package/tests/integration/routes.upload.integration.test.ts +123 -0
- package/tests/integration/userFilters.integration.test.ts +225 -0
- package/tsconfig.json +1 -1
- package/tsconfig.strict.json +45 -0
- package/dist/front/ckeditor5-build/src/ckeditor.d.ts +0 -43
- package/dist/front/ckeditor5-build/src/ckeditor.js +0 -130
- package/dist/front/ckeditor5-build/src/ckeditor.js.map +0 -1
- package/dist/tsconfig.tsbuildinfo +0 -1
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import * as request from "supertest";
|
|
2
|
+
import { fork } from "child_process";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import type { FormDataFile } from "../../src/types/config";
|
|
2
5
|
import { buildAppForManager } from "../helpers/app";
|
|
3
6
|
import { baseConfig, fruitView, EDITOR, VIEWER } from "../helpers/fixtures";
|
|
4
7
|
import {
|
|
@@ -149,7 +152,7 @@ describeIntegration("import/export routes against real databases", () => {
|
|
|
149
152
|
expect(dataLines(res.body.content)[0]).toMatch(/^keep;/);
|
|
150
153
|
});
|
|
151
154
|
|
|
152
|
-
it("scopes the export to the caller's rows via filtersFromUser", async () => {
|
|
155
|
+
it("FILTER USER POLICY: scopes the export to the caller's rows via filtersFromUser", async () => {
|
|
153
156
|
// The export must be scoped exactly like the list view, otherwise a
|
|
154
157
|
// user could download rows their filtersFromUser clause hides.
|
|
155
158
|
await seedFruits([
|
|
@@ -157,7 +160,8 @@ describeIntegration("import/export routes against real databases", () => {
|
|
|
157
160
|
{ fruitName: "theirs", ownerId: 1 },
|
|
158
161
|
]);
|
|
159
162
|
const config = exportConfig({
|
|
160
|
-
filtersFromUser: (
|
|
163
|
+
filtersFromUser: (user: { role: string; userId?: string | number }) =>
|
|
164
|
+
user.role === "root" ? "" : typeof user.userId === "number" ? `ownerId = ${user.userId}` : "1 = 0",
|
|
161
165
|
});
|
|
162
166
|
const res = await postExport(
|
|
163
167
|
buildAppForManager(harness.manager, config, {
|
|
@@ -272,10 +276,165 @@ describeIntegration("import/export routes against real databases", () => {
|
|
|
272
276
|
expect(rows).toHaveLength(0);
|
|
273
277
|
});
|
|
274
278
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
+
it("preserves custom rows, inherited columns, field identities and getter timing", async () => {
|
|
280
|
+
const events: string[] = [];
|
|
281
|
+
const note = {
|
|
282
|
+
toJSON() {
|
|
283
|
+
expect(this).toBe(note);
|
|
284
|
+
events.push("serialize note");
|
|
285
|
+
return { ripe: true };
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
class Fruit {
|
|
289
|
+
id = 1;
|
|
290
|
+
acidity = 0;
|
|
291
|
+
note = note;
|
|
292
|
+
age = false;
|
|
293
|
+
}
|
|
294
|
+
const first = new Fruit();
|
|
295
|
+
Object.defineProperty(Fruit.prototype, "slug", {
|
|
296
|
+
enumerable: true,
|
|
297
|
+
get() {
|
|
298
|
+
expect(this).toBe(first);
|
|
299
|
+
events.push("read inherited slug");
|
|
300
|
+
return "inherited";
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
Object.defineProperty(first, "fruitName", {
|
|
304
|
+
enumerable: true,
|
|
305
|
+
get() {
|
|
306
|
+
expect(this).toBe(first);
|
|
307
|
+
events.push("read fruit name");
|
|
308
|
+
return "pomme d'hiver";
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
const records = [first, { id: 2, fruitName: "poire", acidity: 4 }];
|
|
312
|
+
let iterations = 0;
|
|
313
|
+
Object.defineProperty(records, Symbol.iterator, {
|
|
314
|
+
value: function () {
|
|
315
|
+
expect(this).toBe(records);
|
|
316
|
+
iterations++;
|
|
317
|
+
return Array.prototype.values.call(this);
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
let parserCalls = 0;
|
|
321
|
+
const parseImportFile = async function (file: FormDataFile) {
|
|
322
|
+
parserCalls++;
|
|
323
|
+
expect(arguments).toHaveLength(1);
|
|
324
|
+
expect(file.originalFilename).toBe("import.csv");
|
|
325
|
+
expect(this.parseImportFile).toBe(parseImportFile);
|
|
326
|
+
expect(this.tableName).toBe("fruits");
|
|
327
|
+
expect(events).toEqual([]);
|
|
328
|
+
return records;
|
|
329
|
+
};
|
|
330
|
+
await postImport(buildAppForManager(harness.manager, importConfig({ parseImportFile }))).expect(200);
|
|
331
|
+
expect(parserCalls).toBe(1);
|
|
332
|
+
expect(iterations).toBe(2);
|
|
333
|
+
expect(records[0]).toBe(first);
|
|
334
|
+
expect(first.note).toBe(note);
|
|
335
|
+
expect(events).toEqual(["serialize note", "read fruit name", "read inherited slug"]);
|
|
336
|
+
const { rows } = await harness.query("SELECT id, fruitName, acidity, note, age, slug FROM fruits ORDER BY id");
|
|
337
|
+
expect(rows).toEqual([
|
|
338
|
+
{ id: 1, fruitName: "pomme d'hiver", acidity: 0, note: '{"ripe":true}', age: null, slug: "inherited" },
|
|
339
|
+
{ id: 2, fruitName: "poire", acidity: 4, note: null, age: null, slug: null },
|
|
340
|
+
]);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("rejects hidden inherited columns before reading their getters", async () => {
|
|
344
|
+
let getterCalls = 0;
|
|
345
|
+
const record = Object.create({
|
|
346
|
+
get note() {
|
|
347
|
+
getterCalls++;
|
|
348
|
+
throw new Error("hidden field was read");
|
|
349
|
+
},
|
|
350
|
+
});
|
|
351
|
+
record.id = 1;
|
|
352
|
+
const config = importConfig({
|
|
353
|
+
columns: [{ label: "Secret", name: "note", roles: ["editor"] }],
|
|
354
|
+
parseImportFile: async () => [record],
|
|
355
|
+
});
|
|
356
|
+
await postImport(buildAppForManager(harness.manager, config)).expect(403);
|
|
357
|
+
expect(getterCalls).toBe(0);
|
|
358
|
+
expect((await harness.query("SELECT * FROM fruits")).rows).toHaveLength(0);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("keeps Date field identity and the existing unquoted ISO insertion error", async () => {
|
|
362
|
+
const date = new Date("2026-10-06T12:00:00.000Z");
|
|
363
|
+
let calls = 0;
|
|
364
|
+
date.toISOString = function () {
|
|
365
|
+
expect(this).toBe(date);
|
|
366
|
+
calls++;
|
|
367
|
+
return Date.prototype.toISOString.call(this);
|
|
368
|
+
};
|
|
369
|
+
const config = importConfig({ parseImportFile: async () => [{ id: 1, note: date }] });
|
|
370
|
+
const res = await postImport(buildAppForManager(harness.manager, config)).expect(500);
|
|
371
|
+
expect(res.body.message).toBe("Une erreur est survenue.");
|
|
372
|
+
expect(calls).toBe(1);
|
|
373
|
+
expect((await harness.query("SELECT * FROM fruits")).rows).toHaveLength(0);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
const isolatedParser = (parserCase: string): Promise<unknown> => new Promise((resolve, reject) => {
|
|
377
|
+
const child = fork(path.join(__dirname, "importParserChild.cjs"), [], {
|
|
378
|
+
env: { ...process.env, IMPORT_BACKEND: _backend, IMPORT_CASE: parserCase },
|
|
379
|
+
stdio: ["ignore", "ignore", "pipe", "ipc"],
|
|
380
|
+
});
|
|
381
|
+
let result: unknown;
|
|
382
|
+
let errors = "";
|
|
383
|
+
child.stderr?.on("data", (chunk) => { errors += chunk.toString(); });
|
|
384
|
+
child.on("message", (message) => { result = message; });
|
|
385
|
+
child.on("error", reject);
|
|
386
|
+
child.on("exit", (code) => code === 0 ? resolve(result) : reject(new Error(errors || `Child exited ${code}`)));
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it.each([ ["throw", "parser sync failure"], ["reject", "parser async failure"] ])(
|
|
390
|
+
"preserves an unhandled parser %s outside the insertion catch", async (parserCase, message) => {
|
|
391
|
+
expect(await isolatedParser(parserCase)).toEqual({ kind: "unhandled", message });
|
|
392
|
+
expect((await harness.query("SELECT * FROM fruits")).rows).toHaveLength(0);
|
|
393
|
+
},
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
// Approved remediation: these assertions fail on the original route.
|
|
397
|
+
it.each(["null", "undefined"])("SANITIZATION: rejects top-level %s before insertion", async (parserCase) => {
|
|
398
|
+
expect(await isolatedParser(parserCase)).toEqual({ kind: "response", status: 400, body: {} });
|
|
399
|
+
expect((await harness.query("SELECT * FROM fruits")).rows).toHaveLength(0);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("SANITIZATION: validates all row shapes before reading any field", async () => {
|
|
403
|
+
let reads = 0;
|
|
404
|
+
const first = {
|
|
405
|
+
id: 1,
|
|
406
|
+
get fruitName() {
|
|
407
|
+
reads++;
|
|
408
|
+
return "must not insert";
|
|
409
|
+
},
|
|
410
|
+
};
|
|
411
|
+
const config = importConfig({ parseImportFile: async () => [first, null] });
|
|
412
|
+
const res = await postImport(buildAppForManager(harness.manager, config));
|
|
413
|
+
const { rows } = await harness.query("SELECT * FROM fruits");
|
|
414
|
+
expect({ status: res.status, reads, rows }).toEqual({ status: 400, reads: 0, rows: [] });
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it.each([
|
|
418
|
+
["object with empty length", () => ({ length: 0 })],
|
|
419
|
+
["string result", () => "ab"],
|
|
420
|
+
["number result", () => 17],
|
|
421
|
+
["boolean result", () => true],
|
|
422
|
+
["function result", () => function () {}],
|
|
423
|
+
["string row", () => [{ id: 1, fruitName: "must not insert" }, "ab"]],
|
|
424
|
+
["number row", () => [{ id: 1, fruitName: "must not insert" }, 17]],
|
|
425
|
+
["boolean row", () => [{ id: 1, fruitName: "must not insert" }, true]],
|
|
426
|
+
["null row", () => [{ id: 1, fruitName: "must not insert" }, null]],
|
|
427
|
+
["undefined row", () => [{ id: 1, fruitName: "must not insert" }, undefined]],
|
|
428
|
+
["array row", () => [{ id: 1, fruitName: "must not insert" }, []]],
|
|
429
|
+
["function row", () => [{ id: 1, fruitName: "must not insert" }, function () {}]],
|
|
430
|
+
["sparse hole", () => { const rows = [{ id: 1, fruitName: "must not insert" }]; rows.length = 2; return rows; }],
|
|
431
|
+
])("SANITIZATION: rejects %s without partial insertion", async (_label, result) => {
|
|
432
|
+
const config = importConfig({ parseImportFile: async () => result() });
|
|
433
|
+
const res = await postImport(buildAppForManager(harness.manager, config));
|
|
434
|
+
// Inspect the real table before asserting the new HTTP policy.
|
|
435
|
+
const { rows } = await harness.query("SELECT * FROM fruits");
|
|
436
|
+
expect({ status: res.status, rows }).toEqual({ status: 400, rows: [] });
|
|
437
|
+
});
|
|
279
438
|
|
|
280
439
|
it("returns 500 with a message when the insert fails", async () => {
|
|
281
440
|
const config = importConfig({
|
|
@@ -318,6 +477,149 @@ describeIntegration("import/export routes against real databases", () => {
|
|
|
318
477
|
.field("recordId", "7")
|
|
319
478
|
.attach("data", Buffer.from("fake,csv"), "notes.csv");
|
|
320
479
|
|
|
480
|
+
it.each([
|
|
481
|
+
{ label: "absent roles", roles: undefined, user: EDITOR, status: 200 },
|
|
482
|
+
{ label: "empty roles", roles: [], user: EDITOR, status: 403 },
|
|
483
|
+
{ label: "allowed role", roles: ["editor"], user: EDITOR, status: 200 },
|
|
484
|
+
{ label: "denied role", roles: ["editor"], user: VIEWER, status: 403 },
|
|
485
|
+
{ label: "root with empty roles", roles: [], user: { role: "root" }, status: 200 },
|
|
486
|
+
{ label: "root with restricted roles", roles: ["editor"], user: { role: "root" }, status: 200 },
|
|
487
|
+
])("preserves panel authorization for $label before parsing", async ({ roles, user, status }) => {
|
|
488
|
+
let calls = 0;
|
|
489
|
+
const config = panelConfig({ roles, parseImportFile: async () => { calls++; return []; } });
|
|
490
|
+
const response = await postSubImport(buildAppForManager(harness.manager, config, user)).expect(status);
|
|
491
|
+
expect(calls).toBe(status === 200 ? 1 : 0);
|
|
492
|
+
if (status === 200) expect(response.body).toEqual({ affectedRows: 0 });
|
|
493
|
+
expect((await harness.query("SELECT * FROM fruit_notes")).rows).toEqual([]);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it("checks view authorization before panel configuration or parsing", async () => {
|
|
497
|
+
let calls = 0;
|
|
498
|
+
const config = panelConfig({ roles: ["viewer"], createConfig: undefined,
|
|
499
|
+
parseImportFile: async () => { calls++; return []; } });
|
|
500
|
+
config.views[0].roles = ["editor"];
|
|
501
|
+
config.views[0].readRoles = ["viewer"];
|
|
502
|
+
await postSubImport(buildAppForManager(harness.manager, config, VIEWER)).expect(403);
|
|
503
|
+
expect(calls).toBe(0);
|
|
504
|
+
expect((await harness.query("SELECT * FROM fruit_notes")).rows).toEqual([]);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it("preserves inherited fields, getters, identities, mixed values and parser receiver/parent arguments", async () => {
|
|
508
|
+
const events: string[] = [];
|
|
509
|
+
const note = { toJSON() { expect(this).toBe(note); events.push("serialize"); return { ripe: true }; } };
|
|
510
|
+
class Fruit { id = 1; acidity = 0; age = false; note = note; }
|
|
511
|
+
const first = new Fruit();
|
|
512
|
+
Object.defineProperty(Fruit.prototype, "slug", { enumerable: true, get() {
|
|
513
|
+
expect(this).toBe(first); events.push("inherited"); return "inherited";
|
|
514
|
+
} });
|
|
515
|
+
Object.defineProperty(first, "fruitName", { enumerable: true, get() {
|
|
516
|
+
expect(this).toBe(first); events.push("name"); return "pomme d'hiver";
|
|
517
|
+
} });
|
|
518
|
+
const records = [first, { id: 2, fruitName: "poire", acidity: 4 }];
|
|
519
|
+
let iterations = 0;
|
|
520
|
+
Object.defineProperty(records, Symbol.iterator, { value: function () {
|
|
521
|
+
expect(this).toBe(records); iterations++; return Array.prototype.values.call(this);
|
|
522
|
+
} });
|
|
523
|
+
let calls = 0;
|
|
524
|
+
const parseImportFile = async function (file: FormDataFile, id: string) {
|
|
525
|
+
calls++;
|
|
526
|
+
expect(arguments).toHaveLength(2);
|
|
527
|
+
expect(file.originalFilename).toBe("notes.csv");
|
|
528
|
+
expect(id).toBe("07");
|
|
529
|
+
expect(this.parseImportFile).toBe(parseImportFile);
|
|
530
|
+
expect(this.tableName).toBe("fruits");
|
|
531
|
+
expect(events).toEqual([]);
|
|
532
|
+
return records;
|
|
533
|
+
};
|
|
534
|
+
const config = panelConfig({ tableName: "fruits", parseImportFile,
|
|
535
|
+
createConfig: { label: "Fruit", recordIdKey: "ownerId", columns: [] } });
|
|
536
|
+
await request(buildAppForManager(harness.manager, config)).post("/api/subrecordsImport")
|
|
537
|
+
.field("viewIndex", "0").field("panelIndex", "0").field("recordId", "07")
|
|
538
|
+
.attach("data", Buffer.from("csv"), "notes.csv")
|
|
539
|
+
.expect(harness.dbType === "POSTGRESQL" ? 500 : 200);
|
|
540
|
+
expect(calls).toBe(1);
|
|
541
|
+
expect(iterations).toBe(2);
|
|
542
|
+
expect(records[0]).toBe(first);
|
|
543
|
+
expect(first.note).toBe(note);
|
|
544
|
+
expect(events).toEqual(["serialize", "name", "inherited"]);
|
|
545
|
+
const { rows } = await harness.query("SELECT id, fruitName, acidity, note, age, slug, ownerId FROM fruits ORDER BY id");
|
|
546
|
+
expect(rows).toEqual(harness.dbType === "POSTGRESQL" ? [] : [
|
|
547
|
+
{ id: 1, fruitName: "pomme d'hiver", acidity: 0, note: '{"ripe":true}', age: null, slug: "inherited", ownerId: 7 },
|
|
548
|
+
{ id: 2, fruitName: "poire", acidity: 4, note: null, age: null, slug: null, ownerId: 7 },
|
|
549
|
+
]);
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it.each(["date", "getter", "database"])("preserves the %s insertion error response", async (failure) => {
|
|
553
|
+
const date = new Date("2026-10-06T12:00:00.000Z");
|
|
554
|
+
let calls = 0;
|
|
555
|
+
date.toISOString = function () { expect(this).toBe(date); calls++; return Date.prototype.toISOString.call(this); };
|
|
556
|
+
const row = failure === "date" ? { text: date } : failure === "database" ? { missingColumn: "bad" } : {
|
|
557
|
+
get text() { calls++; throw new Error("field failure"); },
|
|
558
|
+
};
|
|
559
|
+
const config = panelConfig({ parseImportFile: async () => [row] });
|
|
560
|
+
const response = await postSubImport(buildAppForManager(harness.manager, config)).expect(500);
|
|
561
|
+
expect(response.body.message).toBe("Une erreur est survenue.");
|
|
562
|
+
expect(response.body).toHaveProperty("err");
|
|
563
|
+
expect(calls).toBe(failure === "database" ? 0 : 1);
|
|
564
|
+
expect((await harness.query("SELECT * FROM fruit_notes")).rows).toEqual([]);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
const isolatedPanelParser = (parserCase: string): Promise<unknown> => new Promise((resolve, reject) => {
|
|
568
|
+
const child = fork(path.join(__dirname, "importParserChild.cjs"), [], {
|
|
569
|
+
env: { ...process.env, IMPORT_BACKEND: _backend, IMPORT_CASE: parserCase, IMPORT_ROUTE: "panel" },
|
|
570
|
+
stdio: ["ignore", "ignore", "pipe", "ipc"],
|
|
571
|
+
});
|
|
572
|
+
let result: unknown;
|
|
573
|
+
let errors = "";
|
|
574
|
+
child.stderr?.on("data", (chunk) => { errors += chunk.toString(); });
|
|
575
|
+
child.on("message", (message) => { result = message; });
|
|
576
|
+
child.on("error", reject);
|
|
577
|
+
child.on("exit", (code) => code === 0 ? resolve(result) : reject(new Error(errors || `Child exited ${code}`)));
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it.each([["throw", "parser sync failure"], ["reject", "parser async failure"]])(
|
|
581
|
+
"preserves an unhandled panel parser %s outside the insertion catch", async (parserCase, message) => {
|
|
582
|
+
expect(await isolatedPanelParser(parserCase)).toEqual({ kind: "unhandled", message });
|
|
583
|
+
expect((await harness.query("SELECT * FROM fruit_notes")).rows).toEqual([]);
|
|
584
|
+
},
|
|
585
|
+
);
|
|
586
|
+
|
|
587
|
+
// Approved remediation: only these SANITIZATION expectations may fail on original source.
|
|
588
|
+
it.each(["null", "undefined"])("SANITIZATION: rejects panel top-level %s before insertion", async (parserCase) => {
|
|
589
|
+
expect(await isolatedPanelParser(parserCase)).toEqual({ kind: "response", status: 400, body: {} });
|
|
590
|
+
expect((await harness.query("SELECT * FROM fruit_notes")).rows).toEqual([]);
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it("SANITIZATION: validates all panel row shapes before reading fields", async () => {
|
|
594
|
+
let reads = 0;
|
|
595
|
+
const first = { get text() { reads++; return "must not insert"; } };
|
|
596
|
+
const config = panelConfig({ parseImportFile: async () => [first, null] });
|
|
597
|
+
const res = await postSubImport(buildAppForManager(harness.manager, config));
|
|
598
|
+
const { rows } = await harness.query("SELECT * FROM fruit_notes");
|
|
599
|
+
expect({ status: res.status, reads, rows }).toEqual({ status: 400, reads: 0, rows: [] });
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it.each([
|
|
603
|
+
["object with empty length", () => ({ length: 0 })],
|
|
604
|
+
["string result", () => "ab"],
|
|
605
|
+
["number result", () => 17],
|
|
606
|
+
["boolean result", () => true],
|
|
607
|
+
["function result", () => function () {}],
|
|
608
|
+
["string row", () => [{ text: "must not insert" }, "ab"]],
|
|
609
|
+
["number row", () => [{ text: "must not insert" }, 17]],
|
|
610
|
+
["boolean row", () => [{ text: "must not insert" }, true]],
|
|
611
|
+
["null row", () => [{ text: "must not insert" }, null]],
|
|
612
|
+
["undefined row", () => [{ text: "must not insert" }, undefined]],
|
|
613
|
+
["array row", () => [{ text: "must not insert" }, []]],
|
|
614
|
+
["function row", () => [{ text: "must not insert" }, function () {}]],
|
|
615
|
+
["sparse hole", () => { const rows = [{ text: "must not insert" }]; rows.length = 2; return rows; }],
|
|
616
|
+
])("SANITIZATION: rejects panel %s without partial insertion", async (_label, result) => {
|
|
617
|
+
const config = panelConfig({ parseImportFile: async () => result() });
|
|
618
|
+
const res = await postSubImport(buildAppForManager(harness.manager, config));
|
|
619
|
+
const { rows } = await harness.query("SELECT * FROM fruit_notes");
|
|
620
|
+
expect({ status: res.status, rows }).toEqual({ status: 400, rows: [] });
|
|
621
|
+
});
|
|
622
|
+
|
|
321
623
|
it("bulk upserts panel records bound to the parent record", async () => {
|
|
322
624
|
const config = panelConfig();
|
|
323
625
|
const res = await postSubImport(
|
|
@@ -332,9 +332,11 @@ describeIntegration("config enrichment against real information_schema", () => {
|
|
|
332
332
|
"SELECT * FROM information_schema.columns WHERE table_schema = ?",
|
|
333
333
|
["galadmin_test"],
|
|
334
334
|
);
|
|
335
|
+
if (!Array.isArray(rows)) throw new Error("Expected a row set");
|
|
335
336
|
const pri = rows.find(
|
|
336
337
|
(r: any) => r.TABLE_NAME === "fruits" && r.COLUMN_KEY === "PRI",
|
|
337
338
|
);
|
|
339
|
+
if (!("COLUMN_NAME" in pri)) throw new Error("Expected a schema row");
|
|
338
340
|
expect(pri.COLUMN_NAME).toBe("id");
|
|
339
341
|
});
|
|
340
342
|
|
|
@@ -357,8 +359,10 @@ describeIntegration("config enrichment against real information_schema", () => {
|
|
|
357
359
|
["public"],
|
|
358
360
|
);
|
|
359
361
|
if (err) throw err;
|
|
362
|
+
if (!Array.isArray(rows)) throw new Error("Expected a row set");
|
|
360
363
|
expect(rows.length).toBeGreaterThan(0);
|
|
361
364
|
const first = rows[0];
|
|
365
|
+
if (!("table_name" in first)) throw new Error("Expected a schema row");
|
|
362
366
|
expect(first.TABLE_NAME).toBeUndefined();
|
|
363
367
|
expect(first.table_name).toBeDefined();
|
|
364
368
|
expect(first.column_key).toBeUndefined();
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as request from "supertest";
|
|
2
|
+
import type { DatabaseRow } from "../../src/utils/dbManager";
|
|
2
3
|
import { generateUniqueIdForObject } from "../../src/api";
|
|
3
4
|
import { buildAppForManager } from "../helpers/app";
|
|
4
5
|
import { baseConfig, fruitView, EDITOR, VIEWER } from "../helpers/fixtures";
|
|
@@ -100,7 +101,8 @@ describeIntegration("records routes against real databases", () => {
|
|
|
100
101
|
label: "Clients only",
|
|
101
102
|
tableName: "orders",
|
|
102
103
|
roles: ["client"],
|
|
103
|
-
filtersFromUser: (
|
|
104
|
+
filtersFromUser: (user: { role: string; userId?: string | number }) =>
|
|
105
|
+
user.role === "root" ? "" : typeof user.userId === "number" ? `client_id = ${user.userId}` : "1 = 0",
|
|
104
106
|
columns: [{ label: "Id", name: "id" }],
|
|
105
107
|
},
|
|
106
108
|
fruitView({ primaryId: "id" }),
|
|
@@ -203,13 +205,14 @@ describeIntegration("records routes against real databases", () => {
|
|
|
203
205
|
expect(res.body.records[0].fruitName).toBe("l'orange");
|
|
204
206
|
});
|
|
205
207
|
|
|
206
|
-
it("merges view filters, client filters and filtersFromUser", async () => {
|
|
208
|
+
it("FILTER USER POLICY: merges view filters, client filters and filtersFromUser", async () => {
|
|
207
209
|
const config = {
|
|
208
210
|
views: [
|
|
209
211
|
fruitView({
|
|
210
212
|
primaryId: "id",
|
|
211
213
|
filters: "acidity < 10",
|
|
212
|
-
filtersFromUser: (
|
|
214
|
+
filtersFromUser: (user: { role: string; userId?: string | number }) =>
|
|
215
|
+
user.role === "root" ? "" : typeof user.userId === "number" ? `ownerId = ${user.userId}` : "1 = 0",
|
|
213
216
|
}),
|
|
214
217
|
],
|
|
215
218
|
};
|
|
@@ -438,13 +441,14 @@ describeIntegration("records routes against real databases", () => {
|
|
|
438
441
|
// reachable on a healthy engine — when the wrapped query runs, its
|
|
439
442
|
// COUNT(*) projection runs too — so it has no real-engine equivalent.
|
|
440
443
|
|
|
441
|
-
it("serves rows scoped per-user via filtersFromUser(
|
|
444
|
+
it("FILTER USER POLICY: serves rows scoped per-user via filtersFromUser(user)", async () => {
|
|
442
445
|
const config = {
|
|
443
446
|
views: [
|
|
444
447
|
fruitView({
|
|
445
448
|
primaryId: "id",
|
|
446
|
-
filtersFromUser: (
|
|
447
|
-
|
|
449
|
+
filtersFromUser: (user: { role: string; userId?: string | number }) =>
|
|
450
|
+
user.role === "root" ? "" : typeof user.userId === "number"
|
|
451
|
+
? `ownerId = ${user.userId} AND role = '${user.role}'` : "1 = 0",
|
|
448
452
|
}),
|
|
449
453
|
],
|
|
450
454
|
};
|
|
@@ -503,9 +507,81 @@ describeIntegration("records routes against real databases", () => {
|
|
|
503
507
|
.expect(200);
|
|
504
508
|
expect(res.body).toEqual({ recordId: null });
|
|
505
509
|
});
|
|
510
|
+
|
|
511
|
+
it("omits recordId when the selected row lacks the requested column", async () => {
|
|
512
|
+
const res = await request(app())
|
|
513
|
+
.post("/api/adjacentRecordId")
|
|
514
|
+
.send({
|
|
515
|
+
position: "1", offset: "0", primaryId: "missing", table: "fruits",
|
|
516
|
+
customQuery: "SELECT 1 AS id",
|
|
517
|
+
})
|
|
518
|
+
.expect(200);
|
|
519
|
+
expect(res.body).toEqual({});
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("preserves a null value in the requested column", async () => {
|
|
523
|
+
const res = await request(app())
|
|
524
|
+
.post("/api/adjacentRecordId")
|
|
525
|
+
.send({
|
|
526
|
+
position: "1", offset: "0", primaryId: "id", table: "fruits",
|
|
527
|
+
customQuery: "SELECT NULL AS id",
|
|
528
|
+
})
|
|
529
|
+
.expect(200);
|
|
530
|
+
expect(res.body).toEqual({ recordId: null });
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it.each([
|
|
534
|
+
["SELECT 1 AS id", {}],
|
|
535
|
+
["SELECT 17 AS undefined", { recordId: 17 }],
|
|
536
|
+
])("preserves omitted primaryId for %s", async (customQuery, expected) => {
|
|
537
|
+
const res = await request(app())
|
|
538
|
+
.post("/api/adjacentRecordId")
|
|
539
|
+
.send({ position: "1", offset: "0", table: "fruits", customQuery })
|
|
540
|
+
.expect(200);
|
|
541
|
+
expect(res.body).toEqual(expected);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it("returns the database error with HTTP 500", async () => {
|
|
545
|
+
const res = await request(app())
|
|
546
|
+
.post("/api/adjacentRecordId")
|
|
547
|
+
.send({ position: "1", offset: "0", primaryId: "id", table: "port_missing_table" })
|
|
548
|
+
.expect(500);
|
|
549
|
+
expect(res.body.code).toBe(_backend === "postgresql" ? "42P01" : "ER_NO_SUCH_TABLE");
|
|
550
|
+
expect(res.body).not.toHaveProperty("recordId");
|
|
551
|
+
});
|
|
506
552
|
});
|
|
507
553
|
|
|
508
554
|
describe("GET /api/record", () => {
|
|
555
|
+
it("awaits formatters in order with shared record identity, receivers and missing column ids", async () => {
|
|
556
|
+
expect((await harness.query("INSERT INTO fruits (fruitName) VALUES (?)", ["pomme"])).err).toBeFalsy();
|
|
557
|
+
const seenRows: DatabaseRow[] = [];
|
|
558
|
+
const config = baseConfig({ enriched: true, views: [fruitView({
|
|
559
|
+
primaryId: "id",
|
|
560
|
+
columns: [
|
|
561
|
+
{
|
|
562
|
+
label: "First", name: "fruitName",
|
|
563
|
+
async formatValue(this: { label: string }, row: DatabaseRow) {
|
|
564
|
+
seenRows.push(row);
|
|
565
|
+
return `${this.label}:${row.fruitName}`;
|
|
566
|
+
},
|
|
567
|
+
},
|
|
568
|
+
{
|
|
569
|
+
label: "Second", name: "acidity",
|
|
570
|
+
async formatValue(this: { label: string }, row: DatabaseRow) {
|
|
571
|
+
seenRows.push(row);
|
|
572
|
+
return `${row["__formatValue-undefined__"]}:${this.label}`;
|
|
573
|
+
},
|
|
574
|
+
},
|
|
575
|
+
],
|
|
576
|
+
})] });
|
|
577
|
+
const result = await request(buildAppForManager(harness.manager, config))
|
|
578
|
+
.get("/api/record").query({ viewIndex: "0", recordId: "1" }).expect(200);
|
|
579
|
+
expect(result.body["__formatValue-undefined__"]).toBe("First:pomme:Second");
|
|
580
|
+
expect(result.body.fruitName).toBe("pomme");
|
|
581
|
+
expect(seenRows).toHaveLength(2);
|
|
582
|
+
expect(seenRows[0]).toBe(seenRows[1]);
|
|
583
|
+
});
|
|
584
|
+
|
|
509
585
|
it("returns a single record by primary id", async () => {
|
|
510
586
|
const instance = app();
|
|
511
587
|
await request(instance)
|