@galadrim/galadmin 0.2.11 → 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/.github/actions/version-bump/action.yml +41 -0
- package/.github/workflows/alpha.yml +53 -0
- package/.github/workflows/ci.yml +8 -9
- package/.github/workflows/main.yml +13 -14
- 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/front/ckeditor5-build/node_modules/semver/bin/semver.js +0 -0
- 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
- package/src/front/ckeditor5-build/node_modules/.bin/semver +0 -12
- package/src/front/ckeditor5-build/node_modules/.bin/semver.cmd +0 -17
- package/src/front/ckeditor5-build/node_modules/.bin/semver.ps1 +0 -28
|
File without changes
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import * as pgSessionStore from 'connect-pg-simple';
|
|
|
5
5
|
import * as mysql2 from 'mysql2/promise';
|
|
6
6
|
import * as pg from 'pg';
|
|
7
7
|
import dbManager from "./utils/dbManager";
|
|
8
|
+
import type { DatabaseRow } from "./utils/dbManager";
|
|
8
9
|
import * as bcrypt from "bcryptjs";
|
|
9
10
|
|
|
10
11
|
import * as fs from "fs";
|
|
@@ -13,13 +14,17 @@ import * as mime from "mime";
|
|
|
13
14
|
|
|
14
15
|
import * as moment from "moment";
|
|
15
16
|
|
|
16
|
-
import { Config } from "./types/config"
|
|
17
|
+
import type { Config } from "./types/config"
|
|
17
18
|
|
|
18
19
|
import api from "./api"
|
|
19
20
|
|
|
21
|
+
// The runtime accepts promise pools as well as its declared callback connections.
|
|
22
|
+
type MySQLSessionStoreConstructor = ReturnType<typeof SessionStore> &
|
|
23
|
+
(new (options: SessionStore.Options, connection: mysql2.Pool) => session.Store);
|
|
24
|
+
|
|
20
25
|
moment.locale('fr');
|
|
21
26
|
|
|
22
|
-
export default (config: Config)
|
|
27
|
+
export default (config: Config) => {
|
|
23
28
|
const db = new dbManager();
|
|
24
29
|
|
|
25
30
|
db.init(config);
|
|
@@ -51,14 +56,14 @@ export default (config: Config): any => {
|
|
|
51
56
|
}),
|
|
52
57
|
createTableIfMissing: true,
|
|
53
58
|
tableName: 'galadmin_sessions'
|
|
54
|
-
}) : new (SessionStore(session))({
|
|
59
|
+
}) : new (SessionStore(session) as MySQLSessionStoreConstructor)({
|
|
55
60
|
schema: { tableName: 'galadmin_sessions' }
|
|
56
61
|
}, mysql2.createPool({
|
|
57
|
-
host: config.mysql
|
|
58
|
-
user: config.mysql
|
|
59
|
-
password: config.mysql
|
|
60
|
-
database: config.mysql
|
|
61
|
-
port: config.mysql
|
|
62
|
+
host: config.mysql!.host,
|
|
63
|
+
user: config.mysql!.user,
|
|
64
|
+
password: config.mysql!.password,
|
|
65
|
+
database: config.mysql!.database,
|
|
66
|
+
port: config.mysql!.port || 3306
|
|
62
67
|
})),
|
|
63
68
|
resave: false,
|
|
64
69
|
saveUninitialized: false,
|
|
@@ -70,13 +75,13 @@ export default (config: Config): any => {
|
|
|
70
75
|
if (config.trustedAuth) {
|
|
71
76
|
app.use(async (req, res, next) => {
|
|
72
77
|
try {
|
|
73
|
-
const user = await config.trustedAuth(req);
|
|
78
|
+
const user = await config.trustedAuth!(req);
|
|
74
79
|
if (user) {
|
|
75
|
-
if (JSON.stringify(req.session
|
|
76
|
-
req.session
|
|
80
|
+
if (JSON.stringify(req.session!.user) !== JSON.stringify(user)) {
|
|
81
|
+
req.session!.user = user;
|
|
77
82
|
}
|
|
78
|
-
} else if (req.session
|
|
79
|
-
delete req.session
|
|
83
|
+
} else if (req.session!.user) {
|
|
84
|
+
delete req.session!.user;
|
|
80
85
|
}
|
|
81
86
|
next();
|
|
82
87
|
} catch (err) {
|
|
@@ -85,7 +90,7 @@ export default (config: Config): any => {
|
|
|
85
90
|
});
|
|
86
91
|
}
|
|
87
92
|
|
|
88
|
-
const passwordMatch = async (password: string, hash: string, useArgon2: boolean) => {
|
|
93
|
+
const passwordMatch = async (password: string, hash: string, useArgon2: boolean | undefined) => {
|
|
89
94
|
if (password === hash || bcrypt.compareSync(password, hash)) {
|
|
90
95
|
return true;
|
|
91
96
|
}
|
|
@@ -105,15 +110,23 @@ export default (config: Config): any => {
|
|
|
105
110
|
return res.send({ success: false })
|
|
106
111
|
}
|
|
107
112
|
if (config.auth && req.body.username === config.auth.username && req.body.password === config.auth.password) {
|
|
108
|
-
req.session
|
|
113
|
+
req.session!.user = { role: "root" }
|
|
109
114
|
res.send({ success: true })
|
|
110
115
|
} else if (config.authRoles) {
|
|
111
116
|
for (const authRole of config.authRoles) {
|
|
112
117
|
const filters = authRole.filters ? `AND ${authRole.filters}` : ""
|
|
113
118
|
const { rows } = await db.query(`SELECT * FROM ?? WHERE ?? = ? ${filters}`, [authRole.table, authRole.usernameField, req.body.username]);
|
|
114
|
-
|
|
119
|
+
/*
|
|
120
|
+
#PORTING-EDIT
|
|
121
|
+
A mutation result cannot identify a user; ignore non-row results and non-text passwords.
|
|
122
|
+
*/
|
|
123
|
+
if (!Array.isArray(rows)) continue;
|
|
124
|
+
let user: DatabaseRow | undefined;
|
|
115
125
|
for (const row of rows) {
|
|
116
|
-
if (
|
|
126
|
+
if (Array.isArray(row)) continue;
|
|
127
|
+
const record: DatabaseRow = row;
|
|
128
|
+
const hash = record[authRole.passwordField];
|
|
129
|
+
if (typeof hash === "string" && await passwordMatch(req.body.password, hash, authRole.useArgon2)) {
|
|
117
130
|
user = row;
|
|
118
131
|
break;
|
|
119
132
|
}
|
|
@@ -121,7 +134,7 @@ export default (config: Config): any => {
|
|
|
121
134
|
if (!user) {
|
|
122
135
|
continue;
|
|
123
136
|
}
|
|
124
|
-
req.session
|
|
137
|
+
req.session!.user = { role: authRole.role, userId: user[authRole.idField] }
|
|
125
138
|
return res.send({ success: true })
|
|
126
139
|
}
|
|
127
140
|
res.send({ success: false })
|
|
@@ -133,13 +146,13 @@ export default (config: Config): any => {
|
|
|
133
146
|
|
|
134
147
|
app.get('/api/isAuthenticated', (req, res) => {
|
|
135
148
|
res.send({
|
|
136
|
-
isAuthenticated: !!req.session
|
|
149
|
+
isAuthenticated: !!req.session!.user
|
|
137
150
|
})
|
|
138
151
|
})
|
|
139
152
|
|
|
140
153
|
|
|
141
154
|
app.get('/api/logout', (req, res) => {
|
|
142
|
-
req.session
|
|
155
|
+
req.session!.destroy((err) => {
|
|
143
156
|
if (err) {
|
|
144
157
|
console.error(err)
|
|
145
158
|
}
|
|
@@ -148,17 +161,21 @@ export default (config: Config): any => {
|
|
|
148
161
|
})
|
|
149
162
|
|
|
150
163
|
app.use("/api", async (req, res, next) => {
|
|
151
|
-
if (req.method === "OPTIONS" || req.session
|
|
164
|
+
if (req.method === "OPTIONS" || req.session!.user) {
|
|
152
165
|
if (req.session?.user?.userId && !config.trustedAuth) {
|
|
153
166
|
// we check that the user still exists
|
|
154
167
|
let stillExists = false;
|
|
155
|
-
for (const authRole of config.authRoles) {
|
|
168
|
+
for (const authRole of config.authRoles!) {
|
|
156
169
|
// Re-apply the role's `filters` here too, so an account that becomes
|
|
157
170
|
// ineligible (e.g. deactivated) has its existing session invalidated
|
|
158
171
|
// on its next request — not just blocked at login time.
|
|
159
172
|
const filters = authRole.filters ? `AND ${authRole.filters}` : "";
|
|
160
173
|
const { rows } = await db.query(`SELECT * FROM ?? WHERE ?? = ? ${filters}`, [authRole.table, authRole.idField, req.session.user.userId]);
|
|
161
|
-
|
|
174
|
+
/*
|
|
175
|
+
#PORTING-EDIT
|
|
176
|
+
A non-row result cannot establish that the session user still exists.
|
|
177
|
+
*/
|
|
178
|
+
if (Array.isArray(rows) && rows.length > 0) {
|
|
162
179
|
stillExists = true;
|
|
163
180
|
break;
|
|
164
181
|
}
|
|
@@ -189,13 +206,13 @@ export default (config: Config): any => {
|
|
|
189
206
|
|
|
190
207
|
// Send media files just like they are
|
|
191
208
|
if ([".woff", ".png"].includes(path.extname(filePath))) {
|
|
192
|
-
res.contentType(mime.getType(filePath));
|
|
209
|
+
res.contentType(mime.getType(filePath)!);
|
|
193
210
|
res.send(fs.readFileSync(filePath))
|
|
194
211
|
} else {
|
|
195
212
|
// For text files, we replace _HOMEPAGE_ with the base url of the back-office
|
|
196
213
|
let fileContent = fs.readFileSync(filePath, { encoding: "utf-8" })
|
|
197
214
|
fileContent = fileContent.replace(/\/_HOMEPAGE_/g, req.baseUrl)
|
|
198
|
-
res.contentType(mime.getType(filePath));
|
|
215
|
+
res.contentType(mime.getType(filePath)!);
|
|
199
216
|
res.send(fileContent)
|
|
200
217
|
}
|
|
201
218
|
})
|
package/src/types/config.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
|
+
import type { DatabaseId } from "./database";
|
|
2
|
+
|
|
1
3
|
export type Filter = {};
|
|
2
4
|
|
|
5
|
+
export type RootUser = { role: "root" };
|
|
6
|
+
export type RegularUser = { role: string; userId?: DatabaseId };
|
|
7
|
+
export type FilterUser = RootUser | RegularUser;
|
|
8
|
+
|
|
9
|
+
export function isRegularUser(user: FilterUser): user is RegularUser {
|
|
10
|
+
return user.role !== "root";
|
|
11
|
+
}
|
|
12
|
+
|
|
3
13
|
export type BaseTypeColumn = {
|
|
4
14
|
label: string;
|
|
5
15
|
hideInTable?: boolean;
|
|
@@ -89,7 +99,7 @@ export type ForeignSelectColumn = {
|
|
|
89
99
|
selectExpression?: string;
|
|
90
100
|
join?: string;
|
|
91
101
|
filters?: Filter[];
|
|
92
|
-
filtersFromUser?: (
|
|
102
|
+
filtersFromUser?: (user: FilterUser) => string;
|
|
93
103
|
orderBy?: string;
|
|
94
104
|
optional?: boolean;
|
|
95
105
|
formatForeignValue?: (record: any) => string;
|
|
@@ -105,7 +115,7 @@ export type ForeignDatalistColumn = {
|
|
|
105
115
|
selectExpression?: string;
|
|
106
116
|
join?: string;
|
|
107
117
|
filters?: Filter[];
|
|
108
|
-
filtersFromUser?: (
|
|
118
|
+
filtersFromUser?: (user: FilterUser) => string;
|
|
109
119
|
orderBy?: string;
|
|
110
120
|
optional?: boolean;
|
|
111
121
|
formatForeignValue?: (record: any) => string;
|
|
@@ -237,7 +247,7 @@ export type TableView = {
|
|
|
237
247
|
isDesc?: boolean;
|
|
238
248
|
limit?: number;
|
|
239
249
|
filters?: string;
|
|
240
|
-
filtersFromUser?: (
|
|
250
|
+
filtersFromUser?: (user: FilterUser) => string;
|
|
241
251
|
notClickable?: boolean;
|
|
242
252
|
clickToEdit?: boolean;
|
|
243
253
|
openInNewTab?: boolean;
|
|
@@ -292,7 +302,7 @@ export type BaseTypePanel = {
|
|
|
292
302
|
label: string;
|
|
293
303
|
folded?: boolean;
|
|
294
304
|
displayCondition?: (record: any) => boolean;
|
|
295
|
-
}
|
|
305
|
+
} & Pick<BaseTypeView, "roles">;
|
|
296
306
|
|
|
297
307
|
export type CreateConfig = {
|
|
298
308
|
label: string;
|
|
@@ -430,7 +440,7 @@ export type AuthRole = {
|
|
|
430
440
|
|
|
431
441
|
export type TrustedUser = {
|
|
432
442
|
role: string;
|
|
433
|
-
userId?:
|
|
443
|
+
userId?: DatabaseId;
|
|
434
444
|
};
|
|
435
445
|
|
|
436
446
|
export type Config = {
|
package/src/utils/dbManager.ts
CHANGED
|
@@ -2,6 +2,82 @@ import * as mysql from "mysql";
|
|
|
2
2
|
import * as mysql2 from "mysql2";
|
|
3
3
|
import * as pg from "pg";
|
|
4
4
|
|
|
5
|
+
type DatabaseValue = string | number | bigint | boolean | null | Date | Buffer
|
|
6
|
+
| DatabaseValue[] | DatabaseRow;
|
|
7
|
+
export type DatabaseRow = { [column: string]: DatabaseValue };
|
|
8
|
+
type MutationResult = Pick<mysql2.ResultSetHeader, "insertId" | "affectedRows">;
|
|
9
|
+
type QueryRows = DatabaseRow[] | MutationResult | QueryRows[];
|
|
10
|
+
type QueryField = Pick<pg.FieldDef, "name">;
|
|
11
|
+
type QueryFields = (QueryField | QueryField[] | undefined)[];
|
|
12
|
+
type QueryResult = {
|
|
13
|
+
err: Error | null;
|
|
14
|
+
rows: QueryRows | undefined;
|
|
15
|
+
fields: QueryFields | undefined;
|
|
16
|
+
};
|
|
17
|
+
type DatabaseConfig = {
|
|
18
|
+
useMysql2?: boolean;
|
|
19
|
+
mysql?: Pick<mysql.PoolConfig, "host" | "user" | "password" | "database" | "port" | "multipleStatements">;
|
|
20
|
+
postgresql?: Pick<pg.PoolConfig, "host" | "user" | "password" | "database" | "port" | "ssl">;
|
|
21
|
+
};
|
|
22
|
+
type DatabasePool = {
|
|
23
|
+
query(sql: string, values: mysql2.QueryValues[], callback: (
|
|
24
|
+
error: Error | null | undefined, result: unknown, fields?: unknown,
|
|
25
|
+
) => void): void;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function isDatabaseValue(value: unknown): value is DatabaseValue {
|
|
29
|
+
return value === null || typeof value === "string" || typeof value === "number"
|
|
30
|
+
|| typeof value === "bigint" || typeof value === "boolean"
|
|
31
|
+
|| value instanceof Date || Buffer.isBuffer(value)
|
|
32
|
+
|| (Array.isArray(value) ? value.every(isDatabaseValue)
|
|
33
|
+
: typeof value === "object" && Object.values(value).every(isDatabaseValue));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isDatabaseRow(value: unknown): value is DatabaseRow {
|
|
37
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
38
|
+
&& !(value instanceof Date) && !Buffer.isBuffer(value)
|
|
39
|
+
&& Object.values(value).every(isDatabaseValue);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isQueryRows(value: unknown): value is QueryRows {
|
|
43
|
+
return Array.isArray(value)
|
|
44
|
+
? value.every(isDatabaseRow) || value.every(isQueryRows)
|
|
45
|
+
: value !== null && typeof value === "object"
|
|
46
|
+
&& "insertId" in value && typeof value.insertId === "number"
|
|
47
|
+
&& "affectedRows" in value && typeof value.affectedRows === "number";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isQueryField(value: unknown): value is QueryField {
|
|
51
|
+
return value !== null && typeof value === "object" && "name" in value && typeof value.name === "string";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isQueryFields(value: unknown): value is QueryFields {
|
|
55
|
+
return Array.isArray(value) && value.every((field: unknown) =>
|
|
56
|
+
field === undefined || isQueryField(field) || (Array.isArray(field) && field.every(isQueryField)));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/*
|
|
60
|
+
#PORTING-EDIT
|
|
61
|
+
Check driver results before exposing database values, reject unsupported shapes, and use undefined for absent rows.
|
|
62
|
+
*/
|
|
63
|
+
function readQueryResult(result: unknown, fields: unknown, postgres: boolean) {
|
|
64
|
+
let rows = result;
|
|
65
|
+
if (postgres) {
|
|
66
|
+
if (result === undefined || result === null) return { rows: undefined, fields: undefined };
|
|
67
|
+
if (typeof result !== "object") throw new TypeError("Invalid PostgreSQL result");
|
|
68
|
+
rows = "rows" in result ? result.rows : undefined;
|
|
69
|
+
fields = "fields" in result ? result.fields : undefined;
|
|
70
|
+
}
|
|
71
|
+
let checkedFields: QueryFields | undefined;
|
|
72
|
+
if (fields !== undefined) {
|
|
73
|
+
if (!isQueryFields(fields)) throw new TypeError("Invalid database fields");
|
|
74
|
+
checkedFields = fields;
|
|
75
|
+
}
|
|
76
|
+
if (rows === undefined || rows === null) return { rows: undefined, fields: checkedFields };
|
|
77
|
+
if (isQueryRows(rows)) return { rows, fields: checkedFields };
|
|
78
|
+
throw new TypeError("Invalid database rows");
|
|
79
|
+
}
|
|
80
|
+
|
|
5
81
|
export function quoteIdentifier(name: string, dbType: string): string {
|
|
6
82
|
const quoteChar = dbType === "POSTGRESQL" ? '"' : "`";
|
|
7
83
|
return quoteChar + name + quoteChar;
|
|
@@ -42,9 +118,9 @@ function endOfLiteralOrComment(query: string, start: number): number {
|
|
|
42
118
|
return query.length;
|
|
43
119
|
}
|
|
44
120
|
|
|
45
|
-
export function convertQuery(mysqlQuery, params) {
|
|
121
|
+
export function convertQuery(mysqlQuery: string, params: mysql2.QueryValues[]) {
|
|
46
122
|
let pgQuery = "";
|
|
47
|
-
let pgParams = [];
|
|
123
|
+
let pgParams: mysql2.QueryValues[] = [];
|
|
48
124
|
|
|
49
125
|
let paramIndex = 1;
|
|
50
126
|
let charIndex = 0;
|
|
@@ -81,7 +157,7 @@ export function convertQuery(mysqlQuery, params) {
|
|
|
81
157
|
};
|
|
82
158
|
}
|
|
83
159
|
|
|
84
|
-
export function quotePostgresQuery(query) {
|
|
160
|
+
export function quotePostgresQuery(query: string) {
|
|
85
161
|
query = query.replace(/`/g, '"');
|
|
86
162
|
|
|
87
163
|
const reservedWords = [
|
|
@@ -174,7 +250,7 @@ export function quotePostgresQuery(query) {
|
|
|
174
250
|
"RETURNING",
|
|
175
251
|
];
|
|
176
252
|
|
|
177
|
-
const wordsToAddQuotes = new Set();
|
|
253
|
+
const wordsToAddQuotes = new Set<string>();
|
|
178
254
|
const parts = query.split(/[ ,\(\)]/);
|
|
179
255
|
|
|
180
256
|
let inString = false;
|
|
@@ -197,7 +273,7 @@ export function quotePostgresQuery(query) {
|
|
|
197
273
|
) {
|
|
198
274
|
} else {
|
|
199
275
|
for (let x of part.split(".")) {
|
|
200
|
-
if (isNaN(x)) {
|
|
276
|
+
if (isNaN(Number(x))) {
|
|
201
277
|
wordsToAddQuotes.add(x);
|
|
202
278
|
}
|
|
203
279
|
}
|
|
@@ -221,12 +297,14 @@ export function quotePostgresQuery(query) {
|
|
|
221
297
|
}
|
|
222
298
|
|
|
223
299
|
export default class dbManager {
|
|
224
|
-
_db;
|
|
225
|
-
dbType;
|
|
300
|
+
_db?: DatabasePool;
|
|
301
|
+
dbType?: "MYSQL" | "POSTGRESQL";
|
|
226
302
|
|
|
227
|
-
init(config) {
|
|
303
|
+
init(config: DatabaseConfig) {
|
|
228
304
|
if (config.mysql) {
|
|
229
|
-
|
|
305
|
+
const driver: { createPool(options: mysql.PoolConfig & mysql2.PoolOptions): DatabasePool } =
|
|
306
|
+
config.useMysql2 ? mysql2 : mysql;
|
|
307
|
+
this._db = driver.createPool({
|
|
230
308
|
connectionLimit: 10,
|
|
231
309
|
host: config.mysql.host,
|
|
232
310
|
user: config.mysql.user,
|
|
@@ -254,18 +332,25 @@ export default class dbManager {
|
|
|
254
332
|
}
|
|
255
333
|
}
|
|
256
334
|
|
|
257
|
-
async query(queryString, args = []): Promise<
|
|
335
|
+
async query(queryString: string, args: mysql2.QueryValues[] = []): Promise<QueryResult> {
|
|
258
336
|
return new Promise((resolve, reject) => {
|
|
259
337
|
if (this.dbType === "POSTGRESQL") {
|
|
260
338
|
const result = convertQuery(queryString, args);
|
|
261
339
|
queryString = quotePostgresQuery(result.pgQuery);
|
|
262
340
|
args = result.pgParams;
|
|
263
341
|
}
|
|
342
|
+
if (!this._db) throw new TypeError("Cannot read properties of undefined (reading 'query')");
|
|
264
343
|
this._db.query(queryString, args, (a, b, c) => {
|
|
265
|
-
if (this.dbType === "MYSQL") {
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
344
|
+
if (this.dbType === "MYSQL" || this.dbType === "POSTGRESQL") {
|
|
345
|
+
try {
|
|
346
|
+
/*
|
|
347
|
+
#PORTING-EDIT
|
|
348
|
+
Both drivers signal success without an error; use null for PostgreSQL's undefined success value.
|
|
349
|
+
*/
|
|
350
|
+
resolve({ err: a ?? null, ...readQueryResult(b, c, this.dbType === "POSTGRESQL") });
|
|
351
|
+
} catch (error) {
|
|
352
|
+
reject(error);
|
|
353
|
+
}
|
|
269
354
|
}
|
|
270
355
|
});
|
|
271
356
|
});
|
|
@@ -185,6 +185,46 @@ describe("sqlAnd", () => {
|
|
|
185
185
|
});
|
|
186
186
|
|
|
187
187
|
describe("evalExpression", () => {
|
|
188
|
+
it("keeps direct evaluation scope and repeated field getter reads", () => {
|
|
189
|
+
const data = { a: "x" };
|
|
190
|
+
expect(evalExpression("[data, statement, varString]", data)).toEqual([
|
|
191
|
+
data, "var a = 'x';", "var a = 'x';",
|
|
192
|
+
]);
|
|
193
|
+
expect(evalExpression("data", data)).toBe(data);
|
|
194
|
+
let reads = 0;
|
|
195
|
+
expect(evalExpression("text", {
|
|
196
|
+
get text() { return ++reads === 1 ? "guard" : "second read"; },
|
|
197
|
+
})).toBe("second read");
|
|
198
|
+
expect(reads).toBe(2);
|
|
199
|
+
reads = 0;
|
|
200
|
+
expect(evalExpression("item.read", {
|
|
201
|
+
get item() { return { read: ++reads }; },
|
|
202
|
+
})).toBe(3);
|
|
203
|
+
expect(reads).toBe(3);
|
|
204
|
+
reads = 0;
|
|
205
|
+
expect(evalExpression("text", {
|
|
206
|
+
get text() { return ++reads === 1 ? "guard" : 42; },
|
|
207
|
+
})).toBe("Error");
|
|
208
|
+
expect(reads).toBe(2);
|
|
209
|
+
});
|
|
210
|
+
it("preserves unchecked result identity, lazy properties and CSV/JSON coercion", () => {
|
|
211
|
+
const data = { reads: 0 };
|
|
212
|
+
const result = evalExpression("({get label(){ return ++data.reads; }})", data);
|
|
213
|
+
expect(data.reads).toBe(0);
|
|
214
|
+
expect(JSON.stringify({ label: result })).toBe('{"label":{"label":1}}');
|
|
215
|
+
const coercible = evalExpression('({toString(){return "csv";}, toJSON(){return "json";}})', {});
|
|
216
|
+
expect("" + coercible).toBe("csv");
|
|
217
|
+
expect(JSON.stringify({ label: coercible })).toBe('{"label":"json"}');
|
|
218
|
+
const cycle: { self?: object } = {};
|
|
219
|
+
cycle.self = cycle;
|
|
220
|
+
const holder = Object.defineProperty({}, "cycle", { value: cycle });
|
|
221
|
+
expect(evalExpression("data.cycle", holder)).toBe(cycle);
|
|
222
|
+
const fn = evalExpression("(function label(){ return 7; })", {});
|
|
223
|
+
expect("" + fn).toContain("function label");
|
|
224
|
+
expect(JSON.stringify({ label: fn })).toBe("{}");
|
|
225
|
+
expect(evalExpression("undefined", {})).toBeUndefined();
|
|
226
|
+
expect(evalExpression("null", {})).toBeNull();
|
|
227
|
+
});
|
|
188
228
|
it("evaluates an expression against record fields", () => {
|
|
189
229
|
expect(
|
|
190
230
|
evalExpression("weight + ' kg'", { weight: 42 }),
|
|
@@ -205,6 +245,17 @@ describe("evalExpression", () => {
|
|
|
205
245
|
});
|
|
206
246
|
|
|
207
247
|
describe("getFormattedValue", () => {
|
|
248
|
+
it("preserves raw object identity, absent-name indexing and own undefined options", async () => {
|
|
249
|
+
const value = { nested: true };
|
|
250
|
+
const record: Record<string, unknown> = { undefined: value };
|
|
251
|
+
await expect(getFormattedValue({}, record)).resolves.toBe(value);
|
|
252
|
+
expect(Object.prototype.hasOwnProperty.call(record, "options")).toBe(true);
|
|
253
|
+
expect(record.options).toBeUndefined();
|
|
254
|
+
const options = ["x"];
|
|
255
|
+
await expect(getFormattedValue({ formattedValue: "data", options }, record)).resolves.toBe(record);
|
|
256
|
+
expect(record.options).toBe(options);
|
|
257
|
+
await expect(getFormattedValue({ formatValueForExport: () => { throw new Error("formatter failed"); } }, record)).resolves.toBe("");
|
|
258
|
+
});
|
|
208
259
|
it("prefers formatValueForExport", async () => {
|
|
209
260
|
const column = {
|
|
210
261
|
name: "a",
|
|
@@ -240,6 +291,14 @@ describe("getFormattedValue", () => {
|
|
|
240
291
|
});
|
|
241
292
|
|
|
242
293
|
describe("formatValueForInsertion", () => {
|
|
294
|
+
it("preserves unchecked fallback values and existing serialization failures", () => {
|
|
295
|
+
const symbol = Symbol("raw");
|
|
296
|
+
const callback = () => "raw";
|
|
297
|
+
expect(formatValueForInsertion(symbol, "MYSQL")).toBe(symbol);
|
|
298
|
+
expect(formatValueForInsertion(callback, "MYSQL")).toBe(callback);
|
|
299
|
+
expect(formatValueForInsertion(BigInt(9), "MYSQL")).toBe(BigInt(9));
|
|
300
|
+
expect(() => formatValueForInsertion({ toJSON: () => undefined }, "MYSQL")).toThrow(TypeError);
|
|
301
|
+
});
|
|
243
302
|
it("maps falsy (but not 0) to NULL", () => {
|
|
244
303
|
expect(formatValueForInsertion(null, "MYSQL")).toBe("NULL");
|
|
245
304
|
expect(formatValueForInsertion(undefined, "MYSQL")).toBe("NULL");
|
package/tests/dbManager.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import dbManager, {
|
|
2
2
|
convertQuery,
|
|
3
3
|
quotePostgresQuery,
|
|
4
4
|
quoteIdentifier,
|
|
@@ -263,3 +263,18 @@ describe("convertQuery / quotePostgresQuery (adversarial cases)", () => {
|
|
|
263
263
|
);
|
|
264
264
|
});
|
|
265
265
|
});
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
describe("dbManager input behavior", () => {
|
|
269
|
+
it("rejects a query when the manager has no database", async () => {
|
|
270
|
+
await expect(new dbManager().query("SELECT 1"))
|
|
271
|
+
.rejects.toThrow("Cannot read properties of undefined (reading 'query')");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("preserves parameter consumption and exhausted placeholders", () => {
|
|
275
|
+
const params = ["table", 1];
|
|
276
|
+
expect(convertQuery("SELECT ? FROM ?? WHERE a = ?", params))
|
|
277
|
+
.toEqual({ pgQuery: "SELECT $1 FROM 1 WHERE a = $2", pgParams: ["table", undefined] });
|
|
278
|
+
expect(params).toEqual([]);
|
|
279
|
+
});
|
|
280
|
+
});
|
package/tests/helpers/fakeDb.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type DbManager from "../../src/utils/dbManager";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Scripted fake for the dbManager abstraction used by the galadmin routes.
|
|
3
5
|
*
|
|
@@ -15,13 +17,15 @@
|
|
|
15
17
|
export type FakeQueryResult = { err?: any; rows?: any; fields?: any };
|
|
16
18
|
export type RecordedQuery = { sql: string; params: any[] };
|
|
17
19
|
|
|
18
|
-
export class FakeDb {
|
|
20
|
+
export class FakeDb implements DbManager {
|
|
19
21
|
dbType: "MYSQL" | "POSTGRESQL" = "MYSQL";
|
|
20
22
|
schemaRows: any[] = [];
|
|
21
23
|
queries: RecordedQuery[] = [];
|
|
22
24
|
private queue: FakeQueryResult[] = [];
|
|
23
25
|
defaultResult: FakeQueryResult = { err: null, rows: [] };
|
|
24
26
|
|
|
27
|
+
init(_config: Parameters<DbManager["init"]>[0]): void {}
|
|
28
|
+
|
|
25
29
|
queueResult(result: FakeQueryResult) {
|
|
26
30
|
this.queue.push({ err: null, rows: [], ...result });
|
|
27
31
|
return this;
|
|
@@ -35,15 +39,13 @@ export class FakeDb {
|
|
|
35
39
|
return this.queueResult({ err, rows: null });
|
|
36
40
|
}
|
|
37
41
|
|
|
38
|
-
async query(sql: string, params: any[] = []): Promise<
|
|
42
|
+
async query(sql: string, params: any[] = []): Promise<Awaited<ReturnType<DbManager["query"]>>> {
|
|
39
43
|
this.queries.push({ sql, params });
|
|
40
44
|
if (typeof sql === "string" && sql.includes("information_schema")) {
|
|
41
|
-
return { err: null, rows: this.schemaRows };
|
|
42
|
-
}
|
|
43
|
-
if (this.queue.length > 0) {
|
|
44
|
-
return this.queue.shift()!;
|
|
45
|
+
return { err: null, rows: this.schemaRows, fields: undefined };
|
|
45
46
|
}
|
|
46
|
-
|
|
47
|
+
const result = this.queue.length > 0 ? this.queue.shift()! : this.defaultResult;
|
|
48
|
+
return { err: null, rows: [], fields: undefined, ...result };
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
reset() {
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import * as request from "supertest";
|
|
2
|
+
import { canWriteView, roleOf, viewFromRequest } from "../../src/api/authorization";
|
|
3
|
+
import { buildAppForManager } from "../helpers/app";
|
|
4
|
+
import { baseConfig, fruitView } from "../helpers/fixtures";
|
|
5
|
+
import { DbHarness, describeIntegration, getMysqlHarness, resetTables } from "./dbHarness";
|
|
6
|
+
|
|
7
|
+
describeIntegration("authorization input boundaries", () => {
|
|
8
|
+
let harness: DbHarness;
|
|
9
|
+
beforeAll(async () => { harness = await getMysqlHarness(); });
|
|
10
|
+
beforeEach(async () => { await resetTables(harness); });
|
|
11
|
+
afterAll(async () => { await harness.stop(); });
|
|
12
|
+
|
|
13
|
+
it("uses one absent-role value and preserves declared role decisions", () => {
|
|
14
|
+
for (const user of [null, undefined, 0, "editor", {}, { role: null }, { role: 42 }]) {
|
|
15
|
+
const role = roleOf({ session: { user } });
|
|
16
|
+
expect(role).toBeUndefined();
|
|
17
|
+
expect(canWriteView({ roles: ["editor"] }, role)).toBe(false);
|
|
18
|
+
expect(canWriteView({}, role)).toBe(true);
|
|
19
|
+
}
|
|
20
|
+
expect(roleOf({ session: { user: { role: "root" } } })).toBe("root");
|
|
21
|
+
expect(canWriteView({ roles: [] }, "root")).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("preserves canonical index coercion, input precedence, and view identity", () => {
|
|
25
|
+
const view = { roles: ["editor"] };
|
|
26
|
+
const config = { views: [view] };
|
|
27
|
+
for (const viewIndex of [0, "0", [0], { toString: () => "0" }]) {
|
|
28
|
+
expect(viewFromRequest({ body: { viewIndex }, query: { viewIndex: 99 }, config })).toBe(view);
|
|
29
|
+
}
|
|
30
|
+
expect(viewFromRequest({ body: {}, query: { viewIndex: "0" }, config })).toBe(view);
|
|
31
|
+
for (const viewIndex of [null, {}, [], "01", "length", "__proto__", "constructor", ["length"]]) {
|
|
32
|
+
expect(viewFromRequest({ body: { viewIndex }, query: { viewIndex: "0" }, config })).toBeUndefined();
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("rejects non-view properties before HTTP writes and keeps valid index writes", async () => {
|
|
37
|
+
const app = buildAppForManager(harness.manager, baseConfig());
|
|
38
|
+
for (const viewIndex of ["length", "__proto__", "constructor", ["length"], "01", null]) {
|
|
39
|
+
await request(app).post("/api/createRecord").query({ viewIndex: "0" })
|
|
40
|
+
.send({ viewIndex, data: { fruitName: "rejected" } }).expect(403);
|
|
41
|
+
}
|
|
42
|
+
expect((await harness.query("SELECT * FROM fruits")).rows).toEqual([]);
|
|
43
|
+
for (const viewIndex of [0, "0", [0]]) {
|
|
44
|
+
await request(app).post("/api/createRecord").query({ viewIndex: "99" })
|
|
45
|
+
.send({ viewIndex, data: { fruitName: "allowed" } }).expect(200);
|
|
46
|
+
}
|
|
47
|
+
expect((await harness.query("SELECT * FROM fruits")).rows).toHaveLength(3);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("keeps anonymous HTTP writes denied for role-restricted views", async () => {
|
|
51
|
+
const config = baseConfig({ views: [fruitView({ roles: ["editor"] })] });
|
|
52
|
+
const app = buildAppForManager(harness.manager, config, null);
|
|
53
|
+
await request(app).post("/api/createRecord")
|
|
54
|
+
.send({ viewIndex: 0, data: { fruitName: "rejected" } }).expect(403);
|
|
55
|
+
expect((await harness.query("SELECT * FROM fruits")).rows).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
});
|