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/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/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/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/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/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() { +} 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", 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); + }); }); 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); + }); }); });