diff --git a/app/common/Triggers-ti.ts b/app/common/Triggers-ti.ts index a72a15bb..44271718 100644 --- a/app/common/Triggers-ti.ts +++ b/app/common/Triggers-ti.ts @@ -14,6 +14,10 @@ export const WebhookFields = t.iface([], { "memo": t.opt("string"), }); +export const Webhook = t.iface([], { + "fields": "WebhookFields", +}); + export const WebhookBatchStatus = t.union(t.lit('success'), t.lit('failure'), t.lit('rejected')); export const WebhookStatus = t.union(t.lit('idle'), t.lit('sending'), t.lit('retrying'), t.lit('postponed'), t.lit('error'), t.lit('invalid')); @@ -27,6 +31,10 @@ export const WebhookSubscribe = t.iface([], { "memo": t.opt("string"), }); +export const WebhookSubscribeCollection = t.iface([], { + "webhooks": t.array("Webhook"), +}); + export const WebhookSummary = t.iface([], { "id": "string", "fields": t.iface([], { @@ -80,9 +88,11 @@ export const WebhookUsage = t.iface([], { const exportedTypeSuite: t.ITypeSuite = { WebhookFields, + Webhook, WebhookBatchStatus, WebhookStatus, WebhookSubscribe, + WebhookSubscribeCollection, WebhookSummary, WebhookUpdate, WebhookPatch, diff --git a/app/common/Triggers.ts b/app/common/Triggers.ts index e6cb8a06..54eea8cd 100644 --- a/app/common/Triggers.ts +++ b/app/common/Triggers.ts @@ -1,3 +1,11 @@ +export interface WebhookSubscribeCollection{ + webhooks: Array +} + +export interface Webhook { + fields: WebhookFields; +} + export interface WebhookFields { url: string; eventTypes: Array<"add"|"update">; @@ -24,6 +32,8 @@ export interface WebhookSubscribe { memo?: string; } + + export interface WebhookSummary { id: string; fields: { diff --git a/app/server/lib/DocApi.ts b/app/server/lib/DocApi.ts index 2b0acddc..4edfbb31 100644 --- a/app/server/lib/DocApi.ts +++ b/app/server/lib/DocApi.ts @@ -1,13 +1,8 @@ +import {concatenateSummaries, summarizeAction} from "app/common/ActionSummarizer"; import {createEmptyActionSummary} from "app/common/ActionSummary"; import {ApiError, LimitType} from 'app/common/ApiError'; import {BrowserSettings} from "app/common/BrowserSettings"; -import { - BulkColValues, - ColValues, - fromTableDataAction, - TableColValues, - TableRecordValue -} from 'app/common/DocActions'; +import {BulkColValues, ColValues, fromTableDataAction, TableColValues, TableRecordValue} from 'app/common/DocActions'; import {isRaisedException} from "app/common/gristTypes"; import {buildUrlId, parseUrlId} from "app/common/gristUrls"; import {isAffirmative} from "app/common/gutil"; @@ -15,8 +10,9 @@ import {SchemaTypes} from "app/common/schema"; import {SortFunc} from 'app/common/SortFunc'; import {Sort} from 'app/common/SortSpec'; import {MetaRowRecord} from 'app/common/TableData'; -import {DocReplacementOptions, DocState, DocStateComparison, DocStates, NEW_DOCUMENT_CODE} from 'app/common/UserAPI'; +import {WebhookFields} from "app/common/Triggers"; import TriggersTI from 'app/common/Triggers-ti'; +import {DocReplacementOptions, DocState, DocStateComparison, DocStates, NEW_DOCUMENT_CODE} from 'app/common/UserAPI'; import {HomeDBManager, makeDocAuthResult} from 'app/gen-server/lib/HomeDBManager'; import * as Types from "app/plugin/DocApiTypes"; import DocApiTypesTI from "app/plugin/DocApiTypes-ti"; @@ -28,8 +24,8 @@ import { TableOperationsImpl, TableOperationsPlatform } from 'app/plugin/TableOperationsImpl'; -import {concatenateSummaries, summarizeAction} from "app/common/ActionSummarizer"; import {ActiveDoc, colIdToRef as colIdToReference, tableIdToRef} from "app/server/lib/ActiveDoc"; +import {sendForCompletion} from 'app/server/lib/Assistance'; import { assertAccess, getOrSetDocAuth, @@ -44,8 +40,8 @@ import {DocWorker} from "app/server/lib/DocWorker"; import {IDocWorkerMap} from "app/server/lib/DocWorkerMap"; import {DownloadOptions, parseExportParameters} from "app/server/lib/Export"; import {downloadCSV} from "app/server/lib/ExportCSV"; -import {downloadXLSX} from "app/server/lib/ExportXLSX"; import {collectTableSchemaInFrictionlessFormat} from "app/server/lib/ExportTableSchema"; +import {downloadXLSX} from "app/server/lib/ExportXLSX"; import {expressWrap} from 'app/server/lib/expressWrap'; import {filterDocumentInPlace} from "app/server/lib/filterUtils"; import {googleAuthTokenMiddleware} from "app/server/lib/GoogleAuth"; @@ -110,7 +106,8 @@ for (const checker of [RecordsPatch, RecordsPost, RecordsPut, ColumnsPost, Colum // Schema validators for api endpoints that creates or updates records. const { WebhookPatch, - WebhookSubscribe + WebhookSubscribe, + WebhookSubscribeCollection, } = t.createCheckers(TriggersTI); /** @@ -240,13 +237,77 @@ export class DocWorkerApi { activeDoc.fetchMetaTables(docSessionFromRequest(req))); } - async function getWebhookSettings(activeDoc: ActiveDoc, req: RequestWithLogin, webhookId: string|null) { + const registerWebhook = async (activeDoc: ActiveDoc, req: RequestWithLogin, webhook: WebhookFields) => { + const {fields, url} = await getWebhookSettings(activeDoc, req, null, webhook); + if (!fields.eventTypes?.length) { + throw new ApiError(`eventTypes must be a non-empty array`, 400); + } + if (!isUrlAllowed(url)) { + throw new ApiError('Provided url is forbidden', 403); + } + if (!fields.tableRef) { + throw new ApiError(`tableId is required`, 400); + } + + const unsubscribeKey = uuidv4(); + const webhookSecret: WebHookSecret = {unsubscribeKey, url}; + const secretValue = JSON.stringify(webhookSecret); + const webhookId = (await this._dbManager.addSecret(secretValue, activeDoc.docName)).id; + + try { + + const webhookAction: WebhookAction = {type: "webhook", id: webhookId}; + const sandboxRes = await handleSandboxError("_grist_Triggers", [], activeDoc.applyUserActions( + docSessionFromRequest(req), + [['AddRecord', "_grist_Triggers", null, { + enabled: true, + ...fields, + actions: JSON.stringify([webhookAction]) + }]])); + return { + unsubscribeKey, + triggerId: sandboxRes.retValues[0], + webhookId, + }; + + } catch (err) { + + // remove webhook + await this._dbManager.removeWebhook(webhookId, activeDoc.docName, '', false); + throw err; + } finally { + await activeDoc.sendWebhookNotification(); + } + }; + + const removeWebhook = async (activeDoc: ActiveDoc, req: RequestWithLogin, res: Response) => { + const {unsubscribeKey} = req.body as WebhookSubscription; + const webhookId = req.params.webhookId??req.body.webhookId; + + // owner does not need to provide unsubscribeKey + const checkKey = !(await this._isOwner(req)); + const triggerRowId = activeDoc.triggers.getWebhookTriggerRecord(webhookId).id; + // Validate unsubscribeKey before deleting trigger from document + await this._dbManager.removeWebhook(webhookId, activeDoc.docName, unsubscribeKey, checkKey); + activeDoc.triggers.webhookDeleted(webhookId); + + await handleSandboxError("_grist_Triggers", [], activeDoc.applyUserActions( + docSessionFromRequest(req), + [['RemoveRecord', "_grist_Triggers", triggerRowId]])); + + await activeDoc.sendWebhookNotification(); + + res.json({success: true}); + }; + + async function getWebhookSettings(activeDoc: ActiveDoc, req: RequestWithLogin, + webhookId: string|null, webhook: WebhookFields) { const metaTables = await getMetaTables(activeDoc, req); const tablesTable = activeDoc.docData!.getMetaTable("_grist_Tables"); const trigger = webhookId ? activeDoc.triggers.getWebhookTriggerRecord(webhookId) : undefined; let currentTableId = trigger ? tablesTable.getValue(trigger.tableRef, 'tableId')! : undefined; - const {url, eventTypes, isReadyColumn, name} = req.body; - const tableId = req.params.tableId || req.body.tableId; + const {url, eventTypes, isReadyColumn, name} = webhook; + const tableId = req.params.tableId || webhook.tableId; const fields: Partial = {}; if (url && !isUrlAllowed(url)) { @@ -282,7 +343,7 @@ export class DocWorkerApi { } // assign other field properties - Object.assign(fields, _.pick(req.body, ['enabled', 'memo'])); + Object.assign(fields, _.pick(webhook, ['enabled', 'memo'])); if (name) { fields.label = name; } @@ -622,78 +683,48 @@ export class DocWorkerApi { ); // Add a new webhook and trigger + this._app.post('/api/docs/:docId/webhooks', isOwner, validate(WebhookSubscribeCollection), + withDoc(async (activeDoc, req, res) => { + const registeredWebhooks: Array = []; + for(const webhook of req.body.webhooks) { + const registeredWebhook = await registerWebhook(activeDoc, req, webhook.fields); + registeredWebhooks.push(registeredWebhook); + } + res.json({webhooks: registeredWebhooks.map(rw=> { + return {id: rw.webhookId}; + })}); + }) + ); + + /** + @deprecated please call to POST /webhooks instead, this endpoint is only for sake of backward compatibility + */ this._app.post('/api/docs/:docId/tables/:tableId/_subscribe', isOwner, validate(WebhookSubscribe), withDoc(async (activeDoc, req, res) => { - const {fields, url} = await getWebhookSettings(activeDoc, req, null); - if (!fields.eventTypes?.length) { - throw new ApiError(`eventTypes must be a non-empty array`, 400); - } - if (!isUrlAllowed(url)) { - throw new ApiError('Provided url is forbidden', 403); - } - if (!fields.tableRef) { - throw new ApiError(`tableId is required`, 400); - } + const registeredWebhook = await registerWebhook(activeDoc, req, req.body); + res.json(registeredWebhook); + }) + ); - const unsubscribeKey = uuidv4(); - const webhook: WebHookSecret = {unsubscribeKey, url}; - const secretValue = JSON.stringify(webhook); - const webhookId = (await this._dbManager.addSecret(secretValue, activeDoc.docName)).id; - - try { - - const webhookAction: WebhookAction = {type: "webhook", id: webhookId}; - const sandboxRes = await handleSandboxError("_grist_Triggers", [], activeDoc.applyUserActions( - docSessionFromRequest(req), - [['AddRecord', "_grist_Triggers", null, { - enabled: true, - ...fields, - actions: JSON.stringify([webhookAction]) - }]])); - - res.json({ - unsubscribeKey, - triggerId: sandboxRes.retValues[0], - webhookId, - }); - - } catch (err) { - - // remove webhook - await this._dbManager.removeWebhook(webhookId, activeDoc.docName, '', false); - throw err; - } finally { - await activeDoc.sendWebhookNotification(); - } + // Clears all outgoing webhooks in the queue for this document. + this._app.delete('/api/docs/:docId/webhooks/queue', isOwner, + withDoc(async (activeDoc, req, res) => { + await activeDoc.clearWebhookQueue(); + await activeDoc.sendWebhookNotification(); + res.json({success: true}); }) ); // Remove webhook and trigger created above + this._app.delete('/api/docs/:docId/webhooks/:webhookId', isOwner, + withDoc(removeWebhook) + ); + + /** + @deprecated please call to DEL /webhooks instead, this endpoint is only for sake of backward compatibility + */ this._app.post('/api/docs/:docId/tables/:tableId/_unsubscribe', canEdit, - withDoc(async (activeDoc, req, res) => { - const metaTables = await getMetaTables(activeDoc, req); - const tableRef = tableIdToRef(metaTables, req.params.tableId); - const {unsubscribeKey, webhookId} = req.body as WebhookSubscription; - - // Validate combination of triggerId, webhookId, and tableRef. - // This is overly strict, webhookId should be enough, - // but it should be easy to relax that later if we want. - const triggerRowId = activeDoc.triggers.getWebhookTriggerRecord(webhookId, tableRef).id; - - const checkKey = !(await this._isOwner(req)); - // Validate unsubscribeKey before deleting trigger from document - await this._dbManager.removeWebhook(webhookId, activeDoc.docName, unsubscribeKey, checkKey); - activeDoc.triggers.webhookDeleted(webhookId); - - // TODO handle trigger containing other actions when that becomes possible - await handleSandboxError("_grist_Triggers", [], activeDoc.applyUserActions( - docSessionFromRequest(req), - [['RemoveRecord', "_grist_Triggers", triggerRowId]])); - - await activeDoc.sendWebhookNotification(); - - res.json({success: true}); - }) + withDoc(removeWebhook) ); // Update a webhook @@ -702,7 +733,7 @@ export class DocWorkerApi { const docId = activeDoc.docName; const webhookId = req.params.webhookId; - const {fields, trigger, url} = await getWebhookSettings(activeDoc, req, webhookId); + const {fields, trigger, url} = await getWebhookSettings(activeDoc, req, webhookId, req.body); const triggerRowId = activeDoc.triggers.getWebhookTriggerRecord(webhookId).id; @@ -731,14 +762,7 @@ export class DocWorkerApi { }) ); - // Clears all outgoing webhooks in the queue for this document. - this._app.delete('/api/docs/:docId/webhooks/queue', isOwner, - withDoc(async (activeDoc, req, res) => { - await activeDoc.clearWebhookQueue(); - await activeDoc.sendWebhookNotification(); - res.json({success: true}); - }) - ); + // Lists all webhooks and their current status in the document. this._app.get('/api/docs/:docId/webhooks', isOwner, @@ -1109,7 +1133,6 @@ export class DocWorkerApi { return res.status(200).json(docId); })); } - /** * Check for read access to the given document, and return its * canonical docId. Throws error if read access not available. @@ -1127,11 +1150,10 @@ export class DocWorkerApi { private _getDownloadOptions(req: Request, name: string): DownloadOptions { const params = parseExportParameters(req); - const options: DownloadOptions = { + return { ...params, filename: name + (params.tableId === name ? '' : '-' + params.tableId), }; - return options; } private _getActiveDoc(req: RequestWithLogin): Promise { diff --git a/app/server/lib/Triggers.ts b/app/server/lib/Triggers.ts index a27d93cd..915d24c7 100644 --- a/app/server/lib/Triggers.ts +++ b/app/server/lib/Triggers.ts @@ -294,7 +294,7 @@ export class DocTriggers { return result; } - public getWebhookTriggerRecord(webhookId: string, tableRef?: number) { + public getWebhookTriggerRecord(webhookId: string) { const docData = this._activeDoc.docData!; const triggersTable = docData.getMetaTable("_grist_Triggers"); const trigger = triggersTable.getRecords().find(t => { @@ -304,9 +304,6 @@ export class DocTriggers { if (!trigger) { throw new ApiError(`Webhook not found "${webhookId || ''}"`, 404); } - if (tableRef && trigger.tableRef !== tableRef) { - throw new ApiError(`Wrong table`, 400); - } return trigger; } diff --git a/test/server/lib/DocApi.ts b/test/server/lib/DocApi.ts index c28fc080..e488c148 100644 --- a/test/server/lib/DocApi.ts +++ b/test/server/lib/DocApi.ts @@ -2772,93 +2772,245 @@ function testDocApi() { } }); - it("POST /docs/{did}/tables/{tid}/_subscribe validates inputs", async function () { - async function check(requestBody: any, status: number, ...errors: RegExp[]) { + describe('webhooks related endpoints', async function () { + + /* + Regression test for old _subscribe endpoint. /docs/{did}/webhooks should be used instead to subscribe + */ + async function oldSubscribeCheck(requestBody: any, status: number, ...errors: RegExp[]) { + const resp = await axios.post( + `${serverUrl}/api/docs/${docIds.Timesheets}/tables/Table1/_subscribe`, + requestBody, chimpy + ); + assert.equal(resp.status, status); + for (const error of errors) { + assert.match(resp.data.details?.userError || resp.data.error, error); + } + } + + async function postWebhookCheck(requestBody: any, status: number, ...errors: RegExp[]) { const resp = await axios.post( - `${serverUrl}/api/docs/${docIds.Timesheets}/tables/Table1/_subscribe`, + `${serverUrl}/api/docs/${docIds.Timesheets}/webhooks`, requestBody, chimpy ); assert.equal(resp.status, status); for (const error of errors) { assert.match(resp.data.details?.userError || resp.data.error, error); } + return resp.data; + } + + it("POST /docs/{did}/tables/{tid}/_subscribe validates inputs", async function () { + + + await oldSubscribeCheck({}, 400, /eventTypes is missing/); + await oldSubscribeCheck({eventTypes: 0}, 400, /url is missing/, /eventTypes is not an array/); + await oldSubscribeCheck({eventTypes: []}, 400, /url is missing/); + await oldSubscribeCheck({eventTypes: [], url: "https://example.com"}, 400, /eventTypes must be a non-empty array/); + await oldSubscribeCheck({eventTypes: ["foo"], url: "https://example.com"}, 400, /eventTypes\[0] is none of "add", "update"/); + await oldSubscribeCheck({eventTypes: ["add"]}, 400, /url is missing/); + await oldSubscribeCheck({eventTypes: ["add"], url: "https://evil.com"}, 403, /Provided url is forbidden/); + await oldSubscribeCheck({eventTypes: ["add"], url: "http://example.com"}, 403, /Provided url is forbidden/); // not https + await oldSubscribeCheck({eventTypes: ["add"], url: "https://example.com", isReadyColumn: "bar"}, 404, /Column not found "bar"/); + }); + + // in this endpoint webhookID is in body, not in path, so it also should be verified + it("POST /docs/{did}/webhooks validates inputs", async function () { + + await postWebhookCheck({webhooks:[{fields: {tableId: "Table1"}}]}, 400, + /eventTypes is missing/); + await postWebhookCheck({webhooks:[{fields: {tableId: "Table1", eventTypes: 0}}]}, 400, + /url is missing/, /eventTypes is not an array/); + await postWebhookCheck({webhooks:[{fields: {tableId: "Table1", eventTypes: []}}]}, + 400, /url is missing/); + await postWebhookCheck({webhooks:[{fields: {tableId: "Table1", eventTypes: [], + url: "https://example.com"}}]}, + 400, /eventTypes must be a non-empty array/); + await postWebhookCheck({webhooks:[{fields: {tableId: "Table1", eventTypes: ["foo"], + url: "https://example.com"}}]}, + 400, /eventTypes\[0] is none of "add", "update"/); + await postWebhookCheck({webhooks:[{fields: {tableId: "Table1", eventTypes: ["add"]}}]}, + 400, /url is missing/); + await postWebhookCheck({webhooks:[{fields: {tableId: "Table1", eventTypes: ["add"], + url: "https://evil.com"}}]}, + 403, /Provided url is forbidden/); + await postWebhookCheck({webhooks:[{fields: {tableId: "Table1", eventTypes: ["add"], + url: "http://example.com"}}]}, + 403, /Provided url is forbidden/); // not https + await postWebhookCheck({webhooks:[{fields: {tableId: "Table1", eventTypes: ["add"], + url: "https://example.com", isReadyColumn: "bar"}}]}, + 404, /Column not found "bar"/); + await postWebhookCheck({webhooks:[{fields: {eventTypes: ["add"], url: "https://example.com"}}]}, + 400, /tableId is missing/); + await postWebhookCheck({}, 400, /webhooks is missing/); + + + }); + + async function userCheck(user: AxiosRequestConfig, requestBody: any, status: number, responseBody: any) { + const resp = await axios.post( + `${serverUrl}/api/docs/${docIds.Timesheets}/tables/Table1/_unsubscribe`, + requestBody, user + ); + assert.equal(resp.status, status); + if (status !== 200) { + responseBody = {error: responseBody}; + } + assert.deepEqual(resp.data, responseBody); + } + + async function userDeleteCheck(user: AxiosRequestConfig, webhookId: string, status: number, ...errors: RegExp[]) { + const resp = await axios.delete( + `${serverUrl}/api/docs/${docIds.Timesheets}/webhooks/${webhookId}`, + user + ); + assert.equal(resp.status, status); + for (const error of errors) { + assert.match(resp.data.details?.userError || resp.data.error, error); + } } - await check({}, 400, /eventTypes is missing/); - await check({eventTypes: 0}, 400, /url is missing/, /eventTypes is not an array/); - await check({eventTypes: []}, 400, /url is missing/); - await check({eventTypes: [], url: "https://example.com"}, 400, /eventTypes must be a non-empty array/); - await check({eventTypes: ["foo"], url: "https://example.com"}, 400, /eventTypes\[0] is none of "add", "update"/); - await check({eventTypes: ["add"]}, 400, /url is missing/); - await check({eventTypes: ["add"], url: "https://evil.com"}, 403, /Provided url is forbidden/); - await check({eventTypes: ["add"], url: "http://example.com"}, 403, /Provided url is forbidden/); // not https - await check({eventTypes: ["add"], url: "https://example.com", isReadyColumn: "bar"}, 404, /Column not found "bar"/); - }); - - async function userCheck(user: AxiosRequestConfig, requestBody: any, status: number, responseBody: any) { - const resp = await axios.post( - `${serverUrl}/api/docs/${docIds.Timesheets}/tables/Table1/_unsubscribe`, - requestBody, user - ); - assert.equal(resp.status, status); - if (status !== 200) { - responseBody = {error: responseBody}; + interface SubscriptionInfo{ + unsubscribeKey: string; + webhookId: string; + } + async function subscribeWebhook(): Promise { + const subscribeResponse = await axios.post( + `${serverUrl}/api/docs/${docIds.Timesheets}/tables/Table1/_subscribe`, + {eventTypes: ["add"], url: "https://example.com"}, chimpy + ); + assert.equal(subscribeResponse.status, 200); + // Editor needs unsubscribeKey. + const {unsubscribeKey, webhookId} = subscribeResponse.data; + return {unsubscribeKey, webhookId}; } - assert.deepEqual(resp.data, responseBody); - } - it("POST /docs/{did}/tables/{tid}/_unsubscribe validates inputs for owners", async function () { - const subscribeResponse = await axios.post( - `${serverUrl}/api/docs/${docIds.Timesheets}/tables/Table1/_subscribe`, - {eventTypes: ["add"], url: "https://example.com"}, chimpy - ); - assert.equal(subscribeResponse.status, 200); - // Owner doesn't need unsubscribeKey. - const {webhookId} = subscribeResponse.data; + it("POST /docs/{did}/tables/{tid}/_unsubscribe validates inputs for owners", async function () { + // Owner doesn't need unsubscribeKey. + const {webhookId} = await subscribeWebhook(); - const check = userCheck.bind(null, chimpy); + const check = userCheck.bind(null, chimpy); - await check({webhookId: "foo"}, 404, `Webhook not found "foo"`); - await check({}, 404, `Webhook not found ""`); + await check({webhookId: "foo"}, 404, `Webhook not found "foo"`); + await check({}, 404, `Webhook not found ""`); - // Actually unsubscribe - await check({webhookId}, 200, {success: true}); + // Actually unsubscribe + await check({webhookId}, 200, {success: true}); - // Trigger is now deleted! - await check({webhookId}, 404, `Webhook not found "${webhookId}"`); - }); + // Trigger is now deleted! + await check({webhookId}, 404, `Webhook not found "${webhookId}"`); + }); - it("POST /docs/{did}/tables/{tid}/_unsubscribe validates inputs for editors", async function () { - const subscribeResponse = await axios.post( - `${serverUrl}/api/docs/${docIds.Timesheets}/tables/Table1/_subscribe`, - {eventTypes: ["add"], url: "https://example.com"}, chimpy - ); - assert.equal(subscribeResponse.status, 200); - // Editor needs unsubscribeKey. - const {unsubscribeKey, webhookId} = subscribeResponse.data; + it("DELETE /docs/{did}/tables/webhooks validates inputs for owners", async function () { + // Owner doesn't need unsubscribeKey. + const {webhookId} = await subscribeWebhook(); - const delta = { - users: {"kiwi@getgrist.com": 'editors' as string | null} - }; - let accessResp = await axios.patch(`${homeUrl}/api/docs/${docIds.Timesheets}/access`, {delta}, chimpy); - assert.equal(accessResp.status, 200); + const check = userDeleteCheck.bind(null, chimpy); - const check = userCheck.bind(null, kiwi); + await check("foo", 404, /Webhook not found "foo"/); - await check({webhookId: "foo"}, 404, `Webhook not found "foo"`); - await check({webhookId}, 400, 'Bad request: unsubscribeKey required'); - await check({webhookId, unsubscribeKey: "foo"}, 401, 'Wrong unsubscribeKey'); + await check("", 404, /not found/, new RegExp(`/api/docs/${docIds.Timesheets}/webhooks/`)); - // Actually unsubscribe - await check({webhookId, unsubscribeKey}, 200, {success: true}); - // Trigger is now deleted! - await check({webhookId, unsubscribeKey}, 404, `Webhook not found "${webhookId}"`); + // Actually unsubscribe + await check(webhookId, 200); - // Remove editor access - delta.users['kiwi@getgrist.com'] = null; - accessResp = await axios.patch(`${homeUrl}/api/docs/${docIds.Timesheets}/access`, {delta}, chimpy); - assert.equal(accessResp.status, 200); + // Trigger is now deleted! + await check(webhookId, 404, new RegExp(`Webhook not found "${webhookId}"`)); + }); + + async function getRegisteredWebhooks() { + const response = await axios.get( + `${serverUrl}/api/docs/${docIds.Timesheets}/webhooks`, chimpy); + return response.data; + } + + async function deleteWebhookCheck(webhookId: any) { + const response = await axios.delete( + `${serverUrl}/api/docs/${docIds.Timesheets}/webhooks/${webhookId}`, chimpy); + return response.data; + } + + it("POST /docs/{did}/webhooks is adding new webhook to table "+ + "and DELETE /docs/{did}/webhooks/{wid} is removing new webhook from table", async function(){ + const registeredWebhook = await postWebhookCheck({webhooks:[{fields:{tableId: "Table1", eventTypes: ["add"], url: "https://example.com"}}]}, 200); + let webhookList = await getRegisteredWebhooks(); + assert.equal(webhookList.length, 1); + assert.equal(webhookList[0].id, registeredWebhook.webhooks[0].id); + await deleteWebhookCheck(registeredWebhook.webhooks[0].id); + webhookList = await getRegisteredWebhooks(); + assert.equal(webhookList.length, 0); + }); + + it("POST /docs/{did}/webhooks is adding new webhook should be able to add many webhooks at once", async function(){ + const response = await postWebhookCheck( + { + webhooks:[ + {fields:{tableId: "Table1", eventTypes: ["add"], url: "https://example.com"}}, + {fields:{tableId: "Table1", eventTypes: ["add"], url: "https://example.com/2"}}, + {fields:{tableId: "Table1", eventTypes: ["add"], url: "https://example.com/3"}}, + ]}, 200); + assert.equal(response.webhooks.length, 3); + const webhookList = await getRegisteredWebhooks(); + assert.equal(webhookList.length, 3); + }); + + + + it("POST /docs/{did}/tables/{tid}/_unsubscribe validates inputs for editors", async function () { + + const subscribeResponse = await subscribeWebhook(); + + const delta = { + users: {"kiwi@getgrist.com": 'editors' as string | null} + }; + let accessResp = await axios.patch(`${homeUrl}/api/docs/${docIds.Timesheets}/access`, {delta}, chimpy); + assert.equal(accessResp.status, 200); + + const check = userCheck.bind(null, kiwi); + + await check({webhookId: "foo"}, 404, `Webhook not found "foo"`); + //no unsubscribeKey - should be not accepted for role other that owner + await check({webhookId: subscribeResponse.webhookId}, 400, 'Bad request: unsubscribeKey required'); + //wrong unsubscribeKey - it should not be accepted + //subscribeResponse = await subscribeWebhook(); + await check({webhookId: subscribeResponse.webhookId, unsubscribeKey: "foo"}, + 401, 'Wrong unsubscribeKey'); + //subscribeResponse = await subscribeWebhook(); + // Actually unsubscribe with the same unsubscribeKey that was returned by registration + await check({webhookId: subscribeResponse.webhookId, unsubscribeKey:subscribeResponse.unsubscribeKey}, + 200, {success: true}); + + // Trigger is now deleted! + await check({webhookId: subscribeResponse.webhookId, unsubscribeKey:subscribeResponse.unsubscribeKey}, + 404, `Webhook not found "${subscribeResponse.webhookId}"`); + + // Remove editor access + delta.users['kiwi@getgrist.com'] = null; + accessResp = await axios.patch(`${homeUrl}/api/docs/${docIds.Timesheets}/access`, {delta}, chimpy); + assert.equal(accessResp.status, 200); + }); + + it("DELETE /docs/{did}/tables/webhooks should not be allowed for not-owner", async function () { + + const subscribeResponse = await subscribeWebhook(); + const check = userDeleteCheck.bind(null, kiwi); + + const delta = { + users: {"kiwi@getgrist.com": 'editors' as string | null} + }; + let accessResp = await axios.patch(`${homeUrl}/api/docs/${docIds.Timesheets}/access`, {delta}, chimpy); + assert.equal(accessResp.status, 200); + + // Actually unsubscribe with the same unsubscribeKey that was returned by registration - it shouldn't be accepted + await check(subscribeResponse.webhookId, 403, /No owner access/); + + + // Remove editor access + delta.users['kiwi@getgrist.com'] = null; + accessResp = await axios.patch(`${homeUrl}/api/docs/${docIds.Timesheets}/access`, {delta}, chimpy); + assert.equal(accessResp.status, 200); + }); }); describe("Daily API Limit", () => { @@ -2924,7 +3076,8 @@ function testDocApi() { for (let i = 1; i <= 9; i++) { const last = i === 9; m = moment.utc(); // get this before delaying to calculate accurate keys below - const response = await axios.get(`${serverUrl}/api/docs/${docId}/tables/Table1/records`, chimpy); + const response = await axios.get(`${serverUrl}/api/docs/${docId}/tables/Table1/records`, + chimpy); // Allow time for redis to be updated. await delay(100); if (i <= 4) { @@ -3184,7 +3337,8 @@ function testDocApi() { }); app.post('/404', ({body}, res) => { notFoundCalled.emit(body[0].A); - res.sendStatus(404); // Webhooks treats it as an error and will retry. Probably it shouldn't work this way. + // Webhooks treats it as an error and will retry. Probably it shouldn't work this way. + res.sendStatus(404); res.end(); }); app.post('/probe', async ({body}, res) => { @@ -3264,7 +3418,8 @@ function testDocApi() { } }); - it("delivers expected payloads from combinations of changes, with retrying and batching", async function () { + it("delivers expected payloads from combinations of changes, with retrying and batching", + async function () { // Create a test document. const ws1 = (await userApi.getOrgWorkspaces('current'))[0].id; const docId = await userApi.newDoc({name: 'testdoc'}, ws1);