From 9dfebefc9b3d425bcdfa39620fcedd5c87874516 Mon Sep 17 00:00:00 2001 From: Florent Date: Wed, 23 Aug 2023 15:23:29 +0200 Subject: [PATCH 1/4] Dump the rule for ACL formula warnings (#639) When an ACL formula fails to be run, a warning is printed. However, it is painful to know which formula is concerned by the warning. ACL: if RHS is null, return false for "in" and "not in" --- .editorconfig | 2 +- app/common/GranularAccessClause.ts | 1 + app/server/lib/ACLFormula.ts | 4 +- app/server/lib/GranularAccess.ts | 1 + app/server/lib/PermissionInfo.ts | 3 +- test/server/lib/ACLFormula.ts | 87 ++++++++++++++++++++++++------ 6 files changed, 77 insertions(+), 21 deletions(-) diff --git a/.editorconfig b/.editorconfig index be98d56e..af85d866 100644 --- a/.editorconfig +++ b/.editorconfig @@ -6,7 +6,7 @@ root = true # Unix-style newlines with a newline ending every file [*.{ts,js}] end_of_line = lf -insert_final_newline = false +insert_final_newline = true charset = utf-8 # indent with 2 spaces indent_style = space diff --git a/app/common/GranularAccessClause.ts b/app/common/GranularAccessClause.ts index 402e843e..5509314b 100644 --- a/app/common/GranularAccessClause.ts +++ b/app/common/GranularAccessClause.ts @@ -58,6 +58,7 @@ export interface AclMatchInput { user: UserInfo; rec?: InfoView; newRec?: InfoView; + docId?: string; } /** diff --git a/app/server/lib/ACLFormula.ts b/app/server/lib/ACLFormula.ts index e6f824d0..d1dd13ea 100644 --- a/app/server/lib/ACLFormula.ts +++ b/app/server/lib/ACLFormula.ts @@ -55,8 +55,8 @@ function _compileNode(parsedAclFormula: ParsedAclFormula): AclEvalFunc { case 'GtE': return _compileAndCombine(args, ([a, b]) => a >= b); case 'Is': return _compileAndCombine(args, ([a, b]) => a === b); case 'IsNot': return _compileAndCombine(args, ([a, b]) => a !== b); - case 'In': return _compileAndCombine(args, ([a, b]) => b.includes(a)); - case 'NotIn': return _compileAndCombine(args, ([a, b]) => !b.includes(a)); + case 'In': return _compileAndCombine(args, ([a, b]) => Boolean(b?.includes(a))); + case 'NotIn': return _compileAndCombine(args, ([a, b]) => !b?.includes(a)); case 'List': return _compileAndCombine(args, (values) => values); case 'Const': return constant(parsedAclFormula[1] as CellValue); case 'Name': { diff --git a/app/server/lib/GranularAccess.ts b/app/server/lib/GranularAccess.ts index 460a30d0..2da069f8 100644 --- a/app/server/lib/GranularAccess.ts +++ b/app/server/lib/GranularAccess.ts @@ -342,6 +342,7 @@ export class GranularAccess implements GranularAccessForBundle { public async inputs(docSession: OptDocSession): Promise { return { user: await this._getUser(docSession), + docId: this._docId }; } diff --git a/app/server/lib/PermissionInfo.ts b/app/server/lib/PermissionInfo.ts index 6a1816cf..897dc8d5 100644 --- a/app/server/lib/PermissionInfo.ts +++ b/app/server/lib/PermissionInfo.ts @@ -219,7 +219,8 @@ function evaluateRule(ruleSet: RuleSet, input: AclMatchInput): PartialPermission // Anything it would explicitly allow, no longer allow through this rule. // Anything it would explicitly deny, go ahead and deny. pset = mergePartialPermissions(pset, mapValues(rule.permissions, val => (val === 'allow' ? "" : val))); - log.warn("ACLRule for %s failed: %s", ruleSet.tableId, e.message); + const prefixedTableName = input.docId ? `${input.docId}.${ruleSet.tableId}` : ruleSet.tableId; + log.warn("ACLRule for %s (`%s`) failed: %s", prefixedTableName, rule.aclFormula, e.stack); } } } diff --git a/test/server/lib/ACLFormula.ts b/test/server/lib/ACLFormula.ts index a9f82e1f..1f913276 100644 --- a/test/server/lib/ACLFormula.ts +++ b/test/server/lib/ACLFormula.ts @@ -13,7 +13,7 @@ describe('ACLFormula', function() { // Turn off logging for this test, and restore afterwards. testUtils.setTmpLogLevel('error'); - const docTools = createDocTools(); + const docTools = createDocTools({ persistAcrossCases: true }); const fakeSession = makeExceptionalDocSession('system'); function getInfoView(row: Record): InfoView { @@ -25,47 +25,72 @@ describe('ACLFormula', function() { const V = getInfoView; // A shortcut. - it('should correctly interpret parsed ACL formulas', async function() { + type SetAndCompile = (aclFormula: string) => Promise; + let setAndCompile: SetAndCompile; + + before(async function () { const docName = 'docdata1'; const activeDoc1 = await docTools.createDoc(docName); - let compiled: AclMatchFunc; const resourceRef = (await activeDoc1.applyUserActions(fakeSession, [['AddRecord', '_grist_ACLResources', null, {tableId: '*', colIds: '*'}]])).retValues[0]; const ruleRef = (await activeDoc1.applyUserActions(fakeSession, [['AddRecord', '_grist_ACLRules', null, {resource: resourceRef}]])).retValues[0]; - async function setAndCompile(aclFormula: string): Promise { + setAndCompile = async function setAndCompile(aclFormula) { await activeDoc1.applyUserActions(fakeSession, [['UpdateRecord', '_grist_ACLRules', ruleRef, {aclFormula}]]); const {tableData} = await activeDoc1.fetchQuery( fakeSession, {tableId: '_grist_ACLRules', filters: {id: [ruleRef]}}); assert(tableData[3].aclFormulaParsed, "Expected aclFormulaParsed to be populated"); const parsedFormula = String(tableData[3].aclFormulaParsed[0]); return compileAclFormula(JSON.parse(parsedFormula)); - } + }; + }); - compiled = await setAndCompile("user.Email == 'X@'"); + it('should handle a comparison', async function() { + const compiled = await setAndCompile("user.Email == 'X@'"); assert.equal(compiled({user: new User({Email: 'X@'})}), true); assert.equal(compiled({user: new User({Email: 'Y@'})}), false); assert.equal(compiled({user: new User({Email: 'X'}), rec: V({Email: 'Y@'})}), false); assert.equal(compiled({user: new User({Name: 'X@'})}), false); + }); - compiled = await setAndCompile("user.Role in ('editors', 'owners')"); + it('should handle the "in" operator', async function () { + const compiled = await setAndCompile("user.Role in ('editors', 'owners')"); assert.equal(compiled({user: new User({Role: 'editors'})}), true); assert.equal(compiled({user: new User({Role: 'owners'})}), true); assert.equal(compiled({user: new User({Role: 'viewers'})}), false); assert.equal(compiled({user: new User({Role: null})}), false); assert.equal(compiled({user: new User({})}), false); + }); - // Opposite of the previous formula. - compiled = await setAndCompile("user.Role not in ('editors', 'owners')"); + it('should handle the "not in" operator', async function () { + const compiled = await setAndCompile("user.Role not in ('editors', 'owners')"); assert.equal(compiled({user: new User({Role: 'editors'})}), false); assert.equal(compiled({user: new User({Role: 'owners'})}), false); assert.equal(compiled({user: new User({Role: 'viewers'})}), true); assert.equal(compiled({user: new User({Role: null})}), true); assert.equal(compiled({user: new User({})}), true); + }); + + [{ + op: 'in' + }, { + op: 'not in' + }].forEach(ctx => { + it(`should handle the "${ctx.op}" operator with a string RHS to check if substring exist`, async function() { + const compiled = await setAndCompile(`user.Name ${ctx.op} 'FooBar'`); + assert.equal(compiled({user: new User({Name: 'FooBar'})}), ctx.op === 'in'); + assert.equal(compiled({user: new User({Name: 'Foo'})}), ctx.op === 'in'); + assert.equal(compiled({user: new User({Name: 'Bar'})}), ctx.op === 'in'); + assert.equal(compiled({user: new User({Name: 'bar'})}), ctx.op === 'not in'); + assert.equal(compiled({user: new User({Name: 'qux'})}), ctx.op === 'not in'); + assert.equal(compiled({user: new User({Name: null})}), ctx.op === 'not in'); + }); + }); - compiled = await setAndCompile("rec.office == 'Seattle' and user.email in ['sally@', 'xie@']"); + it('should handle the "and" operator', async function () { + const compiled = await setAndCompile("rec.office == 'Seattle' and user.email in ['sally@', 'xie@']"); assert.throws(() => compiled({user: new User({email: 'xie@'})}), /Missing row data 'rec'/); assert.equal(compiled({user: new User({email: 'xie@'}), rec: V({})}), false); assert.equal(compiled({user: new User({email: 'xie@'}), rec: V({office: null})}), false); @@ -75,9 +100,19 @@ describe('ACLFormula', function() { assert.equal(compiled({user: new User({email: 'sally@'}), rec: V({office: 'Chicago'})}), false); assert.equal(compiled({user: new User({email: null}), rec: V({office: null})}), false); assert.equal(compiled({user: new User({}), rec: V({})}), false); + }); + + it('should handle the "or" operator', async function () { + const compiled = await setAndCompile('user.Email=="X@" or user.Email is None'); + assert.equal(compiled({user: new User({Email: 'X@'})}), true); + assert.equal(compiled({user: new User({})}), true); + assert.equal(compiled({user: new User({Email: 'Y@'})}), false); + }); + + it('should handle a complex combination of operators', async function () { // This is not particularly meaningful, but involves more combinations. - compiled = await setAndCompile( + const compiled = await setAndCompile( "user.IsAdmin or rec.assigned is None or (not newRec.HasDuplicates and rec.StatusIndex <= newRec.StatusIndex)"); assert.equal(compiled({user: new User({IsAdmin: true})}), true); assert.equal(compiled({user: new User({IsAdmin: 17})}), true); @@ -102,9 +137,10 @@ describe('ACLFormula', function() { newRec: V({HasDuplicates: false, StatusIndex: 1})}), false); assert.equal(compiled({user: new User({IsAdmin: false}), rec: V({assigned: 1, StatusIndex: 1}), newRec: V({HasDuplicates: true, StatusIndex: 17})}), false); + }); - // Test arithmetic. - compiled = await setAndCompile( + it('should handle arithmetic tests', async function () { + const compiled = await setAndCompile( "rec.A <= rec.B + 1 and rec.A >= rec.B - 1 and rec.A < rec.C * 2.5 and rec.A > rec.C / 2.5 and rec.A % 2 != 0"); assert.equal(compiled({user: new User({}), rec: V({A: 3, B: 3, C: 3})}), true); assert.equal(compiled({user: new User({}), rec: V({A: 3, B: 4, C: 3})}), true); @@ -117,9 +153,10 @@ describe('ACLFormula', function() { assert.equal(compiled({user: new User({}), rec: V({A: 1.3, B: 1, C: 3})}), true); assert.equal(compiled({user: new User({}), rec: V({A: 7.4, B: 7, C: 3})}), true); assert.equal(compiled({user: new User({}), rec: V({A: 7.5, B: 7, C: 3})}), false); + }); - // Test is/is-not. - compiled = await setAndCompile( + it('should handle "is" and "is not" operators', async function () { + const compiled = await setAndCompile( "rec.A is True or rec.B is not False"); assert.equal(compiled({user: new User({}), rec: V({A: true})}), true); assert.equal(compiled({user: new User({}), rec: V({A: 2})}), true); @@ -130,9 +167,10 @@ describe('ACLFormula', function() { assert.equal(compiled({user: new User({}), rec: V({A: null, B: 2})}), true); assert.equal(compiled({user: new User({}), rec: V({A: null, B: false})}), false); assert.equal(compiled({user: new User({}), rec: V({A: null, B: 0})}), true); + }); - // Test nested attribute lookups. - compiled = await setAndCompile('user.office.city == "New York"'); + it('should handle nested attribute lookups', async function () { + const compiled = await setAndCompile('user.office.city == "New York"'); assert.equal(compiled({user: new User({office: V({city: "New York"})})}), true); assert.equal(compiled({user: new User({office: V({city: "Boston"})})}), false); assert.equal(compiled({user: new User({office: V({city: null})})}), false); @@ -140,4 +178,19 @@ describe('ACLFormula', function() { assert.equal(compiled({user: new User({office: 5})}), false); assert.throws(() => compiled({user: new User({office: null})}), /No value for 'user.office'/); }); + + it('should handle "in" and "not in" when RHS is nullish', async function() { + let compiled = await setAndCompile('user.Email in rec.emails'); + const user = new User({Email: 'X@'}); + assert.equal(compiled({user, rec: V({emails: null})}), false); + assert.equal(compiled({user, rec: V({unrelated: 'X@'})}), false); + assert.equal(compiled({user, rec: V({emails: 'X@'})}), true); + compiled = await setAndCompile('user.Email not in rec.emails'); + assert.equal(compiled({user, rec: V({emails: null})}), true); + assert.equal(compiled({user, rec: V({unrelated: 'X@'})}), true); + assert.equal(compiled({user, rec: V({emails: 'X@'})}), false); + compiled = await setAndCompile('(user.Email in rec.emails) == (user.Name in rec.emails)'); + assert.equal(compiled({user, rec: V({emails: null})}), true); + assert.equal(compiled({user, rec: V({emails: 'X@'})}), false); + }); }); From ee31764b839a2b05c8cbed27dda91ee1423d1e1c Mon Sep 17 00:00:00 2001 From: Florent Date: Thu, 24 Aug 2023 14:33:53 +0200 Subject: [PATCH 2/4] DocApi: Implement DELETE on columns (#601) (#640) * Factorize generateDocAndUrl * Add describe for regrouping /records --- app/plugin/TableOperationsImpl.ts | 7 +- app/server/lib/DocApi.ts | 11 ++ test/server/lib/DocApi.ts | 220 ++++++++++++++++++------------ 3 files changed, 148 insertions(+), 90 deletions(-) diff --git a/app/plugin/TableOperationsImpl.ts b/app/plugin/TableOperationsImpl.ts index 27c0de43..6e2ca933 100644 --- a/app/plugin/TableOperationsImpl.ts +++ b/app/plugin/TableOperationsImpl.ts @@ -200,17 +200,20 @@ export async function handleSandboxErrorOnPlatform( platform.throwError('', `Invalid row id ${match[1]}`, 400); } match = message.match( - /\[Sandbox] (?:KeyError u?'(?:Table \w+ has no column )?|ValueError No such table: )(\w+)/ + // eslint-disable-next-line max-len + /\[Sandbox] (?:KeyError u?'(?:Table \w+ has no column )?|ValueError No such table: |ValueError No such column: )([\w.]+)/ ); if (match) { if (match[1] === tableId) { platform.throwError('', `Table not found "${tableId}"`, 404); } else if (colNames.includes(match[1])) { platform.throwError('', `Invalid column "${match[1]}"`, 400); + } else if (colNames.includes(match[1].replace(`${tableId}.`, ''))) { + platform.throwError('', `Table or column not found "${match[1]}"`, 404); } } platform.throwError('', `Error manipulating data: ${message}`, 400); } throw err; } -} +} \ No newline at end of file diff --git a/app/server/lib/DocApi.ts b/app/server/lib/DocApi.ts index 33cc558c..378243bf 100644 --- a/app/server/lib/DocApi.ts +++ b/app/server/lib/DocApi.ts @@ -746,6 +746,17 @@ export class DocWorkerApi { }) ); + this._app.delete('/api/docs/:docId/tables/:tableId/columns/:colId', canEdit, + withDoc(async (activeDoc, req, res) => { + const {tableId, colId} = req.params; + const actions = [ [ 'RemoveColumn', tableId, colId ] ]; + await handleSandboxError(tableId, [colId], + activeDoc.applyUserActions(docSessionFromRequest(req), actions) + ); + res.json(null); + }) + ); + // Add a new webhook and trigger this._app.post('/api/docs/:docId/webhooks', isOwner, validate(WebhookSubscribeCollection), withDoc(async (activeDoc, req, res) => { diff --git a/test/server/lib/DocApi.ts b/test/server/lib/DocApi.ts index 35348f13..4aded15c 100644 --- a/test/server/lib/DocApi.ts +++ b/test/server/lib/DocApi.ts @@ -877,123 +877,167 @@ function testDocApi() { assert.equal(resp.status, 200); }); - describe("PUT /docs/{did}/columns", function () { - - async function generateDocAndUrl() { + describe("/docs/{did}/tables/{tid}/columns", function () { + async function generateDocAndUrl(docName: string = "Dummy") { const wid = (await userApi.getOrgWorkspaces('current')).find((w) => w.name === 'Private')!.id; - const docId = await userApi.newDoc({name: 'ColumnsPut'}, wid); + const docId = await userApi.newDoc({name: docName}, wid); const url = `${serverUrl}/api/docs/${docId}/tables/Table1/columns`; return { url, docId }; } - async function getColumnFieldsMapById(url: string, params: any) { - const result = await axios.get(url, {...chimpy, params}); - assert.equal(result.status, 200); - return new Map( - result.data.columns.map( - ({id, fields}: {id: string, fields: object}) => [id, fields] - ) - ); - } + describe("PUT /docs/{did}/tables/{tid}/columns", function () { + async function getColumnFieldsMapById(url: string, params: any) { + const result = await axios.get(url, {...chimpy, params}); + assert.equal(result.status, 200); + return new Map( + result.data.columns.map( + ({id, fields}: {id: string, fields: object}) => [id, fields] + ) + ); + } - async function checkPut( - columns: [RecordWithStringId, ...RecordWithStringId[]], - params: Record, - expectedFieldsByColId: Record, - opts?: { getParams?: any } - ) { - const {url} = await generateDocAndUrl(); - const body: ColumnsPut = { columns }; - const resp = await axios.put(url, body, {...chimpy, params}); - assert.equal(resp.status, 200); - const fieldsByColId = await getColumnFieldsMapById(url, opts?.getParams); + async function checkPut( + columns: [RecordWithStringId, ...RecordWithStringId[]], + params: Record, + expectedFieldsByColId: Record, + opts?: { getParams?: any } + ) { + const {url} = await generateDocAndUrl('ColumnsPut'); + const body: ColumnsPut = { columns }; + const resp = await axios.put(url, body, {...chimpy, params}); + assert.equal(resp.status, 200); + const fieldsByColId = await getColumnFieldsMapById(url, opts?.getParams); - assert.deepEqual( - [...fieldsByColId.keys()], - Object.keys(expectedFieldsByColId), - "The updated table should have the expected columns" - ); + assert.deepEqual( + [...fieldsByColId.keys()], + Object.keys(expectedFieldsByColId), + "The updated table should have the expected columns" + ); - for (const [colId, expectedFields] of Object.entries(expectedFieldsByColId)) { - assert.deepInclude(fieldsByColId.get(colId), expectedFields); + for (const [colId, expectedFields] of Object.entries(expectedFieldsByColId)) { + assert.deepInclude(fieldsByColId.get(colId), expectedFields); + } } - } - const COLUMN_TO_ADD = { - id: "Foo", - fields: { - type: "Text", - label: "FooLabel", - } - }; + const COLUMN_TO_ADD = { + id: "Foo", + fields: { + type: "Text", + label: "FooLabel", + } + }; - const COLUMN_TO_UPDATE = { - id: "A", - fields: { - type: "Numeric", - colId: "NewA" - } - }; + const COLUMN_TO_UPDATE = { + id: "A", + fields: { + type: "Numeric", + colId: "NewA" + } + }; - it('should create new columns', async function () { - await checkPut([COLUMN_TO_ADD], {}, { - A: {}, B: {}, C: {}, Foo: COLUMN_TO_ADD.fields + it('should create new columns', async function () { + await checkPut([COLUMN_TO_ADD], {}, { + A: {}, B: {}, C: {}, Foo: COLUMN_TO_ADD.fields + }); }); - }); - it('should update existing columns and create new ones', async function () { - await checkPut([COLUMN_TO_ADD, COLUMN_TO_UPDATE], {}, { - NewA: {type: "Numeric", label: "A"}, B: {}, C: {}, Foo: COLUMN_TO_ADD.fields + it('should update existing columns and create new ones', async function () { + await checkPut([COLUMN_TO_ADD, COLUMN_TO_UPDATE], {}, { + NewA: {type: "Numeric", label: "A"}, B: {}, C: {}, Foo: COLUMN_TO_ADD.fields + }); }); - }); - it('should only update existing columns when noadd is set', async function () { - await checkPut([COLUMN_TO_ADD, COLUMN_TO_UPDATE], {noadd: "1"}, { - NewA: {type: "Numeric"}, B: {}, C: {} + it('should only update existing columns when noadd is set', async function () { + await checkPut([COLUMN_TO_ADD, COLUMN_TO_UPDATE], {noadd: "1"}, { + NewA: {type: "Numeric"}, B: {}, C: {} + }); }); - }); - it('should only add columns when noupdate is set', async function () { - await checkPut([COLUMN_TO_ADD, COLUMN_TO_UPDATE], {noupdate: "1"}, { - A: {type: "Any"}, B: {}, C: {}, Foo: COLUMN_TO_ADD.fields + it('should only add columns when noupdate is set', async function () { + await checkPut([COLUMN_TO_ADD, COLUMN_TO_UPDATE], {noupdate: "1"}, { + A: {type: "Any"}, B: {}, C: {}, Foo: COLUMN_TO_ADD.fields + }); }); - }); - it('should remove existing columns if replaceall is set', async function () { - await checkPut([COLUMN_TO_ADD, COLUMN_TO_UPDATE], {replaceall: "1"}, { - NewA: {type: "Numeric"}, Foo: COLUMN_TO_ADD.fields + it('should remove existing columns if replaceall is set', async function () { + await checkPut([COLUMN_TO_ADD, COLUMN_TO_UPDATE], {replaceall: "1"}, { + NewA: {type: "Numeric"}, Foo: COLUMN_TO_ADD.fields + }); }); - }); - it('should NOT remove hidden columns even when replaceall is set', async function () { - await checkPut([COLUMN_TO_ADD, COLUMN_TO_UPDATE], {replaceall: "1"}, { - manualSort: {type: "ManualSortPos"}, NewA: {type: "Numeric"}, Foo: COLUMN_TO_ADD.fields - }, { getParams: { hidden: true } }); - }); + it('should NOT remove hidden columns even when replaceall is set', async function () { + await checkPut([COLUMN_TO_ADD, COLUMN_TO_UPDATE], {replaceall: "1"}, { + manualSort: {type: "ManualSortPos"}, NewA: {type: "Numeric"}, Foo: COLUMN_TO_ADD.fields + }, { getParams: { hidden: true } }); + }); - it('should forbid update by viewers', async function () { - // given - const { url, docId } = await generateDocAndUrl(); - await userApi.updateDocPermissions(docId, {users: {'kiwi@getgrist.com': 'viewers'}}); + it('should forbid update by viewers', async function () { + // given + const { url, docId } = await generateDocAndUrl('ColumnsPut'); + await userApi.updateDocPermissions(docId, {users: {'kiwi@getgrist.com': 'viewers'}}); - // when - const resp = await axios.put(url, { columns: [ COLUMN_TO_ADD ] }, kiwi); + // when + const resp = await axios.put(url, { columns: [ COLUMN_TO_ADD ] }, kiwi); - // then - assert.equal(resp.status, 403); + // then + assert.equal(resp.status, 403); + }); + + it("should return 404 when table is not found", async function() { + // given + const { url } = await generateDocAndUrl('ColumnsPut'); + const notFoundUrl = url.replace("Table1", "NonExistingTable"); + + // when + const resp = await axios.put(notFoundUrl, { columns: [ COLUMN_TO_ADD ] }, chimpy); + + // then + assert.equal(resp.status, 404); + assert.equal(resp.data.error, 'Table not found "NonExistingTable"'); + }); }); - it("should return 404 when table is not found", async function() { - // given - const { url } = await generateDocAndUrl(); - const notFoundUrl = url.replace("Table1", "NonExistingTable"); + describe("DELETE /docs/{did}/tables/{tid}/columns/{colId}", function () { + it('should delete some column', async function() { + const {url} = await generateDocAndUrl('ColumnDelete'); + const deleteUrl = url + '/A'; + const resp = await axios.delete(deleteUrl, chimpy); - // when - const resp = await axios.put(notFoundUrl, { columns: [ COLUMN_TO_ADD ] }, chimpy); + assert.equal(resp.status, 200, "Should succeed in requesting column deletion"); - // then - assert.equal(resp.status, 404); - assert.equal(resp.data.error, 'Table not found "NonExistingTable"'); + const listColResp = await axios.get(url, { ...chimpy, params: { hidden: true } }); + assert.equal(listColResp.status, 200, "Should succeed in listing columns"); + + const columnIds = listColResp.data.columns.map(({id}: {id: string}) => id).sort(); + assert.deepEqual(columnIds, ["B", "C", "manualSort"]); + }); + + it('should return 404 if table not found', async function() { + const {url} = await generateDocAndUrl('ColumnDelete'); + const deleteUrl = url.replace("Table1", "NonExistingTable") + '/A'; + const resp = await axios.delete(deleteUrl, chimpy); + + assert.equal(resp.status, 404); + assert.equal(resp.data.error, 'Table or column not found "NonExistingTable.A"'); + }); + + it('should return 404 if column not found', async function() { + const {url} = await generateDocAndUrl('ColumnDelete'); + const deleteUrl = url + '/NonExistingColId'; + const resp = await axios.delete(deleteUrl, chimpy); + + assert.equal(resp.status, 404); + assert.equal(resp.data.error, 'Table or column not found "Table1.NonExistingColId"'); + }); + + it('should forbid column deletion by viewers', async function() { + const {url, docId} = await generateDocAndUrl('ColumnDelete'); + await userApi.updateDocPermissions(docId, {users: {'kiwi@getgrist.com': 'viewers'}}); + const deleteUrl = url + '/A'; + const resp = await axios.delete(deleteUrl, kiwi); + + assert.equal(resp.status, 403); + }); }); }); From 86070559e14a7a29da785923666ee62213c316fc Mon Sep 17 00:00:00 2001 From: guilherme15990 Date: Sat, 26 Aug 2023 19:50:53 +0000 Subject: [PATCH 3/4] Translated using Weblate (Portuguese) Currently translated at 97.6% (929 of 951 strings) Translation: Grist/client Translate-URL: https://hosted.weblate.org/projects/grist/client/pt/ --- static/locales/pt.client.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/static/locales/pt.client.json b/static/locales/pt.client.json index ca5a35a1..60c96411 100644 --- a/static/locales/pt.client.json +++ b/static/locales/pt.client.json @@ -747,7 +747,9 @@ "Support Grist": "Suporte Grist", "Activation": "Ativação", "Billing Account": "Conta de faturação", - "Upgrade Plan": "Atualizar o Plano" + "Upgrade Plan": "Atualizar o Plano", + "Sign In": "Entrar", + "Sign Up": "Inscrever-se" }, "ViewAsDropdown": { "View As": "Ver como", From 7aebdd15f6315b404d22a9dbbd1d0945367ec217 Mon Sep 17 00:00:00 2001 From: Paul Fitzpatrick Date: Mon, 28 Aug 2023 08:11:01 -0400 Subject: [PATCH 4/4] tweak XLSX export worker to make Piscina happy under Electon (#646) Adds a dummy default export to the worker exporter script used for producing XLSX. This method exists only to make Piscina happier. With it, Piscina will load this file using a regular require(), which under Electron will deal fine with Electron's ASAR app bundle. Without it, Piscina will try fancier methods that aren't at the time of writing correctly patched to deal with an ASAR app bundle, and so report that this file doesn't exist instead of exporting an XLSX file. I tried various other solutions such as upgrading Electron, unpacking various files, patching Piscina, and this was overall the simplest. See https://github.com/gristlabs/grist-electron/issues/9 --- app/server/lib/workerExporter.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/server/lib/workerExporter.ts b/app/server/lib/workerExporter.ts index 153fa1af..bb8f6c12 100644 --- a/app/server/lib/workerExporter.ts +++ b/app/server/lib/workerExporter.ts @@ -224,3 +224,14 @@ export function sanitizeWorksheetName(tableName: string): string { .replace(/^['\s]+/, '') .replace(/['\s]+$/, ''); } + +// This method exists only to make Piscina happier. With it, +// Piscina will load this file using a regular require(), +// which under Electron will deal fine with Electron's ASAR +// app bundle. Without it, Piscina will try fancier methods +// that aren't at the time of writing correctly patched to +// deal with an ASAR app bundle, and so report that this +// file doesn't exist instead of exporting an XLSX file. +// https://github.com/gristlabs/grist-electron/issues/9 +export default function doNothing() { +}