@galadrim/galadmin 0.1.236 → 0.2.6
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 +33 -2
- 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 +1 -1
- package/dist/index.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -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 +1 -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 +363 -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,319 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as os from "os";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import * as request from "supertest";
|
|
5
|
+
import { generateUniqueIdForObject } from "../../src/api";
|
|
6
|
+
import { buildAppForManager } from "../helpers/app";
|
|
7
|
+
import { baseConfig, fruitView, EDITOR, VIEWER } from "../helpers/fixtures";
|
|
8
|
+
import {
|
|
9
|
+
BackendName,
|
|
10
|
+
DbHarness,
|
|
11
|
+
describeIntegration,
|
|
12
|
+
getMysql2Harness,
|
|
13
|
+
getMysqlHarness,
|
|
14
|
+
getPgHarness,
|
|
15
|
+
resetTables,
|
|
16
|
+
} from "./dbHarness";
|
|
17
|
+
|
|
18
|
+
jest.mock("axios");
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The role matrix against every route guarded by the server-side write
|
|
22
|
+
* guard, plus the filter/column trust boundaries. The scripted suite
|
|
23
|
+
* asserted "no query ran" on rejections; here rejections must leave the
|
|
24
|
+
* database untouched, and allowances must really reach the handler.
|
|
25
|
+
*/
|
|
26
|
+
describeIntegration("authorization 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
|
+
let panelId: string;
|
|
48
|
+
|
|
49
|
+
beforeEach(async () => {
|
|
50
|
+
harness = getHarness();
|
|
51
|
+
await resetTables(harness);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
function config() {
|
|
55
|
+
// The root role can see and write every view even when its roles/
|
|
56
|
+
// readRoles exclude it; other roles need to be listed explicitly.
|
|
57
|
+
const panel: any = {
|
|
58
|
+
label: "Notes",
|
|
59
|
+
tableName: "fruit_notes",
|
|
60
|
+
primaryId: "id",
|
|
61
|
+
};
|
|
62
|
+
panelId = generateUniqueIdForObject(panel);
|
|
63
|
+
return baseConfig({
|
|
64
|
+
views: [
|
|
65
|
+
fruitView({
|
|
66
|
+
roles: ["editor", "root"],
|
|
67
|
+
readRoles: ["viewer"],
|
|
68
|
+
customActions: [
|
|
69
|
+
{
|
|
70
|
+
label: "Publish",
|
|
71
|
+
action: async () => null,
|
|
72
|
+
successMessage: "ok",
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
recordViewPanels: [panel],
|
|
76
|
+
}),
|
|
77
|
+
],
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const app = (config: any, user: any = { role: "root", userId: 1 }) =>
|
|
82
|
+
buildAppForManager(harness.manager, config, user);
|
|
83
|
+
|
|
84
|
+
const fruitCount = async () => {
|
|
85
|
+
const { rows } = await harness.query("SELECT * FROM fruits");
|
|
86
|
+
return rows.length;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const cases = [
|
|
90
|
+
{
|
|
91
|
+
name: "POST /updateRecord",
|
|
92
|
+
path: "/api/updateRecord",
|
|
93
|
+
body: () => ({ viewIndex: 0, data: {}, primaryValue: 1 }),
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: "POST /createRecord",
|
|
97
|
+
path: "/api/createRecord",
|
|
98
|
+
body: () => ({ viewIndex: 0, data: { fruitName: "x" } }),
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
name: "POST /deleteRecord",
|
|
102
|
+
path: "/api/deleteRecord",
|
|
103
|
+
body: () => ({ viewIndex: 0, primaryValue: 1 }),
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
name: "POST /deleteRecords",
|
|
107
|
+
path: "/api/deleteRecords",
|
|
108
|
+
body: () => ({ viewIndex: 0, primaryValues: [1] }),
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: "POST /deleteRecordFromPanel",
|
|
112
|
+
path: "/api/deleteRecordFromPanel",
|
|
113
|
+
body: () => ({ viewIndex: 0, panelId, primaryValue: 1 }),
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: "POST /customAction",
|
|
117
|
+
path: "/api/customAction",
|
|
118
|
+
body: () => ({ viewIndex: 0, actionIndex: 0, selectedRecordIds: [1] }),
|
|
119
|
+
},
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
for (const c of cases) {
|
|
123
|
+
// NOTE: /uploadFile is guarded like these routes, but a bare POST (no
|
|
124
|
+
// multipart body) fails in multiparty — covered by the dedicated guard
|
|
125
|
+
// tests below.
|
|
126
|
+
|
|
127
|
+
it(`${c.name}: root is allowed past the write-guard`, async () => {
|
|
128
|
+
const res = await request(app(config()))
|
|
129
|
+
.post(c.path)
|
|
130
|
+
.send(c.body());
|
|
131
|
+
expect(res.status).not.toBe(403);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it(`${c.name}: write role is allowed past the write-guard`, async () => {
|
|
135
|
+
const res = await request(app(config(), EDITOR))
|
|
136
|
+
.post(c.path)
|
|
137
|
+
.send(c.body());
|
|
138
|
+
expect(res.status).not.toBe(403);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it(`${c.name}: read-only role gets 403 and the database is untouched`, async () => {
|
|
142
|
+
await request(app(config(), VIEWER))
|
|
143
|
+
.post(c.path)
|
|
144
|
+
.send(c.body())
|
|
145
|
+
.expect(403);
|
|
146
|
+
expect(await fruitCount()).toBe(0);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it(`${c.name}: anonymous session gets 403`, async () => {
|
|
150
|
+
await request(app(config(), null))
|
|
151
|
+
.post(c.path)
|
|
152
|
+
.send(c.body())
|
|
153
|
+
.expect(403);
|
|
154
|
+
expect(await fruitCount()).toBe(0);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it(`${c.name}: unknown role gets 403`, async () => {
|
|
158
|
+
await request(app(config(), { role: "stranger", userId: 9 }))
|
|
159
|
+
.post(c.path)
|
|
160
|
+
.send(c.body())
|
|
161
|
+
.expect(403);
|
|
162
|
+
expect(await fruitCount()).toBe(0);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
it("views without roles are writable by any authenticated role", async () => {
|
|
167
|
+
await request(app(baseConfig(), VIEWER))
|
|
168
|
+
.post("/api/updateRecord")
|
|
169
|
+
.send({ viewIndex: 0, data: {}, primaryValue: 1 })
|
|
170
|
+
.expect(200);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("updateRecord with an unknown viewIndex gets 403", async () => {
|
|
174
|
+
// viewFromRequest yields undefined and the guard rejects it.
|
|
175
|
+
// (body.viewIndex takes precedence over query.viewIndex.)
|
|
176
|
+
await request(app(config(), EDITOR))
|
|
177
|
+
.post("/api/updateRecord")
|
|
178
|
+
.query({ viewIndex: 99 })
|
|
179
|
+
.send({ viewIndex: 99, data: {}, primaryValue: 1 })
|
|
180
|
+
.expect(403);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("/uploadFile: read-only role gets 403 before any upload happens", async () => {
|
|
184
|
+
const config = baseConfig({
|
|
185
|
+
views: [
|
|
186
|
+
fruitView({
|
|
187
|
+
roles: ["editor", "root"],
|
|
188
|
+
readRoles: ["viewer"],
|
|
189
|
+
columns: [
|
|
190
|
+
{ label: "Nom", name: "fruitName" },
|
|
191
|
+
{
|
|
192
|
+
label: "Img",
|
|
193
|
+
name: "img",
|
|
194
|
+
dataType: "file",
|
|
195
|
+
publicPath: "/tmp",
|
|
196
|
+
},
|
|
197
|
+
],
|
|
198
|
+
}),
|
|
199
|
+
],
|
|
200
|
+
});
|
|
201
|
+
const colId = generateUniqueIdForObject(config.views[0].columns[1]);
|
|
202
|
+
await request(app(config, VIEWER))
|
|
203
|
+
.post("/api/uploadFile")
|
|
204
|
+
.query({ viewIndex: 0, columnId: colId })
|
|
205
|
+
.expect(403);
|
|
206
|
+
expect(await fruitCount()).toBe(0);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("/uploadFileFromBetterRichTextInput: read-only role gets 403 before any upload happens", async () => {
|
|
210
|
+
// This endpoint carries no viewIndex: the upload target comes entirely
|
|
211
|
+
// from the client-supplied file-upload-properties header, so it is
|
|
212
|
+
// gated on the caller being able to write at least one view.
|
|
213
|
+
const config = baseConfig({
|
|
214
|
+
views: [fruitView({ roles: ["editor"], readRoles: ["viewer"] })],
|
|
215
|
+
});
|
|
216
|
+
const uploadRoot = fs.mkdtempSync(
|
|
217
|
+
path.join(os.tmpdir(), "galadmin-auth-"),
|
|
218
|
+
);
|
|
219
|
+
fs.mkdirSync(path.join(uploadRoot, "sub"));
|
|
220
|
+
try {
|
|
221
|
+
await request(app(config, VIEWER))
|
|
222
|
+
.post("/api/uploadFileFromBetterRichTextInput")
|
|
223
|
+
.set(
|
|
224
|
+
"file-upload-properties",
|
|
225
|
+
JSON.stringify({ publicPath: uploadRoot, subdirectory: "/sub" }),
|
|
226
|
+
)
|
|
227
|
+
.attach("upload", Buffer.from("png"), { filename: "x.png" })
|
|
228
|
+
.expect(403);
|
|
229
|
+
expect(await fruitCount()).toBe(0);
|
|
230
|
+
} finally {
|
|
231
|
+
fs.rmSync(uploadRoot, { recursive: true, force: true });
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("updateRecord rejects column names breaking out of the identifier charset", async () => {
|
|
236
|
+
const instance = app(baseConfig());
|
|
237
|
+
for (const bad of ["a;b", "a b", "a-b", "a)b", "`a`", "a'"]) {
|
|
238
|
+
await request(instance)
|
|
239
|
+
.post("/api/updateRecord")
|
|
240
|
+
.send({ viewIndex: 0, primaryValue: 1, data: { [bad]: "x" } })
|
|
241
|
+
.expect(400);
|
|
242
|
+
}
|
|
243
|
+
expect(await fruitCount()).toBe(0);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("uploadFile resolves the column by __id__ before serving", async () => {
|
|
247
|
+
// NOTE: an unknown columnId makes uploadFile read properties of
|
|
248
|
+
// `undefined` — an unhandled rejection under express 4 (not assertable
|
|
249
|
+
// via HTTP without crashing the worker).
|
|
250
|
+
const column = {
|
|
251
|
+
label: "Img",
|
|
252
|
+
name: "img",
|
|
253
|
+
dataType: "file",
|
|
254
|
+
publicPath: "/tmp",
|
|
255
|
+
};
|
|
256
|
+
const config = baseConfig({
|
|
257
|
+
views: [
|
|
258
|
+
fruitView({
|
|
259
|
+
columns: [{ label: "Nom", name: "fruitName" }, column],
|
|
260
|
+
}),
|
|
261
|
+
],
|
|
262
|
+
});
|
|
263
|
+
const colId = generateUniqueIdForObject(column);
|
|
264
|
+
// a valid columnId passes the guard and fails later on the missing
|
|
265
|
+
// multipart body
|
|
266
|
+
await request(app(config))
|
|
267
|
+
.post("/api/uploadFile")
|
|
268
|
+
.query({ viewIndex: 0, columnId: colId })
|
|
269
|
+
.expect(500);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
describe("client filter validation (looksLikeSqlBreakout)", () => {
|
|
274
|
+
let harness: DbHarness;
|
|
275
|
+
|
|
276
|
+
beforeEach(async () => {
|
|
277
|
+
harness = harnesses.mysql;
|
|
278
|
+
await resetTables(harness);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("rejects filters containing SQL breakout patterns with 400", async () => {
|
|
282
|
+
const instance = buildAppForManager(
|
|
283
|
+
harness.manager,
|
|
284
|
+
baseConfig({ views: [fruitView({ primaryId: "id" })] }),
|
|
285
|
+
);
|
|
286
|
+
const attacks = [
|
|
287
|
+
"1=1; DROP TABLE fruits",
|
|
288
|
+
"1=1 -- x",
|
|
289
|
+
"1=1 /* x */",
|
|
290
|
+
"1=1 UNION SELECT password FROM users",
|
|
291
|
+
"INTO OUTFILE '/tmp/x'",
|
|
292
|
+
"SLEEP(10)",
|
|
293
|
+
"BENCHMARK(100000, MD5('x'))",
|
|
294
|
+
"LOAD_FILE('/etc/passwd')",
|
|
295
|
+
];
|
|
296
|
+
for (const attack of attacks) {
|
|
297
|
+
const res = await request(instance)
|
|
298
|
+
.post("/api/records")
|
|
299
|
+
.send({ viewIndex: 0, page: 1, filters: [attack] })
|
|
300
|
+
.expect(400);
|
|
301
|
+
expect(res.body).toEqual({ error: "Invalid filter" });
|
|
302
|
+
}
|
|
303
|
+
const { rows } = await harness.query("SELECT * FROM fruits");
|
|
304
|
+
expect(rows).toHaveLength(0);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("allows ordinary filters through", async () => {
|
|
308
|
+
const instance = buildAppForManager(
|
|
309
|
+
harness.manager,
|
|
310
|
+
baseConfig({ views: [fruitView({ primaryId: "id" })] }),
|
|
311
|
+
);
|
|
312
|
+
const res = await request(instance)
|
|
313
|
+
.post("/api/records")
|
|
314
|
+
.send({ viewIndex: 0, page: 1, filters: ["age < 50"] })
|
|
315
|
+
.expect(200);
|
|
316
|
+
expect(res.body.records).toHaveLength(0);
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
});
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import * as request from "supertest";
|
|
2
|
+
import { buildAppForManager } from "../helpers/app";
|
|
3
|
+
import { baseConfig, fruitView, VIEWER } from "../helpers/fixtures";
|
|
4
|
+
import {
|
|
5
|
+
BackendName,
|
|
6
|
+
DbHarness,
|
|
7
|
+
describeIntegration,
|
|
8
|
+
getMysql2Harness,
|
|
9
|
+
getMysqlHarness,
|
|
10
|
+
getPgHarness,
|
|
11
|
+
resetTables,
|
|
12
|
+
} from "./dbHarness";
|
|
13
|
+
|
|
14
|
+
jest.mock("axios");
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* GET /api/config against real engines. Serialization and role filtering are
|
|
18
|
+
* pure JS; the real database is what feeds enrichConfig (information_schema
|
|
19
|
+
* primaryId/required/dataType resolution).
|
|
20
|
+
*/
|
|
21
|
+
describeIntegration("config route against real databases", () => {
|
|
22
|
+
const harnesses: Record<BackendName, DbHarness> = {} as any;
|
|
23
|
+
|
|
24
|
+
beforeAll(async () => {
|
|
25
|
+
harnesses.mysql = await getMysqlHarness();
|
|
26
|
+
harnesses.mysql2 = await getMysql2Harness();
|
|
27
|
+
harnesses.postgresql = await getPgHarness();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterAll(async () => {
|
|
31
|
+
await harnesses.mysql?.stop();
|
|
32
|
+
await harnesses.mysql2?.stop();
|
|
33
|
+
await harnesses.postgresql?.stop();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe.each([
|
|
37
|
+
["mysql", () => harnesses.mysql],
|
|
38
|
+
["mysql2", () => harnesses.mysql2],
|
|
39
|
+
["postgresql", () => harnesses.postgresql],
|
|
40
|
+
])("backend: %s", (_backend, getHarness) => {
|
|
41
|
+
let harness: DbHarness;
|
|
42
|
+
|
|
43
|
+
beforeEach(async () => {
|
|
44
|
+
harness = getHarness();
|
|
45
|
+
await resetTables(harness);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const app = (config: any, user: any = { role: "root", userId: 1 }) =>
|
|
49
|
+
buildAppForManager(harness.manager, config, user);
|
|
50
|
+
|
|
51
|
+
const getConfig = (instance: any) =>
|
|
52
|
+
request(instance).get("/api/config").expect(200);
|
|
53
|
+
|
|
54
|
+
it("serves a serialized config", async () => {
|
|
55
|
+
const res = await getConfig(app(baseConfig()));
|
|
56
|
+
expect(typeof res.body.config).toBe("string");
|
|
57
|
+
expect(res.body.config).toContain("Fruits");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("strips mysql credentials, auth and sessionSecret", async () => {
|
|
61
|
+
const res = await getConfig(app(baseConfig()));
|
|
62
|
+
expect(res.body.config).not.toContain("test-secret");
|
|
63
|
+
expect(res.body.config).not.toContain("admin-pass");
|
|
64
|
+
expect(res.body.config).not.toContain("galadmin_test");
|
|
65
|
+
expect(res.body.config).not.toContain('"auth"');
|
|
66
|
+
expect(res.body.config).not.toContain('"mysql"');
|
|
67
|
+
expect(res.body.config).not.toContain('"sessionSecret"');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("filters out views whose roles exclude the current user", async () => {
|
|
71
|
+
// The role filter runs before enrichment, so the fictional "secrets"
|
|
72
|
+
// table is never queried by information_schema.
|
|
73
|
+
const config = baseConfig({
|
|
74
|
+
views: [
|
|
75
|
+
fruitView(),
|
|
76
|
+
{
|
|
77
|
+
type: "table",
|
|
78
|
+
label: "Secret",
|
|
79
|
+
tableName: "secrets",
|
|
80
|
+
roles: ["admin"],
|
|
81
|
+
columns: [],
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
});
|
|
85
|
+
const res = await getConfig(app(config, VIEWER));
|
|
86
|
+
expect(res.body.config).toContain("Fruits");
|
|
87
|
+
expect(res.body.config).not.toContain("Secret");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("hides views whose roles exclude root from the root role too", async () => {
|
|
91
|
+
const config = baseConfig({
|
|
92
|
+
views: [
|
|
93
|
+
{
|
|
94
|
+
type: "table",
|
|
95
|
+
label: "Clients only",
|
|
96
|
+
tableName: "orders",
|
|
97
|
+
roles: ["client"],
|
|
98
|
+
filtersFromUser: (id) => `client_id = ${id}`,
|
|
99
|
+
columns: [],
|
|
100
|
+
},
|
|
101
|
+
fruitView(),
|
|
102
|
+
],
|
|
103
|
+
});
|
|
104
|
+
const res = await getConfig(app(config, { role: "root", userId: 1 }));
|
|
105
|
+
expect(res.body.config).not.toContain("Clients only");
|
|
106
|
+
expect(res.body.config).toContain("Fruits");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("marks views root can only read as read-only for root", async () => {
|
|
110
|
+
const config = baseConfig({
|
|
111
|
+
views: [fruitView({ roles: ["editor"], readRoles: ["root"] })],
|
|
112
|
+
});
|
|
113
|
+
const res = await getConfig(app(config, { role: "root", userId: 1 }));
|
|
114
|
+
expect(res.body.config).toContain('"canEdit":false');
|
|
115
|
+
expect(res.body.config).toContain('"canCreate":false');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("keeps views when the role is in readRoles and makes them read-only", async () => {
|
|
119
|
+
const config = baseConfig({
|
|
120
|
+
views: [
|
|
121
|
+
fruitView({
|
|
122
|
+
roles: ["editor"],
|
|
123
|
+
readRoles: ["viewer"],
|
|
124
|
+
}),
|
|
125
|
+
],
|
|
126
|
+
});
|
|
127
|
+
const res = await getConfig(app(config, VIEWER));
|
|
128
|
+
expect(res.body.config).toContain("Fruits");
|
|
129
|
+
expect(res.body.config).toContain('"canEdit":false');
|
|
130
|
+
expect(res.body.config).toContain('"canCreate":false');
|
|
131
|
+
expect(res.body.config).toContain('"canDelete":false');
|
|
132
|
+
expect(res.body.config).toContain('"clickToEdit":false');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("filters columns by column.roles", async () => {
|
|
136
|
+
const config = baseConfig({
|
|
137
|
+
views: [
|
|
138
|
+
fruitView({
|
|
139
|
+
columns: [
|
|
140
|
+
{ label: "Nom", name: "fruitName" },
|
|
141
|
+
{ label: "Secret", name: "secret", roles: ["admin"] },
|
|
142
|
+
],
|
|
143
|
+
}),
|
|
144
|
+
],
|
|
145
|
+
});
|
|
146
|
+
const res = await getConfig(app(config, VIEWER));
|
|
147
|
+
expect(res.body.config).toContain("Nom");
|
|
148
|
+
expect(res.body.config).not.toContain("Secret");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("serves non-table views untouched by the column filter", async () => {
|
|
152
|
+
const config = baseConfig({
|
|
153
|
+
views: [
|
|
154
|
+
fruitView(),
|
|
155
|
+
{ type: "iframe", label: "External", src: "https://example.com" },
|
|
156
|
+
],
|
|
157
|
+
});
|
|
158
|
+
const res = await getConfig(app(config, VIEWER));
|
|
159
|
+
expect(res.body.config).toContain("External");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("enriches table views from information_schema on MySQL", async () => {
|
|
163
|
+
const config = baseConfig({
|
|
164
|
+
views: [
|
|
165
|
+
fruitView({
|
|
166
|
+
tableName: "fruit_tags",
|
|
167
|
+
columns: [
|
|
168
|
+
{ label: "Tag", name: "tag" },
|
|
169
|
+
{ label: "Fruit", name: "fruitId" },
|
|
170
|
+
],
|
|
171
|
+
}),
|
|
172
|
+
],
|
|
173
|
+
});
|
|
174
|
+
const res = await getConfig(app(config));
|
|
175
|
+
if (harness.dbType === "POSTGRESQL") {
|
|
176
|
+
// GAP 10: enrichment filters information_schema by table_schema =
|
|
177
|
+
// database name, but PG's schema is "public" — no rows match, so
|
|
178
|
+
// primaryId/required/dataType are never resolved on PG.
|
|
179
|
+
expect(res.body.config).not.toContain('"primaryId"');
|
|
180
|
+
expect(res.body.config).not.toContain('"required"');
|
|
181
|
+
} else {
|
|
182
|
+
// tag and fruitId are NOT NULL: enrichment marks them required and
|
|
183
|
+
// resolves the fruit_tags PK as primaryId.
|
|
184
|
+
expect(res.body.config).toContain('"primaryId":"id"');
|
|
185
|
+
expect(res.body.config).toContain('"required":true');
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
});
|